Skip to content

📐 Render a projected document's headings relative to where it is invoked #464

Description

@taras

Story

As the author of a document that invokes another document, I want the invoked
document's headings to fit the outline they land in, so that neither author has
to know how deeply the other nests them.

Current behavior

A projected document emits its headings at the level it authored them,
regardless of where it was invoked. README.md#Bootstrap invokes
scripts/BootstrapNpmPackage.md from inside a level-2 section, and the
component's own title arrives as a level-1 heading underneath it:

## Bootstrap

Adding a package to `packages/*` needs one manual step before CI can release it.
…

# Bootstrapping packages/workflow          <- the invoked document's title

Both documents are right on their own terms. BootstrapNpmPackage.md is
directly runnable, so # is the correct level for its title when it is the
root. README.md is a guide whose sections are ##. The result is an outline
that steps backwards, and neither author wrote anything wrong.

Today the only fixes available are bad ones: demote the heading in the invoked
document and make its standalone run wrong, or promote the invoking section and
distort the guide. Both make one document's structure depend on the other's.

Why this is the engine's question, not the author's

Target discovery already treats a projected heading as not structural.
packages/core/src/document-targets.ts parses a masked copy of the body in
which every top-level component span is replaced by spaces, precisely so a
heading inside projected content cannot surface as an addressable target — and
it does not. deno task xmd targets README.md lists six entries, none of them
from the invoked document.

So the engine already knows these headings do not participate in the outline for
addressing. It just emits them unchanged for reading. Closing that gap is what
makes an invoked document composable: an author writes the level that is correct
when their document is the root, and gets the level that is correct wherever it
is invoked.

Contract

This is a property of the rendered output — the text the run writes out —
not of the document model. A heading is emitted at a level relative to the
depth it is emitted at, rather than at the level its source authored:

  • a document rendering as the root emits its headings unchanged, so a
    standalone run is unaffected;
  • content emitted beneath a level-N heading has its headings shifted so its own
    top level lands at N+1;
  • shifting is relative to the emitted content's own shallowest heading, not to
    a fixed assumption that it starts at #, so a document whose title is ##
    composes the same way;
  • nesting composes — content emitted from content that was itself emitted one
    level down accumulates both shifts;
  • origin does not matter. Because the rule is about what is written out, it
    applies to every heading in the stream however it got there: static Markdown
    in an invoked document, a string projection, output() from an eval block.
    There is no category of emitted heading an author has to reason about
    separately;
  • addressing is unchanged: projected headings remain non-structural, and the
    target catalog does not gain entries; and
  • shifting never refuses. There is no depth at which invoking a document
    becomes an error. Markdown stops at six levels, so a heading the shift would
    push past ###### renders at ###### and the run continues. A document that
    is valid standing alone stays valid wherever it is invoked — a ceiling that
    failed would put a document's validity back in the hands of whoever invoked
    it, which is the coupling this issue exists to remove.

Because nothing about the source changes, expansion identity, retained source
ranges, and the journal are untouched: two runs that differ only in where a
document was invoked still record the same operations, and only the text
written out differs.

There is no opt-out. An author never spells this, in either document. It is
presentation, and the whole point is that neither side reasons about it.

Where it lives

This is a CLI output concern, not a document-model one. Core's expansion,
projection and rendering are unchanged; what changes is the text the CLI writes.

One mechanical note for whoever implements it: the CLI consumes the execution's
output as plain string chunks — forEach(function* (chunk: string) in
packages/cli/src/cli.ts — which carry no structure, so the emitting depth is
not available at the point of writing today. Making it available is the first
problem to solve, and it should be solved without giving the document model a
new opinion about heading levels.

Boundaries

  • Do not change target discovery or the addressing grammar (§5.4).
  • Do not change expansion identity or retained source ranges — projection keeps
    original offsets and lines, and a rendered level is not a source edit.
  • Do not rewrite headings in a document that is running as the root.

Acceptance criteria

  • A document whose title is #, invoked beneath a ##, renders that title as
    ###, and the rest of its outline shifts with it.
  • The same document run directly renders #.
  • The target catalog is identical before and after the change.
  • A document invoked two levels deep accumulates both shifts.
  • A shift that would carry a heading past ###### renders it at ######,
    exits zero, and reports nothing.

Evidence

One matrix that renders the same invoked document at the root, one level deep,
and two levels deep, asserting the emitted levels and that xmd targets is
unchanged across all three. A case per origin — invoked document, string
projection, output() — showing the same shift applies to each.

Mutation-check by removing the shift; the nested cases must red rather than
passing from the standalone case alone.

Related work

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions