@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
@@ -29,18 +29,44 @@ h2
29
29
  padding-top 1.2rem
30
30
 
31
31
  button
32
- border 1px solid $ink
33
- bg $ink
32
+ display inline-flex
33
+ align-items center
34
+ justify-content center
35
+ gap .5rem
36
+ border 1px solid $accent
37
+ bg $accent
34
38
  color white
35
- padding .72rem 1rem
39
+ padding .6rem 1.1rem
36
40
  margin .3rem .5rem .3rem 0
37
- radius $radius
38
- font inherit
41
+ radius 999px
42
+ font system-ui, -apple-system, "Segoe UI", Roboto, sans-serif
43
+ font-size .92rem
44
+ font-weight 560
45
+ line-height 1.15
39
46
  cursor pointer
47
+ appearance none
48
+ box-shadow 0 1px 2px rgba(23,23,23,.08)
49
+ transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease, background .14s ease, border-color .14s ease
40
50
 
41
51
  button:hover
42
52
  bg $accent
43
53
  border-color $accent
54
+ transform translateY(-1px)
55
+ box-shadow 0 8px 18px -8px rgba(22,100,90,.5)
56
+
57
+ button:active
58
+ transform translateY(0)
59
+ box-shadow 0 1px 2px rgba(23,23,23,.08)
60
+
61
+ button:focus-visible
62
+ outline 2px solid $accent
63
+ outline-offset 2px
64
+
65
+ button:disabled
66
+ opacity .5
67
+ cursor not-allowed
68
+ box-shadow none
69
+ transform none
44
70
 
45
71
  a
46
72
  color $accent
@@ -89,4 +115,10 @@ ul
89
115
  bg transparent
90
116
  color $warm
91
117
  border 1px solid $line
92
- padding .35rem .8rem
118
+ padding .4rem .9rem
119
+ box-shadow none
120
+
121
+ .card.line button:hover
122
+ bg $warm
123
+ border-color $warm
124
+ color white
@@ -0,0 +1,23 @@
1
+ scoped
2
+
3
+ /* Page-scoped skin for this route. The whole page body is stamped, so these
4
+ selectors style only this page's own markup — proving page-level scoping
5
+ alongside the two include-scoped components. A :global() escape hatch shows a
6
+ single rule opting back out to a plain, unscoped selector. */
7
+
8
+ .intro
9
+ margin 2rem 0 3rem
10
+
11
+ .duo
12
+ margin 3rem 0
13
+
14
+ .how
15
+ margin 4rem 0 2rem
16
+
17
+ .how ul
18
+ color $ink-soft
19
+
20
+ /* :global escape — this selector ships UNSCOPED, matching site-wide. */
21
+ :global(.scoped-note)
22
+ font-size .85rem
23
+ color $muted
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: Scoped styles — same class name, zero collision
3
+ description: Two components reuse the class name .card and never clash. Compile-time scoped styles stamp each component's subtree with a path-derived id and rewrite its skin's selectors to match only there. Zero runtime JS — a static page stays static.
4
+ transitions: true
5
+ ---
6
+
7
+ @include /nav.wd
8
+ @include /base.wd
9
+
10
+ <main>
11
+
12
+ <section class="intro">
13
+ <span class="eyebrow">Darkmown 1.4</span>
14
+
15
+ # Scoped styles
16
+
17
+ Two components on this page both call their root element `.card`. In plain CSS the second skin would overwrite the first. Here they coexist: a `.skin` whose first line is `scoped` only ever styles the subtree it ships with. It's pure compile time — a path-derived id stamped onto the HTML and appended to the skin's selectors. No runtime, no class hashing in your markup, no collision.
18
+
19
+ </section>
20
+
21
+ <section class="duo">
22
+
23
+ <div class="grid cols-2">
24
+
25
+ @include /scoped-promo.wd
26
+ @include /scoped-stat.wd
27
+
28
+ </div>
29
+
30
+ </section>
31
+
32
+ <section class="how">
33
+
34
+ ## What actually shipped
35
+
36
+ - The `.card` in the warm promo and the `.card` in the dark stat tile are **different rules**, scoped to different ids.
37
+ - This page also uses a **global** skin (`base.skin`, via `/base.wd`) — `.btn`, `.eyebrow`, `.grid` still work everywhere, unscoped.
38
+ - Design **tokens stay global**: every scoped skin still reads `$accent`, `$warm`, `$radius` from `:root`, so dark mode keeps working for free.
39
+ - A `:global(.x)` selector inside a scoped skin opts a single rule back out.
40
+
41
+ <a class="btn primary" href="/docs/">Read the docs</a>
42
+
43
+ </section>
44
+
45
+ </main>
46
+
47
+ @include /footer.wd
@@ -32,28 +32,65 @@ a
32
32
  color $accent
