Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Content engine library targeting .NET 11 / C# 15 with union types.
- `src/Pennington.ApiMetadata.Reflection/` — Reflection backend reading compiled assemblies: `AddApiMetadataFromCompiledAssembly(name, …)` registers a **keyed singleton** provider per documented library (own `MetadataLoadContext` + xmldoc index; `CompiledAssemblyApiOptions` takes assembly dirs/files).
- `src/Pennington.DocSite.Api/` — DocSite add-on publishing an API-reference content tree from a named metadata provider: `AddApiReference(name, …)` must be called **after `AddDocSite`** (it mutates `SearchIndexOptions.PrefixPriorities` and `DocSiteOptions.AdditionalRoutingAssemblies`). `ApiReferenceContentService` serves the tree (default prefix `/reference/api/`); Mdazor components under `Components/Reference/` inherit `ApiReferenceComponentBase`. Multiple libraries = repeated named registrations.
- `src/Pennington.TranslationAudit/` — Build-time translation freshness auditor (`AddTranslationAudit`): a core `IBuildAuditor` that compares git commit timestamps (libgit2) to classify each translation Up-to-date/Outdated/Missing; surfaces in the dev overlay and build report.
- `src/Pennington.Beck/` — Build-time Beck diagram rendering: `AddPenningtonBeck` registers a `BeckCodeBlockPreprocessor` (`ICodeBlockPreprocessor`, priority 500) that renders ```` ```beck ```` fences to self-animating inline SVG via the pure-C# Beck engine (no client rendering). Each embed gets a fullscreen-zoom lightbox via a head-contributed script + CSS (`BeckZoomHeadContributor`; opt out with `BeckOptions.Zoom = false`). Supports `beck:symbol` file embeds (resolved against `BeckOptions.ContentRoot`), `static`/`scrub` animation flags, and `style=` overrides; hosts tune fonts/measurer/default style via `BeckOptions.RenderOptions`. Fails loud: a malformed fence renders a `.beck-embed--error` box and lands in `DiagnosticContext`. Note: the SVG keys dark mode off `[data-theme]`, which the DocSite toggle does not set — the docs site mirrors its `.dark` class onto the attribute via `AdditionalHtmlHeadContent`.
- `src/Pennington.Templates/` — `dotnet new` template package (`pennington`, `pennington-docs`, `pennington-blog`); no runtime code.
- `src/Pennington.Book/` — Optional PDF book generation: carves the TOC into `BookDefinition(Title, RoutePrefix)` books (or one whole-site book), composes each into a self-contained print document (per-page HTML→markdown→re-render like llms.txt, inline print CSS + vendored paged.js, images as data: URIs), and renders to PDF via PuppeteerSharp/Chromium. Ships as an artifact-tier service (`BookArtifactService` + `BookArtifactContentService` claiming `/pdf/**.pdf` and `/book-preview/**`): core's artifact router serves both in dev, the static build writes the enumerated PDFs through the same resolver (previews stay dev-only — resolvable, never enumerated). Registration is DI-only (`AddPenningtonBook`; no `Use*` call). `diag books` prints the chapters→pages tree. Registers an `IDownloadLinkProvider` (core `Pennington.Navigation`) so host chrome like the DocSite sidebar advertises the PDFs without referencing this Chromium-dependent project.
- `docs/Pennington.Docs/` — The Pennington docs site (Divio-style: tutorials, how-to, reference, explanation); `docs/docs-voice.md` is the writing-voice guide
Expand Down Expand Up @@ -65,6 +66,7 @@ Content engine library targeting .NET 11 / C# 15 with union types.
- `services.AddTreeSitter(...)` — optional tree-sitter multi-language `:symbol` fragment extraction (registers only when `ContentRoot` is set)
- `services.AddApiMetadataFromCompiledAssembly(name, ...)` + `services.AddApiReference(name, ...)` — named API-reference trees (AddApiReference must follow AddDocSite)
- `services.AddPenningtonBook(...)` — PDF book generation (DI-only; no `Use*` call)
- `services.AddPenningtonBeck(...)` — server-side ```` ```beck ```` diagram fence rendering
- `services.AddTranslationAudit(...)` — translation freshness audit
- `services.AddDataFile<T>(path, name)` — typed hot-reloading data files

Expand Down
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.5.1" />
<PackageVersion Include="Ashcroft" Version="0.5.0" />
<PackageVersion Include="Beck" Version="0.3.0" />
<PackageVersion Include="Beck" Version="0.4.3" />
<PackageVersion Include="CooklangSharp" Version="0.0.0-alpha.0.9" />
<PackageVersion Include="DeweySearch" Version="0.1.2" />
<PackageVersion Include="DeweySearch.Web" Version="0.1.2" />
Expand Down Expand Up @@ -39,6 +39,7 @@
<PackageVersion Include="TreeSitter.DotNet" Version="1.3.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
<PackageVersion Include="ZiggyCreatures.FusionCache" Version="2.6.0" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions Pennington.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<Project Path="src/Pennington.ApiMetadata.Reflection/Pennington.ApiMetadata.Reflection.csproj" />
<Project Path="src/Pennington.TreeSitter/Pennington.TreeSitter.csproj" />
<Project Path="src/Pennington.Book/Pennington.Book.csproj" />
<Project Path="src/Pennington.Beck/Pennington.Beck.csproj" />
<Project Path="src/Pennington.TranslationAudit/Pennington.TranslationAudit.csproj" />
<Project Path="src/Pennington.BlogSite/Pennington.BlogSite.csproj" />
<Project Path="src/Pennington.Templates/Pennington.Templates.csproj" />
Expand Down
34 changes: 17 additions & 17 deletions docs/Pennington.Docs/Content/explanation/core/content-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,23 @@ A `DiscoveredItem` pairs a `ContentRoute` with a second union, `ContentSource`,
Each stage in the pipeline works by replacing the incoming union case with the next one. `ParseAsync` pulls a stream of `ContentItem` values and, for each `DiscoveredItem`, hands its content to the registered `IContentParser`. When the parser succeeds, the `DiscoveredItem` is replaced by a `ParsedItem` carrying the resolved front matter and text. `RenderAsync` does the same thing one level further: each `ParsedItem` is handed to the `IContentRenderer`, and on success a `RenderedItem` takes its place, now carrying the HTML output and a navigation outline. The final stage, `GenerateAsync`, pattern-matches on the full union to write output files and accumulate the build report.

```beck
type: state
meta: { direction: TB }
transitions:
- { from: "[*]", to: discovered }
- { from: discovered, to: parsed, label: parse }
- { from: parsed, to: rendered, label: render }
- { from: discovered, to: failed, label: parse throws }
- { from: parsed, to: failed, label: render throws }
- { from: rendered, to: "[*]", label: emit }
- { from: failed, to: "[*]", label: build report }
- { from: parsed, to: parsed, label: pass through }
- { from: rendered, to: rendered, label: pass through }
states:
- { id: discovered, title: DiscoveredItem, accent: info }
- { id: parsed, title: ParsedItem, accent: info }
- { id: rendered, title: RenderedItem, accent: success }
- { id: failed, title: FailedItem, accent: danger }
type: sequence
participants:
- { id: pipeline, title: ContentPipeline }
- { id: parser, title: IContentParser }
- { id: renderer, title: IContentRenderer }
- { id: generate, title: GenerateAsync, subtitle: "output · build report", kind: db }
messages:
- { section: One item succeeds, accent: info }
- { from: pipeline, to: parser, label: DiscoveredItem, note: "ParseAsync hands each discovered item to the parser" }
- { from: parser, to: pipeline, label: ParsedItem, reply: true, note: "The case is replaced, not mutated — front matter and text take the source's place" }
- { from: pipeline, to: renderer, label: ParsedItem }
- { from: renderer, to: pipeline, label: RenderedItem, reply: true }
- { from: pipeline, to: generate, label: RenderedItem, color: success, note: "The exhaustive match writes the HTML and outline to disk" }
- { section: Another item fails to parse, accent: danger }
- { from: pipeline, to: parser, label: DiscoveredItem }
- { from: parser, to: pipeline, label: FailedItem, reply: true, color: danger, note: "The parser throws; the pipeline boundary demotes the exception to a data case" }
- { from: pipeline, to: generate, label: FailedItem, color: danger, note: "The failed item rides the same stream — it becomes a build-report entry, not a crash" }
```

The replacement invariant is what gives the pipeline its composability. A `RenderedItem` flowing into `ParseAsync` is already past that stage, so `ParseAsync` passes it through unchanged. A `ParsedItem` flowing into `RenderAsync` gets rendered; a `RenderedItem` in the same stream passes through. This means you can hand the pipeline a partially-processed stream — one that mixes discovered and already-parsed items — and it will do the right thing for each. There is no need to coordinate which stage ran last; the case type carries that information.
Expand Down
20 changes: 19 additions & 1 deletion docs/Pennington.Docs/Content/explanation/core/dev-vs-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Pennington keeps one host. Dev mode is that host serving requests over Kestrel;

```beck
type: architecture
meta: { animate: false, direction: TB }
meta: { direction: TB }
nodes:
- { id: browser, title: Browser, kind: user }
- { id: kestrel, title: Kestrel, subtitle: "dev serve" }
Expand All @@ -29,6 +29,24 @@ edges:
- { from: kestrel, to: pipeline }
- { from: crawler, to: pipeline, label: in-process }
- { from: pipeline, to: output, label: build writes HTML }
flow:
repeatDelay: 2
steps:
- narrate: "Dev serve: one request, one render"
- packet: { from: browser, to: kestrel, label: GET /foo }
- packet: { from: kestrel, to: pipeline }
- working: { node: pipeline }
- wait: 0.6
- idle: { node: pipeline }
- packet: { from: pipeline, to: kestrel, color: success }
- packet: { from: kestrel, to: browser, color: success, label: HTML }
- narrate: "The response goes back to the browser — nothing touches the output directory"
- wait: 0.5
- narrate: "Build: the crawler fires every discovered URL through the same pipeline"
- working: { node: pipeline }
- burst: { from: crawler, to: pipeline, count: 5, stagger: 0.15 }
- burst: { from: pipeline, to: output, count: 5, stagger: 0.15, color: success }
- idle: { node: pipeline }
```

## How it works
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ src/Pennington/FrontMatter/Capabilities.cs > IOrderable

`NavigationBuilder` keys off the `IOrderable` interface itself, not a sentinel value in the `Order` property. A content type either implements the interface and participates in ordered navigation, or it does not; there is no "this page has no meaningful order" case to handle. The same applies to `ITaggable` (tag cloud participation), `ISectionable` (section-label breadcrumbs), `IRedirectable` (redirect-stub semantics), and `IStandardSiteDocument` (the AT Protocol record key for Standard Site syndication).

The whole matrix fits in one view. The two shipped records implement different subsets — a doc page orders itself within its section, a blog post syndicates to Standard Site instead — and that difference is visible in the type declarations, not in runtime flags:

```beck
type: class
meta: { direction: TB, spacing: { rank: 64, node: 20 } }
classes:
- id: ifm
name: IFrontMatter
stereotype: interface
accent: primary
fields: ["Title: string", "IsDraft = false", "Search = true", "Llms = true", "SearchOnly = false", "Uid = null", "Description = null", "Date = null"]
- { id: orderable, name: IOrderable, stereotype: interface, fields: ["Order: int"] }
- { id: taggable, name: ITaggable, stereotype: interface, fields: ["Tags: string[]"] }
- { id: sectionable, name: ISectionable, stereotype: interface, fields: ["SectionLabel: string?"] }
- { id: redirectable, name: IRedirectable, stereotype: interface, fields: ["RedirectUrl: string?"] }
- { id: standard, name: IStandardSiteDocument, stereotype: interface, fields: ["AtprotoRkey = null"] }
- { id: doc, name: DocSiteFrontMatter, stereotype: record, accent: info, rank: -1 }
- { id: blog, name: BlogSiteFrontMatter, stereotype: record, accent: info, rank: 1 }
relations:
- { from: doc, to: ifm, kind: implements }
- { from: doc, to: taggable, kind: implements }
- { from: doc, to: sectionable, kind: implements }
- { from: doc, to: orderable, kind: implements }
- { from: doc, to: redirectable, kind: implements }
- { from: blog, to: ifm, kind: implements }
- { from: blog, to: taggable, kind: implements }
- { from: blog, to: sectionable, kind: implements }
- { from: blog, to: redirectable, kind: implements }
- { from: blog, to: standard, kind: implements }
```

Both records also implement `IHasStructuredData`, which belongs to the JSON-LD subsystem rather than this capability system and is omitted here.

The rule of thumb is simple: if adoption is universal, the member lives on `IFrontMatter` with a sensible default. If adoption is selective, it lives on a capability interface so that pattern-matching on the interface remains meaningful.

### Custom front-matter records
Expand Down
20 changes: 20 additions & 0 deletions docs/Pennington.Docs/Content/explanation/core/head-subsystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@ They stop being reasonable together. All of them target the same small region of

The subsystem has four parts: a typed data model, the `IHeadContributor` extension point, a single rewriter that finalizes the head, and a `data-head` attribute that ties the server and client halves together.

```beck
type: architecture
meta: { direction: TB }
nodes:
- { id: pagec, title: Page-band contributors, subtitle: "Order 40 — per-page OpenGraph, title" }
- { id: sitec, title: Site-band contributors, subtitle: "Order 60 — canonical, alternates, defaults" }
- { id: authored, title: Page-authored markup, subtitle: "<HeadContent> · HeadOutlet" }
- { id: builder, title: HeadBuilder, subtitle: "keyed dedup — first add wins", accent: primary }
- { id: rewriter, title: HeadCompositionHtmlRewriter, subtitle: "in the shared AngleSharp pass" }
- { id: head, title: Document head, subtitle: "every element stamped data-head", kind: db }
- { id: client, title: SPA navigation script, kind: user }
edges:
- { from: pagec, to: builder, label: runs first, note: "Lowest Order runs first — a page tag beats a site default at the same key" }
- { from: sitec, to: builder }
- { from: authored, to: rewriter, label: normalized, note: "Rendered page tags are pulled into the same model and win their keys" }
- { from: builder, to: rewriter }
- { from: rewriter, to: head, label: reconciled, note: "One finalization point, between the locale and base-URL rewriters" }
- { from: head, to: client, label: "data-head", note: "On soft navigation the client removes and re-clones every [data-head] element — a rule, not a list" }
```

### The typed model

Everything that can land in the head is a typed `HeadTag` — a union of `TitleTag`, `MetaNameTag`, `MetaPropertyTag`, `LinkTag`, `ScriptTag`, and a `RawTag` case for markup the engine does not model. Each tag that should appear at most once carries a stable `HeadTagKey`: `title`, `meta:prop:og:image`, `link:rel:canonical`, and so on. Repeatable tags — `hreflang` alternates, JSON-LD blocks, preloads, the Standard Site links — carry no key and always append.
Expand Down
28 changes: 16 additions & 12 deletions docs/Pennington.Docs/Content/explanation/core/mental-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,26 @@ That single idea explains most of the project:
- The response pipeline finishes the HTML. Cross-references, locale prefixes, base URLs, live reload, diagnostics, and other response processors run against the actual HTTP response.
- Build mode crawls the host. `dotnet run` serves through Kestrel; `dotnet run -- build` starts the same app on an in-process test server, requests every discovered route, and writes the responses to disk.

That is the whole map. Each deeper architecture page zooms into one box: <xref:explanation.core.content-pipeline> opens up the content pipeline, <xref:explanation.core.response-processing> the response pipeline, and <xref:explanation.core.dev-vs-build> the serve/build fork on the right edge.

```beck
type: architecture
meta: { animate: false, direction: TB }
meta: { direction: TB }
nodes:
- { id: templates, title: DocSite / BlogSite, subtitle: "templates", accent: primary }
- { id: sources, title: Content sources, subtitle: "markdown · Razor · API · taxonomy" }
- { id: pipeline, title: Content pipeline, subtitle: "discover · parse · render" }
- { id: response, title: Response pipeline, subtitle: "xref · locale · base URL" }
- { id: output, title: Browser / static output, kind: external }
groups:
- { id: host, label: "ASP.NET host (Program.cs)", members: [sources, pipeline, response], accent: info }
- { id: markdown, title: Markdown folders }
- { id: razor, title: Razor pages }
- { id: custom, title: Custom sources, subtitle: "API reference · taxonomy · redirects" }
- { id: pipeline, title: Content pipeline, subtitle: "discover · parse · render", accent: primary }
- { id: response, title: Response pipeline, subtitle: "xrefs · locales · base URL · head" }
- { id: browser, title: Browser, kind: user }
- { id: output, title: Static output, kind: db }
edges:
- { from: templates, to: sources, label: wire }
- { from: sources, to: pipeline, label: discover }
- { from: pipeline, to: response, label: render }
- { from: response, to: output, label: serve / write }
- { from: markdown, to: pipeline, note: "Every source reports routes into the same site model" }
- { from: razor, to: pipeline }
- { from: custom, to: pipeline }
- { from: pipeline, to: response, note: "Rendered HTML is finished by the response pipeline, never by the sources" }
- { from: response, to: browser, label: dev serve }
- { from: response, to: output, label: "dotnet run -- build", note: "Build mode crawls the same app and writes each response to disk" }
```

## The layers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ Content files — `.md` sources, front matter, images, assets tracked under a so

The mechanism is a single chain: files change, cached services drop their state, a debounce window elapses, and the browser reloads.

```beck
type: sequence
participants:
- { id: files, title: Content files, kind: db }
- { id: watcher, title: FileWatcher }
- { id: service, title: IFileWatchAware service, subtitle: "nav builder · xref map · search index" }
- { id: reload, title: LiveReloadServer }
- { id: browser, title: Browser, kind: user }
messages:
- { from: files, to: watcher, label: change, note: "A save, create, delete, or rename lands in a watched ContentPath" }
- { from: watcher, to: service, label: OnFileChanged }
- { from: service, to: watcher, label: Recreate, reply: true, note: "The stale instance is dropped; the next request rebuilds it through normal constructor injection" }
- { from: watcher, to: reload, label: notify }
- { from: reload, to: reload, label: reset 300ms debounce, note: "Rapid saves keep resetting the timer — a burst of changes coalesces into one reload" }
- { from: reload, to: browser, label: "reload · /__pennington/reload", kind: async, note: "One WebSocket broadcast after 300ms of quiet" }
```

### Watching content directories

A service tells the engine which directories to watch by declaring `WatchScopes` — the public contract through which every file-reactive service registers its content roots. Creates, deletes, and renames count as changes, not just edits in place, so a new markdown file or a deleted asset reloads the same way a save does. The watcher behaves consistently across Windows and WSL, the two platforms most contributors run, so the dev loop feels the same on either.
Expand Down
Loading
Loading