Skip to main content
All Examples

Personal Blog

A blog whose prose lives in Markdown files. The @foldkit/markdown Vite plugin compiles each file into a typed document, per-node view overrides style the result, and directive islands place a live Counter Submodel and Note callout between paragraphs.

Markdown
Islands
Submodels
Routing
/
import { clsx } from 'clsx'
import { Effect, Option, Schema as S } from 'effect'
import { Command, Runtime, Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { UrlRequest, load, pushUrl } from 'foldkit/navigation'
import { evo } from 'foldkit/struct'
import { Url, toString as urlToString } from 'foldkit/url'

import * as Markdown from '@foldkit/markdown'

import { Post, about, findPost, posts } from './content'
import { Counter, islandAttributes } from './island'
import { proseView } from './prose'
import * as Route from './route'

export { AppRoute } from './route'

// MODEL

export const Model = S.Struct({
  route: Route.AppRoute,
  counter: Counter.Model,
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  CompletedNavigateInternal: {},
  CompletedLoadExternal: {},
  ClickedLink: { request: UrlRequest },
  ChangedUrl: { url: Url },
  GotCounterMessage: { message: Counter.Message },
})

export type Message = typeof Message.Type

// INIT

export const init: Runtime.RoutingApplicationInit<Model, Message> = (
  url: Url,
) => ({ model: { route: Route.urlToAppRoute(url), counter: Counter.init } })

// COMMAND

const NavigateInternal = Command.define('NavigateInternal', {
  args: { url: S.String },
  messages: [Message.CompletedNavigateInternal],
  execute: ({ url }) =>
    pushUrl(url).pipe(Effect.as(Message.CompletedNavigateInternal())),
})

const LoadExternal = Command.define('LoadExternal', {
  args: { href: S.String },
  messages: [Message.CompletedLoadExternal],
  execute: ({ href }) =>
    load(href).pipe(Effect.as(Message.CompletedLoadExternal())),
})

// UPDATE

type UpdateReturn = Update.Return<Model, Message>

const foldCounter = Update.foldChild({
  update: Counter.update,
  read: (model: Model) => Option.some(model.counter),
  write: (model, nextCounter) => evo(model, { counter: () => nextCounter }),
  toParentMessage: message => Message.GotCounterMessage({ message }),
})

export const update = (model: Model, message: Message) =>
  Message.match<UpdateReturn>(message, {
    ClickedLink: ({ request }) =>
      UrlRequest.match<UpdateReturn>(request, {
        Internal: ({ url }) => ({
          model,
          commands: [NavigateInternal({ url: urlToString(url) })],
        }),
        External: ({ href }) => ({
          model,
          commands: [LoadExternal({ href })],
        }),
      }),

    ChangedUrl: ({ url }) => {
      const nextRoute = Route.urlToAppRoute(url)
      return { model: evo(model, { route: () => nextRoute }) }
    },

    GotCounterMessage: ({ message }) => foldCounter(model, message),
    CompletedNavigateInternal: () => ({ model }),
    CompletedLoadExternal: () => ({ model }),
  })

// VIEW

const islandViews = (model: Model, h: HtmlBuilder<Message>): Markdown.Islands =>
  Markdown.islandsFor(islandAttributes, {
    Counter: ({ label }, _content, occurrenceIndex) =>
      h.div(
        [
          h.Class(
            'my-2 flex flex-col items-center gap-3 rounded-xl border border-stone-200 py-6',
          ),
        ],
        [
          h.span([h.Class('text-sm text-stone-500')], [label ?? 'Counter']),
          h.submodel({
            slotId: `counter-${occurrenceIndex}`,
            model: model.counter,
            view: Counter.view,
            toParentMessage: message => Message.GotCounterMessage({ message }),
          }),
        ],
      ),

    Note: (_attributes, content) =>
      h.aside(
        [
          h.Class(
            'space-y-4 rounded-lg border border-stone-200 bg-stone-50 p-4',
          ),
        ],
        content,
      ),
  })

const navLinkClassName = ({ isActive }: { isActive: boolean }): string =>
  clsx('text-sm transition hover:text-stone-900', {
    'font-semibold text-stone-900': isActive,
    'text-stone-500': !isActive,
  })

const headerView = (
  currentRoute: Route.AppRoute,
  h: HtmlBuilder<Message>,
): Html =>
  h.header(
    [h.Class('border-b border-stone-200')],
    [
      h.div(
        [
          h.Class(
            'mx-auto flex max-w-2xl items-baseline justify-between px-6 py-6',
          ),
        ],
        [
          h.a(
            [
              h.Href(Route.homeRouter()),
              h.Class('font-semibold text-stone-900'),
            ],
            ['Devin Jameson'],
          ),
          h.nav(
            [],
            [
              h.ul(
                [h.Class('flex list-none gap-6')],
                [
                  h.li(
                    [],
                    [
                      h.a(
                        [
                          h.Href(Route.homeRouter()),
                          h.Class(
                            navLinkClassName({
                              isActive: currentRoute._tag === 'Home',
                            }),
                          ),
                        ],
                        ['About'],
                      ),
                    ],
                  ),
                  h.li(
                    [],
                    [
                      h.a(
                        [
                          h.Href(Route.postsRouter()),
                          h.Class(
                            navLinkClassName({
                              isActive: Route.isPostOrPosts(currentRoute),
                            }),
                          ),
                        ],
                        ['Posts'],
                      ),
                    ],
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  )

const homeView = (model: Model, h: HtmlBuilder<Message>): Html =>
  proseView(about, islandViews(model, h))

const postCardView = (post: Post, h: HtmlBuilder<Message>): Html =>
  h.keyed('li')(
    post.slug,
    [],
    [
      h.a(
        [h.Href(Route.postRouter({ slug: post.slug })), h.Class('group block')],
        [
          h.h2(
            [
              h.Class(
                'text-xl font-semibold text-stone-900 group-hover:underline',
              ),
            ],
            [post.title],
          ),
          h.p([h.Class('mt-1 text-sm text-stone-500')], [post.publishedOn]),
          h.p([h.Class('mt-2 leading-relaxed text-stone-700')], [post.summary]),
        ],
      ),
    ],
  )

const postsView = (h: HtmlBuilder<Message>): Html =>
  h.div(
    [],
    [
      h.h1([h.Class('mb-8 text-3xl font-bold text-stone-900')], ['Posts']),
      h.ul(
        [h.Class('list-none space-y-8')],
        posts.map(post => postCardView(post, h)),
      ),
    ],
  )

const missingPostView = (slug: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [],
    [
      h.h1([h.Class('text-3xl font-bold text-stone-900')], ['Post Not Found']),
      h.p(
        [h.Class('mt-4 leading-relaxed text-stone-700')],
        [`There is no post named "${slug}".`],
      ),
    ],
  )

const postView = (
  slug: string,
  model: Model,
  h: HtmlBuilder<Message>,
): Html => {
  const maybePost = findPost(slug)

  const contentKey = Option.match(maybePost, {
    onNone: () => 'Missing',
    onSome: post => `Post-${post.slug}`,
  })

  const content = Option.match(maybePost, {
    onNone: () => missingPostView(slug, h),
    onSome: post =>
      h.article(
        [],
        [
          h.h1([h.Class('text-3xl font-bold text-stone-900')], [post.title]),
          h.p(
            [h.Class('mt-2 mb-8 text-sm text-stone-500')],
            [post.publishedOn],
          ),
          proseView(post.document, islandViews(model, h)),
        ],
      ),
  })

  return h.div(
    [],
    [
      h.a(
        [
          h.Href(Route.postsRouter()),
          h.Class(
            'mb-8 inline-block text-sm text-stone-500 hover:text-stone-900',
          ),
        ],
        ['← All posts'],
      ),
      h.keyed('div')(contentKey, [], [content]),
    ],
  )
}

const notFoundView = (path: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [],
    [
      h.h1([h.Class('text-3xl font-bold text-stone-900')], ['404']),
      h.p(
        [h.Class('mt-4 leading-relaxed text-stone-700')],
        [`The path "${path}" was not found.`],
      ),
      h.a(
        [
          h.Href(Route.homeRouter()),
          h.Class(
            'mt-4 inline-block text-sm text-stone-500 hover:text-stone-900',
          ),
        ],
        ['← Go home'],
      ),
    ],
  )

const routeTitle = (route: Route.AppRoute): string =>
  Route.AppRoute.match(route, {
    Home: () => 'Devin Jameson',
    Posts: () => 'Posts | Devin Jameson',
    Post: ({ slug }) =>
      Option.match(findPost(slug), {
        onNone: () => 'Post Not Found | Devin Jameson',
        onSome: post => `${post.title} | Devin Jameson`,
      }),
    NotFound: () => 'Not Found | Devin Jameson',
  })

export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  const routeContent = Route.AppRoute.match(model.route, {
    Home: () => homeView(model, h),
    Posts: () => postsView(h),
    Post: ({ slug }) => postView(slug, model, h),
    NotFound: ({ path }) => notFoundView(path, h),
  })

  return {
    title: routeTitle(model.route),
    body: h.div(
      [h.Class('min-h-screen bg-white text-stone-800')],
      [
        headerView(model.route, h),
        h.main(
          [h.Class('mx-auto max-w-2xl px-6 py-10')],
          [h.keyed('div')(model.route._tag, [], [routeContent])],
        ),
      ],
    ),
  }
}