DocShell's JSON artifacts are a public API. Renderers in other repositories consume them and release independently. This notebook builds a small set of artifacts so you can inspect each file and its fields.

The examples write to a disposable temp directory. They do not modify priv/doc_shell/.

What is public API

The contract includes:

  • the artifact tree
  • the envelope around every JSON payload
  • the doc-shell/v2 schema version
  • the shape of navigation.json, search-index.json, content.json, modules.json, guides.json, livebooks.json, changelog.json, openapi.json, and manifest.json
  • the in-memory presentation shape accepted from graph-backed hosts

Changing one of those shapes is a breaking change unless it is strictly backward-compatible.

Setup

Run this notebook from Livebook's default standalone runtime. The setup cell installs the matching DocShell release from Hex. Release tooling updates this version together with the package and the notebook links.

Mix.install([
  {:doc_shell, "== 1.0.0"}
])

Application.ensure_all_started(:doc_shell)
DocShell.schema_version()

Build a small artifact tree

The fixture has one module, one Markdown guide, one Livebook notebook, and the default empty OpenAPI document.

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

File.rm_rf!(workspace)
File.mkdir_p!(workspace)
workspace = File.cd!(workspace, &File.cwd!/0)

guide_dir = Path.join(workspace, "guides")
livebook_dir = Path.join(workspace, "notebooks")
public_dir = Path.join(workspace, "public")
private_dir = Path.join(workspace, "private")

Enum.each([guide_dir, livebook_dir, public_dir, private_dir], &File.mkdir_p!/1)

File.write!(Path.join(guide_dir, "contract-guide.md"), """
---
id: contract-guide
title: Contract Guide
audience: developers
locale: en
---

# Contract Guide

The renderer reads this guide through `content.json`.
""")

File.write!(Path.join(livebook_dir, "operator-runbook.livemd"), """
# Operator Runbook

Operational notebooks are indexed as documentation, but DocShell does not run
their code.

```elixir
:ok
```
""")

build_opts = [
  modules: [DocShell.Config],
  guide_bases: [guide_dir],
  livebook_base: livebook_dir,
  public_dir: public_dir,
  private_dir: private_dir
]

{:ok, result} = DocShell.Build.run(build_opts)

%{
  public_dir: public_dir,
  private_dir: private_dir,
  presentation_keys: Map.keys(result.presentation) |> Enum.sort()
}

Site-ready collections

Pass a DocShell.Generate.Collection descriptor when a host will publish this build alongside other documentation. The descriptor binds the artifact tree to an exact source checkout. The caller supplies and verifies the revision and tree digest; DocShell records them without running Git or making network requests.

Each document has a unique ID and matching provenance. Several releases from one changelog can share a source path, but each has its own document ID. The synthetic API document reserves the ID openapi in collection mode.

Loading validates source-index entries and exact provenance coverage before qualifying IDs. Unknown source kinds follow the generic source-index contract and retain their kind and metadata. Other extension artifacts remain available as opaque decoded payloads.

DocShell.Json.Canonical.encode/1 and digest/1 reject duplicate encoded object keys and return tagged errors. Collection.digest/1 is a convenience for encodable input and raises on invalid values. Portable canonical byte/digest fixtures ship in priv/contracts/.

The collection checks also cover content and serialization:

  • Canonical bytes distinguish integers from floats. Projection compatibility uses the same strict distinction.
  • Embedded index ASTs must agree with content. The synthetic OpenAPI source has no page AST.
  • Native JSON hashing avoids a redundant encode/decode pass. Other values use the checked protocol path.
  • Artifact writes reject invalid protocol output and host encoder exceptions before replacement. Source extension fields must be native JSON in ordinary builds too.

The collection integrity section of docs/specs/DSH.01-documentation-sites.md defines the required behavior; docs/plans/documentation-sites.md records the implementation's acceptance status. A custom projector may not discard or change nonempty source ASTs in collection mode. Such a build must fail before publishing; the library must not restore excluded public content implicitly.

collection = %{
  id: "doc_shell",
  title: "DocShell",
  version: "0.3.0",
  revision: String.duplicate("a", 40),
  tree_digest: "sha256:" <> String.duplicate("b", 64),
  artifact_dir: public_dir,
  source_url: "https://example.invalid/doc_shell",
  edit_base_url: "https://example.invalid/doc_shell/edit/revision"
}

{:ok, _result} =
  File.cd!(workspace, fn ->
    DocShell.Build.run(Keyword.put(build_opts, :collection, collection))
  end)
{:ok, collection_payload} =
  DocShell.Artifact.read(Path.join(public_dir, "collection.json"))

Map.take(collection_payload, ["schema_version", "descriptor", "content_digest"])

collection.json is an additive public artifact. Its source records and artifact digests let an importer validate the complete generation, and DocShell.Generate.Collection.load/1 qualifies document IDs as doc_shell:document_id without starting the host application.

Import uses finite limits:

LimitDefault
One file32 MiB
Total bytes per corpus, including the manifest2 GiB
Artifacts per corpus256
Sources per corpus100,000
JSON nesting depth64
Collections per load_many/2 call256

