Managoat.Docs.Checks (managoat_docs v0.1.0)

Copy Markdown View Source

The structural checks on an embedded manual, as functions that return a list of failure messages (empty when the manual is sound). Managoat.Docs.GuardrailCase wraps each one as an ExUnit test; they are functions first so a host can run one from a script, a release task or a test of its own shape.

Each check exists because of an incident. The messages say which, and what to do; they are kept verbatim from Fountain's docs_test.exs because they are the documentation of the incident and better than a summary of it.

Every function takes the host's docs module (the one that uses Managoat.Docs) and reads the manual through its generated functions, so a check runs against what the module embedded, not a re-read of the files.

Summary

Types

A failure message. An empty list is a pass.

Functions

Every internal #anchor link targets a heading id on the rendered target page. The rendered HTML is the ground truth: to_trusted_html/1 gives every heading a GFM-style id, and comrak's slug is the only one that matters. (The MkDocs build that slugged the same headings differently for duplicate headings, comrak -1 against python-markdown _1, is the reason no second slugging pass is allowed.)

The source of every plain COPY src dst line in a Dockerfile (flags such as --from excluded).

Every file the docs module reads at compile time (external_resources/0) exists and is COPYd into the image's build stage.

{file, info} for every fence in the given markdown files.

Every fenced code block in the manual (compiled bodies, so snippet includes count) names a language whose parser languages/0 bakes, or one in unhighlightable. A fence in a language that is not baked renders unhighlighted in production and nowhere else, which is how it goes unnoticed (Fountain #879).

Every internal link under the mount targets a page that exists.

Every name in languages/0 is a Lumis language id, so the Dockerfile's Lumis.Languages.cache/1 step succeeds.

Every page the nav names exists on disk.

No authoring syntax survives preprocessing: every relative .md link was rewritten to a path under the mount, every admonition became a blockquote, every snippet include was expanded. A page that introduces syntax the compiler does not rewrite is caught here.

Every .md page on disk under the docs directory is named in the nav.

Every slug resolves to a page with a title and a non-empty body, and the home page is at "".

The search index has exactly one entry per page.

Every heading id in the search index resolves on its own rendered page, and no heading has empty text. Same ground truth as anchors_resolve/1.

search_index_json/0 round-trips through JSON to the same number of entries as search_index/0, and contains no </ that could close the <script> tag a layout inlines it into.

{page, path} for every snippet include in the manual's source pages.

Every --8<-- "path" snippet include in the manual points at a file that exists and that the Dockerfile COPYs into the build stage.

fences_baked/2 over an explicit {where, info} list, for a host that has authored markdown outside the manual (an in-app help directory, say). fenced_languages/1 scans files into that shape.

Types

failure()

@type failure() :: String.t()

A failure message. An empty list is a pass.

Functions

anchors_resolve(docs)

@spec anchors_resolve(module()) :: [failure()]

Every internal #anchor link targets a heading id on the rendered target page. The rendered HTML is the ground truth: to_trusted_html/1 gives every heading a GFM-style id, and comrak's slug is the only one that matters. (The MkDocs build that slugged the same headings differently for duplicate headings, comrak -1 against python-markdown _1, is the reason no second slugging pass is allowed.)

dockerfile_copies(dockerfile)

@spec dockerfile_copies(Path.t()) :: [String.t()]

The source of every plain COPY src dst line in a Dockerfile (flags such as --from excluded).

external_resources_copied(docs, dockerfile)

@spec external_resources_copied(module(), Path.t()) :: [failure()]

Every file the docs module reads at compile time (external_resources/0) exists and is COPYd into the image's build stage.

The generalisation of snippets_copied/2. Snippets are found by scanning markdown; this asks the module itself what it read, so a new kind of compile-time dependency is covered without anyone remembering to extend a scanner. The nav file is the case that motivated it: it is parsed at compile time, and nothing about a snippet scan would ever have noticed it was missing from the Dockerfile.

fenced_languages(files)

@spec fenced_languages([Path.t()]) :: [{String.t(), String.t()}]

{file, info} for every fence in the given markdown files.

fences_baked(docs, unhighlightable)

@spec fences_baked(module(), [String.t()]) :: [failure()]

Every fenced code block in the manual (compiled bodies, so snippet includes count) names a language whose parser languages/0 bakes, or one in unhighlightable. A fence in a language that is not baked renders unhighlighted in production and nowhere else, which is how it goes unnoticed (Fountain #879).

unhighlightable names the fences exempt from the rule: the ones that ask for no highlighting (text, plain, ...) and the ones Lumis has no parser for. Adding to it means checking that Lumis really has no parser, not that baking one is inconvenient.

languages_known(docs)

@spec languages_known(module()) :: [failure()]

Every name in languages/0 is a Lumis language id, so the Dockerfile's Lumis.Languages.cache/1 step succeeds.

no_leftover_syntax(docs)

@spec no_leftover_syntax(module()) :: [failure()]

No authoring syntax survives preprocessing: every relative .md link was rewritten to a path under the mount, every admonition became a blockquote, every snippet include was expanded. A page that introduces syntax the compiler does not rewrite is caught here.

pages_on_disk_named(docs)

@spec pages_on_disk_named(module()) :: [failure()]

Every .md page on disk under the docs directory is named in the nav.

The other direction, and the one Fountain had no gate for while a static site build still existed. MkDocs built every page under docs/ whether the nav named it or not, so a page left out of the nav was still reachable on the published site and its absence from the in-app manual looked like a rendering quirk rather than a mistake. Four such pages had accumulated.

With one publisher, a page missing from the nav is a page that is published nowhere at all: written, merged, and invisible. There is no allowlist on purpose. Somewhere else in the repository is the right home for a markdown file nobody should read here.

pages_resolve(docs)

@spec pages_resolve(module()) :: [failure()]

Every slug resolves to a page with a title and a non-empty body, and the home page is at "".

search_index_complete(docs)

@spec search_index_complete(module()) :: [failure()]

The search index has exactly one entry per page.

search_index_headings_resolve(docs)

@spec search_index_headings_resolve(module()) :: [failure()]

Every heading id in the search index resolves on its own rendered page, and no heading has empty text. Same ground truth as anchors_resolve/1.

search_index_json_safe(docs)

@spec search_index_json_safe(module()) :: [failure()]

search_index_json/0 round-trips through JSON to the same number of entries as search_index/0, and contains no </ that could close the <script> tag a layout inlines it into.

snippet_paths(docs)

@spec snippet_paths(module()) :: [{String.t(), String.t()}]

{page, path} for every snippet include in the manual's source pages.

snippets_copied(docs, dockerfile)

@spec snippets_copied(module(), Path.t()) :: [failure()]

Every --8<-- "path" snippet include in the manual points at a file that exists and that the Dockerfile COPYs into the build stage.

The manual inlines snippets at compile time, reading from root. Inside the image that root is whatever the Dockerfile COPYed, so a snippet pointing outside it does not degrade: mix release dies on File.read! and no image is produced at all. That is a silent failure in the worst place: CI is green, the PR merges, and the deploy simply never happens. It has happened once (Fountain's docs/tour.md including an SDK example).

unbaked_fences(languages, fences, unhighlightable)

@spec unbaked_fences([String.t()], [{String.t(), String.t()}], [String.t()]) :: [
  failure()
]

fences_baked/2 over an explicit {where, info} list, for a host that has authored markdown outside the manual (an in-app help directory, say). fenced_languages/1 scans files into that shape.