SourceThe websiteThe documentation shell

Part X, Chapter 12

Part X, Chapter 12: The documentation shell

Package: site/src/

Language: TypeScript

Raw source

The documentation shell

The documentation reads nothing it was given by hand. Its own corpus of prose sits beside this one, loom weave renders that corpus to JSON before the site builds, and this chapter draws the result.

A chapter of the docs is drawn the way the site's other pages are drawn. It opens on a head: the Loom mark beside the chapter's name, its opening paragraph, and any commands it starts with set beside that paragraph. Its first section opens on a woven flower beside its heading, as the landing's sections do. The sections after it open on their heading alone, except that a section named for an editor opens on the editor's emblem. The chapter ends on a pager that names the chapters either side of it. Beside the sections a menu card lists every chapter in the docs, with the sections of the one on show under its name.

The shell is not tied to one corpus. Every function here takes the corpus it should read, and the page that calls it decides which one that is. The documentation passes its own corpus. The source page passes the slice of Loom's book that the server cut for it, and the same head, menu, sections and pager draw the book.

The woven corpus, read

A weave is data, and the model says what shape that data has: a table of contents of parts and chapters, and a page of blocks for each chapter. The documentation's corpus is part of the client. Its JSON is imported whole and read as a Corpus, since the site's own build wrote it and nothing else reads it.

TypeScript
import { Array, Match, Option, Order, pipe } from 'effect'
import { marked } from 'marked'
import type { Html, HtmlBuilder } from 'foldkit/html'
import {
  ClosedDrawer,
  Copied,
  OpenedChapter,
  PickedRuntime,
  SelectedSection,
  type Block,
  type Chapter,
  type CodeBlock,
  type CodeLink,
  type Corpus,
  type HeadingBlock,
  type Message,
  type Model,
  type Page,
  type Part,
  type Runtime,
  type TokenKind,
} from './model'
import {
  broadSeam,
  bunIcon,
  type Carpet,
  carpetMark,
  checkIcon,
  copyIcon,
  editorMark,
  flowerFor,
  loomMark,
  nodeIcon,
  octocatIcon,
} from './components'
import { commandButton, installLine } from './hero'
import { loomCanvas } from './example'
import { starCard } from './page-head'
import { operations } from './operations'
import projectDrawing from './project.svg?raw'
import wovenDocs from './docs.woven.json'

export const docsCorpus = wovenDocs as unknown as Corpus

A few lookups serve the whole chapter. One finds the page a slug names, one finds the part that page belongs to, and one lists every chapter in reading order, parts and all. Each answers with an Option where a slug might name nothing, so a slug naming nothing stays a value rather than a crash.

Where no chapter is named the shell opens the first one in the corpus, which is what a reader arriving at the documentation root expects.

TypeScript
export const firstSlugOf = (corpus: Corpus): string =>
  pipe(
    Option.fromNullishOr(corpus.pages[0]),
    Option.map((page) => page.slug),
    Option.getOrElse(() => ''),
  )

const pageOf = (corpus: Corpus, slug: string): Option.Option<Page> =>
  Array.findFirst(corpus.pages, (page) => page.slug === slug)

const partOf = (corpus: Corpus, slug: string): Option.Option<Part> =>
  Array.findFirst(corpus.nav, (part) =>
    Array.some(part.chapters, (chapter) => chapter.slug === slug),
  )

const readingOrder = (corpus: Corpus): ReadonlyArray<Chapter> =>
  Array.flatMap(corpus.nav, (part) => part.chapters)

The address of a chapter

Every chapter has an address of its own, so a reader can refresh it, open it in a new tab, or send a link to it, and land on that chapter rather than on the first. The address is the corpus's base, /docs for the documentation, followed by the chapter's folder and file names. The numbers that order them on disk are dropped, so the quick start lives at /docs/getting-started/quick-start, and moving a chapter to a new place in the order does not break a link to it.

chapterAt reads an address back into the chapter it names, and answers nothing for an address no chapter has. The server asks it which chapter to render.

TypeScript
const readable = (slug: string): string =>
  pipe(
    slug.split('/'),
    Array.map((segment) => segment.replace(/^\d+-/, '')),
    Array.join('/'),
  )

export const chapterPath = (base: string, slug: string): string => `${base}/${readable(slug)}`

export const chapterAt = (corpus: Corpus, base: string, pathname: string): Option.Option<string> =>
  pipe(
    Array.findFirst(corpus.pages, (page) => chapterPath(base, page.slug) === pathname),
    Option.map((page) => page.slug),
  )

export const docsChapterAt = (pathname: string): Option.Option<string> =>
  chapterAt(docsCorpus, '/docs', pathname)

A corpus too large to send whole reaches its page as a slice. sliceOf keeps the corpus's whole table of contents and drops every page but the one a slug names. An empty slug names the corpus's first page, the one the shell opens at the corpus's base address. The server cuts the source page's slice with it, so the slice holds exactly what the shell draws.

TypeScript
export const sliceOf = (corpus: Corpus, slug: string): Corpus => {
  const shown = slug === '' ? firstSlugOf(corpus) : slug
  return { nav: corpus.nav, pages: Array.filter(corpus.pages, (page) => page.slug === shown) }
}

A chapter, cut into its parts

The weave hands over a chapter as one flat run of blocks. The shell needs it in three parts: the chapter's title, its opening, and its sections. The title is the one first-level heading. The opening is the prose between the title and the first second-level heading. A section is a second-level heading and every block after it, up to the next second-level heading.

A third-level heading does not start a section. It stays inside the section it falls in and draws as a smaller heading there.

TypeScript
type Section = {
  readonly heading: HeadingBlock
  readonly blocks: ReadonlyArray<Block>
}

type Cut = {
  readonly title: Option.Option<HeadingBlock>
  readonly opening: ReadonlyArray<Block>
  readonly sections: ReadonlyArray<Section>
}

const isTitle = (block: Block): block is HeadingBlock =>
  block.type === 'heading' && block.level === 1

const isSectionHead = (block: Block): block is HeadingBlock =>
  block.type === 'heading' && block.level === 2

const grow = (sections: ReadonlyArray<Section>, block: Block): ReadonlyArray<Section> =>
  isSectionHead(block)
    ? Array.append(sections, { heading: block, blocks: [] })
    : pipe(
        Array.last(sections),
        Option.match({
          onNone: () => sections,
          onSome: (last) =>
            Array.append(Array.dropRight(sections, 1), {
              ...last,
              blocks: Array.append(last.blocks, block),
            }),
        }),
      )