Override limits with positive keywords, for example Collection.load(descriptor, max_file_bytes: 8 * 1_024 * 1_024).

The directory must remain caller-owned and stable during import:

  • Symlink roots (even with / or /. suffixes), untrusted symlink components, and .. traversal are rejected.
  • Ancestors of the existing working/system temporary directories are trusted platform aliases.
  • Duplicate JSON keys fail rather than being overwritten.

The artifact tree

DocShell writes public renderer artifacts under :public_dir and a separate manifest under :private_dir.

Path.wildcard(Path.join(workspace, "**/*.json"))
|> Enum.map(&Path.relative_to(&1, workspace))
|> Enum.sort()

The three files most renderers read directly are:

  • navigation.json
  • search-index.json
  • content.json

The source indexes and openapi.json are still public artifacts. They are useful for ingestion, search, coverage reporting, and API reference tooling.

The envelope

Every artifact file has the same outer object. DocShell.Artifact.read/1 returns only "data"; use read_envelope/1 when the envelope itself matters.

{:ok, navigation_envelope} =
  DocShell.Artifact.read_envelope(Path.join(public_dir, "navigation.json"))

Map.take(navigation_envelope, ["schema_version", "generated_at", "generation_id"])

The fields are:

FieldMeaning
schema_versionThe public contract version, currently doc-shell/v2
generated_atISO 8601 UTC timestamp for the build
generation_idOpaque id shared by every artifact in one build
dataThe payload for that artifact

generation_id is only for equality checks. Do not sort by it, decode meaning from it, or reuse it between builds.

The file itself is compact canonical JSON: keys sorted by UTF-8 bytes and no insignificant whitespace. Build with pretty: true to read it by eye; the decoded value is the same.

doc-shell/v2 replaced doc-shell/v1. Version 2 moved module member documentation out of navigation.json, added member doc_state, limited member metadata to an allow-list by default, and switched files to compact canonical JSON. A v2 reader rejects v1 files.

One generation per tree

Every public artifact and the public manifest from one build share a generation id. The runtime cache uses that to reject mixed snapshots.

Path.wildcard(Path.join(public_dir, "*.json"))
|> Map.new(fn path ->
  {:ok, envelope} = DocShell.Artifact.read_envelope(path)
  {Path.basename(path), envelope["generation_id"]}
end)

The value should be the same for every file in that map.

manifest.json

The manifest describes exactly the artifacts beside it. It is written last and marks a committed generation.

{:ok, public_manifest} = DocShell.Artifact.read(Path.join(public_dir, "manifest.json"))

public_manifest["artifacts"] |> Enum.sort()

The private directory has its own manifest. Today the default build writes no private artifacts, so the list is empty.

DocShell.Artifact.read(Path.join(private_dir, "manifest.json"))

Each manifest describes its own directory; public and private directories need separate manifests.

navigation.json is a list of navigation items. On disk, structs have encoded to maps with string keys.

{:ok, navigation} = DocShell.Artifact.read(Path.join(public_dir, "navigation.json"))

navigation
|> Enum.map(&Map.take(&1, ["id", "title", "path", "kind", "children", "meta"]))

A module's item lists its documented members as references, never their documentation. Members marked @doc false are left out; anchor follows ExDoc's fragment convention so a renderer can link into the module page.

navigation
|> Enum.filter(&(&1["kind"] == "module"))
|> Enum.map(&{&1["id"], Enum.take(&1["meta"]["members"], 3)})

The default DocShell.Presentation.StaticGenerator sorts entries by kind then title and leaves children empty. The host supplies any hierarchy, such as grouping modules by namespace or guides by product area.

search-index.json

search-index.json has document identity, route path, flattened text, optional tokens, and scoping fields.

{:ok, search} = DocShell.Artifact.read(Path.join(public_dir, "search-index.json"))

search
|> Enum.find(&(&1["id"] == "contract-guide"))
|> Map.take(["id", "title", "path", "kind", "audience", "locale", "content", "tokens"])

audience and locale are present as null when unset. For guides, they come from frontmatter. For modules and Livebooks they are usually null.

Tokens are present but empty by default because they duplicate data already in content. Enable them only when a host search backend wants a pre-split field.

{:ok, token_result} = DocShell.Build.run(Keyword.put(build_opts, :search_tokens, true))

token_result.presentation.search
|> Enum.find(&(&1.id == "contract-guide"))
|> Map.take([:id, :tokens])

content.json

content.json maps each entry id to its parsed Markdown AST. This is where page bodies live.

{:ok, content} = DocShell.Artifact.read(Path.join(public_dir, "content.json"))

Map.keys(content) |> Enum.sort()

A content node is recursive. Text nodes are plain strings. Element nodes always carry the same four keys: tag, attrs, content, and meta.

content["contract-guide"] |> List.first()

That uniform shape is why a renderer can use one walker for module docs, guides, and notebooks.

modules.json, guides.json, livebooks.json, and changelog.json

The per-source indexes carry identity and metadata for every extracted entry. They intentionally do not carry "ast"; the body already lives once in content.json.

