@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
@@ -0,0 +1,316 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Typed content collections. ANY `site/pages/<name>/` subdirectory is a
3
+ // queryable collection, referenced by its bare name in `@loop` (no `content/`
4
+ // root, no marker file). The index is built from the router's already-parsed
5
+ // `route.meta` — frontmatter is never re-parsed here — and each entry becomes a
6
+ // row the existing where/sort/format-pipe/`:if`/`:every` machinery consumes
7
+ // UNCHANGED: every frontmatter field plus the derived `url` (entry route),
8
+ // `slug` (filename stem), and an `excerpt` (frontmatter `excerpt:` → first
9
+ // paragraph of a `.md` body).
10
+ //
11
+ // An optional `_schema.wd` at the collection root opts the collection into
12
+ // build-time validation: every entry's frontmatter is checked against a small,
13
+ // closed type DSL (`field: string|number|boolean|date|string[]`, `?` = optional)
14
+ // with `file:line` errors. No `_schema.wd` = no validation (opt-in, like
15
+ // `scoped`/`draft`).
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { firstParagraph } from "../feeds.js";
21
+ import { parseFrontmatter } from "./frontmatter.js";
22
+
23
+ /**
24
+ * @typedef {import("./context.js").Paths} Paths
25
+ * @typedef {import("./context.js").Meta} Meta
26
+ * @typedef {import("../router.js").Route} Route
27
+ */
28
+
29
+ /**
30
+ * A collection entry row: every frontmatter field, plus derived `url`/`slug`/
31
+ * `excerpt`. Keyed loosely so authors' custom frontmatter keys pass through to
32
+ * `{ post.whatever }` and `where`/`sort` without a closed schema here.
33
+ * @typedef {Record<string, unknown> & { url: string, slug: string, excerpt: string }} CollectionRow
34
+ */
35
+
36
+ /** The closed type vocabulary for `_schema.wd`. `?` (optional) is a modifier. */
37
+ const SCHEMA_TYPES = new Set(["string", "number", "boolean", "date", "string[]"]);
38
+
39
+ /**
40
+ * Build the collection index from the router's discovered routes: one entry per
41
+ * `site/pages/<name>/…` route, grouped under the FIRST path segment (the
42
+ * collection name). The home route, top-level pages, and the synthetic `/404/`
43
+ * route are not entries. Reuses each route's already-parsed `meta` (no re-parse)
44
+ * and reads the `.md` body only for the excerpt fallback. When a collection has
45
+ * a `_schema.wd`, every entry is validated against it here (throwing on the
46
+ * first violation), so a malformed entry fails the build with a `file:line`.
47
+ *
48
+ * @param {Route[]} routes Post-draft-filter routes from `discoverRoutes`.
49
+ * @param {Paths} paths Resolved project paths (`routesRoot` for `_schema.wd`).
50
+ * @returns {Map<string, CollectionRow[]>} Collection name → entry rows.
51
+ */
52
+ export function buildCollections(routes, paths) {
53
+ /** @type {Map<string, CollectionRow[]>} */
54
+ const collections = new Map();
55
+ for (const route of routes) {
56
+ const name = collectionNameOf(route.route);
57
+ if (!name) continue;
58
+ const rows = collections.get(name) || [];
59
+ rows.push(rowFor(route));
60
+ collections.set(name, rows);
61
+ }
62
+ for (const [name, rows] of collections) {
63
+ const schema = readSchema(name, paths);
64
+ if (schema) validateRows(rows, schema, name);
65
+ }
66
+ return collections;
67
+ }
68
+
69
+ /**
70
+ * The collection name a route belongs to: the first path segment of a nested
71
+ * route (`/blog/hello/` → `blog`), or null for the home page, a top-level page
72
+ * (`/about/` → no nesting), or the `/404/` route (never a collection entry).
73
+ * @param {string} route Public route path (trailing-slashed).
74
+ * @returns {string | null}
75
+ */
76
+ function collectionNameOf(route) {
77
+ if (route === "/" || route === "/404/") return null;
78
+ const parts = route.replace(/^\/|\/$/g, "").split("/");
79
+ return parts.length >= 2 ? parts[0] : null;
80
+ }
81
+
82
+ /**
83
+ * Build one collection row from a route: spread its frontmatter, then add the
84
+ * derived `url` (the route path), `slug` (filename stem), and `excerpt`
85
+ * (frontmatter `excerpt:` wins; else the first paragraph of a `.md` body; `.wd`
86
+ * bodies are not scanned — directive markup isn't clean prose). The derived keys
87
+ * are added AFTER the spread so a stray frontmatter `url:`/`slug:` can't shadow
88
+ * the real route data the loop relies on.
89
+ * @param {Route} route
90
+ * @returns {CollectionRow}
91
+ */
92
+ function rowFor(route) {
93
+ const slug = path.basename(route.file, path.extname(route.file));
94
+ /** @type {Record<string, unknown>} */
95
+ const fields = {};
96
+ for (const [key, value] of Object.entries(route.meta)) fields[key] = coerceScalar(value);
97
+ return {
98
+ ...fields,
99
+ url: route.route,
100
+ slug: slug === "index" ? path.basename(path.dirname(route.file)) : slug,
101
+ excerpt: excerptFor(route)
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Coerce a frontmatter scalar to its natural type for querying. The frontmatter
107
+ * parser yields scalars as STRINGS (`true`, `42` arrive as `"true"`, `"42"`), but
108
+ * a collection loop should behave like a JSON loop, where `where p.featured ==
109
+ * true` and numeric `sort`/comparisons just work. So a bare `true`/`false` becomes
110
+ * a boolean and a numeric string becomes a number; quoted/other strings and
111
+ * arrays pass through untouched. This coercion is scoped to collection rows only —
112
+ * it never touches the global frontmatter parser (a `:state`/`{ meta.x }` value
113
+ * keeps its existing string form).
114
+ * @param {unknown} value
115
+ * @returns {unknown}
116
+ */
117
+ function coerceScalar(value) {
118
+ if (value === "true") return true;
119
+ if (value === "false") return false;
120
+ if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
121
+ return value;
122
+ }
123
+
124
+ /**
125
+ * The excerpt for an entry: the frontmatter `excerpt:` string if present, else
126
+ * (plain `.md` only) the first paragraph of its body, else "". Mirrors the RSS
127
+ * description fallback so a collection card and the feed read the same snippet.
128
+ * @param {Route} route
129
+ * @returns {string}
130
+ */
131
+ function excerptFor(route) {
132
+ const fromMeta = route.meta.excerpt;
133
+ if (typeof fromMeta === "string" && fromMeta.trim()) return fromMeta.trim();
134
+ if (path.extname(route.file) !== ".md") return "";
135
+ const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
136
+ return firstParagraph(body);
137
+ }
138
+
139
+ /**
140
+ * A parsed `_schema.wd` field rule.
141
+ * @typedef {object} SchemaField
142
+ * @property {string} name Field name.
143
+ * @property {string} type One of {@link SCHEMA_TYPES}.
144
+ * @property {boolean} optional Whether the field may be absent (`?` modifier).
145
+ */
146
+
147
+ /**
148
+ * Read + parse a collection's `_schema.wd`, or null when the file is absent
149
+ * (validation is opt-in). The schema is frontmatter-shaped — one `field: type`
150
+ * line per rule inside the `---` block — so authors write it exactly like the
151
+ * frontmatter it validates. Each type must be in the closed {@link SCHEMA_TYPES}
152
+ * vocabulary (`?` suffix = optional); an unknown token is a compile error in the
153
+ * schema file itself, pointing at the offending line.
154
+ * @param {string} name Collection name.
155
+ * @param {Paths} paths
156
+ * @returns {SchemaField[] | null}
157
+ */
158
+ export function readSchema(name, paths) {
159
+ const file = path.join(paths.routesRoot, name, "_schema.wd");
160
+ if (!fs.existsSync(file)) return null;
161
+ const raw = fs.readFileSync(file, "utf8");
162
+ return parseSchema(raw, file);
163
+ }
164
+
165
+ /**
166
+ * Parse a `_schema.wd` source into field rules. The schema fields live in the
167
+ * frontmatter block; the body (if any) is ignored, so a schema file can carry a
168
+ * prose note after its `---` fence. Each field line is `name: type` where type
169
+ * is a closed token with an optional trailing `?`.
170
+ * @param {string} raw `_schema.wd` file contents.
171
+ * @param {string} file Absolute path, for `file:line` errors.
172
+ * @returns {SchemaField[]}
173
+ */
174
+ export function parseSchema(raw, file) {
175
+ // Use the same `---` extraction as page frontmatter so the schema block is
176
+ // delimited identically. A schema with no fence is read as bare lines.
177
+ const lines = raw.replace(/\r\n?/g, "\n").split("\n");
178
+ /** @type {SchemaField[]} */
179
+ const fields = [];
180
+ let fenced = false;
181
+ for (let i = 0; i < lines.length; i++) {
182
+ const line = lines[i];
183
+ const trimmed = line.trim();
184
+ if (trimmed === "---") {
185
+ // First `---` opens the block; the second closes it and ends the schema.
186
+ if (!fenced && fields.length === 0) {
187
+ fenced = true;
188
+ continue;
189
+ }
190
+ if (fenced) break; // closing fence — rest of the file is prose, ignore it
191
+ }
192
+ if (!trimmed || trimmed.startsWith("#")) continue; // blank line or comment
193
+ const match = trimmed.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
194
+ if (!match) {
195
+ throw new Error(
196
+ `Malformed schema line in ${file}:${i + 1}: "${trimmed}". Use: field: type (e.g. \`title: string\`, \`tags: string[]\`, \`featured: boolean?\`).`
197
+ );
198
+ }
199
+ fields.push(parseSchemaField(match[1], match[2].trim(), file, i + 1));
200
+ }
201
+ return fields;
202
+ }
203
+
204
+ /**
205
+ * Parse one `name: type` schema rule, validating the type against the closed
206
+ * vocabulary and peeling the optional `?` modifier.
207
+ * @param {string} name
208
+ * @param {string} rawType Type token (may end in `?`).
209
+ * @param {string} file
210
+ * @param {number} lineNo 1-based line number for the error.
211
+ * @returns {SchemaField}
212
+ */
213
+ function parseSchemaField(name, rawType, file, lineNo) {
214
+ const optional = rawType.endsWith("?");
215
+ const type = optional ? rawType.slice(0, -1).trim() : rawType;
216
+ if (!SCHEMA_TYPES.has(type)) {
217
+ throw new Error(
218
+ `Unknown schema type "${rawType}" for field "${name}" in ${file}:${lineNo}. Use one of: ${[...SCHEMA_TYPES].join(", ")} (append "?" to make a field optional).`
219
+ );
220
+ }
221
+ return { name, type, optional };
222
+ }
223
+
224
+ /**
225
+ * Validate every collection row against the schema, throwing on the first
226
+ * violation with a `file:line` (the entry's own path) and the offending field.
227
+ * A missing required field, a wrong type, or an unknown extra field all fail —
228
+ * unknown keys are rejected so a typo'd frontmatter key (`titel:`) is caught
229
+ * rather than silently dropped.
230
+ * @param {CollectionRow[]} rows
231
+ * @param {SchemaField[]} schema
232
+ * @param {string} name Collection name, for the error message.
233
+ * @returns {void}
234
+ */
235
+ function validateRows(rows, schema, name) {
236
+ const allowed = new Set(schema.map((field) => field.name));
237
+ // Framework-recognized keys never need a schema declaration: `url`/`slug`/
238
+ // `excerpt` are derived (always present), and `draft` is the routing gate
239
+ // (present on `--drafts` builds). Allow them implicitly so they don't trip the
240
+ // unknown-key check.
241
+ for (const reserved of ["url", "slug", "excerpt", "draft"]) allowed.add(reserved);
242
+ for (const row of rows) {
243
+ const where = `${String(row.url)} (collection "${name}")`;
244
+ for (const field of schema) validateField(row, field, where);
245
+ for (const key of Object.keys(row)) {
246
+ if (allowed.has(key)) continue;
247
+ throw new Error(
248
+ `Unknown frontmatter field "${key}" in ${where}: it is not declared in _schema.wd. Add \`${key}: <type>\` to the schema, or remove the field.`
249
+ );
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Validate one field of one row against its schema rule.
256
+ * @param {CollectionRow} row
257
+ * @param {SchemaField} field
258
+ * @param {string} where Human-readable entry location for the error.
259
+ * @returns {void}
260
+ */
261
+ function validateField(row, field, where) {
262
+ const present = field.name in row && row[field.name] !== null && row[field.name] !== "";
263
+ if (!present) {
264
+ if (field.optional) return;
265
+ throw new Error(
266
+ `Missing required field "${field.name}" (${field.type}) in ${where}. Add \`${field.name}: …\` to the entry's frontmatter, or mark it optional with "${field.name}: ${field.type}?" in _schema.wd.`
267
+ );
268
+ }
269
+ const value = row[field.name];
270
+ if (!matchesType(value, field.type)) {
271
+ throw new Error(
272
+ `Field "${field.name}" in ${where} should be ${field.type} but is "${describe(value)}". Fix the entry's frontmatter or the schema.`
273
+ );
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Whether a frontmatter value satisfies a schema type. Frontmatter scalars are
279
+ * already strings/numbers/booleans/arrays (the parser produces no Date object),
280
+ * so `date` is "a string that `Date` can parse" and `number` accepts a numeric
281
+ * string too (frontmatter `42` parses to a number, but a quoted `"42"` stays a
282
+ * string — both are acceptable for a `number` field). The `type` is always one of
283
+ * {@link SCHEMA_TYPES} (parseSchemaField rejected anything else), so a missing
284
+ * checker can't occur — every {@link SCHEMA_TYPES} member has an entry below.
285
+ * @param {unknown} value
286
+ * @param {string} type
287
+ * @returns {boolean}
288
+ */
289
+ function matchesType(value, type) {
290
+ return TYPE_CHECKS[type](value);
291
+ }
292
+
293
+ /**
294
+ * The per-type value checker for each closed schema type. Keyed exactly by
295
+ * {@link SCHEMA_TYPES}, so the indexed call in {@link matchesType} always lands on
296
+ * a real checker (no unreachable fallback branch to cover).
297
+ * @type {Record<string, (value: unknown) => boolean>}
298
+ */
299
+ const TYPE_CHECKS = {
300
+ string: (value) => typeof value === "string",
301
+ number: (value) =>
302
+ typeof value === "number" || (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)),
303
+ boolean: (value) => typeof value === "boolean" || value === "true" || value === "false",
304
+ date: (value) => typeof value === "string" && !Number.isNaN(new Date(value).getTime()),
305
+ "string[]": (value) => Array.isArray(value) && value.every((item) => typeof item === "string")
306
+ };
307
+
308
+ /**
309
+ * A short, safe description of a value for a validation error message.
310
+ * @param {unknown} value
311
+ * @returns {string}
312
+ */
313
+ function describe(value) {
314
+ if (Array.isArray(value)) return `[${value.join(", ")}]`;
315
+ return String(value);
316
+ }
@@ -35,6 +35,21 @@
35
35
  * @property {boolean} hasCode Whether the page has a build-time-highlighted code
36
36
  * block, so the framework highlight stylesheet (`/__wd/highlight.css`) is
37
37
  * emitted and linked — pay-for-what-you-use, zero JS.
38
+ * @property {Set<string>} scopedSkins Source paths of colocated `.skin` files
39
+ * that opted into scoping (`scoped` first line). The builder emits these with
40
+ * the scope attribute and runs the unused-selector warning against the stamped
41
+ * subtree; the HTML stamp uses them to scope the matching page/include subtree.
42
+ */
43
+
44
+ /**
45
+ * The pagination intent a `@loop … paginate N` records during compile, so the
46
+ * builder can multiply routes (page 1 at the listing route, 2+ at
47
+ * `/<route>/page/<n>/`). `total` is the post-`where`/`sort` page count for the
48
+ * collection the loop drew from.
49
+ * @typedef {object} Pagination
50
+ * @property {number} perPage Rows per page (the `paginate N` value).
51
+ * @property {number} total Total page count (≥ 1).
52
+ * @property {string} collection Collection name the loop paginated.
38
53
  */
39
54
 
40
55
  /**
@@ -45,6 +60,12 @@
45
60
  * @property {Set<string>} stores Page-global store names (a subset of state keys).
46
61
  * @property {string[]} warnings Non-fatal authoring hints.
47
62
  * @property {number} sectionCounter Counter for auto-generated section ids.
63
+ * @property {Map<string, import("./collections.js").CollectionRow[]>} collections
64
+ * Collection name → entry rows, built from the router's routes and threaded in
65
+ * so a bare-name `@loop blog into post` resolves to its entries at build time.
66
+ * @property {import("./context.js").Pagination | null} pagination The pagination
67
+ * intent a `@loop … paginate N` recorded this compile, or null. The builder
68
+ * reads it to multiply routes; one paginated loop per page is supported.
48
69
  */
49
70
 
50
71
  /**
@@ -76,6 +97,8 @@
76
97
  * @property {string} html
77
98
  * @property {Assets} assets
78
99
  * @property {string[]} warnings
100
+ * @property {Pagination | null} pagination The pagination intent a paginated
101
+ * `@loop` recorded, or null. The builder reads it to multiply routes.
79
102
  */
80
103
 
81
104
  /**
@@ -85,6 +108,8 @@
85
108
  * @property {string} html
86
109
  * @property {Assets} assets
87
110
  * @property {string[]} warnings
111
+ * @property {Pagination | null} pagination The pagination intent a paginated
112
+ * `@loop` recorded, or null. The builder reads it to multiply routes.
88
113
  */
89
114
 
90
115
  /**
@@ -111,6 +136,10 @@
111
136
  * @property {boolean} clauseRefsState Whether offset/limit reference state.
112
137
  * @property {boolean} sortable Drag-to-reorder the underlying :state/:store list
113
138
  * (the `sortable` clause). Only valid on a plain reactive state-key loop.
139
+ * @property {number | null} paginate Rows-per-page for a `paginate N` clause, or
140
+ * null. Only valid on a collection loop; multiplies the listing into static
141
+ * pages (page 1 at the route, 2+ at `/<route>/page/<n>/`) and exposes a `page`
142
+ * pager to the template scope.
114
143
  */
115
144
 
116
145
  /**
@@ -144,12 +173,15 @@ export function createCompilation() {
144
173
  files: new Map(),
145
174
  runtime: false,
146
175
  behaviors: new Set(),
147
- hasCode: false
176
+ hasCode: false,
177
+ scopedSkins: new Set()
148
178
  },
149
179
  state: new Map(),
150
180
  stores: new Set(),
151
181
  warnings: [],
152
- sectionCounter: 0
182
+ sectionCounter: 0,
183
+ collections: new Map(),
184
+ pagination: null
153
185
  };
154
186
  }
155
187
 
@@ -12,7 +12,7 @@
12
12
  import { compileBody } from "./body.js";
13
13
  import { at, createScope, LOOP_META } from "./context.js";
14
14
  import { FORMATTERS } from "./format.js";
15
- import { resolveInclude } from "./includes.js";
15
+ import { resolveInclude, scopedSkinFor } from "./includes.js";
16
16
  import {
17
17
  escapeHtml,
18
18
  getPath,
@@ -26,7 +26,7 @@ import {
26
26
  stripQuotes,
27
27
  validatePath
28
28
  } from "./interpolation.js";
29
- import { compileFile } from "./page.js";
29
+ import { compileFile, stampScope } from "./page.js";
30
30
  import { compileComputedExpr, compileWhen, evalPredicate } from "./predicates.js";
31
31
 
32
32
  /**
@@ -45,17 +45,23 @@ export function handleInclude(line, ctx, index) {
45
45
  if (!match) throw new Error(`Malformed @include in ${at(ctx.file, index)}: ${line}`);
46
46
  const target = resolveInclude(match[1], ctx.file, ctx.context);
47
47
  const args = parseIncludeArgs(match[2] || "", ctx);
48
- const scope = createScope(ctx.scope, args);
48
+ const childScope = createScope(ctx.scope, args);
49
49
  const child = compileFile(
50
50
  target,
51
51
  ctx.context,
52
52
  ctx.stack,
53
- scope,
53
+ childScope,
54
54
  ctx.comp,
55
55
  ctx.sections,
56
56
  ctx.loopItem
57
57
  );
58
- return child.html;
58
+ // An include with its OWN scoped colocated skin stamps just its returned
59
+ // subtree — so a scoped `.card` in the include can't collide with a `.card`
60
+ // anywhere else on the page, and the scope never leaks to the include's
61
+ // siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
62
+ // the same path-derived id, so attribute and stylesheet line up.
63
+ const scopedSkin = scopedSkinFor(target, ctx.context);
64
+ return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
59
65
  }
60
66
 
61
67
  /**
@@ -6,6 +6,7 @@
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
+ import { scopeIdFor } from "../skin.js";
9
10
  import { pageIncludeExtensions } from "./context.js";
10
11
  import { stripQuotes } from "./interpolation.js";
11
12
 
@@ -15,6 +16,47 @@ import { stripQuotes } from "./interpolation.js";
15
16
  * @typedef {import("./context.js").Compilation} Compilation
16
17
  */
17
18
 
19
+ /**
20
+ * Read a `.skin` file's first meaningful line (skipping `//` line comments,
21
+ * `/* … *\/` block comments, blanks, and decorative dividers — the same skip
22
+ * rules `compileSkin` applies) to decide whether it opts into scoping. A skin
23
+ * scopes its selectors ONLY when that first line is exactly `scoped`; otherwise
24
+ * it is global and untouched. This is the single source for "is this skin
25
+ * scoped + what is its id", so the CSS rewrite (builder) and the HTML stamp
26
+ * (page/include) always agree on both.
27
+ * @param {string} skinPath Absolute path to the colocated `.skin` file.
28
+ * @param {string} cwd Project root, for the path-based scope id.
29
+ * @returns {{ scoped: boolean, scopeId: string }}
30
+ */
31
+ export function scopeInfoForSkin(skinPath, cwd) {
32
+ const rel = path.relative(cwd, skinPath).replaceAll(path.sep, "/");
33
+ const scopeId = scopeIdFor(rel);
34
+ const src = fs.readFileSync(skinPath, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
35
+ for (const raw of src.split("\n")) {
36
+ const t = raw.trim();
37
+ if (!t || t.startsWith("//") || !/[A-Za-z0-9]/.test(t)) continue;
38
+ return { scoped: t === "scoped", scopeId };
39
+ }
40
+ return { scoped: false, scopeId };
41
+ }
42
+
43
+ /**
44
+ * The scope info for a page/include's OWN colocated `.skin` sibling, or null
45
+ * when there is no sibling skin. Used by the HTML stamp sites: a route's own
46
+ * scoped skin → stamp the page body; an include's scoped skin → stamp the
47
+ * returned subtree. Resolution mirrors `collectColocatedAssets` (same stem,
48
+ * `.skin` extension).
49
+ * @param {string} file Absolute path to the page/include source file.
50
+ * @param {Paths} context
51
+ * @returns {{ scoped: boolean, scopeId: string } | null}
52
+ */
53
+ export function scopedSkinFor(file, context) {
54
+ const ext = path.extname(file);
55
+ const candidate = `${file.slice(0, -ext.length)}.skin`;
56
+ if (!fs.existsSync(candidate)) return null;
57
+ return scopeInfoForSkin(candidate, context.cwd);
58
+ }
59
+
18
60
  /**
19
61
  * Register a page's colocated `.skin`/`.js` siblings as emitted assets.
20
62
  * @param {string} file
@@ -35,7 +77,12 @@ export function collectColocatedAssets(file, context, assets) {
35
77
  const outputExt = assetExt === ".skin" ? ".css" : ".js";
36
78
  const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
37
79
  assets.files.set(candidate, publicPath);
38
- if (assetExt === ".skin") assets.skins.add(publicPath);
80
+ if (assetExt === ".skin") {
81
+ assets.skins.add(publicPath);
82
+ // Surface "this skin is scoped" so the warning pass (builder) and the HTML
83
+ // stamp can find the scoped skins among all colocated ones by source path.
84
+ if (scopeInfoForSkin(candidate, context.cwd).scoped) assets.scopedSkins.add(candidate);
85
+ }
39
86
  if (assetExt === ".js") assets.scripts.add(publicPath);
40
87
  }
41
88
  }
@@ -20,4 +20,4 @@
20
20
  export { parseFrontmatter } from "./frontmatter.js";
21
21
  export { escapeHtml } from "./interpolation.js";
22
22
  export { loopKeyOf } from "./loops.js";
23
- export { compileDocument, compilePage, enhanceImages } from "./page.js";
23
+ export { compileDocument, compilePage, enhanceImages, stampScope } from "./page.js";