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
- A pure crate parses
<% ... %>templates into segments, evaluates the declarative subset (no JS) and reports what input it needs (prompts) before rendering. - 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.
- Folder templates: a new note created in folder F is filled from the template configured for F.
{{title}}-style legacy templates keep working unchanged (they are expanded first).- 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::formatalready 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/(aModalwith a form: oneInput/Textareaper prompt, aSelectper suggester, Enter submits, Esc cancels), opened by the shell whentemplate.inputsreturns inputs.- After
template.apply/new_notethe editor places the caret atcursor(offset in the final note text). Hook:shell/app/editor/palette_insert.rs(insertion) and the command result handler inshell/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
<% tp.date.now("YYYY-MM-DD", 7) %>on 2026-10-13 renders2026-10-20;"P1M"offset gives2026-11-13;tp.date.weekday("YYYY-MM-DD", 0)is that week's Monday under the configured week start.<% tp.file.title %>,<% tp.file.folder(true) %>,<% tp.frontmatter.project %>render fromCtx.- A template with two prompts and a suggester returns 3
Inputs in order; rendering with answers fills them; cancel withthrow_on_cancelaborts. <% tp.file.cursor(1) %>leaves the caret there;cursor_append("x")rendersxafter the mark.<%- -%>and<%_ _%>whitespace control match the Templater doc examples (golden file).<%* ... %>is kept verbatim, produces one error, and the rest of the template renders.includeof a note containing a template renders it, a cycle (A includes A) stops at depth 4 with an error, never loops.{{title}}/{{date}}legacy templates behave exactly as before (existing tests green).- A folder template fills a new empty note in that folder; a non-empty or imported note is untouched.
- 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_folderoverrides. - Q2: should the legacy
{{ }}expander stay forever? Yes in v1; ask before deprecating.
Feature ideas (researched, ranked by value)
- 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/orMeetings/folder; it turns "New note" into zero-friction capture. - 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.
tp.hooks.on_all_templates_executed-like post step: here a declarativerename/movealready exists; a "post command" list (open note, run engine command id) per template, stored in the template's frontmattertemplater: { after: [...] }.- Startup templates (run at app start): append to today's daily note
## Planned; pair with periodic notes (roadmap item 7). 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 likerename.tp.system.suggesterover vault queries:tp.vault.notes_in("People")returning links, bridging to the Bases evaluator (file.*). Replaces most user scripts people write.- Syntax highlighting of
<% %>in the source editor and an inline error squiggle (cheap onceparsereports line/column). - Dynamic commands (
<%+ %>) re-rendered in reading mode for "today" widgets; only worth it with the Tasks/Bases blocks (they already refresh). - Multiple cursors from equal
cursor(n)orders. tp.web.daily_quoteas an opt-in network call: low value, needs the network policy section.