const cutOf = (page: Page): Cut => {
  const body = Array.filter(page.blocks, (block) => !isTitle(block))
  const [opening, rest] = Array.span(body, (block) => !isSectionHead(block))
  return {
    title: Array.findFirst(page.blocks, isTitle),
    opening,
    sections: Array.reduce(rest, [] as ReadonlyArray<Section>, grow),
  }
}

Prose and the fences inside it

A run of prose is Markdown, and Markdown becomes HTML here rather than at weave time. The weave should stay a description of the document, not a description of a web page, so anything else reading that JSON gets the Markdown intact.

Not every fence in that Markdown is drawn as Markdown would draw it. A fenced command becomes a button that copies it, and a quoted loom becomes the landing's highlighted card. So piecesOf first cuts a run of prose into pieces: stretches of text, and the fences between them, each fence carrying the word after its opening backticks and the lines it holds. A stretch of text that is only blank space is dropped, so two fences in a row leave nothing between them.

TypeScript
type Piece =
  | { readonly type: 'text'; readonly markdown: string }
  | { readonly type: 'fence'; readonly kind: string; readonly body: string }

const FENCE = /^```([\w-]*)[ \t]*\r?\n([\s\S]*?)^```[ \t]*$/m

const piecesOf = (markdown: string): ReadonlyArray<Piece> => {
  const [first, ...rest] = markdown.split(FENCE)
  return Array.filter(
    [
      { type: 'text', markdown: first ?? '' } as Piece,
      ...Array.flatMap(Array.chunksOf(rest, 3), ([kind, body, after]): ReadonlyArray<Piece> => [
        { type: 'fence', kind: kind ?? '', body: body ?? '' },
        { type: 'text', markdown: after ?? '' },
      ]),
    ],
    (piece) => piece.type === 'fence' || piece.markdown.trim() !== '',
  )
}

const textOf = (
  markdown: string,
  className: string,
  copied: string,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Class(className), h.InnerHTML(inlineCommands(marked.parse(markdown) as string, copied))],
    [],
  )

A command can also sit inside a sentence: "run ". Code that starts with a dollar sign and a space is a command, the dollar sign standing for the prompt the command blocks show. It draws as a small button in the line of text, the command without its dollar sign and a copy glyph after it, and pressing the button copies the command. Once copied, the glyph turns to a tick in green, the way the label on a command block turns to copied. Code without the dollar sign stays code: a file name, a setting.

A command wears a colour, so it stands out from the code around it. Loom's own commands wear the colours the landing gives them, green for loom init, blue for loom tangle, violet for loom weave and red for loom eject, so a reader meets the same command in the same colour wherever it appears. Any other command, a mv or a bun, wears the orange of the prompt.

The prose arrives as HTML that Markdown produced, so the button cannot carry a click handler of its own the way the rest of the page does. It carries the command in a data-copy attribute instead, and the client listens for presses on any such button. The id a press reports is the command behind inline:, so the button knows it was the one pressed. Markdown has already escaped the command for HTML, and the browser reads the attribute back unescaped, so commandOf unescapes it the same way before comparing.

TypeScript
const INLINE_COMMAND = /<code>\$ ([^<]+)<\/code>/g

const COPY_GLYPH =
  '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15V5a2 2 0 0 1 2-2h10"></path></svg>'

const TICK_GLYPH =
  '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>'

const ENTITIES: ReadonlyArray<readonly [string, string]> = [
  ['&lt;', '<'],
  ['&gt;', '>'],
  ['&quot;', '"'],
  ['&#39;', "'"],
  ['&amp;', '&'],
]

const commandOf = (escaped: string): string =>
  Array.reduce(ENTITIES, escaped, (text, [entity, glyph]) => text.split(entity).join(glyph))

const COMMAND_INKS: ReadonlyArray<readonly [string, string]> = [
  ['loom init', 'var(--init)'],
  ['loom tangle', 'var(--tangle)'],
  ['loom weave', 'var(--weave)'],
  ['loom eject', 'var(--eject)'],
]

const inkOf = (command: string): string =>
  pipe(
    Array.findFirst(COMMAND_INKS, ([name]) => command === name || command.startsWith(`${name} `)),
    Option.map(([, ink]) => ink),
    Option.getOrElse(() => 'var(--prompt-paper)'),
  )

const inlineCommands = (html: string, copied: string): string =>
  html.replace(INLINE_COMMAND, (_, escaped: string) => {
    const command = commandOf(escaped)
    const done = copied === `inline:${command}`
    return `<button type="button" class="${done ? 'inline-copy done' : 'inline-copy'}" style="--cmd-ink: ${inkOf(command)}" data-copy="${escaped}" title="Copy the command"><code>${escaped}</code>${done ? TICK_GLYPH : COPY_GLYPH}</button>`
  })

Drawing a fence

The word after a fence's backticks decides how the fence draws.

  • An empty fence tagged install draws the landing's install line in its place: the same tabs, the same commands, the same copy button.
  • A fence tagged run names a file, and draws a picker that runs it: a Bun tab and a Node tab over one command that copies.
  • A bash fence that holds a single command becomes the landing's copy button on its own, so a reader copies a command rather than selecting it.
  • A loom fence becomes a card in the example's colours, so a loom quoted in the docs reads the way the landing shows one.
  • A fence tagged picture names a picture and draws it: one of the docs' own, such as project, or the picture of one of the landing's commands, such as loom init.
  • Every other fence, a bash script of several lines included, becomes a code sheet: the same card as the loom, holding the code as plain text, with the same copy button in its corner.
TypeScript
const fenceOf = (kind: string, body: string, model: Model, h: HtmlBuilder<Message>): Html =>
  Match.value(kind).pipe(
    Match.when('install', () => installLine(model, h)),
    Match.when('picture', () => pictureOf(body.trim(), h)),
    Match.when('run', () => runLine(body.trim(), model, h)),
    Match.when('loom', () => loomCanvas(body, model.copied, h)),
    Match.when('bash', () => commandsOf(body, model, h)),
    Match.orElse(() => codeSheet(body, model.copied, h)),
  )

const commandsOf = (body: string, model: Model, h: HtmlBuilder<Message>): Html => {
  const lines = pipe(
    body.split('\n'),
    Array.map((line) => line.trim()),
    Array.filter((line) => line !== ''),
  )
  return pipe(
    Array.head(lines),
    Option.filter(() => lines.length === 1),
    Option.match({
      onNone: () => codeSheet(body, model.copied, h),
      onSome: (command) =>
        h.div(
          [h.Class('docs-command')],
          [commandButton(`command:${command}`, command, model.copied, h)],
        ),
    }),
  )
}

The code sheet copies exactly the code it shows, without the newline the fence leaves at its end. Its button shows a tick once pressed and turns back to the copy glyph a moment later, as the loom card's does.

