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 |
The write-up |
The author’s text, from |
file |
the file, or the manifest |
partly: |
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 |
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 ( |
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, |
Table / query row |
Source, identifier or SQL |
fact |
manifest |
yes (location) |
Linked charts |
Charts whose |
rendering of a comparison |
manifest |
yes: the charts’ |
Series tables |
Documented columns |
fact |
manifest |
yes ( |
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 |
manifest; file system |
yes once |
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; |
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 ( |
Versions table |
Each version: name, made, commit, labels, each metric |
fact |
the store |
yes ( |
Trained on |
Each input, linked where the site has it |
rendering: links from |
manifest; site manifest |
yes ( |
Out of date |
The inputs that moved, then and now |
fact: a comparison the build made |
the store, the lockfile, the files |
yes ( |
Files |
A directory model’s files and sizes |
fact |
the file system |
yes ( |
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 |
|
Notebook |
The executed notebook, rendered by Sphinx |
file |
the |
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 |
Diagnostics |
Which assets lack which required fields, against the policy |
a comparison: rendering, or a fact the build records |
|
the |
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.)
The write-up’s path and hash on every asset record and on the
Pipelinerecord (its README). Decided 2026-10-14. A build then says which text it showed, and a diff says the write-up changed.modified_aton everyfilelocation: when the file was last written, in UTC. The freshness lines and ABlog’s dates come from it. Proposed.date_rangeon a dataframe record with adate_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.code_changed_aton thePipelinerecord: 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 theBuildrecord 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’sgeneratoralready carries the versions.The policy on the
Catalogrecord, so the diagnostics page is a comparison a reader can make. Proposed. The schema has a place for it.site_urlandreadmeon thePipelinerecord 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.pyfirst. 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 (pipelineorcatalog) and the build options the pages depend on.The link scheme is the writer’s.
page_path(record) -> str,pipeline_page_link(record)andlink_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.