DEV Community

Cover image for Recreated blog site with Fun.Blazor V2
slaveoftime
slaveoftime

Posted on

Recreated blog site with Fun.Blazor V2

Source code is here https://github.com/slaveOftime/Slaveoftime.Site

Original post is here: https://www.slaveoftime.fun/blog/5cfa9459-5736-4f6c-8358-91de492e0d33

There are two projects:

  1. Slaveoftime.Db: is a csharp project with entityframework core which is the easiest thing for me to manage db creation, migration, and CRUD. No need to talk about it in this post. Why csharp? For me it is a right tool for the right thing.
  2. Slaveoftime.Site: boot the server, pulling public GitHub repos markdown and save its metadata to data. Serve the UI with prerender for SEO and interaction for users. I will focus on UI part.

Startup.fs

This is just standard asp.net core minimal API code for register services like and hook up everything.

services.AddDbContext<SlaveoftimeDb>()
services.AddMemoryCache()

services.AddControllersWithViews()
services.AddServerSideBlazor()
services.AddFunBlazorServer()

services.AddTransient<GithubPoolingService>()
services.AddHostedService<PullingBackgroundService>()
services.AddResponseCompression()
services.AddResponseCaching(fun c -> c.MaximumBodySize <- 1024L * 1024L * 5L)
services.AddImageSharp()
Enter fullscreen mode Exit fullscreen mode

And setup the pipeline and run it:

app.UseResponseCaching()
app.UseResponseCompression()
app.UseImageSharp()
app.UseStaticFiles()

app.MapBlazorHub()
app.MapFunBlazor(UI.Index.page)

app.Run()
Enter fullscreen mode Exit fullscreen mode

UI/Index.fs

This is used to serve the index page which will be used for prerendering and setup the blazor server SignalR connection.

type Index() =
    inherit FunBlazorComponent()

    override _.Render() = app

    static member page(ctx: HttpContext) =
        let store = ctx.RequestServices.GetService<IShareStore>()
        store.IsPrerendering.Publish true

        // Just get title and keywords for prerendering and SEO
        let metas =
            html.route [
                routeCif "blog/%O" (getPostDetailMeta ctx.RequestServices)
                routeAny getPostListMeta
            ]

        let root = rootComp<Index> ctx RenderMode.ServerPrerendered

        fragment {
            doctype "html"
            html' {
                class' "bg-slate-100 dark:bg-slate-900 scrollbar"
                head {
                    staticHead
                    metas
                }
                body {
                    root
                    staticScript
                    interopScript
                }
            }
        }
Enter fullscreen mode Exit fullscreen mode

UI/Main.fs

In VSCode with extension: "Highlight HTML/SQL templates in F#" we can get highlight and intellisence for below code

In Fun.Blazor V2, this is very efficient way to build static html fragments, because there is only one call hanpening under the hood.
And even in csharp razor engine, the generated code will call exactly the same method.

So, it is good if we can keep static fragments in this way if we are using VSCode. Or even in Visual Studio, after installing extension "Html for F# (Lit Template)", we can still use it. At least it gets code highlight.

let staticHead =
    Template.html $"""
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <base href="/" />
        <link rel="stylesheet" media="(prefers-color-scheme:light)" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.68/dist/themes/light.css">
        <link rel="stylesheet" media="(prefers-color-scheme:dark)" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.68/dist/themes/dark.css"
            onload="document.documentElement.classList.add('sl-theme-dark');">
        <link href="css/app-generated.css" rel="stylesheet">
        <link href="css/prism-night-owl.css" rel="stylesheet">
    """
Enter fullscreen mode Exit fullscreen mode

Image description

Routing

There are only two routes,

  • one is for post detail, which will use fsharp printable string and extract the Guid out
  • one is for post list

The html.route will just part the route pattern and call the related function to build a fragment which can be composed with other fragments very easily.

let routes = 
    html.route [
        routeCif "/blog/%O" postDetail
        routeAny postList
    ]
Enter fullscreen mode Exit fullscreen mode

In Fun.Blazor V2, all the UI is just a delegate which will be used to render a fragment for attributes or child nodes (element/compoenent).

Computation expression is very cool concept in fsharp, I use it to build all the DSL in Fun.Blazor V2. But there are some tips for using it for better coding experience:

  • Keep single CE block smaller, declare more fragments then compose them together
  • Keep single file smaller

With those, we can have better readability and inline compiling that will reduce a lot of allocations.

let app =
    div {
        navbar
        routes
        footerSection
    }
Enter fullscreen mode Exit fullscreen mode

UI/PostList.fs

I use tailwindcss + shoelacejs for the styling and controls.

With "Tailwind CSS IntelliSense" + below config in VSCode, we can get intellisense for fsharp code.

