# DSH.01: Portable documentation sites

Specification version: 0.4.0. Contract: accepted. Collection loading, core site
projection, renderer admission, search, and static publication are implemented.
Hosted presentation/OpenAPI pair validation and the additive presentation
settings are implemented in the checkout. Named local archives have independent
Svelte/HEEx/hosted renderer evidence in the implementation plan.
The implementation plan records executable evidence; this specification states
the required behavior and does not treat core tests as renderer qualification.
The [coordinated completion checklist](../plans/documentation-sites.md#coordinated-completion)
records implemented upstream and Phoenix Assets behavior, exact local archive
evidence and consumer ownership. Library completion uses automated tests;
human verification and consumer deployment checks are not prerequisites. Checkout
implementation and local qualification do not establish registry publication.

## Purpose

DocShell turns documentation sources into a versioned, renderer-neutral
corpus. This specification defines how to combine several corpora into one
documentation site and publish it through an application route or static files.

The core package owns document identity, source provenance, site projection,
route and link validation, search records, deterministic manifests, and the
static-export protocol. It does not own a framework, visual brand, deployment
provider, source repository list, or host authorization policy.

`doc-shell/v2` remains the extraction artifact contract. Site publication is a
separate `doc-shell-site/v1` contract built from one or more validated
`doc-shell/v2` corpora. A site renderer consumes the site contract and does not
need to know whether content came from BEAM documentation, Markdown, Livebook,
OpenAPI, or a graph projector.

A site-ready `doc-shell/v2` corpus adds `collection.json` to its public
manifest. This is an additive source artifact permitted by the v2 contract.
Existing generic consumers ignore it; a DSH.01 collection importer requires it.

## Existing foundation

The existing extraction pipeline provides:

- `DocShell.Build` extracts module documentation, Markdown guides, Livebooks,
  release notes, and OpenAPI into one generation of `doc-shell/v2` artifacts.
- `DocShell.Presentation.Source` projects navigation, search, and content.
- `DocShell.Artifact.Transaction` publishes a generation through staged writes
  and rollback.
- `DocShell.Web.Cache` can serve a validated generation without making the
  extraction pipeline a runtime requirement.

These artifacts are inputs to the site contract. The implementation below adds
multi-corpus projection and static publication without changing the extraction
format. Renderer parity still requires evidence from independent renderer
packages.

## Ownership boundaries

| Concern | Owner |
| --- | --- |
| Extracting one application's documentation | `DocShell.Generate` |
| Validating and importing an existing artifact corpus | `DocShell.Generate.Collection` |
| Binding exact source identities into a cohort | `DocShell.Generate.Cohort` |
| Producing pages, navigation, search, routes, and provenance | `DocShell.Presentation.SiteProjector` |
| Rendering a page to HTML or another output | A `DocShell.Presentation.Renderer` implementation |
| Writing a complete static site atomically | `DocShell.Presentation.StaticExporter` |
| Framework components and browser integration | A separate renderer package |
| Branding, taxonomy, authorization, and deployment | The host |

Renderer-neutral settings may carry validated host identity, links, labels and
locale/release choices. Hosts choose that content and those destinations;
DocShell validates their shape and relationships, while renderer packages own
controls, snippets/slots, CSS, SEO markup and visual previews. Native readers,
producer geometry, repository discovery, product workflows and disclosure
decisions remain outside both library implementations. A hosted pair check
compares declared bindings; it cannot authenticate or authorize them.

The `doc_shell` Hex package must not gain Phoenix, LiveView, Svelte, Astro,
React, a CSS framework, Node, a browser, a network client, or a hosted search
service as a required dependency. Optional tools may implement public
behaviours from another package.

### Hosted graph projection boundary

`DocShell.Presentation.GraphProjector` is a presentation port, not a graph
database client or source-cohort manager. A host may index one repository, a
monorepo with many components, or an authorized set of several repositories.
The host resolves that topology, pins exact revisions, applies authorization
and disclosure policy, and projects the resulting store before DocShell is
called.

DocShell receives one renderer-neutral presentation from the host callback. It
does not discover repositories, infer current revisions, compile dependencies,
merge graph stores, fetch HexDocs, or remove private records from a broader
result. A host that needs public and internal sites constructs two independently
authorized projections; it must not pass an internal projection to DocShell and
expect the renderer to hide private content.

The hosted `doc-shell/v2` path and the portable collection/site path are related
but distinct:

- a hosted graph-backed application may serve one validated `doc-shell/v2`
  presentation directly from its own store;
- `DocShell.Generate.Collection` represents one portable extracted corpus; and
- `DocShell.Generate.Cohort` and the `doc-shell-site/v1` contract combine
  portable collections for static or application-neutral publication.

The hosted path needs no collection per repository or component. For both
paths, the host authenticates and admits sources before rendering; DocShell
does not make authorization decisions.

#### Hosted API pairing

`DocShell.Presentation.HostedPair.validate/3` checks a typed graph presentation
and an OpenAPI document against the expected publication binding supplied by the
host. The presentation carries `:publication`; OpenAPI carries the extension
`"x-doc-shell-publication"`. Both contain this string-keyed object:

```json
{"schema_version":"doc-shell-publication/v1","publication_id":"snapshot-1","source_view":"view-1","graph_position":"position-1","locale":"en","release":"v1","disclosure_surface":"public"}
```

All fields are required, nonempty opaque strings of at most 4096 UTF-8 bytes.
Unknown keys and versions refuse. The schema and shared fixture ship in
`priv/contracts/publication-binding-v1.schema.json` and `hosted-pair-v1.json`.
Both bindings must equal the host's expected binding before existing graph
presentation and OpenAPI shape validation completes. This rejects missing,
mixed, cross-surface and mutually consistent but stale pairs when the host
expects another publication. Errors identify the input and field without
returning private values.

A matching operation ID, API version or opaque publication ID alone is
insufficient. The host authenticates the session, admits the inputs, supplies
the expected source view and maintains immutable publication identities.
DocShell neither authenticates those declarations nor detects a host that
relabels altered source data with a reused binding. Public and internal views
remain separate host-admitted pairs. Existing unpaired presentations stay valid;
hosted API integrations must use pair admission before renderer consumption.
No repository discovery or synthetic collections are needed for this path.

## Source collection contract

### Collection descriptor

`DocShell.Generate.Collection` accepts a descriptor with these required fields:

```elixir
%DocShell.Generate.Collection{
  id: "example_core",
  title: "Example Core",
  version: "1.4.0",
  revision: "40-character source revision",
  tree_digest: "sha256:...",
  artifact_dir: "/build/input/example_core",
  source_url: "https://example.invalid/example_core",
  edit_base_url: "https://example.invalid/example_core/edit/<revision>"
}
```

`id` is a stable lowercase identifier. `revision`, `tree_digest`, and
`artifact_dir` bind the imported documentation to source bytes. `source_url`
and `edit_base_url` are inert metadata; DocShell never fetches them. Optional
metadata includes package name, license expression, default locale, audience,
repository-relative source root, and a host-defined status.

`DocShell.Build` accepts the same source identity through an optional
`collection:` configuration. When present, it writes `collection.json` with
the normalized identity, repository-relative paths and SHA-256 digests for
every extracted source, canonical digests of every source index payload, and a
canonical aggregate `content_digest`.

Module entries identify the module and
digest of the extracted BEAM documentation record. DocShell records a supplied
revision and tree digest but never invokes Git to discover or verify them; the
caller that checks out source owns that verification.

Site-ready collections store normalized relative source paths and reject paths
outside the supplied source root, missing source identities, ambiguous source
ownership, and a collection descriptor that differs from `collection.json`.

Absolute input filenames inside the source root may be normalized during build;
absolute paths in a portable artifact are rejected. A current-schema corpus
without `collection.json` remains readable through the artifact APIs but
cannot satisfy DSH-S01 provenance. Previous `doc-shell/v1` envelopes must be
rebuilt before a v2 reader can accept them.

Collection loading must:

1. read the public manifest first;
2. reject an unsupported schema version, mixed generation, missing or unlisted
   artifact, path escape, symlink escape, and digest mismatch;
3. preserve unknown source kinds and JSON metadata;
4. qualify every document identity as `{collection_id}:{document_id}`;
5. retain the unqualified identity for source and edit links; and
6. perform no network request and start no application process.

### Collection integrity and compatibility

Document identity and file identity are different. Every source document ID is
nonempty UTF-8 and unique across the entire corpus, including filtered entries
and the synthetic OpenAPI document whose ID is `openapi`.

A Markdown changelog
produces several release records from one file: those distinct changelog IDs may
share a normalized source path. Two independently extracted guide/notebook files
must not claim the same normalized path.

Provenance records correspond exactly
to admitted source records; missing, repeated, or orphaned records are errors.

The existing source indexes omit ASTs. A collection build may therefore use a
custom presentation only when each extracted AST can be reconstructed from
`content.json` (an omitted empty AST reconstructs as `[]`). A projector that
changes or drops nonempty source content fails before publication. Do not restore
filtered content to public output implicitly. A future alternative source-body
artifact requires an explicit additive design and exposure policy.

Unknown manifested artifacts remain available unchanged under `artifacts`.
An artifact referenced by source provenance must use the generic v1 source-index
entry shape and is validated and qualified like known source indexes. Unknown
`kind` values are preserved. Presentation and manifest artifacts cannot be
misrepresented as source indexes. Known source indexes and OpenAPI require
complete provenance even when their records have no filesystem paths.

Imported data is validated structurally before lookup or qualification: source
indexes contain entry maps, OpenAPI contains a map with a supported version,
content contains recursive AST lists, and every source reference resolves.
Checksums prove internal consistency, not source authenticity. Verification of
the checkout revision and tree digest remains the caller's responsibility.

### Canonical digest contract

Canonical JSON sorts object keys by UTF-8 byte order, preserves array order and
JSON scalar types, and emits compact Jason-compatible JSON strings and numbers.
No Unicode normalization, timestamp, or generation ID is introduced. This is the
DocShell canonical format, not a claim of RFC 8785 conformance. Shared fixtures
record input values, canonical bytes, and lowercase SHA-256 digests.

JSON encoding must reject collisions such as atom `:id` and string `"id"` keys.
Public checked encoding/digest operations return tagged errors. The existing
`Collection.digest/1` convenience function returns a string for encodable values
and explicitly documents its raising behavior for invalid inputs. Build and
import boundaries must use checked operations rather than leaking exceptions.

### Filesystem and publication boundary

Projection/body equality is type-strict, including JSON integer versus float
values. An embedded index AST, when supplied, must exactly match reconstructed
content. The synthetic OpenAPI source never owns a document AST in `content.json`.

Collection import applies these filesystem rules:

- Reject symlinked roots, including trailing-slash and `/.` spellings,
  symlinked components below a trusted existing parent, and symlinked artifact files.
- Trust existing ancestors of the current working directory and system temporary
  directory, including platform aliases such as macOS `/var`.
- Reject parent (`..`) traversal in an import root.
- Bound reads before decoding.

Import requires a stable directory owned by the caller. Portable path-based
filesystem APIs do not provide a sandbox against a hostile process replacing
directories concurrently.

Stale optional artifact removal is a transaction operation. It uses the same
directory locks, backup, publication, and rollback lifetime as writes. A returned
publication failure restores previous bytes or reports retained recovery paths.
Post-commit cleanup must never delete an artifact from another build. Individual
file renames provide generation consistency for validating readers, not a
power-loss-safe transaction or an atomic multi-file filesystem snapshot.

### Foundation quality requirements

Preparation and import must not repeatedly append to growing lists or scan a
whole source index for each record. Validate and index identities once, use
linear collection passes, and keep map-key sorting confined to canonicalization.
Benchmarks exercise increasing corpus sizes and record elapsed time and memory.

All file extractors reject invalid UTF-8 with source-tagged errors before Markdown
parsing. Presentation ordering uses kind, title, then ID, including equal-title
fixtures. Presentation validation rejects empty/duplicate identities and
inconsistent document references while allowing explicit navigation groups and
external links. Optional member-document search is explicit configuration.

For the existing presentation contract, groups are navigation items with children
(their path may be empty), and external links use absolute HTTP(S) URLs.

Local navigation leaves and search results require a content key. Navigation and search
each have unique IDs; a shared ID has the same path. Content may be absent from
both indexes.

Backlink targets require content; their origins may be outside this
corpus, but known origin paths must agree. AST extension fields remain native JSON.

Cache fetches are individually consistent. A caller that needs several artifacts
from one generation uses an explicit snapshot API; successive independent fetches
may cross a reload. Reload timeouts are configurable and old snapshots survive
failed reloads. No host authorization or renderer policy moves into the library.

Qualification includes multi-release changelogs, custom projectors, reserved IDs,
unknown source artifacts, malformed JSON values, equal sort keys, path spellings,
transactional deletion, and concurrent cache readers.

Property generators must
retain valid edge cases rather than exclude them to make invariants pass. Package
consumer checks run meaningful artifact round trips and separately exercise
locked, unlocked, and selected minimum dependency sets without modifying the
repository lockfile, release refs, or GitHub workflow topology.

An absent private corpus is valid. A supplied private corpus requires a
separate explicit input and cannot enter a public site by inheritance from the
public descriptor.

For a graph-backed hosted projection, the equivalent rule is stronger: the
private repository/view must be absent from the public host projection before
navigation, search, backlinks or content are constructed. Renderer-side
filtering is not a disclosure boundary.

## Resource limits

Collection import accepts keyword limits through `Collection.load/2` and
`load_many/2`, validated by `DocShell.Generate.Collection.Limits`:

| Resource | Default maximum | Scope |
| --- | ---: | --- |
| File size | 32 MiB | Per file |
| Total input bytes, including the manifest | 2 GiB | Per collection |
| Manifested artifacts | 256 | Per collection |
| Source records | 100,000 | Per collection |
| Collections | 256 | Per call |
| JSON container nesting | 64 | Per collection |

File bytes and nesting are checked before decoding. Byte, file, and source
budgets apply per collection; they do not bound aggregate retained memory in
`load_many/2`. No unlimited sentinel is accepted. Duplicate JSON object keys fail
rather than taking a parser-dependent first/last value.

Site operations accept a `DocShell.Presentation.Limits` value. Defaults are
large enough for an ecosystem site and finite:

| Resource | Default maximum |
| --- | ---: |
| Collections | 256 |
| Pages across a site generation | 100,000 |
| UTF-8 bytes in one page AST | 8 MiB |
| AST nesting depth | 64 |
| Navigation depth | 16 |
| Qualified ID or anchor bytes | 512 |
| Route or URL bytes | 2,048 |
| Redirects | 100,000 |
| Static output files | 250,000 |
| One generated file | 32 MiB |
| Complete static output | 2 GiB |

Hosts may lower limits. Raising one requires an explicit option and cannot
exceed the VM/filesystem integer range. Limit failures identify the resource,
observed value and admitted maximum and leave the previous artifact tree
unchanged.

### Cohort descriptor

`DocShell.Generate.Cohort` is an ordered list of collection descriptors plus a
site-profile identifier. The cohort digest is SHA-256 over canonical JSON of
the normalized descriptors, their artifact content digests, and the selected
site profile. Timestamps, absolute workspace paths, random generation IDs, and
output directories are excluded.

Every site artifact carries both:

- `generation_id`, which distinguishes one publication attempt; and
- `cohort_digest`, which proves the source and configuration content.

Two builds from identical source corpora and site configuration may have
different generation IDs, but must have the same cohort digest and identical
payload bytes after envelope fields are removed.

## Site model

`DocShell.Presentation.SiteProjector.project/1` returns a
`DocShell.Presentation.Site` containing:

```elixir
%DocShell.Presentation.Site{
  schema_version: "doc-shell-site/v1",
  cohort_digest: "sha256:...",
  profile: "public",
  title: "Example documentation",
  base_path: "/",
  default_locale: "en",
  locales: ["en"],
  pages: %{"example_core:intro" => %DocShell.Presentation.Page{}},
  routes: %{"/start/" => "example_core:intro"},
  navigation: [%DocShell.Presentation.NavigationItem{}],
  search: [%DocShell.Presentation.SearchEntry{}],
  redirects: %{},
  metadata: %{}
}
```

The projector accepts a host module implementing
`DocShell.Presentation.SiteSource`. The host supplies taxonomy and route
policy; DocShell validates the result and derives the relationships between
pages.

### Page contract

The optional site `presentation` object is validated by
`DocShell.Presentation.Settings.validate/1` and copied to renderer context.
Its closed top-level keys are `brand`, `footer`, `labels`, `locales`, `releases`,
`metadata`, and `robots`. Brand and footer links carry `label` and `href`;
locale/release entries add `id` and optional boolean `current`. At most one
entry in each selector is current, and identities are unique. A brand logo
names a local logical `asset` plus `alt` text. Lists have at most 64 entries;
labels have at most 128 keys; strings have at most 4096 UTF-8 bytes.

Metadata carries optional `description`, `image` URL and indexing `robots`
tokens. The separate `robots` object supplies `allow`/`disallow` absolute-path
lists for robots.txt. Unsupported keys, unsafe destinations, conflicting tokens
and executable inputs refuse. Empty settings preserve existing defaults. No
renderer callback, HTML, authorization or product layout policy belongs here.

Every `DocShell.Presentation.Page` has:

- qualified `id`, `collection_id`, source `document_id`, and `kind`;
- normalized absolute site `route` ending in `/`, except file routes;
- `title`, optional `description`, `locale`, optional `audience`, and template
  kind `:document` or `:splash`;
- renderer-neutral AST `content` and a stable `content_digest`;
- ordered breadcrumbs and table-of-contents headings;
- optional previous and next links from the projected reading order;
- canonical, source, and edit URLs when the host can establish them;
- source revision, source-relative path, package version, last-modified value,
  tags, status, and arbitrary JSON metadata;
- search and navigation inclusion flags;
- optional renderer-neutral banner and hero records; and
- capability requirements derived from its AST and site profile.

The host may declare exact page `source_url` and `edit_url` values when its
source provider has file-specific URL rules. Otherwise `source_url` remains the
collection descriptor's exact inert URL and `edit_url` joins the normalized
source path to `edit_base_url`. DocShell validates HTTP(S) syntax but does not
invent provider-specific path segments such as repository blob routes.

`PageDecorations.validate/2` validates closed banner and hero objects. Banners
accept `label`, `text` and `href`, with at least a nonempty label or text. Heroes
require `title` and accept `tagline`, `description`, link `actions` and an `image`
logical asset/alt record. Text fields have at most 4096 UTF-8 bytes; link lists
and images use the shared settings limits and URL policy. Unknown fields,
invalid links and executable values refuse. The supported records keep their
values without synthesizing routes or host product layout.

The reserved `metadata["seo"]` object uses the shared description/image/robots
metadata shape. Other metadata stays inert host data, subject to forbidden
executable/framework keys and the full page byte budget. Description, status and
last-modified values are optional nonempty UTF-8 strings of at most 4096 bytes.
The final projected record, including metadata, breadcrumbs and reading links,
must fit `max_page_bytes`. Site sources must return JSON-encodable declarations;
normalization cannot hide callbacks inside inspected strings.

Raw `<head>` HTML, arbitrary scripts, renderer class names, and framework
component names are not page metadata. A renderer may add host-owned metadata
from validated fields.

Headings receive deterministic, unique anchors. Explicit source anchors take
precedence. Duplicate explicit anchors fail projection. Generated anchors use
Unicode text normalization and stable numeric suffixes. Links to qualified
documents and headings resolve before publication.

### Navigation

Navigation supports links and nested groups. A group has stable identity,
localized label, ordered children, optional badge, and initial collapsed state.
A page may override its navigation label, order, badge, and visibility through
normalized metadata. The host may provide an explicit tree or rules that group
pages by collection, kind, namespace, or path.

The projector rejects duplicate routes, duplicate qualified IDs, cycles,
unknown page references, an empty group label, invalid locale fallback, and a
navigation link that differs from the page's canonical route. It must not infer
ecosystem taxonomy from filesystem adjacency.

### Reading flow and versions

Previous and next links follow visible navigation order within the same locale,
audience set, and nearest containing navigation group. Audience string/list
forms have the same meaning, and list order does not affect membership. Root
leaves share a section. A page can disable or explicitly override either link,
including a page omitted from navigation. Cross-section or cross-view jumps
require an explicit host relationship; these links grant no authorization.

Navigation badges are nonempty strings of at most 256 UTF-8 bytes. A group's
optional `collapsed` value is boolean; leaves cannot declare collapse state.
The same checks apply to these reserved values inside `meta`.

A site may contain several named release cohorts. Version selection is a host
facet over immutable site generations. A stable route may redirect to a named
default release, while retained releases stay at distinct routes. The exporter
must not overwrite one release with another or label a moving branch name as an
immutable version.

## Search contract

Search records include page and heading-level entries with qualified identity,
route, title, section title, plain text, locale, audience, kind, collection,
version, tags, and status. Search inclusion is independent from navigation
visibility. Draft or private content is excluded from a public profile before
the index is written.

`DocShell.Presentation.SearchAdapter` receives validated records and emits
static assets plus a browser query contract. The built-in adapter writes a
deterministic JSON index and needs no external process. Optional adapters may
produce a segmented index such as Pagefind, but they must consume the same
records, retain filters, run only during the build, and leave no hosted-service
dependency.

A renderer presents keyboard-operable search with a labelled dialog, visible
query state, grouped results, matched section context, no-results state, and
Escape/arrow/Enter behavior. Search must remain usable on the static site with
no server connection.

## Renderer contract

`DocShell.Presentation.Renderer` defines:

```elixir
@callback render_page(Page.t(), Context.t()) ::
            {:ok, iodata()} | {:error, term()}
@callback render_not_found(Site.t(), Context.t()) ::
            {:ok, iodata()} | {:error, term()}
@callback assets(Site.t(), keyword()) ::
            {:ok, [Asset.t()]} | {:error, term()}
@callback capabilities() :: Renderer.Capabilities.t()
```

The context contains site identity, navigation, current route, locale,
canonical origin, search contract, and asset paths. It contains no application
session or authorization decision.

`DocShell.Presentation.Renderer.Capabilities` identifies the renderer, its
contract version, supported directives, static-output support, browser
enhancements, and whether a capability needs a live server transport. The
capability value contains data, not executable callbacks or framework module
names.

A renderer must reject a page that requires an unsupported capability
or render the fixture's declared accessible fallback. It must not silently
drop the content or emit a control that cannot operate in the selected output
mode.

The public values have this normalized shape:

```elixir
%DocShell.Presentation.Renderer.Capabilities{
  schema_version: "doc-shell-renderer-capabilities/v1",
  renderer_id: "example-html",
  renderer_version: "1.0.0",
  output_modes: [:hosted, :static],
  features: %{
    "doc-shell/search/v1" =>
      %DocShell.Presentation.Renderer.Capability{
        states: [:fallback, :enhanced],
        runtime: [:browser_js]
      }
  }
}
```

Renderer and feature IDs match `[a-z0-9][a-z0-9._/-]*`; feature IDs include
their contract revision and renderer versions use Semantic Versioning.

`output_modes` and feature states are closed atoms.
The states are `:fallback`, `:enhanced`, and `:connected`. Runtime values are
`:browser_js` and `:live_transport`; an empty list means that the emitted
output needs neither.

Build tools do not appear as served runtime requirements:
a renderer may use Node while producing an asset, but a static result cannot
require Node to serve or operate.

Each page carries normalized
`DocShell.Presentation.Renderer.CapabilityRequirement` values with `feature_id`,
`acceptable_states`, `essential?`, and an optional `fallback_digest`. The
projector derives them from typed AST nodes and the selected site profile, not
from arbitrary metadata.

An essential requirement with no supported acceptable
state fails rendering. A nonessential requirement may be omitted only when its
declared fallback is present and its digest matches. A connected action is not
equivalent to a fallback that merely resembles its successful result.

DSH.01 reserves these feature IDs:

- `doc-shell/html/v1` for semantic document rendering;
- `doc-shell/search/v1` for query and result interaction;
- `doc-shell/theme/v1`, `doc-shell/navigation/v1`, `doc-shell/copy/v1`, and
  `doc-shell/tabs/v1` for local browser enhancement;
- `doc-shell/highlight/v1` and `doc-shell/mermaid/v1` for code and diagram
  enhancement;
- `doc-shell/island/v1` for an explicit client-owned component boundary; and
- `doc-shell/request-execution/v1` for host-authorized API requests.

A renderer may publish additional namespaced IDs. A portable source cannot
make such an extension essential unless the site profile explicitly selects
the renderer that owns it.

The conformance corpus records three states for an enhanced component: the
semantic fallback before browser code runs, the enhanced browser state, and,
when applicable, the connected live state. The same fixture IDs, labels,
content digests, keyboard outcomes, and accessible names apply across those
states. Renderer-specific animation, transport attributes, and internal DOM
are not part of the portable contract.

Conforming HTML renderers provide:

- semantic header, navigation, main, complementary table of contents, and
  footer landmarks;
- a first-focus skip link to the main content;
- desktop sidebar and small-screen navigation containing the same links;
- visible focus, keyboard access, system/light/dark theme selection, and
  reduced-motion behavior;
- a bounded reading width with responsive single-, two-, and three-column
  layouts;
- breadcrumbs, anchored headings, table of contents, previous/next links,
  source/edit link, last-updated and version context when present;
- plain text fallback for code and diagrams before browser enhancement;
- copyable code, syntax highlighting, Mermaid diagrams, API operations,
  recursive schemas, examples, and disabled-by-default request execution when
  the renderer supports those capabilities;
- callouts, cards, link cards, card grids, badges, file trees, steps, and tabs
  through documented AST directives; and
- metadata needed for title, description, canonical URL, Open Graph, sitemap,
  language, writing direction, and robots policy.

Unknown directives render their safe child content with an explicit fallback;
they do not disappear. Text is escaped. Elements, attributes, URL schemes, and
external link behavior pass a closed renderer policy. Raw HTML is disabled by
default and can be enabled only through an explicit renderer option with the
same allowlist.

The renderer must be useful without JavaScript. Browser code enhances search,
theme persistence, mobile navigation, copy controls, tabs, code highlighting,
and diagrams. It must not hide the article, navigation, or source links when
JavaScript fails.

A hosted renderer may mount a client component inside an explicitly declared
island boundary. The host and client renderer must not mutate the same DOM
subtree.

The page's article, ordinary links, provenance, navigation, headings,
and search records remain present outside, or as the declared fallback of, any
island. Live transport is an enhancement capability and cannot be required by
a static export. DocShell defines these capability and fallback semantics but
does not define an island protocol or depend on a browser framework.

## Static export

`DocShell.Presentation.StaticExporter.export/1` accepts a validated site,
renderer, destination, and asset options. It writes into an exclusively
created staging directory, validates the completed tree, then replaces the
destination according to `DocShell.Artifact.Transaction` recovery semantics.

The static tree contains:

```text
index.html
404.html
assets/<content-hashed files>
search-index.json or an adapter-owned search directory
site-manifest.json
sitemap.xml
robots.txt
llms.txt
llms-full.txt
<route>/index.html
```

`site-manifest.json` lists every route, redirect, file, media type, size,
SHA-256 digest, source cohort digest, and renderer identity. `llms.txt` is a
concise route index; `llms-full.txt` contains the public textual corpus in
navigation order with source identities. Both are generated from the same page
records as HTML and search.

Static export rejects absolute output paths from a renderer, `..` traversal,
symlinks, duplicate files, case-folding collisions, unresolved local links,
missing assets, external runtime assets, mixed cohort digests, and output above
host-configured file and byte limits. It emits no current-time value inside
deterministic payloads unless the caller explicitly supplies one.

The exporter also rejects a required renderer capability whose manifest needs
a WebSocket, endpoint, server process, Node runtime, or other unavailable
runtime. Optional live islands may export only when their declared static
fallback is complete and the live control is absent or visibly unavailable.
Static browser enhancements use manifested local assets and deterministic
input; they do not convert a live-only operation into a static claim.

## Host rendering parity

An application may serve the same `Site` and `Page` values through LiveView,
an MVC controller, Svelte, or another renderer. Static and hosted surfaces are
equivalent when they share:

- the same cohort and page content digests;
- the same routes, navigation order, headings, links, search records, locale
  fallback, and visibility decisions; and
- equivalent semantic landmarks and accessible names.

Exact HTML bytes are not required because a hosted framework may add transport
metadata. Renderer conformance compares normalized semantics rather than
framework bookkeeping.

Hosted output may add richer client interaction. Parity requires the same
essential content, routes, actions that are meaningful without the host
runtime, and accessible fallback. It does not require a static page to imitate
a server-authorized command. A live-only action is excluded from static output
or presented as unavailable rather than mocked as successful.

## Requirement catalogue

| ID | Requirement |
| --- | --- |
| DSH-S01 | Emit and import each site-ready `doc-shell/v2` collection without network access or application startup and bind it to exact source and artifact identities through `collection.json`. |
| DSH-S02 | Namespace document identities and reject corpus, route, path, locale, and navigation ambiguity. |
| DSH-S03 | Produce deterministic `Site` and `Page` values with headings, provenance, reading flow, visibility, and content digests. |
| DSH-S04 | Produce one renderer-neutral search corpus with page/section records and public filters. |
| DSH-S05 | Define normalized renderer capability and page-requirement values without a required frontend or hosted service dependency. |
| DSH-S06 | Export a complete static site atomically with hashed local assets, manifests, sitemap, robots, and machine-readable documentation. |
| DSH-S07 | Validate internal links, anchors, canonical URLs, source/edit URLs, redirects, generated files, and output limits before publication. |
| DSH-S08 | Supply shared conformance fixtures for navigation, accessibility semantics, content directives, OpenAPI, localization, responsive behavior, progressive enhancement states, and unsafe input. |
| DSH-S09 | Prove hosted/static parity and honest live-capability degradation from the same site generation without requiring byte-identical framework markup. |
| DSH-S10 | Preserve `doc-shell/v2` compatibility and keep framework adapters outside the core dependency closure. |
| DSH-S11 | For hosted graph-backed API documentation, validate one host-admitted presentation/OpenAPI pair with a common immutable publication binding while leaving session and disclosure policy in the host. |

## Executable vectors

| ID | Evidence |
| --- | --- |
| DSH-V01 | Two independent artifact corpora with colliding source IDs import as distinct qualified IDs; a duplicate collection ID fails. |
| DSH-V02 | Missing, mixed-generation, modified, unlisted, symlinked, unsupported, path-escaping, absolute-source-path, descriptor-mismatched, and legacy-unprovenanced corpus inputs fail with structured errors. |
| DSH-V03 | Reordered filesystem input produces the same cohort digest, routes, page payloads, navigation, search, and static manifest. |
| DSH-V04 | Duplicate routes/anchors, cyclic navigation, unresolved pages/headings, bad locale fallback, and case-folding output collisions fail before publication. |
| DSH-V05 | Public, private, draft, hidden-navigation, hidden-search, locale, audience, version, and status combinations produce the admitted visibility matrix. |
| DSH-V06 | Heading anchors, breadcrumbs, table of contents, previous/next links, source/edit links, canonical URLs, and redirects resolve across collections and base paths. |
| DSH-V07 | Search returns page and section matches and filters by collection, kind, locale, audience, version, tag, and status without a server. |
| DSH-V08 | Static export contains only manifested local files, works below `/` and a subpath, reports broken links/assets, and restores the prior complete tree after failure. |
| DSH-V09 | Renderer fixtures cover capability normalization, unknown essential and optional features, fallback digests, semantic landmarks, skip link, focus order, dialog keyboard flow, mobile navigation, themes, reduced motion, code fallback, diagrams, directives, OpenAPI reference, and fallback/enhanced/connected states. |
| DSH-V10 | Unsafe tags, attributes, URLs, raw HTML, malformed AST, oversized content, limit overflow, and request-execution configuration follow the closed renderer policy. |
| DSH-V11 | Hosted and static consumers report identical cohort/page digests, route graphs, essential visible text, accessible names, headings, and links; live-only actions are absent or explicitly unavailable in static output. |
| DSH-V12 | A fresh consumer compiles and uses core site projection without Phoenix, LiveView, Node, Svelte, or a browser dependency. |
| DSH-V13 | Hosted public and internal fixtures accept matching presentation/OpenAPI publication bindings and reject cross-surface, mixed-generation, graph-position, locale, and release mismatches before rendering. |

## Completion rule

DSH.01 is complete only when DSH-S01 through DSH-S11 are implemented and
DSH-V01 through DSH-V13 run in the owning repository or in named independent
renderer consumers. A renderer's visual tests cannot close core collection or
export requirements. Core tests cannot claim a renderer meets the browser and
accessibility contract.

## Portable artifact extension

The [portable artifact capability draft](portable-artifact-capabilities.md)
has an experimental static subset for typed external-artifact blocks, per-call
producer profiles,
state-specific runtime admission, exact semantic anchors and preview/export
conformance. Core and Svelte/HEEx implementations have local archive/browser
evidence under DSH-P11–P14 and the coordinated checklist. Producer algorithms
and native application deployment remain consumer-owned. This extension is separate
from the original v0.4.0 site baseline and its completion rule above. Existing v1 AST, site,
renderer capability and fallback-digest semantics remain unchanged.