TypeScript
const copyButton = (code: string, copied: string, h: HtmlBuilder<Message>): Html => {
  const id = `code:${code}`
  const taken = copied === id
  return h.button(
    [
      h.Class(taken ? 'canvas-copy done' : 'canvas-copy'),
      h.Type('button'),
      h.Title(taken ? 'Copied' : 'Copy the code'),
      h.AriaLabel('Copy the code'),
      h.OnClick(Copied({ id, text: code })),
    ],
    [taken ? checkIcon() : copyIcon()],
  )
}

const codeSheet = (body: string, copied: string, h: HtmlBuilder<Message>): Html => {
  const code = body.replace(/\n$/, '')
  return h.div(
    [h.Class('docs-canvas')],
    [copyButton(code, copied, h), h.pre([h.Class('sheet-code')], [h.code([], [code])])],
  )
}

The docs keep a few pictures of their own, drawn for the example the chapters build: project is the quick start's folder grown into a project, with hello.ts written to its root. A picture fence looks its name up there first, then among the landing's commands. Either way the picture is drawn straight onto the page. A name with no picture, or a misspelt one, draws nothing, so a chapter never shows a broken frame.

TypeScript
type Picture = { readonly name: string; readonly drawing: string }

const docsPictures: ReadonlyArray<Picture> = [{ name: 'project', drawing: projectDrawing }]

const landingPicture = (command: string): Option.Option<string> =>
  pipe(
    Array.findFirst(operations, (operation) => operation.command === command),
    Option.flatMap((operation) => Option.fromNullishOr(operation.drawing)),
  )

const pictureOf = (name: string, h: HtmlBuilder<Message>): Html =>
  pipe(
    Array.findFirst(docsPictures, (picture) => picture.name === name),
    Option.map((picture) => picture.drawing),
    Option.orElse(() => landingPicture(name)),
    Option.match({
      onNone: () => h.div([], []),
      onSome: (drawing) =>
        h.div(
          [h.Class('ops-frame doc-picture')],
          [h.div([h.Class('ops-svg'), h.InnerHTML(drawing)], [])],
        ),
    }),
  )

The run picker follows the install line's choice rather than keeping one of its own. A reader who picked Bun to install Loom runs the file with Bun, and a reader who picked npm or pnpm runs it with Node. Pressing Node picks npm, and pressing Bun picks Bun, so the two pickers on a page always agree.

TypeScript
type Runner = {
  readonly runtime: Runtime
  readonly label: string
  readonly logo: Html
}

const bunRunner: Runner = { runtime: 'bun', label: 'bun', logo: bunIcon() }
const nodeRunner: Runner = { runtime: 'npm', label: 'node', logo: nodeIcon() }

const runnerOf = (runtime: Runtime): Runner =>
  Match.value(runtime).pipe(
    Match.when('bun', () => bunRunner),
    Match.orElse(() => nodeRunner),
  )

const runLine = (file: string, model: Model, h: HtmlBuilder<Message>): Html => {
  const chosen = runnerOf(model.runtime)
  return h.div(
    [h.Class('hero-install')],
    [
      h.div(
        [h.Class('rt-tabs')],
        Array.map([bunRunner, nodeRunner], (runner) =>
          h.button(
            [
              h.Class(runner.label === chosen.label ? 'rt-tab here' : 'rt-tab'),
              h.Type('button'),
              h.OnClick(PickedRuntime({ runtime: runner.runtime })),
            ],
            [runner.logo, runner.label],
          ),
        ),
      ),
      commandButton(`run:${file}`, `${chosen.label} ${file}`, model.copied, h),
    ],
  )
}

A source page shows the code each section writes, so the shell draws a run of code as well. It draws as the code sheet the docs use for a fence, with the same copy button in its corner.

Every sheet names its language in a small tag on its top edge, set into the border like a legend, with the language's icon and name. A chapter can change language from one section to the next, and some languages look alike at a glance: TOML and YAML, or TypeScript and JavaScript. So the tag never leaves the reader to guess, even on code in the chapter's own language.

A loom composes its code by anchor. A ::[…] anchor in one section's code stands for the section it names, so each anchor in the code is a link to that section. A section in the same chapter is reached by its id alone, and a section in another chapter by that chapter's address and the id.

The code also arrives in colour. The site's build colours the book's code, and each run of code arrives with its marks: stretches of the code, each with the kind of token it covers, such as a keyword or a string. Code between the marks draws in the page's ink.

The weave hands over each anchor as a name, its place in the code, and the section it reaches. So a run of code carries two lists of places, its anchors and its marks. spansOf gathers both into one list, in the order they sit in the code. No two spans overlap, because the colouring drops any mark that falls on an anchor.

runsOf then cuts the code at the spans into runs, in order. A run is one of three things: plain code between two spans, an anchor with its link, or a mark with its kind. A run with no text, such as the gap between two anchors that touch, is dropped.

TypeScript
type Run =
  | { readonly type: 'plain'; readonly text: string }
  | { readonly type: 'anchor'; readonly text: string; readonly link: CodeLink }
  | { readonly type: 'mark'; readonly text: string; readonly kind: TokenKind }

type Span = {
  readonly offset: number
  readonly length: number
  readonly runOf: (text: string) => Run
}

const byOffset = Order.mapInput(Order.Number, (span: Span) => span.offset)

const spansOf = (block: CodeBlock): ReadonlyArray<Span> =>
  Array.sort(
    [
      ...Array.map(block.links, (link): Span => ({
        offset: link.offset,
        length: link.length,
        runOf: (text) => ({ type: 'anchor', text, link }),
      })),
      ...Array.map(block.marks, (mark): Span => ({
        offset: mark.offset,
        length: mark.length,
        runOf: (text) => ({ type: 'mark', text, kind: mark.kind }),
      })),
    ],
    byOffset,
  )

const plain = (text: string): Run => ({ type: 'plain', text })

const runsOf = (block: CodeBlock): ReadonlyArray<Run> => {
  const spans = spansOf(block)
  const ends = Array.map(spans, (span) => span.offset + span.length)
  return Array.filter(
    [
      ...Array.flatMap(Array.zip([0, ...ends], spans), ([from, span]) => [
        plain(block.code.slice(from, span.offset)),
        span.runOf(block.code.slice(span.offset, span.offset + span.length)),
      ]),
      plain(block.code.slice(Option.getOrElse(Array.last(ends), () => 0))),
    ],
    (run) => run.text !== '',
  )
}

A plain run draws as text, an anchor as a link to the section it names, and a mark as a span the paper scheme colours by its kind.

A chapter in colour holds thousands of marks. The view runs again every time the model changes, as it does each time the outline follows the reader's scroll, and a node for each mark would have the browser build and compare thousands of nodes on every run. So a sheet's code draws as one string of HTML, the way a run of prose draws from its Markdown, and the view holds a single node for it. The string changes only when the code does, so a redraw compares one string and leaves the sheet alone.