"tailwindCSS.experimental.classRegex": [
    "class'[\\s]*\"([^\n]*)\"[\\s]*\n"
]
Enter fullscreen mode Exit fullscreen mode

Image description

Inline for performance

Below code is built by the CE DSL:

let private postCard (post: Post) =
    let url = $"blog/{post.Id}?title={post.Title}"
    let title = post.Title
    let viewCount = post.ViewCount
    let author = post.Author
    let description = post.Description
    let createdTime = post.CreatedTime.ToString("yyyy-MM-dd")
    let keywords = keywords post.Keywords

    // To make the whole CE block can be inlined, we need to make sure all its reference is in local scope 
    div {
        class' "p-8 rounded-md bg-gray-600/10 my-5"
        h2 {
            class' "text-purple-500/80 first-letter:text-2xl first-letter:text-yellow-500 underline text-xl font-semibold"
            a {
                href url
                title
                // cannot use post.Title because it will break the fsharp inline and the performance will not be that good
            }
        }
        p {
            class' "text-purple-500/50 text-2xs my-2"
            span { createdTime }
            span {
                class' "pl-3"
                viewCount
            }
            span {
                class' "pl-3 font-semibold"
                author
            }
        }
        keywords
        p {
            class' "text-neutral-400/90 mt-2 text-sm"
            description
        }
    }
Enter fullscreen mode Exit fullscreen mode

Let's see what the compiled code will look like (ILSpy translated to csharp):

internal int Invoke(IComponent comp, RenderTreeBuilder builder, int index)
    {
        builder.OpenElement(index, ((IElementBuilder)Elts.div).Name);
        int num = index + 1;
        builder.AddAttribute(num, "class", "p-8 rounded-md bg-gray-600/10 my-5");
        int num2 = num + 1;
        builder.OpenElement(num2, ((IElementBuilder)Elts.h2).Name);
        int num3 = num2 + 1;
        builder.AddAttribute(num3, "class", "text-purple-500/80 first-letter:text-2xl first-letter:text-yellow-500 underline text-xl font-semibold");
        int num4 = num3 + 1;
        builder.OpenElement(num4, ((IElementBuilder)Elts.a).Name);
        int num5 = num4 + 1;
        builder.AddAttribute(num5, "href", url);
        int num6 = num5 + 1;
        builder.AddContent(num6, title);
        int num7 = num6 + 1;
        builder.CloseElement();
        int num8 = num7;
        builder.CloseElement();
        int num9 = num8;
        builder.OpenElement(num9, ((IElementBuilder)Elts.p).Name);
        num4 = num9 + 1;
        builder.AddAttribute(num4, "class", "text-purple-500/50 text-2xs my-2");
        num7 = num4 + 1;
        builder.OpenElement(num7, ((IElementBuilder)Elts.span).Name);
        int num10 = num7 + 1;
        builder.AddContent(num10, createdTime);
        num6 = num10 + 1;
        builder.CloseElement();
        num5 = num6;
        builder.OpenElement(num5, ((IElementBuilder)Elts.span).Name);
        int num11 = num5 + 1;
        builder.AddAttribute(num11, "class", "pl-3");
        int num12 = num11 + 1;
        builder.AddContent(num12, viewCount);
        num10 = num12 + 1;
        builder.CloseElement();
        num6 = num10;
        builder.OpenElement(num6, ((IElementBuilder)Elts.span).Name);
        num11 = num6 + 1;
        builder.AddAttribute(num11, "class", "pl-3 font-semibold");
        num12 = num11 + 1;
        builder.AddContent(num12, author);
        num10 = num12 + 1;
        builder.CloseElement();
        num3 = num10;
        builder.CloseElement();
        num8 = num3;
        num3 = keywords(comp, builder, num8);
        builder.OpenElement(num3, ((IElementBuilder)Elts.p).Name);
        num7 = num3 + 1;
        builder.AddAttribute(num7, "class", "text-neutral-400/90 mt-2 text-sm");
        num5 = num7 + 1;
        builder.AddContent(num5, description);
        num4 = num5 + 1;
        builder.CloseElement();
        int result = num4;
        builder.CloseElement();
        return result;
    }
Enter fullscreen mode Exit fullscreen mode

So you see everything is inlined together, very less allocation for delegate becuase in fsharp 6 we have InlineIfLambda, so if fsharp can inline our code then the delegate will be removed that will help to reducee a lot of allocation. This the original reason for why I build Fun.Blazor V2, is not just to improve the DSL but also to care about the performance.

I need to quote the tip in the code above again:

To make the whole CE block can be inlined, we need to make sure all its reference is in local scope

Prerender and after render

