ForgeTrust.AppSurface.Docs
Source of truth
Documentation site generation and hosting for AppSurface web applications.
Overview
ForgeTrust.AppSurface.Docs is the reusable Razor Class Library package behind the RazorDocs experience. It aggregates Markdown and C# API documentation into a browsable docs UI, supports an optional version archive for published releases, and is intended to be embedded into AppSurface web applications or used by the standalone RazorDocs host.
If you are evaluating RazorDocs for your own repository, start with Use RazorDocs in your repository. That page explains the consumer model, host shape, authoring metadata, and adoption checklist before you drill into this package reference.
What It Provides
RazorDocsWebModulefor wiring the docs UI into an AppSurface web hostAddRazorDocs()for typed options binding and core service registrationDocAggregatorplus the built-in Markdown and C# harvesters, including structured harvest health diagnostics- Search UI assets, page-local outline behavior, and the
/docsMVC surface used by RazorDocs consumers DocsUrlBuilderplus the MVC surface used by RazorDocs consumers so the live docs root, search shell, and archive routes stay in one shared contractRazorDocsVersionCatalogplusRazorDocsVersionCatalogServicefor mounting exact published release trees and surfacing release-level status in the public archive- Structured trust metadata plus a built-in trust bar for release notes, upgrade guides, and other pages that need status and provenance near the top
- Contributor provenance rendering with a
Source of truthstrip for source links, edit links, and relativeLast updatedtimestamps on details pages - Precompiled Tailwind-powered styling with layout-time path resolution for root-module and embedded hosts
Styling Boundary
When choosing where a new RazorDocs style should live, use this order:
- If the surface needs a reusable component contract or a selector shared across CSS and JavaScript, use a semantic class.
- Otherwise, if RazorDocs does not fully control the nested content markup, use wrapper-scoped semantic CSS.
- Otherwise, for one-off package chrome that RazorDocs owns directly, prefer Tailwind utility classes in markup.
This section is the normative source of truth for the boundary. DESIGN.md explains why the rule exists and how to review edge cases. ROADMAP.md only points future work back to this contract.
Decision Matrix
| Surface | Default | Why | Real examples | Exception / note |
|---|---|---|---|---|
| One-off owned package chrome in Razor views | Prefer Tailwind utility classes in markup | RazorDocs fully owns the markup, so local utility classes keep intent obvious where the change happens | docs landing shell in Views/Docs/Index.cshtml, sidebar shell and layout framing in Views/Shared/_Layout.cshtml, one-off page header spacing in Views/Docs/Details.cshtml |
If the same styling contract repeats across package surfaces, promote it to a semantic component class instead of copying long utility strings |
| Reusable owned package components or stable cross-file UI selectors | Use semantic component classes in the shared package stylesheet | Shared selectors keep repeated UI stable across Razor, CSS, and sometimes JavaScript | docs-page-badge, docs-metadata-chip, docs-page-meta, docs-provenance-strip, docs-trust-bar, and docs-outline-* in wwwroot/css/app.css |
Utilities can still handle surrounding layout and one-off placement |
| Harvested or generated document bodies that RazorDocs does not fully author element by element | Use wrapper-scoped semantic CSS such as .docs-content ... in the shared package stylesheet |
RazorDocs cannot safely push utility classes into nested harvested HTML | headings, paragraphs, code blocks, overload groups, and namespace sections inside .docs-content in Views/Docs/Details.cshtml and wwwroot/css/app.css |
Do not rewrite harvested nested HTML just to satisfy utility-class purity |
| JavaScript-generated or stateful UI that needs CSS and JavaScript to share stable hooks | Use semantic hook classes, then style them in CSS | Runtime UI needs stable names both the stylesheet and script can rely on | search result rows, filter chips, active-filter pills, and state containers in wwwroot/docs/search.css and wwwroot/docs/search-client.js |
Use id values where uniqueness or ARIA wiring require them, but keep reusable styling and state contracts on semantic classes |
Common Calls
- New one-off page header spacing or typography in owned Razor markup: use Tailwind utilities in the view.
- New reusable badge, metadata chip, page metadata row, trust/provenance surface, or page-local outline state: add or extend a semantic component class in
wwwroot/css/app.css, then use utilities around it only when they are purely local. - For
Views/Docs/Search.cshtml, keep the stateful search container or interactive hook semantic, but use local utilities for one-off header copy, helper layout, and fallback-link chrome inside that view. - Restyling paragraphs, headings, or code blocks inside
.docs-content: update wrapper-scoped CSS instead of pushing utility classes into harvested HTML. - Markdown pages with long-form prose use
.docs-content--markdownfor prose measure, paragraph rhythm, list spacing, links, blockquotes, and inline code. Generated non-Markdown docs and docs marked withpage_type: apiorpage_type: api-referenceuse.docs-content--apiso signatures and reference tables keep the wider base content measure. - New search filter pill, active-filter surface, or other stateful search UI: use a semantic hook class because CSS and JavaScript both need to recognize it.
Stylesheet Responsibilities
wwwroot/css/app.cssis the Tailwind entry point for the generated package stylesheet (site.gen.css). It owns shared RazorDocs component primitives and wrapper-scoped document body styling because the generated stylesheet is loaded on every docs page before any search-specific assets.wwwroot/docs/search.cssowns the search shell, interactive search controls, JavaScript-rendered result states, empty/failure states, and search skeletons. It should not define shared page badges, metadata chips, provenance strips, trust bars, or other primitives required by non-search docs pages.
Internal Style Tokens
wwwroot/css/app.css declares RazorDocs' shared dark-slate style tokens on :root with --docs-* custom properties. These tokens describe the current flagship visual system: slate surfaces, muted borders, readable text, cyan accents, focus rings, active fills, code chrome, table chrome, and skeleton treatments.
The tokens are internal package implementation details. They ship in browser CSS because RazorDocs CSS ships to the browser, but hosts should not treat them as a supported override API yet. Future theming work can promote a documented public contract once the host customization model is designed.
Use tokens when a value is either:
- repeated across two or more unrelated selector groups
- part of a documented repeated state such as focus, active selection, muted text, default border, raised surface, code chrome, table chrome, or skeleton loading
Leave a raw literal local when naming it would lie about its scope. Allowed local categories are:
- syntax-highlight token colors such as keyword, string, comment, number, type, member, operator, inserted, and deleted spans
- API signature token colors used only to distinguish return values, parameters, modifiers, literals, and similar generated reference fragments
- one-off semantic page-type badge variants such as example, API/reference, glossary, FAQ, internals, and troubleshooting
- browser or generated-content details that do not represent a reusable design primitive
Do not add broad fallbacks such as var(--docs-color-text-default, #e2e8f0) unless a generated asset, load-order, or host-embedding test proves the fallback is needed. The package-owned shared stylesheet should consume the internal tokens directly. wwwroot/docs/search.css is the exception: exact published release trees are allowed to carry only search.css as their required CSS asset, so it defines --docs-search-* fallback aliases that read the shared --docs-* tokens when available and preserve a self-contained search UI when the generated package stylesheet is absent.
Terms
- Package chrome: one-off layout and presentation markup that RazorDocs owns directly, such as page shells, spacing, and framing.
- Harvested content: nested documentation HTML that RazorDocs renders but does not fully author element by element, such as the body inside
.docs-content. - Markdown prose surface: authored Markdown rendered with
.docs-content--markdown; it optimizes for reading dense release notes, guides, and README-style pages rather than for wide API signatures in generated reference docs. - Stable selector / hook: a semantic class or required unique
idthat Razor, CSS, accessibility wiring, and sometimes JavaScript rely on consistently across files.
Pitfalls
- Do not refactor between utilities and semantic CSS for purity alone. Follow the surface contract unless a real usability or maintainability problem exists.
- Do not treat required
idvalues, such asdocs-search-page-inputordocs-search-page-filters-panel, as the reusable styling contract. They exist for uniqueness, targeting, and ARIA relationships. - Do not assume every child inside a semantic search container needs its own semantic class; local typography and spacing inside one view can still stay inline.
- Do not add semantic classes to static package chrome when plain utilities are clearer and the styling is truly local.
- Do not place non-search primitives in
wwwroot/docs/search.cssjust because the layout loads search assets globally today. Usewwwroot/css/app.cssfor shared components so future theming can target one stable package layer. - Do not introduce new hardcoded slate/cyan literals inside shared selector groups. Add or reuse a
--docs-*token instead. - Do not move syntax-highlight colors into the shared token layer until RazorDocs has a public code-theme story. Code block chrome can use shared tokens; syntax spans stay local.
Details Page Heading Ownership
RazorDocs details pages render the page title in the package-owned shell for authored Markdown pages. The title comes from DocDetailsViewModel.Title, which resolves metadata title first, then a leading Markdown H1, then the harvested file or folder fallback.
Because the shell already owns the semantic page H1, Views/Docs/Details.cshtml suppresses only a leading rendered Markdown <h1> from the harvested body before writing .docs-content. This keeps source Markdown portable for GitHub and editor previews, where a top # Title is still useful, without showing duplicate page headings in RazorDocs.
The suppression is intentionally narrow:
- It runs only when the details shell renders the H1. C# API reference pages keep their harvested body heading because the shell hides its top H1 for generated API content.
- It removes only the first body element when that element is an H1. Later H1 elements remain visible because they are body structure, not duplicated chrome.
- Namespace README intros apply the same rule before the README HTML is wrapped in
.doc-namespace-intro, so# Namespacestays useful in source while the generated namespace shell remains the only page H1. - For ordinary Markdown pages, suppression happens at render time.
DocNode.Content, search extraction, and outline generation still see the harvested document as produced by the harvester. - A leading Markdown H1 still participates in title resolution when explicit metadata
titleis absent, so README-style pages keep their authored title in the shell after the body H1 is suppressed.
Pitfall: do not work around duplicate headings by removing the source # Title from README-style pages. That makes the file worse outside RazorDocs. Let the RazorDocs shell suppress the rendered duplicate instead.
Syntax-highlighted code blocks
RazorDocs renders fenced Markdown code blocks during Markdown harvest. Supported languages are highlighted server-side, so normal docs pages and exported docs do not need client-side Prism, highlight.js, or Shiki initialization after navigation.
The v1 contract is RazorDocs-owned HTML:
HTML<pre class="doc-code doc-code--highlighted doc-code--language-csharp language-csharp"><span class="doc-code__language">C#</span><code>...</code></pre>
Plain fallback uses the same shape with doc-code--plain. Token spans, when present, use doc-token plus semantic modifiers such as doc-token--keyword, doc-token--string, doc-token--comment, doc-token--number, doc-token--type, doc-token--member, doc-token--operator, and doc-token--punctuation. These classes are internal RazorDocs output in v1. They are stable enough for the package stylesheet and tests, but they are not a public custom highlighter API.
Language aliases
RazorDocs uses the first whitespace-delimited code-fence info token as the language. Metadata after the language is ignored in v1, so ```csharp {2} is treated as csharp without activating line markers.
| Authored token | Normalized language |
|---|---|
cs, c#, csharp |
csharp |
razor, cshtml |
razor |
xml |
xml |
json |
json |
bash, sh, shell |
bash |
html |
html |
css |
css |
js, javascript |
javascript |
md, markdown |
markdown |
diff |
diff |
txt, text, plaintext |
plaintext |
Supported normalized languages render highlighted output when the bundled TextMateSharp grammar loads successfully. plaintext, unsupported languages, unknown languages, grammar failures, tokenization failures, and blocks above RazorDocs' internal size threshold render as escaped plaintext with the same quiet code-block treatment. A correct plain block is preferred over fake highlighting.
Authoring pitfalls
- Do not paste raw HTML token spans into Markdown code fences. RazorDocs owns token markup.
- Do not rely on automatic language detection. Add the language token explicitly when highlighting matters.
- Do not assume every language alias supports custom semantics beyond normalization.
- Do not use Shiki or Expressive Code line-marker syntax yet. V1 ignores code-fence metadata after the language.
- Do not style highlighter output outside the RazorDocs package stylesheet. Code block styling belongs under
.docs-contentinwwwroot/css/app.css.
Harvest Health
DocAggregator.GetHarvestHealthAsync(CancellationToken) returns structured health for the same cached harvest snapshot used by docs pages, public sections, and the search index. Hosts should use this API when they need to report whether source-backed docs are healthy, empty by configuration, partially degraded, or unavailable because every harvester failed.
C#var health = await docAggregator.GetHarvestHealthAsync(ct);
if (health.Status is DocHarvestHealthStatus.Failed or DocHarvestHealthStatus.Degraded)
{
foreach (var diagnostic in health.Diagnostics)
{
var logLevel = diagnostic.Severity switch
{
DocHarvestDiagnosticSeverity.Information => LogLevel.Information,
DocHarvestDiagnosticSeverity.Warning => LogLevel.Warning,
DocHarvestDiagnosticSeverity.Error => LogLevel.Error,
DocHarvestDiagnosticSeverity.Critical => LogLevel.Critical,
_ => LogLevel.Warning
};
logger.Log(
logLevel,
"RazorDocs harvest diagnostic {Code}: {Problem} {Fix}",
diagnostic.Code,
diagnostic.Problem,
diagnostic.Fix);
}
}
The returned DocHarvestHealthSnapshot includes:
Status: the aggregateDocHarvestHealthStatus.GeneratedUtc: the timestamp for the cached snapshot generation.RepositoryRoot: the resolved source root passed to harvesters. Treat this as server-only operational data; redact or omit it before forwarding harvest health to client-visible UI or public APIs.TotalHarvesters,SuccessfulHarvesters, andFailedHarvesters: counts for the configured harvesters.TotalDocs: the number of documentation nodes in the final cached docs snapshot after RazorDocs post-processing.Harvesters: oneDocHarvesterHealthentry per configured harvester, including its concrete type name,DocHarvesterHealthStatus, raw returned doc count, and optional diagnostic.Diagnostics: structuredDocHarvestDiagnosticentries for harvester-level and aggregate states. RazorDocs-created snapshots never expose raw exception messages in diagnostics; exception details stay in host logs.
Status Contract
DocHarvestHealthStatus is intentionally distinct from HTTP or process health:
Healthy: at least one configured harvester returned documentation and no harvester failed.Empty: harvesting completed without failures, but the final docs corpus is empty. This can be valid for an empty repository, a disabled source set, or a host with no registered harvesters.Degraded: at least one harvester succeeded or returned a valid empty result while another failed, timed out, or canceled. Docs remain usable, but the corpus may be incomplete.Failed: every configured harvester failed, timed out, or canceled. RazorDocs returns an empty corpus for compatibility, but the snapshot should be treated as an operational failure.
DocHarvesterHealthStatus describes each source contribution:
Succeeded: the harvester returned one or more docs.ReturnedEmpty: the harvester completed without error and returned no docs.Failed: the harvester threw while scanning.TimedOut: the harvester exceeded RazorDocs' per-harvester timeout budget.Canceled: the harvester observed cancellation outside RazorDocs' timeout budget.
The public enum numeric values are stable compatibility contracts for consumers that persist, serialize, bind, or compare them. New members may be added later, but existing values must not be reordered or renumbered.
Diagnostics
Each DocHarvestDiagnostic has a stable Code, Severity, optional HarvesterType, operator-facing Problem, likely Cause, and suggested Fix. Use diagnostic codes for tests, dashboards, and host UI branching instead of parsing log messages.
RazorDocs currently emits these codes:
DocHarvestDiagnosticCodes.HarvesterTimedOut(razordocs.harvest.harvester_timed_out)DocHarvestDiagnosticCodes.HarvesterCanceled(razordocs.harvest.harvester_canceled)DocHarvestDiagnosticCodes.HarvesterFailed(razordocs.harvest.harvester_failed)DocHarvestDiagnosticCodes.NoHarvesters(razordocs.harvest.no_harvesters)DocHarvestDiagnosticCodes.AllFailed(razordocs.harvest.all_failed)DocHarvestDiagnosticCodes.DocReservedRouteCollision(razordocs.routes.reserved_collision)DocHarvestDiagnosticCodes.DocRouteCollision(razordocs.routes.doc_collision)DocHarvestDiagnosticCodes.DocRedirectAliasCollision(razordocs.routes.redirect_alias_collision)DocHarvestDiagnosticCodes.DocInvalidCanonicalSlug(razordocs.routes.invalid_canonical_slug)DocHarvestDiagnosticCodes.DocInvalidRedirectAlias(razordocs.routes.invalid_redirect_alias)DocHarvestDiagnosticCodes.DocLossySlugNormalization(razordocs.routes.lossy_slug_normalization)
An all-failed snapshot logs one critical message when that snapshot is generated. Reusing the cached health snapshot does not log again. Calling InvalidateCache() and then reading docs or harvest health can generate a new snapshot and, if every harvester still fails, a new critical log entry.
Cancellation and Caching
GetHarvestHealthAsync(cancellationToken) observes caller cancellation only while the caller waits for the memoized snapshot. Canceling that wait does not cancel, poison, or evict the shared snapshot computation. A later caller can still receive the completed snapshot.
Health and docs are computed from the same cached snapshot. This is deliberate: a host that reads GetDocsAsync() and then GetHarvestHealthAsync() sees health for the docs it is serving, not a second harvest with different timing or failures. Use InvalidateCache() when an operator explicitly asks RazorDocs to refresh source-backed docs.
Operator Health Routes
RazorDocs reserves a redacted operator health page at {DocsRootPath}/_health and a machine-readable JSON endpoint at {DocsRootPath}/_health.json ahead of the docs catch-all route. Both endpoints return health responses by default only when the host environment is Development; otherwise they return 404. Non-development hosts must opt in with RazorDocs:Harvest:Health:ExposeRoutes=Always.
The JSON response uses the camelCase wire form of RazorDocsHarvestHealthResponse:
status:Healthy,Empty,Degraded, orFailed.verification.ok:trueforHealthyandEmpty;falseforDegradedandFailed.verification.httpStatusCode:200forHealthyandEmpty;503forDegradedandFailed.generatedUtc, harvester counts, total docs, per-harvester status, and redacted diagnostics.
The response omits RepositoryRoot, diagnostic Cause, raw exception messages, stack traces, and absolute filesystem paths. Health routes set Cache-Control: no-store, no-cache so local and CI checks do not pass or fail on stale operator data.
The sidebar health entry follows RazorDocs:Harvest:Health:ShowChrome, which is independent from route exposure. This lets a host expose _health.json for a script without advertising the health page in the docs chrome, or show status-only chrome while the reserved health endpoints still return 404. When chrome is visible but ExposeRoutes hides responses for the current environment, RazorDocs renders a non-clickable status chip instead of a link.
JSON{
"RazorDocs": {
"Harvest": {
"Health": {
"ExposeRoutes": "DevelopmentOnly",
"ShowChrome": "DevelopmentOnly"
}
}
}
}
Allowed exposure values are DevelopmentOnly, Always, and Never. If you set ExposeRoutes=Always, the reserved health endpoints become an operator surface in that environment. Protect them with host-owned authentication, authorization, or network controls when they are reachable by untrusted users.
Pitfalls
- Do not parse logs to infer harvest health. Use
GetHarvestHealthAsync()and diagnostic codes. - Do not treat
Emptyas a failure. It means RazorDocs found no docs without a failed harvester. - Do not expect raw exception details in public diagnostics. Use host logs for stack traces and exception messages.
- Do not assume the health routes are ASP.NET Core
IHealthCheckendpoints. They report documentation harvest health, not whole-application liveness. - Do not set
ExposeRoutes=Alwayson a public host without host-owned protection.
Strict Startup Failure
Set RazorDocs:Harvest:FailOnFailure to true when a host should fail during startup if the cached harvest-health snapshot is DocHarvestHealthStatus.Failed.
JSON{
"RazorDocs": {
"Harvest": {
"FailOnFailure": true
}
}
}
Strict mode is built for CI and export hosts that publish docs artifacts. It prevents an all-failed harvest from becoming an empty or untrustworthy release tree. Leave it off for general public runtime hosts unless failing the whole application is the right operational posture for that host.
The startup preflight calls DocAggregator.GetHarvestHealthAsync(CancellationToken) and reuses the normal cached docs snapshot. It does not run a second harvester pipeline. Healthy, Empty, and Degraded snapshots continue startup; only aggregate Failed throws RazorDocsHarvestFailedException.
RazorDocsHarvestFailedException exposes a redacted DocHarvestFailureSummary with status, counts, timestamp, and diagnostic code/severity/problem/fix fields. It omits RepositoryRoot, raw exception messages, stack traces, and diagnostic Cause text. Host logs can still contain lower-level harvester diagnostics because those logs are operator data, not public exception payload.
Configuration
RazorDocs is still source-backed at runtime in this slice. RazorDocs:Mode should stay Source, and RazorDocs:Bundle remains reserved for a later reusable runtime-bundle host. Versioning in this slice does not change the runtime source mode; it adds a catalog that mounts already-exported release trees beside the live source-backed preview surface.
Source-backed docs without versioning
Use the default single-surface configuration when you want the live docs experience rooted directly at /docs:
JSON{
"RazorDocs": {
"Mode": "Source",
"CacheExpirationMinutes": 5,
"Source": {
"RepositoryRoot": "/path/to/repo"
}
}
}
If RazorDocs:Source:RepositoryRoot is omitted, the package falls back to repository discovery from the app content root.
To host the same live source surface somewhere else, set the route-family root. With versioning disabled, the live docs root defaults to the route root:
JSON{
"RazorDocs": {
"Routing": {
"RouteRootPath": "/foo/bar"
}
}
}
That configuration serves the live docs home at /foo/bar, search at /foo/bar/search, and the live search index at /foo/bar/search-index.json.
Source-backed docs with published-version routing
Enable versioning when you want the host to keep serving the live unreleased snapshot from source while also mounting exact published release trees under stable public routes:
JSON{
"RazorDocs": {
"Mode": "Source",
"Source": {
"RepositoryRoot": "/path/to/repo"
},
"Routing": {
"RouteRootPath": "/foo/bar",
"DocsRootPath": "/foo/bar/next"
},
"Versioning": {
"Enabled": true,
"CatalogPath": "artifacts/razordocs/versions.json"
}
}
}
The route-family root owns the stable entry alias, archive, and exact release routes. The docs root owns the live source-backed preview. In the example above, /foo/bar is the recommended-release alias, /foo/bar/versions is the archive, /foo/bar/v/{version} serves immutable release trees, and /foo/bar/next remains the live preview.
Route contract
RazorDocs keeps two roots on purpose:
RazorDocs:Routing:RouteRootPathis the route-family root. It owns the stable entry alias, public archive, and exact-version release routes.RazorDocs:Routing:DocsRootPathis the live source-backed docs root. It owns current docs pages, the current search shell, and the currentsearch-index.json.
Default routing:
| Configuration | Route root | Live docs root | Archive | Exact versions |
|---|---|---|---|---|
| Versioning off, no routing config | /docs |
/docs |
/docs/versions |
/docs/v/{version} |
| Versioning on, no routing config | /docs |
/docs/next |
/docs/versions |
/docs/v/{version} |
Versioning off, RouteRootPath=/foo/bar |
/foo/bar |
/foo/bar |
/foo/bar/versions |
/foo/bar/v/{version} |
Versioning on, RouteRootPath=/foo/bar |
/foo/bar |
/foo/bar/next |
/foo/bar/versions |
/foo/bar/v/{version} |
Versioning on, RouteRootPath=/ |
/ |
/next |
/versions |
/v/{version} |
DocsRootPath can be configured explicitly, but RazorDocs does not infer RouteRootPath by stripping /next or any other suffix. If you want versioned docs under /foo/bar, configure RouteRootPath=/foo/bar; setting only DocsRootPath=/foo/bar/next keeps the route family at the default /docs.
RazorDocs registers only the configured docs routes. It does not add a generic {controller}/{action} fallback to the host application, so custom docs roots stay isolated from other modules and application routes.
Route references
Consumers should resolve DocsUrlBuilder and use DocsUrlBuilder.Routes instead of hardcoding route strings:
C#var routes = app.Services.GetRequiredService<DocsUrlBuilder>().Routes;
var home = routes.Home;
var search = routes.Search;
var searchIndexRefresh = routes.SearchIndexRefresh;
var healthJson = routes.HealthJson;
RazorDocsRouteReferences contains Home, Search, SearchIndex, SearchIndexRefresh, Versions, Health, and HealthJson. These values are app-relative. Apply HttpRequest.PathBase, Url.PathBaseAware(...), or the host's equivalent presentation helper only at browser-facing boundaries.
Document route identity
RazorDocs assigns each cached snapshot a route identity catalog. The catalog keeps source identity separate from browser-facing route identity, so authors can keep Markdown source links portable while readers see structured URLs.
Default route behavior:
- Markdown files publish without
.md.html. For example,start-here/appsurface-evaluator.mdpublishes at{DocsRootPath}/start-here/appsurface-evaluator. README.md,README.markdown,index.md, andindex.markdowncollapse to their containing directory. For example,packages/README.mdpublishes at{DocsRootPath}/packages.- Source-shaped Markdown requests for public pages, including copy-pasted paths such as
{DocsRootPath}/packages/README.mdor{DocsRootPath}/guides/start.md, permanently redirect to the clean public route. Collision losers and reserved-route conflicts still stay non-public. - The repository-root README represents the docs home and appears in search as
{DocsRootPath}. It is not rendered through/README.md. - Generated API docs and other non-Markdown docs keep the existing
.htmlroute shape, such as{DocsRootPath}/Namespaces/ForgeTrust.AppSurface.Web.html. - Fragments stay fragments. A harvested source path like
guides/intro.md#setuppublishes as{DocsRootPath}/guides/intro#setup.
RazorDocs reserves document routes that belong to chrome, health, search, sections, versions, and assets. The reserved set includes the docs home, search, search-index.json, _health, _health.json, search.css, search-client.js, outline-client.js, minisearch.min.js, versions, and the sections/ and v/ route prefixes. Docs that resolve to reserved routes remain internally available for source lookup, but they are not public document winners and emit route diagnostics.
Markdown route segments are normalized deterministically: Unicode is folded where possible, non-spacing marks are removed, ASCII letters are lower-cased, dots are preserved, and unsafe separators become hyphens. When that conversion is lossy, RazorDocs emits DocLossySlugNormalization so authors can decide whether to set an explicit route.
Use canonical_slug when the source path is not the right reader-facing URL:
yamltitle: Should I Use AppSurface?
canonical_slug: start-here/evaluator
Use redirect_aliases for deliberate migrations:
yamltitle: Should I Use AppSurface?
canonical_slug: start-here/evaluator
redirect_aliases:
- start-here/appsurface-evaluator
- start-here/appsurface-evaluator.md.html
canonical_slug and redirect_aliases are docs-root-relative route paths. Do not include a query string, fragment, leading docs root, or host name. Canonical slugs use the same deterministic segment normalization as source-derived Markdown routes. Redirect aliases preserve their literal authored route text after separator cleanup, so legacy URLs such as Old_Path/Guide.md.html keep their existing shape instead of being slugified. Aliases redirect permanently to the public canonical route and preserve the request query string. Public Markdown source paths such as /docs/foo.md and /docs/foo.md.html also redirect to the clean route so GitHub-style copy-pasted links recover automatically. Use redirect_aliases for non-source legacy URLs, renamed pages, and old route shapes that are not already implied by the source path. Declared aliases that try to shadow another public Markdown source path are ignored with a DocRedirectAliasCollision diagnostic so copy-pasted source URLs keep pointing at their owning page.
Option reference
RazorDocs:Mode- Keep this at
Sourcein this slice. Bundlestill validates as unsupported because reusable request-time bundle hosting is deferred.
- Keep this at
RazorDocs:Source:RepositoryRoot- Optional absolute or app-relative repository root for source harvesting.
- When omitted, RazorDocs falls back to repository discovery from the content root.
RazorDocs:Harvest:FailOnFailure- Defaults to
false. AddRazorDocs()always registersRazorDocsHarvestFailurePreflightService; this flag controls whether that preflight can fail startup.- When
true, the preflight fails the host withRazorDocsHarvestFailedExceptiononly when aggregate harvest health isFailed. - Use this for release publishing, static export, and CI smoke hosts where publishing empty or untrustworthy docs is worse than a failed build.
- Do not use this expecting
EmptyorDegradedto fail in v1. Empty docs can be intentional, and degraded docs can still be usable.
- Defaults to
RazorDocs:Harvest:Health:ExposeRoutes- Defaults to
DevelopmentOnly. - Controls whether
{DocsRootPath}/_healthand{DocsRootPath}/_health.jsonreturn health responses. - RazorDocs always reserves the endpoint patterns before the docs catch-all route so health URLs do not fall through to document lookup.
Alwaysexposes the responses in non-development environments; protect the endpoints at the host boundary when they are publicly reachable.Neverkeeps the reserved endpoints returning404, including in development.
- Defaults to
RazorDocs:Harvest:Health:ShowChrome- Defaults to
DevelopmentOnly. - Controls whether the built-in sidebar shows health status chrome.
- This is independent from
ExposeRoutesso machine-readable checks and visible docs chrome can be configured separately. - If routes are hidden for the current environment, the sidebar renders status-only chrome without an
href.
- Defaults to
RazorDocs:Routing:RouteRootPath- Controls the route-family root for stable entry, archive, and exact-version routes.
- Defaults to
/docswhen versioning is on. - Defaults to
DocsRootPathwhen versioning is off. - Relative-looking values such as
foo/barare normalized to app-relative paths like/foo/barduringAddRazorDocs()post-configuration. /is supported for single-purpose root-mounted docs hosts.- The path must be app-relative, must not end with
/except for/, cannot contain query or fragment segments, and cannot be a reserved child such as/foo/bar/versionsor/foo/bar/v.
RazorDocs:CacheExpirationMinutes- Controls the absolute lifetime of the shared docs snapshot that backs docs pages, public-section data, and
{DocsRootPath}/search-index.json; for example,/docs/search-index.jsonby default or/docs/next/search-index.jsonwhenRazorDocs:Routing:DocsRootPathis/docs/next. - Defaults to
5minutes. - Must be a finite positive number from
0.016666666666666666through35791394.1, inclusive. - Must map to a whole number of seconds, because
{DocsRootPath}/search-index.jsonuses the same duration for its privateCache-Controlmax-ageheader. - Do not use
0, sub-second values, or extreme values such asdouble.MaxValue; RazorDocs rejects values outside the supported range during options validation.
- Controls the absolute lifetime of the shared docs snapshot that backs docs pages, public-section data, and
RazorDocs:Routing:DocsRootPath- Controls the live source-backed docs root.
- Defaults to the route root when versioning is off.
- Defaults to
{RouteRootPath}/nextwhen versioning is on. - Relative-looking values such as
foo/bar/previeware normalized to app-relative paths like/foo/bar/previewduringAddRazorDocs()post-configuration. /is supported for single-purpose unversioned docs hosts.- The path must be app-relative, must not end with
/except for/, and cannot contain query or fragment segments. - When versioning is on, it cannot equal the route root and cannot use the route root's reserved archive or exact-version children, such as
/foo/bar/versions,/foo/bar/v, or/foo/bar/v/1.2.3.
RazorDocs:Versioning:Enabled- Turns on the published-version route contract and archive surface.
- Does not switch the runtime into bundle mode.
RazorDocs:Versioning:CatalogPath- Required when versioning is enabled.
- Points to the JSON catalog that describes the published exact-version trees and the recommended release alias.
- Relative paths resolve from the app content root.
Published version catalog
The version catalog is the release-level source of truth for version routing and archive presentation:
JSON{
"recommendedVersion": "1.2.3",
"versions": [
{
"version": "1.2.3",
"label": "1.2.3 (Current)",
"summary": "Recommended release for new evaluations and adoption.",
"exactTreePath": "./releases/1.2.3",
"supportState": "Current",
"visibility": "Public",
"advisoryState": "None"
},
{
"version": "1.1.0",
"label": "1.1.0",
"summary": "Supported for teams finishing an upgrade.",
"exactTreePath": "./releases/1.1.0",
"supportState": "Maintained",
"visibility": "Public",
"advisoryState": "Vulnerable"
}
]
}
recommendedVersion- Exact version string that should also be mounted at
RouteRootPath. - Must point at a public, available version or the route-root entry falls back to the archive-style recovery surface.
- Exact version string that should also be mounted at
versions[].version- Exact version identifier such as
1.2.3or1.2.3-rc.1.
- Exact version identifier such as
versions[].label- Optional reader-facing label shown in the archive. Defaults to
version.
- Optional reader-facing label shown in the archive. Defaults to
versions[].summary- Optional archive summary copy.
versions[].exactTreePath- Path to the exported stable docs subtree for one exact release.
- Relative paths resolve from the directory containing the catalog file.
- RazorDocs can mount that same artifact at
RouteRootPathfor the recommended alias and at{RouteRootPath}/v/{version}for the exact release surface.
versions[].supportState- Archive posture badge. Supported values are
Current,Maintained,Deprecated, andArchived.
- Archive posture badge. Supported values are
versions[].visibility- Archive visibility policy. Supported values are
PublicandHidden.
- Archive visibility policy. Supported values are
versions[].advisoryState- Release-level warning badge. Supported values are
None,Vulnerable, andSecurityRisk.
- Release-level warning badge. Supported values are
Exact-version tree contract
Each exactTreePath directory is treated as a prebuilt static subtree for one exact release. It is usually exported from the stable /docs surface, and at minimum it must include:
index.htmlat the tree rootsearch.htmlat the tree rootsearch-index.jsonat the tree root- The payload must remain valid JSON with a top-level
documentsarray so version-local search can load safely. - Every
documents[]entry must include non-empty stringpathandtitleproperties. - Missing or blank
path/titlevalues cause RazorDocs to reject the published release tree during startup validation.
- The payload must remain valid JSON with a top-level
search.cssat the tree root. The bundled search stylesheet carries search-local fallbacks for the shared style tokens so exact release search controls remain styled even when a historical/static export does not includesite.gen.css.search-client.jsat the tree rootoutline-client.jsat the tree root for outline-aware exports whose HTML references the page-local outline runtimeminisearch.min.jsat the tree root- any section, detail, partial, and asset routes that belong to the exported docs surface for that release
RazorDocs does not regenerate these trees at request time. It resolves extensionless requests back to the exported .html files and rewrites stable-root HTML plus search-index.json payloads so the same artifact can serve both the recommended alias and {RouteRootPath}/v/{version} honestly, including custom roots such as /foo/bar. Exporters should validate search-index.json, search.css, search-client.js, minisearch.min.js, and, for outline-aware exports, outline-client.js before publishing because a missing required runtime asset or a malformed search payload keeps that release unavailable or incomplete until the artifact is fixed. The version catalog intentionally does not crawl historical HTML to infer optional outline support; old exact archives stay immutable, and any future modernization should be an explicit rebuild from source into a new self-contained tree. Use the RazorWire CLI or another static-export pipeline to publish those trees ahead of time.
Archive ordering
- The public archive preserves the authored order of
versions[]from the catalog. - Use that list order when you want a specific narrative ordering that differs from plain lexical version sorting.
Availability and failure behavior
- Version validation is best-effort and release-local.
- A missing or malformed
exactTreePathmarks only that release unavailable. - Healthy published versions and the live preview surface continue to load.
- If the configured
recommendedVersionis hidden, missing, or unavailable, RazorDocs does not mount it at the route root; that entry route falls back to the archive-style recovery surface with a link to the live preview.
Pitfalls
- Do not set
RazorDocs:Routing:DocsRootPathto the same value asRouteRootPathwhen versioning is enabled. That collides with the stable published-release alias. - Do not configure only
DocsRootPath=/foo/bar/nextand expect archive routes to move to/foo/bar; setRouteRootPath=/foo/barexplicitly. - Do not point
recommendedVersionat a hidden or broken release tree. - Do not assume
RazorDocs:Versioning:Enabledmeans the runtime can read request-time bundles. This slice still serves the live preview from source and mounts published releases as static trees. - Do not forget
search-index.jsonin an exported release tree. A release without it is intentionally marked unavailable.
RazorDocs:CacheExpirationMinutes is interpreted as minutes. Use shorter values for source-backed development hosts where authors need edits to appear quickly; use longer values for production hosts when harvesters are expensive or the docs corpus changes only during deploys.
Pitfalls:
- Do not set
CacheExpirationMinutesto0to disable caching. RazorDocs rejects zero and negative values because every request would rebuild the docs snapshot and search index. - Do not set tiny positive values below
0.016666666666666666minutes; the search-indexCache-Controlmax-ageheader cannot represent sub-second cache lifetimes. - Do not set fractional-second values such as
0.333minutes. RazorDocs rejects values that cannot round-trip to a whole-secondmax-age. - Do not set huge finite values such as
double.MaxValue. RazorDocs caps the value so the derived search-indexCache-Controlmax-ageremains representable. - The search-index response uses the same duration for its private
Cache-Controlmax-age, so client refresh behavior stays aligned with server-side snapshot reuse. - Manual refresh through
{DocsRootPath}/search-index.json?refresh=1still invalidates the server snapshot generation immediately for authenticated users; it does not change the configured TTL for later entries. For example, whenRazorDocs:Routing:DocsRootPathis/docs/next, use/docs/next/search-index.json?refresh=1.
Contributor Provenance
RazorDocs can render a lightweight Source of truth strip directly under the page title and summary on details pages. The strip is evidence-driven:
View sourcelinks to the authored source when RazorDocs can identify one safely.Edit this pagelinks to an edit surface when the host configures one safely.Last updatedrenders as relative time with an exact machine-readable<time datetime="...">value behind it.
If a page has no trustworthy contributor evidence, RazorDocs omits the strip entirely instead of rendering placeholder copy.
Host configuration
Contributor provenance is configured under RazorDocs:Contributor:
JSON{
"RazorDocs": {
"Mode": "Source",
"Source": {
"RepositoryRoot": "/path/to/repo"
},
"Contributor": {
"Enabled": true,
"DefaultBranch": "main",
"SourceRef": "8b7c6d5",
"SourceUrlTemplate": "https://github.com/forge-trust/AppSurface/blob/{branch}/{path}",
"SymbolSourceUrlTemplate": "https://github.com/forge-trust/AppSurface/blob/{ref}/{path}#L{line}",
"EditUrlTemplate": "https://github.com/forge-trust/AppSurface/edit/{branch}/{path}",
"LastUpdatedMode": "Git"
}
}
}
Field behavior:
Enableddefaults totrue. Set it tofalseto disable all contributor provenance rendering.DefaultBranchis the stable branch or ref used when expanding configured source and edit templates, and the fallback source ref for symbol links.SourceRefis the preferred ref for generated C# API symbol links. Use a commit SHA when the docs build knows one.SourceUrlTemplateandEditUrlTemplatesupport only{branch}and{path}tokens, and configured templates must include{path}so each page expands to its own source or edit target.SymbolSourceUrlTemplatesupports only{path},{line},{branch}, and{ref}for generated C# API symbol links. It must include{path}and{line}.LastUpdatedModesupportsNoneandGit.Noneis the default so hosts opt into git-backed freshness explicitly;Gitresolves freshness from local repository history when a trustworthy source path exists.
Host contract:
- If
Enabledisfalse, RazorDocs skips contributor rendering and does not enforceDefaultBranchor{path}template requirements at startup. - If
EnabledistrueandSourceUrlTemplateorEditUrlTemplateis configured,DefaultBranchis required and RazorDocs fails options validation on startup when it is missing. - If
EnabledistrueandSourceUrlTemplateorEditUrlTemplateis configured, that template must contain{path}. RazorDocs rejects startup when a template would collapse every page to one shared URL. - If
EnabledistrueandSymbolSourceUrlTemplateis configured, the template must contain{path}and{line}, and unsupported{token}placeholders are rejected at startup. If it contains{ref}, RazorDocs usesSourceReffirst and falls back toDefaultBranch; one of those values must be configured. If it contains{branch},DefaultBranchmust be configured. - Templates expand both the branch and normalized source path segment-by-segment, so slash-separated refs stay readable while spaces and other special characters are still URL-escaped safely.
- Git-backed freshness runs during docs snapshot generation, not during view rendering. RazorDocs uses a bounded snapshot-time freshness budget so slow or wedged git lookups degrade to omitted timestamps instead of stretching one timeout across the whole docs corpus. If git is unavailable, shallow, or missing history for a page, RazorDocs omits only
Last updated. - Hosts that want
LastUpdatedMode: Gitin CI or export jobs must provide real history for the docs checkout. For GitHub Actions, useactions/checkoutwithfetch-depth: 0or another checkout shape that preserves commit history for the rendered files.
C# symbol source links
Generated C# API pages can render small Source links beside documented types, enums, method overloads, and properties. These are symbol-level links, not page-level namespace links. Namespace API pages are synthetic and may contain declarations from many files, so RazorDocs only links a generated API symbol when the harvester captured an exact source path and 1-based declaration line for that rendered anchor.
SymbolSourceUrlTemplate is separate from SourceUrlTemplate because symbol links need {line} and page-level Markdown links do not. Prefer {ref} with SourceRef when publishing docs from CI so readers jump to the same code version used to build the docs:
JSON{
"RazorDocs": {
"Contributor": {
"DefaultBranch": "main",
"SourceRef": "8b7c6d5",
"SymbolSourceUrlTemplate": "https://github.com/forge-trust/AppSurface/blob/{ref}/{path}#L{line}"
}
}
}
Custom harvesters can populate DocNode.SymbolSourceProvenance, but RazorDocs only renders links for content that also includes the compatible placeholder emitted by the built-in C# harvester. The current placeholder contract is an implementation detail for generated API HTML:
HTML<span data-razordocs-symbol-source="anchor-id"></span>
RazorDocs expands or removes those placeholders during snapshot generation before HTML sanitization runs. If a placeholder has no safe href, if the source path is not repository-relative, if the line number is invalid, if duplicate placeholders make an anchor ambiguous, or if multiple provenance entries claim the same anchor, RazorDocs omits the symbol link. A missing link is better than a confident wrong line.
When a namespace README is merged into a generated namespace API page, the page-level strip still points to the README source and uses the label Namespace intro source. The generated API symbols on the same page use their own inline Source links.
Page-level overrides
Authors can supply a nested contributor: block in inline Markdown front matter or in a paired sidecar such as page.md.yml:
yamlcontributor:
hide_contributor_info: true
source_path_override: Web/ForgeTrust.AppSurface.Docs/README.md
source_url_override: https://github.com/forge-trust/AppSurface/blob/main/Web/ForgeTrust.AppSurface.Docs/README.md
edit_url_override: https://github.com/forge-trust/AppSurface/edit/main/Web/ForgeTrust.AppSurface.Docs/README.md
last_updated_override: 2026-04-22T23:19:00Z
Field behavior:
hide_contributor_info: truesuppresses the strip entirely for that page.source_path_overridefeeds template expansion and git freshness when the rendered page does not map cleanly toDocNode.Path. It must stay repository-relative; rooted paths and traversal segments are ignored.source_url_overrideandedit_url_overridebypass template generation entirely. RazorDocs accepts only absolutehttp/httpsURLs or root-relative paths for these overrides.last_updated_overridemust stay a real timestamp. RazorDocs renders it through the same relative-time treatment as git-backed freshness.
Automatic versus explicit provenance
RazorDocs is intentionally conservative about automatic provenance:
- Markdown pages use their harvested source path automatically.
- Harvested C# API symbols can get inline source links when
SymbolSourceUrlTemplateis configured, but namespace-synthetic pages do not get one automatic page-level C# source link. - Synthetic or merged pages can still opt into source, edit, or freshness evidence through explicit
contributor:overrides.
This keeps RazorDocs from inventing fake precision for pages that do not have one trustworthy underlying source file.
Pitfalls
- Do not configure source or edit templates without
DefaultBranch. RazorDocs rejects that startup shape because local git state is too brittle to guess from. - Do not configure source or edit templates without
{path}. That shape cannot identify one source file per page, so RazorDocs rejects it at startup. - Do not author free-text freshness copy in the provenance strip. Use
last_updated_overridefor an exact timestamp, and usetrust.freshnessfor broader lifecycle guidance. - Do not expect shallow CI clones to populate
Last updated. RazorDocs degrades safely by omitting freshness when history is unavailable. In GitHub Actions, preferactions/checkoutwithfetch-depth: 0for pages that should surface git-backed freshness. - Do not add
{line}toSourceUrlTemplate; useSymbolSourceUrlTemplatefor symbol links so Markdown page provenance keeps working. - Do not invent additional
SymbolSourceUrlTemplatetokens. RazorDocs rejects unsupported placeholders such as{commit}or{linen}instead of rendering silently broken links. - Do not expect automatic edit links on namespace-synthetic API pages. Symbol links point to source browsing locations, while README-authored namespace intros keep the page-level edit link.
Namespace README Intros
RazorDocs can merge an authored README.md into a generated namespace API page so teams can explain a namespace in prose without replacing the generated symbol list.
Authoring contract
A README qualifies as a namespace intro only when all of these are true:
- The C# API harvester generated a namespace page at
Namespaces/{Dotted.Namespace}. - The authored file is named
README.md. - The README is harvested as a root documentation node, not as a child fragment.
- The README directory resolves to the same dotted namespace as the generated page.
- The path has an explicit docs-owned prefix before the namespace directory, currently a
docs/segment or aNamespaces/segment.
Positive examples:
| README path | Merged target |
|---|---|
docs/ForgeTrust.AppSurface.Web/README.md |
Namespaces/ForgeTrust.AppSurface.Web |
Namespaces/ForgeTrust.AppSurface.Web/README.md |
Namespaces/ForgeTrust.AppSurface.Web |
Negative examples:
| README path | Behavior |
|---|---|
Web/ForgeTrust.AppSurface.Web/README.md |
Stays a package README page. |
src/ForgeTrust.AppSurface.Web/README.md |
Stays a source-adjacent README page if harvested. |
README.md |
Stays the repository-root docs landing source. |
docs/Unknown.Namespace/README.md |
Stays a normal README page unless a generated Namespaces/Unknown.Namespace page exists. |
Merge behavior
- The generated namespace page keeps its
Namespaces/{Dotted.Namespace}route. - README HTML is inserted into the namespace page as the namespace intro.
- A leading README H1 is suppressed during merge because the namespace page shell already renders the page H1.
- The standalone README node is removed after a successful merge so readers do not see duplicate pages.
- README metadata can override the namespace page metadata, but derived Markdown defaults are ignored when they would accidentally replace API-reference classification.
- README-relative links are resolved from the README source path before the standalone README page is removed.
- Links that target the removed README page itself are not rewritten to a published page. Avoid self-links such as
./README.mdinside namespace intros because the standalone README route disappears after merge. - Contributor provenance points at the README source, while symbol-level source links still point at the generated API declarations.
Decision guidance
Use a namespace README when the content is specifically about the namespace API surface: concepts, intended usage, lifecycle notes, or cross-type orientation for that namespace.
Use a package README when the content is about package adoption: installation, package-level configuration, examples, compatibility, and links to broader guides. Package READMEs such as Web/ForgeTrust.AppSurface.Web/README.md and src/ForgeTrust.AppSurface.Web/README.md do not automatically become namespace intros, even when the folder name matches a namespace. That boundary is intentional so package docs do not disappear into API pages by folder-name coincidence.
Future dual-use package and namespace docs should use an explicit opt-in contract instead of reopening implicit path matching. At the product-contract level, a future design could use a front matter flag that names the target namespace, a paired sidecar mapping a README to Namespaces/{Dotted.Namespace}, or a resolver extension point that receives an explicit namespace target. This repository should still prefer sidecar or resolver-based opt-in for authored README.md files because repository and package READMEs are expected to stay portable and free of inline front matter. Any future design should require the namespace name to be authored directly, preserve predictable package-doc behavior, and fail closed when the target namespace page does not exist.
Pitfalls
- Do not move package READMEs under package folders expecting them to merge into namespace pages.
- Do not rely on the final folder name alone. A path needs a docs-owned prefix before the namespace directory.
- Do not expect a README to create a namespace API page. It only merges into a namespace page produced by the C# harvester.
- Do not include README self-links such as
./README.mdin a namespace intro. Link to surviving guide pages, generated namespace anchors, or package docs instead. - Do not use namespace README merging as a general redirect or alias mechanism. Use explicit metadata and link authoring for those behaviors.
Usage
Reference the package and add the module to your AppSurface web application:
C#await WebApp<RazorDocsWebModule>.RunAsync(args);Public Sections
RazorDocs now organizes public documentation around a fixed section-first model instead of a flat directory-first landing.
Built-in sections
Start HereConceptsHow-to GuidesExamplesAPI ReferenceReleasesTroubleshootingInternals
These sections back the current docs home, the sidebar shell, and the dedicated section routes under the current docs surface, for example {DocsRootPath}/sections/{slug}.
nav_group normalization and fallback rules
nav_groupcan explicitly select a built-in public section by canonical label, slug, or alias.- Invalid explicit
nav_groupvalues log a warning and fall back to RazorDocs-derived section assignment instead of creating ad hoc groups. - Markdown docs with no explicit
nav_groupare derived into built-in sections using path and filename heuristics:- repository-root
README.mdand start-like names such asquickstartorgetting-startedfall intoStart Here examples/content falls intoExamplesreleases/content and root changelogs fall intoReleases- concepts, architecture, explanation, and glossary-style paths fall into
Concepts - troubleshooting, faq, debug, and error-oriented paths fall into
Troubleshooting - internal-oriented paths fall into
Internals - anything else falls into
How-to Guides
- repository-root
- API reference content continues to use the canonical
API Referencesection.
API reference sidebar shape
The primary sidebar keeps API Reference collapsed at the package and namespace level. Generated type and member anchors
stay available from the namespace page itself through On this page, source links, and search, but they are not emitted
as nested links in the global left rail. Deeper namespaces nest under the nearest namespace page that exists in the
sidebar and use only their leaf label, so AppSurface.Core can show child links such as Defaults and Extensions
instead of repeating AppSurface.Core.Defaults and AppSurface.Core.Extensions. This keeps large harvested API surfaces
browsable without making readers scan hundreds of symbols before they intentionally open a namespace page.
Configure RazorDocs:Sidebar:NamespacePrefixes when a host wants package names shortened in that rail. For example,
ForgeTrust.AppSurface. turns ForgeTrust.AppSurface.Docs.Services into a Web family heading with
RazorDocs.Services as the namespace link label.
Section routes and landing docs
- The current docs surface exposes section routes such as
{DocsRootPath}/sections/start-here. - Only canonical slugs are served directly; label- or alias-shaped section requests redirect to the canonical section route.
- When a section has an authored landing doc, RazorDocs redirects the section route to that page.
- Sections with visible pages but no landing doc render a grouped fallback section page instead of a dead end.
- Invalid slugs or sections with no public pages render an unavailable section surface with recovery links back to the current docs home and
Start Here.
section_landing
Use section_landing: true on a page to mark it as the authored entry point for its public section.
yamltitle: Start Here
nav_group: Start Here
section_landing: true
summary: Start with the strongest evaluator proof path before drilling into implementation detail.
Field behavior and pitfalls:
- The page must still belong to a valid built-in public section through explicit or derived
nav_group. - If multiple docs in one section set
section_landing: true, RazorDocs keeps the lowestordervalue, then the lowest canonical path, and logs a warning for the others. - A section landing doc can also author
featured_page_groups; RazorDocs uses those reader-intent groups for section-level “next steps” on the detail page and collapses the first resolved rows into section preview links surfaced on the current docs home. HideFromPublicNav = truealways wins. Hidden pages do not appear in section routes, the sidebar, the docs home, or the public search index even if they declare a section or landing status.- Default harvesting excludes test-project directories such as
Tests,Test,*.Tests,*.UnitTests, and*.IntegrationTests. The C# harvester also skipsexamplesdirectories so example README walkthroughs can stay public without publishing generated API reference for example application internals.
Docs Link Authoring
RazorDocs rewrites links inside harvested Markdown so authors can use source-friendly paths while readers stay on public docs-surface routes such as {DocsRootPath}/start-here/appsurface-evaluator with Turbo history support.
Authoring contract
- Link to another harvested doc with its source path, such as
./guide.md,../CHANGELOG.md, or/releases/unreleased.md. - Link to an already public docs route only when the target is a harvested doc, such as
/docs/releases/unreleased,/docs/next/releases/unreleased, or a custom-root equivalent like/foo/bar/releases/unreleased. - Use ordinary site URLs, such as
/privacy.htmlor../status.html, for non-doc pages. RazorDocs leaves those links untouched. - Use browser-facing URLs for metadata fields that render plain anchors without content rewriting, such as
trust.migration.href.
Catalog-backed rewriting
During aggregation, RazorDocs builds a route identity catalog from the harvested documentation nodes. Link rewriting consults that catalog before converting any source or public-looking link into the active docs surface.
This means a link is rewritten only when the target exists in the harvested docs set. A missing ./guide.md, an ambiguous docs route like /docs/missing, or a normal site page like ../privacy.html remains authored as-is instead of being guessed into a broken docs route.
Pitfalls
- Do not rely on file extensions alone. A
.md,.cs, or.htmlsuffix does not make a link a RazorDocs target unless the target was harvested. - If a doc link is not rewritten, first confirm the target file is included by the active harvester and not excluded by directory policy.
- Public docs-surface links are safe for exported docs, but source-relative Markdown links are usually easier to keep portable in GitHub and editor previews.
Landing Curation
RazorDocs can turn the root docs landing into a curated reader-intent surface by reading featured_page_groups from the repository-root README.md metadata.
Authoring contract
featured_page_groups is parsed as part of DocMetadata, so the metadata contract stays page-agnostic. RazorDocs uses those groups in two places:
- the root
README.mdmetadata drives grouped proof-path rows on the current docs home - any authored section landing doc can drive grouped section-level next-step rows and the section preview links shown on the current docs home
Authors can now supply that metadata in either of two places:
- Inline Markdown front matter at the top of the
.mdfile - A paired sidecar YAML file such as
README.md.ymlorREADME.md.yaml
Inline front matter remains the default authoring path for ordinary docs pages. Paired sidecars are the recommended escape hatch for portability-sensitive files such as README.md, where raw front matter renders poorly on GitHub and other plain Markdown surfaces.
yaml# README.md.yml
title: AppSurface
summary: Follow the proof paths that explain what this framework is for and how it composes.
featured_page_groups:
- intent: understand
label: Understand the model
summary: Start here when you need the mental model before choosing an implementation path.
order: 10
pages:
- question: How does composition work?
path: guides/composition.md
supporting_copy: Start with the composition guide before drilling into APIs.
order: 10
- label: See it working
order: 20
pages:
- question: Show me an end-to-end example
path: examples/hello-world/README.md
order: 10Field behavior
intentis the stable group identity. If omitted, RazorDocs derives one fromlabel.labelis the reader-facing group heading. If omitted, RazorDocs title-casesintent.summaryexplains when a reader should choose the group.orderis optional on groups and pages. Lower values sort first, and ties preserve authored order.pagesmust contain the featured destinations for the group. Empty page lists are skipped.questionis the reader-facing label shown on a row. If omitted, RazorDocs falls back to the destination page title.pathaccepts either the source path or canonical docs path for the destination page, including an exact#fragmentsuffix when the card should land on a specific section. RazorDocs normalizes forward-slash and backslash separators during resolution while preserving fragment identifiers, and the same resolver used by page details handles the configured live docs root.supporting_copyis optional landing-only text. If omitted, RazorDocs falls back to the destination page summary.
Author three to five groups for a broad landing page, and one to three pages per group. Prefer plain reader intents such as understand, choose-package, see-it-working, release-risk, and api-reference. Use custom intents when your product has domain-specific decisions that those defaults do not capture.
Preview locally from the repository root with the standalone docs host:
Bashdotnet run --project Web/ForgeTrust.AppSurface.Docs.Standalone -- --urls http://localhost:5189
Or use the AppSurface CLI shape, which keeps RazorDocs workflows under the appsurface command family:
Bashdotnet run --project Cli/ForgeTrust.AppSurface.Cli -- docs --repo . --urls http://localhost:5189
Then open the configured docs home, http://localhost:5189/docs by default. The standalone host remains the reusable runtime seam; appsurface docs is the public CLI entry point for the same preview workflow rather than a separate razordocs tool.
Fallback and visibility rules
- If the root
README.mdis missing, the landing stays on the neutral docs index. - If
featured_page_groupsis missing, the landing uses the neutral docs index unless the Start Here public section can provide the built-in proof-path fallback. - If
featured_page_groups: []is authored inline, the explicit empty list is authoritative and suppresses sidecar fallback. - If both
README.md.ymlandREADME.md.yamlexist for the same Markdown file, RazorDocs logs a warning and ignores both sidecars until the conflict is removed. - If both sidecar metadata and inline front matter define the same field, inline front matter wins and the sidecar acts as fallback metadata only.
- Invalid sidecar YAML logs a warning and falls back to the inline/default metadata path instead of breaking the page harvest.
- If a featured path is missing, hidden from public navigation, or duplicated, RazorDocs skips it and logs a warning.
- If all featured entries are skipped, RazorDocs logs one final warning and falls back instead of rendering broken rows.
- The old flat
featured_pagesfield is ignored and logs a migration warning. If both fields are present,featured_page_groupswins.
Diagnostics include the source file, field path when available, problem, cause, and fix. Common warnings are stale featured_pages, missing group identity, missing or null pages, flat-looking group entries, blank path, missing destination, hidden destination, duplicate destination, invalid YAML, sidecar extension conflicts, and all groups skipped after resolution.
Pitfalls
- Do not create both
.ymland.yamlsidecars for the same Markdown file. RazorDocs treats that as an authoring error and ignores both. - Do not use a sidecar as a second secret metadata system. It supports the same
DocMetadataschema as inline front matter, and it is best reserved for files whose Markdown needs to stay portable on other surfaces. - Do not put
pathorquestiondirectly under a group. Page fields belong underpages. - Prefer source-relative paths for authored curation when the docs may be exported or mounted under more than one route. Canonical docs-surface paths are accepted for parity with browser links, but source paths stay easier to review and move with the file.
- README portability matters most at the repository and package level. In this repo, authored
README.mdfiles should stay free of inline front matter so GitHub renders them cleanly.
Metadata-Driven Wayfinding
RazorDocs can render two kinds of page-local wayfinding on details pages without scraping rendered HTML after the fact:
On this pagelinks come from the harvestedDocNode.Outlinecontract.PreviousandNextproof-path links come from explicit metadata, not folder inference.
Page-local outline behavior
On this page is local navigation for the current detail page. It intentionally does not mirror the left sidebar, which remains global documentation navigation. This keeps the two maps separate: the sidebar answers "where am I in the docs product?" while the outline answers "where am I on this page?"
When DocDetailsViewModel.HasOutline is true, RazorDocs renders one semantic outline nav:
- wide desktop (
>=1280px): a sticky right rail beside the article - narrower viewports: a closed-by-default
On this pagetoggle above the article - all viewports: the same outline list, never separate desktop and mobile TOCs
The outline client enhances the server-rendered links by:
- using
#main-contentas the scroll root forIntersectionObserver - marking the current section with
aria-current="location" - refreshing the active section from the scroll position on scroll, throttled through
requestAnimationFrame, so long sections do not stay pinned to the previous outline item until the next heading enters the observer band - keeping the active outline link visible inside the sticky desktop rail when the page-local outline is taller than the viewport
- easing outline-link clicks to the target section over 620 ms, while preserving instant jumps for readers who prefer reduced motion and canceling the animation when the reader manually wheels or touches the scroll root
- initializing from the current URL hash
- rebinding after RazorWire/Turbo frame navigation replaces
rw:island id="doc-content" - containing scroll inside the expanded compact outline so touch or wheel input over
On this pagedoes not move the article behind it - collapsing the mobile outline after an outline link is chosen
- skipping missing heading targets for active-state tracking while leaving their normal hash links intact instead of marking stale entries current or closing the drawer
If JavaScript is unavailable, the server-rendered outline remains a normal list of hash links. If IntersectionObserver is unavailable, RazorDocs keeps static and hash-based behavior rather than adding a scroll polling fallback.
Sequence contract
Use sequence_key together with order when a set of pages should behave like one proof path:
yamlsequence_key: razorwire-proof
order: 20
related_pages:
- Web/ForgeTrust.RazorWire/README.md
- Web/ForgeTrust.RazorWire/Docs/antiforgery.md
sequence_keyopts a page into a specific sequence. Pages do not join a sequence just because they share a folder.orderdetermines the relative previous/next position inside that sequence.related_pagesstays independent from sequencing and can point to source paths, canonical docs paths, or exact page titles.- RazorDocs publishes authored sequence metadata to the current-surface search index for custom clients and integrations. The live source surface emits that payload at
{DocsRootPath}/search-index.json(for example,/docs/search-index.jsonby default when versioning is off,/docs/next/search-index.jsonby default when versioning is on, or/foo/bar/search-index.jsonfor a custom unversioned route root); exported exact-version trees carry their ownsearch-index.jsonpayload at the tree root. Thesequence_keyfront-matter value becomessequenceKey,orderstaysorder, andrelated_pagesstays separate asrelatedPages; for example:{ "sequenceKey": "razorwire-proof", "order": 20, "relatedPages": ["Web/ForgeTrust.RazorWire/README.md"] }.
Resolution rules
- Previous/next links render only when the current page has both
sequence_keyandorder. - RazorDocs only sequences navigable pages. Fragment-only anchor stubs and pages hidden from public navigation do not appear in proof-path navigation.
- Related pages are deduplicated against the current page and any resolved previous/next neighbors.
Pitfalls
- Do not rely on filename prefixes or folder adjacency for proof-path behavior in this slice. Use explicit
sequence_keyvalues instead. - Do not expect
related_pagesto imply ordering. Related links stay unordered beyond the authored list order.
Metadata-Driven Page Type Display
RazorDocs treats page_type metadata as structured UI input, not just as opaque search metadata. The built-in landing cards, detail pages, and search results all normalize the same metadata through DocMetadataPresentation.ResolvePageTypeBadge().
Built-in normalization
- Known values such as
guide,example,api-reference,internals,how-to,start-here,troubleshooting,glossary,faq, andreleaserender with stable labels and intentional badge variants. - Release aliases
release-noteandrelease-notesrender as the canonicalReleasebadge with the normalizedreleasevalue and variant. - Unknown values still render safely: RazorDocs normalizes whitespace, underscores, and dashes, then falls back to a neutral title-cased badge.
- Missing or blank
page_typevalues render no badge at all instead of leaving empty chrome behind.
Search payload contract
The current-surface search-index.json payload continues to emit the raw pageType metadata value and now also includes:
pageTypeLabelfor the normalized display label used by the built-in search UIpageTypeVariantfor the built-in badge variant suffix used by CSS classes such asdocs-page-badge--guidepublicSectionfor the normalized built-in section slug when the page is publicly visiblepublicSectionLabelfor the reader-facing section labelisSectionLandingfor authored section landing entry points These fields let custom search clients stay visually aligned with the landing and detail experiences without re-implementing the mapping table.
When authored metadata uses release-note or release-notes, RazorDocs keeps the raw pageType metadata value in the payload but emits pageTypeLabel = "Release" and pageTypeVariant = "release" so built-in and custom clients can present release pages consistently.
Custom Harvester Outline Contract
The built-in Markdown and C# harvesters now populate DocNode.Outline directly during harvest. Custom IDocHarvester implementations should do the same when they want:
On this pagelinks on details views- heading metadata in the current-surface
search-index.json - stable behavior without re-parsing rendered HTML later
Each outline entry should provide the rendered fragment Id, the reader-facing Title, and the normalized heading Level. For visual parity with the built-in wayfinding UI, custom IDocHarvester implementations should populate DocNode.Outline only with entries that have a non-empty rendered fragment Id and non-empty Title; headings or generated sections missing either value are skipped by the built-ins. The Markdown harvester emits source-ordered H2-H3 headings by default, with titles normalized from inline heading text and IDs taken from the rendered heading fragment. The C# harvester emits level 2 entries for documented types and enums, and level 3 entries for method groups and properties. Matching those defaults keeps custom outlines aligned with the built-in On this page rail, active-section behavior, and search heading metadata.
Public visibility note:
HideFromSearch = trueremoves a page from the search payload directly.HideFromPublicNav = truealso removes the page from the search payload because the public shell treats hidden pages as fully non-public.- Default path exclusions run before metadata is assigned. Test-project README files and C# source under test-project directories are not harvested, and C# source under
examplesis skipped so generated API-reference pages for example apps do not enter navigation, search, or direct docs routing.
Trust Metadata For Release Notes And Policy Pages
RazorDocs can also render a top-of-page trust bar from nested trust metadata. AppSurface uses this for its own release notes, upgrade policy, and changelog pages so the product doubles as a working example for consumers.
yamltrust:
status: Unreleased
summary: This page is provisional until the next tag is cut.
freshness: Updated as changes land on main.
change_scope: Repository-wide.
migration:
label: Read the upgrade policy
href: /docs/releases/upgrade-policy
archive: Tagged release notes will keep the final narrative once the version ships.
sources:
- CHANGELOG.md
- releases/unreleased.mdField behavior
statusis the compact top-level state, such asUnreleasedorPre-1.0 policy.summaryis the short trust statement shown beside the status.freshnessexplains how current the page is and how stable readers should assume it is.change_scopecalls out which surfaces the note covers.migrationis an optional label plus browser-facinghrefto the adoption guidance.archiveexplains where the durable tagged record or long-term home lives.sourcesis an optional list of provenance notes or upstream artifacts.
Merge behavior
- Inline front matter and sidecar YAML both use the same nested
trustschema. - Inline metadata wins over sidecar metadata field by field.
- Explicit empty lists such as
sources: []are authoritative and suppress fallback lists.
Pitfalls
- Use a browser-facing
hrefformigration, not a source path, because the trust bar renders a plain link without path rewriting. - Keep private maintainer-only runbooks outside harvested docs. Hidden pages are removed from nav and search, but they are still public if linked directly.
- Do not turn the trust bar into marketing chrome. It should answer status, safety, and provenance questions quickly.
Related Projects
- ForgeTrust.AppSurface.Docs.Standalone for the exportable host used in docs export and smoke testing
- Back to Web List
- Back to Root
Notes
- This package is the reusable documentation surface;
ForgeTrust.AppSurface.Docs.Standaloneis the thin executable wrapper used for local hosting and export scenarios. - The bundled RazorDocs UI includes its generated stylesheet and docs runtime files as static web assets and assembly-embedded fallback resources. The layout resolves the correct stylesheet path automatically from the host's root module shape for standalone/root-module hosts versus embedded application-part consumers, while endpoint fallbacks keep packaged hosts working when static web asset manifests are unavailable.
- Consumers do not need to call
services.AddTailwind()unless they also want Tailwind build/watch integration for their own host application's CSS. - It depends on the Tailwind package family for RazorDocs package build-time styling generation and on the caching package for docs aggregation performance.