The runHtml function writes a run as HTML. The escaped function makes the code's own text safe to write there, so a < in the code stays a character rather than opening a tag.

TypeScript
const ESCAPES: ReadonlyArray<readonly [RegExp, string]> = [
  [/&/g, '&amp;'],
  [/</g, '&lt;'],
  [/>/g, '&gt;'],
  [/"/g, '&quot;'],
]

const escaped = (text: string): string =>
  Array.reduce(ESCAPES, text, (out, [pattern, entity]) => out.replace(pattern, entity))

const runHtml = (run: Run, base: string, page: Page): string =>
  Match.value(run).pipe(
    Match.when({ type: 'plain' }, (plain) => escaped(plain.text)),
    Match.when(
      { type: 'anchor' },
      (anchor) =>
        `<a class="code-anchor" href="${escaped(anchorHref(base, page.slug, anchor.link))}">${escaped(anchor.text)}</a>`,
    ),
    Match.when(
      { type: 'mark' },
      (mark) => `<span class="tok-${mark.kind}">${escaped(mark.text)}</span>`,
    ),
    Match.exhaustive,
  )

const anchorHref = (base: string, here: string, link: CodeLink): string =>
  link.targetSlug === here
    ? `#${link.targetId}`
    : `${chapterPath(base, link.targetSlug)}#${link.targetId}`

const codeTag = (block: CodeBlock, h: HtmlBuilder<Message>): Html =>
  h.span(
    [h.Class('code-tag')],
    [...Option.toArray(emblemOf(block.language, 14, h)), nameOf(block.language)],
  )

const codeOf = (
  block: CodeBlock,
  base: string,
  page: Page,
  copied: string,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Class('docs-canvas source-code')],
    [
      codeTag(block, h),
      copyButton(block.code, copied, h),
      h.pre(
        [h.Class('sheet-code')],
        [
          h.code(
            [
              h.InnerHTML(
                Array.join(
                  Array.map(runsOf(block), (run) => runHtml(run, base, page)),
                  '',
                ),
              ),
            ],
            [],
          ),
        ],
      ),
    ],
  )

A section, under its heading

A block draws as one of four things, so the match closes on four cases. A heading inside a section draws as a third-level heading carrying its slug. A run of prose draws as its pieces: each stretch of text as Markdown, each fence as fenceOf decides. A run of code draws as codeOf draws it. A note is a reader's annotation kept in the loom, so it draws as prose on a quieter card, and an empty note draws nothing.

TypeScript
const blockOf = (
  block: Block,
  base: string,
  page: Page,
  model: Model,
  h: HtmlBuilder<Message>,
): ReadonlyArray<Html> =>
  Match.value(block).pipe(
    Match.when({ type: 'heading' }, (heading) => [h.h3([h.Id(heading.id)], [heading.title])]),
    Match.when({ type: 'prose' }, (prose) =>
      Array.map(piecesOf(prose.markdown), (piece) =>
        piece.type === 'text'
          ? textOf(piece.markdown, 'docs-prose', model.copied, h)
          : fenceOf(piece.kind, piece.body, model, h),
      ),
    ),
    Match.when({ type: 'code' }, (code) => [codeOf(code, base, page, model.copied, h)]),
    Match.when({ type: 'note' }, (note) =>
      note.markdown.trim() === '' ? [] : [textOf(note.markdown, 'docs-prose doc-note', model.copied, h)],
    ),
    Match.exhaustive,
  )

The first section of a chapter opens on the chapter's flower beside its heading, as a section of the landing opens on its carpet. The sections after it open on their heading alone. One mark to a chapter is enough to say where a reader is, and a mark on every section would turn the docs into a row of stickers.

Editors are the exception. A reader of the guide to editors scans it for one editor, and spots that editor's logo before its name. So a section named for an editor opens on the editor's emblem, and when it is a chapter's first section, the emblem takes the flower's place. The markOf function picks a section's mark: the emblem editorMark finds for the heading, or else the flower the section was given.

TypeScript
const markOf = (flower: Option.Option<Carpet>, section: Section): Option.Option<Html> =>
  Option.orElse(editorMark(section.heading.title), () =>
    Option.map(flower, (carpet) => carpetMark(carpet, '42px', broadSeam)),
  )

Everything a section holds then runs in one column beneath its heading, in the order the chapter wrote it: a paragraph, then the command it introduces, then the next paragraph.

TypeScript
const sectionOf = (
  mark: Option.Option<Html>,
  section: Section,
  base: string,
  page: Page,
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Class('section doc-section')],
    [
      h.div(
        [h.Class('section-head')],
        [
          ...Option.toArray(mark),
          h.div([h.Class('lines')], [h.h2([h.Id(section.heading.id)], [section.heading.title])]),
        ],
      ),
      h.div(
        [h.Class('doc-stack')],
        Array.flatMap(section.blocks, (block) => blockOf(block, base, page, model, h)),
      ),
    ],
  )

The head of a chapter

The head is the page head the site's other pages open on: the Loom mark at four fifths of its size beside the chapter's name. Above the name a line says where the chapter sits: the documentation, the part that holds it, then the chapter. The first two are links: the documentation's to its base address, which opens on its first chapter, and the part's to the first chapter in it.

TypeScript
const crumbLink = (label: string, href: string, slug: string, h: HtmlBuilder<Message>): Html =>
  h.a([h.Class('crumb'), h.Href(href), h.OnClick(OpenedChapter({ slug }))], [label])

const breadcrumb = (
  corpus: Corpus,
  root: string,
  base: string,
  page: Page,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Class('eyebrow doc-crumbs')],
    [
      crumbLink(root, base, firstSlugOf(corpus), h),
      h.span([h.Class('sep'), h.AriaHidden(true)], ['/']),
      ...pipe(
        partOf(corpus, page.slug),
        Option.flatMap((part) =>
          Option.map(Array.head(part.chapters), (first) => [
            crumbLink(part.name, chapterPath(base, first.slug), first.slug, h),
            h.span([h.Class('sep'), h.AriaHidden(true)], ['/']),
          ]),
        ),
        Option.getOrElse((): ReadonlyArray<Html> => []),
      ),
      h.span([h.Class('here')], [page.title]),
    ],
  )

The chapter's opening fills the rest of the head, but only its gist does. The head is what a reader sees on arriving, so it says what the page is for and stops, rather than pushing the first section off the screen. The gist is the first paragraph of the opening's text, and it becomes the lead under the name. The rest of the opening's text leads the article instead, beside the menu and before the first section.

