SourceLoom builds LoomGenerating the table of contents

Part VIII, Chapter 11

Part VIII, Chapter 11: Generating the table of contents

Package: packages/loom-lang/src/weave/

Language: TypeScript

Raw source

Generating the table of contents

The book opens with a table of contents: a heading for each part, a numbered list of the chapters under it, and a paragraph that introduces the part. A table written by hand drifts from the book it describes — a part with no loom behind it, a chapter listed in the wrong place, a number that skips. But a chapter already declares where it belongs. Its frontmatter names its part and its position — Part VIII: Loom builds Loom, then Chapter 12: Generating the table of contents — so Loom can read that placement back out of the corpus and write the table itself.

Not every chapter has a number. A part declared with a bare Part: Work in progress and chapters declared Chapter: <title> is numberless — a group the book carries but does not sequence, ordered by whoever reads it rather than by position. A numberless chapter may also carry a Status and a Priority, the metadata a roadmap reads. So the same pass that writes the numbered book also gathers the numberless work beside it, and a presentation renders one as a contents list and the other as a board.

loom toc is the command. It reads every chapter's frontmatter, orders the numbered parts and the chapters within them, and rewrites the contents section of the book — numbered parts first, then the numberless ones at the end. The one thing it does not invent is the prose: a part's introductory paragraph is the author's, so the command carries each description across untouched and regenerates only the structure around it. This chapter is the pass that builds the table and the writer that puts it back on disk.

The shape of a table of contents

A TableOfContents is the corpus's own structure: an ordered list of parts, and the loose chapters that belong to no part. A TocPart carries its number — empty when the part is numberless — its name, the author's description, and its chapters in order. A TocChapter carries its own number (again empty when numberless), its title, the optional status and priority a board reads, the optional language its frontmatter names, which a menu can mark the chapter by, and the path of the loom it came from, so a presentation resolves it to a page. The description is prose to preserve, never to derive; everything else is read from frontmatter.

TypeScript
export const TocChapterSchema = Schema.Struct({
  number: Schema.String,
  title: Schema.String,
  status: Schema.optional(Schema.String),
  priority: Schema.optional(Schema.String),
  language: Schema.optional(Schema.String),
  path: Schema.String,
})
export type TocChapter = typeof TocChapterSchema.Type

export const TocPartSchema = Schema.Struct({
  number: Schema.String,
  name: Schema.String,
  description: Schema.String,
  chapters: Schema.Array(TocChapterSchema),
})
export type TocPart = typeof TocPartSchema.Type

export const TableOfContentsSchema = Schema.Struct({
  parts: Schema.Array(TocPartSchema),
  loose: Schema.Array(TocChapterSchema),
})
export type TableOfContents = typeof TableOfContentsSchema.Type

The builder and its spine

TableOfContentsBuilder is the pass, the same shape as WeaveBuilder: one build method that wraps a pure function in an Effect so the compiler composes it like any other projection. buildContents is that function, and the weave calls it directly.

It reads the corpus once for the descriptions the author wrote, gathers every chapter that declares a place, and folds each part into a TocPart — its number and name from the part's declaration, its description from the map, and its chapters from the ones that name it, in order. The parts come out numbered-first, so the book reads Part I to Part XI and then its numberless parts, however the corpus happened to load. A chapter that names no part at all is loose, gathered on its own.

TypeScript
export class TableOfContentsBuilder extends Context.Service<TableOfContentsBuilder>()(
  'TableOfContentsBuilder',
  {
    make: Effect.succeed({
      build: (corpus: LoomCorpusAst): Effect.Effect<TableOfContents> =>
        Effect.sync(() => buildContents(corpus)),
    }),
  },
) {
  static readonly layer = Layer.effect(this, this.make)
}

export const buildContents = (corpus: LoomCorpusAst): TableOfContents => {
  const modules = Array.fromIterable(corpus.modules.values())
  const descriptions = descriptionsFrom(modules)
  const chapters = Array.getSomes(Array.map(modules, chapterDeclOf))
  const parts = pipe(
    dedupeParts(Array.getSomes(Array.map(modules, partDeclOf))),
    Array.sort(byPart),
    Array.map((decl) =>
      TocPartSchema.make({
        number: decl.number,
        name: decl.name,
        description: Option.getOrElse(
          Option.fromNullishOr(descriptions.get(identityOf(decl))),
          () => '',
        ),
        chapters: chaptersUnder(chapters, identityOf(decl)),
      }),
    ),
  )
  const loose = pipe(
    Array.filter(chapters, (chapter) => chapter.partId === undefined),
    Array.map(toTocChapter),
  )
  return TableOfContentsSchema.make({ parts, loose })
}