{:ok, module_index} = DocShell.Artifact.read(Path.join(public_dir, "modules.json"))
{:ok, guide_index} = DocShell.Artifact.read(Path.join(public_dir, "guides.json"))
{:ok, livebook_index} = DocShell.Artifact.read(Path.join(public_dir, "livebooks.json"))
{:ok, changelog_index} = DocShell.Artifact.read(Path.join(public_dir, "changelog.json"))

%{
  modules: Enum.map(module_index, &Map.take(&1, ["id", "title", "kind", "meta"])),
  guides: Enum.map(guide_index, &Map.take(&1, ["id", "title", "kind", "meta"])),
  livebooks: Enum.map(livebook_index, &Map.take(&1, ["id", "title", "kind", "meta"])),
  changelog: Enum.map(changelog_index, &Map.take(&1, ["id", "title", "kind", "meta"]))
}

The source indexes are unfiltered. If skip_empty removes an undocumented module from presentation, the module still appears in modules.json, which makes the file useful as a coverage report.

modules.json is the only artifact that carries member documentation. Each member has kind, name, arity, signatures, doc, doc_state ("present", "hidden" or "none") and metadata. Metadata is limited to the keys in DocShell.Generate.ExDoc.default_member_metadata/0 unless the build sets member_metadata.

module_index
|> Enum.flat_map(& &1["meta"]["members"])
|> Enum.take(2)

The doc-shell/v2 source catalogue is additive. Producers may add a new per-source index to manifest.json, and entry kind values are open strings. Generic renderers ignore unknown source files and kinds; selective consumers may continue reading only their supported artifacts. Removing a known artifact or removing, renaming, or retyping a known field requires a schema-version change.

module_index
|> List.first()
|> Map.has_key?("ast")

openapi.json

openapi.json contains the OpenAPI document returned by the configured adapter. With no adapter, DocShell writes a valid empty OpenAPI 3.1 document.

{:ok, openapi} = DocShell.Artifact.read(Path.join(public_dir, "openapi.json"))

Map.take(openapi, ["openapi", "info", "paths"])

Because the artifact is enveloped, standard OpenAPI tooling should not be pointed at priv/doc_shell/public/openapi.json. Set :openapi_spec_path when a tool needs the bare OpenAPI document.

In-memory presentation vs. disk JSON

Before encoding, presentation data uses structs and atom keys.

result.presentation.navigation |> List.first()

After reading from disk, the same artifact is JSON data with string keys.

navigation |> List.first()

Application code uses typed structs; artifacts contain plain JSON.

Graph-backed presentation

Graph-backed hosts can provide their own presentation data through DocShell.Presentation.GraphProjector. The required fields are schema version, navigation, search, and content. backlinks are optional.

presentation = %{
  schema_version: DocShell.schema_version(),
  navigation: [],
  search: [],
  content: %{},
  backlinks: %{
    "contract-guide" => [
      %DocShell.Presentation.Backlink{
        id: "operator-runbook",
        title: "Operator Runbook",
        path: "/docs/livebook/operator-runbook"
      }
    ]
  }
}

DocShell.Presentation.GraphProjector.validate(presentation)

The validator catches malformed projector output before a renderer receives it.

Contract change checklist

Before changing any doc-shell/v2 shape, answer these questions:

QuestionWhy it matters
Does a renderer already read this field?Removing or retyping it is breaking
Can the change be additive and optional?Optional additions are usually safe
Does the value stay JSON-native?Artifacts must not leak Elixir-only terms
Does the schema version need to change?Breaking changes require coordination
Are README, usage rules, and tutorials updated?The contract docs are part of the API

If a renderer could observe the change, treat it as public API work.

Rendering untrusted content

The AST preserves raw HTML and URL schemes. Renderers must allow-list tags and attributes, reject unsafe URL schemes, and escape text for their output context. Parsing Markdown is not sanitization.

Recursive content validation

Host projectors and changelog sources must provide complete recursive AST nodes and JSON metadata with string keys. Invalid nested content fails validation before output is written. DocShell.Ast.valid?/1 checks node lists.

Concurrent artifact writers

Individual artifact writes use exclusively created random temporary files in the destination directory, so independent BEAM instances cannot share a temporary file. A rename publishes each complete file.

Search text

Search content preserves adjacent inline text, including words split by formatting. Block elements and line breaks add separators; image alt text is searchable. Token generation uses this same text.

Document paths

Each document path is calculated once and reused by navigation and search. Default paths percent-encode kind and ID as individual URL segments. Use a custom path_builder when IDs intentionally represent a path hierarchy.

Legacy envelope compatibility

Artifact.read/1 and read_envelope/1 accept current-schema envelopes without generation_id. A present ID must be a nonempty string. Previous schema versions, including doc-shell/v1, are rejected and must be rebuilt. Runtime caches require an ID on every artifact and manifest to verify one generation.

Presentation structs serialize through Jason.Encoder into the documented string-keyed objects. DocShell.Json.stringify/1 instead converts arbitrary structs into text and must not be used to serialize presentation structs. Projector backlinks are validated and available in the in-memory build result; the build does not write a backlinks artifact.