Part X, Chapter 2

Part X, Chapter 2: The model

Package: site/src/

Language: TypeScript

Raw source

The model

The website is a Foldkit application, so it begins with the state a reader changes as they move through it. Every live region on the site draws from one flat struct: the page they are on, the chapter of the documentation they opened, the search palette over it, and the document being typed out in the example.

The state

The state is one flat struct, and it opens on where the reader is. The route names which of the site's six pages they are on, so the nav can mark that page current and the pager can name the two either side of it.

Within the documentation the route is not enough, because every chapter answers on the same page. So the chapter field names the woven chapter on show, and the rail, the article and the outline are all drawn from it. One more field says whether the drawer that replaces the rail on a narrow window is open. The active section is the heading the outline is following.

The runtime is the way the hero's install line installs Loom: one of three package managers, or curl, which fetches the install script for the standalone executable. A reader wants the one command that works on their machine rather than four to choose between.

TypeScript
import { Array, Effect, Match, Option, Schema as S, SchemaTransformation } from 'effect'
import { defineMessageUnion } from 'foldkit/message'

export const Route = S.Literals(['home', 'why-loom', 'docs', 'community', 'source', 'license'])
export type Route = typeof Route.Type

export const Runtime = S.Literals(['bun', 'npm', 'pnpm', 'curl'])
export type Runtime = typeof Runtime.Type

Six fields carry the search palette, which opens over whatever page is showing: whether it is open, where the caret sits inside the query, whether a search is on its way back from the server, the results the server found, and, further down beside the older fields, the query itself and the focused result. The palette draws its own block caret rather than the browser's, so the caret's position has to be state instead of something the input keeps to itself.

The server answers a query with groups of results, one group for each page of the site that has any: the docs, the book, and each of the site's own pages. A result is one section that matched. It carries the address of that section, the chapter or page it sits on, and its heading, which together tell a reader where the result is. It also carries a window onto the section's text, cut into the words before the match, the match itself and the words after, so the palette can pick the match out in colour.

TypeScript
export const SearchHit = S.Struct({
  href: S.String,
  place: S.String,
  heading: S.String,
  pre: S.String,
  hit: S.String,
  post: S.String,
})
export type SearchHit = typeof SearchHit.Type

export const SearchGroup = S.Struct({ route: Route, hits: S.Array(SearchHit) })
export type SearchGroup = typeof SearchGroup.Type

The player is the example's own state, held apart because it changes on every frame while the document is being typed. The version is the latest release, the version of Loom the site was built with. The copied field names the copy button flashing its confirmation, or the empty string when none is.

The book is the slice of Loom's own book that the source page shows: the book's whole table of contents and the one chapter on show. Every other page carries none.

TypeScript
export const Model = S.Struct({
  route: Route,
  chapter: S.String,
  drawerOpen: S.Boolean,
  runtime: Runtime,
  searchOpen: S.Boolean,
  caret: S.Number,
  searching: S.Boolean,
  found: S.Array(SearchGroup),
  activeSection: S.String,
  player: Player,
  version: S.String,
  query: S.String,
  focus: S.Number,
  copied: S.String,
  book: S.OptionFromNullOr(Corpus),
})
export type Model = typeof Model.Type

A woven book

The docs and the source page both draw a woven corpus: the shape loom weave gives a book. Its table of contents is a list of parts, each with a number, a name, and its chapters. Each chapter has a number, a title, a slug, the path of its loom without the extension, and the language its frontmatter names, when it names one. Each chapter's page holds its slug, its title, its frontmatter as the loom writes it, when it has one, and its blocks, in the order the loom wrote them.

A block is one of four things. A heading carries its level, its title, and the id a link reaches it by. A run of prose is Markdown. A run of code carries its language, the code itself, its links and its colours. Its links are its ::[…] anchors, each as the name, where it sits in the code, and the chapter and section it reaches. A note is a reader's annotation, in Markdown. The docs hold headings and prose only, and the book holds all four.

The documentation is small enough to ship to the browser whole, so its corpus is part of the client. Loom's own book is not. Woven, it is two megabytes, and a reader reads one chapter of it at a time. So the server holds the book and hands each source page a slice of it, through the model: the whole table of contents, which the menu and the pager need, and the one page on show.

