@zvndev/darkmown 1.5.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.
Files changed (88) hide show
  1. package/AGENTS.md +17 -3
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +44 -16
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +19 -6
  7. package/package.json +1 -1
  8. package/site/_/base.skin +8 -0
  9. package/site/_/base.wd +4 -0
  10. package/site/_/feature-card.wd +4 -0
  11. package/site/_/footer.wd +4 -0
  12. package/site/_/nav.wd +4 -0
  13. package/site/_/scoped-promo.wd +4 -0
  14. package/site/_/scoped-stat.wd +4 -0
  15. package/site/pages/404.wd +1 -0
  16. package/site/pages/app.wd +1 -0
  17. package/site/pages/blog/collections-and-pagination.skin +127 -0
  18. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  19. package/site/pages/blog/index.wd +1 -0
  20. package/site/pages/blog/shipping-feeds.skin +127 -0
  21. package/site/pages/blog/typed-frontmatter.skin +127 -0
  22. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  23. package/site/pages/blog/zero-js-by-default.skin +127 -0
  24. package/site/pages/carousel/index.wd +1 -0
  25. package/site/pages/compass/index.wd +1 -0
  26. package/site/pages/data.wd +3 -0
  27. package/site/pages/docs/-relative-note.wd +4 -0
  28. package/site/pages/docs/index.skin +27 -0
  29. package/site/pages/docs/index.wd +70 -13
  30. package/site/pages/folio/-style.wd +4 -0
  31. package/site/pages/folio/cart.wd +1 -0
  32. package/site/pages/folio/index.wd +1 -0
  33. package/site/pages/forge/index.wd +1 -0
  34. package/site/pages/highlight/index.wd +1 -0
  35. package/site/pages/index.wd +1 -0
  36. package/site/pages/interactions/index.wd +1 -0
  37. package/site/pages/ledger/index.wd +1 -0
  38. package/site/pages/loops.wd +1 -0
  39. package/site/pages/media/index.wd +1 -0
  40. package/site/pages/pulse/index.wd +1 -0
  41. package/site/pages/reactive.wd +1 -0
  42. package/site/pages/scoped/index.wd +1 -0
  43. package/site/pages/showcase/index.wd +1 -0
  44. package/site/pages/store.wd +1 -0
  45. package/site/pages/theme.wd +1 -0
  46. package/src/builder.js +444 -65
  47. package/src/cli.js +96 -18
  48. package/src/compiler/actions.js +303 -0
  49. package/src/compiler/body.js +36 -24
  50. package/src/compiler/context.js +56 -6
  51. package/src/compiler/directives.js +31 -1470
  52. package/src/compiler/fetch.js +159 -0
  53. package/src/compiler/format.js +17 -4
  54. package/src/compiler/forms.js +456 -0
  55. package/src/compiler/frontmatter.js +10 -5
  56. package/src/compiler/includes.js +66 -1
  57. package/src/compiler/index.js +15 -3
  58. package/src/compiler/loops.js +53 -21
  59. package/src/compiler/markdown.js +65 -15
  60. package/src/compiler/media.js +133 -0
  61. package/src/compiler/page.js +59 -75
  62. package/src/compiler/state.js +168 -0
  63. package/src/compiler/structure.js +377 -0
  64. package/src/dev.js +76 -0
  65. package/src/headers.js +56 -17
  66. package/src/router.js +12 -1
  67. package/src/runtime.js +5 -3
  68. package/src/statics.js +63 -12
  69. package/src/templates/blog/README.md +13 -4
  70. package/src/templates/blog/site/_/nav.wd +4 -0
  71. package/src/templates/blog/site/pages/index.skin +3 -0
  72. package/src/templates/blog/site/pages/index.wd +10 -4
  73. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  74. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  75. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  76. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  77. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  78. package/src/templates/dashboard/site/_/nav.wd +4 -0
  79. package/src/templates/dashboard/site/pages/index.wd +1 -0
  80. package/src/templates/landing/site/_/nav.wd +4 -0
  81. package/src/templates/landing/site/pages/index.wd +1 -0
  82. package/src/templates/starter/site/_/nav.wd +4 -0
  83. package/src/templates/starter/site/pages/about.md +18 -1
  84. package/src/templates/starter/site/pages/about.skin +69 -0
  85. package/src/templates/starter/site/pages/index.wd +1 -0
  86. package/src/templates/store/site/_/nav.wd +4 -0
  87. package/src/templates/store/site/pages/index.wd +1 -0
  88. package/src/templates/blog/site/_/posts.json +0 -16
