@zvndev/darkmown 2.9.0 → 2.10.1

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 (54) hide show
  1. package/AGENTS.md +23 -12
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +105 -6
  4. package/SECURITY.md +1 -0
  5. package/docs/cli.md +1 -1
  6. package/docs/spec-alignment.md +16 -2
  7. package/grammar/wd-directives.gbnf +1 -1
  8. package/package.json +8 -4
  9. package/site/pages/benchmarks/index.skin +67 -0
  10. package/site/pages/benchmarks/index.wd +126 -0
  11. package/site/pages/docs/index.wd +47 -2
  12. package/site/pages/index.wd +1 -0
  13. package/site/pages/playground/index.js +58 -54
  14. package/src/api-runner.js +80 -8
  15. package/src/builder.js +70 -15
  16. package/src/catalog.js +13 -4
  17. package/src/cli.js +18 -6
  18. package/src/compiler/body.js +30 -1
  19. package/src/compiler/context.js +13 -1
  20. package/src/compiler/includes.js +44 -1
  21. package/src/compiler/media.js +25 -0
  22. package/src/compiler/page.js +6 -0
  23. package/src/compiler/state.js +75 -0
  24. package/src/compiler/structure.js +272 -4
  25. package/src/errors.js +74 -3
  26. package/src/font-subset-worker.js +131 -0
  27. package/src/font-subset.js +475 -0
  28. package/src/grammar.js +3 -2
  29. package/src/headers.js +28 -1
  30. package/src/image-variant-resize.js +4 -1
  31. package/src/image-variants.js +9 -4
  32. package/src/router.js +16 -0
  33. package/src/skin.js +111 -45
  34. package/src/statics.js +22 -14
  35. package/src/templates/dashboard/README.md +3 -1
  36. package/src/templates/landing/site/pages/index.skin +7 -0
  37. package/src/templates/landing/site/pages/index.wd +10 -3
  38. package/src/templates/store/README.md +5 -2
  39. package/src/templates/store/site/_/card.wd +19 -0
  40. package/src/templates/store/site/_/products.json +6 -6
  41. package/src/templates/store/site/pages/index.skin +17 -2
  42. package/src/templates/store/site/pages/index.wd +3 -12
  43. package/types/api-runner.d.ts +20 -4
  44. package/types/builder.d.ts +25 -1
  45. package/types/catalog.d.ts +3 -2
  46. package/types/compiler/context.d.ts +28 -0
  47. package/types/compiler/includes.d.ts +23 -0
  48. package/types/compiler/structure.d.ts +53 -1
  49. package/types/errors.d.ts +6 -4
  50. package/types/font-subset.d.ts +144 -0
  51. package/types/headers.d.ts +14 -0
  52. package/types/image-variants.d.ts +7 -3
  53. package/types/router.d.ts +5 -0
  54. package/types/skin.d.ts +9 -4