The opening's fences, when it has any, stand in a column of their own beside the lead, level with the line above the name. The quick start opens this way with the two commands a reader runs first, so they are on screen before the first section starts.

The openingPieces function cuts the opening's prose into its text and its fences. The gistAndRest function splits the text at its first blank line. The gist is what comes before that line, and the rest is what comes after it. A piece of the rest that holds only blank lines draws nothing, so the article never opens on an empty paragraph.

TypeScript
const openingPieces = (cut: Cut): ReadonlyArray<Piece> =>
  Array.flatMap(cut.opening, (block) => (block.type === 'prose' ? piecesOf(block.markdown) : []))

const textsOf = (pieces: ReadonlyArray<Piece>): ReadonlyArray<string> =>
  Array.flatMap(pieces, (piece) => (piece.type === 'text' ? [piece.markdown] : []))

const PARAGRAPH_BREAK = /\n[ \t]*\n/

const gistAndRest = (
  texts: ReadonlyArray<string>,
): readonly [Option.Option<string>, ReadonlyArray<string>] =>
  Array.match(texts, {
    onEmpty: () => [Option.none(), []] as const,
    onNonEmpty: ([first, ...others]) => {
      const at = first.search(PARAGRAPH_BREAK)
      const [gist, more] = at < 0 ? [first, ''] : [first.slice(0, at), first.slice(at)]
      return [
        Option.some(gist),
        Array.filter([more, ...others], (text) => text.trim() !== ''),
      ] as const
    },
  })

The head draws the name, the gist under it, and the column of fences.

TypeScript
const chapterHeading = (page: Page, cut: Cut): HeadingBlock =>
  Option.getOrElse(cut.title, (): HeadingBlock => ({
    type: 'heading',
    level: 1,
    title: page.title,
    id: page.slug,
  }))

const docHead = (
  corpus: Corpus,
  root: string,
  base: string,
  page: Page,
  cut: Cut,
  model: Model,
  h: HtmlBuilder<Message>,
): Html => {
  const pieces = openingPieces(cut)
  const [gist] = gistAndRest(textsOf(pieces))
  const heading = chapterHeading(page, cut)
  return h.div(
    [h.Class('section section-hero page-hero doc-hero')],
    [
      h.div(
        [h.Class('doc-hero-grid')],
        [
          h.div(
            [h.Class('doc-hero-lead')],
            [
              breadcrumb(corpus, root, base, page, h),
              h.div(
                [h.Class('page-title')],
                [loomMark('paper', 0.8), h.h1([h.Id(heading.id)], [heading.title])],
              ),
              ...Option.toArray(
                Option.map(gist, (markdown) => textOf(markdown, 'docs-prose page-lead', model.copied, h)),
              ),
            ],
          ),
          h.div(
            [h.Class('hero-cli')],
            Array.flatMap(pieces, (piece) =>
              piece.type === 'fence' ? [fenceOf(piece.kind, piece.body, model, h)] : [],
            ),
          ),
        ],
      ),
    ],
  )
}

The opening of a chapter of the book

A chapter of the book opens on a head like a docs chapter's: the line that says where it sits, and the Loom mark beside a large line. In the docs that line is the chapter's name. In the book it is the chapter's place, Part X, Chapter 12, read from the table of contents, since a book is read by its parts and chapters. A chapter outside the parts, such as a work in progress, has no place, and shows its name there instead.

Under that line, where a docs chapter sets its lead and its commands, a chapter of the book sets its frontmatter as plain text, in the page's own type rather than on a card. Each field takes a line, its key in the violet the loom gives the frontmatter, and the --- fences that frame the fields in the loom are left out. A chapter with no frontmatter shows none.

The GitHub card that Why Loom and Community carry stands at the head's right, beside the crumb line, the place and the frontmatter, its top level with the crumb line. A reader of Loom's source is the reader most likely to star it, so the card waits there on every chapter. So the head is two columns: the chapter's own lines on the left, and the card on the right.

Under the fields a link, Raw source, opens the chapter's loom on GitHub, where the file sits as the author wrote it. The weave names a page by the last two parts of its file's path, and every chapter of the book sits two folders deep in corpus/. So rawSource finds the file from the page's slug alone.

The chapter's name heads the article instead, where its loom writes it: above the prose and code the chapter writes before its first section. A docs chapter opens on a sentence or two, while a chapter of the book can open on several paragraphs and its first code, too much for a head. So the name and that opening lead the article, beside the menu, in the order the loom wrote them. The name is the page's main heading, and the place in the head is only its label.

TypeScript
const placeOf = (corpus: Corpus, page: Page): string =>
  pipe(
    partOf(corpus, page.slug),
    Option.filter((part) => part.number !== ''),
    Option.flatMap((part) =>
      Option.map(
        Array.findFirst(part.chapters, (chapter) => chapter.slug === page.slug),
        (chapter) => `Part ${part.number}, Chapter ${chapter.number}`,
      ),
    ),
    Option.getOrElse(() => page.title),
  )

const REPOSITORY = 'https://github.com/athrio-com/loom/blob/main'

const rawSource = (page: Page): string => `${REPOSITORY}/corpus/${page.slug}.loom`

const FIELD = /^([^:]+:)(.*)$/

const frontmatterField = (line: string, h: HtmlBuilder<Message>): Html =>
  Option.match(Option.fromNullishOr(FIELD.exec(line)), {
    onNone: () => h.p([h.Class('front-field')], [line]),
    onSome: ([, key, value]) =>
      h.p([h.Class('front-field')], [h.span([h.Class('front-key')], [key ?? '']), value ?? '']),
  })

const frontmatterText = (page: Page, text: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('frontmatter')],
    [
      ...pipe(
        text.split('\n'),
        Array.filter((line) => line.trim() !== '' && line.trim() !== '---'),
        Array.map((line) => frontmatterField(line, h)),
      ),
      h.a(
        [
          h.Class('raw-source'),
          h.Href(rawSource(page)),
          h.Target('_blank'),
          h.Rel('noopener noreferrer'),
        ],
        [octocatIcon('14'), 'Raw source'],
      ),
    ],
  )

const sourceHead = (
  corpus: Corpus,
  root: string,
  base: string,
  page: Page,
  cut: Cut,
  model: Model,
  h: HtmlBuilder<Message>,
): Html => {
  return h.div(
    [h.Class('section section-hero page-hero doc-hero source-hero')],
    [
      h.div(
        [h.Class('source-head')],
        [
          h.div(
            [h.Class('source-head-main')],
            [
              breadcrumb(corpus, root, base, page, h),
              h.div(
                [h.Class('page-title')],
                [loomMark('paper', 0.8), h.p([h.Class('page-place')], [placeOf(corpus, page)])],
              ),
              ...Option.toArray(
                Option.map(Option.fromNullishOr(page.frontmatter), (text) =>
                  frontmatterText(page, text, h),
                ),
              ),
            ],
          ),
          h.div([h.Class('page-places')], [starCard()]),
        ],
      ),
    ],
  )
}

