@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/site/pages/reactive.skin
CHANGED
|
@@ -29,18 +29,44 @@ h2
|
|
|
29
29
|
padding-top 1.2rem
|
|
30
30
|
|
|
31
31
|
button
|
|
32
|
-
|
|
33
|
-
|
|
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 .
|
|
39
|
+
padding .6rem 1.1rem
|
|
36
40
|
margin .3rem .5rem .3rem 0
|
|
37
|
-
radius
|
|
38
|
-
font
|
|
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 .
|
|
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
|
package/site/pages/store.skin
CHANGED
|
@@ -32,28 +32,65 @@ a
|
|
|
32
32
|
color $accent
|
|
33
33
|
|
|
34
34
|
button
|
|
35
|
-
|
|
36
|
-
|
|
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 .
|
|
42
|
+
padding .6rem 1.1rem
|
|
39
43
|
margin .3rem .5rem .3rem 0
|
|
40
|
-
radius
|
|
41
|
-
font
|
|
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 .
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|