@zvndev/darkmown 1.4.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/CHANGELOG.md +10 -0
- package/README.md +70 -1
- package/docs/spec-alignment.md +14 -0
- package/package.json +1 -1
- package/site/_/base.skin +231 -18
- package/site/_/nav.wd +1 -1
- 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 +47 -0
- package/site/pages/loops.skin +43 -6
- package/site/pages/reactive.skin +38 -6
- package/site/pages/store.skin +43 -6
- package/src/builder.js +137 -29
- package/src/compiler/collections.js +316 -0
- package/src/compiler/context.js +28 -1
- package/src/compiler/loops.js +130 -12
- package/src/compiler/page.js +74 -21
- 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
|
@@ -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).
|
|
@@ -158,7 +161,8 @@ ${scripts}
|
|
|
158
161
|
</body>
|
|
159
162
|
</html>`,
|
|
160
163
|
assets: compiled.assets,
|
|
161
|
-
warnings: compiled.warnings
|
|
164
|
+
warnings: compiled.warnings,
|
|
165
|
+
pagination: compiled.pagination
|
|
162
166
|
};
|
|
163
167
|
}
|
|
164
168
|
|
|
@@ -199,28 +203,68 @@ export function enhanceImages(html, paths) {
|
|
|
199
203
|
});
|
|
200
204
|
}
|
|
201
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
|
+
|
|
202
215
|
/**
|
|
203
216
|
* Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
|
|
204
|
-
* half of compile-time scoped styles, mirroring {@link enhanceImages}: a
|
|
205
|
-
* post-pass over assembled HTML, zero runtime JS. Every element
|
|
206
|
-
* doesn't already carry a scope attribute gets one, so the scoped
|
|
207
|
-
* (whose selectors end in `[data-wd-scope="<id>"]`) matches inside
|
|
208
|
-
* and nowhere else. Void/self-closing tags are stamped too (the
|
|
209
|
-
* valid on any element). Closing tags, comments, doctype, and the
|
|
210
|
-
* families are left alone. A tag that ALREADY has a `data-wd-scope`
|
|
211
|
-
* nested include with its own scope) keeps its own — first stamp wins.
|
|
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.
|
|
212
232
|
* @param {string} html Subtree HTML to scope.
|
|
213
233
|
* @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
|
|
214
234
|
* @returns {string}
|
|
215
235
|
*/
|
|
216
236
|
export function stampScope(html, scopeId) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
222
264
|
}
|
|
223
|
-
|
|
265
|
+
}
|
|
266
|
+
out += html.slice(cursor); // trailing text after the last opening tag
|
|
267
|
+
return out;
|
|
224
268
|
}
|
|
225
269
|
|
|
226
270
|
/**
|
|
@@ -266,12 +310,21 @@ function measureImage(src, paths) {
|
|
|
266
310
|
* @param {Paths} context Resolved project paths.
|
|
267
311
|
* @param {string[]} [stack] Include stack for cycle detection.
|
|
268
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.
|
|
269
315
|
* @returns {CompiledDocument}
|
|
270
316
|
*/
|
|
271
|
-
export function compileDocument(file, context, stack = [], vars = {}) {
|
|
317
|
+
export function compileDocument(file, context, stack = [], vars = {}, collections = new Map()) {
|
|
272
318
|
const comp = createCompilation();
|
|
319
|
+
comp.collections = collections;
|
|
273
320
|
const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
|
|
274
|
-
return {
|
|
321
|
+
return {
|
|
322
|
+
meta: result.meta,
|
|
323
|
+
html: result.html,
|
|
324
|
+
assets: comp.assets,
|
|
325
|
+
warnings: comp.warnings,
|
|
326
|
+
pagination: comp.pagination
|
|
327
|
+
};
|
|
275
328
|
}
|
|
276
329
|
|
|
277
330
|
/**
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
tokens
|
|
2
2
|
ink #1d2530
|
|
3
3
|
paper #fbfaf7
|
|
4
|
+
panel #ffffff
|
|
5
|
+
line #e7e3da
|
|
4
6
|
muted #6b7785
|
|
5
7
|
accent #2f6df0
|
|
8
|
+
accent-deep #2356cc
|
|
9
|
+
ring #2f6df0
|
|
10
|
+
radius 10px
|
|
11
|
+
|
|
12
|
+
tokens dark
|
|
13
|
+
ink #e7ecf3
|
|
14
|
+
paper #0f141b
|
|
15
|
+
panel #161d27
|
|
16
|
+
line #28303c
|
|
17
|
+
muted #93a0b2
|
|
18
|
+
accent #5b8cff
|
|
19
|
+
accent-deep #80a6ff
|
|
20
|
+
ring #5b8cff
|
|
6
21
|
|
|
7
22
|
page
|
|
8
23
|
margin 0
|
|
@@ -10,6 +25,7 @@ page
|
|
|
10
25
|
color $ink
|
|
11
26
|
bg $paper
|
|
12
27
|
line-height 1.6
|
|
28
|
+
-webkit-font-smoothing antialiased
|
|
13
29
|
|
|
14
30
|
main
|
|
15
31
|
max-width 720px
|
|
@@ -49,3 +65,71 @@ nav a
|
|
|
49
65
|
margin 0 0 .5rem
|
|
50
66
|
color $muted
|
|
51
67
|
font-size .9rem
|
|
68
|
+
|
|
69
|
+
/* Buttons & form controls — for newsletter signups, :button actions, etc. */
|
|
70
|
+
button
|
|
71
|
+
display inline-flex
|
|
72
|
+
align-items center
|
|
73
|
+
justify-content center
|
|
74
|
+
gap .5rem
|
|
75
|
+
bg $accent
|
|
76
|
+
color white
|
|
77
|
+
border 1px solid $accent
|
|
78
|
+
padding .6rem 1.15rem
|
|
79
|
+
margin .4rem .5rem .4rem 0
|
|
80
|
+
radius 999px
|
|
81
|
+
font-size .94rem
|
|
82
|
+
font-weight 560
|
|
83
|
+
line-height 1.15
|
|
84
|
+
cursor pointer
|
|
85
|
+
appearance none
|
|
86
|
+
box-shadow 0 1px 2px rgba(29,37,48,.08)
|
|
87
|
+
transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease, background .14s ease, border-color .14s ease
|
|
88
|
+
|
|
89
|
+
button:hover
|
|
90
|
+
bg $accent-deep
|
|
91
|
+
border-color $accent-deep
|
|
92
|
+
transform translateY(-1px)
|
|
93
|
+
box-shadow 0 8px 18px -8px rgba(47,109,240,.55)
|
|
94
|
+
|
|
95
|
+
button:active
|
|
96
|
+
transform translateY(0)
|
|
97
|
+
box-shadow 0 1px 2px rgba(29,37,48,.08)
|
|
98
|
+
|
|
99
|
+
button:focus-visible
|
|
100
|
+
outline 2px solid $ring
|
|
101
|
+
outline-offset 2px
|
|
102
|
+
|
|
103
|
+
button:disabled
|
|
104
|
+
opacity .5
|
|
105
|
+
cursor not-allowed
|
|
106
|
+
box-shadow none
|
|
107
|
+
transform none
|
|
108
|
+
|
|
109
|
+
input, textarea, select
|
|
110
|
+
font inherit
|
|
111
|
+
font-size .95rem
|
|
112
|
+
color $ink
|
|
113
|
+
bg $panel
|
|
114
|
+
border 1px solid $line
|
|
115
|
+
radius $radius
|
|
116
|
+
padding .58rem .8rem
|
|
117
|
+
margin .35rem 0
|
|
118
|
+
width 100%
|
|
119
|
+
max-width 24rem
|
|
120
|
+
appearance none
|
|
121
|
+
transition border-color .14s ease, box-shadow .14s ease
|
|
122
|
+
|
|
123
|
+
input:hover, textarea:hover, select:hover
|
|
124
|
+
border-color $accent
|
|
125
|
+
|
|
126
|
+
input:focus-visible, textarea:focus-visible, select:focus-visible
|
|
127
|
+
outline none
|
|
128
|
+
border-color $accent
|
|
129
|
+
box-shadow 0 0 0 3px rgba(47,109,240,.18)
|
|
130
|
+
|
|
131
|
+
@media (prefers-reduced-motion: reduce)
|
|
132
|
+
button
|
|
133
|
+
transition none
|
|
134
|
+
button:hover
|
|
135
|
+
transform none
|
|
@@ -2,9 +2,13 @@ tokens
|
|
|
2
2
|
ink #0f1722
|
|
3
3
|
paper #0b1018
|
|
4
4
|
panel #131c28
|
|
5
|
+
field-bg #0e1620
|
|
5
6
|
line #243245
|
|
6
7
|
muted #8aa0b8
|
|
7
8
|
accent #4f9cff
|
|
9
|
+
accent-deep #6fb0ff
|
|
10
|
+
ring #4f9cff
|
|
11
|
+
radius 12px
|
|
8
12
|
|
|
9
13
|
page
|
|
10
14
|
margin 0
|
|
@@ -12,6 +16,7 @@ page
|
|
|
12
16
|
color #e7eefc
|
|
13
17
|
bg $paper
|
|
14
18
|
line-height 1.55
|
|
19
|
+
-webkit-font-smoothing antialiased
|
|
15
20
|
|
|
16
21
|
main
|
|
17
22
|
max-width 880px
|
|
@@ -72,3 +77,82 @@ nav a
|
|
|
72
77
|
color $muted
|
|
73
78
|
font-size .85rem
|
|
74
79
|
margin-top .5rem
|
|
80
|
+
|
|
81
|
+
/* Buttons & controls — refresh, filters, range pickers, etc. */
|
|
82
|
+
button
|
|
83
|
+
display inline-flex
|
|
84
|
+
align-items center
|
|
85
|
+
justify-content center
|
|
86
|
+
gap .5rem
|
|
87
|
+
bg $accent
|
|
88
|
+
color #07101d
|
|
89
|
+
border 1px solid $accent
|
|
90
|
+
padding .55rem 1.05rem
|
|
91
|
+
margin .4rem .5rem .4rem 0
|
|
92
|
+
radius 999px
|
|
93
|
+
font-size .92rem
|
|
94
|
+
font-weight 600
|
|
95
|
+
line-height 1.15
|
|
96
|
+
cursor pointer
|
|
97
|
+
appearance none
|
|
98
|
+
transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease, background .14s ease, border-color .14s ease
|
|
99
|
+
|
|
100
|
+
button:hover
|
|
101
|
+
bg $accent-deep
|
|
102
|
+
border-color $accent-deep
|
|
103
|
+
transform translateY(-1px)
|
|
104
|
+
box-shadow 0 8px 20px -8px rgba(79,156,255,.6)
|
|
105
|
+
|
|
106
|
+
button:active
|
|
107
|
+
transform translateY(0)
|
|
108
|
+
box-shadow none
|
|
109
|
+
|
|
110
|
+
button:focus-visible
|
|
111
|
+
outline 2px solid $ring
|
|
112
|
+
outline-offset 2px
|
|
113
|
+
|
|
114
|
+
button:disabled
|
|
115
|
+
opacity .5
|
|
116
|
+
cursor not-allowed
|
|
117
|
+
transform none
|
|
118
|
+
|
|
119
|
+
button.ghost
|
|
120
|
+
bg transparent
|
|
121
|
+
color #e7eefc
|
|
122
|
+
border-color $line
|
|
123
|
+
|
|
124
|
+
button.ghost:hover
|
|
125
|
+
bg $panel
|
|
126
|
+
border-color $accent
|
|
127
|
+
box-shadow none
|
|
128
|
+
|
|
129
|
+
input, select, textarea
|
|
130
|
+
font inherit
|
|
131
|
+
font-size .95rem
|
|
132
|
+
color #e7eefc
|
|
133
|
+
bg $field-bg
|
|
134
|
+
border 1px solid $line
|
|
135
|
+
radius 10px
|
|
136
|
+
padding .55rem .8rem
|
|
137
|
+
margin .35rem 0
|
|
138
|
+
width 100%
|
|
139
|
+
max-width 24rem
|
|
140
|
+
appearance none
|
|
141
|
+
transition border-color .14s ease, box-shadow .14s ease
|
|
142
|
+
|
|
143
|
+
input::placeholder, textarea::placeholder
|
|
144
|
+
color $muted
|
|
145
|
+
|
|
146
|
+
input:hover, select:hover, textarea:hover
|
|
147
|
+
border-color #2f4763
|
|
148
|
+
|
|
149
|
+
input:focus-visible, select:focus-visible, textarea:focus-visible
|
|
150
|
+
outline none
|
|
151
|
+
border-color $accent
|
|
152
|
+
box-shadow 0 0 0 3px rgba(79,156,255,.22)
|
|
153
|
+
|
|
154
|
+
@media (prefers-reduced-motion: reduce)
|
|
155
|
+
button
|
|
156
|
+
transition none
|
|
157
|
+
button:hover
|
|
158
|
+
transform none
|