@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.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +19 -0
- package/README.md +114 -1
- package/docs/spec-alignment.md +30 -0
- package/package.json +1 -1
- package/site/_/base.skin +231 -18
- package/site/_/nav.wd +1 -1
- package/site/_/scoped-promo.skin +43 -0
- package/site/_/scoped-promo.wd +10 -0
- package/site/_/scoped-stat.skin +32 -0
- package/site/_/scoped-stat.wd +9 -0
- package/site/pages/app.skin +27 -5
- package/site/pages/blog/_schema.wd +15 -0
- package/site/pages/blog/collections-and-pagination.md +38 -0
- package/site/pages/blog/drafts-that-never-leak.md +2 -0
- package/site/pages/blog/index.skin +144 -0
- package/site/pages/blog/index.wd +73 -0
- package/site/pages/blog/shipping-feeds.md +1 -0
- package/site/pages/blog/typed-frontmatter.md +45 -0
- package/site/pages/blog/zero-js-by-default.md +31 -0
- package/site/pages/data.skin +50 -9
- package/site/pages/docs/index.wd +70 -0
- package/site/pages/loops.skin +43 -6
- package/site/pages/reactive.skin +38 -6
- package/site/pages/scoped/index.skin +23 -0
- package/site/pages/scoped/index.wd +47 -0
- package/site/pages/store.skin +43 -6
- package/src/builder.js +213 -32
- package/src/compiler/collections.js +316 -0
- package/src/compiler/context.js +34 -2
- package/src/compiler/directives.js +11 -5
- package/src/compiler/includes.js +48 -1
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +130 -12
- package/src/compiler/page.js +93 -9
- package/src/compiler.js +2 -1
- package/src/skin.js +147 -3
- package/src/templates/blog/site/pages/index.skin +84 -0
- package/src/templates/dashboard/site/pages/index.skin +84 -0
- package/src/templates/landing/site/pages/index.skin +113 -6
- package/src/templates/starter/site/pages/index.skin +81 -3
- package/src/templates/store/site/pages/index.skin +93 -11
- package/site/pages/blog/index.md +0 -13
package/src/compiler/loops.js
CHANGED
|
@@ -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 ||
|
|
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
|
-
|
|
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
|
);
|
package/src/compiler/page.js
CHANGED
|
@@ -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]
|
|
34
|
-
* RSS feed is emitted (the home page set `site_url` and the
|
|
35
|
-
* posts), `feed` carries its absolute href + title so every page
|
|
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
|
-
|
|
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 {
|
|
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
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 =
|
|
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
|
|
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"]`.
|