TypeScript
const HeadingBlock = S.Struct({
  type: S.Literal('heading'),
  level: S.Number,
  title: S.String,
  id: S.String,
})
export type HeadingBlock = typeof HeadingBlock.Type

const ProseBlock = S.Struct({ type: S.Literal('prose'), markdown: S.String })

const CodeLink = S.Struct({
  name: S.String,
  offset: S.Number,
  length: S.Number,
  targetSlug: S.String,
  targetId: S.String,
})
export type CodeLink = typeof CodeLink.Type

A run of code's colours are its marks. A mark is one stretch of the code drawn in one colour: where it starts, how long it is, and the kind of token it covers. There are six kinds, the ones a reader tells apart at a glance: keywords, strings, constants such as numbers, comments, the names of functions and the names of types. Code between the marks draws in the page's ink.

The weave hands over no marks, because colouring code is the site's concern rather than Loom's. A block read from the woven book starts with none, and the site's build colours the whole book before the server reads it.

A chapter in colour carries thousands of marks, and every one travels to the browser inside the page's model. So a mark travels as a bare triple, [offset, length, kind], rather than as an object that spells out its three field names each time. The triple makes the largest chapter's model more than a third smaller. The model still reads a mark by its names, and the schema converts between the two forms on the way in and on the way out.

TypeScript
export const TokenKind = S.Literals(['keyword', 'string', 'constant', 'comment', 'function', 'type'])
export type TokenKind = typeof TokenKind.Type

const CodeMark = S.Tuple([S.Number, S.Number, TokenKind]).pipe(
  S.decodeTo(
    S.Struct({ offset: S.Number, length: S.Number, kind: TokenKind }),
    SchemaTransformation.transform({
      decode: ([offset, length, kind]) => ({ offset, length, kind }),
      encode: ({ offset, length, kind }) => [offset, length, kind] as const,
    }),
  ),
)
export type CodeMark = typeof CodeMark.Type

const CodeBlock = S.Struct({
  type: S.Literal('code'),
  language: S.String,
  code: S.String,
  links: S.Array(CodeLink),
  marks: S.Array(CodeMark).pipe(S.withDecodingDefaultKey(Effect.succeed([]))),
})
export type CodeBlock = typeof CodeBlock.Type

const NoteBlock = S.Struct({ type: S.Literal('note'), markdown: S.String })

export const Block = S.Union([HeadingBlock, ProseBlock, CodeBlock, NoteBlock])
export type Block = typeof Block.Type

const Chapter = S.Struct({
  number: S.String,
  title: S.String,
  slug: S.String,
  language: S.optional(S.String),
})
export type Chapter = typeof Chapter.Type

const Part = S.Struct({ number: S.String, name: S.String, chapters: S.Array(Chapter) })
export type Part = typeof Part.Type

const Page = S.Struct({
  slug: S.String,
  title: S.String,
  frontmatter: S.optional(S.String),
  blocks: S.Array(Block),
})
export type Page = typeof Page.Type

export const Corpus = S.Struct({ nav: S.Array(Part), pages: S.Array(Page) })
export type Corpus = typeof Corpus.Type

What the player remembers

The example types a document out one character at a time, so its state is a position in that document rather than a step in a list. The struck count is how many characters have landed. The beat is where the run has reached on its own clock, which is a finer measure than the count — several characters can share one beat, and a pause is beats with no characters in them at all.

Three more fields say what the reader asked for. The playing flag drives the frame loop. The output names which of the two commands' files is open under the document, or none. The full flag says the card has grown to the height of the window, which a reader asks for when they would rather read the document than watch it arrive.

TypeScript
export const PlayerOutput = S.Literals(['tangle', 'weave'])
export type PlayerOutput = typeof PlayerOutput.Type

export const Player = S.Struct({
  struck: S.Number,
  beat: S.Number,
  playing: S.Boolean,
  output: S.Option(PlayerOutput),
  full: S.Boolean,
})
export type Player = typeof Player.Type

Every page has an address

Each route answers at one address. The home page sits at the root, and every other page sits at a path named after its route, so the why-loom route answers at /why-loom. The pathOf function writes that mapping out once, as an exhaustive match, so a route added to the list breaks the build until it is given an address.

