@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.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.
Files changed (73) hide show
  1. package/AGENTS.md +392 -77
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +316 -49
  4. package/docs/cli.md +51 -17
  5. package/docs/spec-alignment.md +25 -4
  6. package/grammar/wd-directives.gbnf +13 -5
  7. package/package.json +7 -3
  8. package/site/_/base.skin +1 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/pages/api-errors/index.wd +81 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/attr-binding/index.wd +84 -0
  13. package/site/pages/blog/darkmown-2-7-0.md +176 -0
  14. package/site/pages/blog/darkmown-2-7-0.skin +127 -0
  15. package/site/pages/blog/no-unsafe-eval.md +66 -0
  16. package/site/pages/blog/no-unsafe-eval.skin +127 -0
  17. package/site/pages/blog/zero-js-by-default.md +1 -1
  18. package/site/pages/branch-state/index.wd +68 -0
  19. package/site/pages/docs/index.wd +320 -19
  20. package/site/pages/index.wd +3 -1
  21. package/site/pages/playground/index.js +63 -56
  22. package/site/pages/pulse/index.wd +1 -1
  23. package/site/pages/settings/index.wd +97 -0
  24. package/site/pages/showcase/index.wd +37 -1
  25. package/site/pages/upload/index.wd +63 -0
  26. package/site/pages/url-state/index.wd +70 -0
  27. package/src/builder.js +222 -34
  28. package/src/catalog.js +217 -21
  29. package/src/cli.js +6 -3
  30. package/src/compiler/actions.js +80 -12
  31. package/src/compiler/body.js +104 -28
  32. package/src/compiler/collections.js +29 -13
  33. package/src/compiler/context.js +9 -2
  34. package/src/compiler/expr-ast.js +14 -0
  35. package/src/compiler/fetch.js +8 -4
  36. package/src/compiler/forms.js +133 -14
  37. package/src/compiler/frontmatter.js +28 -7
  38. package/src/compiler/includes.js +46 -6
  39. package/src/compiler/interpolation.js +56 -1
  40. package/src/compiler/loops.js +489 -35
  41. package/src/compiler/markdown.js +457 -44
  42. package/src/compiler/page.js +8 -3
  43. package/src/compiler/predicates.js +84 -14
  44. package/src/compiler/state.js +167 -18
  45. package/src/compiler/structure.js +159 -29
  46. package/src/errors.js +111 -3
  47. package/src/feeds.js +140 -7
  48. package/src/grammar.js +53 -10
  49. package/src/runtime.js +509 -130
  50. package/src/runtime.min.js +2 -0
  51. package/src/runtime.min.js.map +1 -0
  52. package/src/statics.js +46 -2
  53. package/src/templates/blog/README.md +2 -0
  54. package/src/templates/dashboard/README.md +2 -0
  55. package/src/templates/landing/README.md +2 -0
  56. package/src/templates/starter/README.md +1 -1
  57. package/src/templates/store/README.md +2 -0
  58. package/src/tools/validate.js +14 -4
  59. package/types/builder.d.ts +6 -2
  60. package/types/catalog.d.ts +12 -0
  61. package/types/compiler/actions.d.ts +1 -1
  62. package/types/compiler/body.d.ts +0 -3
  63. package/types/compiler/forms.d.ts +12 -1
  64. package/types/compiler/frontmatter.d.ts +8 -1
  65. package/types/compiler/includes.d.ts +16 -2
  66. package/types/compiler/interpolation.d.ts +23 -0
  67. package/types/compiler/loops.d.ts +47 -0
  68. package/types/compiler/markdown.d.ts +63 -1
  69. package/types/compiler/predicates.d.ts +7 -1
  70. package/types/compiler/state.d.ts +8 -2
  71. package/types/compiler/structure.d.ts +32 -1
  72. package/types/feeds.d.ts +68 -2
  73. package/types/statics.d.ts +25 -0
@@ -33,13 +33,45 @@ import {
33
33
  handleTheme,
34
34
  renderDemoDirective
35
35
  } from "./directives.js";