A chapter's place in the corpus

A part is declared by any loom that names it: a numbered part in an opener's Part R: name line, a numberless part in a bare Part: name. partDeclOf reads that declaration — the number, empty when there is none, and the name. A chapter is any loom with a title, and chapterDeclOf reads it: the part it belongs to, its own number, its title, its status and priority, and its path. A chapter names its part by number when the part is numbered and by name when it is numberless, so partId is one or the other — and absent when the loom declares no part, which makes the chapter loose.

TypeScript
type PartDecl = { readonly number: string; readonly name: string }

type Chapter = {
  readonly partId: string | undefined
  readonly number: string
  readonly title: string
  readonly status: string | undefined
  readonly priority: string | undefined
  readonly language: string | undefined
  readonly path: string
}

const partDeclOf = (module: LoomModule): Option.Option<PartDecl> =>
  pipe(
    Option.fromNullishOr(module.doc.frontmatter),
    Option.flatMap((frontmatter) =>
      Option.map(Option.fromNullishOr(frontmatter.partName), (partName) => ({
        number: frontmatter.part?.value ?? '',
        name: partName.value,
      })),
    ),
  )

const chapterDeclOf = (module: LoomModule): Option.Option<Chapter> =>
  pipe(
    Option.fromNullishOr(module.doc.frontmatter),
    Option.flatMap((frontmatter) =>
      Option.map(Option.fromNullishOr(frontmatter.title), (title) => ({
        partId: frontmatter.part?.value ?? frontmatter.partName?.value,
        number: frontmatter.chapter?.value ?? '',
        title: title.value,
        status: frontmatter.status?.value,
        priority: frontmatter.priority?.value,
        language: frontmatter.language?.value,
        path: module.path,
      })),
    ),
  )

const toTocChapter = (chapter: Chapter): TocChapter =>
  TocChapterSchema.make({
    number: chapter.number,
    title: chapter.title,
    status: chapter.status,
    priority: chapter.priority,
    language: chapter.language,
    path: chapter.path,
  })

Ordering parts and chapters

A part's identity is its number when it has one and its name when it does not, so identityOf reads whichever the part carries and a chapter finds its part by the same key. dedupeParts keeps one declaration per identity, since every loom in a numberless part repeats the part's name.

Numbered parts sort by their Roman numeral, numberless parts fall after them by name, and byPart orders both at once. romanValue reads a Roman numeral as an integer: each letter is worth its unit, less that unit when a larger one follows it, so IX is nine and XII is twelve. byChapter orders a part's chapters the same way — numbered by their Arabic number, numberless after them by title — and chaptersUnder gathers the chapters that name one part and sorts them.

TypeScript
const romanUnits: Record<string, number> = {
  I: 1,
  V: 5,
  X: 10,
  L: 50,
  C: 100,
  D: 500,
  M: 1000,
}

const romanValue = (roman: string): number => {
  const units = Array.map(roman.toUpperCase().split(''), (letter) => romanUnits[letter] ?? 0)
  return pipe(
    units,
    Array.map((unit, index) => (unit < (units[index + 1] ?? 0) ? -unit : unit)),
    Array.reduce(0, (sum, unit) => sum + unit),
  )
}

const identityOf = (part: PartDecl): string => (part.number === '' ? part.name : part.number)

const dedupeParts = (decls: ReadonlyArray<PartDecl>): ReadonlyArray<PartDecl> =>
  Array.reduce(decls, [] as ReadonlyArray<PartDecl>, (kept, decl) =>
    Array.some(kept, (seen) => identityOf(seen) === identityOf(decl)) ? kept : [...kept, decl],
  )

const byPart: Order.Order<PartDecl> = Order.combineAll([
  Order.mapInput(Order.Number, (part: PartDecl) => (part.number === '' ? 1 : 0)),
  Order.mapInput(Order.Number, (part: PartDecl) =>
    part.number === '' ? 0 : romanValue(part.number),
  ),
  Order.mapInput(Order.String, (part: PartDecl) => part.name),
])

