Design: the writer, and what every page shows#

Note

A design document, written 2026-10-14 as the second of the four steps the maintainer approved: vocabulary (Vocabulary), then this inventory, then the Sphinx writer, then the documentation site. Status: built (2026-10-15). The writer renders every page from the index and the files it names, the build calls it (sitefiles.write_site()), and the generator is gone. The decisions it proposes are marked proposed until the maintainer takes them.

What this is for#

A writer turns the index into pages for one site tool. For the Sphinx writer to replace the generator that writes pages today, two things have to be true: the index must carry every fact a page shows, and every rendering a page shows must be something the writer can make from facts. This page inventories every section of every page the generator writes now, says which it is, and lists what the index must gain. The rule it applies is the one on the vocabulary page: the index records facts about the build, including comparisons it made, and no renderings.

The writer’s input: the index, and the files it names#

Proposed. The vocabulary page settles that the writer reads the write-ups the index points at, because they are source and not facts about the build. The inventory below shows the same is true of two more things a page shows: the first rows of a dataframe (the glimpse) and a chart’s HTML (the iframe). Neither is a fact about the build, and neither belongs in a JSON file; both are files the index names, with their hashes. So the writer’s input is: the index, and the files the index names — write-ups, data files, chart files, notebooks — read at the paths the index gives, and nothing else. A writer never reads a manifest, never walks a folder, and never asks git. The index is the map; the files are the territory it maps.

The inventory#

Each row: a section a page shows today; what it shows; whether that is a fact (belongs in the index), a rendering (the writer makes it from facts), or a file (the writer reads what the index names); where the generator gets it today; and whether the index has it.

Every asset page#

Section

Shows

Is

Today from

In the index

Title

Kind, ID, name

fact

manifest

yes

Freshness line

When the data file was last written (“Data updated 2 days ago”); for a chart, when the chart file was; “Code updated” on the pipeline page

fact: a file’s modification time; a pipeline’s last code change

file system; git

no. Add modified_at to a file location; add the pipeline’s last code change to the Pipeline record

The write-up

The author’s text, from docs_path or inline docs

file

the file, or the manifest

partly: docs has the path or the text. Add the hash (decided)

Provenance panel

What this is, where, hash, size, rows, built by, made from, used by, version

fact

the index

yes (6b)

Manifest tables

Name, description, tags, sources, providers, links, access, pull method, units, frequency… every declared field

fact

manifest

yes: every declared field is on the record

Pipeline manifest table

Name, ID, maintainer, contributors, repository, site URL, OS, code update date, linked dataframes, build command

fact

manifest; git

mostly. Add site_url and readme if absent; the code change date as above; linked dataframes are the assets list

Download links

Parquet, Excel, HTML of the chart

rendering of a location

manifest

yes (location)

Dataframe page#

Section

Shows

Is

Today from

In the index

Glimpse

Row and column counts, dtypes, one sample row

counts and dtypes: fact (row_count, columns); the sample row: file

the parquet file

counts and columns yes; the sample is read from the file the location names

Data available up to (min/max)

For a file: the least and the greatest, over the other columns, of the last date at which that column is non-null (a staleness measure across columns); for a table or query: the first and last date

a comparison the writer makes from the file, for a file; a fact, for a table or query

the parquet file; the engine

the plain first and last date are now a fact, date_range, for both; the per-column measure the file page shows stays a rendering the writer computes from the file it names

Table / query row

Source, identifier or SQL

fact

manifest

yes (location)

Linked charts

Charts whose dataframe is this one

rendering of a comparison

manifest

yes: the charts’ inputs; the panel’s reverse map

Series tables

Documented columns

fact

manifest

yes (Series records)

Chart page#

Section

Shows

Is

Today from

In the index

Front matter

Date, tags, category, for ABlog’s listing

rendering of facts: the chart file’s time, the dataframe’s sources, the chart’s tags

manifest; file system

yes once modified_at exists

The chart

The HTML file, in an iframe; “Sources: …” under it

file; a fact

the chart file; the dataframe’s declared fields

the location names the file; sources is on the dataframe record

Chart specs

Frequency, units, period, release lag…

fact

manifest

yes

Model page#

Section

Shows

Is

Today from

In the index

Scores

The metrics file’s values

fact

the metrics file

yes (metrics)

Versions table

Each version: name, made, commit, labels, each metric

fact

the store

yes (versions)

Trained on

Each input, linked where the site has it

rendering: links from inputs

manifest; site manifest