A chapter of the book marks its sections by language rather than by flower. The chapter's own opening, its name and the prose under it, wears the emblem of the chapter's language, the Language its frontmatter names, beside the name. A section after it wears an emblem only when its code is written in another language, a {Bash} or {json} section in a chapter of TypeScript, so a change of language is what stands out. A section with no code takes its language from the chapter, and so wears nothing.

Each language's emblem is its icon from Devicon, a free collection of language logos under the MIT licence, kept beside the site as files named for the icon: typescript.svg, json.svg, and so on. Devicon has no icon for two of the book's languages. The tree-sitter queries are written in the query language's S-expressions, so they wear Racket's, a Scheme dialect whose λ marks the same family. TOML wears its logo from Simple Icons, a public-domain collection of the same kind.

Prose is Loom's own language, so it wears Loom's own icon, loom.svg, kept beside the others. The icon is a square cut from the carpets the logo weaves: two rows of the logo's flowers, crossed so each orange flower sits over a blue one, either side of its yellow stitch.

A section's language comes from the weave, which names it the way the tangler does: by the section's specifier, else by the extension of the file it writes, else by the chapter's language. An extension the tangler does not know stands as itself, so .rs reads rs and .sh reads sh. iconOf maps each of these names to its icon, and nameOf to the name a tag shows, a tree-sitter query reading Query; so rs and rust share Rust's, sh and bash share Bash's, a .h header shares C's, and .gitignore and .gitattributes share Git's. A section is compared with its chapter through the same map, so a Rust chapter's .rs file is not mistaken for another language. A name with no icon, such as a LICENSE, wears nothing.

The same emblem marks each chapter in the menu, at sixteen pixels beside its name, the way an editor marks each file in its sidebar by type. So a reader scanning the book's ninety chapters sees at once which are TypeScript, which are packaging in JSON, and which are the grammar's C.

TypeScript
const LANGUAGE_LINE = /^Language:[ \t]*(.+)$/m

const chapterLanguage = (page: Page): Option.Option<string> =>
  pipe(
    Option.fromNullishOr(page.frontmatter),
    Option.flatMap((text) => Option.fromNullishOr(LANGUAGE_LINE.exec(text)?.[1])),
    Option.map((language) => language.trim().toLowerCase()),
  )

const isCode = (block: Block): block is CodeBlock => block.type === 'code'

const sectionLanguage = (section: Section): Option.Option<string> =>
  Option.map(Array.findFirst(section.blocks, isCode), (code) => code.language.toLowerCase())

const ICONS: ReadonlyMap<string, string> = new Map([
  ['typescript', 'typescript'],
  ['javascript', 'javascript'],
  ['css', 'css'],
  ['html', 'html'],
  ['json', 'json'],
  ['yaml', 'yaml'],
  ['toml', 'toml'],
  ['markdown', 'markdown'],
  ['scm', 'scm'],
  ['c', 'c'],
  ['h', 'c'],
  ['rust', 'rust'],
  ['rs', 'rust'],
  ['bash', 'bash'],
  ['sh', 'bash'],
  ['lua', 'lua'],
  ['dockerfile', 'dockerfile'],
  ['gitignore', 'git'],
  ['gitattributes', 'git'],
  ['vscodeignore', 'vscode'],
  ['prose', 'loom'],
])

const NAMES: ReadonlyMap<string, string> = new Map([
  ['typescript', 'TypeScript'],
  ['javascript', 'JavaScript'],
  ['css', 'CSS'],
  ['html', 'HTML'],
  ['json', 'JSON'],
  ['yaml', 'YAML'],
  ['toml', 'TOML'],
  ['markdown', 'Markdown'],
  ['scm', 'Query'],
  ['c', 'C'],
  ['h', 'C'],
  ['rust', 'Rust'],
  ['rs', 'Rust'],
  ['bash', 'Bash'],
  ['sh', 'Bash'],
  ['lua', 'Lua'],
  ['dockerfile', 'Dockerfile'],
  ['prose', 'Prose'],
])

const nameOf = (language: string): string => NAMES.get(language) ?? language

const iconOf = (language: string): Option.Option<string> =>
  Option.fromNullishOr(ICONS.get(language))

const sameLanguage = (one: string, other: string): boolean =>
  one === other || Option.exists(iconOf(one), (icon) => Option.contains(iconOf(other), icon))

const iconMark = (icon: string, size: number, h: HtmlBuilder<Message>): Html =>
  h.img([
    h.Class('lang-icon'),
    h.Src(`/languages/${icon}.svg`),
    h.Alt(''),
    h.Width(String(size)),
    h.Height(String(size)),
  ])

const emblemOf = (language: string, size: number, h: HtmlBuilder<Message>): Option.Option<Html> =>
  Option.map(iconOf(language), (icon) => iconMark(icon, size, h))

const chapterEmblem = (page: Page, h: HtmlBuilder<Message>): Option.Option<Html> =>
  Option.flatMap(chapterLanguage(page), (language) => emblemOf(language, 42, h))

const languageMark =
  (page: Page, h: HtmlBuilder<Message>) =>
  (section: Section): Option.Option<Html> =>
    pipe(
      sectionLanguage(section),
      Option.filter(
        (language) => !Option.exists(chapterLanguage(page), (chapter) => sameLanguage(chapter, language)),
      ),
      Option.flatMap((language) => emblemOf(language, 42, h)),
    )

const chapterIcon = (chapter: Chapter, size: number, h: HtmlBuilder<Message>): Option.Option<Html> =>
  Option.flatMap(Option.fromNullishOr(chapter.language), (language) =>
    emblemOf(language.toLowerCase(), size, h),
  )

const menuEmblem =
  (h: HtmlBuilder<Message>) =>
  (chapter: Chapter): Option.Option<Html> =>
    chapterIcon(chapter, 16, h)

The menu of every chapter

The menu is the corpus's own table of contents, drawn on a pale card like the cards on the site's other pages. Each part is a label in small capitals, and each chapter under it is a link to the chapter's address. The chapter on show sets in dark ink at a heavier weight, and its sections hang beneath it, smaller and indented.

Only the section a reader has reached takes the accent. It turns orange and wears a small square in front, so the menu marks one place, never two. A section counts as reached once its heading has scrolled up to the top of the window. Before any has, the first is marked.

The menu's groups come out of a function of their own, because the drawer draws exactly the same list on a narrow window. A reader who opens the drawer should find the menu they already know, current marker included, not a second navigation that merely resembles it.

