From a36d85b21096ab8f144620ca03ab245520432f9d Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Fri, 10 Jul 2026 09:06:36 -0400 Subject: [PATCH] feat: add Pennington.Beck server-side diagram rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/Pennington.Beck, a build-time renderer for ```beck fenced code blocks. AddPenningtonBeck registers a BeckCodeBlockPreprocessor (ICodeBlockPreprocessor, priority 500) that renders fences to self-animating inline SVG via the pure-C# Beck engine — Beck 0.4 dropped the client-JS engine, so no script hydration remains. - Fullscreen-zoom lightbox per embed via BeckZoomHeadContributor (opt out with BeckOptions.Zoom = false) - beck:symbol file embeds resolved against BeckOptions.ContentRoot, static/scrub animation flags, style= overrides - Malformed fences fail loud: .beck-embed--error box + DiagnosticContext Docs site: replace the Beck RCL script tag with AddPenningtonBeck, mirror the .dark class onto [data-theme] so the SVGs follow the theme toggle, frame embeds as preview cards, and add a beck-diagrams how-to. Existing explanation-page diagrams updated for the 0.4 schema. Bump Beck 0.3.0 -> 0.4.3; add YamlDotNet 18.1.0. --- CLAUDE.md | 2 + Directory.Packages.props | 3 +- Pennington.slnx | 1 + .../explanation/core/content-pipeline.md | 34 +-- .../Content/explanation/core/dev-vs-build.md | 20 +- .../core/front-matter-capabilities.md | 33 +++ .../explanation/core/head-subsystem.md | 20 ++ .../Content/explanation/core/mental-model.md | 28 +- .../explanation/dev-experience/hot-reload.md | 17 ++ .../localization/urls-and-fallback.md | 25 ++ .../how-to/rich-content/beck-diagrams.md | 190 +++++++++++++ .../how-to/rich-content/client-side-widget.md | 2 +- .../how-to/rich-content/content-tabs.md | 2 +- .../structured-data-custom-types.md | 2 +- .../rich-content/ui-components-in-markdown.md | 2 +- docs/Pennington.Docs/Pennington.Docs.csproj | 8 +- docs/Pennington.Docs/Program.cs | 50 +++- .../BeckCodeBlockPreprocessor.cs | 254 ++++++++++++++++++ src/Pennington.Beck/BeckOptions.cs | 29 ++ src/Pennington.Beck/BeckServiceExtensions.cs | 27 ++ .../BeckZoomHeadContributor.cs | 88 ++++++ src/Pennington.Beck/Pennington.Beck.csproj | 21 ++ .../Beck/BeckCodeBlockPreprocessorTests.cs | 160 +++++++++++ .../Beck/BeckZoomHeadContributorTests.cs | 61 +++++ .../Pennington.Tests/Pennington.Tests.csproj | 1 + 25 files changed, 1035 insertions(+), 45 deletions(-) create mode 100644 docs/Pennington.Docs/Content/how-to/rich-content/beck-diagrams.md create mode 100644 src/Pennington.Beck/BeckCodeBlockPreprocessor.cs create mode 100644 src/Pennington.Beck/BeckOptions.cs create mode 100644 src/Pennington.Beck/BeckServiceExtensions.cs create mode 100644 src/Pennington.Beck/BeckZoomHeadContributor.cs create mode 100644 src/Pennington.Beck/Pennington.Beck.csproj create mode 100644 tests/Pennington.Tests/Beck/BeckCodeBlockPreprocessorTests.cs create mode 100644 tests/Pennington.Tests/Beck/BeckZoomHeadContributorTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index e8ddbdba..3d1e6abb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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(path, name)` — typed hot-reloading data files diff --git a/Directory.Packages.props b/Directory.Packages.props index 6e388028..a0115907 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ - + @@ -39,6 +39,7 @@ + \ No newline at end of file diff --git a/Pennington.slnx b/Pennington.slnx index e4a8aad7..4a0a1f52 100644 --- a/Pennington.slnx +++ b/Pennington.slnx @@ -9,6 +9,7 @@ + diff --git a/docs/Pennington.Docs/Content/explanation/core/content-pipeline.md b/docs/Pennington.Docs/Content/explanation/core/content-pipeline.md index 4911ace1..75e724b5 100644 --- a/docs/Pennington.Docs/Content/explanation/core/content-pipeline.md +++ b/docs/Pennington.Docs/Content/explanation/core/content-pipeline.md @@ -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. diff --git a/docs/Pennington.Docs/Content/explanation/core/dev-vs-build.md b/docs/Pennington.Docs/Content/explanation/core/dev-vs-build.md index 2df4e429..b9afaed8 100644 --- a/docs/Pennington.Docs/Content/explanation/core/dev-vs-build.md +++ b/docs/Pennington.Docs/Content/explanation/core/dev-vs-build.md @@ -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" } @@ -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 diff --git a/docs/Pennington.Docs/Content/explanation/core/front-matter-capabilities.md b/docs/Pennington.Docs/Content/explanation/core/front-matter-capabilities.md index 27ee8be8..7fe10dc9 100644 --- a/docs/Pennington.Docs/Content/explanation/core/front-matter-capabilities.md +++ b/docs/Pennington.Docs/Content/explanation/core/front-matter-capabilities.md @@ -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 diff --git a/docs/Pennington.Docs/Content/explanation/core/head-subsystem.md b/docs/Pennington.Docs/Content/explanation/core/head-subsystem.md index 30cbb40c..58c8fdce 100644 --- a/docs/Pennington.Docs/Content/explanation/core/head-subsystem.md +++ b/docs/Pennington.Docs/Content/explanation/core/head-subsystem.md @@ -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: " · 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. diff --git a/docs/Pennington.Docs/Content/explanation/core/mental-model.md b/docs/Pennington.Docs/Content/explanation/core/mental-model.md index 6d3a5c54..e17acd3c 100644 --- a/docs/Pennington.Docs/Content/explanation/core/mental-model.md +++ b/docs/Pennington.Docs/Content/explanation/core/mental-model.md @@ -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: opens up the content pipeline, the response pipeline, and 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 diff --git a/docs/Pennington.Docs/Content/explanation/dev-experience/hot-reload.md b/docs/Pennington.Docs/Content/explanation/dev-experience/hot-reload.md index d9e6d088..3178c827 100644 --- a/docs/Pennington.Docs/Content/explanation/dev-experience/hot-reload.md +++ b/docs/Pennington.Docs/Content/explanation/dev-experience/hot-reload.md @@ -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. diff --git a/docs/Pennington.Docs/Content/explanation/localization/urls-and-fallback.md b/docs/Pennington.Docs/Content/explanation/localization/urls-and-fallback.md index 388aba3f..5fe9b10b 100644 --- a/docs/Pennington.Docs/Content/explanation/localization/urls-and-fallback.md +++ b/docs/Pennington.Docs/Content/explanation/localization/urls-and-fallback.md @@ -31,6 +31,31 @@ The second is *runtime* fallback, and it only runs when the first attempt finds Either way — flag read off a precomputed route, or set after a runtime miss — the resolver records `RequestedLocale` so the view layer can render a "this page has not been translated yet" notice via `FallbackNotice`. The resolver never rewrites URLs — the URL the reader typed stays in the address bar, only the content source changes. +The two flavors side by side — same request, different point of decision: + +```beck +type: sequence +participants: + - { id: reader, title: Reader, kind: user } + - { id: mw, title: LocaleDetectionMiddleware } + - { id: resolver, title: DocSiteContentResolver } + - { id: routes, title: Content services, subtitle: "route table", kind: db } +messages: + - { section: "Startup-precomputed fallback (the common path)", accent: info } + - { from: reader, to: mw, label: GET /fr/guides/intro } + - { from: mw, to: resolver, label: resolve, note: "The prefix sets LocaleContext; the resolver still sees the full URL" } + - { from: resolver, to: routes, label: exact-URL match } + - { from: routes, to: resolver, label: "route · IsFallback: true", reply: true, note: "MarkdownContentService registered this route at startup for every missing translation" } + - { from: resolver, to: reader, label: English body + FallbackNotice } + - { section: "Runtime fallback (no route at all)", accent: warn } + - { from: resolver, to: routes, label: exact-URL match } + - { from: routes, to: resolver, label: no match, reply: true, color: warn } + - { from: resolver, to: resolver, label: StripLocalePrefix, note: "Pure URL math — /fr/guides/intro becomes guides/intro" } + - { from: resolver, to: routes, label: resolve default-locale source } + - { from: routes, to: resolver, label: route, reply: true } + - { from: resolver, to: reader, label: English body + FallbackNotice, note: "This time the resolver sets IsFallback itself" } +``` + ### Fallback: default locale stands in There is exactly one fallback step: the default locale. A missing `/es/guides/intro` falls through to `guides/intro` — the unprefixed default-locale source — not to `/fr/guides/intro`. There is no cascade across non-default locales, no per-locale fallback chain, and no "similar language" matcher. The reason is that a cascade hides coverage gaps. Readers end up seeing a page in a language they did not request and cannot explain why, and translators cannot tell which pages still need coverage because every miss silently inherits from a neighbor. A single fallback step keeps the rule easy to reason about and makes missing translations easy to find. diff --git a/docs/Pennington.Docs/Content/how-to/rich-content/beck-diagrams.md b/docs/Pennington.Docs/Content/how-to/rich-content/beck-diagrams.md new file mode 100644 index 00000000..5fdca901 --- /dev/null +++ b/docs/Pennington.Docs/Content/how-to/rich-content/beck-diagrams.md @@ -0,0 +1,190 @@ +--- +title: "Render an animated Beck diagram at build time" +description: "Fence Beck YAML with a `beck` block and let Pennington.Beck render it server-side to a self-animating inline SVG — no client JavaScript." +uid: how-to.rich-content.beck-diagrams +order: 3 +sectionLabel: "Rich Content" +tags: [markdown, beck, diagrams, svg, build-time] +--- + +To draw an architecture, sequence, state, or class diagram that renders at build time — no client rendering, no CDN — fence Beck YAML with `beck` as the language. [Beck](https://usepennington.github.io/beck/) is a pure-C# diagram engine; the `Pennington.Beck` package registers a code-block preprocessor that turns each fence into a self-animating inline SVG during markdown rendering. The emitted SVG keys dark mode off the site's `data-theme` attribute and its `--beck-*` color tokens fall back to the host's `--color-*` palette, so diagrams adopt your theme with no per-diagram configuration. For a client-rendered alternative with Mermaid syntax, see . + +## Register the renderer + +Add the package and register the preprocessor. It hooks the shared code-block pipeline, so it works the same on `AddPennington`, `AddDocSite`, and `AddBlogSite` hosts: + +```bash +dotnet add package Pennington.Beck +``` + +```csharp +builder.Services.AddPenningtonBeck(); +``` + +That is the whole integration — the next `beck` fence in any markdown page renders as a diagram, on both the live dev server and the static build. + +## Author a diagram + +Every Beck document opens with a `type:`. This page covers the fence wiring, not the YAML grammar — the [Beck documentation](https://usepennington.github.io/beck/docs/) owns the node, edge, flow, and styling vocabulary. + +### Architecture diagram + +````markdown +```beck +type: architecture +nodes: + - { id: gw, title: API Gateway, kind: gateway } + - { id: orders, title: Orders } + - { id: db, title: Postgres, kind: db } + - { id: bus, title: Events, kind: queue } +edges: + - { from: gw, to: orders } + - { from: orders, to: db, label: query } + - { from: orders, to: bus, label: publish, kind: async } +``` +```` + +```beck +type: architecture +nodes: + - { id: gw, title: API Gateway, kind: gateway } + - { id: orders, title: Orders } + - { id: db, title: Postgres, kind: db } + - { id: bus, title: Events, kind: queue } +edges: + - { from: gw, to: orders } + - { from: orders, to: db, label: query } + - { from: orders, to: bus, label: publish, kind: async } +``` + +### Sequence diagram + +Sequence, state, and class documents use the same fence — only the `type:` and its vocabulary change: + +````markdown +```beck +type: sequence +participants: + - { id: web, title: Web App, kind: user } + - { id: api, title: Orders API } + - { id: db, title: Postgres, kind: db } +messages: + - { from: web, to: api, label: POST /orders } + - { from: api, to: db, label: INSERT } + - { from: db, to: api, label: ok, reply: true } + - { from: api, to: web, label: 201 Created, reply: true } +``` +```` + +```beck +type: sequence +participants: + - { id: web, title: Web App, kind: user } + - { id: api, title: Orders API } + - { id: db, title: Postgres, kind: db } +messages: + - { from: web, to: api, label: POST /orders } + - { from: api, to: db, label: INSERT } + - { from: db, to: api, label: ok, reply: true } + - { from: api, to: web, label: 201 Created, reply: true } +``` + +## Tune a fence with flags + +A comma-separated tail after the language adjusts one fence without touching its YAML. Flags combine: `beck:symbol,static` works. + +### Freeze the animation — `beck,static` + +To show the fully-revealed final frame with no motion, add `static`. Useful when several diagrams share a page and only one should draw the eye. + +### Drive playback from scroll — `beck,scrub` + +`scrub` binds the choreography to scroll position instead of a looping timeline: the diagram plays as the reader scrolls it through the viewport. + +### Override the style — `beck,style=sketch` + +`style=` overrides the document's own `meta.style`, so one YAML snippet can render in any of Beck's built-in looks: + +```beck,style=sketch,static +type: architecture +nodes: + - { id: gw, title: API Gateway, kind: gateway } + - { id: orders, title: Orders } + - { id: db, title: Postgres, kind: db } +edges: + - { from: gw, to: orders } + - { from: orders, to: db, label: query } +``` + +An unknown style name warns in the diagnostics and renders the document with its own style unchanged. + +## Render a diagram from a file — `beck:symbol` + +When a diagram should appear as both highlighted source and a live render, keep it in one `.beck.yaml` file and reference it from two fences — the same DRY convention as the tree-sitter `:symbol` source embeds: + +````markdown +```yaml:symbol +diagrams/checkout.beck.yaml +``` + +```beck:symbol +diagrams/checkout.beck.yaml +``` +```` + +The fence body is one file path per line; each file renders as its own diagram, and one malformed file shows its own error box without dropping the rest. Paths resolve against `BeckOptions.ContentRoot`, which defaults to the working directory — set it to match your tree-sitter `ContentRoot` so both `:symbol` forms address files the same way: + +```csharp +builder.Services.AddPenningtonBeck(beck => +{ + beck.ContentRoot = "../.."; +}); +``` + +## Configure the render + +`BeckOptions.RenderOptions` is the base `SvgRenderOptions` applied to every fence: fonts, an exact text measurer, a site-wide default style, custom style registrations. By default Beck measures text with its embedded font-metrics tables and emits a `textLength` guard on every label, so a font mismatch compresses glyphs slightly instead of breaking layout. To size cards against your site's exact fonts, reference the optional `Beck.Skia` package and supply a measurer: + +```csharp +var font = new BeckFontSpec +{ + Family = "Lexend", + Files = new Dictionary { [400] = "fonts/Lexend-Regular.ttf" }, +}; + +builder.Services.AddPenningtonBeck(beck => +{ + beck.RenderOptions = new SvgRenderOptions + { + Font = font, + Measurer = new SkiaTextMeasurer(font), + }; +}); +``` + +## Fullscreen zoom + +Each rendered embed carries a zoom button — visible on hover, always visible on touch — that opens the diagram in a full-screen lightbox over a dimmed backdrop. Click anywhere or press Escape to close. The package contributes the button, the lightbox script, and their styles automatically; this is its one piece of client JavaScript, and rendering stays server-side. To emit bare SVG with no client behavior, turn it off: + +```csharp +builder.Services.AddPenningtonBeck(beck => +{ + beck.Zoom = false; +}); +``` + +## What the renderer emits + +Each fence renders as `
` — finished HTML that skips the standard code-block chrome. A malformed document fails loud instead of vanishing: the fence renders as `
` showing the offending YAML, and the failure lands in the per-request diagnostics, so it surfaces in the dev overlay and fails the static build report. Frame the wrapper with your own CSS (`DocSiteOptions.ExtraStyles` on a DocSite host); the SVG itself needs nothing. + +## Verify + +- Open a page with a `beck` fence. It renders as an inline SVG, not a code block, and follows the theme toggle between light and dark. +- Break a fence's YAML on purpose. The page shows a bordered box with the raw YAML, and `dotnet run -- diag warnings` reports the render failure. + +## Related + +- How-to: — the client-side Mermaid alternative +- Reference: [Code-block argument reference](xref:reference.markdown.code-block-args) — the full fence info-string grammar +- How-to: — the tree-sitter `:symbol` embeds the `beck:symbol` form mirrors +- The [Beck documentation](https://usepennington.github.io/beck/) — YAML grammar, diagram types, styles, and the C# authoring API diff --git a/docs/Pennington.Docs/Content/how-to/rich-content/client-side-widget.md b/docs/Pennington.Docs/Content/how-to/rich-content/client-side-widget.md index abe21611..591a582e 100644 --- a/docs/Pennington.Docs/Content/how-to/rich-content/client-side-widget.md +++ b/docs/Pennington.Docs/Content/how-to/rich-content/client-side-widget.md @@ -2,7 +2,7 @@ title: "Ship a custom client-side widget" description: "Add browser behavior to a static Pennington site by composing a server-rendered Mdazor component, your own script, and the head seam that loads a CDN library — built here as an image-gallery lightbox." uid: how-to.rich-content.client-side-widget -order: 6 +order: 7 sectionLabel: "Rich Content" tags: [client-side, javascript, mdazor, components, spa] --- diff --git a/docs/Pennington.Docs/Content/how-to/rich-content/content-tabs.md b/docs/Pennington.Docs/Content/how-to/rich-content/content-tabs.md index 50854aa7..15fbcd94 100644 --- a/docs/Pennington.Docs/Content/how-to/rich-content/content-tabs.md +++ b/docs/Pennington.Docs/Content/how-to/rich-content/content-tabs.md @@ -2,7 +2,7 @@ title: "Tab platform or language variants together" description: "Wrap whole sections of prose, code, and lists in DocFX-style content tabs — synced page-wide, with dependent tabs for a second axis." uid: how-to.rich-content.content-tabs -order: 5 +order: 6 sectionLabel: "Rich Content" tags: [authoring, markdown, tabs, content-tabs] --- diff --git a/docs/Pennington.Docs/Content/how-to/rich-content/structured-data-custom-types.md b/docs/Pennington.Docs/Content/how-to/rich-content/structured-data-custom-types.md index 0deca735..b38914f3 100644 --- a/docs/Pennington.Docs/Content/how-to/rich-content/structured-data-custom-types.md +++ b/docs/Pennington.Docs/Content/how-to/rich-content/structured-data-custom-types.md @@ -2,7 +2,7 @@ title: "Add a custom schema.org JSON-LD type" description: "Define a record that subclasses JsonLdEntity, attribute its properties for System.Text.Json, and either let the front matter own it via IHasStructuredData or render it inline from a Razor page." uid: how-to.rich-content.structured-data-custom-types -order: 4 +order: 5 sectionLabel: "Rich Content" tags: [seo, structured-data, json-ld, schema-org] --- diff --git a/docs/Pennington.Docs/Content/how-to/rich-content/ui-components-in-markdown.md b/docs/Pennington.Docs/Content/how-to/rich-content/ui-components-in-markdown.md index 0047559e..ddf196e6 100644 --- a/docs/Pennington.Docs/Content/how-to/rich-content/ui-components-in-markdown.md +++ b/docs/Pennington.Docs/Content/how-to/rich-content/ui-components-in-markdown.md @@ -2,7 +2,7 @@ title: "Drop a Razor component into a markdown page" description: "Embed Pennington.UI components (and your own Razor components) inside a `.md` file through Mdazor-backed rendering." uid: how-to.rich-content.ui-components-in-markdown -order: 3 +order: 4 sectionLabel: "Rich Content" tags: [authoring, components, mdazor, razor] --- diff --git a/docs/Pennington.Docs/Pennington.Docs.csproj b/docs/Pennington.Docs/Pennington.Docs.csproj index 096f5dd4..17178eb8 100644 --- a/docs/Pennington.Docs/Pennington.Docs.csproj +++ b/docs/Pennington.Docs/Pennington.Docs.csproj @@ -22,17 +22,13 @@ + + - - - - + + + diff --git a/tests/Pennington.Tests/Beck/BeckCodeBlockPreprocessorTests.cs b/tests/Pennington.Tests/Beck/BeckCodeBlockPreprocessorTests.cs new file mode 100644 index 00000000..f52921a6 --- /dev/null +++ b/tests/Pennington.Tests/Beck/BeckCodeBlockPreprocessorTests.cs @@ -0,0 +1,160 @@ +using Microsoft.AspNetCore.Http; +using Pennington.Beck; + +namespace Pennington.Tests.Beck; + +/// +/// Tests for — fence detection, inline and +/// :symbol rendering, flag parsing, and the loud-failure error box. +/// +public class BeckCodeBlockPreprocessorTests +{ + private const string ValidYaml = """ + type: architecture + nodes: + - { id: a, title: Alpha } + - { id: b, title: Beta } + edges: + - { from: a, to: b } + """; + + private static BeckCodeBlockPreprocessor CreatePreprocessor(Action? configure = null) + { + var options = new BeckOptions(); + configure?.Invoke(options); + return new BeckCodeBlockPreprocessor(options, new HttpContextAccessor()); + } + + [Theory] + [InlineData("csharp")] + [InlineData("csharp:symbol")] + [InlineData("yaml")] + [InlineData("beckish")] + public void TryProcess_NonBeckFence_ReturnsNull(string languageId) + { + var result = CreatePreprocessor().TryProcess(ValidYaml, languageId); + + result.ShouldBeNull(); + } + + [Fact] + public void TryProcess_InlineFence_RendersSvgOwningTheWholeOutput() + { + var result = CreatePreprocessor().TryProcess(ValidYaml, "beck"); + + result.ShouldNotBeNull(); + result.HighlightedHtml.ShouldContain(" o.ContentRoot = root); + + var result = preprocessor.TryProcess("diagram.beck.yaml", "beck:symbol"); + + result.ShouldNotBeNull(); + result.HighlightedHtml.ShouldContain(" o.ContentRoot = Path.GetTempPath()); + + var result = preprocessor.TryProcess("does-not-exist.beck.yaml", "beck:symbol"); + + result.ShouldNotBeNull(); + result.HighlightedHtml.ShouldContain("beck-embed--error"); + } + + [Fact] + public void TryProcess_ZoomOnByDefault_EmitsZoomButtonAfterSvg() + { + var result = CreatePreprocessor().TryProcess(ValidYaml, "beck"); + + result.ShouldNotBeNull(); + result.HighlightedHtml.ShouldContain("class=\"beck-zoom\""); + } + + [Fact] + public void TryProcess_ZoomDisabled_OmitsZoomButton() + { + var result = CreatePreprocessor(o => o.Zoom = false).TryProcess(ValidYaml, "beck"); + + result.ShouldNotBeNull(); + result.HighlightedHtml.ShouldNotContain("beck-zoom"); + } + + [Fact] + public void TryProcess_MalformedYaml_ErrorBoxHasNoZoomButton() + { + var result = CreatePreprocessor().TryProcess("nodes: [ +/// Tests for — the gate, +/// the contributed lightbox assets, and its registration by +/// . +/// +public class BeckZoomHeadContributorTests +{ + private static HeadContext CreateContext() => + new() { HttpContext = new DefaultHttpContext(), FullPath = "/docs/page" }; + + [Fact] + public void ShouldContribute_ZoomOn_IsTrue() + { + var contributor = new BeckZoomHeadContributor(new BeckOptions()); + + contributor.ShouldContribute(CreateContext()).ShouldBeTrue(); + } + + [Fact] + public void ShouldContribute_ZoomOff_IsFalse() + { + var contributor = new BeckZoomHeadContributor(new BeckOptions { Zoom = false }); + + contributor.ShouldContribute(CreateContext()).ShouldBeFalse(); + } + + [Fact] + public async Task ContributeAsync_AddsOneKeyedRawTagWithLightboxAssets() + { + var contributor = new BeckZoomHeadContributor(new BeckOptions()); + var head = new HeadBuilder(); + + await contributor.ContributeAsync(CreateContext(), head); + + var entries = head.Build(); + var entry = entries.ShouldHaveSingleItem(); + entry.Key.ShouldBe(new HeadTagKey("beck:zoom")); + var raw = entry.Tag.Value.ShouldBeOfType(); + raw.Html.ShouldContain("beck-lightbox"); + raw.Html.ShouldContain(".beck-zoom"); + } + + [Fact] + public void AddPenningtonBeck_RegistersTheZoomContributor() + { + var services = new ServiceCollection(); + + services.AddPenningtonBeck(); + + services.ShouldContain(d => + d.ServiceType == typeof(IHeadContributor) + && d.ImplementationType == typeof(BeckZoomHeadContributor)); + } +} diff --git a/tests/Pennington.Tests/Pennington.Tests.csproj b/tests/Pennington.Tests/Pennington.Tests.csproj index badc22a1..aa790385 100644 --- a/tests/Pennington.Tests/Pennington.Tests.csproj +++ b/tests/Pennington.Tests/Pennington.Tests.csproj @@ -24,6 +24,7 @@ +