@@ -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.file);
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.file);
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.file);
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.file);
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.file);
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 {string} file Source path for errors.
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, file) {
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(file, start)}`);
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 (const line of body) {
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 {string} file Source path for errors.
426
+ * @param {Ctx} ctx Compile context, for `file:line` errors.
422
427
  * @returns {{ body: string[], end: number }}
423
428
  */
424
- function scanContainer(lines, start, file) {
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(file, start)}`);
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 {string} file Source path for errors.
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, file) {
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(file, start)}`);
543
+ throw new Error(`Missing :endif for "${lines[start]}" in ${at(ctx, start)}`);
532
544
  }
@@ -58,14 +58,25 @@
58
58
  * @property {Assets} assets
59
59
  * @property {Map<string, unknown>} state Declared state keys → initial values.
60
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.
61
64
  * @property {string[]} warnings Non-fatal authoring hints.
62
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.
63
68
  * @property {Map<string, import("./collections.js").CollectionRow[]>} collections
64
69
  * Collection name → entry rows, built from the router's routes and threaded in
65
70
  * so a bare-name `@loop blog into post` resolves to its entries at build time.
66
71
  * @property {import("./context.js").Pagination | null} pagination The pagination
67
72
  * intent a `@loop … paginate N` recorded this compile, or null. The builder
68
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.
69
80
  */
70
81
 
71
82
  /**
@@ -79,6 +90,11 @@
79
90
  * Per-file compile context threaded through the directive handlers.
80
91
  * @typedef {object} Ctx
81
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.
82
98
  * @property {Paths} context Resolved project paths.
83
99
  * @property {string[]} stack Include stack (real paths) for cycle detection.
84
100
  * @property {Scope} scope Static value scope chain.
@@ -88,6 +104,13 @@
88
104
  * @property {string} [loopKey] State key of the list being looped, if any.
89
105
  * @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
90
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.
91
114
  */
92
115
 
93
116
  /**
@@ -99,6 +122,8 @@
99
122
  * @property {string[]} warnings
100
123
  * @property {Pagination | null} pagination The pagination intent a paginated
101
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.
102
127
  */
103
128
 
104
129
  /**
@@ -110,6 +135,8 @@
110
135
  * @property {string[]} warnings
111
136
  * @property {Pagination | null} pagination The pagination intent a paginated
112
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.
113
140
  */
114
141
 
115
142
  /**
@@ -133,6 +160,8 @@
133
160
  * @property {NumArg | null} offset
134
161
  * @property {NumArg | null} limit
135
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.
136
165
  * @property {boolean} clauseRefsState Whether offset/limit reference state.
137
166
  * @property {boolean} sortable Drag-to-reorder the underlying :state/:store list
138
167
  * (the `sortable` clause). Only valid on a plain reactive state-key loop.
@@ -178,10 +207,14 @@ export function createCompilation() {
178
207
  },
179
208
  state: new Map(),
180
209
  stores: new Set(),
210
+ fetchKeys: new Set(),
181
211
  warnings: [],
182
212
  sectionCounter: 0,
213
+ headingSlugs: new Map(),
183
214
  collections: new Map(),
184
- pagination: null
215
+ pagination: null,
216
+ deps: new Set(),
217
+ collectionsUsed: new Set()
185
218
  };
186
219
  }
187
220
 
@@ -197,11 +230,28 @@ export function createScope(parent, vars = {}) {
197
230
  /**
198
231
  * Format a source location as `file:line` (1-based) for compile errors. Keeps the
199
232
  * file path intact so existing message matchers still pass, while pointing at the
200
- * directive's (or unclosed opener's) line.
201
- * @param {string} file
202
- * @param {number} index 0-based line index.
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.
203
239
  * @returns {string}
204
240
  */
205
- export function at(file, index) {
206
- 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 };
207
257
  }