@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.
- package/AGENTS.md +23 -12
- package/CHANGELOG.md +29 -0
- package/README.md +105 -6
- package/SECURITY.md +1 -0
- package/docs/cli.md +1 -1
- package/docs/spec-alignment.md +16 -2
- package/grammar/wd-directives.gbnf +1 -1
- package/package.json +8 -4
- package/site/pages/benchmarks/index.skin +67 -0
- package/site/pages/benchmarks/index.wd +126 -0
- package/site/pages/docs/index.wd +47 -2
- package/site/pages/index.wd +1 -0
- package/site/pages/playground/index.js +58 -54
- package/src/api-runner.js +80 -8
- package/src/builder.js +70 -15
- package/src/catalog.js +13 -4
- package/src/cli.js +18 -6
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +13 -1
- package/src/compiler/includes.js +44 -1
- package/src/compiler/media.js +25 -0
- package/src/compiler/page.js +6 -0
- package/src/compiler/state.js +75 -0
- package/src/compiler/structure.js +272 -4
- package/src/errors.js +74 -3
- package/src/font-subset-worker.js +131 -0
- package/src/font-subset.js +475 -0
- package/src/grammar.js +3 -2
- package/src/headers.js +28 -1
- package/src/image-variant-resize.js +4 -1
- package/src/image-variants.js +9 -4
- package/src/router.js +16 -0
- package/src/skin.js +111 -45
- package/src/statics.js +22 -14
- package/src/templates/dashboard/README.md +3 -1
- package/src/templates/landing/site/pages/index.skin +7 -0
- package/src/templates/landing/site/pages/index.wd +10 -3
- package/src/templates/store/README.md +5 -2
- package/src/templates/store/site/_/card.wd +19 -0
- package/src/templates/store/site/_/products.json +6 -6
- package/src/templates/store/site/pages/index.skin +17 -2
- package/src/templates/store/site/pages/index.wd +3 -12
- package/types/api-runner.d.ts +20 -4
- package/types/builder.d.ts +25 -1
- package/types/catalog.d.ts +3 -2
- package/types/compiler/context.d.ts +28 -0
- package/types/compiler/includes.d.ts +23 -0
- package/types/compiler/structure.d.ts +53 -1
- package/types/errors.d.ts +6 -4
- package/types/font-subset.d.ts +144 -0
- package/types/headers.d.ts +14 -0
- package/types/image-variants.d.ts +7 -3
- package/types/router.d.ts +5 -0
- package/types/skin.d.ts +9 -4
package/src/compiler/media.js
CHANGED
|
@@ -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.
|
package/src/compiler/page.js
CHANGED
|
@@ -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 — ``, 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.
|
package/src/compiler/state.js
CHANGED
|
@@ -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
|
|
15
|
-
import {
|
|
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
|
|
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,
|
|
98
|
-
*
|
|
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
|
-
|
|
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
|
);
|