yes (inputs)

Out of date

The inputs that moved, then and now

fact: a comparison the build made

the store, the lockfile, the files

yes (trained_with, stale_inputs)

Files

A directory model’s files and sizes

fact

the file system

yes (files)

Pipeline page, notebooks, notes#

Page

Shows

Is

Today from

In the index

Pipeline README

The README’s text under a title; the manifest table

file; facts

the README file

docs on the Pipeline record names it; add the hash

Notebook

The executed notebook, rendered by Sphinx

file

the .ipynb file

the location names it

Note

The write-up

file

the file

the location names it

The catalog’s own pages#

Page

Shows

Is

Today from

In the index

Landing

Cards to charts, dataframes, models, pipelines, contributing, diagnostics

rendering (static text)

the theme

nothing needed

Lists (dataframes, models, pipelines)

Every page of a kind

rendering of the records

the manifest

yes

Charts

ABlog’s listing by date and category

rendering

front matter

yes once modified_at exists

Diagnostics

Which assets lack which required fields, against the policy

a comparison: rendering, or a fact the build records

diagnostics.py, against [policy]

the Catalog record must carry the policy; then the writer computes it, or the build records a complete flag per record. Proposed: the writer computes it; the policy is a fact, completeness is a comparison any reader can make

Contributing

Static text

rendering

the theme

nothing needed

What the index must gain#

In the order a writer needs them. Each is a fact, and each is small. (All built 2026-10-14, the same day, as 6c; tests/test_index.py, TestWhatTheWriterNeeds. The diff reads what a file holds, not when it was written, so modified_at alone is not a change; a changed write-up reads “write-up changed”, a date_range whose last date moved “data reaches further” or “less far”, a pipeline’s code_changed_at “source changed”. A review found and fixed: code_changed_at was written from file times by any build_index() while the schema promised git — it is present only in an index a docs build writes; a directory’s modified_at was the directory’s own; a text date_col gave a lexical range — only temporal columns are read, in ISO 8601; the diff said “reaches further” whatever the direction. Known: the time is carried as an instant when freshly worked out, and from the incremental state’s local-time string when remembered, so a state file reused on a machine in another zone, or a stamp in the repeated hour of a DST change, can be off.)

  1. The write-up’s path and hash on every asset record and on the Pipeline record (its README). Decided 2026-10-14. A build then says which text it showed, and a diff says the write-up changed.

  2. modified_at on every file location: when the file was last written, in UTC. The freshness lines and ABlog’s dates come from it. Proposed.

  3. date_range on a dataframe record with a date_col: the first and last date in the data, in ISO 8601; a text column is parsed as dates, and what does not parse is left out. Proposed. The engine answers it for a table or a query; for a file it is one column, read from the parquet statistics. The file page’s per-column “available up to” rows are a different quantity, a comparison across columns, which the writer computes from the file it names.

  4. code_changed_at on the Pipeline record: when the pipeline’s code last changed, as the build works it out (the last commit that touched the source where it is committed and unmodified, else the newest modification time), which the pipeline page shows today. Proposed. The index asks git for nothing itself: the build works the times out once, and an incremental build remembers them for the pipelines it reuses. It is the first field of the Build record the index design leaves for later; the rest of that record (the commit the site was built at, the time, the tool versions) belongs with it, and the envelope’s generator already carries the versions.

  5. The policy on the Catalog record, so the diagnostics page is a comparison a reader can make. Proposed. The schema has a place for it.

  6. site_url and readme on the Pipeline record if either is missing; a check, not a design.

Nothing on this list is a rendering. The sample row, the chart HTML and the write-up text stay in the files the index names.

The writer, in outline#

Proposed.

  • One module per site tool: chartbook/writers/sphinx.py first. Its input is the index and the files it names; its output is a folder of pages and the files Sphinx needs beside them. It takes the theme (pipeline or catalog) and the build options the pages depend on.

  • The link scheme is the writer’s. page_path(record) -> str, pipeline_page_link(record) and link_to_dataframe(record, from_kind), one rule per writer. The provenance panel names assets by ID today because the scheme is not yet a function; it becomes one here, and the panel gains links.

  • Today’s Jinja templates become the Sphinx writer’s templates. They already render from a context; the context becomes the record plus what the writer computed. The generator is replaced section by section, each section’s page compared with the old one on the example sites, and the old code removed when the pages match.

  • Golden tests: one page of each kind, rendered from a fixed index and fixed files, compared whole. The index is small enough to keep as a fixture.

  • MkDocs and Next.js are later writers reading the same index; they are why the index stays free of renderings. MkDocs is built (below); Next.js is not scheduled.