const byChapter: Order.Order<Chapter> = Order.combineAll([
  Order.mapInput(Order.Number, (chapter: Chapter) => (chapter.number === '' ? 1 : 0)),
  Order.mapInput(Order.Number, (chapter: Chapter) =>
    chapter.number === '' ? 0 : Number(chapter.number),
  ),
  Order.mapInput(Order.String, (chapter: Chapter) => chapter.title),
])

const chaptersUnder = (
  chapters: ReadonlyArray<Chapter>,
  identity: string,
): ReadonlyArray<TocChapter> =>
  pipe(
    Array.filter(chapters, (chapter) => chapter.partId === identity),
    Array.sort(byChapter),
    Array.map(toTocChapter),
  )

Keeping the author's descriptions

A part's introductory paragraph is prose the command must not lose, so it reads the descriptions out of the current contents before it rewrites them. bookEntries finds the module that carries a {TOC} section and reads its lines in order. lineOf names each line by what it holds — a heading is a part, a numbered item or a bulleted one is a chapter, and anything else is prose. Every prose line between a part heading and its first chapter is that part's description; descriptionsFrom gathers those, keyed by the part's identity, and drops a part whose description is empty. partNumberOf reads that identity from a Part R — name heading, falling back to the whole label for a numberless name heading, so the key matches the one the parts sort by.

TypeScript
const partLabel = /^Part\s+(\S+)\s*[—–-]\s*/
const bulletMarker = /^\s*[-*]\s+\S/

const partNumberOf = (label: string): string =>
  Option.match(Option.fromNullishOr(partLabel.exec(label)), {
    onNone: () => label,
    onSome: (match) => match[1] ?? label,
  })

const bookEntries = (modules: ReadonlyArray<LoomModule>): ReadonlyArray<TocWeft> =>
  pipe(
    Array.findFirst(modules, (module) =>
      Array.some(module.doc.sections, (section) => section.entries !== undefined),
    ),
    Option.map((module) => Array.flatMap(module.doc.sections, (section) => section.entries ?? [])),
    Option.getOrElse((): ReadonlyArray<TocWeft> => []),
  )

type Line =
  | { readonly kind: 'part'; readonly identity: string }
  | { readonly kind: 'chapter' }
  | { readonly kind: 'prose' }

const lineOf = (entry: TocWeft): Line =>
  Option.match(Option.fromNullishOr(entry.part), {
    onSome: (part): Line => ({ kind: 'part', identity: partNumberOf(part.value) }),
    onNone: (): Line =>
      entry.chapter !== undefined || bulletMarker.test(entry.source)
        ? { kind: 'chapter' }
        : { kind: 'prose' },
  })

type Gather = {
  readonly part: Option.Option<string>
  readonly open: boolean
  readonly lines: ReadonlyArray<string>
  readonly found: ReadonlyMap<string, string>
}

const flushed = (state: Gather): ReadonlyMap<string, string> => {
  const description = state.lines.join('\n').trim()
  return Option.match(state.part, {
    onNone: () => state.found,
    onSome: (part) =>
      description.length === 0 ? state.found : new Map(state.found).set(part, description),
  })
}

const descriptionsFrom = (modules: ReadonlyArray<LoomModule>): ReadonlyMap<string, string> => {
  const gathered = Array.reduce(
    bookEntries(modules),
    {
      part: Option.none<string>(),
      open: false,
      lines: [],
      found: new Map<string, string>(),
    } as Gather,
    (state, entry): Gather =>
      Match.value(lineOf(entry)).pipe(
        Match.when({ kind: 'part' }, ({ identity }): Gather => ({
          part: Option.some(identity),
          open: true,
          lines: [],
          found: flushed(state),
        })),
        Match.when({ kind: 'chapter' }, (): Gather => ({
          ...state,
          open: false,
          lines: [],
          found: flushed(state),
        })),
        Match.orElse((): Gather =>
          state.open ? { ...state, lines: [...state.lines, entry.source] } : state,
        ),
      ),
  )
  return flushed(gathered)
}

Rendering the table

renderContents turns the parts back into the lines of a {TOC} section. partBlock writes a part: a ### Part R — name heading for a numbered part or a bare ### name for a numberless one, the author's description when there is one, and the list. chapterLine writes an entry as N. title when it is numbered and - title when it is not. A part with no description drops the blank line and the paragraph, so the heading meets its list directly. The loose chapters are left out — they belong to no part, so no reading order places them, and a presentation surfaces them however it likes.

