@zvndev/darkmown 1.4.0 → 2.0.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 +17 -3
- package/CHANGELOG.md +38 -0
- package/README.md +114 -17
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +31 -4
- package/package.json +1 -1
- package/site/_/base.skin +239 -18
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +4 -0
- package/site/_/nav.wd +5 -1
- package/site/_/scoped-promo.wd +4 -0
- package/site/_/scoped-stat.wd +4 -0
- package/site/pages/404.wd +1 -0
- package/site/pages/app.skin +27 -5
- package/site/pages/app.wd +1 -0
- package/site/pages/blog/_schema.wd +15 -0
- package/site/pages/blog/collections-and-pagination.md +38 -0
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.md +2 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.skin +144 -0
- package/site/pages/blog/index.wd +74 -0
- package/site/pages/blog/shipping-feeds.md +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -0
- package/site/pages/blog/typed-frontmatter.md +45 -0
- package/site/pages/blog/typed-frontmatter.skin +127 -0
- package/site/pages/blog/unfinished-thoughts.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +31 -0
- package/site/pages/blog/zero-js-by-default.skin +127 -0
- package/site/pages/carousel/index.wd +1 -0
- package/site/pages/compass/index.wd +1 -0
- package/site/pages/data.skin +50 -9
- package/site/pages/data.wd +3 -0
- package/site/pages/docs/-relative-note.wd +4 -0
- package/site/pages/docs/index.skin +27 -0
- package/site/pages/docs/index.wd +115 -11
- package/site/pages/folio/-style.wd +4 -0
- package/site/pages/folio/cart.wd +1 -0
- package/site/pages/folio/index.wd +1 -0
- package/site/pages/forge/index.wd +1 -0
- package/site/pages/highlight/index.wd +1 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/interactions/index.wd +1 -0
- package/site/pages/ledger/index.wd +1 -0
- package/site/pages/loops.skin +43 -6
- package/site/pages/loops.wd +1 -0
- package/site/pages/media/index.wd +1 -0
- package/site/pages/pulse/index.wd +1 -0
- package/site/pages/reactive.skin +38 -6
- package/site/pages/reactive.wd +1 -0
- package/site/pages/scoped/index.wd +1 -0
- package/site/pages/showcase/index.wd +1 -0
- package/site/pages/store.skin +43 -6
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +535 -48
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/collections.js +316 -0
- package/src/compiler/context.js +83 -6
- package/src/compiler/directives.js +31 -1470
- package/src/compiler/fetch.js +159 -0
- package/src/compiler/format.js +17 -4
- package/src/compiler/forms.js +456 -0
- package/src/compiler/frontmatter.js +10 -5
- package/src/compiler/includes.js +66 -1
- package/src/compiler/index.js +15 -3
- package/src/compiler/loops.js +181 -31
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +78 -41
- package/src/compiler/state.js +168 -0
- package/src/compiler/structure.js +377 -0
- package/src/dev.js +76 -0
- package/src/headers.js +56 -17
- package/src/router.js +12 -1
- package/src/runtime.js +5 -3
- package/src/statics.js +63 -12
- package/src/templates/blog/README.md +13 -4
- package/src/templates/blog/site/_/nav.wd +4 -0
- package/src/templates/blog/site/pages/index.skin +87 -0
- package/src/templates/blog/site/pages/index.wd +10 -4
- package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
- package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
- package/src/templates/dashboard/site/_/nav.wd +4 -0
- package/src/templates/dashboard/site/pages/index.skin +84 -0
- package/src/templates/dashboard/site/pages/index.wd +1 -0
- package/src/templates/landing/site/_/nav.wd +4 -0
- package/src/templates/landing/site/pages/index.skin +113 -6
- package/src/templates/landing/site/pages/index.wd +1 -0
- package/src/templates/starter/site/_/nav.wd +4 -0
- package/src/templates/starter/site/pages/about.md +18 -1
- package/src/templates/starter/site/pages/about.skin +69 -0
- package/src/templates/starter/site/pages/index.skin +81 -3
- package/src/templates/starter/site/pages/index.wd +1 -0
- package/src/templates/store/site/_/nav.wd +4 -0
- package/src/templates/store/site/pages/index.skin +93 -11
- package/src/templates/store/site/pages/index.wd +1 -0
- package/site/pages/blog/index.md +0 -13
- package/src/templates/blog/site/_/posts.json +0 -16
package/src/compiler/body.js
CHANGED
|
@@ -82,9 +82,9 @@ export function compileBody(lines, ctx) {
|
|
|
82
82
|
}
|
|
83
83
|
if (/^@loop\s/.test(line)) {
|
|
84
84
|
flush();
|
|
85
|
-
const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx
|
|
85
|
+
const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx);
|
|
86
86
|
const split = splitEmptyBranch(block.body);
|
|
87
|
-
out.push(handleLoop(line, split.body, split.empty, ctx));
|
|
87
|
+
out.push(handleLoop(line, split.body, split.empty, ctx, i, split.emptyStart));
|
|
88
88
|
i = block.end;
|
|
89
89
|
continue;
|
|
90
90
|
}
|
|
@@ -93,7 +93,7 @@ export function compileBody(lines, ctx) {
|
|
|
93
93
|
flush();
|
|
94
94
|
if (!container[1].trim())
|
|
95
95
|
throw new Error(`Stray ::: close with no open container in ${ctx.file}`);
|
|
96
|
-
const block = scanContainer(lines, i, ctx
|
|
96
|
+
const block = scanContainer(lines, i, ctx);
|
|
97
97
|
out.push(handleContainer(container[1].trim(), block.body, ctx, i));
|
|
98
98
|
i = block.end;
|
|
99
99
|
continue;
|
|
@@ -152,14 +152,14 @@ export function compileBody(lines, ctx) {
|
|
|
152
152
|
}
|
|
153
153
|
if (/^:form\s/.test(line)) {
|
|
154
154
|
flush();
|
|
155
|
-
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx
|
|
155
|
+
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx);
|
|
156
156
|
out.push(handleForm(line, block.body, ctx, i));
|
|
157
157
|
i = block.end;
|
|
158
158
|
continue;
|
|
159
159
|
}
|
|
160
160
|
if (/^:carousel(?:\s|$)/.test(line)) {
|
|
161
161
|
flush();
|
|
162
|
-
const block = scanBlock(lines, i, /^:carousel(?:\s|$)/, ":endcarousel", ctx
|
|
162
|
+
const block = scanBlock(lines, i, /^:carousel(?:\s|$)/, ":endcarousel", ctx);
|
|
163
163
|
out.push(handleCarousel(line, block.body, ctx, i));
|
|
164
164
|
i = block.end;
|
|
165
165
|
continue;
|
|
@@ -223,8 +223,8 @@ export function compileBody(lines, ctx) {
|
|
|
223
223
|
}
|
|
224
224
|
if (/^:if\s/.test(line)) {
|
|
225
225
|
flush();
|
|
226
|
-
const block = scanConditional(lines, i, ctx
|
|
227
|
-
out.push(handleIf(line, block.truthy, block.falsy, ctx, i));
|
|
226
|
+
const block = scanConditional(lines, i, ctx);
|
|
227
|
+
out.push(handleIf(line, block.truthy, block.falsy, ctx, i, block.falsyStart));
|
|
228
228
|
i = block.end;
|
|
229
229
|
continue;
|
|
230
230
|
}
|
|
@@ -342,10 +342,10 @@ function joinValueDirective(lines, start) {
|
|
|
342
342
|
* @param {number} start Index of the opening line.
|
|
343
343
|
* @param {RegExp} openRe Pattern that re-opens a nested block.
|
|
344
344
|
* @param {string} endToken Literal closing token (e.g. `@endloop`).
|
|
345
|
-
* @param {
|
|
345
|
+
* @param {Ctx} ctx Compile context, for `file:line` errors.
|
|
346
346
|
* @returns {{ body: string[], end: number }}
|
|
347
347
|
*/
|
|
348
|
-
function scanBlock(lines, start, openRe, endToken,
|
|
348
|
+
function scanBlock(lines, start, openRe, endToken, ctx) {
|
|
349
349
|
/** @type {string[]} */
|
|
350
350
|
const body = [];
|
|
351
351
|
let depth = 0;
|
|
@@ -371,25 +371,29 @@ function scanBlock(lines, start, openRe, endToken, file) {
|
|
|
371
371
|
}
|
|
372
372
|
body.push(line);
|
|
373
373
|
}
|
|
374
|
-
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${at(
|
|
374
|
+
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${at(ctx, start)}`);
|
|
375
375
|
}
|
|
376
376
|
|
|
377
377
|
/**
|
|
378
378
|
* Split a `@loop` body at a top-level `@empty` into the rows body and the empty
|
|
379
379
|
* branch. Honors nested `@loop … @endloop` (so an inner loop's `@empty` is not
|
|
380
|
-
* mistaken for the outer one) and fenced code.
|
|
380
|
+
* mistaken for the outer one) and fenced code. `emptyStart` is the index within
|
|
381
|
+
* `body` the empty branch starts at (the line after `@empty`), so nested errors
|
|
382
|
+
* in that branch still report the true file line.
|
|
381
383
|
* @param {string[]} body
|
|
382
|
-
* @returns {{ body: string[], empty: string[] | null }}
|
|
384
|
+
* @returns {{ body: string[], empty: string[] | null, emptyStart: number }}
|
|
383
385
|
*/
|
|
384
386
|
function splitEmptyBranch(body) {
|
|
385
387
|
/** @type {string[]} */
|
|
386
388
|
const rows = [];
|
|
387
389
|
/** @type {string[] | null} */
|
|
388
390
|
let empty = null;
|
|
391
|
+
let emptyStart = 0;
|
|
389
392
|
let target = rows;
|
|
390
393
|
let depth = 0;
|
|
391
394
|
let fence = null;
|
|
392
|
-
for (
|
|
395
|
+
for (let i = 0; i < body.length; i++) {
|
|
396
|
+
const line = body[i];
|
|
393
397
|
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
394
398
|
if (fence) {
|
|
395
399
|
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
|
|
@@ -406,22 +410,23 @@ function splitEmptyBranch(body) {
|
|
|
406
410
|
if (line.trim() === "@endloop") depth--;
|
|
407
411
|
if (depth === 0 && line.trim() === "@empty") {
|
|
408
412
|
empty = [];
|
|
413
|
+
emptyStart = i + 1;
|
|
409
414
|
target = empty;
|
|
410
415
|
continue;
|
|
411
416
|
}
|
|
412
417
|
target.push(line);
|
|
413
418
|
}
|
|
414
|
-
return { body: rows, empty };
|
|
419
|
+
return { body: rows, empty, emptyStart };
|
|
415
420
|
}
|
|
416
421
|
|
|
417
422
|
/**
|
|
418
423
|
* Capture the body of a `:::` container up to its matching close, honoring nesting.
|
|
419
424
|
* @param {string[]} lines
|
|
420
425
|
* @param {number} start Index of the opening line.
|
|
421
|
-
* @param {
|
|
426
|
+
* @param {Ctx} ctx Compile context, for `file:line` errors.
|
|
422
427
|
* @returns {{ body: string[], end: number }}
|
|
423
428
|
*/
|
|
424
|
-
function scanContainer(lines, start,
|
|
429
|
+
function scanContainer(lines, start, ctx) {
|
|
425
430
|
/** @type {string[]} */
|
|
426
431
|
const body = [];
|
|
427
432
|
let depth = 0;
|
|
@@ -452,7 +457,7 @@ function scanContainer(lines, start, file) {
|
|
|
452
457
|
}
|
|
453
458
|
body.push(line);
|
|
454
459
|
}
|
|
455
|
-
throw new Error(`Missing closing ::: for "${lines[start]}" in ${at(
|
|
460
|
+
throw new Error(`Missing closing ::: for "${lines[start]}" in ${at(ctx, start)}`);
|
|
456
461
|
}
|
|
457
462
|
|
|
458
463
|
/**
|
|
@@ -463,16 +468,21 @@ function scanContainer(lines, start, file) {
|
|
|
463
468
|
* `compileBody`/`handleIf` recursively. A whole `:if/:else if/:else` chain
|
|
464
469
|
* therefore compiles to nested `data-wd-if` regions the runtime already drives —
|
|
465
470
|
* identical behavior for static, reactive, and loop-row conditionals.
|
|
471
|
+
*
|
|
472
|
+
* `falsyStart` is the index within `lines` the falsy body starts at — the line
|
|
473
|
+
* after a bare `:else`, or the `:else if` line itself (its synthesized `:if`
|
|
474
|
+
* stands in for it) — so nested errors in that branch report the true file line.
|
|
466
475
|
* @param {string[]} lines
|
|
467
476
|
* @param {number} start Index of the `:if` line.
|
|
468
|
-
* @param {
|
|
469
|
-
* @returns {{ truthy: string[], falsy: string[], end: number }}
|
|
477
|
+
* @param {Ctx} ctx Compile context, for `file:line` errors.
|
|
478
|
+
* @returns {{ truthy: string[], falsy: string[], end: number, falsyStart: number }}
|
|
470
479
|
*/
|
|
471
|
-
function scanConditional(lines, start,
|
|
480
|
+
function scanConditional(lines, start, ctx) {
|
|
472
481
|
/** @type {string[]} */
|
|
473
482
|
const truthy = [];
|
|
474
483
|
/** @type {string[]} */
|
|
475
484
|
const falsy = [];
|
|
485
|
+
let falsyStart = start + 1;
|
|
476
486
|
let current = truthy;
|
|
477
487
|
// "truthy": collecting the :if body. "else": a bare :else closed the chain —
|
|
478
488
|
// no further branches allowed. "elseif": the first :else if was desugared into
|
|
@@ -499,7 +509,7 @@ function scanConditional(lines, start, file) {
|
|
|
499
509
|
if (line.trim() === ":endif") {
|
|
500
510
|
if (depth === 0) {
|
|
501
511
|
if (mode === "elseif") falsy.push(":endif"); // close the synthesized nested :if
|
|
502
|
-
return { truthy, falsy, end: i };
|
|
512
|
+
return { truthy, falsy, end: i, falsyStart };
|
|
503
513
|
}
|
|
504
514
|
depth--;
|
|
505
515
|
}
|
|
@@ -509,9 +519,10 @@ function scanConditional(lines, start, file) {
|
|
|
509
519
|
if (elseIf) {
|
|
510
520
|
if (mode === "else")
|
|
511
521
|
throw new Error(
|
|
512
|
-
`":else if" after ":else" in ${file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`
|
|
522
|
+
`":else if" after ":else" in ${ctx.file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`
|
|
513
523
|
);
|
|
514
524
|
falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
|
|
525
|
+
falsyStart = i;
|
|
515
526
|
current = falsy;
|
|
516
527
|
mode = "elseif";
|
|
517
528
|
continue;
|
|
@@ -519,8 +530,9 @@ function scanConditional(lines, start, file) {
|
|
|
519
530
|
if (t === ":else") {
|
|
520
531
|
if (mode === "else")
|
|
521
532
|
throw new Error(
|
|
522
|
-
`Duplicate ":else" in ${file}. A conditional may have only one bare ":else".`
|
|
533
|
+
`Duplicate ":else" in ${ctx.file}. A conditional may have only one bare ":else".`
|
|
523
534
|
);
|
|
535
|
+
falsyStart = i + 1;
|
|
524
536
|
current = falsy;
|
|
525
537
|
mode = "else";
|
|
526
538
|
continue;
|
|
@@ -528,5 +540,5 @@ function scanConditional(lines, start, file) {
|
|
|
528
540
|
}
|
|
529
541
|
current.push(line);
|
|
530
542
|
}
|
|
531
|
-
throw new Error(`Missing :endif for "${lines[start]}" in ${at(
|
|
543
|
+
throw new Error(`Missing :endif for "${lines[start]}" in ${at(ctx, start)}`);
|
|
532
544
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Typed content collections. ANY `site/pages/<name>/` subdirectory is a
|
|
3
|
+
// queryable collection, referenced by its bare name in `@loop` (no `content/`
|
|
4
|
+
// root, no marker file). The index is built from the router's already-parsed
|
|
5
|
+
// `route.meta` — frontmatter is never re-parsed here — and each entry becomes a
|
|
6
|
+
// row the existing where/sort/format-pipe/`:if`/`:every` machinery consumes
|
|
7
|
+
// UNCHANGED: every frontmatter field plus the derived `url` (entry route),
|
|
8
|
+
// `slug` (filename stem), and an `excerpt` (frontmatter `excerpt:` → first
|
|
9
|
+
// paragraph of a `.md` body).
|
|
10
|
+
//
|
|
11
|
+
// An optional `_schema.wd` at the collection root opts the collection into
|
|
12
|
+
// build-time validation: every entry's frontmatter is checked against a small,
|
|
13
|
+
// closed type DSL (`field: string|number|boolean|date|string[]`, `?` = optional)
|
|
14
|
+
// with `file:line` errors. No `_schema.wd` = no validation (opt-in, like
|
|
15
|
+
// `scoped`/`draft`).
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { firstParagraph } from "../feeds.js";
|
|
21
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {import("./context.js").Paths} Paths
|
|
25
|
+
* @typedef {import("./context.js").Meta} Meta
|
|
26
|
+
* @typedef {import("../router.js").Route} Route
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A collection entry row: every frontmatter field, plus derived `url`/`slug`/
|
|
31
|
+
* `excerpt`. Keyed loosely so authors' custom frontmatter keys pass through to
|
|
32
|
+
* `{ post.whatever }` and `where`/`sort` without a closed schema here.
|
|
33
|
+
* @typedef {Record<string, unknown> & { url: string, slug: string, excerpt: string }} CollectionRow
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The closed type vocabulary for `_schema.wd`. `?` (optional) is a modifier. */
|
|
37
|
+
const SCHEMA_TYPES = new Set(["string", "number", "boolean", "date", "string[]"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the collection index from the router's discovered routes: one entry per
|
|
41
|
+
* `site/pages/<name>/…` route, grouped under the FIRST path segment (the
|
|
42
|
+
* collection name). The home route, top-level pages, and the synthetic `/404/`
|
|
43
|
+
* route are not entries. Reuses each route's already-parsed `meta` (no re-parse)
|
|
44
|
+
* and reads the `.md` body only for the excerpt fallback. When a collection has
|
|
45
|
+
* a `_schema.wd`, every entry is validated against it here (throwing on the
|
|
46
|
+
* first violation), so a malformed entry fails the build with a `file:line`.
|
|
47
|
+
*
|
|
48
|
+
* @param {Route[]} routes Post-draft-filter routes from `discoverRoutes`.
|
|
49
|
+
* @param {Paths} paths Resolved project paths (`routesRoot` for `_schema.wd`).
|
|
50
|
+
* @returns {Map<string, CollectionRow[]>} Collection name → entry rows.
|
|
51
|
+
*/
|
|
52
|
+
export function buildCollections(routes, paths) {
|
|
53
|
+
/** @type {Map<string, CollectionRow[]>} */
|
|
54
|
+
const collections = new Map();
|
|
55
|
+
for (const route of routes) {
|
|
56
|
+
const name = collectionNameOf(route.route);
|
|
57
|
+
if (!name) continue;
|
|
58
|
+
const rows = collections.get(name) || [];
|
|
59
|
+
rows.push(rowFor(route));
|
|
60
|
+
collections.set(name, rows);
|
|
61
|
+
}
|
|
62
|
+
for (const [name, rows] of collections) {
|
|
63
|
+
const schema = readSchema(name, paths);
|
|
64
|
+
if (schema) validateRows(rows, schema, name);
|
|
65
|
+
}
|
|
66
|
+
return collections;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The collection name a route belongs to: the first path segment of a nested
|
|
71
|
+
* route (`/blog/hello/` → `blog`), or null for the home page, a top-level page
|
|
72
|
+
* (`/about/` → no nesting), or the `/404/` route (never a collection entry).
|
|
73
|
+
* @param {string} route Public route path (trailing-slashed).
|
|
74
|
+
* @returns {string | null}
|
|
75
|
+
*/
|
|
76
|
+
function collectionNameOf(route) {
|
|
77
|
+
if (route === "/" || route === "/404/") return null;
|
|
78
|
+
const parts = route.replace(/^\/|\/$/g, "").split("/");
|
|
79
|
+
return parts.length >= 2 ? parts[0] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build one collection row from a route: spread its frontmatter, then add the
|
|
84
|
+
* derived `url` (the route path), `slug` (filename stem), and `excerpt`
|
|
85
|
+
* (frontmatter `excerpt:` wins; else the first paragraph of a `.md` body; `.wd`
|
|
86
|
+
* bodies are not scanned — directive markup isn't clean prose). The derived keys
|
|
87
|
+
* are added AFTER the spread so a stray frontmatter `url:`/`slug:` can't shadow
|
|
88
|
+
* the real route data the loop relies on.
|
|
89
|
+
* @param {Route} route
|
|
90
|
+
* @returns {CollectionRow}
|
|
91
|
+
*/
|
|
92
|
+
function rowFor(route) {
|
|
93
|
+
const slug = path.basename(route.file, path.extname(route.file));
|
|
94
|
+
/** @type {Record<string, unknown>} */
|
|
95
|
+
const fields = {};
|
|
96
|
+
for (const [key, value] of Object.entries(route.meta)) fields[key] = coerceScalar(value);
|
|
97
|
+
return {
|
|
98
|
+
...fields,
|
|
99
|
+
url: route.route,
|
|
100
|
+
slug: slug === "index" ? path.basename(path.dirname(route.file)) : slug,
|
|
101
|
+
excerpt: excerptFor(route)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Coerce a frontmatter scalar to its natural type for querying. The frontmatter
|
|
107
|
+
* parser yields scalars as STRINGS (`true`, `42` arrive as `"true"`, `"42"`), but
|
|
108
|
+
* a collection loop should behave like a JSON loop, where `where p.featured ==
|
|
109
|
+
* true` and numeric `sort`/comparisons just work. So a bare `true`/`false` becomes
|
|
110
|
+
* a boolean and a numeric string becomes a number; quoted/other strings and
|
|
111
|
+
* arrays pass through untouched. This coercion is scoped to collection rows only —
|
|
112
|
+
* it never touches the global frontmatter parser (a `:state`/`{ meta.x }` value
|
|
113
|
+
* keeps its existing string form).
|
|
114
|
+
* @param {unknown} value
|
|
115
|
+
* @returns {unknown}
|
|
116
|
+
*/
|
|
117
|
+
function coerceScalar(value) {
|
|
118
|
+
if (value === "true") return true;
|
|
119
|
+
if (value === "false") return false;
|
|
120
|
+
if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The excerpt for an entry: the frontmatter `excerpt:` string if present, else
|
|
126
|
+
* (plain `.md` only) the first paragraph of its body, else "". Mirrors the RSS
|
|
127
|
+
* description fallback so a collection card and the feed read the same snippet.
|
|
128
|
+
* @param {Route} route
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
function excerptFor(route) {
|
|
132
|
+
const fromMeta = route.meta.excerpt;
|
|
133
|
+
if (typeof fromMeta === "string" && fromMeta.trim()) return fromMeta.trim();
|
|
134
|
+
if (path.extname(route.file) !== ".md") return "";
|
|
135
|
+
const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
|
|
136
|
+
return firstParagraph(body);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A parsed `_schema.wd` field rule.
|
|
141
|
+
* @typedef {object} SchemaField
|
|
142
|
+
* @property {string} name Field name.
|
|
143
|
+
* @property {string} type One of {@link SCHEMA_TYPES}.
|
|
144
|
+
* @property {boolean} optional Whether the field may be absent (`?` modifier).
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read + parse a collection's `_schema.wd`, or null when the file is absent
|
|
149
|
+
* (validation is opt-in). The schema is frontmatter-shaped — one `field: type`
|
|
150
|
+
* line per rule inside the `---` block — so authors write it exactly like the
|
|
151
|
+
* frontmatter it validates. Each type must be in the closed {@link SCHEMA_TYPES}
|
|
152
|
+
* vocabulary (`?` suffix = optional); an unknown token is a compile error in the
|
|
153
|
+
* schema file itself, pointing at the offending line.
|
|
154
|
+
* @param {string} name Collection name.
|
|
155
|
+
* @param {Paths} paths
|
|
156
|
+
* @returns {SchemaField[] | null}
|
|
157
|
+
*/
|
|
158
|
+
export function readSchema(name, paths) {
|
|
159
|
+
const file = path.join(paths.routesRoot, name, "_schema.wd");
|
|
160
|
+
if (!fs.existsSync(file)) return null;
|
|
161
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
162
|
+
return parseSchema(raw, file);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse a `_schema.wd` source into field rules. The schema fields live in the
|
|
167
|
+
* frontmatter block; the body (if any) is ignored, so a schema file can carry a
|
|
168
|
+
* prose note after its `---` fence. Each field line is `name: type` where type
|
|
169
|
+
* is a closed token with an optional trailing `?`.
|
|
170
|
+
* @param {string} raw `_schema.wd` file contents.
|
|
171
|
+
* @param {string} file Absolute path, for `file:line` errors.
|
|
172
|
+
* @returns {SchemaField[]}
|
|
173
|
+
*/
|
|
174
|
+
export function parseSchema(raw, file) {
|
|
175
|
+
// Use the same `---` extraction as page frontmatter so the schema block is
|
|
176
|
+
// delimited identically. A schema with no fence is read as bare lines.
|
|
177
|
+
const lines = raw.replace(/\r\n?/g, "\n").split("\n");
|
|
178
|
+
/** @type {SchemaField[]} */
|
|
179
|
+
const fields = [];
|
|
180
|
+
let fenced = false;
|
|
181
|
+
for (let i = 0; i < lines.length; i++) {
|
|
182
|
+
const line = lines[i];
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (trimmed === "---") {
|
|
185
|
+
// First `---` opens the block; the second closes it and ends the schema.
|
|
186
|
+
if (!fenced && fields.length === 0) {
|
|
187
|
+
fenced = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (fenced) break; // closing fence — rest of the file is prose, ignore it
|
|
191
|
+
}
|
|
192
|
+
if (!trimmed || trimmed.startsWith("#")) continue; // blank line or comment
|
|
193
|
+
const match = trimmed.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
|
|
194
|
+
if (!match) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Malformed schema line in ${file}:${i + 1}: "${trimmed}". Use: field: type (e.g. \`title: string\`, \`tags: string[]\`, \`featured: boolean?\`).`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
fields.push(parseSchemaField(match[1], match[2].trim(), file, i + 1));
|
|
200
|
+
}
|
|
201
|
+
return fields;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Parse one `name: type` schema rule, validating the type against the closed
|
|
206
|
+
* vocabulary and peeling the optional `?` modifier.
|
|
207
|
+
* @param {string} name
|
|
208
|
+
* @param {string} rawType Type token (may end in `?`).
|
|
209
|
+
* @param {string} file
|
|
210
|
+
* @param {number} lineNo 1-based line number for the error.
|
|
211
|
+
* @returns {SchemaField}
|
|
212
|
+
*/
|
|
213
|
+
function parseSchemaField(name, rawType, file, lineNo) {
|
|
214
|
+
const optional = rawType.endsWith("?");
|
|
215
|
+
const type = optional ? rawType.slice(0, -1).trim() : rawType;
|
|
216
|
+
if (!SCHEMA_TYPES.has(type)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Unknown schema type "${rawType}" for field "${name}" in ${file}:${lineNo}. Use one of: ${[...SCHEMA_TYPES].join(", ")} (append "?" to make a field optional).`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return { name, type, optional };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Validate every collection row against the schema, throwing on the first
|
|
226
|
+
* violation with a `file:line` (the entry's own path) and the offending field.
|
|
227
|
+
* A missing required field, a wrong type, or an unknown extra field all fail —
|
|
228
|
+
* unknown keys are rejected so a typo'd frontmatter key (`titel:`) is caught
|
|
229
|
+
* rather than silently dropped.
|
|
230
|
+
* @param {CollectionRow[]} rows
|
|
231
|
+
* @param {SchemaField[]} schema
|
|
232
|
+
* @param {string} name Collection name, for the error message.
|
|
233
|
+
* @returns {void}
|
|
234
|
+
*/
|
|
235
|
+
function validateRows(rows, schema, name) {
|
|
236
|
+
const allowed = new Set(schema.map((field) => field.name));
|
|
237
|
+
// Framework-recognized keys never need a schema declaration: `url`/`slug`/
|
|
238
|
+
// `excerpt` are derived (always present), and `draft` is the routing gate
|
|
239
|
+
// (present on `--drafts` builds). Allow them implicitly so they don't trip the
|
|
240
|
+
// unknown-key check.
|
|
241
|
+
for (const reserved of ["url", "slug", "excerpt", "draft"]) allowed.add(reserved);
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
const where = `${String(row.url)} (collection "${name}")`;
|
|
244
|
+
for (const field of schema) validateField(row, field, where);
|
|
245
|
+
for (const key of Object.keys(row)) {
|
|
246
|
+
if (allowed.has(key)) continue;
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Unknown frontmatter field "${key}" in ${where}: it is not declared in _schema.wd. Add \`${key}: <type>\` to the schema, or remove the field.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Validate one field of one row against its schema rule.
|
|
256
|
+
* @param {CollectionRow} row
|
|
257
|
+
* @param {SchemaField} field
|
|
258
|
+
* @param {string} where Human-readable entry location for the error.
|
|
259
|
+
* @returns {void}
|
|
260
|
+
*/
|
|
261
|
+
function validateField(row, field, where) {
|
|
262
|
+
const present = field.name in row && row[field.name] !== null && row[field.name] !== "";
|
|
263
|
+
if (!present) {
|
|
264
|
+
if (field.optional) return;
|
|
265
|
+
throw new Error(
|
|
266
|
+
`Missing required field "${field.name}" (${field.type}) in ${where}. Add \`${field.name}: …\` to the entry's frontmatter, or mark it optional with "${field.name}: ${field.type}?" in _schema.wd.`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const value = row[field.name];
|
|
270
|
+
if (!matchesType(value, field.type)) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Field "${field.name}" in ${where} should be ${field.type} but is "${describe(value)}". Fix the entry's frontmatter or the schema.`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Whether a frontmatter value satisfies a schema type. Frontmatter scalars are
|
|
279
|
+
* already strings/numbers/booleans/arrays (the parser produces no Date object),
|
|
280
|
+
* so `date` is "a string that `Date` can parse" and `number` accepts a numeric
|
|
281
|
+
* string too (frontmatter `42` parses to a number, but a quoted `"42"` stays a
|
|
282
|
+
* string — both are acceptable for a `number` field). The `type` is always one of
|
|
283
|
+
* {@link SCHEMA_TYPES} (parseSchemaField rejected anything else), so a missing
|
|
284
|
+
* checker can't occur — every {@link SCHEMA_TYPES} member has an entry below.
|
|
285
|
+
* @param {unknown} value
|
|
286
|
+
* @param {string} type
|
|
287
|
+
* @returns {boolean}
|
|
288
|
+
*/
|
|
289
|
+
function matchesType(value, type) {
|
|
290
|
+
return TYPE_CHECKS[type](value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The per-type value checker for each closed schema type. Keyed exactly by
|
|
295
|
+
* {@link SCHEMA_TYPES}, so the indexed call in {@link matchesType} always lands on
|
|
296
|
+
* a real checker (no unreachable fallback branch to cover).
|
|
297
|
+
* @type {Record<string, (value: unknown) => boolean>}
|
|
298
|
+
*/
|
|
299
|
+
const TYPE_CHECKS = {
|
|
300
|
+
string: (value) => typeof value === "string",
|
|
301
|
+
number: (value) =>
|
|
302
|
+
typeof value === "number" || (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)),
|
|
303
|
+
boolean: (value) => typeof value === "boolean" || value === "true" || value === "false",
|
|
304
|
+
date: (value) => typeof value === "string" && !Number.isNaN(new Date(value).getTime()),
|
|
305
|
+
"string[]": (value) => Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A short, safe description of a value for a validation error message.
|
|
310
|
+
* @param {unknown} value
|
|
311
|
+
* @returns {string}
|
|
312
|
+
*/
|
|
313
|
+
function describe(value) {
|
|
314
|
+
if (Array.isArray(value)) return `[${value.join(", ")}]`;
|
|
315
|
+
return String(value);
|
|
316
|
+
}
|
package/src/compiler/context.js
CHANGED
|
@@ -41,14 +41,42 @@
|
|
|
41
41
|
* subtree; the HTML stamp uses them to scope the matching page/include subtree.
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The pagination intent a `@loop … paginate N` records during compile, so the
|
|
46
|
+
* builder can multiply routes (page 1 at the listing route, 2+ at
|
|
47
|
+
* `/<route>/page/<n>/`). `total` is the post-`where`/`sort` page count for the
|
|
48
|
+
* collection the loop drew from.
|
|
49
|
+
* @typedef {object} Pagination
|
|
50
|
+
* @property {number} perPage Rows per page (the `paginate N` value).
|
|
51
|
+
* @property {number} total Total page count (≥ 1).
|
|
52
|
+
* @property {string} collection Collection name the loop paginated.
|
|
53
|
+
*/
|
|
54
|
+
|
|
44
55
|
/**
|
|
45
56
|
* Per-document compilation accumulator shared across includes/sections.
|
|
46
57
|
* @typedef {object} Compilation
|
|
47
58
|
* @property {Assets} assets
|
|
48
59
|
* @property {Map<string, unknown>} state Declared state keys → initial values.
|
|
49
60
|
* @property {Set<string>} stores Page-global store names (a subset of state keys).
|
|
61
|
+
* @property {Set<string>} fetchKeys Base state keys declared by `:fetch`, so
|
|
62
|
+
* bare `:if <key>_loading` / `:if <key>_error` regions get compile-time
|
|
63
|
+
* `role`/`aria-live` announcements.
|
|
50
64
|
* @property {string[]} warnings Non-fatal authoring hints.
|
|
51
65
|
* @property {number} sectionCounter Counter for auto-generated section ids.
|
|
66
|
+
* @property {Map<string, number>} headingSlugs Heading-slug occurrence counts,
|
|
67
|
+
* shared across prose chunks and includes so anchor ids dedupe document-wide.
|
|
68
|
+
* @property {Map<string, import("./collections.js").CollectionRow[]>} collections
|
|
69
|
+
* Collection name → entry rows, built from the router's routes and threaded in
|
|
70
|
+
* so a bare-name `@loop blog into post` resolves to its entries at build time.
|
|
71
|
+
* @property {import("./context.js").Pagination | null} pagination The pagination
|
|
72
|
+
* intent a `@loop … paginate N` recorded this compile, or null. The builder
|
|
73
|
+
* reads it to multiply routes; one paginated loop per page is supported.
|
|
74
|
+
* @property {Set<string>} deps Absolute paths of every source file this compile
|
|
75
|
+
* read: the page itself, every `@include` target, and every `@loop` JSON data
|
|
76
|
+
* file. The dev builder unions these (plus colocated assets) into the per-route
|
|
77
|
+
* dependency map that drives incremental rebuilds.
|
|
78
|
+
* @property {Set<string>} collectionsUsed Names of the collections this compile
|
|
79
|
+
* looped, so a change to any entry of a collection rebuilds its consumers.
|
|
52
80
|
*/
|
|
53
81
|
|
|
54
82
|
/**
|
|
@@ -62,6 +90,11 @@
|
|
|
62
90
|
* Per-file compile context threaded through the directive handlers.
|
|
63
91
|
* @typedef {object} Ctx
|
|
64
92
|
* @property {string} file Absolute path to the file being compiled.
|
|
93
|
+
* @property {number} [bodyLine] 0-based file line the compiled body starts on
|
|
94
|
+
* (the frontmatter offset), so `at` reports true file line numbers.
|
|
95
|
+
* @property {number} [lineOffset] 0-based body line the current `compileBody`
|
|
96
|
+
* slice starts on. Block handlers recurse with {@link nestedCtx}, so a line
|
|
97
|
+
* index inside a nested block body still maps to the true file line.
|
|
65
98
|
* @property {Paths} context Resolved project paths.
|
|
66
99
|
* @property {string[]} stack Include stack (real paths) for cycle detection.
|
|
67
100
|
* @property {Scope} scope Static value scope chain.
|
|
@@ -71,6 +104,13 @@
|
|
|
71
104
|
* @property {string} [loopKey] State key of the list being looped, if any.
|
|
72
105
|
* @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
|
|
73
106
|
* @property {import("markdown-it").default} [md] Markdown-it instance selected for this file.
|
|
107
|
+
* @property {(lines: string[], ctx: Ctx) => string} compileBody The body
|
|
108
|
+
* dispatcher, threaded through the context so block handlers (`:if`, `:::`,
|
|
109
|
+
* `:form`, `@loop`, …) recurse into nested bodies without importing the
|
|
110
|
+
* dispatcher back — the module graph stays an import-cycle-free DAG.
|
|
111
|
+
* @property {(file: string, context: Paths, stack: string[], scope: Scope, comp: Compilation, sections: string[], loopItem: string | null) => { meta: Meta, html: string }} compileFile
|
|
112
|
+
* The per-file compile, threaded for the same reason so `@include` can
|
|
113
|
+
* compile its target file.
|
|
74
114
|
*/
|
|
75
115
|
|
|
76
116
|
/**
|
|
@@ -80,6 +120,10 @@
|
|
|
80
120
|
* @property {string} html
|
|
81
121
|
* @property {Assets} assets
|
|
82
122
|
* @property {string[]} warnings
|
|
123
|
+
* @property {Pagination | null} pagination The pagination intent a paginated
|
|
124
|
+
* `@loop` recorded, or null. The builder reads it to multiply routes.
|
|
125
|
+
* @property {Set<string>} deps Absolute source-file dependencies of this compile.
|
|
126
|
+
* @property {Set<string>} collectionsUsed Collection names this compile looped.
|
|
83
127
|
*/
|
|
84
128
|
|
|
85
129
|
/**
|
|
@@ -89,6 +133,10 @@
|
|
|
89
133
|
* @property {string} html
|
|
90
134
|
* @property {Assets} assets
|
|
91
135
|
* @property {string[]} warnings
|
|
136
|
+
* @property {Pagination | null} pagination The pagination intent a paginated
|
|
137
|
+
* `@loop` recorded, or null. The builder reads it to multiply routes.
|
|
138
|
+
* @property {Set<string>} deps Absolute source-file dependencies of this compile.
|
|
139
|
+
* @property {Set<string>} collectionsUsed Collection names this compile looped.
|
|
92
140
|
*/
|
|
93
141
|
|
|
94
142
|
/**
|
|
@@ -112,9 +160,15 @@
|
|
|
112
160
|
* @property {NumArg | null} offset
|
|
113
161
|
* @property {NumArg | null} limit
|
|
114
162
|
* @property {string[] | null} empty Empty-branch body lines, if any.
|
|
163
|
+
* @property {number} [emptyStart] 0-based index the empty branch starts at
|
|
164
|
+
* within the loop body, so its nested errors report the true file line.
|
|
115
165
|
* @property {boolean} clauseRefsState Whether offset/limit reference state.
|
|
116
166
|
* @property {boolean} sortable Drag-to-reorder the underlying :state/:store list
|
|
117
167
|
* (the `sortable` clause). Only valid on a plain reactive state-key loop.
|
|
168
|
+
* @property {number | null} paginate Rows-per-page for a `paginate N` clause, or
|
|
169
|
+
* null. Only valid on a collection loop; multiplies the listing into static
|
|
170
|
+
* pages (page 1 at the route, 2+ at `/<route>/page/<n>/`) and exposes a `page`
|
|
171
|
+
* pager to the template scope.
|
|
118
172
|
*/
|
|
119
173
|
|
|
120
174
|
/**
|
|
@@ -153,8 +207,14 @@ export function createCompilation() {
|
|
|
153
207
|
},
|
|
154
208
|
state: new Map(),
|
|
155
209
|
stores: new Set(),
|
|
210
|
+
fetchKeys: new Set(),
|
|
156
211
|
warnings: [],
|
|
157
|
-
sectionCounter: 0
|
|
212
|
+
sectionCounter: 0,
|
|
213
|
+
headingSlugs: new Map(),
|
|
214
|
+
collections: new Map(),
|
|
215
|
+
pagination: null,
|
|
216
|
+
deps: new Set(),
|
|
217
|
+
collectionsUsed: new Set()
|
|
158
218
|
};
|
|
159
219
|
}
|
|
160
220
|
|
|
@@ -170,11 +230,28 @@ export function createScope(parent, vars = {}) {
|
|
|
170
230
|
/**
|
|
171
231
|
* Format a source location as `file:line` (1-based) for compile errors. Keeps the
|
|
172
232
|
* file path intact so existing message matchers still pass, while pointing at the
|
|
173
|
-
* directive's (or unclosed opener's) line.
|
|
174
|
-
*
|
|
175
|
-
* @
|
|
233
|
+
* directive's (or unclosed opener's) line. `index` is 0-based into the current
|
|
234
|
+
* `compileBody` slice; `ctx.lineOffset` (where that slice starts in the body,
|
|
235
|
+
* accumulated by {@link nestedCtx} as block handlers recurse) and `ctx.bodyLine`
|
|
236
|
+
* (the frontmatter offset) shift it back to the true file line.
|
|
237
|
+
* @param {Pick<Ctx, "file" | "bodyLine" | "lineOffset">} ctx
|
|
238
|
+
* @param {number} index 0-based line index into the current body slice.
|
|
176
239
|
* @returns {string}
|
|
177
240
|
*/
|
|
178
|
-
export function at(
|
|
179
|
-
return `${file}:${index + 1}`;
|
|
241
|
+
export function at(ctx, index) {
|
|
242
|
+
return `${ctx.file}:${index + 1 + (ctx.bodyLine ?? 0) + (ctx.lineOffset ?? 0)}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The compile context for a nested block body that starts `start` lines into the
|
|
247
|
+
* current slice. The offset accumulates, so errors inside arbitrarily nested
|
|
248
|
+
* blocks (`::: container`, `:form`, `@loop`, `:if`, `:carousel`) still report
|
|
249
|
+
* the true file line through {@link at}.
|
|
250
|
+
* @template {Pick<Ctx, "lineOffset">} T
|
|
251
|
+
* @param {T} ctx
|
|
252
|
+
* @param {number} start 0-based index the nested body starts at in the current slice.
|
|
253
|
+
* @returns {T}
|
|
254
|
+
*/
|
|
255
|
+
export function nestedCtx(ctx, start) {
|
|
256
|
+
return { ...ctx, lineOffset: (ctx.lineOffset ?? 0) + start };
|
|
180
257
|
}
|