A second writer: MkDocs#

Proposed 2026-10-16. The maintainer asked for an MkDocs writer, behind an extra: pip install chartbook keeps installing Sphinx, and pip install chartbook[mkdocs] adds MkDocs. The writer is the proof that the index is what it claims to be: a second site tool, reading the same index, writing pages of its own, with the build unchanged above the writer.

Which tool a site is built with is a fact about the pipeline’s site, so it is in the manifest: [project] writer = "mkdocs", default "sphinx". A catalog’s choice applies to the catalog’s site; a pipeline’s to its own. (chartbook docs keeps no flag for it: the site is built the same way by everyone who builds it.)

What the writer is. chartbook/writers/mkdocs.py, MkDocsWriter, with the Sphinx writer’s methods and signatures: page_path(record), page(record), pipeline_page_link, link_to_dataframe, catalog_page_paths(), catalog_page(path). Its input rule is the same: the index and the files it names, nothing else. Its output is a MkDocs project: mkdocs.yml with the nav written from the index, and a docs/ folder of Markdown pages in MkDocs’ dialect (Material admonitions, plain tables, relative .md links). The provenance and manifest templates are plain Markdown already and are shared; the two templates that use MyST (chart_entry_top.md, freshness.md) and the theme’s own pages (index.md, charts.md, the catalog’s five) get MkDocs versions in a theme folder of the writer’s own.

What MkDocs does not have. Sphinx’s blog (ablog) carries the notes; in MkDocs the notes are pages listed on the front page, newest first, with their dates. Notebooks are rendered by mkdocs-jupyter, which the extra installs. Charts are HTML files, shown as they are now, in an iframe on the chart page. The diagnostics page, the catalog’s tables and the search are MkDocs’ own.

The build. sitefiles.write_site() picks the writer by the manifest and writes the pages; build_docs runs mkdocs build where it runs sphinx-build, and copies the site out the same way; index.json is written beside the site as now, by code that never knew which tool rendered it. The incremental build is Sphinx’s for now: an MkDocs site is built whole.

Tests. The golden tests of tests/test_writer_sphinx.py have an MkDocs twin: one page of each kind from the same fixed index, compared whole. A build test runs mkdocs build where MkDocs is installed (the test environments install it) and checks the pages are there and the index is beside them. The first page compared by eye is the example pipelines’ sites, built with both writers.

Not in this step. Theme options beyond the logo and the favicon the manifest already has; a Next.js writer; a plugin seam for writers (two implementations are not three).

Found on the way, 2026-10-16: three links the Sphinx writer carries from the generator do not resolve, in either site tool: the dataframe page’s link to itself in the pipeline theme (../dataframes/<slug>/<name>.md from cb/dataframes/<slug>/), the chart page’s link to itself (../../charts/<slug>.<name>.md from cb/charts/) and, in the catalog theme, a dataframe page’s link to its pipeline (../pipelines/<slug>_README.md from cb/dataframes/<slug>/), and in the catalog theme a dataframe page’s link to itself (<slug>/<name>.md from cb/dataframes/<slug>/); Sphinx renders each as an anchor on the same page. MkDocs reports them as warnings. They are the link scheme’s to fix, for both writers at once, with the golden tests updated; not part of this step. Fixed 2026-10-16: link_to_dataframe and pipeline_page_link are relative to the page they are on, the linked-charts list uses the page’s own prefix, and the MkDocs build test asserts that MkDocs reports no unresolved link.

Built 2026-10-16: chartbook.writers.mkdocs.MkDocsWriter, a subclass of the Sphinx writer that swaps the template search path for a theme folder of its own (chartbook/docs_src_mkdocs/<theme>) and renders the front pages and lists as links rather than toctrees; [project] writer = "mkdocs" chooses it, and the build (build_docs._generate_docs_mkdocs) writes the pages under pages/, mkdocs.yml beside them with the navigation from the records, and runs mkdocs build. The site’s own pages (cb/charts.md, the catalog’s) are a question the build asks any writer, own_page_paths() and own_page(); the Sphinx writer answers with the catalog’s pages, the MkDocs writer with the chart listing too. The incremental build is Sphinx’s and says so when asked for an MkDocs site. pip install chartbook[mkdocs] installs MkDocs, the Material theme and mkdocs-jupyter; pip install chartbook still installs Sphinx and nothing of MkDocs. Two things the proposal above says differently from what was built: the notes and the charts are listed by name, not newest first; and the tests are not golden twins but checks that every page of a pipeline site and a catalog site is free of Sphinx syntax, that the chart listing and the navigation name every page, and that MkDocs builds a pipeline site (notes, site pages in the order of index_toc.md, MkDocs’ own search) and a catalog site end to end. A pipeline’s own site pages are copied as they are, listed on the front page and in the navigation, and one that carries a MyST fence is named in a warning, since MkDocs shows it as code. chartbook catalog build reads the catalog’s manifest and builds whole when it names MkDocs; --watch is refused there. The model page’s versions table, which had blank lines between its rows and so rendered as paragraphs in both tools, is a table now.