TypeScript
const chapterLine = (chapter: TocChapter): string =>
  chapter.number === '' ? `- ${chapter.title}` : `${chapter.number}. ${chapter.title}`

const partBlock = (part: TocPart): string => {
  const heading = part.number === '' ? `### ${part.name}` : `### Part ${part.number} — ${part.name}`
  const list = pipe(part.chapters, Array.map(chapterLine), Array.join('\n'))
  const description = part.description.trim()
  return description.length === 0
    ? `${heading}\n\n${list}`
    : `${heading}\n\n${description}\n\n${list}`
}

export const renderContents = (toc: TableOfContents): string =>
  pipe(toc.parts, Array.map(partBlock), Array.join('\n\n'))

Splicing it into the book

rewriteBook puts the rendered table back in place. It finds the book's {TOC} section, keeps everything before it and the heading line itself, and replaces the body with the freshly rendered parts. spliceToc does the cut, reading the section's span from the parse so the heading and its entries are exactly the region it rewrites. A corpus with no contents section is left as it is — there is nothing to regenerate — and the rewrite is idempotent, so running the command twice writes the same file.

TypeScript
const tocSectionOf = (module: LoomModule): Option.Option<LoomSection> =>
  Array.findFirst(module.doc.sections, (section) => section.entries !== undefined)

const spliceToc = (module: LoomModule, section: LoomSection, toc: TableOfContents): string => {
  const heading = module.text
    .slice(section.heading.position.start.offset, section.heading.position.end.offset)
    .trimEnd()
  const before = module.text.slice(0, section.position.start.offset)
  const tail = module.text.slice(section.position.end.offset).replace(/^\n+/, '')
  const rebuilt = `${before}${heading}\n\n${renderContents(toc)}\n`
  return tail.length === 0 ? rebuilt : `${rebuilt}\n${tail}`
}

export const rewriteBook = (corpus: LoomCorpusAst, path: Path, toc: TableOfContents): string =>
  Option.match(Option.fromNullishOr(corpus.modules.get(path)), {
    onNone: () => '',
    onSome: (module) =>
      Option.match(tocSectionOf(module), {
        onNone: () => module.text,
        onSome: (section) => spliceToc(module, section, toc),
      }),
  })

What the builder draws on

The builder reads the Loom AST and writes the contents model, so it draws from both. From effect come the array and option combinators, Match for reading a contents line, Order for the sort, pipe for the spine, and Schema for the model. From the Loom side come the section a {TOC} lives in, the toc weft its lines are, and the corpus with the module a chapter is and the path one is addressed by.

TypeScript
import { Array, Context, Effect, Layer, Match, Option, Order, pipe, Schema } from 'effect'
import type { LoomSection } from '@athrio/loom-ast/LoomAst'
import type { TocWeft } from '@athrio/loom-ast/Weft'
import { type LoomCorpusAst, type LoomModule, type Path } from '@athrio/loom-ast/LoomCorpusAst'

The writer

LoomContents is the command's disk writer, the mirror of LoomWeaver. Where the weaver asks the compiler to project a corpus and writes the data, the writer asks the compiler to regenerate a book's contents and writes the source back over the file it read. The service is thin because the compiler does the work: write runs the compiler's contents verb for the book at entry, writes what it returns, and reports the path so the command can name it.

TypeScript
export class LoomContents extends Context.Service<LoomContents>()('LoomContents', {
  make: Effect.gen(function* () {
    const compiler = yield* LoomCompiler
    const fs = yield* FileSystem.FileSystem

    const write = (entry: Path): Effect.Effect<Path> =>
      compiler
        .contents(entry)
        .pipe(
          Effect.flatMap((text) =>
            fs.writeFileString(entry, text).pipe(Effect.orDie, Effect.as(entry)),
          ),
        )

    return { write }
  }),
}) {
  static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(LoomCompiler.layer))
}

What the writer draws on

The writer needs the composition primitives, the filesystem it writes through, the path a book is addressed by, and the compiler that regenerates the contents.

TypeScript
import { Context, Effect, FileSystem, Layer } from 'effect'
import { type Path } from '@athrio/loom-ast/LoomCorpusAst'
import { LoomCompiler } from '../LoomCompiler'

The writer module

Loom