@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/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 {
|
|
@@ -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, page.html, warned);
|
|
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
|
|
@@ -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
|
+
}
|
package/src/compiler/context.js
CHANGED
|
@@ -41,6 +41,17 @@
|
|
|
41
41
|
* subtree; the HTML stamp uses them to scope the matching page/include subtree.
|
|
42
42
|
*/
|
|
43
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.
|
|
53
|
+
*/
|
|
54
|
+
|
|
44
55
|
/**
|
|
45
56
|
* Per-document compilation accumulator shared across includes/sections.
|
|
46
57
|
* @typedef {object} Compilation
|
|
@@ -49,6 +60,12 @@
|
|
|
49
60
|
* @property {Set<string>} stores Page-global store names (a subset of state keys).
|
|
50
61
|
* @property {string[]} warnings Non-fatal authoring hints.
|
|
51
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.
|
|
52
69
|
*/
|
|
53
70
|
|
|
54
71
|
/**
|
|
@@ -80,6 +97,8 @@
|
|
|
80
97
|
* @property {string} html
|
|
81
98
|
* @property {Assets} assets
|
|
82
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.
|
|
83
102
|
*/
|
|
84
103
|
|
|
85
104
|
/**
|
|
@@ -89,6 +108,8 @@
|
|
|
89
108
|
* @property {string} html
|
|
90
109
|
* @property {Assets} assets
|
|
91
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.
|
|
92
113
|
*/
|
|
93
114
|
|
|
94
115
|
/**
|
|
@@ -115,6 +136,10 @@
|
|
|
115
136
|
* @property {boolean} clauseRefsState Whether offset/limit reference state.
|
|
116
137
|
* @property {boolean} sortable Drag-to-reorder the underlying :state/:store list
|
|
117
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.
|
|
118
143
|
*/
|
|
119
144
|
|
|
120
145
|
/**
|
|
@@ -154,7 +179,9 @@ export function createCompilation() {
|
|
|
154
179
|
state: new Map(),
|
|
155
180
|
stores: new Set(),
|
|
156
181
|
warnings: [],
|
|
157
|
-
sectionCounter: 0
|
|
182
|
+
sectionCounter: 0,
|
|
183
|
+
collections: new Map(),
|
|
184
|
+
pagination: null
|
|
158
185
|
};
|
|
159
186
|
}
|
|
160
187
|
|