Managoat.Docs.Compiler (managoat_docs v0.1.0)

Copy Markdown View Source

Pure functions use Managoat.Docs runs at compile time to turn the authoring dialect of a manual into plain markdown. Split out because a module cannot call its own functions from its own body, and so the transforms can be tested directly.

Covers exactly the dialect a manual may use (see the Managoat.Docs moduledoc): snippet includes, admonitions, and relative .md links. The syntax is inherited from the MkDocs Material site Fountain's pages were published as until they moved in-app; this is not, and never was, a general MkDocs renderer.

Every function that produces a served path takes the manual's mount ("/docs" for a manual served at /docs), because the only place that path is known is the host's use line.

Summary

Types

One search-index entry: the page and the h2-h6 headings on it.

{title, file} for a page, {section_title, [{title, file}]} for a section.

One compiled page: its nav title and its preprocessed markdown.

Functions

Reads a nav file and every page it names into the shape use Managoat.Docs stores: the parsed nav, the slug => page map, and the search index over the rendered headings. Runs in the host module's body at compile time; the host declares every path here as an @external_resource.

Pulls {id, text} for every <h2><h6> out of a page's rendered HTML, in document order. The input is Managoat.Docs.Markdown.to_trusted_html/1 output, not markdown. <h1> is skipped; every page has exactly one, and it duplicates the nav title a search result already shows next to it.

Flattens a nav (sections one level deep) to {title, file} pairs in order.

Parses the nav: block of a nav.yml into the shape use Managoat.Docs serves: {title, file} for a page and {section_title, [{title, file}, ...]} for a section, in document order.

With mount "/docs": """/docs", "setup""/docs/setup".

Snippets, then admonitions, then link rewriting. file is the page's path relative to the docs directory (links resolve against its directory), root is where snippet includes are read from, and mount: is the path the manual is served at (default "/docs").

"index.md""", "integrations/index.md""integrations", else the rootname.

Types

index_entry()

@type index_entry() :: %{
  title: String.t(),
  slug: String.t(),
  headings: [%{id: String.t(), text: String.t()}]
}

One search-index entry: the page and the h2-h6 headings on it.

nav_entry()

@type nav_entry() :: {String.t(), String.t() | [{String.t(), String.t()}]}

{title, file} for a page, {section_title, [{title, file}]} for a section.

page()

@type page() :: %{title: String.t(), body: String.t()}

One compiled page: its nav title and its preprocessed markdown.

Functions

compile(root, docs_dir, nav_file, mount)

@spec compile(String.t(), String.t(), String.t(), String.t()) :: %{
  nav: [nav_entry()],
  pages: %{required(String.t()) => page()},
  search_index: [index_entry()]
}

Reads a nav file and every page it names into the shape use Managoat.Docs stores: the parsed nav, the slug => page map, and the search index over the rendered headings. Runs in the host module's body at compile time; the host declares every path here as an @external_resource.

extract_headings(html)

@spec extract_headings(String.t()) :: [%{id: String.t(), text: String.t()}]

Pulls {id, text} for every <h2><h6> out of a page's rendered HTML, in document order. The input is Managoat.Docs.Markdown.to_trusted_html/1 output, not markdown. <h1> is skipped; every page has exactly one, and it duplicates the nav title a search result already shows next to it.

Reads the id comrak already assigned rather than re-deriving one from the heading text. Slugging headings a second time is a known trap: the MkDocs build Fountain's pages came from and comrak's header_id_prefix extension picked different ids for duplicate headings on the same page (-1 vs _1), and a search result is only useful if its anchor is the one the page actually renders. The anchor guardrail leans on the same rendered output, for the same reason.

flat_pages(nav)

@spec flat_pages([nav_entry()]) :: [{String.t(), String.t()}]

Flattens a nav (sections one level deep) to {title, file} pairs in order.

parse_nav(yaml)

@spec parse_nav(String.t()) :: [nav_entry()]

Parses the nav: block of a nav.yml into the shape use Managoat.Docs serves: {title, file} for a page and {section_title, [{title, file}, ...]} for a section, in document order.

Deliberately not a YAML parser, and it does not need to be: the nav is a hand-written two-level list, and this reads the two line shapes it can take. What matters is the failure mode: a line it does not recognise raises rather than being skipped. A page silently missing from the manual is exactly the bug this parser exists to make impossible, so being unable to parse the nav has to fail the compile, not shrink the site.

Two levels is the whole depth. A sidebar that embeds this nav renders exactly a section and its pages, so a third tier raises here with a message that says to flatten it. That includes a page indented past its siblings, which would otherwise be quietly promoted into its grandparent section, the one silent outcome this parser must not have.

This replaced a hand-maintained copy of the nav in Fountain's docs module. Adding a page to nav.yml is now the whole change.

path_for_slug(slug, mount)

@spec path_for_slug(String.t(), String.t()) :: String.t()

With mount "/docs": """/docs", "setup""/docs/setup".

preprocess(text, file, root, opts \\ [])

@spec preprocess(String.t(), String.t(), String.t(), [{:mount, String.t()}]) ::
  String.t()

Snippets, then admonitions, then link rewriting. file is the page's path relative to the docs directory (links resolve against its directory), root is where snippet includes are read from, and mount: is the path the manual is served at (default "/docs").

slug_for(file)

@spec slug_for(String.t()) :: String.t()

"index.md""", "integrations/index.md""integrations", else the rootname.