let postList =
    html.inject (fun (store: IShareStore, globalStore: IGlobalStore, hook: IComponentHook, js: IJSRuntime) ->
        // If it is for prerendering then we will do a sync call so we can get the data and fill the store immedately.
        if store.IsPrerendering.Value then hook.TryLoadPosts(0).Wait()

        // Below callback will happen when the browser rendered the content and SignalR connection is live.
        // Prerender already got the title and keywords information, but after user navigate to other locations those information may be changed so we will need to update again just for better user experience.
        hook.OnFirstAfterRender.Add(fun () ->
            js.changeTitle TitleStr |> ignore
            js.changeKeywords KeywordsStr |> ignore
            hook.TryLoadPosts 0 |> ignore
        )

        // Declare more fragment for better readability
        let cards =
            adaptiview () {
                match! globalStore.UsePosts 0 with
                | DeferredState.Loading -> loader
                | DeferredState.Loaded ps ->
                    for post in ps.Posts do
                        postCard post
                | _ ->
                    html.none
            }


        div {
            class' "sm:w-5/6 md:w-3/4 max-w-[720px] m-auto min-h-[500px]"
            cards
        }
    )
Enter fullscreen mode Exit fullscreen mode

UI/Hooks.fs

In Fun.Blazor every component we build with html.inject will create a new instance of IComponentHook, and IServiceProvider will be attached to it. With that we can access all the resources and build a standalone and reusable functions.

I use GlobalStore because it is registered as singleton, so all the user can share the post list. If post list is same and in prerendering then after blazor server setup the SignalR connection it will try to build the server state and sync back to the browser, so if we use the same data at the begining, then the UI will have no flashing.

type IComponentHook with
    member hook.TryLoadPosts(page) =
        task {
            let sp = hook.ServiceProvider.CreateScope().ServiceProvider
            let logger = sp.GetService<ILoggerFactory>().CreateLogger(nameof hook.TryLoadPosts)
            let store = sp.GetService<IGlobalStore>()

            let postsStore = store.UsePosts(page)

            match postsStore.Value with
            | DeferredState.Loading -> ()
            | DeferredState.Loaded x when x.ExpireDate < DateTime.Now -> ()
            | _ ->
                try
                    let db = sp.GetService<SlaveoftimeDb>()
                    let! posts = db.Posts.OrderByDescending(fun x -> x.CreatedTime).ToArrayAsync() |> Task.map Array.toList
                    postsStore.Publish(DeferredState.Loaded { ExpireDate = DateTime.Now.AddMinutes 5; Posts = posts })
                with
                    | ex -> logger.LogError $"Load posts failed for page {page}: {ex.Message}"
        }
Enter fullscreen mode Exit fullscreen mode

UI/JsInterop.fs

This is for blazor to invoke javascript. We can build a separate js file and add it to the index file too, but here I write in fsharp, because normally those functions are very small and put them in multiple places is hard to manage for me. With the VSCode plugin I mentioned before, we can also get intelicense for it too.

let private highlightCode =
    js """
        window.highlightCode = () => {
            if (!!Prism) {
                Prism.highlightAll();
            } else {
                setTimeout(Prism.highlightAll, 5000)
            }
        }
    """

type IJSRuntime with
    member js.highlightCode() = js.InvokeAsync("highlightCode")
Enter fullscreen mode Exit fullscreen mode

Image description

I know there is a prism-autoloader.min.js which can automatically highlight the code block in the page for you, but it only works for the first load of your document. When user navigates to different locations, the document will not be fully reloaded, because blazor will just patch the diff of the dom. So I need to manully call it after brower rendered.

For example, below in UI/PostDetail:

hook.OnFirstAfterRender.Add(fun () ->
    hook.IncreaseViewCount postId |> ignore
    hook.TryLoadPost postId |> ignore

    hook.AddDisposes [
        // Use InstantCallback so we can trigger a call immediatly, because the postStore may already cached so it will not load again, and lazy callback will be triggered.
        postStore.AddInstantCallback(
            function
            | DeferredState.Loaded data ->
                js.changeTitle data.Post.Title |> ignore
                js.changeKeywords data.Post.Keywords |> ignore
                // If everything is rendered on the browser, we can invoke js to highlight the code
                js.highlightCode () |> ignore
            | _ -> ()
        )
    ]
)
Enter fullscreen mode Exit fullscreen mode

Invoke js is pretty simple concept in blazor server, it just sends a signal by websocket to let the client know which function should be called with arguments.

Finally

The result is pretty good after the quick coding, it may contain bugs but it works pretty well and the performance is pretty good. At least as a blog reader, I think it is good enough for me. The response time is very slow, the rendering is very consistent.

Top comments (0)