36
- import { handleLoop } from "./loops.js";
36
+ import { handleLoop, spliceTables } from "./loops.js";
37
37
  import { renderProse } from "./markdown.js";
38
38
 
39
39
  /**
40
40
  * @typedef {import("./context.js").Ctx} Ctx
41
41
  */
42
42
 
43
+ // The directives that REQUIRE arguments, mapped to the handler that owns their
44
+ // `Use:` hint. Each entry is called with the bare line and an empty body, and
45
+ // every one of them throws — the block openers included, because their argument
46
+ // check runs before they ever look at a body. `:theme` and `:carousel` are
47
+ // deliberately absent: both are valid bare, and both dispatchers already accept
48
+ // `(?:\s|$)`.
49
+ /** @type {Record<string, (line: string, ctx: Ctx, index: number) => unknown>} */
50
+ const BARE_DIRECTIVE = {
51
+ "@include": (line, ctx, i) => handleInclude(line, ctx, i),
52
+ "@loop": (line, ctx, i) => handleLoop(line, [], null, ctx, i),
53
+ ":state": (line, ctx, i) => handleState(line, ctx, i),
54
+ ":store": (line, ctx, i) => handleStore(line, ctx, i),
55
+ ":fetch": (line, ctx, i) => handleFetch(line, ctx, i),
56
+ ":computed": (line, ctx, i) => handleComputed(line, ctx, i),
57
+ ":effect": (line, ctx, i) => handleEffect(line, ctx, i),
58
+ ":every": (line, ctx, i) => handleEvery(line, ctx, i),
59
+ ":video": (line, ctx, i) => handleMedia(line, "video", ctx, i),
60
+ ":audio": (line, ctx, i) => handleMedia(line, "audio", ctx, i),
61
+ ":embed": (line, ctx, i) => handleEmbed(line, ctx, i),
62
+ ":form": (line, ctx, i) => handleForm(line, [], ctx, i),
63
+ ":input": (line, ctx, i) => handleInput(line, ctx, i),
64
+ ":textarea": (line, ctx, i) => handleTextarea(line, ctx, i),
65
+ ":select": (line, ctx, i) => handleSelect(line, [], ctx, i),
66
+ ":checkbox": (line, ctx, i) => handleChoiceGroup(line, [], ctx, "checkbox", i),
67
+ ":radio": (line, ctx, i) => handleChoiceGroup(line, [], ctx, "radio", i),
68
+ ":bind": (line, ctx, i) => handleBind(line, ctx, i),
69
+ ":slider": (line, ctx, i) => handleSlider(line, ctx, i),
70
+ ":submit": (line, ctx, i) => handleSubmit(line, ctx, i),
71
+ ":button": (line, ctx, i) => handleButton(line, ctx, i),
72
+ ":if": (line, ctx, i) => handleIf(line, [], [], ctx, i)
73
+ };
74
+
43
75
  /**
44
76
  * Parse a `.wd` body line-by-line into HTML, mixing directives and prose.
45
77
  * @param {string[]} lines
@@ -51,17 +83,37 @@ export function compileBody(lines, ctx) {
51
83
  const out = [];
52
84
  /** @type {string[]} */
53
85
  let prose = [];
86
+ // The slice index the pending prose run starts at, so a warning raised while
87
+ // rendering it (an unbindable value in a link destination or in raw HTML)
88
+ // reports the offending line and not the top of the file.
89
+ let proseStart = 0;
54
90
  let fence = null;
55
91
 
92
+ // Append one chunk, splicing it into the previous one when the seam between
93
+ // them is a table split in half. Markdown only sees a table when a header row
94
+ // is followed by a `|---|` separator, so `| Name | N |` + `|---|---|` in the
95
+ // prose ABOVE a loop compiles to a header-only table, and the loop's rows
96
+ // compile separately: the two halves have to be stitched back together here,
97
+ // where prose output and handler output meet. Every other seam falls through
98
+ // to the plain newline join, so all other output is byte-identical.
99
+ const push = (/** @type {string} */ html) => {
100
+ const last = out.length - 1;
101
+ const merged = last >= 0 ? spliceTables(out[last], html) : null;
102
+ if (merged === null) out.push(html);
103
+ else out[last] = merged;
104
+ };
105
+
56
106
  const flush = () => {
57
107
  if (!prose.length) return;
58
108
  const text = prose.join("\n");
109
+ const start = proseStart;
59
110
  prose = [];
60
- if (text.trim()) out.push(renderProse(text, ctx));
111
+ if (text.trim()) push(renderProse(text, ctx, start));
61
112
  };