33
33
 
34
34
  button
35
- border 1px solid $ink
36
- bg $ink
35
+ display inline-flex
36
+ align-items center
37
+ justify-content center
38
+ gap .5rem
39
+ border 1px solid $accent
40
+ bg $accent
37
41
  color white
38
- padding .72rem 1rem
42
+ padding .6rem 1.1rem
39
43
  margin .3rem .5rem .3rem 0
40
- radius $radius
41
- font inherit
44
+ radius 999px
45
+ font system-ui, -apple-system, "Segoe UI", Roboto, sans-serif
46
+ font-size .92rem
47
+ font-weight 560
48
+ line-height 1.15
42
49
  cursor pointer
50
+ appearance none
51
+ box-shadow 0 1px 2px rgba(23,23,23,.08)
52
+ transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease, background .14s ease, border-color .14s ease
43
53
 
44
54
  button:hover
45
55
  bg $accent
46
56
  border-color $accent
57
+ transform translateY(-1px)
58
+ box-shadow 0 8px 18px -8px rgba(22,100,90,.5)
59
+
60
+ button:active
61
+ transform translateY(0)
62
+ box-shadow 0 1px 2px rgba(23,23,23,.08)
63
+
64
+ button:focus-visible
65
+ outline 2px solid $accent
66
+ outline-offset 2px
67
+
68
+ button:disabled
69
+ opacity .5
70
+ cursor not-allowed
71
+ box-shadow none
72
+ transform none
47
73
 
48
74
  input
49
- padding .7rem .9rem
75
+ padding .62rem .85rem
50
76
  border 1px solid $line
51
77
  radius $radius
52
78
  font inherit
79
+ font-size .95rem
53
80
  bg $panel
54
81
  width 100%
55
82
  max-width 360px
56
83
  margin 1rem 0
84
+ appearance none
85
+ transition border-color .14s ease, box-shadow .14s ease
86
+
87
+ input:hover
88
+ border-color $accent
89
+
90
+ input:focus-visible
91
+ outline none
92
+ border-color $accent
93
+ box-shadow 0 0 0 3px rgba(22,100,90,.18)
57
94
 
58
95
  #shop
59
96
  border 1px solid $line
package/src/builder.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { discoverApiRoutes } from "./api-runner.js";
5
+ import { buildCollections } from "./compiler/collections.js";
5
6
  import { compilePage, parseFrontmatter } from "./compiler.js";
6
7
  import { createPaths } from "./config.js";
