@zvndev/darkmown 1.3.0 → 1.5.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 (43) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +114 -1
  4. package/docs/spec-alignment.md +30 -0
  5. package/package.json +1 -1
  6. package/site/_/base.skin +231 -18
  7. package/site/_/nav.wd +1 -1
  8. package/site/_/scoped-promo.skin +43 -0
  9. package/site/_/scoped-promo.wd +10 -0
  10. package/site/_/scoped-stat.skin +32 -0
  11. package/site/_/scoped-stat.wd +9 -0
  12. package/site/pages/app.skin +27 -5
  13. package/site/pages/blog/_schema.wd +15 -0
  14. package/site/pages/blog/collections-and-pagination.md +38 -0
  15. package/site/pages/blog/drafts-that-never-leak.md +2 -0
  16. package/site/pages/blog/index.skin +144 -0
  17. package/site/pages/blog/index.wd +73 -0
  18. package/site/pages/blog/shipping-feeds.md +1 -0
  19. package/site/pages/blog/typed-frontmatter.md +45 -0
  20. package/site/pages/blog/zero-js-by-default.md +31 -0
  21. package/site/pages/data.skin +50 -9
  22. package/site/pages/docs/index.wd +70 -0
  23. package/site/pages/loops.skin +43 -6
  24. package/site/pages/reactive.skin +38 -6
  25. package/site/pages/scoped/index.skin +23 -0
  26. package/site/pages/scoped/index.wd +47 -0
  27. package/site/pages/store.skin +43 -6
  28. package/src/builder.js +213 -32
  29. package/src/compiler/collections.js +316 -0
  30. package/src/compiler/context.js +34 -2
  31. package/src/compiler/directives.js +11 -5
  32. package/src/compiler/includes.js +48 -1
  33. package/src/compiler/index.js +1 -1
  34. package/src/compiler/loops.js +130 -12
  35. package/src/compiler/page.js +93 -9
  36. package/src/compiler.js +2 -1
  37. package/src/skin.js +147 -3
  38. package/src/templates/blog/site/pages/index.skin +84 -0
  39. package/src/templates/dashboard/site/pages/index.skin +84 -0
  40. package/src/templates/landing/site/pages/index.skin +113 -6
  41. package/src/templates/starter/site/pages/index.skin +81 -3
  42. package/src/templates/store/site/pages/index.skin +93 -11
  43. package/site/pages/blog/index.md +0 -13