62
113
 
63
114
  for (let i = 0; i < lines.length; i++) {
64
115
  const line = lines[i];
116
+ if (!prose.length) proseStart = i;
65
117
  const fenceMatch = line.match(/^(```+|~~~+)/);
66
118
  if (fence) {
67
119
  prose.push(line);
@@ -75,9 +127,23 @@ export function compileBody(lines, ctx) {
75
127
  continue;
76
128
  }
77
129
 
130
+ // A directive name ALONE on a line is a mistake with one obvious fix: it is
131
+ // missing its arguments. Every dispatcher below requires a space after the
132
+ // keyword, so such a line used to fall through to prose — and because
133
+ // `KNOWN_DIRECTIVE` accepted `(?:\s|$)`, even the "looks like a directive"
134
+ // warning was suppressed. `:state` on its own rendered as the literal text
135
+ // `:state` with nothing said about it. Hand the line to the directive's OWN
136
+ // handler with an empty body: it throws its own coded malformed error with
137
+ // its own `Use:` hint, so there is exactly one place that owns each hint.
138
+ const bare = line.match(/^([@:][a-z]+)\s*$/);
139
+ if (bare && Object.hasOwn(BARE_DIRECTIVE, bare[1])) {
140
+ flush();
141
+ BARE_DIRECTIVE[bare[1]](line, ctx, i);
142
+ }
143
+
78
144
  if (/^@include\s/.test(line)) {
79
145
  flush();
80
- out.push(handleInclude(line, ctx, i));
146
+ push(handleInclude(line, ctx, i));
81
147
  continue;
82
148
  }
83
149
  if (/^@loop\s/.test(line)) {
@@ -85,7 +151,7 @@ export function compileBody(lines, ctx) {
85
151
  const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx);
86
152
  const split = splitEmptyBranch(block.body);
87
153
  const loopAt = ctx.comp.symbols.length;
88
- out.push(handleLoop(line, split.body, split.empty, ctx, i, split.emptyStart));
154
+ push(handleLoop(line, split.body, split.empty, ctx, i, split.emptyStart));
89
155
  stampBlockEnd(ctx, loopAt, block.end);
90
156
  i = block.end;
91
157
  continue;
@@ -99,84 +165,84 @@ export function compileBody(lines, ctx) {
99
165
  file: ctx.file
100
166
  });
101
167
  const block = scanContainer(lines, i, ctx);
102
- out.push(handleContainer(container[1].trim(), block.body, ctx, i));
168
+ push(handleContainer(container[1].trim(), block.body, ctx, i));
103
169
  i = block.end;
104
170
  continue;
105
171
  }
106
172
  if (/^:state\s/.test(line)) {
107
173
  flush();
108
174
  const v = joinValueDirective(lines, i);
109
- out.push(handleState(v.line, ctx, i));
175
+ push(handleState(v.line, ctx, i));
110
176
  i = v.end;
111
177
  continue;
112
178
  }
113
179
  if (/^:store\s/.test(line)) {
114
180
  flush();
115
181
  const v = joinValueDirective(lines, i);
116
- out.push(handleStore(v.line, ctx, i));
182
+ push(handleStore(v.line, ctx, i));
117
183
  i = v.end;
118
184
  continue;
119
185
  }
120
186
  if (/^:fetch\s/.test(line)) {
121
187
  flush();
122
- out.push(handleFetch(line, ctx, i));
188
+ push(handleFetch(line, ctx, i));
123
189
  continue;
124
190
  }
125
191
  if (/^:computed\s/.test(line)) {
126
192
  flush();
127
- out.push(handleComputed(line, ctx, i));
193
+ push(handleComputed(line, ctx, i));
128
194
  continue;
129
195
  }
130
196
  if (/^:effect\s/.test(line)) {
131
197
  flush();
132
- out.push(handleEffect(line, ctx, i));
198
+ push(handleEffect(line, ctx, i));
133
199
  continue;
134
200
  }
135
201
  if (/^:every\s/.test(line)) {
136
202
  flush();
137
- out.push(handleEvery(line, ctx, i));
203
+ push(handleEvery(line, ctx, i));
138
204
  continue;
139
205
  }
140
206
  if (/^:theme(?:\s|$)/.test(line)) {
141
207
  flush();
142
208
  const v = joinValueDirective(lines, i);
143
- out.push(handleTheme(v.line, ctx, i));
209
+ push(handleTheme(v.line, ctx, i));
144
210
  i = v.end;
145
211
  continue;
146
212
  }
147
213
  const media = line.match(/^:(video|audio)\s/);
148
214
  if (media) {
149
215
  flush();
150
- out.push(handleMedia(line, /** @type {"video" | "audio"} */ (media[1]), ctx, i));
216
+ push(handleMedia(line, /** @type {"video" | "audio"} */ (media[1]), ctx, i));
151
217
  continue;
152
218
  }
153
219
  if (/^:embed\s/.test(line)) {
154
220
  flush();
155
- out.push(handleEmbed(line, ctx, i));
221
+ push(handleEmbed(line, ctx, i));
156
222
  continue;
157
223
  }
158
224
  if (/^:form\s/.test(line)) {
159
225
  flush();
160
226
  const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx);
161
- out.push(handleForm(line, block.body, ctx, i));
227
+ push(handleForm(line, block.body, ctx, i));
162
228
  i = block.end;
163
229
  continue;
164
230
  }
165
231
  if (/^:carousel(?:\s|$)/.test(line)) {
166
232
  flush();
167
233
  const block = scanBlock(lines, i, /^:carousel(?:\s|$)/, ":endcarousel", ctx);
168
- out.push(handleCarousel(line, block.body, ctx, i));
234
+ push(handleCarousel(line, block.body, ctx, i));
169
235
  i = block.end;
170
236
  continue;
171
237
  }
172
238
  if (/^:input\s/.test(line)) {
173
239
  flush();
174
- out.push(handleInput(line, ctx, i));
240
+ push(handleInput(line, ctx, i));
175
241
  continue;
176
242
  }
177
243
  if (/^:textarea\s/.test(line)) {
178
244
  flush();
179
- out.push(handleTextarea(line, ctx, i));
245
+ push(handleTextarea(line, ctx, i));
180
246
  continue;
181
247
  }
182
248
  if (/^:select\s/.test(line)) {
@@ -187,7 +253,7 @@ export function compileBody(lines, ctx) {
187
253
  opts.push(lines[j]);
188
254
  j++;
189
255
  }
190
- out.push(handleSelect(line, opts, ctx, i));
256
+ push(handleSelect(line, opts, ctx, i));
191
257
  i = j - 1;
192
258
  continue;
193
259
  }
@@ -200,7 +266,7 @@ export function compileBody(lines, ctx) {
200
266
  opts.push(lines[j]);
201
267
  j++;
202
268
  }
203
- out.push(
269
+ push(
204
270
  handleChoiceGroup(line, opts, ctx, /** @type {"checkbox" | "radio"} */ (choiceMatch[1]), i)
205
271
  );
206
272
  i = j - 1;
@@ -208,29 +274,29 @@ export function compileBody(lines, ctx) {
208
274
  }
209
275
  if (/^:bind\s/.test(line)) {
210
276
  flush();
211
- out.push(handleBind(line, ctx, i));
277
+ push(handleBind(line, ctx, i));
212
278
  continue;
213
279
  }
214
280
  if (/^:slider\s/.test(line)) {
215
281
  flush();
216
- out.push(handleSlider(line, ctx, i));
282
+ push(handleSlider(line, ctx, i));
217
283
  continue;
218
284
  }
219
285
  if (/^:submit\s/.test(line)) {
220
286
  flush();
221
- out.push(handleSubmit(line, ctx, i));
287
+ push(handleSubmit(line, ctx, i));
222
288
  continue;
223
289
  }
224
290
  if (/^:button\s/.test(line)) {
225
291
  flush();
226
- out.push(handleButton(line, ctx, i));
292
+ push(handleButton(line, ctx, i));
227
293
  continue;
228
294
  }
229
295
  if (/^:if\s/.test(line)) {
230
296
  flush();
231
297
  const block = scanConditional(lines, i, ctx);
232
298
  const ifAt = ctx.comp.symbols.length;
233
- out.push(handleIf(line, block.truthy, block.falsy, ctx, i, block.falsyStart));
299
+ push(handleIf(line, block.truthy, block.falsy, ctx, i, block.falsyStart));
234
300
  stampBlockEnd(ctx, ifAt, block.end);
235
301
  i = block.end;
236
302
  continue;
@@ -238,7 +304,7 @@ export function compileBody(lines, ctx) {
238
304
  const demo = renderDemoDirective(line, ctx);
239
305
  if (demo) {
240
306
  flush();
241
- out.push(demo);
307
+ push(demo);
242
308
  continue;
243
309
  }
244
310
  if (/^@repeat\b/.test(line)) {
@@ -253,7 +319,13 @@ export function compileBody(lines, ctx) {
253
319
  { code: "WD009", file: ctx.file, hint: "@loop items into item ... @endloop" }
254
320
  );
255
321
  }
256
- if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
322
+ // `:endcarousel` joins the family: a closer with no opener is a mistake with
323
+ // one obvious fix, and the vague "matches none" warning it used to get named
324
+ // the wrong problem ("check the spelling") for a token spelled correctly.
325
+ // `@empty` too: it is a MID-block marker, consumed by `splitEmptyBranch` when
326
+ // it sits inside a `@loop`, so one reaching here has no loop to belong to and
327
+ // used to render as the literal text `@empty` with nothing said about it.
328
+ if (/^(@endloop|@empty|:endif|:endfor|:endform|:endcarousel|:else)\s*$/.test(line)) {
257
329
  throw wdError(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`, {
258
330
  code: "WD010",
259
331
  file: ctx.file
@@ -273,8 +345,12 @@ export function compileBody(lines, ctx) {
273
345
  // throw: prose legitimately contains `@` and `:`, so the match is deliberately
274
346
  // narrow (lowercase token + boundary; emoji shortcodes, times, and `@user`
275
347
  // followed by punctuation do not match).
348
+ // Keep this in step with the dispatch above: a name listed here but no longer
349
+ // handled is the worst outcome — the line renders as literal text AND the
350
+ // warning that would have said so is suppressed. (`:note`/`:sprint` were deleted
351
+ // from the demo handler and are gone from here for exactly that reason.)
276
352
  const KNOWN_DIRECTIVE =
277
- /^(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|every|theme|video|audio|embed|form|endform|input|textarea|select|checkbox|radio|bind|submit|button|if|endif|else|try|note|sprint))(?:\s|$)/;
353
+ /^(?:(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|every|theme|video|audio|embed|form|endform|input|textarea|select|checkbox|radio|bind|slider|submit|button|carousel|endcarousel|if|endif|else))(?:\s|$)|:try\s)/;
278
354
  /**
279
355
  * @param {string} line
280
356
  * @param {Ctx} ctx
@@ -95,26 +95,42 @@ function collectionNameOf(route) {
95
95
  */
96
96
  function rowFor(route, reader) {
97
97
  const slug = path.basename(route.file, path.extname(route.file));
98
+ // The router's `route.meta` stays the source of truth for the VALUES (it is
99
+ // parsed once, there), but it cannot carry which of them the author quoted —
100
+ // and that flag is the whole difference between the text "007" and the number
101
+ // 7. Read the entry once here for `quotedKeys` plus the excerpt body; the
102
+ // `.md` excerpt fallback already cost this read.
103
+ const { quotedKeys, body } = parseFrontmatter(reader.readText(route.file), route.file);
98
104
  /** @type {Record<string, unknown>} */
99
105
  const fields = {};
100
- for (const [key, value] of Object.entries(route.meta)) fields[key] = coerceScalar(value);
106
+ for (const [key, value] of Object.entries(route.meta))
107
+ fields[key] = quotedKeys.has(key) ? value : coerceScalar(value);
101
108
  return {
102
109
  ...fields,
103
110
  url: route.route,
104
111
  slug: slug === "index" ? path.basename(path.dirname(route.file)) : slug,
105
- excerpt: excerptFor(route, reader)
112
+ excerpt: excerptFor(route, body)
106
113
  };
107
114
  }
108
115
 
109
116
  /**
110
- * Coerce a frontmatter scalar to its natural type for querying. The frontmatter
111
- * parser yields scalars as STRINGS (`true`, `42` arrive as `"true"`, `"42"`), but
112
- * a collection loop should behave like a JSON loop, where `where p.featured ==
113
- * true` and numeric `sort`/comparisons just work. So a bare `true`/`false` becomes
114
- * a boolean and a numeric string becomes a number; quoted/other strings and
115
- * arrays pass through untouched. This coercion is scoped to collection rows only —
116
- * it never touches the global frontmatter parser (a `:state`/`{ meta.x }` value
117
- * keeps its existing string form).
117
+ * Coerce an UNQUOTED frontmatter scalar to its natural type for querying. The
118
+ * frontmatter parser yields scalars as STRINGS (`true`, `42` arrive as `"true"`,
119
+ * `"42"`), but a collection loop should behave like a JSON loop, where
120
+ * `where p.featured == true` and numeric `sort`/comparisons just work. So a bare
121
+ * `true`/`false` becomes a boolean and a numeric string becomes a number; other
122
+ * strings and arrays pass through untouched.
123
+ *
124
+ * QUOTED values never reach here — {@link rowFor} skips them by `quotedKeys`.
125
+ * They used to, because the parser strips quotes before anything downstream can
126
+ * see them, and the result was silent data loss with no escape hatch: `"007"`
127
+ * became 7, `"1.10"` became 1.1, `"01234"` became 1234, and a `_schema.wd`
128
+ * declaring `sku: string` then failed the whole build with a WD124 blaming the
129
+ * author for a string they had in fact written. Zero-padded ids, ISBNs, zip
130
+ * codes, SKUs and `x.y0` versions all live in that gap.
131
+ *
132
+ * This coercion is scoped to collection rows only — it never touches the global
133
+ * frontmatter parser (a `:state`/`{ meta.x }` value keeps its string form).
118
134
  * @param {unknown} value
119
135
  * @returns {unknown}
120
136
  */
@@ -130,14 +146,14 @@ function coerceScalar(value) {
130
146
  * (plain `.md` only) the first paragraph of its body, else "". Mirrors the RSS
131
147
  * description fallback so a collection card and the feed read the same snippet.
132
148
  * @param {Route} route
133
- * @param {import("./reader.js").Reader} reader Source reader for the `.md` body.
149
+ * @param {string} body The entry's body, already split from its frontmatter by
150
+ * {@link rowFor} (which reads the file once for both this and `quotedKeys`).
134
151
  * @returns {string}
135
152
  */
136
- function excerptFor(route, reader) {
153
+ function excerptFor(route, body) {
137
154
  const fromMeta = route.meta.excerpt;
138
155
  if (typeof fromMeta === "string" && fromMeta.trim()) return fromMeta.trim();
139
156
  if (path.extname(route.file) !== ".md") return "";
140
- const { body } = parseFrontmatter(reader.readText(route.file), route.file);
141
157
  return firstParagraph(body);
142
158
  }
143
159
 
@@ -266,14 +266,21 @@ export const pageIncludeExtensions = [".md", ".wd"];
266
266
 
267
267
  // Reserved per-row meta variables, mapped to their runtime marker token. Shared
268
268
  // by the body parser, the `:if` handler, and prose interpolation.
269
+ //
270
+ // PROTOTYPE-FREE ON PURPOSE. Every consumer asks `LOOP_META[head]` where `head`
271
+ // is an author-supplied identifier, so a plain object literal would answer for
272
+ // every `Object.prototype` member: `{ toString }` and `:if __proto__` resolved
273
+ // as loop meta variables and emitted a `data-wd-each-meta="…"` marker for a
274
+ // name nobody declared. A null-prototype object closes that at the source, so
275
+ // every consumer (body parser, `:if`, prose interpolation) is fixed at once.
269
276
  /** @type {Record<string, string>} */
270
- export const LOOP_META = {
277
+ export const LOOP_META = Object.assign(Object.create(null), {
271
278
  $index: "index",
272
279
  $number: "number",
273
280
  $first: "first",
274
281
  $last: "last",
275
282
  $count: "count"
276
- };
283
+ });
277
284
 
278
285
  /**
279
286
  * @param {import("./reader.js").Reader} reader The source reader for this
@@ -169,6 +169,20 @@ export function astOf(code) {
169
169
  const readNumber = () => {
170
170
  let j = i;
171
171
  while (j < n && /[0-9.]/.test(code[j])) j++;
172
+ // Exponent notation. The compilers fold a build-known operand with
173
+ // `JSON.stringify`, and JS prints a number outside 1e-7…1e21 in exponent
174
+ // form (`1e+21`, `1e-7`), so the fragment this module re-parses can carry
175
+ // one even though no author ever typed it. The exponent is only consumed
176
+ // when `e`/`E` is actually followed by digits (with an optional sign), so a
177
+ // bare trailing `e` still falls through to the closed grammar's hard error.
178
+ if (code[j] === "e" || code[j] === "E") {
179
+ let k = j + 1;
180
+ if (code[k] === "+" || code[k] === "-") k++;
181
+ if (k < n && code[k] >= "0" && code[k] <= "9") {
182
+ while (k < n && code[k] >= "0" && code[k] <= "9") k++;
183
+ j = k;
184
+ }
185
+ }
172
186
  const num = Number(code.slice(i, j));
173
187
  i = j;
174
188
  return num;
@@ -15,7 +15,7 @@ import {
15
15
  stripQuotes,
16
16
  validatePath
17
17
  } from "./interpolation.js";
18
- import { declareState } from "./state.js";
18
+ import { declareErrorState, declareState } from "./state.js";
19
19
 
20
20
  /**
21
21
  * @typedef {import("./context.js").Ctx} Ctx
@@ -119,15 +119,19 @@ export function handleFetch(line, ctx, index) {
119
119
  if (!deps.includes(headKey)) deps.push(headKey);
120
120
  }
121
121
 
122
- // Auto-declare the four lifecycle keys. `name` is declared via declareState
123
- // (collision-checked); the derived keys are seeded directly.
122
+ // Auto-declare the lifecycle keys. `name` is declared via declareState
123
+ // (collision-checked); the derived keys are seeded directly, and the error
124
+ // pair goes through the shared helper so `:fetch` and `:form` declare the
125
+ // same two keys.
124
126
  const key = declareState(name, null, ctx);
125
127
  ctx.comp.fetchKeys.add(key);
128
+ declareErrorState(key, ctx);
126
129
  recordSymbol(ctx, index, { kind: "fetch", name: key, detail: `${key} from "${url}"` });
127
130
  /** @type {Record<string, unknown>} */
128
131
  const seeds = { [key]: null };
129
132
  for (const [suffix, seed] of [
130
133
  ["_error", null],
134
+ ["_error_body", null],
131
135
  ["_loading", false],
132
136
  ["_empty", false]
133
137
  ]) {
@@ -140,7 +144,7 @@ export function handleFetch(line, ctx, index) {
140
144
  const attr = (n, v) =>
141
145
  v != null && v !== false && v !== "" ? ` data-wd-fetch-${n}="${escapeHtml(v)}"` : "";
142
146
  const marker =
143
- `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"` +
147
+ `<span data-wd-fetch data-wd-fetch-key="${escapeHtml(key)}" data-wd-fetch-url="${escapeHtml(url)}"` +
144
148
  attr("method", opts.method && opts.method.toUpperCase()) +
145
149
  attr("when", opts.when === "visible" ? "visible" : "") +
146
150
  attr("timeout", opts.timeout) +