Skip to content

document_site

Build a static HTML documentation site from a compiled ODD or Guidelines document.

opm.document_site

Build a static HTML documentation site from a compiled ODD or Guidelines document.

prepare_document_tree turns the compiled schema into a TEI tree that already holds every page (title page, chapters, catalogs, reference entries) and the schema-wide content those pages show — spec relations, A–Z lists, and so on, via expand_document_tree.

build_document_site then runs chunk_document three times over that tree, as configured in resources/document/opm.toml: Guidelines chapters, the home page (the titlePage), and one HTML page per spec. tagdocs.odd supplies the processing models that render each chunk.

DocumentSite dataclass

DocumentSite(
    output_dir: Path,
    index: SpecIndex,
    pages: int,
    chapters: int,
    unsupported: list[dict] = list(),
    warnings: list[str] = list(),
)

Result of build_document_site.

build_document_site

build_document_site(
    compiled: CompiledSchema,
    output_dir: Path | str,
    *,
    lang: str = "en",
    title: str | None = None,
    odd: Path | str | None = None,
    on_progress: ProgressFn | None = None,
    config_path: Path | str | None = None,
) -> DocumentSite

Write a static HTML site for compiled into output_dir.

config_path is the opm.toml to build with: a documentation project's (opm init --example odd), or by default the packaged one. It supplies the ODD, the chunking runs, the page template and the extension modules; the page assets and nav.xml come from the page template's directory.

Source code in src/opm/document_site.py
def build_document_site(
    compiled: CompiledSchema,
    output_dir: Path | str,
    *,
    lang: str = 'en',
    title: str | None = None,
    odd: Path | str | None = None,
    on_progress: ProgressFn | None = None,
    config_path: Path | str | None = None,
) -> DocumentSite:
    """Write a static HTML site for *compiled* into *output_dir*.

    *config_path* is the ``opm.toml`` to build with: a documentation project's
    (``opm init --example odd``), or by default the packaged one. It supplies
    the ODD, the chunking runs, the page template and the extension modules;
    the page assets and ``nav.xml`` come from the page template's directory.
    """
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    config_file = Path(config_path) if config_path else packaged_document_dir() / 'opm.toml'
    base_cfg = load_project_config(config_file)
    site_dir = site_assets_dir(base_cfg, config_file)

    tree, index = _prepare(compiled, lang=lang, title=title, nav=site_dir / 'nav.xml')
    site_title = title or index.title
    if odd:
        odd_path = Path(odd)
    elif config_path and base_cfg.chunking and base_cfg.chunking.odd:
        odd_path = base_cfg.chunking.odd
    else:
        odd_path = packaged_odd('tagdocs')
    header_source = compiled.tree

    cfg = replace(
        base_cfg,
        template_context={
            **base_cfg.template_context,
            'site_name': site_title,
            'lang': lang,
            'edition': _edition_line(compiled.tree) or _edition_line(header_source),
            'rights': _rights_line(header_source) or _rights_line(compiled.tree),
        },
        parameters={**base_cfg.parameters, 'lng': lang, 'mode': 'ref'},
    )
    extensions = tuple(cfg.xpath_extensions) or _TAGDOCS_XPATH_EXTENSIONS
    runs = cfg.chunking_runs or ((cfg.chunking,) if cfg.chunking else ())
    if not runs:
        raise ValueError(f'{config_file} declares no [chunking] run')

    assets = site_assets(site_dir, logo=cfg.template_context.get('logo'))

    chapters = [
        div for div in iter_guideline_chapters(tree)
        if div.get(XML_ID) not in _CATALOG_IDS
    ]
    specs = [
        spec for spec in index.all()
        if spec.kind in {'element', 'class', 'macro', 'datatype'}
    ]
    total = max(len(specs) + len(chapters) + 6, 1)
    written = 0

    def _on_chunk_progress(done: int, _run_total: int) -> None:
        # Several runs, one bar: each continues where the previous ended.
        if on_progress:
            on_progress(written + done, total, 'pages')

    with tempfile.TemporaryDirectory(prefix='opm-document-') as tmp:
        xml_path = Path(tmp) / 'schema.xml'
        xml_path.write_bytes(
            etree.tostring(tree, xml_declaration=True, encoding='utf-8'),
        )
        # In order, each run handed the anchors of those before it: the text
        # first, so a spec page's pointers into the prose resolve
        # (`#SATSRN` → SA.html#SATSRN).
        anchors: dict[str, str] = {}
        for position, run in enumerate(runs):
            # The page assets go into assets/ like any project's, unless the
            # run lists its own.
            chunking = replace(
                run, output_dir='.', odd=odd_path, assets=run.assets or assets,
            )
            anchors = chunk_document(
                module_path=None,
                xml_path=xml_path,
                config=chunking,
                project_root=output_dir,
                template_path=chunking.template,
                on_progress=_on_chunk_progress,
                project_config=cfg,
                xpath_extensions=extensions,
                anchors=anchors,
                merge_manifest=position > 0,
            )
            written = len(set(anchors.values()))

    _write_idents(index, output_dir)
    figures, warnings = figure_files(compiled, tree)
    copy_figures(figures, output_dir)

    module_path, _fresh = ensure_compiled_module(odd_path, output_mode='web')
    mod = load_transform_module(module_path)
    unsupported = [
        entry for entry in getattr(mod, 'ODD_UNSUPPORTED', [])
        if entry.get('odd') == odd_path.name
    ]

    html_pages = list(output_dir.glob('*.html'))
    return DocumentSite(
        output_dir=output_dir,
        index=index,
        pages=len(html_pages),
        chapters=len(chapters),
        unsupported=unsupported,
        warnings=warnings,
    )

