# Publish a Portable Documentation Site

Publishing a site has three steps:

1. Load a collection and verify its source artifacts.
2. Use a site source to choose routes, visibility, and navigation.
3. Render the validated site.

This notebook follows those steps with a small HTML renderer. A production
renderer can live in another package and implement the same behaviour.

This example covers the existing core export path. The
[coordinated completion checklist](https://github.com/futhr/doc_shell/blob/main/docs/plans/documentation-sites.md#coordinated-completion)
tracks renderer adoption of presentation settings, hosted API pairing and complete artifact
preview/reader qualification. They are not options exercised by this notebook.
The host supplies source policy and brand content; renderer packages own the
visual controls and preview, while native readers and producer algorithms stay
with their respective consumers/producers. The settings section below describes
the settings contract included in the pinned 2.0.0 package.

## Setup

Run the notebook from Livebook's default standalone runtime. The release task
keeps this exact package version in sync with `mix.exs`.

```elixir
Mix.install([
  {:doc_shell, "== 2.0.0"}
])

Application.ensure_all_started(:doc_shell)
```

## Define host policy

The source below selects one qualified document and gives it a stable route.
It does not read files or render HTML; loaded collections are its only input.

```elixir
defmodule SitePublication.Source do
  @behaviour DocShell.Presentation.SiteSource

  @impl true
  def project(_collections, _options) do
    {:ok,
     %{
       "title" => "Example documentation",
       "base_path" => "/docs/",
       "default_locale" => "en",
       "locales" => ["en"],
       "pages" => [
         %{"id" => "example:intro", "route" => "/start/"}
       ],
       "navigation" => [
         %{
           "id" => "guides",
           "title" => "Guides",
           "children" => [%{"id" => "example:intro"}]
         }
       ],
       "redirects" => %{"/old/" => "/start/"},
       "metadata" => %{"owner" => "documentation"}
     }}
  end
end
```

The map declares the host's site policy. A database or graph projection can
produce it too; the returned values must remain inert JSON. In the current
checkout, automatic reading links stay within the same locale, audience set and
nearest navigation group. Explicit `previous`/`next` document IDs can cross
sections or apply to a page omitted from navigation. Badges use nonempty text
(up to 256 UTF-8 bytes), and `collapsed` is a boolean on a navigation group.

### Presentation settings in DocShell 2.0.0

The source may also return a `presentation` object. For example:

```json
{
  "brand": {"label": "Example", "href": "/docs/start/"},
  "labels": {"search": "Search examples"},
  "locales": [{"id": "en", "label": "English", "href": "/docs/start/", "current": true}],
  "footer": {"text": "Example documentation", "links": []},
  "metadata": {"description": "Example documentation", "robots": ["index", "follow"]},
  "robots": {"allow": ["/docs/"]}
}
```

`DocShell.Presentation.Settings.validate/1` checks this inert data before
projection. The exporter forwards it as `context.presentation` for both pages
and not-found output, and uses the explicit robots.txt rules. A renderer
implements its own controls and recognizes its own label keys; the source
supplies translations, destinations and brand content. The runnable baseline
below uses no settings, so it also works with the pinned release.

### Page decoration data in DocShell 2.0.0

Page declarations may carry `banner` records (`label`, `text`, `href`) and
`hero` records (`title`, `tagline`, `description`, ordinary link `actions`, and
an `image` logical asset/alt record). `PageDecorations.validate/2` checks them.
Use `metadata["seo"]` for page description/image/robots overrides. All values
stay inert and must be JSON-encodable. The complete projected page, including
metadata and reading relationships, must fit its byte budget. These fields do
not choose a host's product layout or supply executable rendering callbacks.

### Hosted API pairing in DocShell 2.0.0

A graph-backed host can call
`DocShell.Presentation.HostedPair.validate(presentation, openapi, expected)`
before invoking a renderer. Put the same `doc-shell-publication/v1` binding in
the presentation's `:publication` field, OpenAPI's `x-doc-shell-publication`
extension, and the host's expected request view. The required opaque fields are
`publication_id`, `source_view`, `graph_position`, `locale`, `release`, and
`disclosure_surface`. Missing or mismatched fields refuse, including stale
inputs that agree with each other but differ from the expected publication.
This API is included in DocShell 2.0.0. Host authorization and source
authenticity remain prerequisites; a matching declaration grants no access.

## Define a small renderer

The renderer declares what it can provide, returns logical assets, and renders
already validated pages. It does not choose routes or write the destination.

```elixir
defmodule SitePublication.Renderer do
  @behaviour DocShell.Presentation.Renderer

  alias DocShell.Presentation.{Asset, Page, Site}
  alias DocShell.Presentation.Renderer.{Capabilities, Capability, Context}

  @impl true
  def capabilities do
    %Capabilities{
      schema_version: Capabilities.schema_version(),
      renderer_id: "site-publication-example",
      renderer_version: "1.0.0",
      output_modes: [:static],
      features: %{
        "doc-shell/html/v1" => %Capability{states: [:fallback], runtime: []}
      }
    }
  end

  @impl true
  def assets(%Site{}, []) do
    {:ok,
     [
       %Asset{
         path: "site.css",
         media_type: "text/css",
         bytes: "main{max-width:48rem;margin:4rem auto;font:18px system-ui}"
       }
     ]}
  end

  @impl true
  def render_page(%Page{} = page, %Context{} = context) do
    body = Enum.map(page.content, &render_node/1)
    css = Map.fetch!(context.assets, "site.css")

    {:ok,
     [
       "<!doctype html><html lang=\"",
       escape(page.locale),
       "\"><head><meta charset=\"utf-8\"><title>",
       escape(page.title),
       "</title><link rel=\"stylesheet\" href=\"",
       escape(css),
       "\"></head><body><main id=\"main\">",
       body,
       "</main></body></html>"
     ]}
  end

  @impl true
  def render_not_found(%Site{}, %Context{}) do
    {:ok, "<!doctype html><html><body><main>Not found</main></body></html>"}
  end

  defp render_node(text) when is_binary(text), do: escape(text)

  defp render_node(%{"tag" => tag, "attrs" => attrs, "content" => content})
       when tag in ["h1", "p", "strong", "em"] do
    id = if tag == "h1", do: [" id=\"", escape(Map.get(attrs, "id", "")), "\""], else: []
    ["<", tag, id, ">", Enum.map(content, &render_node/1), "</", tag, ">"]
  end

  defp render_node(%{"content" => content}), do: Enum.map(content, &render_node/1)

  defp escape(value) do
    value
    |> to_string()
    |> String.replace("&", "&amp;")
    |> String.replace("<", "&lt;")
    |> String.replace("\"", "&quot;")
  end
end
```

Unknown AST elements above keep their safe child content. Production HTML
renderers should use a complete allowlist and validate link schemes as described
by the renderer contract.

## Build and load a collection

The source revision and tree digest come from the caller. DocShell records them;
it does not run Git or authenticate a checkout.

```elixir
workspace =
  Path.join(
    System.tmp_dir!(),
    "doc_shell_site_publication_#{System.unique_integer([:positive])}"
  )

File.rm_rf!(workspace)
File.mkdir_p!(Path.join(workspace, "guides"))

File.write!(Path.join(workspace, "guides/intro.md"), """
---
id: intro
title: Introduction
---

# Introduction

This page came from a validated portable collection.
""")

artifact_dir = Path.join(workspace, "artifacts")

{:ok, descriptor} =
  DocShell.Generate.Collection.new(%{
    id: "example",
    title: "Example",
    version: "1.0.0",
    revision: String.duplicate("a", 40),
    tree_digest: "sha256:" <> String.duplicate("b", 64),
    artifact_dir: artifact_dir,
    source_url: "https://example.invalid/example",
    edit_base_url: "https://example.invalid/example/edit"
  })

{:ok, _result} =
  File.cd!(workspace, fn ->
    DocShell.Build.run(
      modules: [],
      guide_bases: ["guides"],
      livebook_base: "notebooks",
      changelog_source: false,
      public_dir: artifact_dir,
      private_dir: Path.join(workspace, "private"),
      collection: descriptor
    )
  end)

{:ok, collection} = DocShell.Generate.Collection.load(descriptor)

Enum.map(collection.documents, & &1["id"])
```

## Project the site

Projection resolves anchors, links, breadcrumbs, reading order, search records,
visibility, and capability requirements before a renderer sees the page.

```elixir
{:ok, site} =
  DocShell.Presentation.SiteProjector.project(
    collections: [collection],
    source: SitePublication.Source,
    profile: "public",
    generation_id: "site-publication-example",
    canonical_origin: "https://docs.example"
  )

page = site.pages["example:intro"]

%{
  schema: site.schema_version,
  route: page.route,
  headings: Enum.map(page.headings, & &1.id),
  breadcrumbs: Enum.map(page.breadcrumbs, &{&1.title, &1.path}),
  requirements: Enum.map(page.requirements, & &1.feature_id)
}
```

The cohort digest is deterministic for the same collections and profile. The
generation ID identifies this publication attempt and may differ on a rebuild.

## Export the static tree

The exporter validates renderer capabilities and output, hashes local assets,
checks links and limits, stages the whole tree, and then replaces the destination.

```elixir
destination = Path.join(workspace, "site")

{:ok, manifest} =
  DocShell.Presentation.StaticExporter.export(
    site: site,
    renderer: SitePublication.Renderer,
    destination: destination,
    canonical_origin: "https://docs.example"
  )

%{
  renderer: manifest["renderer"],
  routes: manifest["routes"],
  files: destination |> Path.join("**/*") |> Path.wildcard() |> Enum.map(&Path.relative_to(&1, destination))
}
```

`site-manifest.json` records every generated payload except itself, avoiding a
self-referential digest. The tree also contains `404.html`, search data,
`sitemap.xml`, `robots.txt`, `llms.txt`, and `llms-full.txt`.

## Query the same search corpus

The built-in adapter is both the default static index and a reference query
implementation. Other adapters consume the same records.

```elixir
{:ok, matches} =
  DocShell.Presentation.SearchAdapter.JSON.query(site.search, "validated", locale: "en")

Enum.map(matches, &{&1.id, &1.route})
```

## External artifact blocks (experimental)

External illustrations and other typed artifact blocks have an
[experimental static implementation](../docs/specs/portable-artifact-capabilities.md).

This notebook continues to exercise the unchanged v1 path. The executable
artifact tests separately exercise verified fallback, unknown optional profiles,
per-state runtime selection and the same static exporter.

Runtime requirement ordering does not change selection. Script-free export
rejects browser JavaScript MIME aliases, case variants and MIME parameters
before publication. Empty sites still validate their artifact options.

A host passes
`:artifact_options` explicitly; it never starts an artifact producer implicitly.
Independent Svelte/HEEx archive reader observations are recorded in the shared
plan. Producer algorithms and native applications stay with their consumers.

## Clean up

```elixir
File.rm_rf!(workspace)
:ok
```

## Artifact input budgets in DocShell 2.0.0

For artifact-bearing pages, pass positive `:limits` through `:artifact_options`.
`ArtifactLimits.new/1` supplies defaults for page/AST/JSON size and depth,
block/binding/variant counts, descriptors, manifests, projections and assets.
`ArtifactBlock.project_many/2` validates every page before loading descriptors,
then loads each descriptor once per invocation. Static export uses this batch
path and applies the lower site page/depth ceilings. A late invalid page cannot
invoke an earlier page's producer or replace the prior output. These APIs are
available in DocShell 2.0.0; they do not relax source authentication,
producer limits or renderer qualification.

The packaged `priv/contracts/artifact-contracts-v1.schema.json` contains
structural definitions for nodes, descriptors, variants, capabilities, receipts
and export manifests. `artifact-conformance-v1.json` contains frozen synthetic
chart/drawing inputs, exact original bytes, expected selections and semantic
observations. Tests use a separate schema engine only in development/test;
DocShell core does not acquire that runtime dependency. These vectors do not
establish external producer or native-reader conformance.

In file-reader mode, resolve selected asset URLs against
`presentation-manifest.json` itself. HTML has page-relative URLs; the manifest
has output-root-relative paths and lists only selected artifact assets. Web
mode retains root-relative URLs.

Before displaying an existing artifact receipt, call
`ArtifactBlock.verify_receipt(content, receipt)`. It checks the portable binding
without producer callbacks or a second presentation choice. The original
producer admission and source authenticity remain host prerequisites. A static
renderer receives selected original bytes in `context.artifact_assets` as
`%{"media_type" => type, "base64" => encoded_bytes}` records, so it can check
its own media subset and raw digest without fetching URLs or reading files.

Artifact binding targets must be textual AST `attrs.id` values. JSON arrays,
objects, numbers and booleans cannot stand in for an anchor that readers ignore;
a genuine textual target may be outside the artifact's fallback subtree.
The void `br` element must have empty content; putting fallback text under it
fails admission rather than disappearing in HTML output.
