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)
- Whole-file include, re-parsed as markdown via
DirectiveOutput::Markdown so the included content goes through the full pipeline (nested directives, wikilinks, code blocks).
- 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.
- Cycle / runaway recursion terminated at
max_include_depth (default 10) with a warning — already implemented in DirectiveProcessor.
- 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.
- 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.
- 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
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:statusand:::tab.Background
The leaf-directive infrastructure is already in place —
DirectiveOutput::Markdownis documented as "used by::include", the test fixture incrates/rw-renderer/src/directive/leaf.rsalready implementsTestInclude, andDirectiveProcessorConfig::max_include_depthprovides cycle protection out of the box. What's missing is the production directive, the wiring intorw-site, and storage-backend support.MVP scope (what's in)
DirectiveOutput::Markdownso the included content goes through the full pipeline (nested directives, wikilinks, code blocks)...segments allowed up to the docs root; absolute paths (/foo.md) and root-escaping paths rejected.max_include_depth(default 10) with a warning — already implemented inDirectiveProcessor._*.mdare excluded from the FS scanner — not routable, not in nav, but available to::include. Files only, not directories.Storage::read_file.BundlePublisherpre-resolves::includeat publish time, so S3 bundles store flattened page content.S3Storage::read_filereturns NotFound — partials never appear at read time.with_partial(path, content)builder for tests.Out of scope (defer until requested)
{offset=N}) — useful when a partial has its own H1.{lines=2-10}) for code-style includes.{region=name}) — AsciiDoc / VS Code#regionstyle.::include[snippets/*.md]).rw confluence update). Defer until requested; the wiring is small (one parallel change inrw-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}—IncludeDirectiveimpl, mirroring theTestIncludefixture. Re-exported fromrw-renderer::IncludeDirective.crates/rw-storage/src/storage.rs— addfn read_file(&self, path: &str) -> Result<String, StorageError>to theStoragetrait. Reads a raw file by path relative to the storage root (with extension); rejects..escapes.crates/rw-storage-fs/src/lib.rs— implread_fileassource_dir.join(path)aftervalidate_path.crates/rw-storage-s3/src/storage.rs— implread_filereturnsNotFound.crates/rw-storage/src/mock.rs— partials map +with_partialbuilder +read_fileimpl.crates/rw-storage-fs/src/source.rs— inSourceFile::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&selfmethod takingpage_url_path, build aDirectiveProcessorConfigwithbase_dir = parent of url path, registerIncludeDirective, supply aread_filecallback that normalizes..segments and delegates tostorage.read_file().crates/rw-storage-s3/src/publisher.rs— pre-resolve::includebefore storing eachPageBundle.content. Other directives (:status,:::tab) pass through unchanged for runtime rendering. Changespublish()signature to take&Arc<dyn Storage>so the read_file callback canArc::cloneinto a'static + Sendclosure.SourceFile::classifyunderscore 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.mdUnreleased entry.Example
The output of
usage.mdwill have the note callout rendered inline at the::includelocation.Related
:status[Label]{color=NAME}(inline),:::tab[Label](container)LeafDirectivetrait,DirectiveContext,DirectiveOutput::Markdownre-parse,max_include_depthcycle protection,resolve_path_safetraversal protection