As built, slice by slice#

2026-10-15, slice 1: the dataframe page. chartbook/writers/sphinx.py, SphinxWriter. Its input is the full index (with resolved_path, since it reads the files the index names) and the files; it takes the theme, the project’s own docs_src for template overrides, and the two build options the page depends on. It renders today’s templates unchanged, from a context made of records: the dataframe’s declared fields and location; a project table and the dataframes list made from the Pipeline record; the write-up read at the path the record gives; the glimpse and the per-column date rows from the data file, or from the engine for a table or a query, opened through chartbook.backends.source_url() from the public url the location now carries; the freshness line from modified_at; the panel from the records. The link scheme is three functions on the writer. A golden test (tests/test_writer_sphinx.py) builds the site with the generator and asserts the writer’s page is the same text, on a file-backed pipeline and on one with a DuckDB table and query. Two facts were added for it: site_url on the Pipeline record (dropped from the published index when it is a local file:// path, as resolved_path is) and url on a table or query location. Not yet: the chart, model, notebook, note and pipeline pages, the catalog’s list pages, and the build calling the writer instead of the generator.

Decided with the slice, proposed: a write-up is text, not a template. The generator had concatenated the write-up into the page’s Jinja source, so {{ name }} or {% if %} inside a write-up was evaluated, and {{'a': 1}} crashed the build; nothing documented it and no example used it. The writer passes the write-up as a value, and the generator’s dataframe page does the same now, so what the author wrote is what the page shows. The date rows of a table or query come from the index’s date_range, which parses a text column as dates, where the generator had shown the engine’s least and greatest text. A declared path is shown normalized (../shared/x as the folder it names), as the index and the resolver have it. An adversarial review of the slice found the writer resolving a relative DuckDB url against the process’s working directory instead of the pipeline’s folder, which the golden test’s chdir had hidden (fixed; the catalog theme, built from the catalog’s root, is tested now); a published index gave a bare KeyError (a message says the writer needs the full index); docs_src_dir unset dropped the override paths (it defaults as the generator’s). Known: the pipeline table’s code-change time is converted back from UTC to local for the page and can land on the other side of a repeated DST hour.

2026-10-15, slice 2: the chart page. SphinxWriter.chart_page(). The chart’s record gives its declared fields and its file’s path and modified_at; its inputs name the dataframe it was drawn from, whose record gives the dataframe manifest table, the link and “Data updated”; the pipeline’s record gives the pipeline table and the fallback date for the listing’s front matter; the write-up is read at the path the record gives. The chart’s HTML is not read: the page embeds it by its path under the site, and the build copies it there (that copy is the build’s, not the writer’s, as the inventory said). The link scheme gained the chart page’s own forms, which the generator had: a chart links to the pipeline’s page from one folder less deep, and to its dataframe through ../dataframes/ in both themes. Decided with it, proposed: a chart’s write-up is text too, as a dataframe’s became; and “Data updated” on a chart of a table or query is the engine file’s time — the generator had taken the pipeline folder’s own modification time for it, since a table has no path, and now says what the writer says. The writer does not refuse an index that a build could not have made (a chart whose dataframe record is missing, or whose file has no modified_at): it writes the page with no data line and no dataframe table, and takes the listing’s date from the pipeline’s code-change time, as the generator would; the manifest loader and a strict build refuse both cases before an index exists. The golden test covers the chart pages of the file-backed pipeline, of a chart drawn from a DuckDB table (with a series list, which the index had been dropping from charts: series was treated as structural for every kind, where only a dataframe’s become Series records — an adversarial review of the slice found it), and of the catalog. The same review found the generator’s provenance-panel cache keyed by the id() of the records dict, which a later build in the same process could reuse after the first was freed; it holds the records themselves now.

2026-10-15, slice 3: the model page. SphinxWriter.model_page(). The whole page is in the Model record, as the inventory said it should be: scores from metrics, the versions table from versions, the files table from files, “Trained On” from inputs (a link when the input’s record is in this index, which is when its pipeline is part of the site; a name and a note otherwise), and the out-of-date notice from trained_with, inputs_now and stale_inputs. The third of those is new with this slice, and an adversarial review is why: the notice shows each moved input’s hash then and now, and the first version of the writer took “now” from the inputs’ own records (a local dataframe’s content_hash, another pipeline’s pin in pinned_inputs), matching the lockfile-style keys of trained_with (d, rates:treasury) to the build IDs of inputs by name. The review built the cases where that guess is wrong — the declaration reordered after the snapshot, two upstream pipelines with a dataframe of the same name, an input dropped from trained_on but still in the version’s record — and showed the page naming a wrong hash or raising. The index did not state the mapping, so the writer could not derive it; now the record states the three facts of the comparison, keyed alike, and a reader needs nothing else. The model’s bytes are never read. The page’s skeleton is still the generator’s two template strings, imported, until the generator goes and they move here. Decided with it, proposed: a model’s write-up is text too. Aligned on both sides: a directory model’s files are listed by path as text, as the index sorts them (the generator had sorted Path objects, which order a-b/x and a/x the other way); a directory’s modified_at in the index counts the files its hash counts, and is absent when there are none — an empty folder, or one holding only .DS_Store, had been given the folder’s own time and so a “Model updated” line the generator did not show; the generator’s “Data updated” for a dataframe that is a directory now says the same (it had shown the folder’s own modification time, which moves only when an entry is added or removed, so a folder of parts written days ago could be dated today); and training data that a pipeline in the site does not declare is named by its build ID on both sides, with the note “which that pipeline does not declare” (the generator had named it as stated and called it “not part of this site”, which it was). The golden test covers a file model with scores, a bare directory model, one with nothing counted in it, a model with two versions that is out of date on a local and on another pipeline’s dataframe, the same model in a catalog that also holds the other pipeline (where “Trained On” links to it), and one trained on a name its upstream does not declare.

2026-10-15, slice 4: the pipeline page. SphinxWriter.pipeline_page(). In the pipeline theme it is the site’s front page: the theme’s index.md (or the project’s override) rendered with the README’s text from the file the Pipeline record’s docs names, the tables of contents made from the records — notebooks and notes of this pipeline, dataframes and models of the site, each by name — and “Code updated” from code_changed_at. In a catalog it is the pipeline’s README page, cb/pipelines/<slug>_README.md, with the repository and site named above the text from the record’s repo_url and site_url. One fact was added for it: a pipeline’s own site pages. The generator found them by walking [project] site_dir (or docs_src/site/), which the writer may not do; the Pipeline record now carries site_dir (path, and resolved_path until published) and site_pages, every Markdown file under it by relative path, sorted. The build copies them beside the generated pages, as before; the writer lists them in the front page’s table of contents and reads index_toc.md, if it is one of them, to merge in. Decided with it, proposed: notebooks and notes are listed by name, as the index has them, where the generator had kept the manifest’s order; the generator does the same now. Notebooks and notes themselves are files the build copies, not pages the writer renders (the inventory above). The golden test covers a pipeline site with and without its own site pages, one with notes, and a catalog’s README pages. An adversarial review found the generator ordering the site pages as Path objects (guide/intro.md before guide.md) where the index orders them as text, and listing a directory named *.md; the generator now does as the index does, since the index’s order is the one any reader of the JSON reproduces. It also found the writer splitting the README with splitlines(), which breaks on a form feed where the generator’s readlines() does not; both read lines now. A diff says “site pages changed” when one appears or goes. Known: the site pages are listed without hashes, so a diff cannot say which page changed. Proposed: give each the same path + content_hash shape as a write-up when a reader needs it.

2026-10-15, slice 5: the catalog’s own pages. SphinxWriter.catalog_page(path) for index.md, cb/dataframes.md, cb/models.md (when some pipeline declares a model), cb/pipelines.md and cb/diagnostics.md; catalog_page_paths() says which. The landing page and the lists are renderings of the records, as the inventory said; pipelines.md reuses the pipeline manifest table made from each Pipeline record. The diagnostics page is the comparison the inventory proposed the writer should make: each record’s declared fields against the required lists of the policy the Catalog record carries (always resolved, defaults filled). A record carries the fields its entry declared, and its structure stands in for the rest — a location is the path, a docs is the write-up, a chart’s inputs is its dataframe, a model’s inputs are its trained_on — so the rows come out as the generator’s build_diagnostics() does from the manifests, which the golden test holds them to on a catalog with a [policy], a model and assets missing fields. The generator had read the rows back from the CSV the build writes; the build still writes that CSV, and the page links to it. charts.md and contributing.md are static files of the theme that the build copies, not pages the writer renders. One fact was added: the Catalog record’s location, so the writer looks in the catalog’s folder for template overrides as it does in a pipeline’s. Aligned on both sides: the lists and the diagnostics rows are by name (pipelines, then assets), as the index orders records, where the generator had kept the manifests’ order; and an empty “Missing Fields” cell is empty on the page, where the generator’s round trip through pandas had printed nan. An adversarial review found the first version of the comparison treating as missing what a record states in another form: a policy may require readme, site_url or copyright of a pipeline, docs_path, linked_charts, series or source of a dataframe, metrics_path of a model, and the writer had no such keys to look at. Each is put back under the entry’s name now, from the record’s docs, location, the records that point at it (charts, declared series) or its own fields; the Pipeline record carries copyright and the Model record metrics_path for it, as declared. A blank inline write-up (docs = " ") is missing on both sides, since the record’s docs.text is what is checked, not whether a docs exists. The review also found a decision nobody had made: a pipeline that --no-strict leaves out of the build was still listed by the generator’s catalog pages (pages that did not exist) and counted in the diagnostics, while the index, and so the writer, left it out. Decided with the slice, proposed: a skipped pipeline is not part of the site, so the lists and the diagnostics leave it out; the generator does so now. Two more: the loader now refuses a dataframe, chart, notebook or note key that cannot be part of an address, as it already did a model’s, since the index left such an asset out with a warning while the generator rendered its page — the manifest and the index must describe the same set; and a catalog’s location is dropped entirely from the published index, not left as a bare kind, which a diff against an older index had called “moved”. With this, every page the generator renders has a writer; what remains is for the build to call the writer and copy the files, and for the generator to go.

2026-10-15, the build calls the writer; the generator goes. chartbook/sitefiles.py, write_site(), replaces the generator’s build_all(): it builds the index first (or takes the one the incremental build made), then writes a page for every Dataframe, Chart, Model and Pipeline record of a pipeline the build generates — one the build skips, or an incremental build keeps, has none written, except a pipeline site’s front page, which is the site’s — and the catalog’s own pages; then it copies the files the index names beside them, from the records rather than from the manifests: a chart’s HTML to the download folder and to _static, a notebook to cb/notebooks/, a note to cb/, a dataframe’s one file to the download folder when downloads are enabled; gives a copied notebook a title cell if it lacks one, from its record’s name; copies the theme’s own files and, in the pipeline theme, the pipeline’s own site pages from site_dir. The page skeletons and helpers the writer had imported from the generator (the model page’s two template strings, the freshness fact, the two number formats, the YAML escape, the front-matter check, the per-column date range) live in the writer now; markdown_generator.py is deleted. The golden tests keep their shape — build the site with the CLI, compare each page with the writer’s — and now guard the plumbing: that the build writes every page at its path and leaves out what it should. Two behaviours changed with it, both proposed: a catalog copies no pipeline’s site pages (the generator had copied the first pipeline’s to the catalog’s root, where no front page listed them), and the generated folders cb/download_chart/<slug>/, _static/<slug>/ and cb/notebooks/<slug>/ exist only when something is copied into them. An adversarial review of the step found a regression and two smaller things: an incremental rebuild had deleted a reused pipeline’s notes — nothing of a reused pipeline was copied, and the sync removes what a build did not write, while the front page, written on every build, still listed them; a reused pipeline’s notes are copied again now, since the page that lists them is. Notes are copied in the pipeline theme only, as the generator did (a catalog has no page that lists them, and two pipelines’ notes of one name would collide at cb/<name>.md). And the theme files to leave out are matched against their path under the theme folder, not the machine’s whole path, so a project under a folder named models gets its theme (the generator had the same fault).

What this does not decide#

Whether a site built later for a past snapshot (the direction on the vocabulary page) reads the write-ups from git at the snapshot’s commit, or from the store; the Build record’s full shape; whether the catalog’s diagnostics are computed by the writer or recorded by the build. Each is marked above where it arises.