prepare_document_tree

prepare_document_tree(
    compiled: CompiledSchema,
    *,
    lang: str = "en",
    title: str | None = None,
    nav: Path | None = None,
) -> _Element

Return a deep copy of compiled ready for opm chunk / tagdocs.

nav is the sidebar nav.xml to inject; the packaged copy is used when omitted.

The preparation has three steps:

  1. Normalize. Drop Guidelines appendix dumps that our catalog pages replace and give every page node an xml:id. Specs get ref-{ident} so file_pattern = "{xml_id}.html" matches the URLs tagdocs emits.

  2. Inject site pages. Chunking selects nodes by XPath, so every page needs a node in the tree. The home page is front/titlePage, built from the teiHeader when the document has none. A–Z catalogs go into text/back as appendices. The sidebar list goes into text/body.

  3. Expand. expand_document_tree fills in relations, catalog lists, and the other content each page shows.

Source code in src/opm/document_site.py
def prepare_document_tree(
    compiled: CompiledSchema,
    *,
    lang: str = 'en',
    title: str | None = None,
    nav: Path | None = None,
) -> etree._Element:
    """Return a deep copy of *compiled* ready for ``opm chunk`` / tagdocs.

    *nav* is the sidebar ``nav.xml`` to inject; the packaged copy is used when
    omitted.

    The preparation has three steps:

    1. **Normalize.** Drop Guidelines appendix dumps that our catalog pages
       replace and give every page node an ``xml:id``. Specs get
       ``ref-{ident}`` so ``file_pattern = "{xml_id}.html"`` matches the URLs
       tagdocs emits.

    2. **Inject site pages.** Chunking selects nodes by XPath, so every page
       needs a node in the tree. The home page is ``front/titlePage``, built
       from the ``teiHeader`` when the document has none. A–Z catalogs go into
       ``text/back`` as appendices. The sidebar list goes into ``text/body``.

    3. **Expand.** [`expand_document_tree`][opm.odd_expand.expand_document_tree]
       fills in relations, catalog lists, and the other content each page
       shows.
    """
    return _prepare(compiled, lang=lang, title=title, nav=nav)[0]

site_assets_dir

site_assets_dir(
    cfg: ProjectConfig, config_file: Path
) -> Path

Where the page assets and nav.xml live: beside the page template.

The first run with a template decides; without one, the directory of config_file.

Source code in src/opm/document_site.py
def site_assets_dir(cfg: ProjectConfig, config_file: Path) -> Path:
    """Where the page assets and ``nav.xml`` live: beside the page template.

    The first run with a template decides; without one, the directory of
    *config_file*.
    """
    runs = cfg.chunking_runs or ((cfg.chunking,) if cfg.chunking else ())
    for run in runs:
        if run.template is not None:
            return Path(run.template).parent
    return config_file.parent

