@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.
- package/AGENTS.md +392 -77
- package/CHANGELOG.md +47 -0
- package/README.md +316 -49
- package/docs/cli.md +51 -17
- package/docs/spec-alignment.md +25 -4
- package/grammar/wd-directives.gbnf +13 -5
- package/package.json +7 -3
- package/site/_/base.skin +1 -1
- package/site/_/footer.wd +1 -1
- package/site/pages/api-errors/index.wd +81 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/attr-binding/index.wd +84 -0
- package/site/pages/blog/darkmown-2-7-0.md +176 -0
- package/site/pages/blog/darkmown-2-7-0.skin +127 -0
- package/site/pages/blog/no-unsafe-eval.md +66 -0
- package/site/pages/blog/no-unsafe-eval.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/branch-state/index.wd +68 -0
- package/site/pages/docs/index.wd +320 -19
- package/site/pages/index.wd +3 -1
- package/site/pages/playground/index.js +63 -56
- package/site/pages/pulse/index.wd +1 -1
- package/site/pages/settings/index.wd +97 -0
- package/site/pages/showcase/index.wd +37 -1
- package/site/pages/upload/index.wd +63 -0
- package/site/pages/url-state/index.wd +70 -0
- package/src/builder.js +222 -34
- package/src/catalog.js +217 -21
- package/src/cli.js +6 -3
- package/src/compiler/actions.js +80 -12
- package/src/compiler/body.js +104 -28
- package/src/compiler/collections.js +29 -13
- package/src/compiler/context.js +9 -2
- package/src/compiler/expr-ast.js +14 -0
- package/src/compiler/fetch.js +8 -4
- package/src/compiler/forms.js +133 -14
- package/src/compiler/frontmatter.js +28 -7
- package/src/compiler/includes.js +46 -6
- package/src/compiler/interpolation.js +56 -1
- package/src/compiler/loops.js +489 -35
- package/src/compiler/markdown.js +457 -44
- package/src/compiler/page.js +8 -3
- package/src/compiler/predicates.js +84 -14
- package/src/compiler/state.js +167 -18
- package/src/compiler/structure.js +159 -29
- package/src/errors.js +111 -3
- package/src/feeds.js +140 -7
- package/src/grammar.js +53 -10
- package/src/runtime.js +509 -130
- package/src/runtime.min.js +2 -0
- package/src/runtime.min.js.map +1 -0
- package/src/statics.js +46 -2
- package/src/templates/blog/README.md +2 -0
- package/src/templates/dashboard/README.md +2 -0
- package/src/templates/landing/README.md +2 -0
- package/src/templates/starter/README.md +1 -1
- package/src/templates/store/README.md +2 -0
- package/src/tools/validate.js +14 -4
- package/types/builder.d.ts +6 -2
- package/types/catalog.d.ts +12 -0
- package/types/compiler/actions.d.ts +1 -1
- package/types/compiler/body.d.ts +0 -3
- package/types/compiler/forms.d.ts +12 -1
- package/types/compiler/frontmatter.d.ts +8 -1
- package/types/compiler/includes.d.ts +16 -2
- package/types/compiler/interpolation.d.ts +23 -0
- package/types/compiler/loops.d.ts +47 -0
- package/types/compiler/markdown.d.ts +63 -1
- package/types/compiler/predicates.d.ts +7 -1
- package/types/compiler/state.d.ts +8 -2
- package/types/compiler/structure.d.ts +32 -1
- package/types/feeds.d.ts +68 -2
- package/types/statics.d.ts +25 -0
package/src/compiler/body.js
CHANGED
|
@@ -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())
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
+
push(handleFetch(line, ctx, i));
|
|
123
189
|
continue;
|
|
124
190
|
}
|
|
125
191
|
if (/^:computed\s/.test(line)) {
|
|
126
192
|
flush();
|
|
127
|
-
|
|
193
|
+
push(handleComputed(line, ctx, i));
|
|
128
194
|
continue;
|
|
129
195
|
}
|
|
130
196
|
if (/^:effect\s/.test(line)) {
|
|
131
197
|
flush();
|
|
132
|
-
|
|
198
|
+
push(handleEffect(line, ctx, i));
|
|
133
199
|
continue;
|
|
134
200
|
}
|
|
135
201
|
if (/^:every\s/.test(line)) {
|
|
136
202
|
flush();
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
+
push(handleInput(line, ctx, i));
|
|
175
241
|
continue;
|
|
176
242
|
}
|
|
177
243
|
if (/^:textarea\s/.test(line)) {
|
|
178
244
|
flush();
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
+
push(handleBind(line, ctx, i));
|
|
212
278
|
continue;
|
|
213
279
|
}
|
|
214
280
|
if (/^:slider\s/.test(line)) {
|
|
215
281
|
flush();
|
|
216
|
-
|
|
282
|
+
push(handleSlider(line, ctx, i));
|
|
217
283
|
continue;
|
|
218
284
|
}
|
|
219
285
|
if (/^:submit\s/.test(line)) {
|
|
220
286
|
flush();
|
|
221
|
-
|
|
287
|
+
push(handleSubmit(line, ctx, i));
|
|
222
288
|
continue;
|
|
223
289
|
}
|
|
224
290
|
if (/^:button\s/.test(line)) {
|
|
225
291
|
flush();
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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))
|
|
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,
|
|
112
|
+
excerpt: excerptFor(route, body)
|
|
106
113
|
};
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
/**
|
|
110
|
-
* Coerce
|
|
111
|
-
* parser yields scalars as STRINGS (`true`, `42` arrive as `"true"`,
|
|
112
|
-
* a collection loop should behave like a JSON loop, where
|
|
113
|
-
* true` and numeric `sort`/comparisons just work. So a bare
|
|
114
|
-
* a boolean and a numeric string becomes a number;
|
|
115
|
-
* arrays pass through untouched.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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 {
|
|
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,
|
|
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
|
|
package/src/compiler/context.js
CHANGED
|
@@ -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
|
package/src/compiler/expr-ast.js
CHANGED
|
@@ -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;
|
package/src/compiler/fetch.js
CHANGED
|
@@ -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
|
|
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) +
|