@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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// Structure: the directives that shape a page's tree — `@include` (sub-file
|
|
3
3
|
// compile + scoped-skin stamp), `::: container` sections, `:if` conditional
|
|
4
|
-
// regions, the `:carousel` block — plus the documentation-demo
|
|
5
|
-
//
|
|
4
|
+
// regions, the `:carousel` block — plus the documentation-demo directive
|
|
5
|
+
// `:try`. Nested block bodies recurse through
|
|
6
6
|
// `ctx.compileBody` (and `@include` through `ctx.compileFile`), threaded in by
|
|
7
7
|
// the per-file compile so the module graph stays an import-cycle-free DAG.
|
|
8
8
|
//
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
14
|
import { at, createScope, LOOP_META, lineOf, nestedCtx, recordSymbol, wdError } from "./context.js";
|
|
15
|
-
import { astOf, serializeExpr } from "./expr-ast.js";
|
|
16
15
|
import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
|
|
17
16
|
import {
|
|
18
17
|
escapeHtml,
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
parseScalar,
|
|
23
22
|
resolveStateKey
|
|
24
23
|
} from "./interpolation.js";
|
|
24
|
+
import { astAt, serializeExprAt } from "./loops.js";
|
|
25
25
|
import { compileWhen, evalPredicate } from "./predicates.js";
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -31,6 +31,9 @@ import { compileWhen, evalPredicate } from "./predicates.js";
|
|
|
31
31
|
// Concrete, compilable structure examples for the hint tails + the catalog.
|
|
32
32
|
export const INCLUDE_EXAMPLE = "@include /header.wd";
|
|
33
33
|
export const CONTAINER_EXAMPLE = "::: card .featured";
|
|
34
|
+
// The same header carrying the accessibility attributes the whitelist allows —
|
|
35
|
+
// the WD650/WD651 hint tail, so a rejected attribute shows the accepted shape.
|
|
36
|
+
export const CONTAINER_A11Y_EXAMPLE = '::: card .note role="region" aria-label="Notes"';
|
|
34
37
|
export const IF_EXAMPLE = ":if count > 0";
|
|
35
38
|
export const CAROUSEL_EXAMPLE = ":carousel autoplay=3000";
|
|
36
39
|
const INCLUDE_USE = `Use: @include /partial.wd [with key="value"] — e.g. ${INCLUDE_EXAMPLE}`;
|
|
@@ -43,6 +46,105 @@ const INCLUDE_USE = `Use: @include /partial.wd [with key="value"] — e.g. ${INC
|
|
|
43
46
|
// author-supplied — so no tag-injection surface; classes/ids still escape.
|
|
44
47
|
const SEMANTIC_CONTAINER_TAGS = new Set(["nav", "main"]);
|
|
45
48
|
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// The accessibility attribute whitelist, shared by `::: container` headers and
|
|
51
|
+
// `:button` lines.
|
|
52
|
+
//
|
|
53
|
+
// A disclosure widget, a tab list, a menu, or a semantic table needs `role` and
|
|
54
|
+
// `aria-*` on the elements the compiler emits, and until now neither directive
|
|
55
|
+
// accepted any attribute at all — so those patterns simply could not be built
|
|
56
|
+
// (audit 8.3). This opens exactly three shapes and nothing else:
|
|
57
|
+
//
|
|
58
|
+
// role="…" one ARIA role
|
|
59
|
+
// aria-*="…" any `aria-` attribute
|
|
60
|
+
// title="…" the native advisory title
|
|
61
|
+
//
|
|
62
|
+
// The value is STATIC quoted text; it is HTML-escaped on emit. There is no
|
|
63
|
+
// `{ state }` interpolation in an attribute value in this pass — a reactive
|
|
64
|
+
// `aria-expanded` would need a runtime attribute binding, which does not exist.
|
|
65
|
+
// Everything else (`onclick`, `style`, `href`, `class`, `data-*`, `id=`) is a
|
|
66
|
+
// compile error naming the whitelist, so the directive surface cannot quietly
|
|
67
|
+
// become "arbitrary HTML attributes".
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** The corrective `Use:` tail every attribute error carries. */
|
|
71
|
+
export const A11Y_ATTR_USE = 'role="…", aria-…="…", or title="…" (quoted static text)';
|
|
72
|
+
|
|
73
|
+
// `aria-` plus at least one lowercase letter; `role`/`title` exactly. Names are
|
|
74
|
+
// matched against this whitelist, so an emitted name can never carry markup.
|
|
75
|
+
const A11Y_ATTR_NAME = /^(?:role|title|aria-[a-z][a-z0-9-]*)$/;
|
|
76
|
+
|
|
77
|
+
// One `name="value"` pair at the cursor. The value cannot contain `"`, so the
|
|
78
|
+
// pair always ends where it looks like it ends.
|
|
79
|
+
const ATTR_PAIR = /^([A-Za-z][A-Za-z0-9-]*)="([^"]*)"/;
|
|
80
|
+
// Anything that OPENS like an attribute, so a rejected one gets the whitelist
|
|
81
|
+
// error rather than the generic "unexpected token".
|
|
82
|
+
const ATTR_START = /^([A-Za-z][A-Za-z0-9-]*)\s*=/;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Peel the leading run of whitelisted accessibility attributes off `rest`.
|
|
86
|
+
*
|
|
87
|
+
* Stops at the first token that does not open like an attribute (a `.class`, a
|
|
88
|
+
* `#id`, a `->`, or the end of the line) and hands that remainder back. A token
|
|
89
|
+
* that DOES open like an attribute but is misspelt, unquoted, or outside the
|
|
90
|
+
* whitelist is a compile error — never silently skipped.
|
|
91
|
+
* @param {string} rest Everything from the cursor to the end of the line.
|
|
92
|
+
* @param {Ctx} ctx
|
|
93
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
94
|
+
* @param {string} what Directive label for the message, e.g. `:button`.
|
|
95
|
+
* @returns {{ attrs: [string, string][], rest: string }}
|
|
96
|
+
*/
|
|
97
|
+
export function takeA11yAttrs(rest, ctx, index, what) {
|
|
98
|
+
/** @type {[string, string][]} */
|
|
99
|
+
const attrs = [];
|
|
100
|
+
let s = rest.trim();
|
|
101
|
+
for (let start = s.match(ATTR_START); start; start = s.match(ATTR_START)) {
|
|
102
|
+
// The NAME is checked before the quoting, so `onclick=x` reports the
|
|
103
|
+
// whitelist (the attribute is the problem) rather than sending the author
|
|
104
|
+
// off to add quotes to something that will still be rejected.
|
|
105
|
+
if (!A11Y_ATTR_NAME.test(start[1])) {
|
|
106
|
+
throw wdError(
|
|
107
|
+
`Attribute "${start[1]}" is not allowed in ${what} at ${at(ctx, index)}. ` +
|
|
108
|
+
`Only accessibility attributes are: ${A11Y_ATTR_USE}`,
|
|
109
|
+
{
|
|
110
|
+
code: "WD650",
|
|
111
|
+
file: ctx.file,
|
|
112
|
+
line: lineOf(ctx, index),
|
|
113
|
+
hint: A11Y_ATTR_USE,
|
|
114
|
+
example: CONTAINER_A11Y_EXAMPLE
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const pair = s.match(ATTR_PAIR);
|
|
119
|
+
if (!pair) {
|
|
120
|
+
throw wdError(
|
|
121
|
+
`Attribute "${start[1]}" in ${what} at ${at(ctx, index)} needs a double-quoted value. Use: ${A11Y_ATTR_USE}`,
|
|
122
|
+
{
|
|
123
|
+
code: "WD651",
|
|
124
|
+
file: ctx.file,
|
|
125
|
+
line: lineOf(ctx, index),
|
|
126
|
+
hint: A11Y_ATTR_USE,
|
|
127
|
+
example: CONTAINER_A11Y_EXAMPLE
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
attrs.push([pair[1], pair[2]]);
|
|
132
|
+
s = s.slice(pair[0].length).trim();
|
|
133
|
+
}
|
|
134
|
+
return { attrs, rest: s };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Serialize validated attribute pairs into an HTML attribute string. The NAME
|
|
139
|
+
* came through {@link A11Y_ATTR_NAME} so it is inert; the VALUE is author text
|
|
140
|
+
* and is escaped.
|
|
141
|
+
* @param {[string, string][]} attrs
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
export function a11yAttrHtml(attrs) {
|
|
145
|
+
return attrs.map(([name, value]) => ` ${name}="${escapeHtml(value)}"`).join("");
|
|
146
|
+
}
|
|
147
|
+
|
|
46
148
|
/**
|
|
47
149
|
* @param {string} line
|
|
48
150
|
* @param {Ctx} ctx
|
|
@@ -138,13 +240,20 @@ export function handleContainer(header, bodyLines, ctx, index) {
|
|
|
138
240
|
const eachClasses = [];
|
|
139
241
|
/** @type {[string, any[]][]} Global state-driven class bindings (data-wd-class): [class, exprAST]. */
|
|
140
242
|
const stateClasses = [];
|
|
243
|
+
/** @type {[string, string][]} Whitelisted accessibility attributes: [name, value]. */
|
|
244
|
+
const a11y = [];
|
|
141
245
|
let id = "";
|
|
142
246
|
// Leading tag/name token (anything not starting with . or #). "section" keeps
|
|
143
247
|
// the <section> tag; a whitelisted semantic landmark (`nav`/`main`) emits that
|
|
144
248
|
// real element AND keeps the name as a class hook (so `.nav`/`.main` skins
|
|
145
249
|
// still cascade); any other name becomes a <div> and also a class. The tag is
|
|
146
250
|
// always one of these fixed constants — never author text — so it can't inject.
|
|
147
|
-
|
|
251
|
+
// …unless the header OPENS with an attribute (`::: role="region" .card`), which
|
|
252
|
+
// `^([^\s.#]\S*)` would otherwise swallow whole and emit as a class:
|
|
253
|
+
// `class="role="region" card"`. An attribute in first position leaves
|
|
254
|
+
// the tag at its default and falls through to the token loop below, so both
|
|
255
|
+
// orders work — `::: nav role="navigation"` still gets the <nav> element.
|
|
256
|
+
const lead = ATTR_START.test(rest) ? null : rest.match(/^([^\s.#]\S*)/);
|
|
148
257
|
let nameToken = "section";
|
|
149
258
|
if (lead) {
|
|
150
259
|
nameToken = lead[1];
|
|
@@ -166,6 +275,16 @@ export function handleContainer(header, bodyLines, ctx, index) {
|
|
|
166
275
|
rest = rest.slice((m ? m[0] : rest).length).trim();
|
|
167
276
|
continue;
|
|
168
277
|
}
|
|
278
|
+
// `role="…"` / `aria-…="…"` / `title="…"`, in any position among the class
|
|
279
|
+
// and id tokens. A token that opens like an attribute but is not on the
|
|
280
|
+
// whitelist throws here rather than reaching the generic unexpected-token
|
|
281
|
+
// error, so the message names what IS allowed.
|
|
282
|
+
const taken = takeA11yAttrs(rest, ctx, index, `container "::: ${header.trim()}"`);
|
|
283
|
+
if (taken.attrs.length) {
|
|
284
|
+
a11y.push(...taken.attrs);
|
|
285
|
+
rest = taken.rest;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
169
288
|
const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
|
|
170
289
|
if (!cm)
|
|
171
290
|
throw wdError(
|
|
@@ -176,14 +295,18 @@ export function handleContainer(header, bodyLines, ctx, index) {
|
|
|
176
295
|
rest = rest.slice(cm[0].length).trim();
|
|
177
296
|
// Optional `when <predicate>` makes the class reactive. The predicate runs to
|
|
178
297
|
// the next ` .`/` #` token or the end of the header.
|
|
179
|
-
|
|
298
|
+
// The predicate runs to the next ` .`/` #` token, the next ATTRIBUTE token,
|
|
299
|
+
// or the end of the header — without the attribute stop, `.live when a == b
|
|
300
|
+
// role="status"` swallowed the attribute into the predicate.
|
|
301
|
+
const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|\s+[A-Za-z][A-Za-z0-9-]*\s*=|$)/);
|
|
180
302
|
if (whenMatch) {
|
|
181
303
|
rest = rest.slice(whenMatch[0].length).trim();
|
|
182
304
|
const compiled = compileWhen(whenMatch[1].trim(), ctx);
|
|
183
305
|
if (compiled.static) {
|
|
184
306
|
if (compiled.value) extraClass.push(cls);
|
|
185
|
-
} else if (compiled.item)
|
|
186
|
-
|
|
307
|
+
} else if (compiled.item)
|
|
308
|
+
eachClasses.push([cls, astAt(compiled.body, ctx, index, '"::: … when"')]);
|
|
309
|
+
else stateClasses.push([cls, astAt(compiled.body, ctx, index, '"::: … when"')]);
|
|
187
310
|
} else {
|
|
188
311
|
extraClass.push(cls);
|
|
189
312
|
}
|
|
@@ -200,6 +323,7 @@ export function handleContainer(header, bodyLines, ctx, index) {
|
|
|
200
323
|
}
|
|
201
324
|
const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
|
|
202
325
|
const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
|
|
326
|
+
const a11yAttr = a11yAttrHtml(a11y);
|
|
203
327
|
let classBind = "";
|
|
204
328
|
if (eachClasses.length) {
|
|
205
329
|
ctx.comp.assets.runtime = true;
|
|
@@ -209,7 +333,7 @@ export function handleContainer(header, bodyLines, ctx, index) {
|
|
|
209
333
|
ctx.comp.assets.runtime = true;
|
|
210
334
|
classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`;
|
|
211
335
|
}
|
|
212
|
-
return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
|
|
336
|
+
return `<${tag}${idAttr}${classAttr}${a11yAttr}${classBind}>\n${inner}\n</${tag}>`;
|
|
213
337
|
}
|
|
214
338
|
|
|
215
339
|
/**
|
|
@@ -288,7 +412,10 @@ function fetchLiveAttr(key, sourceLines, ctx) {
|
|
|
288
412
|
export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart = index + 1) {
|
|
289
413
|
const truthyCtx = nestedCtx(ctx, index + 1);
|
|
290
414
|
const falsyCtx = nestedCtx(ctx, falsyStart);
|
|
291
|
-
|
|
415
|
+
// `\s*` (not `\s+`) so a BARE `:if` yields an empty condition and lands on the
|
|
416
|
+
// malformed-`:if` error below, instead of falling through with the literal
|
|
417
|
+
// text `:if` as its condition and reporting a baffling unsupported operand.
|
|
418
|
+
const condition = line.replace(/^:if\s*/, "").trim();
|
|
292
419
|
recordSymbol(ctx, index, { kind: "if", name: condition, detail: `:if ${condition}` });
|
|
293
420
|
// Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
|
|
294
421
|
// existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
|
|
@@ -313,12 +440,12 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
|
|
|
313
440
|
// static: the meta boolean is in scope — fall through to the static branch below.
|
|
314
441
|
}
|
|
315
442
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
443
|
+
// RESOLUTION ORDER: reactive loop item → static scope → declared state, the
|
|
444
|
+
// framework-wide order `{ }` interpolation and `@loop … where` already use.
|
|
445
|
+
// Static scope used to be tried FIRST here, so in a nested static-then-
|
|
446
|
+
// reactive loop that reuses one item name, `{ it.name }` bound to the
|
|
447
|
+
// reactive row while `:if it.done` folded against the OUTER static value and
|
|
448
|
+
// hard-baked a branch with no `data-wd-each-if` — unfixable at runtime.
|
|
322
449
|
if (ctx.loopItem && head === ctx.loopItem) {
|
|
323
450
|
const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
|
|
324
451
|
const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
|
|
@@ -327,6 +454,12 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
|
|
|
327
454
|
return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
328
455
|
}
|
|
329
456
|
|
|
457
|
+
const staticValue = lookupVar(ctx.scope, head);
|
|
458
|
+
if (staticValue.found) {
|
|
459
|
+
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
460
|
+
return ctx.compileBody(active ? truthyLines : falsyLines, active ? truthyCtx : falsyCtx);
|
|
461
|
+
}
|
|
462
|
+
|
|
330
463
|
const key = resolveStateKey(head, ctx);
|
|
331
464
|
if (!key) {
|
|
332
465
|
throw wdError(
|
|
@@ -342,7 +475,11 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
|
|
|
342
475
|
const liveAttr = restPath ? "" : fetchLiveAttr(key, [...truthyLines, ...falsyLines], ctx);
|
|
343
476
|
const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
|
|
344
477
|
const active = initialTruthy ? truthy : falsy;
|
|
345
|
-
|
|
478
|
+
// The key carries the `::: name #id` section prefix, which is author text.
|
|
479
|
+
// It is escaped exactly like the `id="…"` attribute beside it — an unescaped
|
|
480
|
+
// `&`/`"` in a section id closed the attribute early and produced broken
|
|
481
|
+
// markup with a dead binding.
|
|
482
|
+
return `<div data-wd-if="${escapeHtml(key)}"${pathAttr}${liveAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
346
483
|
}
|
|
347
484
|
|
|
348
485
|
// Predicate path: a comparison / logical condition. Compiles through the same
|
|
@@ -368,17 +505,20 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
|
|
|
368
505
|
ctx.comp.assets.runtime = true;
|
|
369
506
|
const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
|
|
370
507
|
const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
|
|
371
|
-
const exprAttr = ` data-wd-if-expr="${escapeHtml(
|
|
508
|
+
const exprAttr = ` data-wd-if-expr="${escapeHtml(serializeExprAt(compiled.body, ctx, index, '":if"'))}"`;
|
|
372
509
|
if (compiled.item) {
|
|
373
510
|
return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
374
511
|
}
|
|
375
|
-
const initialTruthy = evalPredicate(compiled.body, undefined, ctx);
|
|
512
|
+
const initialTruthy = evalPredicate(compiled.body, undefined, ctx, '":if"');
|
|
376
513
|
const active = initialTruthy ? truthy : falsy;
|
|
377
514
|
return `<div data-wd-if=""${exprAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
378
515
|
}
|
|
379
516
|
|
|
380
517
|
/**
|
|
381
|
-
* Render the documentation-demo
|
|
518
|
+
* Render the documentation-demo directive `:try` (the "Try it" card the homepage
|
|
519
|
+
* uses). `:note` and `:sprint` lived here too and were deleted: nothing in the
|
|
520
|
+
* site, the docs, or the public directive set used them, and a directive the
|
|
521
|
+
* catalog does not list is a trap for an AI author that discovers it in source.
|
|
382
522
|
* @param {string} line
|
|
383
523
|
* @param {Ctx} ctx
|
|
384
524
|
* @returns {string}
|
|
@@ -389,16 +529,6 @@ export function renderDemoDirective(line, ctx) {
|
|
|
389
529
|
const href = validateDemoHref(tryMatch[2], ctx);
|
|
390
530
|
return `<a class="try-card" href="${escapeHtml(href)}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
|
|
391
531
|
}
|
|
392
|
-
const note = line.match(/^:note\s+"([^"]+)"$/);
|
|
393
|
-
if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
|
|
394
|
-
const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
|
|
395
|
-
if (sprint) {
|
|
396
|
-
const roles = sprint[3]
|
|
397
|
-
.split(",")
|
|
398
|
-
.map((role) => role.trim())
|
|
399
|
-
.filter(Boolean);
|
|
400
|
-
return `<section class="sprint-board" data-min="${sprint[1]}" data-max="${sprint[2]}">${roles.map((role) => `<article><strong>${escapeHtml(role)}</strong><span>active lane</span></article>`).join("")}</section>`;
|
|
401
|
-
}
|
|
402
532
|
return "";
|
|
403
533
|
}
|
|
404
534
|
|
package/src/errors.js
CHANGED
|
@@ -62,9 +62,16 @@ import {
|
|
|
62
62
|
} from "./compiler/forms.js";
|
|
63
63
|
import { LOOP_EXAMPLE } from "./compiler/loops.js";
|
|
64
64
|
import { EMBED_EXAMPLE, VIDEO_EXAMPLE } from "./compiler/media.js";
|
|
65
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
COMPUTED_EXAMPLE,
|
|
67
|
+
STATE_EXAMPLE,
|
|
68
|
+
STORE_EXAMPLE,
|
|
69
|
+
THEME_EXAMPLE,
|
|
70
|
+
URL_STATE_EXAMPLE
|
|
71
|
+
} from "./compiler/state.js";
|
|
66
72
|
import {
|
|
67
73
|
CAROUSEL_EXAMPLE,
|
|
74
|
+
CONTAINER_A11Y_EXAMPLE,
|
|
68
75
|
CONTAINER_EXAMPLE,
|
|
69
76
|
IF_EXAMPLE,
|
|
70
77
|
INCLUDE_EXAMPLE
|
|
@@ -362,6 +369,20 @@ const ENTRIES = [
|
|
|
362
369
|
cause: "An entry's value does not match its schema type.",
|
|
363
370
|
fix: "Fix the entry's value, or widen the type in `_schema.wd`."
|
|
364
371
|
},
|
|
372
|
+
{
|
|
373
|
+
code: "WD190",
|
|
374
|
+
title: "Expression could not be compiled",
|
|
375
|
+
cause:
|
|
376
|
+
"A `:if` or `::: … when` condition folded a build-time value the expression re-parser cannot read back.",
|
|
377
|
+
fix: 'Use simpler operands — a field path, a declared `:state`, a plain number, or a `"string"`.'
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
code: "WD191",
|
|
381
|
+
title: "Reactive `@loop` over markdown table rows",
|
|
382
|
+
cause:
|
|
383
|
+
"A `@loop` whose body is bare `| … |` cells resolves to a reactive source, and a reactive row is cloned into a `<div>`, which cannot live inside a `<table>`.",
|
|
384
|
+
fix: "Loop a static source (a JSON file, a frontmatter list, or a collection) for a markdown table — and if the source is already static, drop the `:state` the `where`/`limit`/`sort by` clause reads — or build reactive rows from containers (`::: trow` / `::: td`) instead of `|` cells."
|
|
385
|
+
},
|
|
365
386
|
|
|
366
387
|
// --- WD2xx: state and expressions ----------------------------------------
|
|
367
388
|
{
|
|
@@ -435,8 +456,9 @@ const ENTRIES = [
|
|
|
435
456
|
{
|
|
436
457
|
code: "WD211",
|
|
437
458
|
title: "Persistence token on `:computed`",
|
|
438
|
-
cause:
|
|
439
|
-
|
|
459
|
+
cause:
|
|
460
|
+
"The expression ends in a bare `persist`, `ephemeral`, or `from-url`, which is swallowed into it.",
|
|
461
|
+
fix: "Computed values are derived, not stored. Put the token on the state they derive from instead.",
|
|
440
462
|
example: COMPUTED_EXAMPLE
|
|
441
463
|
},
|
|
442
464
|
{
|
|
@@ -534,6 +556,37 @@ const ENTRIES = [
|
|
|
534
556
|
fix: "Declare it with `:state`, `:store`, or `:fetch` first.",
|
|
535
557
|
example: COMPUTED_EXAMPLE
|
|
536
558
|
},
|
|
559
|
+
{
|
|
560
|
+
code: "WD250",
|
|
561
|
+
title: "Reserved state declaration name",
|
|
562
|
+
cause:
|
|
563
|
+
"A `:state`/`:store`/`:computed`/`:fetch`/`:form`/`:slider`/`:theme` name is `__proto__`, `constructor`, or `prototype`, which the runtime's state object inherits rather than owns.",
|
|
564
|
+
fix: "Rename the key — any other name works.",
|
|
565
|
+
example: STATE_EXAMPLE
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
code: "WD251",
|
|
569
|
+
title: "State seeded from another state",
|
|
570
|
+
cause:
|
|
571
|
+
"A `:state`/`:store` value is a bare name that is already declared state, so it would be stored as that literal text and never track the value.",
|
|
572
|
+
fix: "Derive it with `:computed`, or quote the text to keep it literal.",
|
|
573
|
+
example: COMPUTED_EXAMPLE
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
code: "WD260",
|
|
577
|
+
title: "`from-url` on a keyword that cannot take it",
|
|
578
|
+
cause:
|
|
579
|
+
"`:store` and `:theme` are shared by every page and every tab, while a query parameter belongs to one page's address, so the two cannot mean the same thing.",
|
|
580
|
+
fix: "Declare the value with `:state … from-url`, or drop the token.",
|
|
581
|
+
example: URL_STATE_EXAMPLE
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
code: "WD261",
|
|
585
|
+
title: "Conflicting persistence modifiers",
|
|
586
|
+
cause: "A declaration carries `persist` and `ephemeral` at once, or repeats the same modifier.",
|
|
587
|
+
fix: "Pick one: `persist` keeps the value across reloads, `ephemeral` drops it.",
|
|
588
|
+
example: STATE_EXAMPLE
|
|
589
|
+
},
|
|
537
590
|
|
|
538
591
|
// --- WD3xx: actions ------------------------------------------------------
|
|
539
592
|
{
|
|
@@ -627,6 +680,14 @@ const ENTRIES = [
|
|
|
627
680
|
cause: "The action value is not a quoted string, number, boolean, null, or valid JSON.",
|
|
628
681
|
fix: "Quote strings and use valid JSON for arrays and objects."
|
|
629
682
|
},
|
|
683
|
+
{
|
|
684
|
+
code: "WD315",
|
|
685
|
+
title: "`:every`/`:effect` inside a reactive `@loop`",
|
|
686
|
+
cause:
|
|
687
|
+
"A reactive loop clones its body per row, so the timer or watcher would be registered once per row and a removed row's would keep firing.",
|
|
688
|
+
fix: "Declare it once outside the loop — at page level or inside the `:::` section — and act on the whole list.",
|
|
689
|
+
example: EVERY_EXAMPLE
|
|
690
|
+
},
|
|
630
691
|
|
|
631
692
|
// --- WD4xx: forms and fields ---------------------------------------------
|
|
632
693
|
{
|
|
@@ -819,6 +880,32 @@ const ENTRIES = [
|
|
|
819
880
|
example: RADIO_EXAMPLE
|
|
820
881
|
},
|
|
821
882
|
|
|
883
|
+
{
|
|
884
|
+
code: "WD450",
|
|
885
|
+
title: "Bound field with no state",
|
|
886
|
+
cause:
|
|
887
|
+
"A `:select`/`:radio`/`:checkbox` outside a `:form` names state that is not declared, so there is nothing for it to bind to.",
|
|
888
|
+
fix: "Declare the state first, or move the field inside a `:form`, where the name is a form field instead.",
|
|
889
|
+
example: SELECT_EXAMPLE
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
code: "WD451",
|
|
893
|
+
title: "Bound `:checkbox` with several options",
|
|
894
|
+
cause:
|
|
895
|
+
"A `:checkbox` bound to state carries one true/false, so a list of options has no meaning.",
|
|
896
|
+
fix: "Give it one `- Label` line, or use a `:radio` group for a set of choices.",
|
|
897
|
+
example: CHECKBOX_EXAMPLE
|
|
898
|
+
},
|
|
899
|
+
|
|
900
|
+
{
|
|
901
|
+
code: "WD452",
|
|
902
|
+
title: "File field in a GET form",
|
|
903
|
+
cause:
|
|
904
|
+
'A `:form` contains an `<input type=file>` and declares `method="get"`. A GET request has no body, so only the file\'s name would travel.',
|
|
905
|
+
fix: 'Drop `method="get"` — a `:form` posts by default, and a form with a file field is submitted as multipart.',
|
|
906
|
+
example: FORM_EXAMPLE
|
|
907
|
+
},
|
|
908
|
+
|
|
822
909
|
// --- WD5xx: data fetching and URL safety ---------------------------------
|
|
823
910
|
{
|
|
824
911
|
code: "WD501",
|
|
@@ -963,6 +1050,21 @@ const ENTRIES = [
|
|
|
963
1050
|
"An `@include` chain reaches a file that is already being compiled, so the compile would never terminate.",
|
|
964
1051
|
fix: "Break the loop: remove the `@include` that points back, or move the shared content into a third file both sides include."
|
|
965
1052
|
},
|
|
1053
|
+
{
|
|
1054
|
+
code: "WD650",
|
|
1055
|
+
title: "Attribute is not on the accessibility whitelist",
|
|
1056
|
+
cause:
|
|
1057
|
+
"A `::: ` container header or a `:button` carries an attribute outside the accessibility whitelist (`onclick=`, `style=`, `href=`, `class=`, `data-…=`).",
|
|
1058
|
+
fix: 'Keep only `role="…"`, `aria-…="…"`, and `title="…"`; style with `.class` tokens and act with `->` actions.',
|
|
1059
|
+
example: CONTAINER_A11Y_EXAMPLE
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
code: "WD651",
|
|
1063
|
+
title: "Accessibility attribute has no quoted value",
|
|
1064
|
+
cause: "An attribute in a container header or a `:button` is missing its double-quoted value.",
|
|
1065
|
+
fix: 'Write the value in double quotes, e.g. `role="region"`.',
|
|
1066
|
+
example: CONTAINER_A11Y_EXAMPLE
|
|
1067
|
+
},
|
|
966
1068
|
|
|
967
1069
|
// --- WD7xx: media and embeds ---------------------------------------------
|
|
968
1070
|
{
|
|
@@ -1063,6 +1165,12 @@ const ENTRIES = [
|
|
|
1063
1165
|
title: "Unknown `ai_crawlers` policy",
|
|
1064
1166
|
cause: "The home page's `ai_crawlers:` is neither `allow` nor `deny`.",
|
|
1065
1167
|
fix: "Write `ai_crawlers: allow` or `ai_crawlers: deny` (absent means allow)."
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
code: "WD950",
|
|
1171
|
+
title: "Invalid `rss_limit`",
|
|
1172
|
+
cause: "The home page's `rss_limit:` is not a positive whole number of items.",
|
|
1173
|
+
fix: "Write `rss_limit: 20` (absent means 20)."
|
|
1066
1174
|
}
|
|
1067
1175
|
];
|
|
1068
1176
|
|
package/src/feeds.js
CHANGED
|
@@ -190,10 +190,43 @@ export function rssDescription(meta, file, body) {
|
|
|
190
190
|
return "";
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The `<lastmod>` form the sitemap protocol accepts: W3C Datetime, of which
|
|
195
|
+
* `yyyy-mm-dd` is the shortest legal spelling. Anchored at the start so a full
|
|
196
|
+
* ISO timestamp (`2026-01-05T10:00:00Z`) matches on its date half.
|
|
197
|
+
*/
|
|
198
|
+
const W3C_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validate a frontmatter `date:` into a sitemap `<lastmod>` value, or "" when it
|
|
202
|
+
* is not one.
|
|
203
|
+
*
|
|
204
|
+
* This is the only feed value that used to be neither validated nor escaped, and
|
|
205
|
+
* both halves mattered: `date: 2026<bad&"q"` made the whole document
|
|
206
|
+
* unparseable, so NOTHING got indexed, and the far likelier `date: Jan 5, 2026`
|
|
207
|
+
* was silently truncated to `Jan 5, 202` by a blind 10-character slice. Returning
|
|
208
|
+
* "" (rather than guessing) lets the caller omit the element and say so — a
|
|
209
|
+
* sitemap entry with no `<lastmod>` is valid; one with a bogus date is not.
|
|
210
|
+
* @param {string} raw Trimmed frontmatter `date:` value.
|
|
211
|
+
* @returns {string} `yyyy-mm-dd`, or "" when `raw` is not a real calendar date.
|
|
212
|
+
*/
|
|
213
|
+
export function sitemapDate(raw) {
|
|
214
|
+
const day = String(raw ?? "")
|
|
215
|
+
.trim()
|
|
216
|
+
.slice(0, 10);
|
|
217
|
+
if (!W3C_DATE.test(day)) return "";
|
|
218
|
+
// The shape is right; is it a real day? `new Date("2026-02-30")` does NOT
|
|
219
|
+
// throw — V8 rolls it forward to March 2 — so the only honest check is a round
|
|
220
|
+
// trip: a genuine calendar date formats back to the string it came from.
|
|
221
|
+
const parsed = new Date(`${day}T00:00:00Z`);
|
|
222
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === day ? day : "";
|
|
223
|
+
}
|
|
224
|
+
|
|
193
225
|
/**
|
|
194
226
|
* Build `sitemap.xml` from the emitted (post-draft-filter) routes. One `<url>`
|
|
195
227
|
* per route — reactive pages included (they're indexable HTML) — with `<loc>` =
|
|
196
|
-
* `site_url` + path and `<lastmod>` from the supplied date.
|
|
228
|
+
* `site_url` + path and `<lastmod>` from the supplied date. An empty `lastmod`
|
|
229
|
+
* omits the element rather than emitting a blank one. The `/404/` route is
|
|
197
230
|
* excluded by the caller before this point. No `<priority>`/`<changefreq>` —
|
|
198
231
|
* Google ignores them and they invite bit-rot.
|
|
199
232
|
* @param {DatedRoute[]} entries Absolute loc + ISO lastmod, in route order.
|
|
@@ -201,11 +234,15 @@ export function rssDescription(meta, file, body) {
|
|
|
201
234
|
*/
|
|
202
235
|
export function buildSitemap(entries) {
|
|
203
236
|
const urls = entries
|
|
204
|
-
.map(
|
|
205
|
-
(entry)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
237
|
+
.map((entry) => {
|
|
238
|
+
const lines = [" <url>", ` <loc>${escapeXml(entry.loc)}</loc>`];
|
|
239
|
+
// Escaped even though `sitemapDate` already guarantees a safe value:
|
|
240
|
+
// this builder is exported and called directly, so the guarantee has to
|
|
241
|
+
// live where the string is written, not only in one caller.
|
|
242
|
+
if (entry.lastmod) lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
|
|
243
|
+
lines.push(" </url>");
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
})
|
|
209
246
|
.join("\n");
|
|
210
247
|
return (
|
|
211
248
|
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
@@ -214,6 +251,67 @@ export function buildSitemap(entries) {
|
|
|
214
251
|
);
|
|
215
252
|
}
|
|
216
253
|
|
|
254
|
+
/**
|
|
255
|
+
* The sitemap protocol's hard cap: 50,000 `<url>` entries per sitemap file. Over
|
|
256
|
+
* it, Google rejects the document outright — so a large site must split into
|
|
257
|
+
* numbered sitemaps behind a `<sitemapindex>`.
|
|
258
|
+
*/
|
|
259
|
+
export const SITEMAP_URL_LIMIT = 50000;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build a `<sitemapindex>` — the document that points at the numbered sitemap
|
|
263
|
+
* shards. No `<lastmod>` per shard: it would have to be the max over the shard's
|
|
264
|
+
* entries, which is a number nobody consumes and one more thing to keep true.
|
|
265
|
+
* @param {string[]} locs Absolute URLs of the shard files, in order.
|
|
266
|
+
* @returns {string} The full XML document (trailing newline).
|
|
267
|
+
*/
|
|
268
|
+
export function buildSitemapIndex(locs) {
|
|
269
|
+
const entries = locs
|
|
270
|
+
.map((loc) => ` <sitemap>\n <loc>${escapeXml(loc)}</loc>\n </sitemap>`)
|
|
271
|
+
.join("\n");
|
|
272
|
+
return (
|
|
273
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
274
|
+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
|
275
|
+
`${entries}\n</sitemapindex>\n`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Plan the sitemap file(s) a set of entries needs.
|
|
281
|
+
*
|
|
282
|
+
* At or under {@link SITEMAP_URL_LIMIT} that is one `sitemap.xml`, byte-identical
|
|
283
|
+
* to what every site got before. Over it, the entries shard into
|
|
284
|
+
* `sitemap-1.xml`, `sitemap-2.xml`, … and `sitemap.xml` becomes the
|
|
285
|
+
* `<sitemapindex>` pointing at them — so `robots.txt` keeps naming
|
|
286
|
+
* `/sitemap.xml` and every existing submission stays valid.
|
|
287
|
+
*
|
|
288
|
+
* Returned rather than written so the split is a pure function the tests can
|
|
289
|
+
* drive with 50,001 synthetic entries without building 50,001 pages.
|
|
290
|
+
* @param {DatedRoute[]} entries
|
|
291
|
+
* @param {string} siteUrl Absolute origin, for the index's shard URLs.
|
|
292
|
+
* @returns {{ file: string, xml: string }[]} Shards first, `sitemap.xml` last.
|
|
293
|
+
*/
|
|
294
|
+
export function sitemapDocuments(entries, siteUrl) {
|
|
295
|
+
if (entries.length <= SITEMAP_URL_LIMIT) {
|
|
296
|
+
return [{ file: "sitemap.xml", xml: buildSitemap(entries) }];
|
|
297
|
+
}
|
|
298
|
+
/** @type {{ file: string, xml: string }[]} */
|
|
299
|
+
const shards = [];
|
|
300
|
+
for (let i = 0; i < entries.length; i += SITEMAP_URL_LIMIT) {
|
|
301
|
+
shards.push({
|
|
302
|
+
file: `sitemap-${shards.length + 1}.xml`,
|
|
303
|
+
xml: buildSitemap(entries.slice(i, i + SITEMAP_URL_LIMIT))
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return [
|
|
307
|
+
...shards,
|
|
308
|
+
{
|
|
309
|
+
file: "sitemap.xml",
|
|
310
|
+
xml: buildSitemapIndex(shards.map((shard) => absoluteUrl(siteUrl, `/${shard.file}`)))
|
|
311
|
+
}
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
|
|
217
315
|
/**
|
|
218
316
|
* An RSS item — a dated post in feed order.
|
|
219
317
|
* @typedef {object} RssItem
|
|
@@ -392,5 +490,40 @@ export function aiCrawlerPolicy(meta, file) {
|
|
|
392
490
|
);
|
|
393
491
|
}
|
|
394
492
|
|
|
395
|
-
/**
|
|
493
|
+
/**
|
|
494
|
+
* The DEFAULT most-recent-first RSS post cap — a feed is a recent window, not an
|
|
495
|
+
* archive. Overridable per site with `rss_limit:` on the home page
|
|
496
|
+
* ({@link rssItemLimit}).
|
|
497
|
+
*/
|
|
396
498
|
export const RSS_ITEM_LIMIT = 20;
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Resolve the site's RSS item cap from the HOME page's `rss_limit:` frontmatter.
|
|
502
|
+
*
|
|
503
|
+
* Darkmown has no config file by design: site-wide settings live in the home
|
|
504
|
+
* page's frontmatter next to `site_url` / `ai_crawlers`, and this is one of them.
|
|
505
|
+
* Absent means {@link RSS_ITEM_LIMIT}. A value that is not a positive integer
|
|
506
|
+
* THROWS rather than silently falling back, for the same reason `ai_crawlers`
|
|
507
|
+
* does: `rss_limit: 0` and `rss_limit: all` are both attempts to state a policy,
|
|
508
|
+
* and quietly publishing 20 items instead is the wrong failure direction.
|
|
509
|
+
* @param {import("./compiler.js").Meta} meta Home page frontmatter.
|
|
510
|
+
* @param {string} [file] Home page source path, for the error message.
|
|
511
|
+
* @returns {number} A positive integer.
|
|
512
|
+
*/
|
|
513
|
+
export function rssItemLimit(meta, file) {
|
|
514
|
+
const raw = meta.rss_limit;
|
|
515
|
+
if (raw === undefined) return RSS_ITEM_LIMIT;
|
|
516
|
+
const text = String(raw).trim();
|
|
517
|
+
// `Number("")` is 0 and `Number("12px")` is NaN — both fail the guard below,
|
|
518
|
+
// so an empty or non-numeric value lands on the same actionable error.
|
|
519
|
+
// Digits only: `Number()` would also accept "1e3", "0x10" and "7.0", none
|
|
520
|
+
// of which is the "positive whole number" the error promises.
|
|
521
|
+
const value = /^\d+$/.test(text) ? Number(text) : Number.NaN;
|
|
522
|
+
if (Number.isInteger(value) && value > 0) return value;
|
|
523
|
+
const hint = "rss_limit: 20";
|
|
524
|
+
throw wdError(
|
|
525
|
+
`Invalid rss_limit "${text}"${file ? ` in ${file}` : ""}. ` +
|
|
526
|
+
`Use a positive whole number of items: ${hint}.`,
|
|
527
|
+
{ code: "WD950", file, hint }
|
|
528
|
+
);
|
|
529
|
+
}
|