TypeScript
const sectionButton = (section: Section, here: boolean, h: HtmlBuilder<Message>): Html =>
  h.button(
    [
      h.Class(here ? 'menu-sub here' : 'menu-sub'),
      h.Type('button'),
      h.OnClick(SelectedSection({ id: section.heading.id })),
    ],
    [section.heading.title],
  )

const reachedOf = (cut: Cut, active: string): string =>
  active !== ''
    ? active
    : pipe(
        Array.head(cut.sections),
        Option.map((section) => section.heading.id),
        Option.getOrElse(() => ''),
      )

const chapterRows = (
  chapter: Chapter,
  page: Page,
  cut: Cut,
  active: string,
  base: string,
  markFor: (chapter: Chapter) => Option.Option<Html>,
  h: HtmlBuilder<Message>,
): ReadonlyArray<Html> => {
  const mark = Option.toArray(markFor(chapter))
  const marked = Array.isReadonlyArrayNonEmpty(mark) ? ' marked' : ''
  return chapter.slug === page.slug
    ? [
        h.a(
          [h.Class(`menu-item here${marked}`), h.Href(chapterPath(base, chapter.slug))],
          [...mark, chapter.title],
        ),
        h.div(
          [h.Class('menu-subs')],
          Array.map(cut.sections, (section) =>
            sectionButton(section, section.heading.id === reachedOf(cut, active), h),
          ),
        ),
      ]
    : [
        h.a(
          [
            h.Class(`menu-item${marked}`),
            h.Href(chapterPath(base, chapter.slug)),
            h.OnClick(OpenedChapter({ slug: chapter.slug })),
          ],
          [...mark, chapter.title],
        ),
      ]
}

const menuGroups = (
  corpus: Corpus,
  page: Page,
  cut: Cut,
  active: string,
  base: string,
  markFor: (chapter: Chapter) => Option.Option<Html>,
  h: HtmlBuilder<Message>,
): ReadonlyArray<Html> =>
  Array.map(corpus.nav, (part) =>
    h.div(
      [h.Class('menu-part')],
      [
        h.span([h.Class('eyebrow')], [part.name]),
        ...Array.flatMap(part.chapters, (chapter) =>
          chapterRows(chapter, page, cut, active, base, markFor, h),
        ),
      ],
    ),
  )

const menu = (
  corpus: Corpus,
  page: Page,
  cut: Cut,
  active: string,
  base: string,
  markFor: (chapter: Chapter) => Option.Option<Html>,
  h: HtmlBuilder<Message>,
): Html =>
  h.nav(
    [h.Class('menu-card thin-scroll'), h.AriaLabel('Chapters')],
    menuGroups(corpus, page, cut, active, base, markFor, h),
  )

The pager between chapters

A chapter ends on the site's own pager, naming the chapters either side of it in reading order. A card is a flower and two lines of type, the direction and the chapter's name, and under the pointer both lines take the deep orange the source page answers to. The flower is the chapter's own, the one its first section wears, so a card shows a reader the mark they will meet on the page it leads to. A chapter whose first section names an editor is the one exception, since there the editor's emblem stands in the flower's place. Each card is a link to the chapter's address.

The book's cards wear the icon of the chapter each one leads to, the same icon the menu sets beside that chapter's name. So a card for a chapter of TypeScript wears TypeScript's icon, and a card for a chapter of prose wears Loom's. A chapter whose language has no icon wears Loom's too. The page hands the pager each card's mark ready drawn, a flower or an icon, rather than a carpet to draw. The first chapter has nothing before it and the last nothing after, so a pager can carry one card rather than two. A next card keeps to the right even on its own, as every next card on the site does.

TypeScript
const chapterFlower = (corpus: Corpus, slug: string): Carpet =>
  flowerFor(
    pipe(
      Array.findFirstIndex(readingOrder(corpus), (chapter) => chapter.slug === slug),
      Option.getOrElse(() => 0),
    ),
  )

const chapterCard = (
  corpus: Corpus,
  base: string,
  chapter: Chapter,
  direction: 'Previous' | 'Next',
  cardMark: (chapter: Chapter) => Html,
  h: HtmlBuilder<Message>,
): Html => {
  const mark = cardMark(chapter)
  const words = h.div(
    [h.Class('pager-words')],
    [h.span([h.Class('eyebrow')], [direction]), h.span([h.Class('pager-title')], [chapter.title])],
  )
  return h.a(
    [
      h.Class(direction === 'Next' ? 'pager-card next' : 'pager-card'),
      h.Href(chapterPath(base, chapter.slug)),
      h.Style({ '--pg-ink': '#C2410C' }),
      h.OnClick(OpenedChapter({ slug: chapter.slug })),
    ],
    direction === 'Next' ? [words, mark] : [mark, words],
  )
}

const chapterPager = (
  corpus: Corpus,
  base: string,
  page: Page,
  cardMark: (chapter: Chapter) => Html,
  h: HtmlBuilder<Message>,
): Html => {
  const order = readingOrder(corpus)
  const at = Array.findFirstIndex(order, (chapter) => chapter.slug === page.slug)
  const step = (by: number): Option.Option<Chapter> =>
    Option.flatMap(at, (index) => Array.get(order, index + by))
  return h.div(
    [h.Class('pager-row doc-pager')],
    [
      ...Option.toArray(
        Option.map(step(-1), (chapter) =>
          chapterCard(corpus, base, chapter, 'Previous', cardMark, h),
        ),
      ),
      ...Option.toArray(
        Option.map(step(1), (chapter) => chapterCard(corpus, base, chapter, 'Next', cardMark, h)),
      ),
    ],
  )
}

The drawer, where the menu will not fit

Below a thousand pixels there is no room for the menu beside the sections, so the same menu arrives in a drawer instead. It slides in from the left over a scrim, and it carries the Loom mark and wordmark at its head so a reader knows what they opened. The nav opens it: on the docs, a narrow window gives the nav a burger button at its left end.

Both the scrim and the panel stay in the document whether the drawer is open or shut, and open is a class rather than a condition. That is what lets them animate: an element that appears only when open has nothing to slide in from.

TypeScript
const closeIcon = (h: HtmlBuilder<Message>): Html =>
  h.svg(
    [
      h.Width('15'),
      h.Height('15'),
      h.ViewBox('0 0 24 24'),
      h.Fill('none'),
      h.Stroke('currentColor'),
      h.StrokeWidth('2'),
      h.StrokeLinecap('round'),
      h.AriaHidden(true),
    ],
    [
      h.line([h.X1('5'), h.Y1('5'), h.X2('19'), h.Y2('19')], []),
      h.line([h.X1('19'), h.Y1('5'), h.X2('5'), h.Y2('19')], []),
    ],
  )

