@zvndev/darkmown 2.11.0 → 2.12.1
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 +4 -4
- package/CHANGELOG.md +15 -0
- package/README.md +10 -4
- package/docs/cli.md +1 -0
- package/docs/spec-alignment.md +10 -0
- package/package.json +1 -1
- package/site/pages/docs/index.wd +4 -4
- package/site/pages/playground/index.js +6 -8
- package/src/catalog.js +2 -0
- package/src/cli.js +14 -4
- package/src/compiler/markdown.js +14 -2
- package/src/compiler/page.js +12 -12
- package/src/font-subset.js +335 -105
- package/src/statics.js +171 -15
- package/types/font-subset.d.ts +54 -6
- package/types/statics.d.ts +42 -3
package/src/statics.js
CHANGED
|
@@ -67,36 +67,72 @@ function routeEntry(distRoot, route) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The conditional-request headers of an incoming request, forwarded by the
|
|
72
|
+
* caller. Passing this object turns on validator-based caching for the
|
|
73
|
+
* response: every served file (200s and the built 404) carries `Last-Modified`
|
|
74
|
+
* (the file's mtime) and a strong `ETag` (`"<size>-<mtimeMs>"`), plus a
|
|
75
|
+
* `Cache-Control` by file class — `no-cache` for HTML (always revalidate, but
|
|
76
|
+
* revalidations 304), `public, max-age=3600` for framework assets under
|
|
77
|
+
* `/__wd/` and media/image/font files (they change only across builds, and a
|
|
78
|
+
* rebuild also changes the validators), and `no-cache` for everything else. A
|
|
79
|
+
* matching `If-None-Match` (which takes precedence) or `If-Modified-Since`
|
|
80
|
+
* gets a bare 304: the validators again, no body, no Content-Encoding.
|
|
81
|
+
* Callers that omit it (the dev server) get no validators at all.
|
|
82
|
+
* @typedef {object} ConditionalRequest
|
|
83
|
+
* @property {string | string[] | undefined} [ifNoneMatch]
|
|
84
|
+
* @property {string | string[] | undefined} [ifModifiedSince]
|
|
85
|
+
*/
|
|
86
|
+
|
|
70
87
|
/**
|
|
71
88
|
* Serve a built file for a request URL out of `distRoot`, or a 404 page.
|
|
72
89
|
*
|
|
73
90
|
* When `options.acceptEncoding` carries the request's Accept-Encoding header,
|
|
74
91
|
* a compressible body (HTML, CSS, JS, JSON, SVG, …) is negotiated and sent
|
|
75
92
|
* brotli- or gzip-compressed with `Content-Encoding` + `Vary: Accept-Encoding`.
|
|
76
|
-
*
|
|
77
|
-
*
|
|
93
|
+
* When `options.validators` carries the request's conditional headers,
|
|
94
|
+
* validator-based caching (ETag/Last-Modified/Cache-Control, 304s) is on —
|
|
95
|
+
* see {@link ConditionalRequest}; the validators describe the selected
|
|
96
|
+
* variant, so they compose with the encoding negotiation (`Vary` already
|
|
97
|
+
* covers Accept-Encoding, and a 304 to a compressible type repeats it).
|
|
98
|
+
* Callers that omit both options (the dev server) get the exact pre-2.12
|
|
99
|
+
* behavior: 200 bodies stream, nothing is re-encoded, no caching headers.
|
|
78
100
|
* @param {string} distRoot Absolute path to the build output directory.
|
|
79
101
|
* @param {string} url Request URL (may include a query string).
|
|
80
102
|
* @param {http.ServerResponse} res Response to write to.
|
|
81
|
-
* @param {{ acceptEncoding?: string | string[] }} [options]
|
|
103
|
+
* @param {{ acceptEncoding?: string | string[], validators?: ConditionalRequest }} [options]
|
|
82
104
|
* @returns {void}
|
|
83
105
|
*/
|
|
84
106
|
export function serve(distRoot, url, res, options = {}) {
|
|
85
107
|
const encoding = negotiateEncoding(options.acceptEncoding);
|
|
86
108
|
const file = resolvePublicFile(distRoot, url || "/");
|
|
87
|
-
|
|
109
|
+
const stat = file ? fs.statSync(file, { throwIfNoEntry: false }) : undefined;
|
|
110
|
+
if (!file || !stat?.isFile()) {
|
|
88
111
|
const notFound = path.join(distRoot, "404.html");
|
|
89
|
-
const
|
|
112
|
+
const notFoundStat = fs.statSync(notFound, { throwIfNoEntry: false });
|
|
113
|
+
const built = Boolean(notFoundStat?.isFile());
|
|
114
|
+
const body = built
|
|
90
115
|
? fs.readFileSync(notFound)
|
|
91
116
|
: "<h1>Not found</h1><p>This route is hidden or has not been created.</p>";
|
|
92
|
-
// The 404 body is always text/html, always compressible.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
// The 404 body is always text/html, always compressible. The BUILT 404 is
|
|
118
|
+
// a real file, so it carries validators like any other HTML response; the
|
|
119
|
+
// inline fallback has no file to validate and stays header-minimal.
|
|
120
|
+
/** @type {Record<string, string>} */
|
|
121
|
+
const headers = {
|
|
122
|
+
"content-type": "text/html; charset=utf-8",
|
|
123
|
+
...htmlSecurityHeaders(distRoot, url)
|
|
124
|
+
};
|
|
125
|
+
if (options.validators && built) {
|
|
126
|
+
const etag = strongEtag(/** @type {fs.Stats} */ (notFoundStat));
|
|
127
|
+
headers.etag = etag;
|
|
128
|
+
headers["last-modified"] = /** @type {fs.Stats} */ (notFoundStat).mtime.toUTCString();
|
|
129
|
+
headers["cache-control"] = "no-cache";
|
|
130
|
+
if (isFresh(options.validators, etag, /** @type {fs.Stats} */ (notFoundStat))) {
|
|
131
|
+
sendNotModified(res, headers, true);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
sendBuffered(res, 404, headers, body, encoding);
|
|
100
136
|
return;
|
|
101
137
|
}
|
|
102
138
|
const type = contentType(file);
|
|
@@ -104,11 +140,24 @@ export function serve(distRoot, url, res, options = {}) {
|
|
|
104
140
|
const headers = type.startsWith("text/html")
|
|
105
141
|
? { "content-type": type, ...htmlSecurityHeaders(distRoot, url) }
|
|
106
142
|
: { "content-type": type };
|
|
107
|
-
|
|
143
|
+
const compressible = isCompressibleType(type);
|
|
144
|
+
if (options.validators) {
|
|
145
|
+
const etag = strongEtag(stat);
|
|
146
|
+
headers.etag = etag;
|
|
147
|
+
headers["last-modified"] = stat.mtime.toUTCString();
|
|
148
|
+
headers["cache-control"] = cacheControlFor(url || "/", type);
|
|
149
|
+
// The validators describe the variant the request would select, so the
|
|
150
|
+
// freshness check runs before any compression work.
|
|
151
|
+
if (isFresh(options.validators, etag, stat)) {
|
|
152
|
+
sendNotModified(res, headers, compressible);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!encoding || !compressible) {
|
|
108
157
|
// Identity path (or a non-compressible asset): stream, exactly as before.
|
|
109
158
|
// A compressible type still varies on Accept-Encoding even when this
|
|
110
159
|
// response is identity, so caches key correctly.
|
|
111
|
-
if (
|
|
160
|
+
if (compressible) headers.vary = appendVary(headers.vary, "Accept-Encoding");
|
|
112
161
|
res.writeHead(200, headers);
|
|
113
162
|
pipeFile(file, res);
|
|
114
163
|
return;
|
|
@@ -126,6 +175,113 @@ export function serve(distRoot, url, res, options = {}) {
|
|
|
126
175
|
sendBuffered(res, 200, headers, body, encoding);
|
|
127
176
|
}
|
|
128
177
|
|
|
178
|
+
/** Image and font extensions that earn the long-cache policy outside `/__wd/`. */
|
|
179
|
+
const LONG_CACHE_EXTENSIONS = new Set([
|
|
180
|
+
".png",
|
|
181
|
+
".jpg",
|
|
182
|
+
".jpeg",
|
|
183
|
+
".gif",
|
|
184
|
+
".webp",
|
|
185
|
+
".avif",
|
|
186
|
+
".ico",
|
|
187
|
+
".svg",
|
|
188
|
+
".woff",
|
|
189
|
+
".woff2",
|
|
190
|
+
".ttf",
|
|
191
|
+
".otf"
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The Cache-Control for a served file: `no-cache` for HTML (every load
|
|
196
|
+
* revalidates, and the revalidation is a cheap 304), `public, max-age=3600`
|
|
197
|
+
* for framework assets under `/__wd/` and for media/image/font files anywhere
|
|
198
|
+
* (unchanged between builds, and any rebuild moves the validators), and
|
|
199
|
+
* `no-cache` for everything else — a deliberately unopinionated default that
|
|
200
|
+
* keeps correctness for files with no caching contract (routes.json, feeds).
|
|
201
|
+
* @param {string} url Request URL (may include a query string).
|
|
202
|
+
* @param {string} type The resolved content-type.
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
function cacheControlFor(url, type) {
|
|
206
|
+
if (type.startsWith("text/html")) return "no-cache";
|
|
207
|
+
const pathname = url.split("?")[0];
|
|
208
|
+
if (pathname.startsWith("/__wd/")) return "public, max-age=3600";
|
|
209
|
+
if (LONG_CACHE_EXTENSIONS.has(path.extname(pathname).toLowerCase())) {
|
|
210
|
+
return "public, max-age=3600";
|
|
211
|
+
}
|
|
212
|
+
return "no-cache";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A strong ETag for a served file: `"<size>-<mtimeMs>"`. Byte-exact static
|
|
217
|
+
* files change exactly when size or mtime does, so no weak prefix.
|
|
218
|
+
* @param {fs.Stats} stat
|
|
219
|
+
* @returns {string}
|
|
220
|
+
*/
|
|
221
|
+
function strongEtag(stat) {
|
|
222
|
+
return `"${stat.size}-${Math.floor(stat.mtimeMs)}"`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Whether the request's conditional headers match the file's current
|
|
227
|
+
* validators. `If-None-Match` wins when present (RFC 7232 precedence: an
|
|
228
|
+
* `If-Modified-Since` riding alongside is ignored), and uses weak comparison
|
|
229
|
+
* semantics — a `*` matches anything, otherwise the client token must be the
|
|
230
|
+
* ETag itself (browsers echo back exactly what they received).
|
|
231
|
+
* `If-Modified-Since` compares at second precision, the granularity the
|
|
232
|
+
* Last-Modified header itself carries.
|
|
233
|
+
* @param {ConditionalRequest} request
|
|
234
|
+
* @param {string} etag The file's current ETag.
|
|
235
|
+
* @param {fs.Stats} stat
|
|
236
|
+
* @returns {boolean}
|
|
237
|
+
*/
|
|
238
|
+
function isFresh(request, etag, stat) {
|
|
239
|
+
const inm = headerValue(request.ifNoneMatch);
|
|
240
|
+
if (inm !== undefined) {
|
|
241
|
+
return inm
|
|
242
|
+
.split(",")
|
|
243
|
+
.some((candidate) => candidate.trim() === "*" || candidate.trim() === etag);
|
|
244
|
+
}
|
|
245
|
+
const ims = headerValue(request.ifModifiedSince);
|
|
246
|
+
if (ims !== undefined) {
|
|
247
|
+
const time = Date.parse(ims);
|
|
248
|
+
if (Number.isNaN(time)) return false;
|
|
249
|
+
return Math.floor(stat.mtimeMs / 1000) <= Math.floor(time / 1000);
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Join a possibly-arrayed header value into one string, or undefined.
|
|
256
|
+
* @param {string | string[] | undefined} value
|
|
257
|
+
* @returns {string | undefined}
|
|
258
|
+
*/
|
|
259
|
+
function headerValue(value) {
|
|
260
|
+
return Array.isArray(value) ? value.join(",") : value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Answer 304: the validators and Cache-Control again (so the cached entry's
|
|
265
|
+
* freshness state is refreshed), `Vary: Accept-Encoding` for compressible
|
|
266
|
+
* types (the 304 revalidates one variant of several), and no body or body
|
|
267
|
+
* headers — no Content-Type, no Content-Encoding.
|
|
268
|
+
* @param {http.ServerResponse} res
|
|
269
|
+
* @param {Record<string, string>} headers The 200 response's headers.
|
|
270
|
+
* @param {boolean} compressible
|
|
271
|
+
* @returns {void}
|
|
272
|
+
*/
|
|
273
|
+
function sendNotModified(res, headers, compressible) {
|
|
274
|
+
/** @type {Record<string, string>} */
|
|
275
|
+
const out = {
|
|
276
|
+
etag: /** @type {string} */ (headers.etag),
|
|
277
|
+
"last-modified": /** @type {string} */ (headers["last-modified"]),
|
|
278
|
+
"cache-control": /** @type {string} */ (headers["cache-control"])
|
|
279
|
+
};
|
|
280
|
+
if (compressible) out.vary = "Accept-Encoding";
|
|
281
|
+
res.writeHead(304, out);
|
|
282
|
+
res.end();
|
|
283
|
+
}
|
|
284
|
+
|
|
129
285
|
/**
|
|
130
286
|
* Write a buffered response body for a COMPRESSIBLE content-type, compressing
|
|
131
287
|
* when an encoding was negotiated. Always appends `Vary: Accept-Encoding` so
|
package/types/font-subset.d.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Subset every project-local font referenced by the emitted stylesheets and
|
|
3
|
-
* rewrite the dist CSS to lead with the subset woff2.
|
|
3
|
+
* rewrite the dist CSS to lead with the subset woff2. Then inject one font
|
|
4
|
+
* `<link rel="preload" as="font" crossorigin>` per project font into every
|
|
5
|
+
* emitted page that loads it through its own linked stylesheets.
|
|
4
6
|
*
|
|
5
7
|
* Skips, none of them errors: remote/protocol-relative/data/relative URLs
|
|
6
|
-
* (silently), already-woff2 sources (silently
|
|
7
|
-
* project (silently — a broken link is an
|
|
8
|
-
* An unreadable font file, and one the
|
|
9
|
-
* the file and ship as-is. With no
|
|
10
|
-
* pass is a no-op and
|
|
8
|
+
* (silently), already-woff2 sources (silently — not subsetted, but preloaded
|
|
9
|
+
* as-is), URLs that escape or miss the project (silently — a broken link is an
|
|
10
|
+
* authoring bug, not a subsetting one). An unreadable font file, and one the
|
|
11
|
+
* toolchain fails on, get a hint naming the file and ship as-is. With no
|
|
12
|
+
* project-font reference in any emitted stylesheet the pass is a no-op and
|
|
13
|
+
* never even scans the HTML.
|
|
11
14
|
* @param {Paths} paths
|
|
12
15
|
* @param {{ cacheDir?: string, quiet?: boolean,
|
|
13
16
|
* runJobs?: (payload: { charset: number[], jobs: SubsetJob[] }) => SubsetResult }} [options]
|
|
@@ -68,6 +71,19 @@ export function charsetFromTexts(texts: string[]): number[];
|
|
|
68
71
|
* @returns {string}
|
|
69
72
|
*/
|
|
70
73
|
export function rewriteFontFaceCss(css: string, subsetByUrl: Map<string, string>): string;
|
|
74
|
+
/**
|
|
75
|
+
* Inject one `<link rel="preload" href="…" as="font" type="…" crossorigin>`
|
|
76
|
+
* per font, immediately after the LAST stylesheet link in the head (after the
|
|
77
|
+
* favicon link when the page has no stylesheet link, and not at all when it
|
|
78
|
+
* has neither). `crossorigin` is mandatory: font fetches are always CORS-mode,
|
|
79
|
+
* even same-origin, so a preload without it downloads the font twice.
|
|
80
|
+
* Idempotent — a preload already present (an earlier pass over the same dist)
|
|
81
|
+
* is never duplicated.
|
|
82
|
+
* @param {string} html The emitted page.
|
|
83
|
+
* @param {PreloadFont[]} preloads The fonts to preload, in order.
|
|
84
|
+
* @returns {string} The page, unchanged when there is nothing new to inject.
|
|
85
|
+
*/
|
|
86
|
+
export function injectPreloadLinks(html: string, preloads: PreloadFont[]): string;
|
|
71
87
|
/** The base charset, sorted ascending. */
|
|
72
88
|
export const BASE_CODEPOINTS: number[];
|
|
73
89
|
export type Paths = import("./config.js").Paths;
|
|
@@ -142,3 +158,35 @@ export type FontRef = {
|
|
|
142
158
|
*/
|
|
143
159
|
cacheFile?: string | undefined;
|
|
144
160
|
};
|
|
161
|
+
/**
|
|
162
|
+
* A project-local font an emitted stylesheet can preload. For a subsettable
|
|
163
|
+
* font this is the FontRef itself; an already-woff2 original carries a null
|
|
164
|
+
* `subsetUrl` (there is nothing to subset — it preloads as-is).
|
|
165
|
+
*/
|
|
166
|
+
export type PreloadRef = {
|
|
167
|
+
/**
|
|
168
|
+
* The url() target exactly as written in the CSS.
|
|
169
|
+
*/
|
|
170
|
+
url: string;
|
|
171
|
+
/**
|
|
172
|
+
* Absolute path of the font file it resolves to.
|
|
173
|
+
*/
|
|
174
|
+
source: string;
|
|
175
|
+
/**
|
|
176
|
+
* Served URL of the subset, when subsettable.
|
|
177
|
+
*/
|
|
178
|
+
subsetUrl: string | null;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* One preload link to inject into a page.
|
|
182
|
+
*/
|
|
183
|
+
export type PreloadFont = {
|
|
184
|
+
/**
|
|
185
|
+
* The URL the page's CSS leads with (subset or original).
|
|
186
|
+
*/
|
|
187
|
+
href: string;
|
|
188
|
+
/**
|
|
189
|
+
* The MIME type for the href's extension.
|
|
190
|
+
*/
|
|
191
|
+
type: string;
|
|
192
|
+
};
|
package/types/statics.d.ts
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conditional-request headers of an incoming request, forwarded by the
|
|
3
|
+
* caller. Passing this object turns on validator-based caching for the
|
|
4
|
+
* response: every served file (200s and the built 404) carries `Last-Modified`
|
|
5
|
+
* (the file's mtime) and a strong `ETag` (`"<size>-<mtimeMs>"`), plus a
|
|
6
|
+
* `Cache-Control` by file class — `no-cache` for HTML (always revalidate, but
|
|
7
|
+
* revalidations 304), `public, max-age=3600` for framework assets under
|
|
8
|
+
* `/__wd/` and media/image/font files (they change only across builds, and a
|
|
9
|
+
* rebuild also changes the validators), and `no-cache` for everything else. A
|
|
10
|
+
* matching `If-None-Match` (which takes precedence) or `If-Modified-Since`
|
|
11
|
+
* gets a bare 304: the validators again, no body, no Content-Encoding.
|
|
12
|
+
* Callers that omit it (the dev server) get no validators at all.
|
|
13
|
+
* @typedef {object} ConditionalRequest
|
|
14
|
+
* @property {string | string[] | undefined} [ifNoneMatch]
|
|
15
|
+
* @property {string | string[] | undefined} [ifModifiedSince]
|
|
16
|
+
*/
|
|
1
17
|
/**
|
|
2
18
|
* Serve a built file for a request URL out of `distRoot`, or a 404 page.
|
|
3
19
|
*
|
|
4
20
|
* When `options.acceptEncoding` carries the request's Accept-Encoding header,
|
|
5
21
|
* a compressible body (HTML, CSS, JS, JSON, SVG, …) is negotiated and sent
|
|
6
22
|
* brotli- or gzip-compressed with `Content-Encoding` + `Vary: Accept-Encoding`.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
23
|
+
* When `options.validators` carries the request's conditional headers,
|
|
24
|
+
* validator-based caching (ETag/Last-Modified/Cache-Control, 304s) is on —
|
|
25
|
+
* see {@link ConditionalRequest}; the validators describe the selected
|
|
26
|
+
* variant, so they compose with the encoding negotiation (`Vary` already
|
|
27
|
+
* covers Accept-Encoding, and a 304 to a compressible type repeats it).
|
|
28
|
+
* Callers that omit both options (the dev server) get the exact pre-2.12
|
|
29
|
+
* behavior: 200 bodies stream, nothing is re-encoded, no caching headers.
|
|
9
30
|
* @param {string} distRoot Absolute path to the build output directory.
|
|
10
31
|
* @param {string} url Request URL (may include a query string).
|
|
11
32
|
* @param {http.ServerResponse} res Response to write to.
|
|
12
|
-
* @param {{ acceptEncoding?: string | string[] }} [options]
|
|
33
|
+
* @param {{ acceptEncoding?: string | string[], validators?: ConditionalRequest }} [options]
|
|
13
34
|
* @returns {void}
|
|
14
35
|
*/
|
|
15
36
|
export function serve(distRoot: string, url: string, res: http.ServerResponse, options?: {
|
|
16
37
|
acceptEncoding?: string | string[];
|
|
38
|
+
validators?: ConditionalRequest;
|
|
17
39
|
}): void;
|
|
18
40
|
/**
|
|
19
41
|
* Pick the response encoding from an Accept-Encoding header value: brotli when
|
|
@@ -85,4 +107,21 @@ export function resolvePublicFile(distRoot: string, url: string): string | null;
|
|
|
85
107
|
* @returns {string}
|
|
86
108
|
*/
|
|
87
109
|
export function contentType(file: string): string;
|
|
110
|
+
/**
|
|
111
|
+
* The conditional-request headers of an incoming request, forwarded by the
|
|
112
|
+
* caller. Passing this object turns on validator-based caching for the
|
|
113
|
+
* response: every served file (200s and the built 404) carries `Last-Modified`
|
|
114
|
+
* (the file's mtime) and a strong `ETag` (`"<size>-<mtimeMs>"`), plus a
|
|
115
|
+
* `Cache-Control` by file class — `no-cache` for HTML (always revalidate, but
|
|
116
|
+
* revalidations 304), `public, max-age=3600` for framework assets under
|
|
117
|
+
* `/__wd/` and media/image/font files (they change only across builds, and a
|
|
118
|
+
* rebuild also changes the validators), and `no-cache` for everything else. A
|
|
119
|
+
* matching `If-None-Match` (which takes precedence) or `If-Modified-Since`
|
|
120
|
+
* gets a bare 304: the validators again, no body, no Content-Encoding.
|
|
121
|
+
* Callers that omit it (the dev server) get no validators at all.
|
|
122
|
+
*/
|
|
123
|
+
export type ConditionalRequest = {
|
|
124
|
+
ifNoneMatch?: string | string[] | undefined;
|
|
125
|
+
ifModifiedSince?: string | string[] | undefined;
|
|
126
|
+
};
|
|
88
127
|
import http from "node:http";
|