@@ -15,6 +15,7 @@ import {
15
15
  escapeHtml,
16
16
  getPath,
17
17
  lookupPath,
18
+ lookupVar,
18
19
  resolveStateKey,
19
20
  stripQuotes,
20
21
  unescapeHtml
@@ -30,15 +31,15 @@ import { compilePredicate, evalPredicate } from "./predicates.js";
30
31
 
31
32
  // The fixed corrective suggestion shown for any malformed @loop header.
32
33
  const LOOP_USAGE =
33
- "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [sortable]";
34
+ "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable]";
34
35
 
35
36
  /**
36
37
  * Parse the optional clause tail of a `@loop` header in FIXED order:
37
- * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N] [sortable]`.
38
+ * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N] [paginate N] [sortable]`.
38
39
  * @param {string} tail Everything after `@loop src into item`.
39
40
  * @param {string} itemName
40
41
  * @param {Ctx} ctx
41
- * @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean, sortable: boolean }}
42
+ * @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean, sortable: boolean, paginate: number|null }}
42
43
  */
43
44
  function parseLoopClauses(tail, itemName, ctx) {
44
45
  // Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
@@ -53,6 +54,7 @@ function parseLoopClauses(tail, itemName, ctx) {
53
54
  /** @type {NumArg|null} */ let limit = null;
54
55
  let refsState = false;
55
56
  let sortable = false;
57
+ /** @type {number|null} */ let paginate = null;
56
58
  /** @returns {never} */
57
59
  const bad = () => {
58
60
  throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`);
@@ -69,6 +71,20 @@ function parseLoopClauses(tail, itemName, ctx) {
69
71
  .trim();
70
72
  }
71
73
 
74
+ // `paginate N` slices the listing into static pages. Peel it position-
75
+ // independently (like `sortable`) so `paginate 2 limit 1` parses and handleLoop
76
+ // can reject the combination with a precise message rather than failing the
77
+ // fixed-order parse. The count is a positive integer literal — paginating "by a
78
+ // state value" makes no sense for a build-time route split.
79
+ const pm = s.match(/(^|\s)paginate\s+(\d+)(?=\s|$)/);
80
+ if (pm) {
81
+ paginate = Number(pm[2]);
82
+ if (paginate < 1) bad();
83
+ s = (s.slice(0, pm.index) + s.slice((pm.index ?? 0) + pm[0].length))
84
+ .replace(/\s+/g, " ")
85
+ .trim();
86
+ }
87
+
72
88
  let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
73
89
  if (m) {
74
90
  limit = parseNumArg(m[2], ctx);
@@ -109,7 +125,7 @@ function parseLoopClauses(tail, itemName, ctx) {
109
125
  // predicate means a clause was written before `where` finished (wrong order).
110
126
  if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
111
127
  }
112
- return { where, sort, reverse, offset, limit, refsState, sortable };
128
+ return { where, sort, reverse, offset, limit, refsState, sortable, paginate };
113
129
  }
114
130
 
115
131
  /**
@@ -213,18 +229,32 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
213
229
  offset: null,
214
230
  limit: null,
215
231
  refsState: false,
216
- sortable: false
232
+ sortable: false,
233
+ paginate: null
217
234
  };
218
235
  const where = clauses.where ? compilePredicate(clauses.where, itemName, ctx) : null;
219
236
  // `sortable` reorders the list directly, so it can't combine with a clause that
220
237
  // re-derives the visible order (where/sort/reverse) or shows a partial view
221
- // (offset/limit) — those would break the 1:1 DOM↔array index mapping.
238
+ // (offset/limit/paginate) — those would break the 1:1 DOM↔array index mapping.
222
239
  if (
223
240
  clauses.sortable &&
224
- (where || clauses.sort || clauses.reverse || clauses.offset || clauses.limit)
241
+ (where ||
242
+ clauses.sort ||
243
+ clauses.reverse ||
244
+ clauses.offset ||
245
+ clauses.limit ||
246
+ clauses.paginate)
225
247
  ) {
226
248
  throw new Error(
227
- `@loop sortable cannot combine with where/sort/reverse/offset/limit in ${ctx.file}. ${LOOP_USAGE}`
249
+ `@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}`
250
+ );
251
+ }
252
+ // `paginate N` OWNS the page slice, so an explicit offset/limit alongside it is
253
+ // a conflict (which one wins?). Reject it with a clear hint rather than silently
254
+ // letting one override the other.
255
+ if (clauses.paginate && (clauses.offset || clauses.limit)) {
256
+ throw new Error(
257
+ `@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}`
228
258
  );
229
259
  }
230
260
  /** @type {LoopOpts} */
@@ -236,7 +266,8 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
236
266
  limit: clauses.limit,
237
267
  empty: emptyLines,
238
268
  clauseRefsState: clauses.refsState,
239
- sortable: clauses.sortable
269
+ sortable: clauses.sortable,
270
+ paginate: clauses.paginate
240
271
  };
241
272
 
242
273
  if (
@@ -245,6 +276,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
245
276
  source.startsWith("../") ||
246
277
  source.endsWith(".json")
247
278
  ) {
279
+ if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
248
280
  const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
249
281
  const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
250
282
  if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
@@ -253,6 +285,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
253
285
 
254
286
  const resolved = lookupPath(source, ctx);
255
287
  if (resolved.found) {
288
+ if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
256
289
  if (!Array.isArray(resolved.value)) {
257
290
  throw new Error(
258
291
  `@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`
@@ -269,9 +302,15 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
269
302
  }
270
303
  const key = resolveStateKey(segs[0], ctx);
271
304
  if (key) {
305
+ if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
272
306
  const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
273
307
  return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
274
308
  }
309
+ // A bare name matching a collection (any `site/pages/<name>/` subdir) resolves
310
+ // to its entry rows at build time and static-unrolls — zero JS. This is checked
311
+ // after state so a declared `:state` of the same name still wins.
312
+ const collection = segs.length === 1 ? ctx.comp.collections.get(source) : undefined;
313
+ if (collection) return loopOverCollection(collection, source, itemName, bodyLines, ctx, opts);
275
314
  // Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
276
315
  // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
277
316
  if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
@@ -280,15 +319,93 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
280
319
  `@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`
281
320
  );
282
321
  }
322
+ if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
283
323
  return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
284
324
  }
285
325
  }
286
326
 
287
- throw new Error(
288
- `@loop source "${source}" in ${ctx.file} was not found. Loop over a JSON file (@loop /data.json into row), an in-scope value, or a :state list.`
327
+ throw new Error(unresolvedSourceError(source, ctx));
328
+ }
329
+
330
+ /**
331
+ * The "source not found" error for an unresolved `@loop` bare name. Lists the
332
+ * valid collection names (the `site/pages/<name>/` subdirs) so a typo'd
333
+ * collection reference is immediately actionable.
334
+ * @param {string} source
335
+ * @param {Ctx} ctx
336
+ * @returns {string}
337
+ */
338
+ function unresolvedSourceError(source, ctx) {
339
+ const names = [...ctx.comp.collections.keys()].sort();
340
+ const collectionsHint = names.length ? ` Available collections: ${names.join(", ")}.` : "";
341
+ return (
342
+ `@loop source "${source}" in ${ctx.file} was not found. Loop over a collection ` +
343
+ `(a site/pages/<name>/ subdirectory, by its bare name), a JSON file ` +
344
+ `(@loop /data.json into row), an in-scope value, or a :state list.${collectionsHint}`
289
345
  );
290
346
  }
291
347
 
348
+ /**
349
+ * The error for `paginate` on a non-collection source: pagination multiplies
350
+ * static routes, which only makes sense for a build-time collection listing.
351
+ * @param {Ctx} ctx
352
+ * @returns {string}
353
+ */
354
+ function paginateOnlyCollections(ctx) {
355
+ return `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`;
356
+ }
357
+
358
+ /**
359
+ * Loop over a build-time collection's entry rows. With no `paginate`, this is a
360
+ * plain static unroll (filter/sort/reverse/offset/limit at build time, zero JS).
361
+ * With `paginate N`, it records the pagination intent (so the builder multiplies
362
+ * routes), then renders only the current page's slice using the injected `page`
363
+ * pager — `offset = (page.current - 1) * N`, `limit = N`.
364
+ * @param {import("./collections.js").CollectionRow[]} rows
365
+ * @param {string} name Collection name, for the pagination record.
366
+ * @param {string} itemName
367
+ * @param {string[]} bodyLines
368
+ * @param {Ctx} ctx
369
+ * @param {LoopOpts} opts
370
+ * @returns {string}
371
+ */
372
+ function loopOverCollection(rows, name, itemName, bodyLines, ctx, opts) {
373
+ if (!opts.paginate) {
374
+ // No pagination → behave exactly like a JSON-file/in-scope source: a pure
375
+ // listing static-unrolls (zero JS); a `where` that reads :state turns it into
376
+ // a reactive baked-rows loop. `loopOverData` makes that call.
377
+ return loopOverData(rows, itemName, bodyLines, ctx, opts);
378
+ }
379
+ // Count rows AFTER where/sort/reverse but BEFORE the page slice, so `total`
380
+ // reflects what the reader will actually page through. Reuse pipelineRows with
381
+ // offset/limit stripped for the count.
382
+ const filtered = pipelineRows(rows, opts.where, { ...opts, offset: null, limit: null }, ctx);
383
+ const perPage = opts.paginate;
384
+ const total = Math.max(1, Math.ceil(filtered.length / perPage));
385
+ ctx.comp.pagination = { perPage, total, collection: name };
386
+ // `page.current` is the 1-based page the builder is rendering (it seeds the
387
+ // document scope). Slice this page out of the already-filtered/sorted list.
388
+ const current = currentPage(ctx);
389
+ const start = (current - 1) * perPage;
390
+ return staticUnroll(filtered.slice(start, start + perPage), itemName, bodyLines, ctx, opts.empty);
391
+ }
392
+
393
+ /**
394
+ * The 1-based page the builder is currently rendering, read from the injected
395
+ * `page.current` scope var. Defaults to page 1 (the discovery compile, before the
396
+ * builder knows the total, runs with `page.current = 1`).
397
+ * @param {Ctx} ctx
398
+ * @returns {number}
399
+ */
400
+ function currentPage(ctx) {
401
+ const page = lookupVar(ctx.scope, "page");
402
+ if (page.found && page.value && typeof page.value === "object") {
403
+ const current = /** @type {Record<string, unknown>} */ (page.value).current;
404
+ if (typeof current === "number" && current >= 1) return current;
405
+ }
406
+ return 1;
407
+ }
408
+
292
409
  // A static source (JSON file / in-scope value). No `where`, or a `where` that
293
410
  // only reads the loop item → filter at build time and stay zero-JS. A `where`
294
411
  // that reads :state → becomes a reactive filtered loop with the rows baked in.
@@ -440,7 +557,8 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
440
557
  : limit,
441
558
  empty: null,
442
559
  clauseRefsState: false,
443
- sortable: false
560
+ sortable: false,
561
+ paginate: null
444
562
  },
445
563
  ctx
446
564
  );
@@ -12,7 +12,7 @@ import { htmlHasHighlight } from "../highlight.js";
12
12
  import { compileBody } from "./body.js";
13
13
  import { createCompilation, createScope } from "./context.js";
14
14
  import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
15
- import { collectColocatedAssets, scanMarkdownHints } from "./includes.js";
15
+ import { collectColocatedAssets, scanMarkdownHints, scopedSkinFor } from "./includes.js";
16
16
  import { escapeHtml } from "./interpolation.js";
17
17
  import { selectMd } from "./markdown.js";
18
18
 
@@ -30,13 +30,16 @@ import { selectMd } from "./markdown.js";
30
30
  * Compile a page source file into a full HTML document plus its assets.
31
31
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
32
32
  * @param {Paths} context Resolved project paths.
33
- * @param {{ feed?: { href: string, title: string } }} [options] When a site-wide
34
- * RSS feed is emitted (the home page set `site_url` and the site has dated
35
- * posts), `feed` carries its absolute href + title so every page links it.
33
+ * @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown> }} [options]
34
+ * When a site-wide RSS feed is emitted (the home page set `site_url` and the
35
+ * site has dated posts), `feed` carries its absolute href + title so every page
36
+ * links it. `collections` is the build-time collection index a bare-name
37
+ * `@loop` resolves against; `vars` seeds the document scope (the pager `page`
38
+ * object on a paginated route).
36
39
  * @returns {CompiledPage}
37
40
  */
38
41
  export function compilePage(file, context, options = {}) {
39
- const compiled = compileDocument(file, context);
42
+ const compiled = compileDocument(file, context, [], options.vars, options.collections);
40
43
  const title = compiled.meta.title || "Darkmown";
41
44
  const description = compiled.meta.description || "";
42
45
  // Optional `image:` frontmatter sets the social-share preview (absolute URL).
@@ -132,7 +135,14 @@ export function compilePage(file, context, options = {}) {
132
135
  ? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
133
136
  : "";
134
137
 
135
- const body = enhanceImages(compiled.html, context);
138
+ // A route whose OWN colocated skin opted into scoping stamps the whole page
139
+ // body so its scoped selectors match. Pure compile-time string post-pass (like
140
+ // enhanceImages) — no runtime, no `data-wd-*` marker, so a static page stays
141
+ // static. Include-scoped skins are stamped earlier, in handleInclude.
142
+ const scopedSkin = scopedSkinFor(file, context);
143
+ const stamped =
144
+ scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
145
+ const body = enhanceImages(stamped, context);
136
146
 
137
147
  return {
138
148
  meta: compiled.meta,
@@ -151,7 +161,8 @@ ${scripts}
151
161
  </body>
152
162
  </html>`,
153
163
  assets: compiled.assets,
154
- warnings: compiled.warnings
164
+ warnings: compiled.warnings,
165
+ pagination: compiled.pagination
155
166
  };
156
167
  }
157
168
 
@@ -192,6 +203,70 @@ export function enhanceImages(html, paths) {
192
203
  });
193
204
  }
194
205
 
206
+ /**
207
+ * Raw-text (`script`, `style`) and escapable-raw-text (`textarea`, `title`)
208
+ * elements per the HTML spec: their CONTENT is not markup, so `<`/`>` inside them
209
+ * are plain characters and must never be treated as tags. The content runs to the
210
+ * first matching close tag.
211
+ * @type {Set<string>}
212
+ */
213
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
214
+
215
+ /**
216
+ * Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
217
+ * half of compile-time scoped styles, mirroring {@link enhanceImages}: a
218
+ * compile-time post-pass over assembled HTML, zero runtime JS. Every element
219
+ * start tag that doesn't already carry a scope attribute gets one, so the scoped
220
+ * stylesheet (whose selectors end in `[data-wd-scope="<id>"]`) matches inside
221
+ * this subtree and nowhere else. Void/self-closing tags are stamped too (the
222
+ * attribute is valid on any element). Closing tags, comments, doctype, and the
223
+ * `<!`/`<?` families are left alone. A tag that ALREADY has a `data-wd-scope`
224
+ * (e.g. a nested include with its own scope) keeps its own — first stamp wins.
225
+ *
226
+ * Raw-text / escapable-raw-text elements (`script`, `style`, `textarea`,
227
+ * `title`) are special-cased: their OPENING tag is stamped (valid), but their
228
+ * BODY is left byte-intact — `<`/`>` inside a `<script>` are JavaScript, not
229
+ * markup, so stamping there would corrupt the content. A small tokenizer walks
230
+ * tag-by-tag (quote-aware in attribute lists) and, on entering a raw-text
231
+ * element, copies through verbatim until the matching close tag.
232
+ * @param {string} html Subtree HTML to scope.
233
+ * @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
234
+ * @returns {string}
235
+ */
236
+ export function stampScope(html, scopeId) {
237
+ // Opening tag: name + (quote-aware) attribute list + optional self-close slash.
238
+ const openTag = /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
239
+ let out = "";
240
+ let cursor = 0;
241
+ for (let m = openTag.exec(html); m; m = openTag.exec(html)) {
242
+ const [tag, name, attrs, slash] = m;
243
+ out += html.slice(cursor, m.index); // text/markup before this opening tag
244
+ const stamped = /\bdata-wd-scope\s*=/.test(attrs)
245
+ ? tag // first stamp wins — a pre-stamped tag keeps its own scope
246
+ : `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
247
+ out += stamped;
248
+ cursor = m.index + tag.length;
249
+
250
+ // Raw-text element (not self-closed): copy its body through untouched and
251
+ // resume scanning after the matching close tag, so nothing inside is stamped.
252
+ if (!slash && RAW_TEXT_ELEMENTS.has(name.toLowerCase())) {
253
+ const rest = html.slice(cursor);
254
+ const closeMatch = new RegExp(`</${name}\\s*>`, "i").exec(rest);
255
+ if (!closeMatch) {
256
+ out += rest; // unterminated raw-text element — copy the remainder verbatim
257
+ cursor = html.length;
258
+ break;
259
+ }
260
+ const through = closeMatch.index + closeMatch[0].length; // body + close tag
261
+ out += rest.slice(0, through); // byte-intact
262
+ cursor += through;
263
+ openTag.lastIndex = cursor; // skip the body the scanner would otherwise re-walk
264
+ }
265
+ }
266
+ out += html.slice(cursor); // trailing text after the last opening tag
267
+ return out;
268
+ }
269
+
195
270
  /**
196
271
  * Extract the `src` attribute value from an `<img>` tag, or "" if absent.
197
272
  * @param {string} tag
@@ -235,12 +310,21 @@ function measureImage(src, paths) {
235
310
  * @param {Paths} context Resolved project paths.
236
311
  * @param {string[]} [stack] Include stack for cycle detection.
237
312
  * @param {Record<string, unknown>} [vars] Initial static scope variables.
313
+ * @param {Map<string, import("./collections.js").CollectionRow[]>} [collections]
314
+ * Build-time collection index a bare-name `@loop` resolves against.
238
315
  * @returns {CompiledDocument}
239
316
  */
240
- export function compileDocument(file, context, stack = [], vars = {}) {
317
+ export function compileDocument(file, context, stack = [], vars = {}, collections = new Map()) {
241
318
  const comp = createCompilation();
319
+ comp.collections = collections;
242
320
  const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
243
- return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
321
+ return {
322
+ meta: result.meta,
323
+ html: result.html,
324
+ assets: comp.assets,
325
+ warnings: comp.warnings,
326
+ pagination: comp.pagination
327
+ };
244
328
  }
245
329
 
246
330
  /**
package/src/compiler.js CHANGED
@@ -22,5 +22,6 @@ export {
22
22
  enhanceImages,
23
23
  escapeHtml,
24
24
  loopKeyOf,
25
- parseFrontmatter
25
+ parseFrontmatter,
26
+ stampScope
26
27
  } from "./compiler/index.js";
package/src/skin.js CHANGED
@@ -1,3 +1,5 @@
1
+ import crypto from "node:crypto";
2
+
1
3
  const aliases = new Map([
2
4
  ["radius", "border-radius"],
3
5
  ["shadow", "box-shadow"],
@@ -19,6 +21,29 @@ function resolveProp(prop, rest) {
19
21
  return aliases.get(prop) || prop;
20
22
  }
21
23
 
24
+ /**
25
+ * Derive a short, deterministic scope id from a skin's PROJECT-RELATIVE PATH.
26
+ * Path-based (stable across content edits, unique per component file) and short
27
+ * enough to read in the emitted CSS (`wd-7c21`). The same path always yields the
28
+ * same id, so the CSS rewrite (builder) and the HTML stamp (page/include) agree
29
+ * without sharing state — they just hash the same relative path. The path is
30
+ * normalized to POSIX separators first so a build is identical on Windows.
31
+ * @param {string} relPath Skin path relative to the project root (cwd).
32
+ * @returns {string} The scope id, e.g. `wd-7c21`.
33
+ */
34
+ export function scopeIdFor(relPath) {
35
+ const posix = relPath.replaceAll("\\", "/");
36
+ const hash = crypto.createHash("sha256").update(posix).digest("hex");
37
+ return `wd-${hash.slice(0, 4)}`;
38
+ }
39
+
40
+ // Selectors that style the whole page, not a component subtree. In a `scoped`
41
+ // skin they are a mistake: scoping appends an attribute to the *subject* element,
42
+ // so `page`/`body`/`html`/`*`/`::selection` would either become a no-op (no
43
+ // element in the subtree carries the attribute on <body>/<html>) or silently
44
+ // fail to do what the author means. We error and point them at a global skin.
45
+ const PAGE_LEVEL_SELECTORS = new Set(["page", "*", "html", "body", "::selection"]);
46
+
22
47
  /**
23
48
  * A nesting frame in the indentation stack.
24
49
  * @typedef {object} SkinFrame
@@ -29,10 +54,25 @@ function resolveProp(prop, rest) {
29
54
 
30
55
  /**
31
56
  * Compile indentation-based `.skin` source into CSS.
57
+ *
58
+ * Scoping (opt-in): when `opts.scope` is set the skin is compiled in scoped
59
+ * mode — every selector RULE gets `[data-wd-scope="<id>"]` appended to the
60
+ * subject of each compound selector, so the stylesheet only ever matches inside
61
+ * the stamped subtree. Design tokens (`tokens` / `tokens dark` / `tokens [attr]`)
62
+ * stay GLOBAL on `:root` regardless, so `var(--x)` and dark mode keep working
63
+ * site-wide. A whole-selector `:global(.x)` escapes scoping. Page-level selectors
64
+ * (`page`/`*`/`html`/`body`/`::selection`) are a compile error in scoped mode.
65
+ * Without `opts.scope`, output is byte-identical to before this feature existed.
32
66
  * @param {string} source
67
+ * @param {{ scope?: string, subjects?: Set<string> }} [opts] `scope`: the scope
68
+ * id enabling scoped mode. `subjects`: an optional set the compiler fills with
69
+ * every scoped subject token (`.card`, `h2`, `#id`) for the unused-selector
70
+ * warning, so the caller need not re-parse the source.
33
71
  * @returns {string}
34
72
  */
35
- export function compileSkin(source) {
73
+ export function compileSkin(source, opts = {}) {
74
+ const scope = opts.scope;
75
+ const subjects = opts.subjects;
36
76
  const lines = source
37
77
  .replace(/\r\n?/g, "\n")
38
78
  .replace(/\/\*[\s\S]*?\*\//g, "") // strip /* … */ block comments (any span)
@@ -47,6 +87,19 @@ export function compileSkin(source) {
47
87
  })
48
88
  .map((raw) => ({ indent: (raw.match(/^\s*/)?.[0] ?? "").length, text: raw.trim() }));
49
89
 
90
+ // The opt-in marker: `scoped` must be the FIRST meaningful line (the filter
91
+ // above has already dropped comments, blanks, and dividers). Anywhere else it
92
+ // is an error — a `scoped` mid-file means the author misread the contract.
93
+ let start = 0;
94
+ if (scope && lines.length && lines[0].text === "scoped" && lines[0].indent === 0) {
95
+ start = 1;
96
+ }
97
+ for (let i = start; i < lines.length; i++) {
98
+ if (lines[i].text === "scoped" && lines[i].indent === 0) {
99
+ throw new Error('"scoped" must be the first line of a .skin file');
100
+ }
101
+ }
102
+
50
103
  /** @type {SkinFrame[]} */
51
104
  const stack = [];
52
105
  /** @type {string[]} */
@@ -65,7 +118,7 @@ export function compileSkin(source) {
65
118
  /** @type {Map<string, string[]>} */
66
119
  const attrVars = new Map();
67
120
 
68
- for (let i = 0; i < lines.length; i++) {
121
+ for (let i = start; i < lines.length; i++) {
69
122
  const { indent, text } = lines[i];
70
123
  // Structure decides meaning: a line opens a block (selector) exactly when
71
124
  // the next line is indented deeper. Everything else is a declaration.
@@ -119,17 +172,28 @@ export function compileSkin(source) {
119
172
  if (text.startsWith("@")) {
120
173
  stack.push({ indent, selector: parent ?? null, media: text });
121
174
  } else {
175
+ if (scope) guardPageLevel(text, parent);
122
176
  stack.push({ indent, selector: normalizeSelector(text, parent) });
123
177
  }
124
178
  continue;
125
179
  }
126
180
 
127
181
  const current = stack.at(-1)?.selector || ":root";
182
+ // A leaf with no enclosing selector lands on `:root` — a page-level write. In
183
+ // a scoped skin that is the same mistake `guardPageLevel` rejects (and the
184
+ // place a filtered bare `*`/`page` reset would otherwise leak through to), so
185
+ // it errors with the same hint instead of emitting a never-matching rule.
186
+ if (scope && current === ":root") {
187
+ throw new Error(
188
+ `page-level declaration "${text}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one`
189
+ );
190
+ }
128
191
  const media = stack.find((frame) => frame.media)?.media;
129
192
  const [prop, ...rest] = text.split(/\s+/);
130
193
  const cssProp = resolveProp(prop, rest);
131
194
  const value = rest.join(" ").replace(/\$([a-zA-Z0-9_-]+)/g, "var(--$1)");
132
- const rule = `${current} { ${cssProp}: ${value}; }`;
195
+ const subject = scope ? scopeSelector(current, scope, subjects) : current;
196
+ const rule = `${subject} { ${cssProp}: ${value}; }`;
133
197
  css.push(media ? `${media} { ${rule} }` : rule);
134
198
  }
135
199
 
@@ -153,6 +217,86 @@ export function compileSkin(source) {
153
217
  return css.join("\n");
154
218
  }
155
219
 
220
+ /**
221
+ * Reject page-level selectors in a scoped skin. Checks the AUTHORED selector
222
+ * text (before `page`→`body` rewrite) so `page` is caught by name. Each
223
+ * comma-separated part is inspected; a bare top-level selector (no parent) that
224
+ * is one of the page-level set, or a top-level `::selection`, errors with a hint
225
+ * pointing the author at a global skin.
226
+ * @param {string} text Raw selector line.
227
+ * @param {string | null | undefined} parent Enclosing selector, if nested.
228
+ * @returns {void}
229
+ */
230
+ function guardPageLevel(text, parent) {
231
+ // Only TOP-LEVEL selectors are page-level mistakes — `.card *` (a descendant
232
+ // wildcard inside a component) is fine, so the check applies when there is no
233
+ // parent selector chain (`:root` is the implicit root, treated as none).
234
+ if (parent && parent !== ":root") return;
235
+ for (const part of text.split(",")) {
236
+ const clean = part.trim();
237
+ if (PAGE_LEVEL_SELECTORS.has(clean)) {
238
+ throw new Error(
239
+ `page-level selector "${clean}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one`
240
+ );
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Append the scope attribute to a resolved selector list (post `&`-nesting).
247
+ * Each comma-separated compound selector gets `[data-wd-scope="<id>"]` on its
248
+ * SUBJECT (rightmost simple selector), inserted BEFORE any trailing
249
+ * pseudo-class/element so the result stays valid CSS (`.card[…]:hover`, never
250
+ * `.card:hover[…]`). A whole-selector `:global(.x)` unwraps to a plain `.x` with
251
+ * NO attribute — the documented escape hatch (whole-selector only this release).
252
+ * @param {string} selectorList Resolved selector list (may contain commas).
253
+ * @param {string} scope Scope id.
254
+ * @param {Set<string>} [subjects] Optional sink for subject tokens (warning).
255
+ * @returns {string}
256
+ */
257
+ function scopeSelector(selectorList, scope, subjects) {
258
+ return selectorList
259
+ .split(",")
260
+ .map((part) => {
261
+ const clean = part.trim();
262
+ // Whole-selector `:global(.toast)` → `.toast`, unscoped. Descendant
263
+ // `:global` (`.card :global(.x)`) is intentionally NOT supported yet.
264
+ const global = clean.match(/^:global\(\s*(.+?)\s*\)$/);
265
+ if (global) return global[1];
266
+ // The subject is the last whitespace-delimited compound. Combinators
267
+ // (`>`, `+`, `~`) stand as their own tokens, so the subject is whatever
268
+ // follows the final combinator/descendant gap.
269
+ const tokens = clean.split(/\s+/);
270
+ const subjectIndex = tokens.length - 1;
271
+ const lead = tokens.slice(0, subjectIndex);
272
+ const subject = tokens[subjectIndex];
273
+ // Split the subject into its base (tag/class/id, plus attribute selectors)
274
+ // and any trailing pseudo `:hover` / `::before`. The attribute slots in at
275
+ // the base/pseudo seam: `.card::before` → `.card[scope]::before`.
276
+ const seam = subject.search(/::?[a-zA-Z-]/);
277
+ const base = seam === -1 ? subject : subject.slice(0, seam);
278
+ const pseudo = seam === -1 ? "" : subject.slice(seam);
279
+ if (subjects) subjects.add(subjectBase(base));
280
+ const scoped = `${base}[data-wd-scope="${scope}"]${pseudo}`;
281
+ return [...lead, scoped].join(" ");
282
+ })
283
+ .join(", ");
284
+ }
285
+
286
+ /**
287
+ * Reduce a scoped subject base to the single token the unused-selector warning
288
+ * checks against the stamped subtree: the leading `.class`, `#id`, or bare tag,
289
+ * dropping any chained attribute selectors (`a[href]` → `a`). Returns `""` for a
290
+ * subject that has no checkable token (a bare attribute selector), which the
291
+ * caller treats as "always present" (never warns).
292
+ * @param {string} base
293
+ * @returns {string}
294
+ */
295
+ function subjectBase(base) {
296
+ const m = base.match(/^([.#]?[A-Za-z_][\w-]*)/);
297
+ return m ? m[1] : "";
298
+ }
299
+
156
300
  /**
157
301
  * Normalize a bracketed token modifier into a valid attribute selector,
158
302
  * quoting the value so `[data-theme=dark]` becomes `[data-theme="dark"]`.