site_assets

site_assets(
    site_dir: Path | None = None, logo: str | None = None
) -> tuple[Path, ...]

The stylesheets, scripts, fonts and logo every page links from assets/.

Each is taken from site_dir where it has one — a project's own — else from the packaged resources/document, so a project only keeps the files it changes. logo is context.logo: a bare filename found in site_dir is added, since the page template resolves it under assets/.

Source code in src/opm/document_site.py
def site_assets(site_dir: Path | None = None, logo: str | None = None) -> tuple[Path, ...]:
    """The stylesheets, scripts, fonts and logo every page links from assets/.

    Each is taken from *site_dir* where it has one — a project's own — else
    from the packaged ``resources/document``, so a project only keeps the
    files it changes. *logo* is ``context.logo``: a bare filename found in
    *site_dir* is added, since the page template resolves it under assets/.
    """
    packaged = Path(str(resources.files('opm').joinpath('resources/document')))

    def source(name: str) -> Path:
        own = site_dir / name if site_dir is not None else None
        return own if own is not None and own.exists() else packaged / name

    found = [source(name) for name in SITE_ASSETS]
    if (
        logo
        and site_dir is not None
        and logo not in SITE_ASSETS
        and '/' not in logo
        and not logo.startswith(('.', 'http'))
        and (site_dir / logo).is_file()
    ):
        found.append(site_dir / logo)
    return tuple(found)

figure_urls

figure_urls(tree: _Element) -> list[str]

The relative graphic/@url values tree shows, in document order.

A graphic quoted in an example is markup being explained, not a figure; an absolute URL, or one climbing out of the output, is left as it is.

Source code in src/opm/document_site.py
def figure_urls(tree: etree._Element) -> list[str]:
    """The relative ``graphic/@url`` values *tree* shows, in document order.

    A graphic quoted in an example is markup being explained, not a figure;
    an absolute URL, or one climbing out of the output, is left as it is.
    """
    seen: dict[str, None] = {}
    for graphic in tree.iter(qn('graphic')):
        url = (graphic.get('url') or '').strip()
        if url and _is_local_url(url) and not _inside_egxml(graphic):
            seen.setdefault(url, None)
    return list(seen)

figure_files

figure_files(
    compiled: CompiledSchema, tree: _Element
) -> tuple[dict[str, Path], list[str]]

Map each figure tree shows to its file, and say which were not found.

Figures resolve against the documented source, as they do in its own repository. For the TEI Guidelines that is the cached p5all.xml, and their Images/ are fetched beside it the first time a page needs one.

Source code in src/opm/document_site.py
def figure_files(
    compiled: CompiledSchema, tree: etree._Element,
) -> tuple[dict[str, Path], list[str]]:
    """Map each figure *tree* shows to its file, and say which were not found.

    Figures resolve against the documented source, as they do in its own
    repository. For the TEI Guidelines that is the cached ``p5all.xml``, and
    their ``Images/`` are fetched beside it the first time a page needs one.
    """
    urls = figure_urls(tree)
    if not urls:
        return {}, []
    warnings: list[str] = []
    bases: list[Path] = []
    if compiled.source_path is not None:
        source = compiled.source_path
        bases.append(source if source.is_dir() else source.parent)

    def resolve() -> dict[str, Path]:
        found: dict[str, Path] = {}
        for url in urls:
            for base in bases:
                path = base / unquote(url)
                if path.is_file():
                    found[url] = path
                    break
        return found

    found = resolve()
    if len(found) < len(urls) and compiled.fetched_source is not None:
        try:
            bases.append(ensure_guideline_images())
        except SchemaError as exc:
            warnings.append(f'The TEI figures could not be fetched: {exc}')
        else:
            found = resolve()
    missing = [url for url in urls if url not in found]
    if missing:
        shown = ', '.join(missing[:5]) + (', …' if len(missing) > 5 else '')
        warnings.append(
            f'{len(missing)} figure{"s" if len(missing) != 1 else ""} not found, '
            f'shown as broken images: {shown}'
        )
    return found, warnings

copy_figures