TypeScript
export const pathOf = (route: Route): string =>
  Match.value(route).pipe(
    Match.when('home', () => '/'),
    Match.when('docs', () => '/docs'),
    Match.when('why-loom', () => '/why-loom'),
    Match.when('community', () => '/community'),
    Match.when('source', () => '/source'),
    Match.when('license', () => '/license'),
    Match.exhaustive,
  )

The server reads the same mapping the other way. A request arrives as an address and nothing else, and the route decides which page the view draws, so the address has to name a route before anything renders. The routeOf function searches the routes for the one whose path matches. It derives its answer from the pathOf function rather than from a second table that could drift from it. An address the site does not serve names no route, so the answer is an Option rather than a guess.

TypeScript
export const routeOf = (pathname: string): Option.Option<Route> =>
  Array.findFirst(Route.literals, (route) => pathOf(route) === pathname)

A link to another page switches the page on the spot, while the browser fetches the next one, because the client already holds what every page draws. The source page is the exception. It draws Loom's own book, and the book reaches the client only inside a source page the server rendered. Switched to on the spot, the source page would draw its frame around nothing. drawsInPlace names the routes the client can draw on the spot, which is every route but the source page.

TypeScript
export const drawsInPlace = (route: Route): boolean => route !== 'source'

The messages

The reader does a handful of things. They follow a link to another page, and the site records which page they went to. They open the search palette with a slash and close it with escape, type a query into it, and move the caret inside what they have typed. A moment after the last keystroke the server's results arrive, named with the query they answer, and the reader walks them with the arrows and opens one with Enter. Opening the palette puts the keyboard's focus in its field, and the answer that focus has landed is a message of its own.

In the example they press play, pause it partway, send it back to the start, or skip to the end, and while it runs each frame advances the clock. They also ask for the card at the height of the window; the page glides to the card first and the height opens only once it has arrived, which is a second message rather than the same one. Once the document has finished they ask for the file one of the two commands writes. Each frame the document may also need moving under the caret, and the answer to that says it has been done.

In the documentation they open a chapter, jump to one of its sections, and — scrolling by hand instead — let the outline follow them. They type into the palette and move its focus with the arrows. They copy a command, and a moment later the flash resets.

Foldkit declares every message in one place, as a table from each tag to the fields it carries. The defineMessageUnion function turns that table into a schema that checks a message and a constructor for each tag. The module also exports each constructor under its own name, so the views and the update write WentTo({ route }) rather than reaching through the union every time.

TypeScript
export const Message = defineMessageUnion({
  WentTo: { route: Route },
  OpenedChapter: { slug: S.String },
  PickedRuntime: { runtime: Runtime },
  OpenedDrawer: {},
  ClosedDrawer: {},
  OpenedSearch: {},
  FocusedPalette: {},
  ClosedSearch: {},
  MovedCaret: { at: S.Number },
  Found: { query: S.String, groups: S.Array(SearchGroup) },
  OpenedHit: { href: S.String },
  ToggledPlay: {},
  ReplayedDocument: {},
  SkippedToEnd: {},
  ToggledFullHeight: {},
  GrewCard: {},
  Ticked: { delta: S.Number },
  ShowedOutput: { which: PlayerOutput },
  StageMoved: {},
  SelectedSection: { id: S.String },
  SectionScrolled: {},
  SpottedSection: { id: S.String },
  Typed: { query: S.String },
  MovedFocus: { delta: S.Number, count: S.Number },
  Copied: { id: S.String, text: S.String },
  CopyReset: {},
})
export type Message = typeof Message.Type

export const {
  WentTo,
  OpenedChapter,
  PickedRuntime,
  OpenedDrawer,
  ClosedDrawer,
  OpenedSearch,
  FocusedPalette,
  ClosedSearch,
  MovedCaret,
  Found,
  OpenedHit,
  ToggledPlay,
  ReplayedDocument,
  SkippedToEnd,
  ToggledFullHeight,
  GrewCard,
  Ticked,
  ShowedOutput,
  StageMoved,
  SelectedSection,
  SectionScrolled,
  SpottedSection,
  Typed,
  MovedFocus,
  Copied,
  CopyReset,
} = Message

The module

The module puts the player first, because the state is written in terms of it and a module evaluates from the top down. The chapter tells it the other way round. It introduces the state before the player, since that is the easier order to learn them in.

Loom