@zvndev/darkmown 2.4.1-next.7.ge25dbba → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +33 -0
- package/docs/spec-alignment.md +12 -1
- package/package.json +5 -1
- package/site/pages/docs/index.wd +22 -0
- package/src/compiler/actions.js +25 -1
- package/src/compiler/body.js +5 -1
- package/src/compiler/context.js +107 -1
- package/src/compiler/fetch.js +2 -1
- package/src/compiler/forms.js +19 -1
- package/src/compiler/loops.js +14 -1
- package/src/compiler/markdown.js +2 -1
- package/src/compiler/page.js +3 -1
- package/src/compiler/state.js +9 -1
- package/src/compiler/structure.js +3 -1
- package/src/tools/apply.js +274 -0
- package/src/tools/grammar.js +171 -0
- package/src/tools/index.js +170 -0
- package/src/tools/outline.js +321 -0
- package/src/tools/parse.js +81 -0
- package/src/tools/validate.js +169 -0
- package/types/builder.d.ts +21 -0
- package/types/catalog.d.ts +80 -1
- package/types/compiler/context.d.ts +155 -0
- package/types/compiler/page.d.ts +33 -6
- package/types/compiler/schema.d.ts +88 -0
- package/types/compiler.d.ts +2 -1
- package/types/feeds.d.ts +47 -4
- package/types/index.d.ts +3 -0
- package/types/scaffold.d.ts +3 -1
- package/types/tools/apply.d.ts +68 -0
- package/types/tools/grammar.d.ts +44 -0
- package/types/tools/index.d.ts +75 -0
- package/types/tools/outline.d.ts +102 -0
- package/types/tools/parse.d.ts +26 -0
- package/types/tools/validate.d.ts +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
|
|
4
4
|
|
|
5
|
+
## 2.5.0 (2026-08-16)
|
|
6
|
+
|
|
7
|
+
Everything Darkmown has shipped for AI authoring so far has been *description*: a directive catalog, a cheatsheet, a corpus, a GBNF grammar. All of it teaches a model to write a `.wd` file from scratch. None of it helps a model edit one it did not write, which is the operation an AI editor actually performs. This release adds the other half: a tool surface the model calls instead of rewriting whole files. No runtime change (7654 B gzipped, unchanged) and no breaking changes.
|
|
8
|
+
|
|
9
|
+
- **`@zvndev/darkmown/tools`, a six-tool surface for editing agents.** `outline` lists what a page declares with line numbers and block spans; `refs` finds every declare, write, and read of one symbol; `deps` reports what a page pulls in and what pulls it in; `grammar` returns only the cheatsheet rows this edit needs; `apply` makes a targeted edit addressed by line, symbol, or anchor; `validate` compiles the result for real. `Session` stages edits in memory so `apply` and `outline` cannot disagree about what the file currently is, and `parseToolCall` reads a call back out of a model's reply (brace-counting and string-aware, because the one thing a Darkmown edit reliably contains is `{ count }`). Everything runs over a plain `{ path: contents }` object with no filesystem, so a full compile is milliseconds and checking after every edit is affordable rather than aspirational.
|
|
10
|
+
- **Every tool answers one shape, and a refusal is an answer.** All six return `{ ok, text, data }` and none of them throw. A model only ever reads `text`, so a tool that fails in its own shape is invisible to the thing driving it and ends the run. Every rejection names what was wrong *and* what would have worked: an unknown grammar category lists the real ones, a symbol that is not there lists what the page does have, a bad edit op lists the four valid ones. Addressing an edit three ways is deliberate. Anchored-only editing is the obvious design and it fails as files grow, not because small models cannot count lines but because they cannot quote back a string they read once.
|
|
11
|
+
- **The compiler records a symbol table.** It already knew everything a tool would want to ask (what a page declares, on what line, whether a loop stayed reactive, which state key an action writes) and threw all of it away. Each directive handler now records its symbol as it parses, and a compile returns them alongside the HTML. The point is that there is exactly one parser: anything answering "what is in this page?" from a second parse of the `.wd` will eventually disagree with the compiler, and the day it does is the day a tool reports something true about a file that will not compile. No change to any page's output and no runtime cost.
|
|
12
|
+
- **BUGFIX: TypeScript consumers were getting stale declarations.** The committed `types/` had drifted 344 lines across eight files, and the root entrypoint had never declared `directiveCatalog` or `llmsText` at all, so `import { directiveCatalog } from "@zvndev/darkmown"` type-checked as a missing export. This class of bug is invisible to every gate the project had: tests, typecheck, and `npm pack` all pass while the shipped `.d.ts` says something else. Two guards now close it, one regenerating the declarations and diffing the whole tree, one asserting the hand-authored entrypoints name every export that exists at runtime.
|
|
13
|
+
- **BUGFIX: three dev-server tests were measuring the wrong rebuild.** On macOS `fs.watch` is backed by FSEvents, which replays a short history of events from *before* the watcher was created, so the files `darkmown init` scaffolds land in the dev server's first debounce window. The full rebuild two of those tests observed was correct behavior, just not the rebuild under test, and they had been passing for that reason. They now drain the watcher first.
|
|
14
|
+
|
|
5
15
|
## 2.4.0 (2026-08-09)
|
|
6
16
|
|
|
7
17
|
A discoverability release, in both senses. Search engines can now index the site correctly, and coding agents working in a Darkmown project finally get the directive reference where they will actually read it. No runtime change (7654 B gzipped, unchanged) and no breaking changes.
|
package/README.md
CHANGED
|
@@ -1118,6 +1118,39 @@ darkmown build # also emits dist/llms.txt + dist/l
|
|
|
1118
1118
|
|
|
1119
1119
|
Because the catalog, cheatsheet, grammar, and error `example`s are all generated from the compiler's own tables, they cannot drift from what actually compiles (enforced by tests).
|
|
1120
1120
|
|
|
1121
|
+
### Agent tools
|
|
1122
|
+
|
|
1123
|
+
`@zvndev/darkmown/tools` is the same language description turned into a **tool surface**: six functions a model calls instead of rewriting a whole file. Everything runs in memory against a plain `{ path: contents }` object, so a full compile is milliseconds and "check after every edit" is affordable rather than aspirational.
|
|
1124
|
+
|
|
1125
|
+
| Tool | Answers |
|
|
1126
|
+
| --- | --- |
|
|
1127
|
+
| `outline(files, entry)` | What is declared in this page, with line numbers and block spans. |
|
|
1128
|
+
| `refs(files, entry, name)` | Every place a symbol is declared, written, or read. |
|
|
1129
|
+
| `deps(files, entry)` | What this page pulls in, and which pages pull it in. |
|
|
1130
|
+
| `grammar(categories)` | Only the cheatsheet rows this edit needs. |
|
|
1131
|
+
| `apply(files, entry, edits)` | A targeted edit, addressed by `line`, `symbol`, or `anchor`. |
|
|
1132
|
+
| `validate(files, entry)` | Compiles it for real, or says exactly what broke. |
|
|
1133
|
+
|
|
1134
|
+
Every tool answers the same shape, `{ ok, text, data }`, and a refusal always carries a sentence the model can act on rather than throwing. `Session` holds the files so `apply` and `outline` are guaranteed to be looking at one snapshot, and records a `history` of calls:
|
|
1135
|
+
|
|
1136
|
+
```js
|
|
1137
|
+
import { Session, parseToolCall } from "@zvndev/darkmown/tools";
|
|
1138
|
+
|
|
1139
|
+
const session = new Session({ "site/pages/index.wd": source }, "site/pages/index.wd");
|
|
1140
|
+
|
|
1141
|
+
session.call("outline", {});
|
|
1142
|
+
// :7 state count = 0
|
|
1143
|
+
// :12-14 loop @loop /products.json into p
|
|
1144
|
+
|
|
1145
|
+
session.call("apply", {
|
|
1146
|
+
edits: [{ op: "replace", symbol: "state:count", text: ":state count = 5" }]
|
|
1147
|
+
});
|
|
1148
|
+
session.call("validate", {});
|
|
1149
|
+
// compiles. reactive (ships the runtime), 0 skins.
|
|
1150
|
+
```
|
|
1151
|
+
|
|
1152
|
+
`toolPrompt()` renders the tool list for a system prompt, and `parseToolCall(reply)` reads a call back out of a model's answer (brace-counting and string-aware, because the one thing a Darkmown edit reliably contains is `{ count }`).
|
|
1153
|
+
|
|
1121
1154
|
## Editor support
|
|
1122
1155
|
|
|
1123
1156
|
A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown.
|
package/docs/spec-alignment.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
This audit compares the current Darkmown implementation with the original MARROW/WhateverDown vision and the follow-up decisions made on 2026-06-11.
|
|
4
4
|
|
|
5
|
+
## Agent tool surface: `@zvndev/darkmown/tools` (2026-08-16)
|
|
6
|
+
|
|
7
|
+
The AI-authoring work so far shipped *description*: a catalog, a cheatsheet, a corpus, a GBNF grammar. All of it teaches a model to write a `.wd` file from scratch. None of it helps a model **edit one it did not write**, which is the operation an AI editor actually performs, and the one that fails: measurement showed the failure is not counting lines, it is recalling a string read once, and it gets worse as a file grows.
|
|
8
|
+
|
|
9
|
+
- **Six tools, all derived from the compiler.** `outline` (what is declared, with line numbers and block spans), `refs` (every declare/write/read of a symbol), `deps` (what the page pulls in and what pulls it in), `grammar` (only the cheatsheet rows this edit needs), `apply` (a targeted edit), `validate` (compile it for real). They are built on the symbol table `compileFromMemory` returns, recorded by the directive handlers as they parse, so there is no second parser to drift: the day a parser beside the compiler disagrees is the day a model is told something true about a file that will not compile.
|
|
10
|
+
- **One answer shape, and refusals are answers.** Every tool returns `{ ok, text, data }` and nothing throws. A model only ever reads `text`, so a tool that fails in its own shape is invisible to the thing driving it and ends the run. Every rejection names what was wrong *and* what would have worked: an unknown grammar category lists the real ones, a missing symbol lists what the page does have, a bad op lists the four valid ops.
|
|
11
|
+
- **Three ways to say where, because the plan's objection was the wrong one.** The original design ruled out line numbers on the grounds that small models cannot count. They can; what they cannot do is quote a line back verbatim, which is where anchored editing dies (42% of tasks at 16 lines, 67% at 244). `apply` accepts `line` (as printed by `outline`), `symbol` (as named by `outline`/`refs`), or `anchor`, and the staleness worry is designed out rather than ignored: every target resolves against the snapshot passed to *this* call, all edits apply against that one snapshot in descending line order, and an optional `expect` catches a file that moved underneath.
|
|
12
|
+
- **`Session` is what keeps the snapshot single.** It stages edits in memory rather than on disk, so `apply` and `outline` cannot disagree about what the file currently is, and records a `history` a run can be scored on later. Everything runs against a plain `{ path: contents }` object with no filesystem, which is what makes "validate after every edit" affordable rather than aspirational.
|
|
13
|
+
- **BUGFIX: `validate` leaked the virtual project root into its own error text.** It shortened the location it prints but not the compiler's message, which names the file mid-sentence (`Malformed @loop in /site/pages/index.wd:3`). A model shown `/site/pages/` uses it, and the path it then writes resolves to nothing.
|
|
14
|
+
- **BUGFIX: a flaky dev-server test, found by running the suite rather than the file.** On macOS `fs.watch` is backed by FSEvents, which replays a short history of events from *before* the watcher existed, so the files `darkmown init` scaffolded landed in the dev server's first debounce window. The rebuild the test measured was a correct full rebuild (one replayed path is the watched directory itself, the "no attributable file" signal) rather than the incremental one under test. Two sibling tests were passing for the same wrong reason. All three now drain the watcher before acting.
|
|
15
|
+
|
|
5
16
|
## Technical SEO: canonical URLs, structured data, AI crawlers, llms-full.txt (2026-08-09)
|
|
6
17
|
|
|
7
18
|
The 1.3.0 cycle shipped the crawler *files* (sitemap, rss, robots). This closes the gaps a real deployment exposed: the files existed but nothing on the page said which URL was canonical, and the host disagreed with the framework about the URL form.
|
|
@@ -235,7 +246,7 @@ The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Trac
|
|
|
235
246
|
- **`llms.txt` cheatsheet** — `llmsText()` renders a compact (~90-line) markdown cheatsheet from the same catalog; `darkmown catalog` prints the JSON and `darkmown catalog --llms` the cheatsheet, and every build writes `dist/llms.txt`. This is the artifact an app pastes into a small model's system prompt.
|
|
236
247
|
- **Generated GBNF grammar** — `src/grammar.js` (`generateGrammar()`, wrapped by `scripts/gen-grammar.mjs`) emits `grammar/wd-directives.gbnf`, a llama.cpp grammar for `.wd` **directive lines** (plus the `{ path | pipe }` interpolation token). It constrains a line's *shape* — the leading keyword must be a real directive, `@loop` must be `@loop <src> into <item> …clauses`, a condition must be `operand <op> operand`, a `:button` must be `"…" -> <action-chain>` — which deletes the on-device failure class (JS/HTML muscle-memory openers become unrepresentable) while staying small; free-form values remain `text`. Well-formedness + catalog coverage are tested in `tests/grammar.test.js`, and the committed file is kept in sync by the same test.
|
|
237
248
|
- **Structured compile errors** — errors carry a machine-readable mirror on `err.wd = { file, line?, hint?, example? }` (helper `wdError` in context.js) alongside the unchanged string message. Applied across loops, includes, predicates, forms, state, actions, media, fetch, and structure. `example` is always a compilable line.
|
|
238
|
-
- **Concrete examples in error hints** — every corrective `Use:` hint that contained bracket-placeholders now ends with ` — e.g. <concrete valid line>`, drawn from the same `*_EXAMPLE` constants. `tests/error-examples.test.js` extracts each `e.g.` example from the *real* thrown error string and compiles it (enforcement, not convention). Motivation:
|
|
249
|
+
- **Concrete examples in error hints** — every corrective `Use:` hint that contained bracket-placeholders now ends with ` — e.g. <concrete valid line>`, drawn from the same `*_EXAMPLE` constants. `tests/error-examples.test.js` extracts each `e.g.` example from the *real* thrown error string and compiles it (enforcement, not convention). Motivation: benchmarking small local models found they echo bracket-placeholders into source verbatim.
|
|
239
250
|
|
|
240
251
|
## Demo-only directives (not part of the public surface)
|
|
241
252
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"types": "./types/catalog.d.ts",
|
|
21
21
|
"default": "./src/catalog.js"
|
|
22
22
|
},
|
|
23
|
+
"./tools": {
|
|
24
|
+
"types": "./types/tools/index.d.ts",
|
|
25
|
+
"default": "./src/tools/index.js"
|
|
26
|
+
},
|
|
23
27
|
"./package.json": "./package.json"
|
|
24
28
|
},
|
|
25
29
|
"types": "./types/index.d.ts",
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -677,6 +677,28 @@ darkmown catalog --llms-full # the complete reference + every error c
|
|
|
677
677
|
|
|
678
678
|
Compile errors help here too: every corrective `Use:` hint ends with a concrete `— e.g. <valid line>`, and each thrown error carries a structured `err.wd = { file, line, hint, example }` mirror alongside its message — so an edit-and-retry loop gets the fix without parsing prose.
|
|
679
679
|
|
|
680
|
+
### Agent tools
|
|
681
|
+
|
|
682
|
+
`@zvndev/darkmown/tools` turns that same description into six functions a model calls instead of rewriting a whole file. Everything runs in memory over a plain `{ path: contents }` object, so a full compile is milliseconds:
|
|
683
|
+
|
|
684
|
+
- **`outline(files, entry)`** lists what the page declares, with line numbers and block spans.
|
|
685
|
+
- **`refs(files, entry, name)`** finds every declare, write, and read of one symbol.
|
|
686
|
+
- **`deps(files, entry)`** reports what the page pulls in, and what pulls it in.
|
|
687
|
+
- **`grammar(categories)`** returns only the cheatsheet rows this edit needs.
|
|
688
|
+
- **`apply(files, entry, edits)`** makes a targeted edit, addressed by `line`, `symbol`, or `anchor`.
|
|
689
|
+
- **`validate(files, entry)`** compiles it for real, or says exactly what broke.
|
|
690
|
+
|
|
691
|
+
Every tool answers `{ ok, text, data }`, and a refusal carries a sentence the model can act on rather than throwing. `Session` holds the files so `apply` and `outline` always see one snapshot:
|
|
692
|
+
|
|
693
|
+
```js
|
|
694
|
+
import { Session } from "@zvndev/darkmown/tools";
|
|
695
|
+
|
|
696
|
+
const session = new Session({ "site/pages/index.wd": source }, "site/pages/index.wd");
|
|
697
|
+
session.call("outline", {});
|
|
698
|
+
session.call("apply", { edits: [{ op: "replace", symbol: "state:count", text: ":state count = 5" }] });
|
|
699
|
+
session.call("validate", {});
|
|
700
|
+
```
|
|
701
|
+
|
|
680
702
|
## Compile error codes
|
|
681
703
|
|
|
682
704
|
Every author-facing compile error opens with a stable `WDxxx` code, so it is searchable and matchable by tooling without parsing prose:
|
package/src/compiler/actions.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// while keeping the message text and `Use:` hints intact.
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
import { at, lineOf, wdError } from "./context.js";
|
|
12
|
+
import { at, lineOf, recordSymbol, wdError } from "./context.js";
|
|
13
13
|
import {
|
|
14
14
|
escapeHtml,
|
|
15
15
|
resolveStateKey,
|
|
@@ -45,6 +45,14 @@ export function handleButton(line, ctx, index) {
|
|
|
45
45
|
});
|
|
46
46
|
ctx.comp.assets.runtime = true;
|
|
47
47
|
const action = parseAction(match[2], ctx);
|
|
48
|
+
for (const a of Array.isArray(action) ? action : [action])
|
|
49
|
+
recordSymbol(ctx, index, {
|
|
50
|
+
kind: "action",
|
|
51
|
+
name: a.target,
|
|
52
|
+
target: a.target,
|
|
53
|
+
op: a.op,
|
|
54
|
+
detail: `:button "${match[1]}" -> ${match[2].trim()}`
|
|
55
|
+
});
|
|
48
56
|
if (Array.isArray(action)) {
|
|
49
57
|
return `<button type="button" data-wd-actions="${escapeHtml(JSON.stringify(action))}">${escapeHtml(match[1])}</button>`;
|
|
50
58
|
}
|
|
@@ -92,6 +100,14 @@ export function handleEffect(line, ctx, index) {
|
|
|
92
100
|
const watch = [key, ...segs.slice(1)].join(".");
|
|
93
101
|
const action = parseAction(match[2], ctx);
|
|
94
102
|
const actions = Array.isArray(action) ? action : [action];
|
|
103
|
+
for (const a of actions)
|
|
104
|
+
recordSymbol(ctx, index, {
|
|
105
|
+
kind: "action",
|
|
106
|
+
name: a.target,
|
|
107
|
+
target: a.target,
|
|
108
|
+
op: a.op,
|
|
109
|
+
detail: `:effect ${watch} -> ${match[2].trim()}`
|
|
110
|
+
});
|
|
95
111
|
return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
|
|
96
112
|
}
|
|
97
113
|
|
|
@@ -142,6 +158,14 @@ export function handleEvery(line, ctx, index) {
|
|
|
142
158
|
ctx.comp.assets.runtime = true;
|
|
143
159
|
const action = parseAction(match[2], ctx);
|
|
144
160
|
const actions = Array.isArray(action) ? action : [action];
|
|
161
|
+
for (const a of actions)
|
|
162
|
+
recordSymbol(ctx, index, {
|
|
163
|
+
kind: "action",
|
|
164
|
+
name: a.target,
|
|
165
|
+
target: a.target,
|
|
166
|
+
op: a.op,
|
|
167
|
+
detail: `:every ${match[1]} -> ${match[2].trim()}`
|
|
168
|
+
});
|
|
145
169
|
return `<script type="application/json" data-wd-every>${safeScriptJson({ ms, actions })}</script>`;
|
|
146
170
|
}
|
|
147
171
|
|
package/src/compiler/body.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// multi-line directive bodies with nesting + fence awareness.
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
import { at, lineOf, wdError } from "./context.js";
|
|
10
|
+
import { at, lineOf, stampBlockEnd, wdError } from "./context.js";
|
|
11
11
|
import {
|
|
12
12
|
handleBind,
|
|
13
13
|
handleButton,
|
|
@@ -84,7 +84,9 @@ export function compileBody(lines, ctx) {
|
|
|
84
84
|
flush();
|
|
85
85
|
const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx);
|
|
86
86
|
const split = splitEmptyBranch(block.body);
|
|
87
|
+
const loopAt = ctx.comp.symbols.length;
|
|
87
88
|
out.push(handleLoop(line, split.body, split.empty, ctx, i, split.emptyStart));
|
|
89
|
+
stampBlockEnd(ctx, loopAt, block.end);
|
|
88
90
|
i = block.end;
|
|
89
91
|
continue;
|
|
90
92
|
}
|
|
@@ -227,7 +229,9 @@ export function compileBody(lines, ctx) {
|
|
|
227
229
|
if (/^:if\s/.test(line)) {
|
|
228
230
|
flush();
|
|
229
231
|
const block = scanConditional(lines, i, ctx);
|
|
232
|
+
const ifAt = ctx.comp.symbols.length;
|
|
230
233
|
out.push(handleIf(line, block.truthy, block.falsy, ctx, i, block.falsyStart));
|
|
234
|
+
stampBlockEnd(ctx, ifAt, block.end);
|
|
231
235
|
i = block.end;
|
|
232
236
|
continue;
|
|
233
237
|
}
|
package/src/compiler/context.js
CHANGED
|
@@ -81,6 +81,33 @@
|
|
|
81
81
|
* read threads through — the real filesystem by default, or an in-memory map
|
|
82
82
|
* for `compileFromMemory`. Set once by `createCompilation` and reached via
|
|
83
83
|
* `ctx.comp.reader` in the directive handlers (includes, loop data).
|
|
84
|
+
* @property {Symbol[]} symbols Every declared thing this compile saw, in source
|
|
85
|
+
* order, recorded by {@link recordSymbol} from the handler that parsed it.
|
|
86
|
+
* Surfaced on {@link CompiledPage} so a tool layer can answer "what is in this
|
|
87
|
+
* page?" without parsing `.wd` a second time.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One declared thing in a page, as the compiler saw it.
|
|
92
|
+
*
|
|
93
|
+
* `kind` is the directive family; `name` is what an author would call the thing
|
|
94
|
+
* and is what `refs` matches on. `detail` is a short human string in the same
|
|
95
|
+
* vocabulary the model writes, never a serialized AST: the tool layer's job is
|
|
96
|
+
* to hand back text a model can nearly paste, and translation is where small
|
|
97
|
+
* models lose.
|
|
98
|
+
*
|
|
99
|
+
* @typedef {object} Symbol
|
|
100
|
+
* @property {"state" | "store" | "computed" | "theme" | "fetch" | "action" | "loop" | "form" | "field" | "if" | "include" | "read"} kind
|
|
101
|
+
* @property {string} name The symbol's own name, or the thing it targets.
|
|
102
|
+
* @property {string} [detail] Short source-shaped description, e.g. `cart append p`.
|
|
103
|
+
* @property {boolean} [reactive] For loops: whether it stays reactive at runtime.
|
|
104
|
+
* @property {string} [target] For actions: the state key written.
|
|
105
|
+
* @property {string} [op] For actions: the validated action token.
|
|
106
|
+
* @property {string} file Absolute path of the file the symbol was declared in.
|
|
107
|
+
* @property {number} [endLine] For a block directive (`@loop`, `:if`), the
|
|
108
|
+
* 1-based line its closer sits on, so a tool can address the whole block.
|
|
109
|
+
* @property {number | null} line 1-based line in that file, or null for a read
|
|
110
|
+
* recorded by {@link recordRead} (see there for why prose reads carry no line).
|
|
84
111
|
*/
|
|
85
112
|
|
|
86
113
|
/**
|
|
@@ -137,6 +164,8 @@
|
|
|
137
164
|
* `@loop` recorded, or null. The builder reads it to multiply routes.
|
|
138
165
|
* @property {Set<string>} deps Absolute source-file dependencies of this compile.
|
|
139
166
|
* @property {Set<string>} collectionsUsed Collection names this compile looped.
|
|
167
|
+
* @property {Symbol[]} symbols Every declared thing the compile saw, in
|
|
168
|
+
* source order. See {@link recordSymbol}.
|
|
140
169
|
*/
|
|
141
170
|
|
|
142
171
|
/**
|
|
@@ -150,6 +179,8 @@
|
|
|
150
179
|
* `@loop` recorded, or null. The builder reads it to multiply routes.
|
|
151
180
|
* @property {Set<string>} deps Absolute source-file dependencies of this compile.
|
|
152
181
|
* @property {Set<string>} collectionsUsed Collection names this compile looped.
|
|
182
|
+
* @property {Symbol[]} symbols Every declared thing the compile saw, in
|
|
183
|
+
* source order. See {@link recordSymbol}.
|
|
153
184
|
*/
|
|
154
185
|
|
|
155
186
|
/**
|
|
@@ -270,10 +301,85 @@ export function createCompilation(reader) {
|
|
|
270
301
|
collections: new Map(),
|
|
271
302
|
pagination: null,
|
|
272
303
|
deps: new Set(),
|
|
273
|
-
collectionsUsed: new Set()
|
|
304
|
+
collectionsUsed: new Set(),
|
|
305
|
+
symbols: []
|
|
274
306
|
};
|
|
275
307
|
}
|
|
276
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Record one declared thing as the handler that owns it parses it.
|
|
311
|
+
*
|
|
312
|
+
* This is the compiler telling a tool layer what is in a page, rather than a
|
|
313
|
+
* tool layer parsing `.wd` a second time. A shadow parser drifts, and the day it
|
|
314
|
+
* disagrees with `compilePage` it tells a model something true about a file that
|
|
315
|
+
* will not compile.
|
|
316
|
+
*
|
|
317
|
+
* Called from the `handle*` functions, where the 0-based `index` is live, so
|
|
318
|
+
* every symbol carries a real `file:line`. Include bodies share the parent's
|
|
319
|
+
* `Compilation` but compile under their own `ctx.file`, so attribution stays
|
|
320
|
+
* correct with no extra plumbing.
|
|
321
|
+
*
|
|
322
|
+
* @param {Ctx} ctx
|
|
323
|
+
* @param {number} index 0-based line index into the current body slice.
|
|
324
|
+
* @param {Omit<Symbol, "file" | "line">} sym
|
|
325
|
+
* @returns {Symbol} The pushed record, so a caller whose reactive/static decision
|
|
326
|
+
* happens in a later branch can stamp it rather than duplicate that logic.
|
|
327
|
+
*/
|
|
328
|
+
export function recordSymbol(ctx, index, sym) {
|
|
329
|
+
const record = { ...sym, file: ctx.file, line: lineOf(ctx, index) };
|
|
330
|
+
ctx.comp.symbols.push(record);
|
|
331
|
+
return record;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Record where a block directive CLOSES, so a tool layer can address the whole
|
|
336
|
+
* construct rather than only its opening line.
|
|
337
|
+
*
|
|
338
|
+
* Measured need, not speculation: with only the opener line recorded, the 1.5B
|
|
339
|
+
* targeted a `@loop` for replacement and swapped out its header, leaving the
|
|
340
|
+
* `@endloop` behind. That produced `[WD010] Stray "@endloop"` as the single
|
|
341
|
+
* most common compile failure of the round-4 tool run. A `@loop` is three or
|
|
342
|
+
* more lines and a tool has to be able to say so.
|
|
343
|
+
*
|
|
344
|
+
* The dispatcher owns this because only it has the block's extent, from
|
|
345
|
+
* `scanBlock`/`scanConditional`. `at` is the index the block handler's own
|
|
346
|
+
* symbol was pushed to; nested symbols recorded while compiling the body land
|
|
347
|
+
* after it and are left alone.
|
|
348
|
+
*
|
|
349
|
+
* @param {Ctx} ctx
|
|
350
|
+
* @param {number} at Index into `ctx.comp.symbols` the block handler wrote to.
|
|
351
|
+
* @param {number} endIndex 0-based body index of the closing line.
|
|
352
|
+
* @returns {void}
|
|
353
|
+
*/
|
|
354
|
+
export function stampBlockEnd(ctx, at, endIndex) {
|
|
355
|
+
const sym = ctx.comp.symbols[at];
|
|
356
|
+
if (sym) sym.endLine = lineOf(ctx, endIndex);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Record that a page READS a state key through an interpolation.
|
|
361
|
+
*
|
|
362
|
+
* Deliberately line-less. Prose is rendered a chunk at a time by markdown-it, so
|
|
363
|
+
* no 0-based body `index` is in scope at the point a `{ name }` binding resolves,
|
|
364
|
+
* and threading one through the markdown layer would mean tracking source
|
|
365
|
+
* positions inside a renderer that does not care about them.
|
|
366
|
+
*
|
|
367
|
+
* Instead the compiler records the two things only it can know: that the
|
|
368
|
+
* expression resolved to this state key, and the exact expression text. The tool
|
|
369
|
+
* layer locates the line by searching the file for that compiler-supplied
|
|
370
|
+
* string, which is a substring search for a known needle rather than a second
|
|
371
|
+
* parser. Duplicates (a static loop re-renders its body once per row) are the
|
|
372
|
+
* caller's to fold.
|
|
373
|
+
*
|
|
374
|
+
* @param {Ctx} ctx
|
|
375
|
+
* @param {string} key Resolved state key.
|
|
376
|
+
* @param {string} expr The binding expression as authored, e.g. `cart | count`.
|
|
377
|
+
* @returns {void}
|
|
378
|
+
*/
|
|
379
|
+
export function recordRead(ctx, key, expr) {
|
|
380
|
+
ctx.comp.symbols.push({ kind: "read", name: key, detail: expr, file: ctx.file, line: null });
|
|
381
|
+
}
|
|
382
|
+
|
|
277
383
|
/**
|
|
278
384
|
* @param {Scope | null} parent
|
|
279
385
|
* @param {Record<string, unknown>} [vars]
|
package/src/compiler/fetch.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// while keeping the message text and `Use:` hints intact.
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
import { at, lineOf, wdError } from "./context.js";
|
|
10
|
+
import { at, lineOf, recordSymbol, wdError } from "./context.js";
|
|
11
11
|
import {
|
|
12
12
|
escapeHtml,
|
|
13
13
|
resolveStateKey,
|
|
@@ -123,6 +123,7 @@ export function handleFetch(line, ctx, index) {
|
|
|
123
123
|
// (collision-checked); the derived keys are seeded directly.
|
|
124
124
|
const key = declareState(name, null, ctx);
|
|
125
125
|
ctx.comp.fetchKeys.add(key);
|
|
126
|
+
recordSymbol(ctx, index, { kind: "fetch", name: key, detail: `${key} from "${url}"` });
|
|
126
127
|
/** @type {Record<string, unknown>} */
|
|
127
128
|
const seeds = { [key]: null };
|
|
128
129
|
for (const [suffix, seed] of [
|
package/src/compiler/forms.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// while keeping the message text and `Use:` hints intact.
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
import { at, lineOf, nestedCtx, wdError } from "./context.js";
|
|
12
|
+
import { at, lineOf, nestedCtx, recordSymbol, wdError } from "./context.js";
|
|
13
13
|
import { validateFetchUrl } from "./fetch.js";
|
|
14
14
|
import {
|
|
15
15
|
escapeHtml,
|
|
@@ -73,6 +73,12 @@ export function handleForm(line, bodyLines, ctx, index) {
|
|
|
73
73
|
}
|
|
74
74
|
const key = declareState(into, null, ctx);
|
|
75
75
|
declareErrorState(key, ctx);
|
|
76
|
+
recordSymbol(ctx, index, {
|
|
77
|
+
kind: "form",
|
|
78
|
+
name: key,
|
|
79
|
+
target: key,
|
|
80
|
+
detail: `:form action="${rawAction}" into ${into}`
|
|
81
|
+
});
|
|
76
82
|
const actionAttrs = action
|
|
77
83
|
? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"`
|
|
78
84
|
: "";
|
|
@@ -96,6 +102,7 @@ export function handleInput(line, ctx, index) {
|
|
|
96
102
|
example: INPUT_EXAMPLE
|
|
97
103
|
});
|
|
98
104
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
105
|
+
recordSymbol(ctx, index, { kind: "field", name: match[1], detail: `:input ${match[1]}` });
|
|
99
106
|
let type = "text";
|
|
100
107
|
let placeholder;
|
|
101
108
|
let hasAria = false;
|
|
@@ -175,6 +182,12 @@ export function handleBind(line, ctx, index) {
|
|
|
175
182
|
{ code: "WD406", file: ctx.file }
|
|
176
183
|
);
|
|
177
184
|
}
|
|
185
|
+
recordSymbol(ctx, index, {
|
|
186
|
+
kind: "field",
|
|
187
|
+
name: match[1],
|
|
188
|
+
target: key,
|
|
189
|
+
detail: `:bind ${match[1]}`
|
|
190
|
+
});
|
|
178
191
|
ctx.comp.assets.runtime = true;
|
|
179
192
|
let type = "text";
|
|
180
193
|
let placeholder;
|
|
@@ -326,6 +339,7 @@ export function handleSlider(line, ctx, index) {
|
|
|
326
339
|
const valueAttr =
|
|
327
340
|
initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
328
341
|
const ariaAttr = ` aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`;
|
|
342
|
+
recordSymbol(ctx, index, { kind: "field", name, target: key, detail: `:slider ${name}` });
|
|
329
343
|
return `${seed}<input type="range" data-wd-bind-input="${key}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
|
|
330
344
|
}
|
|
331
345
|
|
|
@@ -348,6 +362,7 @@ export function handleSubmit(line, ctx, index) {
|
|
|
348
362
|
example: SUBMIT_EXAMPLE
|
|
349
363
|
}
|
|
350
364
|
);
|
|
365
|
+
recordSymbol(ctx, index, { kind: "field", name: "submit", detail: line.trim() });
|
|
351
366
|
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
352
367
|
}
|
|
353
368
|
|
|
@@ -374,6 +389,7 @@ export function handleTextarea(line, ctx, index) {
|
|
|
374
389
|
});
|
|
375
390
|
}
|
|
376
391
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
392
|
+
recordSymbol(ctx, index, { kind: "field", name: match[1], detail: `:textarea ${match[1]}` });
|
|
377
393
|
let placeholder;
|
|
378
394
|
let hasAria = false;
|
|
379
395
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
@@ -441,6 +457,7 @@ export function handleSelect(line, optionLines, ctx, index) {
|
|
|
441
457
|
});
|
|
442
458
|
}
|
|
443
459
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
460
|
+
recordSymbol(ctx, index, { kind: "field", name: match[1], detail: `:select ${match[1]}` });
|
|
444
461
|
let hasAria = false;
|
|
445
462
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
446
463
|
for (const token of (match[2] || "").matchAll(re)) {
|
|
@@ -518,6 +535,7 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
|
|
|
518
535
|
});
|
|
519
536
|
}
|
|
520
537
|
const name = match[1];
|
|
538
|
+
recordSymbol(ctx, index, { kind: "field", name, detail: `:${kind} ${name}` });
|
|
521
539
|
const flags = [];
|
|
522
540
|
let ariaLabel = null;
|
|
523
541
|
let ariaDescribedby = null;
|
package/src/compiler/loops.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// string fill used for the initial paint (text binds, per-row `:if`, meta).
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
import { at, createScope, lineOf, nestedCtx, wdError } from "./context.js";
|
|
9
|
+
import { at, createScope, lineOf, nestedCtx, recordSymbol, wdError } from "./context.js";
|
|
10
10
|
import { serializeExpr } from "./expr-ast.js";
|
|
11
11
|
import { applyPipeline, stagesFromAttr } from "./format.js";
|
|
12
12
|
import { resolveInclude } from "./includes.js";
|
|
@@ -296,6 +296,15 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
296
296
|
sortable: clauses.sortable,
|
|
297
297
|
paginate: clauses.paginate
|
|
298
298
|
};
|
|
299
|
+
// Stamped by whichever branch below resolves the source. `reactive` is the one
|
|
300
|
+
// fact a tool layer cannot recover from the source text alone, and it is the
|
|
301
|
+
// fact that decides whether the page ships the runtime.
|
|
302
|
+
const loopSymbol = recordSymbol(ctx, index, {
|
|
303
|
+
kind: "loop",
|
|
304
|
+
name: itemName,
|
|
305
|
+
detail: line.replace(/^@loop\s+/, "@loop "),
|
|
306
|
+
reactive: Boolean((where && where.refsState) || clauses.refsState)
|
|
307
|
+
});
|
|
299
308
|
// The loop body starts on the line after the opener; nested errors in it (and
|
|
300
309
|
// in the empty branch, via `opts.emptyStart`) report the true file line. The
|
|
301
310
|
// opener's location rides on the body ctx so a reactive loop nested inside this
|
|
@@ -362,6 +371,9 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
362
371
|
if (key) {
|
|
363
372
|
if (opts.paginate) throw paginateOnlyCollections(ctx);
|
|
364
373
|
const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
|
|
374
|
+
loopSymbol.reactive = true;
|
|
375
|
+
loopSymbol.name = itemName;
|
|
376
|
+
loopSymbol.target = fullKey;
|
|
365
377
|
return reactiveLoop(fullKey, itemName, bodyLines, bodyCtx, opts);
|
|
366
378
|
}
|
|
367
379
|
// A bare name matching a collection (any `site/pages/<name>/` subdir) resolves
|
|
@@ -382,6 +394,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
382
394
|
);
|
|
383
395
|
}
|
|
384
396
|
if (opts.paginate) throw paginateOnlyCollections(ctx);
|
|
397
|
+
loopSymbol.reactive = true;
|
|
385
398
|
return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, bodyCtx, opts);
|
|
386
399
|
}
|
|
387
400
|
}
|
package/src/compiler/markdown.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import MarkdownIt from "markdown-it";
|
|
9
9
|
import { highlightCode } from "../highlight.js";
|
|
10
|
-
import { LOOP_META, wdError } from "./context.js";
|
|
10
|
+
import { LOOP_META, recordRead, wdError } from "./context.js";
|
|
11
11
|
import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
|
|
12
12
|
import {
|
|
13
13
|
escapeHtml,
|
|
@@ -288,6 +288,7 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
288
288
|
const key = resolveStateKey(head, ctx);
|
|
289
289
|
if (key) {
|
|
290
290
|
ctx.comp.assets.runtime = true;
|
|
291
|
+
recordRead(ctx, key, expr);
|
|
291
292
|
const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
|
|
292
293
|
const rest = segs.slice(1).join(".");
|
|
293
294
|
const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
|
package/src/compiler/page.js
CHANGED
|
@@ -206,6 +206,7 @@ export function compilePage(file, context, options = {}) {
|
|
|
206
206
|
meta: compiled.meta,
|
|
207
207
|
deps: compiled.deps,
|
|
208
208
|
collectionsUsed: compiled.collectionsUsed,
|
|
209
|
+
symbols: compiled.symbols,
|
|
209
210
|
html: `<!doctype html>
|
|
210
211
|
<html lang="${escapeHtml(lang)}">
|
|
211
212
|
<head>
|
|
@@ -391,7 +392,8 @@ export function compileDocument(
|
|
|
391
392
|
warnings: comp.warnings,
|
|
392
393
|
pagination: comp.pagination,
|
|
393
394
|
deps: comp.deps,
|
|
394
|
-
collectionsUsed: comp.collectionsUsed
|
|
395
|
+
collectionsUsed: comp.collectionsUsed,
|
|
396
|
+
symbols: comp.symbols
|
|
395
397
|
};
|
|
396
398
|
}
|
|
397
399
|
|
package/src/compiler/state.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// while keeping the message text and `Use:` hints intact.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
import { at, lineOf, wdError } from "./context.js";
|
|
11
|
+
import { at, lineOf, recordSymbol, wdError } from "./context.js";
|
|
12
12
|
import { astOf, evalAst } from "./expr-ast.js";
|
|
13
13
|
import { escapeHtml, parseStateValue, safeScriptJson } from "./interpolation.js";
|
|
14
14
|
import { compileComputedExpr } from "./predicates.js";
|
|
@@ -42,6 +42,7 @@ export function handleState(line, ctx, index) {
|
|
|
42
42
|
});
|
|
43
43
|
const value = parseStateValue(match[2], at(ctx, index));
|
|
44
44
|
const key = declareState(match[1], value, ctx);
|
|
45
|
+
recordSymbol(ctx, index, { kind: "state", name: key, detail: `${key} = ${match[2].trim()}` });
|
|
45
46
|
const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
|
|
46
47
|
return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
47
48
|
}
|
|
@@ -101,6 +102,7 @@ export function handleStore(line, ctx, index) {
|
|
|
101
102
|
);
|
|
102
103
|
const value = parseStateValue(match[2], at(ctx, index));
|
|
103
104
|
const name = declareStore(match[1], value, ctx);
|
|
105
|
+
recordSymbol(ctx, index, { kind: "store", name, detail: `${name} = ${match[2].trim()}` });
|
|
104
106
|
const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
|
|
105
107
|
return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
|
|
106
108
|
}
|
|
@@ -195,6 +197,7 @@ export function handleComputed(line, ctx, index) {
|
|
|
195
197
|
// folds to null, never throws — so parse-failure above is the only error path.
|
|
196
198
|
const initial = evalAst(ast, undefined, ctx.comp);
|
|
197
199
|
const key = declareState(match[1], initial ?? null, ctx);
|
|
200
|
+
recordSymbol(ctx, index, { kind: "computed", name: key, detail: `${key} = ${match[2].trim()}` });
|
|
198
201
|
return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(JSON.stringify(ast))}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
|
|
199
202
|
}
|
|
200
203
|
|
|
@@ -227,5 +230,10 @@ export function handleTheme(line, ctx, index) {
|
|
|
227
230
|
const name = match[1] || "theme";
|
|
228
231
|
const seed = match[2] != null ? parseStateValue(match[2], at(ctx, index)) : "auto";
|
|
229
232
|
const storeName = declareStore(name, seed, ctx);
|
|
233
|
+
recordSymbol(ctx, index, {
|
|
234
|
+
kind: "theme",
|
|
235
|
+
name: storeName,
|
|
236
|
+
detail: `${storeName} = ${JSON.stringify(seed)}`
|
|
237
|
+
});
|
|
230
238
|
return `<script type="application/json" data-wd-store="${storeName}">${safeScriptJson(seed)}</script><span data-wd-theme="${storeName}" hidden></span>`;
|
|
231
239
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// while keeping the message text and `Use:` hints intact.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
import { at, createScope, LOOP_META, lineOf, nestedCtx, wdError } from "./context.js";
|
|
14
|
+
import { at, createScope, LOOP_META, lineOf, nestedCtx, recordSymbol, wdError } from "./context.js";
|
|
15
15
|
import { astOf, serializeExpr } from "./expr-ast.js";
|
|
16
16
|
import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
|
|
17
17
|
import {
|
|
@@ -68,6 +68,7 @@ export function handleInclude(line, ctx, index) {
|
|
|
68
68
|
ctx.comp.reader
|
|
69
69
|
);
|
|
70
70
|
const args = parseIncludeArgs(match[2] || "", ctx);
|
|
71
|
+
recordSymbol(ctx, index, { kind: "include", name: match[1], detail: `@include ${match[1]}` });
|
|
71
72
|
const childScope = createScope(ctx.scope, args);
|
|
72
73
|
// Thread the reactive-nesting depth (and the enclosing loop opener, so a depth
|
|
73
74
|
// error names the right opener) into the include, exactly as `loopItem` is —
|
|
@@ -288,6 +289,7 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
|
|
|
288
289
|
const truthyCtx = nestedCtx(ctx, index + 1);
|
|
289
290
|
const falsyCtx = nestedCtx(ctx, falsyStart);
|
|
290
291
|
const condition = line.replace(/^:if\s+/, "").trim();
|
|
292
|
+
recordSymbol(ctx, index, { kind: "if", name: condition, detail: `:if ${condition}` });
|
|
291
293
|
// Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
|
|
292
294
|
// existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
|
|
293
295
|
// `not`, …) falls through to the predicate path below.
|