@@ -175,6 +175,7 @@ export function handleEmbed(line, ctx, index) {
175
175
  const sizing = embedSizing(attrs, ctx, index);
176
176
  const allow = embedAllow(attrs, ctx, index);
177
177
  const { url, label } = resolveEmbed(raw, ctx);
178
+ recordEmbedHost(ctx, url);
178
179
  const title = attrs.get("title") || "";
179
180
  const iframe =
180
181
  `<iframe src="${escapeHtml(url)}" title="${escapeHtml(title || label)}" loading="lazy" ` +
@@ -237,6 +238,30 @@ function embedAllow(attrs, ctx, index) {
237
238
  return tokens.join("; ");
238
239
  }
239
240
 
241
+ /**
242
+ * Record the frame source the emitted iframe actually loads, so the builder can
243
+ * union it into this route's CSP `frame-src`. An absolute http(s) URL contributes
244
+ * its origin; a relative URL frames the page's own origin, so it contributes
245
+ * `'self'`. A `{ state }`-driven URL is unknowable at compile time and records
246
+ * nothing (widening that policy is the app's deploy-config decision, exactly
247
+ * like a remote `:fetch` target widening `connect-src`).
248
+ * @param {Ctx} ctx
249
+ * @param {string} url The FINAL iframe src (post YouTube/Vimeo rewrite).
250
+ * @returns {void}
251
+ */
252
+ function recordEmbedHost(ctx, url) {
253
+ if (url.startsWith("{")) return;
254
+ if (!/^https?:\/\//i.test(url)) {
255
+ ctx.comp.assets.embedHosts.add("'self'");
256
+ return;
257
+ }
258
+ try {
259
+ ctx.comp.assets.embedHosts.add(new URL(url).origin);
260
+ } catch {
261
+ /* a malformed-but-scheme-valid URL paints as-is; nothing to authorize */
262
+ }
263
+ }
264
+
240
265
  /**
241
266
  * Map a watch/share URL to its embeddable form. YouTube → no-cookie embed, Vimeo →
242
267
  * player; anything else is validated as a generic http(s) iframe source.
@@ -14,6 +14,7 @@ import { parseFrontmatter, resolveSharedSkins, warnLikelyFrontmatter } from "./f
14
14
  import { imageSize } from "./image-size.js";
15
15
  import {
16
16
  collectColocatedAssets,
17
+ isAllowedInclude,
17
18
  scanMarkdownHints,
18
19
  scopedSkinFor,
19
20
  scopeInfoForSkin,
@@ -408,6 +409,10 @@ function srcOf(tag) {
408
409
  * Read an image's intrinsic dimensions from disk, or null when the src is
409
410
  * remote/relative/unreadable. Resolution mirrors the asset emit: `/__wd/media/x`
410
411
  * comes from the shelf (`site/_`), other absolute paths from `site/pages`.
412
+ * Containment mirrors the `@include` sandbox ({@link isAllowedInclude}): a src
413
+ * that resolves OUTSIDE those roots — `![x](/../../anywhere.png)`, or a symlink
414
+ * pointing out — is treated exactly like a missing image (no dimensions), never
415
+ * read.
411
416
  * @param {string} src
412
417
  * @param {Paths} paths
413
418
  * @param {import("./reader.js").Reader} reader
@@ -423,6 +428,7 @@ function measureImage(src, paths, reader) {
423
428
  } else {
424
429
  return null; // page-relative: the source directory is lost after assembly
425
430
  }
431
+ if (!isAllowedInclude(filePath, paths, reader)) return null;
426
432
  try {
427
433
  // `imageSize` itself never throws (an unsupported or malformed image is just
428
434
  // null); the try/catch is for the read, which does throw on a missing file.
@@ -174,6 +174,79 @@ function rejectStateSeed(value, raw, directive, ctx, index) {
174
174
  );
175
175
  }
176
176
 
177
+ // ---------------------------------------------------------------------------
178
+ // A SECRET-SHAPED SEED IS A HINT, NEVER AN ERROR.
179
+ //
180
+ // Every `:state`/`:store` seed is stored verbatim and inlined into the public
181
+ // HTML as a JSON script tag — that is the design, and it means a seed that is
182
+ // really an API key or token is published to every reader of the page. The
183
+ // documented pattern is to seed empty and fill from an api/ endpoint at
184
+ // runtime, but nothing stopped an author from pasting the key itself. This
185
+ // check is a heuristic over string seeds (and string values nested in
186
+ // array/object seeds), pushed onto the same `comp.warnings` channel as the
187
+ // other build hints: it can never fail a build, because a false positive must
188
+ // never break one, and it fires at most once per declaration.
189
+ //
190
+ // The shapes are deliberately tight — false positives are the enemy:
191
+ //
192
+ // - Known key prefixes (`sk-`, `ghp_`, `xoxb-`, `AKIA`, …) followed by a real
193
+ // token body, and the `-----BEGIN` private-key header.
194
+ // - A JWT: three base64url segments joined by dots, opening with `eyJ` (the
195
+ // base64 of `{"` — requiring it keeps dotted prose quiet).
196
+ // - A long unbroken token: 32+ chars from the base64/base64url alphabet with
197
+ // no spaces, requiring BOTH cases and a digit. Requiring the case mix keeps
198
+ // kebab-case slugs, hex hashes, and UUIDs (all lowercase) silent, and the
199
+ // charset excludes `:`, `.`, and space, so URLs, file paths, and prose
200
+ // never match.
201
+ // ---------------------------------------------------------------------------
202
+ const SECRET_PREFIX = /^(?:sk-|sk_live_|sk_test_|ghp_|gho_|xoxb-|xoxp-|AKIA)/;
203
+ const SECRET_TOKEN_BODY = /^[A-Za-z0-9_-]{8,}$/;
204
+ const JWT_SHAPE = /^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/;
205
+ const LONG_TOKEN_SHAPE = /^[A-Za-z0-9/_+=-]{32,}$/;
206
+
207
+ /**
208
+ * Test one string for the secret shapes above.
209
+ * @param {string} text
210
+ * @returns {boolean}
211
+ */
212
+ function looksLikeSecret(text) {
213
+ if (text.startsWith("-----BEGIN")) return true;
214
+ const prefixed = text.match(SECRET_PREFIX);
215
+ if (prefixed && SECRET_TOKEN_BODY.test(text.slice(prefixed[0].length))) return true;
216
+ if (JWT_SHAPE.test(text)) return true;
217
+ return LONG_TOKEN_SHAPE.test(text) && /[a-z]/.test(text) && /[A-Z]/.test(text) && /\d/.test(text);
218
+ }
219
+
220
+ /**
221
+ * Walk a parsed seed for a secret-shaped string: the seed itself, or any
222
+ * string value nested in its arrays/objects.
223
+ * @param {unknown} value
224
+ * @returns {boolean}
225
+ */
226
+ function seedContainsSecret(value) {
227
+ if (typeof value === "string") return looksLikeSecret(value);
228
+ if (Array.isArray(value)) return value.some(seedContainsSecret);
229
+ if (value && typeof value === "object") return Object.values(value).some(seedContainsSecret);
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * Push the secret-seed hint when a `:state`/`:store` seed carries one.
235
+ * @param {unknown} value The parsed seed.
236
+ * @param {string} name The declared name, for the message.
237
+ * @param {string} directive `:state` or `:store`, for the message.
238
+ * @param {Ctx} ctx
239
+ * @param {number} index 0-based line index for `file:line`.
240
+ * @returns {void}
241
+ */
242
+ function warnSecretSeed(value, name, directive, ctx, index) {
243
+ if (!seedContainsSecret(value)) return;
244
+ ctx.comp.warnings.push(
245
+ `${at(ctx, index)}: ${directive} "${name}" seed looks like a secret — it will be inlined into public HTML. ` +
246
+ `Seed empty and fill it from an api/ endpoint at runtime.`
247
+ );
248
+ }
249
+
177
250
  /**
178
251
  * @param {string} line
179
252
  * @param {Ctx} ctx
@@ -195,6 +268,7 @@ export function handleState(line, ctx, index) {
195
268
  const flags = parseModifiers(match[3], ":state", ctx, index);
196
269
  const value = parseStateValue(match[2], at(ctx, index));
197
270
  rejectStateSeed(value, match[2], ":state", ctx, index);
271
+ warnSecretSeed(value, match[1], ":state", ctx, index);
198
272
  const key = declareState(match[1], value, ctx);
199
273
  recordSymbol(ctx, index, { kind: "state", name: key, detail: `${key} = ${match[2].trim()}` });
200
274
  const persistAttr = flags.persist ? ` data-wd-persist="${escapeHtml(key)}"` : "";
@@ -263,6 +337,7 @@ export function handleStore(line, ctx, index) {
263
337
  const flags = parseModifiers(match[3], ":store", ctx, index);
264
338
  const value = parseStateValue(match[2], at(ctx, index));
265
339
  rejectStateSeed(value, match[2], ":store", ctx, index);
340
+ warnSecretSeed(value, match[1], ":store", ctx, index);
266
341
  const name = declareStore(match[1], value, ctx);
267
342
  recordSymbol(ctx, index, { kind: "store", name, detail: `${name} = ${match[2].trim()}` });
268
343
  const ephemeral = flags.ephemeral ? " data-wd-store-ephemeral" : "";
@@ -11,8 +11,25 @@
11
11
  // while keeping the message text and `Use:` hints intact.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- import { at, createScope, LOOP_META, lineOf, nestedCtx, recordSymbol, wdError } from "./context.js";
15
- import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
14
+ import path from "node:path";
15
+ import {
16
+ at,
17
+ createScope,
18
+ LOOP_META,
19
+ lineOf,
20
+ nestedCtx,
21
+ normalizeNewlines,
22
+ recordSymbol,
23
+ wdError
24
+ } from "./context.js";
25
+ import { parseFrontmatter } from "./frontmatter.js";
26
+ import {
27
+ CHILDREN_SLOT_LINE,
28
+ INCLUDE_CHILDREN,
29
+ resolveInclude,
30
+ scopedSkinFor,
31
+ stampScope
32
+ } from "./includes.js";
16
33
  import {
17
34
  escapeHtml,
18
35
  getPath,
@@ -146,12 +163,31 @@ export function a11yAttrHtml(attrs) {
146
163
  }
147
164
 
148
165
  /**
166
+ * Compile an `@include` line — inline form (`@include /nav.wd`) or block form
167
+ * (`@include /card.wd … @endinclude`, with `childrenLines` holding the lines
168
+ * between opener and closer, captured by {@link scanIncludeBlock}).
169
+ *
170
+ * Block form is compile-time macro expansion and nothing more: the children
171
+ * lines compile in the CALLER's context (its scope, its `:state`, its loop
172
+ * item when the call sits in a `@loop` body) BEFORE the target compiles, and
173
+ * the resulting HTML rides into the include's scope under
174
+ * {@link INCLUDE_CHILDREN}, where the body parser splices it at each
175
+ * `{ children }` slot line. No runtime marker, no reactivity of its own.
176
+ * Children compile ONCE even when the include places the slot twice, so state
177
+ * a child line declares is declared once in the caller.
178
+ *
179
+ * The include file's own frontmatter may declare `defaults: [name="value" …]`
180
+ * fallback values for `with` arguments (see {@link parseIncludeDefaults}); a
181
+ * caller-provided argument always wins over a default, and an argument with
182
+ * neither keeps the literal-braces behavior.
149
183
  * @param {string} line
150
184
  * @param {Ctx} ctx
151
185
  * @param {number} index 0-based line index for `file:line` errors.
186
+ * @param {string[]} [childrenLines] Lines between `@include` and `@endinclude`
187
+ * for the block form; undefined for the inline form.
152
188
  * @returns {string}
153
189
  */
154
- export function handleInclude(line, ctx, index) {
190
+ export function handleInclude(line, ctx, index, childrenLines) {
155
191
  const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
156
192
  if (!match)
157
193
  throw wdError(`Malformed @include in ${at(ctx, index)}: ${line}. ${INCLUDE_USE}`, {
@@ -171,7 +207,39 @@ export function handleInclude(line, ctx, index) {
171
207
  );
172
208
  const args = parseIncludeArgs(match[2] || "", ctx);
173
209
  recordSymbol(ctx, index, { kind: "include", name: match[1], detail: `@include ${match[1]}` });
174
- const childScope = createScope(ctx.scope, args);
210
+ const component = includeComponentInfo(target, ctx);
211
+ let childrenHtml = "";
212
+ if (childrenLines) {
213
+ // One nesting level is the contract: a block include's children (or an
214
+ // include file's own body) may hold one block include, no more. Children
215
+ // depth travels on the ctx (children compile in the caller's context);
216
+ // include-FILE depth is the include stack's length (page = 1, its include
217
+ // = 2, an include's include = 3 — too deep for a block).
218
+ if ((ctx.includeBlockDepth ?? 0) >= 2 || ctx.stack.length > 2) {
219
+ const hint = `${INCLUDE_BLOCK_USE}`;
220
+ throw wdError(
221
+ `Block-form @include nested too deep in ${at(ctx, index)}: ${line}. A children block or an include body may hold ONE level of @include … @endinclude. Use: ${hint}`,
222
+ { code: "WD616", file: ctx.file, line: lineOf(ctx, index), hint, example: INCLUDE_EXAMPLE }
223
+ );
224
+ }
225
+ childrenHtml = ctx.compileBody(childrenLines, {
226
+ ...nestedCtx(ctx, index + 1),
227
+ includeBlockDepth: (ctx.includeBlockDepth ?? 0) + 1
228
+ });
229
+ if (childrenHtml.trim() && !component.usesChildren) {
230
+ ctx.comp.warnings.push(
231
+ `${at(ctx, index)}: the @include block for "${match[1]}" passes children, but ${target} never uses a { children } line, so the content is dropped. Add { children } where the slot content should appear, or drop the block.`
232
+ );
233
+ }
234
+ }
235
+ // Caller args sit over defaults (the caller always wins); the children slot
236
+ // sits alongside them. An inline call still sets the slot (""), so a nested
237
+ // include never sees an OUTER include's children through the scope chain.
238
+ const childScope = createScope(ctx.scope, {
239
+ ...component.defaults,
240
+ ...args,
241
+ [INCLUDE_CHILDREN]: childrenHtml
242
+ });
175
243
  // Thread the reactive-nesting depth (and the enclosing loop opener, so a depth
176
244
  // error names the right opener) into the include, exactly as `loopItem` is —
177
245
  // otherwise a reactive @loop inside the include would start counting from zero
@@ -196,6 +264,206 @@ export function handleInclude(line, ctx, index) {
196
264
  return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
197
265
  }
198
266
 
267
+ const INCLUDE_BLOCK_USE = `@include /partial.wd [with key="value"] … @endinclude — e.g. @include /card.wd with title="Pricing"`;
268
+
269
+ /**
270
+ * Scan ahead from an `@include` opener for the `@endinclude` that pairs with
271
+ * it. Pairing is nearest-opener-first: a nested `@include` line pushes, an
272
+ * `@endinclude` line pops the most recent unclosed opener, and the first
273
+ * `@endinclude` found with nothing on the stack belongs to THIS opener — so
274
+ * nested blocks pair correctly, and an inline `@include` earlier in the file
275
+ * than a block (the common nav-include-then-card-block page) is unaffected.
276
+ * Fenced code is skipped, exactly as in the other block scanners.
277
+ *
278
+ * Returns null when no closer pairs — the include is the INLINE form, which
279
+ * stays legal (backward compatible): an opener that never pairs is an inline
280
+ * include followed by ordinary prose, and the two are indistinguishable, so
281
+ * there is deliberately no "unclosed block" error.
282
+ * @param {string[]} lines The current body slice.
283
+ * @param {number} start 0-based index of the `@include` opener line.
284
+ * @returns {{ body: string[], end: number } | null} The children lines and the
285
+ * closer's 0-based index, or null for the inline form.
286
+ */
287
+ export function scanIncludeBlock(lines, start) {
288
+ /** @type {number[]} */
289
+ const stack = [];
290
+ /** @type {string | null} */
291
+ let fence = null;
292
+ for (let i = start + 1; i < lines.length; i++) {
293
+ const line = lines[i];
294
+ const fenceMatch = line.match(/^(```+|~~~+)/);
295
+ if (fence) {
296
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
297
+ fence = null;
298
+ continue;
299
+ }
300
+ if (fenceMatch) {
301
+ fence = fenceMatch[1];
302
+ continue;
303
+ }
304
+ if (/^@include\s/.test(line)) {
305
+ stack.push(i);
306
+ continue;
307
+ }
308
+ if (line.trim() === "@endinclude") {
309
+ if (stack.length === 0) return { body: lines.slice(start + 1, i), end: i };
310
+ stack.pop();
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+
316
+ /**
317
+ * `@endinclude` reached the body dispatcher, meaning no `@include` opener
318
+ * claimed it — the one-closer-too-many case (a typo'd closer, or a nested
319
+ * block attempted inside a children block, where the inner opener already
320
+ * took the closer the author meant for the outer one). Always throws.
321
+ * @param {string} line The offending line, for the message.
322
+ * @param {Ctx} ctx
323
+ * @param {number} index 0-based line index for `file:line`.
324
+ * @returns {never}
325
+ */
326
+ export function strayEndinclude(line, ctx, index) {
327
+ const hint = INCLUDE_BLOCK_USE;
328
+ throw wdError(
329
+ `Stray "${line.trim()}" with no unpaired @include before it in ${at(ctx, index)}. @endinclude pairs with the nearest preceding unpaired @include. Use: ${hint}`,
330
+ { code: "WD614", file: ctx.file, line: lineOf(ctx, index), hint, example: INCLUDE_EXAMPLE }
331
+ );
332
+ }
333
+
334
+ /**
335
+ * The component-facing facts about an include target's source: its declared
336
+ * argument `defaults` (frontmatter) and whether its body ever places the
337
+ * `{ children }` slot. Reads the target once, ahead of the compileFile read.
338
+ * @param {string} target Absolute include path.
339
+ * @param {Ctx} ctx
340
+ * @returns {{ defaults: Record<string, string>, usesChildren: boolean }}
341
+ */
342
+ function includeComponentInfo(target, ctx) {
343
+ const raw = normalizeNewlines(ctx.comp.reader.readText(target));
344
+ const { meta, body } = parseFrontmatter(raw, target);
345
+ return {
346
+ defaults: parseIncludeDefaults(meta, raw, target),
347
+ // A `.md` include never runs the directive/interpolation layer, so a
348
+ // `{ children }` line in one is inert text, not a slot: the drop-hint must
349
+ // treat the file as slotless.
350
+ usesChildren: path.extname(target) !== ".md" && usesChildrenSlot(body)
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Parse an include file's `defaults:` frontmatter: an inline array of
356
+ * `name="value"` entries, e.g. `defaults: [title="Untitled", cta="Learn more"]`.
357
+ * Values are STRINGS (frontmatter is string-typed) and apply only inside that
358
+ * include; a caller-provided `with` argument always wins.
359
+ *
360
+ * The raw `defaults:` line is split quote-aware (so a value may contain a
361
+ * comma) rather than trusting the generic frontmatter array items, whose
362
+ * splitter only honors item-leading quotes. Reached only when
363
+ * `parseFrontmatter` already produced an array for the key, so the source
364
+ * line and its `[…]` shape are guaranteed present.
365
+ * @param {import("./context.js").Meta} meta The include file's frontmatter.
366
+ * @param {string} raw The include file's full (newline-normalized) source.
367
+ * @param {string} target Absolute include path, for error messages.
368
+ * @returns {Record<string, string>}
369
+ */
370
+ function parseIncludeDefaults(meta, raw, target) {
371
+ if (meta.defaults === undefined) return {};
372
+ const use = 'an inline array of name="value" entries — e.g. defaults: [title="Untitled"]';
373
+ if (!Array.isArray(meta.defaults)) {
374
+ throw wdError(`Invalid defaults frontmatter in ${target}: the value must be ${use}.`, {
375
+ code: "WD613",
376
+ file: target,
377
+ hint: use,
378
+ example: 'defaults: [title="Untitled"]'
379
+ });
380
+ }
381
+ const lines = raw.split("\n");
382
+ const lineIndex = lines.findIndex((l) => /^defaults\s*:/.test(l));
383
+ const valueText = lines[lineIndex].replace(/^defaults\s*:\s*/, "").trim();
384
+ const inner = valueText.slice(1, -1); // strip the […] the array shape guarantees
385
+ /** @type {Record<string, string>} */
386
+ const defaults = {};
387
+ for (const entry of splitDefaultEntries(inner)) {
388
+ const match =
389
+ entry.match(/^([A-Za-z0-9_-]+)="([^"]*)"$/) ?? entry.match(/^([A-Za-z0-9_-]+)='([^']*)'$/);
390
+ if (!match) {
391
+ throw wdError(
392
+ `Invalid defaults frontmatter in ${target}:${lineIndex + 1}: entry "${entry}" must look like name="value". Use: ${use}.`,
393
+ {
394
+ code: "WD613",
395
+ file: target,
396
+ line: lineIndex + 1,
397
+ hint: use,
398
+ example: 'defaults: [title="Untitled"]'
399
+ }
400
+ );
401
+ }
402
+ defaults[match[1]] = match[2];
403
+ }
404
+ return defaults;
405
+ }
406
+
407
+ /**
408
+ * Split the inside of a `defaults: […]` array on top-level commas, honoring
409
+ * quotes ANYWHERE in an entry (the value follows `name=`, so the quote is not
410
+ * entry-leading and the generic frontmatter splitter would break on a comma
411
+ * inside it). An unterminated quote leaves its entry malformed, which the
412
+ * caller's entry check rejects.
413
+ * @param {string} inner
414
+ * @returns {string[]} Trimmed, non-empty entries.
415
+ */
416
+ function splitDefaultEntries(inner) {
417
+ /** @type {string[]} */
418
+ const entries = [];
419
+ let buf = "";
420
+ /** @type {string | null} */
421
+ let quote = null;
422
+ for (const ch of inner) {
423
+ if (quote) {
424
+ buf += ch;
425
+ if (ch === quote) quote = null;
426
+ } else if (ch === '"' || ch === "'") {
427
+ quote = ch;
428
+ buf += ch;
429
+ } else if (ch === ",") {
430
+ entries.push(buf);
431
+ buf = "";
432
+ } else {
433
+ buf += ch;
434
+ }
435
+ }
436
+ entries.push(buf);
437
+ return entries.map((entry) => entry.trim()).filter(Boolean);
438
+ }
439
+
440
+ /**
441
+ * Whether an include body ever places the `{ children }` slot: a standalone
442
+ * slot-marker line outside fenced code. Fence-aware so a documentation fence
443
+ * demonstrating `{ children }` does not count (its content is literal text —
444
+ * the slot really is unused there, and the drop-hint should still fire).
445
+ * @param {string} body The include file's post-frontmatter body.
446
+ * @returns {boolean}
447
+ */
448
+ function usesChildrenSlot(body) {
449
+ /** @type {string | null} */
450
+ let fence = null;
451
+ for (const line of body.split("\n")) {
452
+ const fenceMatch = line.match(/^(```+|~~~+)/);
453
+ if (fence) {
454
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
455
+ fence = null;
456
+ continue;
457
+ }
458
+ if (fenceMatch) {
459
+ fence = fenceMatch[1];
460
+ continue;
461
+ }
462
+ if (CHILDREN_SLOT_LINE.test(line)) return true;
463
+ }
464
+ return false;
465
+ }
466
+
199
467
  /**
200
468
  * @param {string} raw
201
469
  * @param {Ctx} ctx
package/src/errors.js CHANGED
@@ -76,6 +76,7 @@ import {
76
76
  IF_EXAMPLE,
77
77
  INCLUDE_EXAMPLE
78
78
  } from "./compiler/structure.js";
79
+ import { FONT_FACE_EXAMPLE, KEYFRAMES_EXAMPLE } from "./skin.js";
79
80
 
80
81
  /**
81
82
  * One subsystem block of the numbering scheme.
@@ -94,8 +95,9 @@ import {
94
95
  * @property {string} title Short name for the mistake.
95
96
  * @property {string} cause What the author wrote that triggers it.
96
97
  * @property {string} fix The corrective action, in one line.
97
- * @property {string} [example] A concrete, compilable line, sourced from the
98
- * compiler's own `*_EXAMPLE` constants.
98
+ * @property {string} [example] A concrete, compilable line (or, for block
99
+ * constructs like the `.skin` at-rules, a minimal compilable block), sourced
100
+ * from the compiler's own `*_EXAMPLE` constants.
99
101
  */
100
102
 
101
103
  /** The subsystem blocks, in code order. */
@@ -1057,6 +1059,30 @@ const ENTRIES = [
1057
1059
  "An `@include` chain reaches a file that is already being compiled, so the compile would never terminate.",
1058
1060
  fix: "Break the loop: remove the `@include` that points back, or move the shared content into a third file both sides include."
1059
1061
  },
1062
+ {
1063
+ code: "WD613",
1064
+ title: "Invalid include `defaults`",
1065
+ cause:
1066
+ 'An include file\'s `defaults:` frontmatter is not an inline array of `name="value"` entries.',
1067
+ fix: 'Write an inline array of quoted entries, e.g. `defaults: [title="Untitled"]`. Values are strings; a caller-provided `with` argument always wins over a default.',
1068
+ example: INCLUDE_EXAMPLE
1069
+ },
1070
+ {
1071
+ code: "WD614",
1072
+ title: "Stray `@endinclude`",
1073
+ cause:
1074
+ "An `@endinclude` has no unpaired `@include` before it — the closer pairs with the nearest preceding unpaired opener, and every opener in range already paired.",
1075
+ fix: "Remove the extra closer, or open a block above it: `@include /partial.wd`, the children lines, then `@endinclude`.",
1076
+ example: INCLUDE_EXAMPLE
1077
+ },
1078
+ {
1079
+ code: "WD616",
1080
+ title: "Include block nested too deep",
1081
+ cause:
1082
+ "A block-form `@include … @endinclude` sits inside another include block's children or inside a nested include's own body, past the one supported level.",
1083
+ fix: "Keep one level: move the inner block into the include's own file, or use the inline form `@include /partial.wd`.",
1084
+ example: INCLUDE_EXAMPLE
1085
+ },
1060
1086
  {
1061
1087
  code: "WD650",
1062
1088
  title: "Attribute is not on the accessibility whitelist",
@@ -1143,6 +1169,36 @@ const ENTRIES = [
1143
1169
  cause: "A top-level `page`/`body`/`html`/`*`/`::selection` selector escapes the scope.",
1144
1170
  fix: "Move page-level styles to a global (non-scoped) skin."
1145
1171
  },
1172
+ {
1173
+ code: "WD804",
1174
+ title: "Malformed `@font-face` block",
1175
+ cause:
1176
+ "`@font-face` was given a name, holds a nested block, or has no descriptors — it takes plain `prop value` declaration lines only.",
1177
+ fix: "Write a bare `@font-face` line and indent each descriptor beneath it.",
1178
+ example: FONT_FACE_EXAMPLE
1179
+ },
1180
+ {
1181
+ code: "WD805",
1182
+ title: "Invalid `@keyframes` name",
1183
+ cause: "The name after `@keyframes` is missing or is not a plain CSS identifier.",
1184
+ fix: "Name the animation with a plain identifier, e.g. `@keyframes fade-in`.",
1185
+ example: KEYFRAMES_EXAMPLE
1186
+ },
1187
+ {
1188
+ code: "WD806",
1189
+ title: "Invalid keyframe selector",
1190
+ cause: "A child of `@keyframes` is not `from`, `to`, or a percentage.",
1191
+ fix: "Use `from`, `to`, a percentage (`50%`), or a comma list of them (`50%, 75%`).",
1192
+ example: KEYFRAMES_EXAMPLE
1193
+ },
1194
+ {
1195
+ code: "WD807",
1196
+ title: "Malformed `@keyframes` body",
1197
+ cause:
1198
+ "A keyframe's declarations nest deeper, a keyframe selector has no declarations, or the block has no keyframe selectors at all.",
1199
+ fix: "Give each keyframe selector a flat list of declarations indented one level beneath it.",
1200
+ example: KEYFRAMES_EXAMPLE
1201
+ },
1146
1202
 
1147
1203
  // --- WD9xx: project, routing, CLI ----------------------------------------
1148
1204
  {
@@ -1187,6 +1243,13 @@ const ENTRIES = [
1187
1243
  cause: "The home page's `ai_crawlers:` is neither `allow` nor `deny`.",
1188
1244
  fix: "Write `ai_crawlers: allow` or `ai_crawlers: deny` (absent means allow)."
1189
1245
  },
1246
+ {
1247
+ code: "WD908",
1248
+ title: "Unsafe route name",
1249
+ cause:
1250
+ "A page filename produced a route containing whitespace or control characters, which would corrupt generated config files (dist/_headers, llms.txt).",
1251
+ fix: "Rename the file using only letters, digits, dashes, and underscores."
1252
+ },
1190
1253
  {
1191
1254
  code: "WD950",
1192
1255
  title: "Invalid `rss_limit`",
@@ -1272,7 +1335,15 @@ export function errorsMarkdown() {
1272
1335
  out.push("| Code | Error | Cause | Fix | Example |");
1273
1336
  out.push("| --- | --- | --- | --- | --- |");
1274
1337
  for (const row of rows) {
1275
- const example = row.example ? `\`${escapeCell(row.example)}\`` : "";
1338
+ // A multi-line example (the `.skin` at-rule blocks are block constructs)
1339
+ // becomes one code span per line joined by <br>, keeping the table row
1340
+ // on a single markdown line.
1341
+ const example = row.example
1342
+ ? row.example
1343
+ .split("\n")
1344
+ .map((line) => `\`${escapeCell(line)}\``)
1345
+ .join("<br>")
1346
+ : "";
1276
1347
  out.push(
1277
1348
  `| \`${row.code}\` | ${escapeCell(row.title)} | ${escapeCell(row.cause)} | ${escapeCell(row.fix)} | ${example} |`
1278
1349
  );