const drawer = (
  corpus: Corpus,
  base: string,
  page: Page,
  cut: Cut,
  model: Model,
  markFor: (chapter: Chapter) => Option.Option<Html>,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Class(model.drawerOpen ? 'docs-drawer open' : 'docs-drawer')],
    [
      h.div(
        [h.Class('drawer-head')],
        [
          loomMark('paper', 0.495),
          h.span([h.Class('drawer-word')], ['Loom']),
          h.button(
            [
              h.Class('drawer-close'),
              h.Type('button'),
              h.AriaLabel('Close menu'),
              h.OnClick(ClosedDrawer()),
            ],
            [closeIcon(h)],
          ),
        ],
      ),
      h.div(
        [h.Class('drawer-body thin-scroll')],
        menuGroups(corpus, page, cut, model.activeSection, base, markFor, h),
      ),
    ],
  )

const scrim = (open: boolean, h: HtmlBuilder<Message>): Html =>
  h.div(
    [
      h.Class(open ? 'docs-scrim open' : 'docs-scrim'),
      h.AriaHidden(true),
      h.OnClick(ClosedDrawer()),
    ],
    [],
  )

The page, assembled

The shell stacks the head over a body of two columns, the menu on the left and the sections on the right, with the pager closing the right-hand column. The pager sits at the foot of that column, so once a reader has scrolled to the end, the pager's lower edge lines up with the menu's.

A slug naming no chapter falls back to the first, so the documentation always has something to show.

The page decides how its chapters open, so the shell asks it for an opening. An opening is two lists: what stands above the menu and the article, across the page, and what leads the article, beside the menu. Both the docs and the book open on their head above. The docs' head draws only the opening's gist and its fences, so the rest of the opening's text, then any code or notes, lead the article. The book's head draws the frontmatter, so its whole opening leads the article. Either way no block of a chapter goes undrawn.

The flowers belong to the docs. A docs chapter's first section and the pager's cards wear the chapter's flower. A chapter of the book marks its sections by language instead, and each card of its pager wears the icon of the chapter it leads to. So an opening also says which mark each section wears, which mark each card of the pager wears, and which mark each chapter wears in the menu.

TypeScript
type Opening = {
  readonly above: ReadonlyArray<Html>
  readonly lead: ReadonlyArray<Html>
  readonly markOf: (section: Section, place: number) => Option.Option<Html>
  readonly cardMark: (chapter: Chapter) => Html
  readonly menuMark: (chapter: Chapter) => Option.Option<Html>
}

type OpeningOf = (
  corpus: Corpus,
  root: string,
  base: string,
  page: Page,
  cut: Cut,
  model: Model,
  h: HtmlBuilder<Message>,
) => Opening

export const docsOpening: OpeningOf = (corpus, root, base, page, cut, model, h) => {
  const [, rest] = gistAndRest(textsOf(openingPieces(cut)))
  const leading = [
    ...Array.map(rest, (markdown) => textOf(markdown, 'docs-prose', model.copied, h)),
    ...pipe(
      cut.opening,
      Array.filter((block) => block.type === 'code' || block.type === 'note'),
      Array.flatMap((block) => blockOf(block, base, page, model, h)),
    ),
  ]
  return {
    cardMark: (chapter) => carpetMark(chapterFlower(corpus, chapter.slug), '42px', broadSeam),
    markOf: (section, place) =>
      markOf(place === 0 ? Option.some(chapterFlower(corpus, page.slug)) : Option.none(), section),
    menuMark: () => Option.none(),
    above: [docHead(corpus, root, base, page, cut, model, h)],
    lead: Array.isReadonlyArrayNonEmpty(leading)
      ? [h.div([h.Class('doc-stack doc-opening')], leading)]
      : [],
  }
}

const sourceOpening: OpeningOf = (corpus, root, base, page, cut, model, h) => {
  const heading = chapterHeading(page, cut)
  return {
    cardMark: (chapter) => Option.getOrElse(chapterIcon(chapter, 42, h), () => iconMark('loom', 42, h)),
    markOf: languageMark(page, h),
    menuMark: menuEmblem(h),
    above: [sourceHead(corpus, root, base, page, cut, model, h)],
    lead: [
      h.div(
        [h.Class('doc-stack doc-opening')],
        [
          h.div(
            [h.Class('section-head chapter-head')],
            [
              ...Option.toArray(
                chapterEmblem(page, h),
              ),
              h.h1([h.Id(heading.id), h.Class('chapter-title')], [heading.title]),
            ],
          ),
          ...Array.flatMap(cut.opening, (block) => blockOf(block, base, page, model, h)),
        ],
      ),
    ],
  }
}

export const wovenPage = (
  corpus: Corpus,
  root: string,
  base: string,
  openingOf: OpeningOf,
  model: Model,
  h: HtmlBuilder<Message>,
): Html => {
  const slug = model.chapter === '' ? firstSlugOf(corpus) : model.chapter
  return pipe(
    pageOf(corpus, slug),
    Option.orElse(() => Option.fromNullishOr(corpus.pages[0])),
    Option.match({
      onNone: () => h.div([h.Class('docs-page')], []),
      onSome: (page) => {
        const cut = cutOf(page)
        const opening = openingOf(corpus, root, base, page, cut, model, h)
        return h.div(
          [h.Class('docs-page')],
          [
            ...opening.above,
            h.div(
              [h.Class('docs-body')],
              [
                menu(corpus, page, cut, model.activeSection, base, opening.menuMark, h),
                h.article(
                  [h.Class('docs-article')],
                  [
                    ...opening.lead,
                    ...Array.map(cut.sections, (section, place) =>
                      sectionOf(
                        opening.markOf(section, place),
                        section,
                        base,
                        page,
                        model,
                        h,
                      ),
                    ),
                    chapterPager(corpus, base, page, opening.cardMark, h),
                  ],
                ),
              ],
            ),
            scrim(model.drawerOpen, h),
            drawer(corpus, base, page, cut, model, opening.menuMark, h),
          ],
        )
      },
    }),
  )
}

export const documentationPage = (model: Model, h: HtmlBuilder<Message>): Html =>
  wovenPage(docsCorpus, 'Docs', '/docs', docsOpening, model, h)

The source page is the same shell over the slice of the book its model carries. A page whose server had no woven book to cut from carries no slice, and draws over an empty corpus, which the shell answers with an empty page.

TypeScript
const emptyCorpus: Corpus = { nav: [], pages: [] }

export const sourcePage = (model: Model, h: HtmlBuilder<Message>): Html =>
  wovenPage(
    Option.getOrElse(model.book, () => emptyCorpus),
    'Source',
    '/source',
    sourceOpening,
    model,
    h,
  )
Loom