Skip to content
🚧 Early alpha — building the foundation. See the roadmap →

v0.1.2 shipped — render() v1 (folder + file + heading)

Created Updated

Milestone v0.1.2render() v1 (folder + file + heading mechanisms). Status flipped to ✅ in the milestone hub.

The pure-function render pipeline from Ch 22 synthesis §3.1 is now the single coupling point between recipes and vault layout.

SurfaceDelivered
src/render/types.tsAddress, ConceptIdentity, SourceScope types matching Ch 22 §3.1 spec
src/render/template.tsR2RML-style {var|filter} interpolation + 7-filter closed pipeline (lower, upper, title, slug, tagsafe, fs-safe, truncate(N)); dotted-path variable resolution; throws RenderError for unknown filter / missing var / malformed expression
src/render/mechanisms/folder.tsFolder mechanism — appends directory segment to address.primary.path
src/render/mechanisms/file.tsFile mechanism — leaf-bearing markdown file; auto-appends .md if missing
src/render/mechanisms/heading.tsHeading mechanism — intra-file anchor; nested anchors via Obsidian’s heading-range form (Note#H1#H2); level_depth 1..6 enforced
src/render/mechanisms/tag.tsStub — throws informative “deferred to v0.2” error pointing users at also_emit.tags for the v0.1 path
src/render/mechanisms/wikilink.tsStub — throws informative “deferred to v0.2” error pointing at v0.2 wikilink-graph + graph_edges work
src/render/index.tsPublic render() function dispatching layout entries by mechanism, applying also_emit cross-cutting (tags + aliases + managed frontmatter), computing Pass-1 absolute-form wikilinkTarget, ensuring curie always lands in frontmatter
src/main.tsrender exposed as plugin-instance handle for E2E reachability
SuiteCountPassing
Jest unit (tests/render.test.ts)37 (template engine + 3 worked NIST examples + also_emit + 6 error cases + 3 determinism)
Jest unit total (all suites)64
WebDriver E2E (tests/e2e/render.spec.ts)6 (handle exposure + 2 worked recipes + also_emit + tag-deferral + 50-iteration determinism inside Obsidian)
WebDriver E2E total19
Grand total83 tests across 8 spec files

E2E tests run against real Obsidian v1.12.7 via wdio-obsidian-service. Build clean.

Notable design decisions made during implementation

Section titled “Notable design decisions made during implementation”
  1. Determinism is a first-class test. Both unit + E2E include explicit determinism checks: same (recipe, identity) → byte-identical Address output across 100 (unit) and 50 (E2E) iterations. This protects the canonical-state hashing commitment from Ch 22 §8 — if Date.now() or Math.random() ever leaks into render(), the determinism test fails red.
  2. Filter pipeline left-to-right semantics. {var|lower|truncate(3)} applies lower first, then truncate(3). Tests cover the chained case explicitly; otherwise a future refactor that reverses pipeline order would silently break recipes.
  3. fs-safe filter strips spaces too. Windows treats trailing spaces as illegal; sync clients (OneDrive notably) misbehave above 256 bytes per Ch 22 §1.1. The filter strips <>:"/\|?*-and-spaces in one pass, with trailing dot/space stripped after.
  4. Heading anchors use # separator (Obsidian’s heading-range form). Nested headings stack as H1#H2#H3 in the anchor portion. [[Note#H1#H2]] is Obsidian’s way of resolving deep anchors.
  5. Stubs for tag + wikilink mechanisms throw informative errors. Rather than silent no-ops or generic “not implemented”, they explain what to use instead (e.g., also_emit.tags for emitting tags when the layout mechanism is not tag) and when the deferred feature ships (v0.2). Prevents misdiagnosis.
  6. render() exposed on plugin instance for E2E reachability. Module-level pure function; the plugin instance just forwards a handle. No duplicate state.

Per Ch 22 §3, render() is the single coupling point between recipe (the user’s declarative spec) and vault layout (the actual files Tier 1 contains). Everything upstream of render is recipe + source-data validation; everything downstream is bytes-on-disk.

                Crosswalker import pipeline (component view)
                ═══════════════════════════════════════════

  ┌─ INPUT ────────────────────────────────────────────────────┐
  │                                                            │
  │   Source data               Recipe (YAML / JSON)           │
  │   CSV / XLSX / JSON         spec/recipe.schema.json        │
  │   ─────────────────         ────────────────────────       │
  │         │                            │                     │
  │         ▼                            ▼                     │
  │   ┌─ Parser ─┐              ┌─ AJV validator ─┐ ◄── v0.1.1 │
  │   │ PapaParse │              │ initValidator()  │          │
  │   │ (csv);    │              │ Ajv2020 + 2020-12│          │
  │   │ xlsx etc. │              └────────┬─────────┘          │
  │   └─────┬─────┘                       │                    │
  │         │                             │                    │
  │         ▼                             ▼                    │
  │   ParsedData                  Recipe (typed)               │
  │   (rows, columns,             from src/types/generated/    │
  │    column-info)               recipe.ts                    │
  │                                                            │
  └─────────────┬───────────────────────────┬──────────────────┘
                │                           │
                │     for each row ◄────────┤
                │                           │
                ▼                           ▼
  ┌─ THE COUPLING POINT (Ch 22) ──────────────────────────────┐
  │                                                            │
  │       ConceptIdentity      +     Recipe                    │
  │       { curie, scope }                                     │
  │              │                       │                     │
  │              └───────────┬───────────┘                     │
  │                          ▼                                 │
  │               ┌──────────────────────┐                     │
  │               │      render()        │  ◄── v0.1.2 SHIPPED │
  │               │   PURE FUNCTION      │                     │
  │               │   (no I/O, no vault  │                     │
  │               │    state, hashable)  │                     │
  │               └──────────┬───────────┘                     │
  │                          ▼                                 │
  │               Address                                      │
  │               ───────                                      │
  │               primary.path     'Frameworks/.../AC-2.md'    │
  │               primary.anchor   optional                    │
  │               wikilinkTarget   absolute form (Pass-1)      │
  │               tags             [from also_emit.tags]       │
  │               aliases          [from also_emit.aliases]    │
  │               frontmatter      { curie, …managed }         │
  │                                                            │
  └─────────────────────────┬─────────────────────────────────┘


  ┌─ OUTPUT ──────────────────────────────────────────────────┐
  │                                                            │
  │   ┌─ Generation Engine ─┐  ◄── v0.1.3 (next milestone)    │
  │   │ • write at          │                                  │
  │   │   address.primary.  │                                  │
  │   │   path              │                                  │
  │   │ • merge frontmatter │                                  │
  │   │   (managed +        │                                  │
  │   │   user_preserve)    │                                  │
  │   │ • write _crosswalker│                                  │
  │   │   provenance        │                                  │
  │   └──────────┬──────────┘                                  │
  │              ▼                                             │
  │   TIER 1 — Markdown + YAML frontmatter (canonical)         │
  │                                                            │
  └──────────────┬────────────────────────────────────────────┘
                 │  (later milestones)

                 ├─► v0.1.5 ─ sqlite-wasm projector ─► TIER 2  │
                 ├─► v0.1.6 ─ Bases queries read T1+T2         │
                 ├─► v0.1.7 ─ STRM TSV / OSCAL JSON exporters  │
                 └─► v0.1.8 ─ git commits + signed manifests   │
InterfaceDefined inConsumers (current + planned)
Recipe (loose runtime shape)src/render/index.tsrender() itself; v0.1.3 generation engine; future external producers via spec/recipe.schema.json
ConceptIdentitysrc/render/types.tsrender(); v0.1.3 engine builds one per source row from CURIE + scope variables
SourceScopesrc/render/types.tsPopulated upstream of render — typically { catalog: {...}, family: {...}, control: {...} } per source level
Address (render output)src/render/types.tsv0.1.3 engine writes notes from this; v0.1.4 junction notes consume it; v0.1.5 sidecar projects from frontmatter; v0.1.7 exporters read tags / aliases / managed frontmatter
RenderErrorsrc/render/template.tsSurfaced to user via Obsidian Notice in v0.1.3 (the milestone E2E asserts the AJV-Notice path); thrown by template engine + mechanisms for any pre-write failure
Template grammar ({var|filter|filter})src/render/template.tsRecipe authors writing target.layout[].template, also_emit.tags[], also_emit.aliases[], also_emit.frontmatter.managed values
Plugin-instance handle plugin.rendersrc/main.tsE2E tests (already exercising 6 cases); v0.1.3 generation engine refactor will call this internally; future commands that want one-off rendering
  • Tier 1 schema (spec/tier1.schema.json) — render() produces an Address, not a Tier 1 frontmatter object directly. The generation engine in v0.1.3 maps Address → Tier 1 frontmatter via the merge logic. The two schemas remain decoupled: render’s output is layout-shaped; Tier 1 is content-shaped.
  • Recipe schema (spec/recipe.schema.json) — no changes. render() consumes the same shape AJV validated in v0.1.1. The schema still allows tag / wikilink mechanism values; render() rejects them with informative deferral errors. This is correct: the spec admits the future grammar; the runtime implements a subset.
  • Plugin command surface — still two commands (import-structured-data, browse-saved-configs). Render is internal infrastructure; users don’t invoke it directly.
  • Generation engine (src/generation/generation-engine.ts) — still uses the v0.1.0 column-role logic. The refactor to call render() per row is the entire scope of v0.1.3.
  • Validator wiring — already calls validateRecipe() at recipe load (v0.1.1). v0.1.3 adds validateTier1Frontmatter() calls pre-write once the engine emits spec-shaped frontmatter via render’s Address.
TierStatus
Recipe-shape contract (machine-readable, validated, typed in code)✅ Live since v0.1.1
Address-shape contract (what render produces)✅ Live since v0.1.2 (this milestone)
Tier 1 frontmatter contract (machine-readable, validated, typed)✅ Live since v0.1.1; not yet emitted by the generation engine
Generation engine consuming render()🚧 v0.1.3 (next)
Tier 2 sidecar📋 v0.1.5
Output query layer (Bases)📋 v0.1.6
Exporters (STRM TSV / OSCAL JSON)📋 v0.1.7
Audit trail📋 v0.1.8

From the unit + E2E tests, here are the three NIST examples from spec/recipe.schema.json rendered against nist:AC-2:

RecipeOutput
(a) all-folderspath: Frameworks/NIST 800-53 r5/AC/AC-2.md; wikilinkTarget: Frameworks/NIST 800-53 r5/AC/AC-2
(b) mostly-headingspath: Frameworks/NIST 800-53 r5.md; anchor: AC — Access Control#AC-2 Account Management; wikilinkTarget: Frameworks/NIST 800-53 r5#AC — Access Control#AC-2 Account Management
(e) hybrid (folder + folder + file + heading on enhancement)path: Frameworks/NIST 800-53 r5/AC/AC-2.md; anchor (for nist:AC-2(1)): AC-2(1) Automated System Account Management
  • Generation engine integration: the engine currently writes notes from the v0.1.0 column-role config shape. v0.1.3 refactors the engine to call render() for each row, write to address.primary.path, embed body content under address.primary.anchor (when present), and merge address.frontmatter with user-preserve keys.
  • The _crosswalker provenance writer now has a clear Address to attach to.
  • Phase-0 compat shim: legacy hierarchy column-role configs translate to a target.layout shape with all-folder mechanisms — a small adapter at the engine boundary.
  • Does not wire render() into the generation engine yet (v0.1.3).
  • Does not implement Pass-2 link minimization (linkStyle: shortest); that’s v0.3 — Pass-1 always emits absolute-form wikilinks.
  • Does not implement tag-as-layout-mechanism, wikilink-as-layout-mechanism, or graph_edges — schema-reserved through v0.1; activated v0.2.
  • Does not implement the Function primitive (Ch 20) — templates are limited to the closed 7-filter set; computation beyond that errors out.
  • Does not ship a JSONata expression layer — also Ch 20 territory; deferred until concrete need surfaces in real recipes.
  • ✅ Always test thoroughly before flipping milestone status (memory: feedback_test_thoroughly.md) — this milestone shipped only after all 83 tests pass
  • ✅ No personal data in logs (memory: feedback_no_personal_data_in_logs.md) — this log uses relative paths only

v0.1.3 — Generation engine integration — refactor src/generation/generation-engine.ts to call render() per row instead of computing path/frontmatter inline; managed/user_preserve frontmatter merge semantics; _crosswalker provenance writer; idempotent re-import. Per-milestone E2E spec is tests/e2e/re-import.spec.ts — must pass before flipping milestone status to ✅.