Skip to content

Add ::include[path] directive for inlining markdown partials #411

Description

@yumike

Add a ::include[path/to/file.md] leaf directive that inlines the contents of another markdown file at the directive's location. Re-uses the existing CommonMark directive machinery (:name / ::name / :::name) that already powers :status and :::tab.

Background

The leaf-directive infrastructure is already in place — DirectiveOutput::Markdown is documented as "used by ::include", the test fixture in crates/rw-renderer/src/directive/leaf.rs already implements TestInclude, and DirectiveProcessorConfig::max_include_depth provides cycle protection out of the box. What's missing is the production directive, the wiring into rw-site, and storage-backend support.

MVP scope (what's in)

  1. Whole-file include, re-parsed as markdown via DirectiveOutput::Markdown so the included content goes through the full pipeline (nested directives, wikilinks, code blocks).
  2. Path resolution: relative to the current source file's parent directory; .. segments allowed up to the docs root; absolute paths (/foo.md) and root-escaping paths rejected.
  3. Cycle / runaway recursion terminated at max_include_depth (default 10) with a warning — already implemented in DirectiveProcessor.
  4. Underscore-prefix convention: files matching _*.md are excluded from the FS scanner — not routable, not in nav, but available to ::include. Files only, not directories.
  5. All backends (FS + S3):
    • FS-backed sites resolve at render time via Storage::read_file.
    • S3-backed sites: the BundlePublisher pre-resolves ::include at publish time, so S3 bundles store flattened page content. S3Storage::read_file returns NotFound — partials never appear at read time.
  6. MockStorage gets a with_partial(path, content) builder for tests.

Out of scope (defer until requested)

  • Heading offset ({offset=N}) — useful when a partial has its own H1.
  • Line ranges ({lines=2-10}) for code-style includes.
  • Region/anchor tags ({region=name}) — AsciiDoc / VS Code #region style.
  • Variable interpolation / props.
  • Glob includes (::include[snippets/*.md]).
  • URL rewriting inside partials. Today, relative links from inside a partial resolve relative to the parent page's location, not the partial's — same gotcha as Quarto. Document this.
  • Confluence backend (rw confluence update). Defer until requested; the wiring is small (one parallel change in rw-confluence/src/renderer.rs::configure_renderer) but no user is asking for it yet.

Implementation sketch

  • crates/rw-renderer/src/include/{mod.rs,directive.rs}IncludeDirective impl, mirroring the TestInclude fixture. Re-exported from rw-renderer::IncludeDirective.
  • crates/rw-storage/src/storage.rs — add fn read_file(&self, path: &str) -> Result<String, StorageError> to the Storage trait. Reads a raw file by path relative to the storage root (with extension); rejects .. escapes.
  • crates/rw-storage-fs/src/lib.rs — impl read_file as source_dir.join(path) after validate_path.
  • crates/rw-storage-s3/src/storage.rs — impl read_file returns NotFound.
  • crates/rw-storage/src/mock.rs — partials map + with_partial builder + read_file impl.
  • crates/rw-storage-fs/src/source.rs — in SourceFile::classify, skip filenames matching _*.md. (Underscore-prefixed directories still get scanned — narrower rule, fewer surprises.)
  • crates/rw-site/src/page.rs::configure_renderer — promote to a &self method taking page_url_path, build a DirectiveProcessorConfig with base_dir = parent of url path, register IncludeDirective, supply a read_file callback that normalizes .. segments and delegates to storage.read_file().
  • crates/rw-storage-s3/src/publisher.rs — pre-resolve ::include before storing each PageBundle.content. Other directives (:status, :::tab) pass through unchanged for runtime rendering. Changes publish() signature to take &Arc<dyn Storage> so the read_file callback can Arc::clone into a 'static + Send closure.
  • Tests: directive unit tests (happy path, missing file → warning, empty path → warning, nested includes, cycle terminates at depth limit), SourceFile::classify underscore filter, end-to-end site render test.
  • docs/includes.md — usage page describing syntax, partials convention, path semantics, and the known relative-link gotcha. CHANGELOG.md Unreleased entry.

Example

<!-- docs/api/usage.md -->
# Usage

::include[_shared/auth-snippet.md]

The endpoint accepts...
<!-- docs/_shared/auth-snippet.md (not routable; skipped by scanner) -->
> [!NOTE]
> All requests require an `Authorization` header.

The output of usage.md will have the note callout rendered inline at the ::include location.

Related

  • Existing directive vocab: :status[Label]{color=NAME} (inline), :::tab[Label] (container)
  • Existing infrastructure already in place: LeafDirective trait, DirectiveContext, DirectiveOutput::Markdown re-parse, max_include_depth cycle protection, resolve_path_safe traversal protection

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions