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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions