Files
Marcos Paulo de3ec375ae
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
docs: specs and briefs for Tasks, Templater, Kanban and icons/banners
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 06:06:54 -03:00

13 KiB

Templater subset: <% %> templates with dates, prompts and cursor (roadmap item 12)

Part of specs/plugins-parity/. Replaces the community plugin Templater (5.97M downloads) for the 80% of templates that use no JavaScript. Research sources (2026-10-21): Templater docs (silentvoid13.github.io/Templater: introduction, commands overview, tp.date, tp.file, tp.system, tp.frontmatter module pages). Where this spec and the docs differ, the docs win for syntax and function names (we must run templates people already wrote).

Problem

crates/templates expands {{title}}, {{date}}, {{date:FMT}}, {{time}} and nothing else. People migrating from Obsidian bring Templater templates: <% tp.date.now("YYYY-MM-DD") %>, <% tp.file.title %>, <% tp.system.prompt("Project name") %>, <% tp.file.cursor() %>, a template per folder. Those currently insert as literal text.

Goal

  1. A pure crate parses <% ... %> templates into segments, evaluates the declarative subset (no JS) and reports what input it needs (prompts) before rendering.
  2. Engine commands render a template for a target note, insert it, or create a note from it, with prompts answered by the UI; daily and periodic notes, the slash template command and "new note from template" all go through the same renderer.
  3. Folder templates: a new note created in folder F is filled from the template configured for F.
  4. {{title}}-style legacy templates keep working unchanged (they are expanded first).
  5. Anything that would need JavaScript is reported, never executed, and never lost.

Non-Goals (v1)

  • JavaScript: <%* ... %> blocks, tp.user.* scripts, arrow functions, await, template literals with ${} code, tp.obsidian, tp.app, tp.hooks, tp.config (but see Feature ideas for hooks).
  • tp.web.* (daily quote, random picture, requests): network. Skipped on principle (roadmap network policy).
  • Moment locale tables beyond what notescraft_templates::format already supports.
  • Multiple cursors (the lowest tp.file.cursor(n) order wins; the rest are ignored and reported).
  • Dynamic commands (<%+ ... %> refresh in preview).

Design decisions

D1 - Crate and layering

New crate crates/templater, package notescraft-templater, layer L3. Depends on notescraft-templates (L2: Date, DateTime, Weekday, format), notescraft-bases (L2: Value, expr parser and eval for the fallback of D4) and thiserror. No I/O, no clock, no egui. The caller passes everything in Ctx (D5), including a note-reading closure for include. Registration (root [workspace.dependencies], xtask/src/layers.rs) is lead work (step 0 of tasks.md).

D2 - Syntax

<% expr %>      output the value
<%- expr %>     also strip ALL whitespace (incl. newlines) before the tag
<% expr -%>     also strip one newline and the spaces after the tag
<%_ expr %>     strip spaces/tabs (not newlines) before    <% expr _%>   after
<%* ... %>      JavaScript: NOT SUPPORTED (kept as text, one RenderError)

Text outside tags is copied verbatim. <% inside a fenced code block of the TEMPLATE is still a command (Templater does the same). An unterminated <% is a parse error with the line number; the template is not rendered at all. Limits: template 1 MiB, 2 000 commands, expression 2 KiB, nesting of include depth 4, output 4 times the input plus 256 KiB.

D3 - The expression subset

expr    = coalesce
coalesce= add ('??' add)*
add     = atom ('+' atom)*                  // string concat if either side is a string, else numeric
atom    = string | number | 'true' | 'false' | 'null' | path | call | '(' expr ')'
string  = "..." | '...' | `...` (no ${ })   // escapes \n \t \\ \" \'
path    = ident ('.' ident)*                 // tp.file.title, tp.frontmatter.project, tp.frontmatter["my key"]
call    = path '(' [expr (',' expr)*] ')'

Everything else (?:, &&, ||, arrow functions, new, template literal substitutions) is Unsupported at parse time with a message. Values are notescraft_bases::Value.

Supported API (names and argument order as the Templater docs):

Module Function / property Notes
tp.date now(format = "YYYY-MM-DD", offset = 0, reference?, reference_format?) offset number = days, string = ISO 8601 duration (P1Y, -P1W, P1M2D, PT2H)
tp.date tomorrow(format), yesterday(format), weekday(format, weekday, reference?, reference_format?) 0 = this week's Monday, 7 = next Monday, week start follows the app setting passed in Ctx
tp.file title, folder(absolute = false), path(relative = false), tags, content property or call as in docs
tp.file creation_date(format), last_modified_date(format) from Ctx times
tp.file cursor(order?), cursor_append(text) records a mark, renders empty / the text
tp.file selection() Ctx.selection
tp.file include("[[Note#Heading]]") note text via Ctx.read_note, section/block if given, depth <= 4, templates inside are rendered
tp.file rename(name), move(path) recorded as actions in Rendered, the engine applies them; renders empty
tp.file exists(path) Ctx.exists closure
tp.system prompt(text?, default?, throw_on_cancel = false, multiline = false, select_default = false) static args only (D6)
tp.system suggester(labels, values, throw_on_cancel = false, placeholder = "", limit?) labels/values must be array literals ["a","b"]; arrays need a literal-array atom (added to the grammar: '[' expr, ... ']')
tp.system clipboard() Ctx.clipboard
tp.frontmatter <key>, ["key with space"] from the target note's frontmatter at render time (for a new note: the template's own frontmatter)
bases fallback any path not rooted at tp evaluated by notescraft_bases::expr against a RowRef of the target note, so <% file.name.upper() %> and <% date(now()).format("YYYY") %> work

Unknown tp.* function: RenderError { line, message: "tp.foo.bar is not supported" }, the command is kept verbatim in the output (nothing lost) and the rest renders.

D4 - API

pub fn parse(src: &str) -> Result<Template>;
impl Template {
    pub fn inputs(&self) -> Vec<Input>;                       // prompts/suggesters in document order
    pub fn render(&self, ctx: &Ctx<'_>, answers: &Answers) -> Rendered;
}
pub struct Input { pub id: u32, pub kind: InputKind }          // id = command index
pub enum InputKind {
    Prompt { text: String, default: String, multiline: bool },
    Suggester { labels: Vec<String>, values: Vec<String>, placeholder: String },
}
pub struct Answers(pub BTreeMap<u32, Option<String>>);         // None = cancelled
pub struct Ctx<'a> {
    pub title: &'a str, pub folder: &'a str, pub path: &'a str,   // target note (path may be "" for a note not yet created)
    pub now: DateTime, pub week_start: Weekday,
    pub created: Option<DateTime>, pub modified: Option<DateTime>,
    pub tags: &'a [String], pub frontmatter: &'a [(String, Value)],
    pub selection: &'a str, pub clipboard: &'a str,
    pub read_note: &'a dyn Fn(&str) -> Option<String>, pub exists: &'a dyn Fn(&str) -> bool,
}
pub struct Rendered {
    pub text: String, pub cursor: Option<usize>,                // byte offset of the lowest cursor mark
    pub rename: Option<String>, pub move_to: Option<String>,
    pub errors: Vec<RenderError>,                               // { line, message }
}

A cancelled prompt (None) with throw_on_cancel = true aborts: Rendered.text empty and one error cancelled; with false the answer is the empty string (docs behaviour).

D5 - Engine and settings

crates/engine/src/templater/ (mod.rs, commands.rs, apply.rs, folder.rs):

id params result writes
template.inputs {template} (vault path or text) {inputs:[...], errors} no
template.render {template, target?, answers?, selection?} {text, cursor, errors, rename, move} no
template.apply {template, target, mode: "insert"|"replace_note", answers, at?} {path, cursor} yes
template.new_note {template, folder?, name?, answers} {path, cursor} yes

Legacy hook points keep their behaviour and gain the renderer: vault/daily.rs:148,156, periodic/mod.rs:65 (non-interactive: prompts use their default value, cursor ignored), builtin/slash_commands.rs:232 (interactive). One helper render_text(session, text, ctx) -> String in templater/mod.rs runs the legacy expand first, then <% %> only when the text contains <%. Settings [templater] in prefs/templater_config.rs: enabled (true), templates_folder (string, default empty = use the existing templates setting), folder_templates: Vec<{folder, template}> (deepest folder wins), trigger_on_new_file (false), file_regex_templates: Vec<{regex, template}> (Feature ideas, not v1). Folder templates apply in Session::create_note (the single new-note path) only when the new note is EMPTY and trigger_on_new_file is true.