copy_figures(figures: dict[str, Path], dest: Path) -> None

Copy figures under dest, each at the path its URL names.

Source code in src/opm/document_site.py
def copy_figures(figures: dict[str, Path], dest: Path) -> None:
    """Copy *figures* under *dest*, each at the path its URL names."""
    for url, path in figures.items():
        target = dest / unquote(url)
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(path, target)

Expanding the tree

Before rendering, everything a page shows that depends on the schema as a whole is written into the prepared tree as plain TEI: the relations of each spec, the A–Z catalogs, the table of contents, heading numbers, chapter navigation and link targets. opm odd document --prepare writes that tree out.

opm.odd_expand

Expand a prepared documentation tree so tagdocs renders it without lookups.

prepare_document_tree ends with expand_document_tree: everything a page shows that depends on the schema's graph or on the document as a whole is computed here, from a SpecIndex, and written into the tree as plain TEI: contained-by, may-contain, members, used-by and the attribute tree onto each spec, the A–Z lists into the catalog pages, and a spec's notes, examples, processing models and content model into sections of their own.

What a page can read off the document itself stays with tagdocs.odd, which resolves it while rendering: a division's outline label, the table of contents, the chapter before and after, how many specs a catalog lists, the chapters a spec cites, and every link from a name to the spec it names.

What depends on the language is left for the ODD to choose: every xml:lang variant of a note, an example or a description stays in the tree. So is what depends on the output format: links are plain pointers to an id (#ref-p, #COEDADD), which the web and the PDF each resolve their own way.

expand_document_tree

expand_document_tree(
    root: _Element, index: SpecIndex
) -> None

Write everything tagdocs needs into root, so it renders without lookups.

index must be built from root before this runs: the relations are read from it, never from what this adds. Each spec that has a page (the copy stamped xml:id="ref-{ident}") gets its relation lists and its sections; the catalog pages get their A–Z lists; and every gi/ident naming a spec points at it.

Source code in src/opm/odd_expand.py
def expand_document_tree(root: etree._Element, index: SpecIndex) -> None:
    """Write everything tagdocs needs into *root*, so it renders without lookups.

    *index* must be built from *root* before this runs: the relations are read
    from it, never from what this adds. Each spec that has a page (the copy
    stamped ``xml:id="ref-{ident}"``) gets its relation lists and its sections;
    the catalog pages get their A–Z lists; and every ``gi``/``ident`` naming a
    spec points at it.
    """
    for spec in index.all():
        if spec.kind not in _PAGE_KINDS or spec.node.get(XML_ID) != f'ref-{spec.ident}':
            continue
        _complete_changed_atts(index, spec)
        for build in _SPEC_LISTS[_page_kind(spec)]:
            spec.node.append(build(index, spec))
        _wrap_sections(spec)
    _fill_catalogs(root, index)

tp: function

The one spec-specific function tagdocs.odd still calls; it formats the node being rendered.

opm.runtime.spec_xpath_functions

The one tp: function tagdocs needs beyond the generic ones.

Everything else a documentation page shows is written into the tree before rendering, by opm.odd_expand; what remains is formatting the node being rendered.

serialize_spec

serialize_spec(node: Any) -> str

Pretty-print a spec subtree (content model, Schematron, pb:template) without a default TEI xmlns declaration.

with_tail=False so mixed-content examples (element + following text) stay well-formed; tails are handled by serialize_egxml.

Source code in src/opm/runtime/spec_xpath_functions.py
def serialize_spec(node: Any) -> str:
    """Pretty-print a spec subtree (content model, Schematron, ``pb:template``)
    without a default TEI xmlns declaration.

    ``with_tail=False`` so mixed-content examples (element + following text)
    stay well-formed; tails are handled by
    [`serialize_egxml`][opm.runtime.common_xpath_functions.serialize_egxml].
    """
    if node is None or node == [] or node == ():
        return ''
    el = expect_element(node, arg_name='serialize_spec(node)')
    copy = etree.fromstring(etree.tostring(el, with_tail=False))
    etree.cleanup_namespaces(copy)
    text = etree.tostring(copy, encoding='unicode', pretty_print=True)
    return text.replace(f' xmlns="{TEI_NS}"', '').strip()