One source for page, feed, and archive
M31V stopped parsing articles separately for each output and built one normalized content graph that every publication surface consumed.
M31V published one article into several places: its page, the home list, an author archive, topic archives, an Atom feed, a sitemap, and a lightweight search index.
The first generator treated each output as a separate script. The article page read description. The feed fell back to the first paragraph. The archive used a shorter summary field added later. Publication date came from metadata in one place and the filename in another.
All outputs began from the same source file and still disagreed.
I replaced the collection of parsers with one normalized content graph. Every surface received the same article meaning and made only presentation-specific choices.
One source was not enough by itself
It was comforting to say that Markdown was the source of truth. A file can contain ambiguous, incomplete, or inconsistently interpreted truth.
M31V needed a stage that converted flexible authoring input into a trusted article object:
source bytes
-> parsed metadata and document
-> validated relationships
-> normalized content node
-> page, archive, feed, sitemap, searchTemplates never reopened the file. They consumed a node whose required fields and relationships were already established.
This made “one source” a behavioral guarantee. A title correction flowed to every surface because there was one normalized title field. A missing description failed before any output rather than triggering a different fallback in each renderer.
The graph was the source of publication meaning for one build. Files remained the editable origin.
The article node carried public identity
The normalized article included stable identity and derived paths:
interface ArticleNode {
id: string;
source: SourceRef;
title: string;
description: string;
canonicalPath: string;
publishedAt: Date;
updatedAt: Date | null;
author: AuthorNode;
topics: TopicNode[];
document: DocumentNode;
excerpt: DocumentFragment;
}The id survived a slug change. canonicalPath followed explicit URL policy and the redirect graph. Source reference supported diagnostics but did not enter public output.
Dates were parsed once into unambiguous values. Renderers formatted them for HTML, feed XML, or sitemap output without changing which instant they represented.
Author and topics were resolved nodes, not strings each surface looked up independently. A missing relationship prevented normalization.
The type described what every output could rely on. Surface-specific optional fields stayed out unless the publication had a real shared concept for them.
The early drift came partly from vocabulary. description, summary, and first paragraph had been used interchangeably.
M31V defined them:
- Description: a concise editorial sentence used in metadata, archive context, and feed summary where plain text was required.
- Excerpt: an optional document fragment selected from article content for surfaces that could preserve limited markup.
- Body: the complete article document.
The source required a description. The build could derive a default excerpt under documented rules or accept an explicit excerpt boundary. It did not silently promote the first paragraph to a description, where links and context might become awkward.
The feed chose description or excerpt according to its content field. The archive used description. The article's metadata used a safely escaped plain-text description.
Once the terms were distinct, writers could edit the one sentence knowing every surface that would change.
Each output had previously converted source markup independently. Different parser options produced different heading IDs and link handling.
The build parsed the body into one document tree. A normalization pass resolved internal content references, assigned stable heading identifiers, validated images, and marked code, quotes, lists, and figures semantically.
HTML rendering consumed the tree. Feed rendering serialized the supported subset and converted unsupported structures according to policy. Search extracted text from the same nodes while excluding navigation or code where appropriate.
This did not mean every output had identical markup. It meant differences were explicit transformations over one document model rather than side effects of separate parsers.
Heading links became consistent between article and feed. Image captions did not vanish from search descriptions accidentally. A new content element required declaring how each relevant renderer handled it.
The tree turned markup support into a reviewed publication contract.
Archives were projections, not source lists
The topic archive script originally scanned filenames, read enough metadata to sort, and built cards. The author archive used a different scan and sorting rule.
The graph constructed indexes after all articles normalized:
articlesById
articlesByCanonicalPath
articlesByAuthor
articlesByTopic
publishedArticlesByDate
redirectsBySourcePathEvery archive selected from those indexes under shared publication-state rules. Drafts and future scheduled articles could not leak because a template forgot a filter.
The sort rule lived in one function: publication time descending, stable identity as a deterministic tie-breaker. Author and topic views did not disagree when two articles shared a date.
Archive pagination received a complete ordered list and generated stable page paths. The sitemap consumed the same public set.
The graph made archives read models of one publication, not independent mini-databases assembled from files.
The first feed script ran after the site build and parsed the generated home page. That made it depend on presentation markup and truncated lists.
The feed renderer moved beside page renderers and consumed article nodes directly. It selected the public article set, used canonical URLs from the graph, preserved stable entry IDs, and formatted the normalized publication and update times.
Its content policy was explicit. M31V could publish a summary plus canonical link or a supported full-content serialization. It did not scrape rendered HTML with page navigation and unrelated wrappers.
Feed validation checked unique entry identity, valid dates, absolute URLs, escaped text, and expected item count. A candidate preview included the actual feed artifact for inspection.
This made feed readers first-class readers. A metadata change could not ship on the page while leaving the feed stale because both outputs belonged to one artifact.
Canonical URLs came from one namespace
Each renderer had once joined strings to form URLs. One added a trailing slash, another omitted it, and the feed used a configured host with an old path prefix.
The graph owned public path identity. A URL service combined canonical path with the candidate's declared origin for outputs requiring absolute URLs.
article node canonical path: /notes/one-source/
page output: /notes/one-source/index.html
canonical URL: https://example.invalid/notes/one-source/
feed link: same canonical URL
sitemap location: same canonical URLOutput file location was not treated as public URL. That distinction allowed different host layouts while preserving identifiers.
The redirect map occupied the same path namespace and could not claim a live canonical path. A slug change created a new canonical path and an explicit old-path redirect.
One path model eliminated a surprising number of publication inconsistencies.
A scheduled article needed to appear in preview and remain absent from the current public candidate. Independent scripts each compared dates differently, sometimes in local timezone.
The build accepted an explicit publication clock and produced a public view of the graph. That view contained published nodes as of the clock. Preview could create an alternate future view with annotations.
Renderers received the selected view, not raw article status. They did not reimplement date logic.
This prevented a future article from leaking into the sitemap while remaining absent from archives. It also made a scheduled release testable: build at one minute before and one minute after the intended instant and compare the complete artifact.
The scheduling trigger still had to run. The graph model ensured that when it ran, every surface agreed about inclusion.
Time became a build input shared across publication projections.
Updates required a policy, not file modification time
File modification times changed during checkouts, copies, and builds. They were poor public evidence that an article had been editorially updated.
M31V used an explicit updated field only for meaningful published revisions. The original publication date remained stable. The validator required updated to be after publication and warned when an article's body changed without an editorial decision about update signaling.
Page metadata, feed updated time, sitemap metadata where used, and visible article date all consumed the normalized decision. A template could choose whether to display both dates but could not invent one from filesystem state.
Not every typo needed a prominent “updated” label. The editorial workflow allowed a small correction category while still producing a new release artifact. Significant changes set the field and could add a change note.
The graph preserved one definition of public recency instead of letting each protocol interpret source timestamps differently.
The first search experiment crawled generated HTML and extracted text. It included navigation, footer links, and repeated topic labels. A template change altered relevance.
The search-index renderer consumed normalized nodes. It selected title, description, headings, and body text under a declared weighting scheme. Code blocks and captions had explicit inclusion policy. Author and topic IDs mapped to public labels.
The index stored canonical path and article identity from the graph. It did not rediscover URLs from output filenames.
This kept search aligned with publication meaning while allowing the HTML template to evolve. It also reduced duplicated boilerplate in index size.
Search remained a derived artifact. Rebuilding it from the graph was safe. Its inclusion set matched the same public view as archives and feed, preventing draft leakage.
One content model supported a new surface without asking that surface to scrape another.
A “related articles” component initially read topic labels from rendered cards and chose recent matches. The graph already held stable topic relationships.
The related-content projection used shared topics, explicit series links, and publication time under a deterministic rule. It excluded the current article and unpublished nodes. Ties resolved by stable ID.
The article page received related article summaries as part of its render view. The feed did not include them unless its policy chose to. Search did not index the repeated related titles as body content.
This illustrated the difference between shared meaning and shared presentation. Relationships belonged in the graph. A related-card list belonged to one surface.
I kept the rule simple and inspectable rather than inventing a recommendation system. The build manifest recorded related links so broken or circular series relationships could be checked.
Derived features became easier when they started from normalized structure and stayed honest about their scope.
Renderer boundaries were tested with contracts
Each renderer accepted a typed view and returned files plus diagnostics. It could not query the source directory or mutate the graph.
interface RenderResult {
files: GeneratedFile[];
diagnostics: Diagnostic[];
}Page renderer tests used representative article nodes. Feed tests used the same fixtures and asserted canonical identity, dates, and escaping. A graph integration test built all surfaces and checked cross-output invariants.
Important invariants included:
- Every public article has one canonical page.
- Every feed entry canonical URL resolves in the candidate artifact.
- Every archive link targets a public node.
- Sitemap paths equal the canonical public set under policy.
- No draft identity appears in public outputs.
- Redirect sources do not overlap canonical paths.
The tests evaluated relationships between outputs, not just snapshots of each file.
One graph made those assertions possible before deployment.
The page renderer once tolerated a missing description by rendering nothing. The feed renderer derived one. That made a source look valid depending on which output someone inspected.
Required shared fields failed normalization. Optional shared fields carried one declared default in the graph-building phase. Renderers could choose representation, not meaning.
For example, an article without an explicit excerpt could receive a normalized excerpt derived under one rule. A renderer could use or omit it. It could not derive a different excerpt from raw body text.
Surface-specific fields were named accordingly. feedTitle would require a genuine editorial reason and documentation of how it differed from title; it could not appear as an emergency patch for a feed template.
This discipline kept output quirks from pushing contradictions back into source metadata.
When a surface exposed a missing concept, the project decided whether to add that concept to the normalized model or solve it as presentation.
Build order stopped carrying meaning
Separate scripts had depended on running page generation before feed scraping and archive generation before sitemap discovery. The order was an undocumented data flow.
With the graph, normalization completed first. Renderers could run independently over the immutable public view. Their outputs joined one candidate directory only after path collision checks.
One renderer could not use another's generated file as its source of truth. If both needed a value, the graph or a shared projection supplied it.
This opened safe parallel generation later, but speed was not the main benefit. The build order no longer decided semantics. Reordering feed and page rendering produced the same publication.
The asset renderer remained a dependency where document nodes referenced generated asset paths. That relationship was explicit: assets received identities during normalization, and renderers consumed the resolved manifest.
An explicit build graph replaced a shell sequence that happened to work.
Rebuilding every article during editing was slow. An incremental cache stored normalized nodes keyed by source content and relevant build inputs.
Changing one article could reuse unchanged parses, then rebuild affected archives, feed window, sitemap, search index, and related-content edges. The dependency graph named those consequences.
Incremental speed did not waive global integrity. The path namespace, public inclusion set, relationship resolution, and redirect graph were recomputed or validated against complete cached data.
A cache entry included parser and schema versions. Changing normalization code invalidated it. A debugging command could bypass the cache and compare full output.
The candidate release always had a clean-build verification path. Incremental development optimized feedback; it did not become the sole evidence that source could reproduce the artifact.
Again, one normalized model made affected work calculable without pretending one file had only one output.
A graph dump made differences explainable
When an article appeared on the page but not in an archive, I wanted to inspect publication state before reading templates.
M31V could emit a diagnostic graph summary:
article ID: one-source
status at build clock: published
canonical path: /notes/one-source/
author: bob
topics: publishing, architecture
archive memberships: author/bob, topic/publishing, topic/architecture
feed inclusion: yes
sitemap inclusion: yes
search inclusion: yesThe summary was generated from projections, not inferred from final files. It showed why an output should exist. A missing file then pointed to a renderer or assembly bug.
For a draft, the dump showed exclusions explicitly. For a redirect collision, it named both owners.
This became a modest observability interface for the build. The content graph was not hidden inside process memory; its decisions could be inspected in publication language.
Centralizing meaning increased the consequence of a normalization bug. A wrong date parser could make every surface consistently wrong. Agreement alone was not truth.
I kept fixtures at the source boundary and verified normalized nodes against known expectations. Renderers had protocol-specific validation, such as parsing the generated feed independently. Candidate link checks exercised actual output paths.
The graph schema stayed small enough to review. Derived fields recorded their inputs and source references. A new fallback required tests across affected surfaces.
This was an important limit. Eliminating drift did not eliminate mistakes. It changed them from inconsistent local interpretations into centralized policy errors that could be caught once and otherwise spread everywhere.
The architecture traded many hidden parsers for one highly accountable parser and model.
Publication became one coherent build
After the change, an article did not separately “enter” the page, feed, archive, sitemap, and search index. It entered the normalized public graph once. Renderers projected that decision into their formats.
This removed an entire class of drift:
- One title and description meaning.
- One publication and update chronology.
- One author and topic relationship.
- One canonical path namespace.
- One published set at an explicit build time.
- One document tree with declared renderer behavior.
Surface differences remained where they belonged. A feed serialized content differently from a page. Search weighted text rather than rendering it. Archives paginated summaries. None of them reinterpreted source identity.
The breakthrough was not a sophisticated graph database. It was an in-memory, validated model built from files and discarded after generating an artifact. Its value came from making every public surface argue from the same facts.
One source file had never guaranteed consistency. One normalized meaning did.