D6 - Prompts are static, two-phase

JS Templater runs prompts while executing arbitrary code. We cannot, so the template is scanned first: inputs() returns every tp.system.prompt/suggester whose arguments are literals. A prompt with a non-literal argument (tp.system.prompt(tp.file.title)) evaluates its arguments with the pure context at inputs() time (they may only use tp.file.*, tp.date.*, literals); if that fails it is an error row. The UI asks all inputs in one modal (sequential fields), then calls render with Answers. The same prompt text asked twice is asked twice (Templater does the same).

D7 - UI

  • views/templater_prompt/ (a Modal with a form: one Input/Textarea per prompt, a Select per suggester, Enter submits, Esc cancels), opened by the shell when template.inputs returns inputs.
  • After template.apply/new_note the editor places the caret at cursor (offset in the final note text). Hook: shell/app/editor/palette_insert.rs (insertion) and the command result handler in shell/app/window/commands.rs.
  • Palette: "Templater: Insert template", "Templater: Create note from template", "Templater: Render errors" (shows the last errors). A failed command shows a toast with the first error and its line.

Acceptance criteria

  1. <% tp.date.now("YYYY-MM-DD", 7) %> on 2026-10-13 renders 2026-10-20; "P1M" offset gives 2026-11-13; tp.date.weekday("YYYY-MM-DD", 0) is that week's Monday under the configured week start.
  2. <% tp.file.title %>, <% tp.file.folder(true) %>, <% tp.frontmatter.project %> render from Ctx.
  3. A template with two prompts and a suggester returns 3 Inputs in order; rendering with answers fills them; cancel with throw_on_cancel aborts.
  4. <% tp.file.cursor(1) %> leaves the caret there; cursor_append("x") renders x after the mark.
  5. <%- -%> and <%_ _%> whitespace control match the Templater doc examples (golden file).
  6. <%* ... %> is kept verbatim, produces one error, and the rest of the template renders.
  7. include of a note containing a template renders it, a cycle (A includes A) stops at depth 4 with an error, never loops.
  8. {{title}}/{{date}} legacy templates behave exactly as before (existing tests green).
  9. A folder template fills a new empty note in that folder; a non-empty or imported note is untouched.
  10. Hostile input: 1 MiB template of <% repeated, 2 001 commands, 3 MiB output, 10 000-char expression all return errors, never panic or hang (< 200 ms).

Open questions

  • Q1: file/folder location of templates: reuse the existing templates folder setting (this spec) or the Templater templates_folder. Default: reuse, [templater].templates_folder overrides.
  • Q2: should the legacy {{ }} expander stay forever? Yes in v1; ask before deprecating.

Feature ideas (researched, ranked by value)

  1. Folder templates and file-regex templates (Templater settings "Folder Templates", "File regex templates"): v1 has folder templates; regex rules next. Highest value for people with a Journal/ or Meetings/ folder; it turns "New note" into zero-friction capture.
  2. Template picker with preview in the "Insert template" palette: render with default answers and show the first 12 lines; reuses the palette preview used for Quick Switcher.
  3. tp.hooks.on_all_templates_executed-like post step: here a declarative rename/move already exists; a "post command" list (open note, run engine command id) per template, stored in the template's frontmatter templater: { after: [...] }.
  4. Startup templates (run at app start): append to today's daily note ## Planned; pair with periodic notes (roadmap item 7).
  5. tp.file.create_new(template, name, open, folder): spawn notes from a template inside a template (project scaffolds). Needs write inside render, so it runs as a recorded action like rename.
  6. tp.system.suggester over vault queries: tp.vault.notes_in("People") returning links, bridging to the Bases evaluator (file.*). Replaces most user scripts people write.
  7. Syntax highlighting of <% %> in the source editor and an inline error squiggle (cheap once parse reports line/column).
  8. Dynamic commands (<%+ %>) re-rendered in reading mode for "today" widgets; only worth it with the Tasks/Bases blocks (they already refresh).
  9. Multiple cursors from equal cursor(n) orders.
  10. tp.web.daily_quote as an opt-in network call: low value, needs the network policy section.