7
8
  import {
@@ -17,7 +18,7 @@ import {
17
18
  import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
18
19
  import { HIGHLIGHT_CSS } from "./highlight.js";
19
20
  import { discoverRoutes, outputPathForRoute } from "./router.js";
20
- import { compileSkin } from "./skin.js";
21
+ import { compileSkin, scopeIdFor } from "./skin.js";
21
22
 
22
23
  /**
23
24
  * @typedef {import("./config.js").Paths} Paths
@@ -59,6 +60,12 @@ export function buildSite(cwd = process.cwd(), options = {}) {
59
60
  fs.mkdirSync(paths.distRoot, { recursive: true });
60
61
  emitShelfAssets(paths);
61
62
  const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
63
+ // Build the collection index from the (post-draft-filter) routes — every
64
+ // site/pages/<name>/ subdirectory's entries become queryable rows a bare-name
65
+ // `@loop blog into post` resolves against. Drafts are already excluded, so a
66
+ // draft entry can never appear in a listing. Validates each collection's
67
+ // `_schema.wd` here (throwing on a bad entry with file:line).
68
+ const collections = buildCollections(routes, paths);
62
69
  // Site identity lives in the HOME page frontmatter (no config loader): the
63
70
  // `site_url` origin triggers + prefixes the feeds, reusing `title`/`description`
64
71
  // as the RSS channel fields. Resolved once, up front, so every page can link
@@ -80,35 +87,46 @@ export function buildSite(cwd = process.cwd(), options = {}) {
80
87
  /** @type {string | undefined} HTML of the `/404/` route, copied to dist/404.html. */
81
88
  let notFoundHtml;
82
89
 
90
+ // Extra (paginated 2+) routes the build generates beyond the discovered set —
91
+ // each is indexable static HTML, so it belongs in the sitemap (it has no own
92
+ // frontmatter `date:`; it reuses its listing source file for `<lastmod>`).
93
+ /** @type {Route[]} */
94
+ const extraRoutes = [];
95
+
83
96
  for (const route of routes) {
84
- const page = compilePage(route.file, paths, { feed: feedLink });
85
- for (const warning of page.warnings || []) {
86
- if (warned.has(warning)) continue;
87
- warned.add(warning);
88
- console.warn(`hint: ${warning}`);
89
- }
90
- const outFile = outputPathForRoute(paths.distRoot, route.route);
91
- fs.mkdirSync(path.dirname(outFile), { recursive: true });
92
- fs.writeFileSync(outFile, page.html);
93
- if (route.route === "/404/") notFoundHtml = page.html;
94
- emitAssets(page.assets, paths);
95
- if (page.assets.runtime) emitRuntime(paths);
96
- emitBehaviors(page.assets, paths);
97
- if (page.assets.hasCode) emitHighlight(paths);
98
- const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
99
- manifest.push({
100
- route: route.route,
101
- file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
102
- assets: {
103
- skins: [...page.assets.skins],
104
- scripts: [
105
- ...(page.assets.runtime ? ["/__wd/runtime.js"] : []),
106
- ...behaviorSrcs,
107
- ...page.assets.scripts
108
- ],
109
- runtime: page.assets.runtime
97
+ // Compile each route's page(s). A normal route yields one page; a route whose
98
+ // `@loop … paginate N` slices a collection yields page 1 at the route plus
99
+ // `/<route>/page/<n>/` for pages 2..N — every one static HTML.
100
+ for (const page of compileRoutePages(route, paths, collections, feedLink)) {
101
+ if (page.route !== route.route) extraRoutes.push({ ...route, route: page.route });
102
+ for (const warning of page.warnings || []) {
103
+ if (warned.has(warning)) continue;
104
+ warned.add(warning);
105
+ console.warn(`hint: ${warning}`);
110
106
  }
111
- });
107
+ const outFile = outputPathForRoute(paths.distRoot, page.route);
108
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
109
+ fs.writeFileSync(outFile, page.html);
110
+ if (page.route === "/404/") notFoundHtml = page.html;
111
+ emitAssets(page.assets, paths, page.html, warned);
112
+ if (page.assets.runtime) emitRuntime(paths);
113
+ emitBehaviors(page.assets, paths);
114
+ if (page.assets.hasCode) emitHighlight(paths);
115
+ const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
116
+ manifest.push({
117
+ route: page.route,
118
+ file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
119
+ assets: {
120
+ skins: [...page.assets.skins],
121
+ scripts: [
122
+ ...(page.assets.runtime ? ["/__wd/runtime.js"] : []),
123
+ ...behaviorSrcs,
124
+ ...page.assets.scripts
125
+ ],
126
+ runtime: page.assets.runtime
127
+ }
128
+ });
129
+ }
112
130
  }
113
131
 
114
132
  // Hosts (Vercel, Cloudflare Pages) and the framework's own server auto-serve
@@ -126,8 +144,10 @@ export function buildSite(cwd = process.cwd(), options = {}) {
126
144
 
127
145
  // Crawler files + feeds. robots.txt is ALWAYS emitted; sitemap.xml + rss.xml
128
146
  // need the home `site_url` (else a loud, actionable build hint). Drafts are
129
- // already filtered out of `routes`, so neither feed can ever see one.
130
- const feeds = emitFeeds(routes, identity, paths);
147
+ // already filtered out of `routes`, so neither feed can ever see one. The
148
+ // paginated 2+ routes are indexable HTML too, so they join the sitemap (RSS is
149
+ // dated-posts-only, which they are not, so it's unaffected).
150
+ const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths);
131
151
 
132
152
  // Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
133
153
  // sees every emitted route/framework file already on disk.
@@ -146,6 +166,94 @@ export function buildSite(cwd = process.cwd(), options = {}) {
146
166
  return { routes: manifest, distRoot: paths.distRoot, feeds };
147
167
  }
148
168
 
169
+ /**
170
+ * A compiled page bound for a concrete output route. Mirrors `CompiledPage` plus
171
+ * the public `route` it's written to — for a paginated listing, page 1 keeps the
172
+ * listing route and pages 2+ get `/<route>/page/<n>/`.
173
+ * @typedef {object} EmittedPage
174
+ * @property {string} route Public route path this page is written to.
175
+ * @property {string} html
176
+ * @property {import("./compiler.js").Assets} assets
177
+ * @property {string[]} warnings
178
+ */
179
+
180
+ /**
181
+ * Compile a route into the one-or-more static pages it emits. A normal route is
182
+ * one page. A route whose body paginates a collection (`@loop … paginate N`) is
183
+ * compiled once to discover the page count, then recompiled per page with the
184
+ * correct `page` pager seeded into scope — page 1 at the route, pages 2..total at
185
+ * `/<route>/page/<n>/`. Every emitted page is static HTML (`runtime: false` for a
186
+ * pure listing).
187
+ * @param {Route} route
188
+ * @param {Paths} paths
189
+ * @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} collections
190
+ * @param {{ href: string, title: string } | undefined} feedLink
191
+ * @returns {EmittedPage[]}
192
+ */
193
+ function compileRoutePages(route, paths, collections, feedLink) {
194
+ // First compile (discovery): page 1 with a placeholder pager. If the page has
195
+ // no `paginate`, this is the only compile and we emit it as-is.
196
+ const first = compilePage(route.file, paths, {
197
+ feed: feedLink,
198
+ collections,
199
+ vars: { page: pagerVars(route.route, 1, 1) }
200
+ });
201
+ if (!first.pagination || first.pagination.total <= 1) {
202
+ return [
203
+ { route: route.route, html: first.html, assets: first.assets, warnings: first.warnings }
204
+ ];
205
+ }
206
+ // Paginated: recompile every page (including page 1, whose pager `total`/`next`
207
+ // were unknown on the discovery pass) with the correct pager.
208
+ const total = first.pagination.total;
209
+ /** @type {EmittedPage[]} */
210
+ const pages = [];
211
+ for (let n = 1; n <= total; n++) {
212
+ const page = compilePage(route.file, paths, {
213
+ feed: feedLink,
214
+ collections,
215
+ vars: { page: pagerVars(route.route, n, total) }
216
+ });
217
+ pages.push({
218
+ route: pageRoute(route.route, n),
219
+ html: page.html,
220
+ assets: page.assets,
221
+ warnings: page.warnings
222
+ });
223
+ }
224
+ return pages;
225
+ }
226
+
227
+ /**
228
+ * The output route for page `n` of a paginated listing: page 1 keeps the
229
+ * listing's own clean route; pages 2+ live at `/<route>/page/<n>/`.
230
+ * @param {string} baseRoute Listing route (trailing-slashed).
231
+ * @param {number} n 1-based page number.
232
+ * @returns {string}
233
+ */
234
+ function pageRoute(baseRoute, n) {
235
+ return n === 1 ? baseRoute : `${baseRoute}page/${n}/`;
236
+ }
237
+
238
+ /**
239
+ * Build the `{ page.* }` pager exposed to the template scope of a paginated
240
+ * listing: `current`/`total` numbers and `prev`/`next` URLs (empty string at the
241
+ * ends, so `{ page.prev }` renders nothing on page 1). Plain links to the
242
+ * generated routes — zero JS.
243
+ * @param {string} baseRoute Listing route.
244
+ * @param {number} current 1-based current page.
245
+ * @param {number} total Total page count.
246
+ * @returns {{ current: number, total: number, prev: string, next: string }}
247
+ */
248
+ function pagerVars(baseRoute, current, total) {
249
+ return {
250
+ current,
251
+ total,
252
+ prev: current > 1 ? pageRoute(baseRoute, current - 1) : "",
253
+ next: current < total ? pageRoute(baseRoute, current + 1) : ""
254
+ };
255
+ }
256
+
149
257
  /**
150
258
  * Resolve the site's identity from the HOME route's frontmatter — the single
151
259
  * place site-wide metadata lives this cycle (no config loader). `site_url` is
@@ -544,22 +652,95 @@ function emitHighlight(paths) {
544
652
  }
545
653
 
546
654
  /**
655
+ * Emit a page's colocated assets. `.skin` files compile through {@link compileSkin}
656
+ * (other files copy verbatim). A skin that opted into scoping (`assets.scopedSkins`)
657
+ * compiles in scoped mode: the scope id is derived here from its PROJECT-RELATIVE
658
+ * PATH — the single coordination point with the HTML stamp, which derives the same
659
+ * id from the same path — and threaded into `compileSkin({ scope })`. The compiler
660
+ * also reports the scoped subject selectors, which we check against the stamped page
661
+ * HTML to warn (never remove) on a scoped selector that matches no element.
547
662
  * @param {Assets} assets
548
663
  * @param {Paths} paths
664
+ * @param {string} pageHtml Assembled page HTML (carries the `data-wd-scope` stamps).
665
+ * @param {Set<string>} warned Cross-route dedupe set for emitted hints.
549
666
  * @returns {void}
550
667
  */
551
- function emitAssets(assets, paths) {
668
+ function emitAssets(assets, paths, pageHtml, warned) {
552
669
  for (const [source, href] of assets.files) {
553
670
  const out = path.join(paths.distRoot, href);
554
671
  fs.mkdirSync(path.dirname(out), { recursive: true });
555
672
  if (source.endsWith(".skin")) {
556
- fs.writeFileSync(out, compileSkin(fs.readFileSync(source, "utf8")));
673
+ const src = fs.readFileSync(source, "utf8");
674
+ if (assets.scopedSkins.has(source)) {
675
+ const rel = path.relative(paths.cwd, source).replaceAll(path.sep, "/");
676
+ const scope = scopeIdFor(rel);
677
+ /** @type {Set<string>} */
678
+ const subjects = new Set();
679
+ fs.writeFileSync(out, compileSkin(src, { scope, subjects }));
680
+ warnUnusedScoped(subjects, scope, pageHtml, path.basename(source), warned);
681
+ } else {
682
+ fs.writeFileSync(out, compileSkin(src));
683
+ }
557
684
  } else {
558
685
  fs.copyFileSync(source, out);
559
686
  }
560
687
  }
561
688
  }
562
689
 
690
+ /**
691
+ * Warn (do NOT remove) when a scoped selector's subject never appears in its
692
+ * stamped subtree — a likely typo or stale rule. We only have a class/element/id
693
+ * to look for, so we scan the elements actually stamped with THIS scope id in the
694
+ * assembled page and check each subject against them. A colocated `.js` may add
695
+ * classes at runtime, so this stays advisory: the rule is always emitted. An empty
696
+ * subject token (a bare attribute-selector subject) is unknowable here and skipped.
697
+ * @param {Set<string>} subjects Scoped subject tokens (`.card`, `h2`, `#id`).
698
+ * @param {string} scope Scope id.
699
+ * @param {string} pageHtml Assembled page HTML.
700
+ * @param {string} skinName Skin filename for the message.
701
+ * @param {Set<string>} warned Cross-route dedupe set.
702
+ * @returns {void}
703
+ */
704
+ function warnUnusedScoped(subjects, scope, pageHtml, skinName, warned) {
705
+ // The opening tags carrying this scope id — the only elements the scoped
706
+ // stylesheet can match. One scan, reused for every subject.
707
+ const stamped = pageHtml.match(
708
+ new RegExp(`<[a-zA-Z][\\w-]*[^>]*\\bdata-wd-scope="${scope}"[^>]*>`, "g")
709
+ );
710
+ const tags = stamped ?? [];
711
+ for (const subject of subjects) {
712
+ if (!subject) continue;
713
+ const present = tags.some((tag) => subjectMatchesTag(subject, tag));
714
+ if (present) continue;
715
+ const warning = `scoped selector "${subject}" in ${skinName} matches no element`;
716
+ if (warned.has(warning)) continue;
717
+ warned.add(warning);
718
+ console.warn(`hint: ${warning}`);
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Whether a scoped subject token (`.card` / `#id` / a bare tag like `h2`) could
724
+ * match a single stamped opening tag. Class subjects look in the tag's `class`
725
+ * attribute; id subjects in `id`; a bare tag compares the element name.
726
+ * @param {string} subject
727
+ * @param {string} tag A single opening tag string.
728
+ * @returns {boolean}
729
+ */
730
+ function subjectMatchesTag(subject, tag) {
731
+ if (subject.startsWith(".")) {
732
+ const cls = subject.slice(1);
733
+ const m = tag.match(/\bclass\s*=\s*"([^"]*)"/);
734
+ return m ? m[1].split(/\s+/).includes(cls) : false;
735
+ }
736
+ if (subject.startsWith("#")) {
737
+ const m = tag.match(/\bid\s*=\s*"([^"]*)"/);
738
+ return m ? m[1] === subject.slice(1) : false;
739
+ }
740
+ const name = tag.match(/^<([a-zA-Z][\w-]*)/);
741
+ return name ? name[1].toLowerCase() === subject.toLowerCase() : false;
742
+ }
743
+
563
744
  /**
564
745
  * Copy non-page shelf assets (JSON data, media) into `dist/__wd`.
565
746
  * @param {Paths} paths