@docubook/flame 2.0.0-beta.2 → 2.0.0-beta.4
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/.docu/components/Pagination.tsx +12 -1
- package/.docu/lib/build.deno.js +1 -1
- package/.docu/lib/{build.impl-BJsi28mD.js → build.impl-CT-YLyLn.js} +73 -25
- package/.docu/lib/build.impl-CT-YLyLn.js.map +1 -0
- package/.docu/lib/build.impl-cJPJeQ8r.js +2 -0
- package/.docu/lib/build.node.js +1 -1
- package/.docu/lib/clean.js +1 -1
- package/.docu/lib/deploy.deno.js +1 -1
- package/.docu/lib/deploy.node.js +1 -1
- package/.docu/lib/{deploy.shared-D3UWafa6.js → deploy.shared-CmOzm3b5.js} +3 -3
- package/.docu/lib/{deploy.shared-D3UWafa6.js.map → deploy.shared-CmOzm3b5.js.map} +1 -1
- package/.docu/lib/{html.shared-DSn-7_6t.js → html.shared-WwQnhDty.js} +269 -34
- package/.docu/lib/html.shared-WwQnhDty.js.map +1 -0
- package/.docu/lib/{logger-CycvLCrQ.js → logger-Cz0CPMi8.js} +9 -15
- package/.docu/lib/logger-Cz0CPMi8.js.map +1 -0
- package/.docu/lib/{paths-BtOIPBQ9.js → paths-Bl2cdp9E.js} +27 -3
- package/.docu/lib/paths-Bl2cdp9E.js.map +1 -0
- package/.docu/lib/preview.deno.js +1 -1
- package/.docu/lib/{preview.impl-CXJS2tQS.js → preview.impl-BaYhTRBW.js} +4 -4
- package/.docu/lib/{preview.impl-CXJS2tQS.js.map → preview.impl-BaYhTRBW.js.map} +1 -1
- package/.docu/lib/preview.node.js +1 -1
- package/.docu/lib/server.deno.js +1 -1
- package/.docu/lib/{server.impl-CJuWimfN.js → server.impl-l8hXfV_0.js} +7 -7
- package/.docu/lib/server.impl-l8hXfV_0.js.map +1 -0
- package/.docu/lib/server.node.js +1 -1
- package/.docu/lib/{utils-DA17MyQG.js → utils-B_CoyKie.js} +28 -6
- package/.docu/lib/utils-B_CoyKie.js.map +1 -0
- package/.docu/node/build.impl.ts +115 -43
- package/.docu/node/build.ts +134 -44
- package/.docu/node/cache-key.ts +311 -0
- package/.docu/node/html.shared.ts +2 -1
- package/.docu/node/html.ts +2 -1
- package/.docu/node/hydrate.node.ts +8 -11
- package/.docu/node/hydrate.ts +13 -14
- package/.docu/node/mdx.ts +14 -2
- package/.docu/node/paths.ts +27 -1
- package/.docu/node/plugin-builder.ts +10 -1
- package/.docu/node/route.ts +19 -21
- package/.docu/node/security.ts +18 -1
- package/.docu/node/server-routes.ts +3 -3
- package/.docu/node/types.ts +23 -5
- package/.docu/node/utils.ts +22 -2
- package/bin/cli.js +56 -18
- package/package.json +11 -17
- package/.docu/lib/build.impl-BJsi28mD.js.map +0 -1
- package/.docu/lib/build.impl-yVLaa1eQ.js +0 -2
- package/.docu/lib/html.shared-DSn-7_6t.js.map +0 -1
- package/.docu/lib/logger-CycvLCrQ.js.map +0 -1
- package/.docu/lib/paths-BtOIPBQ9.js.map +0 -1
- package/.docu/lib/server.impl-CJuWimfN.js.map +0 -1
- package/.docu/lib/utils-DA17MyQG.js.map +0 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { FRAMEWORK_ROOT, STYLES_DIR, resolveProjectFile } from "./paths";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build cache version — bump when the toolchain output contract changes
|
|
8
|
+
* (e.g. Bun.build barrel optimization, Tailwind CLI upgrade). Old caches
|
|
9
|
+
* with a mismatched version are discarded on read (see build.ts readCache).
|
|
10
|
+
*/
|
|
11
|
+
export const BUILD_CACHE_VERSION = 4;
|
|
12
|
+
|
|
13
|
+
/** Toolchain fingerprint: Bun version on Bun, Deno version on Deno, Node elsewhere. */
|
|
14
|
+
export function runtimeStamp(): string {
|
|
15
|
+
const g = globalThis as Record<string, unknown>;
|
|
16
|
+
const bun = g.Bun as { version?: string } | undefined;
|
|
17
|
+
if (typeof bun?.version === "string" && bun.version.length > 0) return `bun-${bun.version}`;
|
|
18
|
+
const deno = g.Deno as { version?: { deno?: string } } | undefined;
|
|
19
|
+
if (typeof deno?.version?.deno === "string" && deno.version.deno.length > 0)
|
|
20
|
+
return `deno-${deno.version.deno}`;
|
|
21
|
+
const proc = g.process as { version?: string } | undefined;
|
|
22
|
+
if (typeof proc?.version === "string" && proc.version.length > 0) return `node-${proc.version}`;
|
|
23
|
+
return "node-unknown";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when a CSS file can change Tailwind v4 output.
|
|
28
|
+
* v4 is CSS-first: `@theme`, `@source`, `@plugin`, `@custom-variant`,
|
|
29
|
+
* `@utility`, `@config`, `@apply`/`@variant`, and `@import "tailwindcss"`
|
|
30
|
+
* all live in CSS. Plain CSS without these cannot affect the CLI output,
|
|
31
|
+
* so it is excluded to avoid false cache busts. Broad match deliberate:
|
|
32
|
+
* false positive busts safe, false negative serves stale CSS.
|
|
33
|
+
*/
|
|
34
|
+
export function isTailwindRelevantCss(content: string): boolean {
|
|
35
|
+
return (
|
|
36
|
+
content.includes("@theme") ||
|
|
37
|
+
content.includes("@source") ||
|
|
38
|
+
content.includes("@plugin") ||
|
|
39
|
+
content.includes("@custom-variant") ||
|
|
40
|
+
content.includes("@utility") ||
|
|
41
|
+
content.includes("@config") ||
|
|
42
|
+
content.includes("@apply") ||
|
|
43
|
+
content.includes("@variant") ||
|
|
44
|
+
content.includes("@layer") ||
|
|
45
|
+
content.includes("tailwindcss")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const IMPORT_RE = /@import\s+(?:url\()?["']([^"']+)["']/g;
|
|
50
|
+
const MAX_IMPORT_DEPTH = 10;
|
|
51
|
+
|
|
52
|
+
/** Resolve `./` + `../` imports only — bare specifiers are version-pinned deps. */
|
|
53
|
+
function resolveRelativeImport(spec: string, fromDir: string): string | undefined {
|
|
54
|
+
if (!spec.startsWith(".")) return undefined;
|
|
55
|
+
const clean = spec.split("?")[0]!.split("#")[0]!;
|
|
56
|
+
if (clean.length === 0) return undefined;
|
|
57
|
+
return join(fromDir, clean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read a CSS file plus transitively imported relative files.
|
|
62
|
+
* Non-relevant files contribute "" themselves, but their imports are still
|
|
63
|
+
* followed (nested file may carry `@theme`). `visited` breaks import cycles.
|
|
64
|
+
* Missing/unreadable files resolve to "" — never throws.
|
|
65
|
+
*/
|
|
66
|
+
function readCssWithImports(path: string, visited: Set<string>, depth = 0): string {
|
|
67
|
+
if (depth > MAX_IMPORT_DEPTH || visited.has(path)) return "";
|
|
68
|
+
visited.add(path);
|
|
69
|
+
let content = "";
|
|
70
|
+
try {
|
|
71
|
+
if (!existsSync(path)) return "";
|
|
72
|
+
content = readFileSync(path, "utf-8");
|
|
73
|
+
} catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
let out = isTailwindRelevantCss(content) ? content : "";
|
|
77
|
+
try {
|
|
78
|
+
const dir = join(path, "..");
|
|
79
|
+
for (const m of content.matchAll(IMPORT_RE)) {
|
|
80
|
+
const resolved = resolveRelativeImport(m[1] ?? "", dir);
|
|
81
|
+
if (resolved) out += readCssWithImports(resolved, visited, depth + 1);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// import scan failed — keep what we have
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function scanCssDir(dir: string, visited: Set<string>): string {
|
|
90
|
+
let out = "";
|
|
91
|
+
try {
|
|
92
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
const full = join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) {
|
|
96
|
+
if (e.name === "assets" || e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
97
|
+
out += scanCssDir(full, visited);
|
|
98
|
+
} else if (e.name.endsWith(".css")) {
|
|
99
|
+
out += readCssWithImports(full, visited);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// docs/ missing — nothing to add
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scanRootCss(root: string, visited: Set<string>): string {
|
|
109
|
+
let out = "";
|
|
110
|
+
try {
|
|
111
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
112
|
+
for (const e of entries) {
|
|
113
|
+
if (e.isFile() && e.name.endsWith(".css")) {
|
|
114
|
+
out += readCssWithImports(join(root, e.name), visited);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// unreadable root — proceed without
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Hash @theme/@source/@plugin-bearing CSS in project docs/ + root *.css. */
|
|
124
|
+
function tailwindCssThemeInputs(root: string): string {
|
|
125
|
+
const visited = new Set<string>();
|
|
126
|
+
return scanCssDir(join(root, "docs"), visited) + scanRootCss(root, visited);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const TAILWIND_CONFIG_FILES = [
|
|
130
|
+
"tailwind.config.ts",
|
|
131
|
+
"tailwind.config.js",
|
|
132
|
+
"tailwind.config.mjs",
|
|
133
|
+
"tailwind.config.cjs",
|
|
134
|
+
"tailwind.config.mts",
|
|
135
|
+
"tailwind.config.cts",
|
|
136
|
+
"postcss.config.js",
|
|
137
|
+
"postcss.config.mjs",
|
|
138
|
+
"postcss.config.cjs",
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* JS config still affects v4 when referenced via `@config`
|
|
143
|
+
* (plus PostCSS pipeline config). Content hashed when present.
|
|
144
|
+
*/
|
|
145
|
+
function tailwindJsConfigInputs(root: string): string {
|
|
146
|
+
let out = "";
|
|
147
|
+
for (const name of TAILWIND_CONFIG_FILES) {
|
|
148
|
+
try {
|
|
149
|
+
const p = join(root, name);
|
|
150
|
+
if (existsSync(p)) out += readFileSync(p, "utf-8") + "\0";
|
|
151
|
+
} catch {
|
|
152
|
+
// unreadable config — skip
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readInstalledVersion(pkg: string, roots: string[]): string {
|
|
159
|
+
for (const root of roots) {
|
|
160
|
+
try {
|
|
161
|
+
const p = join(root, "node_modules", ...pkg.split("/"), "package.json");
|
|
162
|
+
if (existsSync(p)) {
|
|
163
|
+
const parsed = JSON.parse(readFileSync(p, "utf-8")) as { version?: string };
|
|
164
|
+
if (typeof parsed.version === "string" && parsed.version.length > 0) return parsed.version;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// try next root
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pin resolved CSS-affecting deps into the key (installed version wins). */
|
|
174
|
+
function tailwindVersionPins(root: string): string {
|
|
175
|
+
const names = ["tailwindcss", "@tailwindcss/cli", "@tailwindcss/typography", "daisyui"] as const;
|
|
176
|
+
let extra = "";
|
|
177
|
+
const roots = [root, FRAMEWORK_ROOT];
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
const installed = readInstalledVersion(name, roots);
|
|
180
|
+
if (installed) extra += `${name}@${installed}\0`;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const pkgPath = join(root, "package.json");
|
|
184
|
+
if (existsSync(pkgPath)) {
|
|
185
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
|
|
186
|
+
dependencies?: Record<string, string>;
|
|
187
|
+
devDependencies?: Record<string, string>;
|
|
188
|
+
};
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
const range = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? "";
|
|
191
|
+
if (range) extra += `${name}:${range}\0`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// package.json unreadable — proceed without version pin
|
|
196
|
+
}
|
|
197
|
+
return extra;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Extra Tailwind inputs beyond globals.css + theme.
|
|
202
|
+
* v4 is CSS-first: user overrides live in `@theme`/`@source`/`@plugin`
|
|
203
|
+
* blocks inside CSS files under the project root (e.g. docs slash star dot css),
|
|
204
|
+
* plus JS config referenced via `@config` and installed plugin versions.
|
|
205
|
+
* Missing files resolve to "" — never throws.
|
|
206
|
+
*/
|
|
207
|
+
export function tailwindExtraInputs(root: string = resolveProjectFile()): string {
|
|
208
|
+
return (
|
|
209
|
+
tailwindCssThemeInputs(root) + "\0" + tailwindJsConfigInputs(root) + tailwindVersionPins(root)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Shared Tailwind cache key: globals.css + theme + tailwind config +
|
|
215
|
+
* toolchain version. Used by both hydrate.ts (Bun) and hydrate.node.ts
|
|
216
|
+
* (Vite) so the two runtimes agree on filenames. Segments joined with NUL
|
|
217
|
+
* so `("ab","c")` and `("a","bc")` hash differently.
|
|
218
|
+
*/
|
|
219
|
+
export function computeTailwindCacheKey(
|
|
220
|
+
globals: string,
|
|
221
|
+
themeSuffix: string,
|
|
222
|
+
projectRoot: string = resolveProjectFile()
|
|
223
|
+
): string {
|
|
224
|
+
const h = createHash("sha256");
|
|
225
|
+
h.update(globals);
|
|
226
|
+
h.update("\0");
|
|
227
|
+
h.update(themeSuffix);
|
|
228
|
+
h.update("\0");
|
|
229
|
+
h.update(tailwindExtraInputs(projectRoot));
|
|
230
|
+
h.update("\0");
|
|
231
|
+
h.update(runtimeStamp());
|
|
232
|
+
h.update("\0");
|
|
233
|
+
h.update(`v${BUILD_CACHE_VERSION}`);
|
|
234
|
+
return h.digest("hex").slice(0, 16);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Read globals.css content ("" when missing). Shared by both hydrators. */
|
|
238
|
+
export function readGlobalsCss(): string {
|
|
239
|
+
const globalsPath = join(STYLES_DIR, "globals.css");
|
|
240
|
+
return existsSync(globalsPath) ? readFileSync(globalsPath, "utf-8") : "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Atomic file write: tmp + rename so a crash mid-write never leaves a
|
|
245
|
+
* half-written cache/CSS behind. Tmp name includes pid so parallel
|
|
246
|
+
* builds (`bun run --parallel`) do not clobber each other.
|
|
247
|
+
*/
|
|
248
|
+
export async function atomicWriteFile(
|
|
249
|
+
writeFile: (p: string, data: string | Uint8Array) => Promise<void>,
|
|
250
|
+
rename: (from: string, to: string) => Promise<void>,
|
|
251
|
+
unlink: (p: string) => Promise<void>,
|
|
252
|
+
target: string,
|
|
253
|
+
data: string | Uint8Array
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
const g = globalThis as Record<string, unknown>;
|
|
256
|
+
const proc = g.process as { pid?: number } | undefined;
|
|
257
|
+
const pid = typeof proc?.pid === "number" ? proc.pid : Math.floor(Math.random() * 1e9);
|
|
258
|
+
const tmp = `${target}.tmp-${pid}-${Date.now()}`;
|
|
259
|
+
try {
|
|
260
|
+
await writeFile(tmp, data);
|
|
261
|
+
await rename(tmp, target);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
try {
|
|
264
|
+
await unlink(tmp);
|
|
265
|
+
} catch {
|
|
266
|
+
// best-effort tmp cleanup
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Wire memory-pressure hook once: OS low-memory → drop parsed page maps. */
|
|
273
|
+
let memoryPressureHooked = false;
|
|
274
|
+
export function hookMemoryPressure(clear: () => void): void {
|
|
275
|
+
if (memoryPressureHooked) return;
|
|
276
|
+
memoryPressureHooked = true;
|
|
277
|
+
try {
|
|
278
|
+
const g = globalThis as Record<string, unknown>;
|
|
279
|
+
const proc = g.process as
|
|
280
|
+
| {
|
|
281
|
+
on?: (event: string, listener: (level: string) => void) => void;
|
|
282
|
+
}
|
|
283
|
+
| undefined;
|
|
284
|
+
proc?.on?.("memoryPressure", () => {
|
|
285
|
+
try {
|
|
286
|
+
clear();
|
|
287
|
+
} catch {
|
|
288
|
+
// never throw out of a pressure handler
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
} catch {
|
|
292
|
+
// runtimes without the event (Node < Bun 1.4 backport) — no-op
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Hash of compiled MDX module sources (sorted keys + content).
|
|
298
|
+
* Used to skip the JS bundle rebuild when no page content changed.
|
|
299
|
+
*/
|
|
300
|
+
export function hashMdxSources(mdxSources: Record<string, string>): string {
|
|
301
|
+
const h = createHash("sha256");
|
|
302
|
+
const slugs = Object.keys(mdxSources).sort();
|
|
303
|
+
h.update(`v${BUILD_CACHE_VERSION}:${runtimeStamp()}:`);
|
|
304
|
+
for (const slug of slugs) {
|
|
305
|
+
h.update(slug);
|
|
306
|
+
h.update("\0");
|
|
307
|
+
h.update(mdxSources[slug]);
|
|
308
|
+
h.update("\0");
|
|
309
|
+
}
|
|
310
|
+
return h.digest("hex").slice(0, 16);
|
|
311
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { escapeHtml } from "./escapeHtml";
|
|
10
|
+
import { cspMeta } from "./security";
|
|
10
11
|
|
|
11
12
|
import type { SeoMeta } from "./seo";
|
|
12
13
|
|
|
@@ -86,7 +87,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
|
|
|
86
87
|
${favicon ? `<link rel="icon" type="image/x-icon" href="${escapeHtml(resolvePath(favicon))}">` : ""}${themeStyle}
|
|
87
88
|
<link rel="preload" href="${escapeHtml(assetPrefix + css)}" as="style">
|
|
88
89
|
<link rel="stylesheet" href="${escapeHtml(assetPrefix + css)}">
|
|
89
|
-
${csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">` : ""}
|
|
90
|
+
${csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(cspMeta(csp))}">` : ""}
|
|
90
91
|
${seoTags}
|
|
91
92
|
<script${nonceAttr}>try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}</script>${headInjection}
|
|
92
93
|
</head>
|
package/.docu/node/html.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HtmlShellOptions } from "./html.shared";
|
|
2
2
|
export type { HtmlShellOptions };
|
|
3
|
+
import { cspMeta } from "./security";
|
|
3
4
|
|
|
4
5
|
export function htmlShell(opts: HtmlShellOptions): string {
|
|
5
6
|
const {
|
|
@@ -48,7 +49,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
|
|
|
48
49
|
${favicon ? `<link rel="icon" type="image/x-icon" href="${Bun.escapeHTML(resolvePath(favicon))}">` : ""}${themeStyle}
|
|
49
50
|
<link rel="preload" href="${Bun.escapeHTML(assetPrefix + css)}" as="style">
|
|
50
51
|
<link rel="stylesheet" href="${Bun.escapeHTML(assetPrefix + css)}">
|
|
51
|
-
${csp ? `<meta http-equiv="Content-Security-Policy" content="${Bun.escapeHTML(csp)}">` : ""}
|
|
52
|
+
${csp ? `<meta http-equiv="Content-Security-Policy" content="${Bun.escapeHTML(cspMeta(csp))}">` : ""}
|
|
52
53
|
${seoTags}
|
|
53
54
|
<script${nonceAttr}>try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}</script>${headInjection}
|
|
54
55
|
</head>
|
|
@@ -13,8 +13,7 @@ import { builtinModules, createRequire } from "node:module";
|
|
|
13
13
|
import { basename, dirname, join, resolve } from "node:path";
|
|
14
14
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
15
15
|
import { promisify } from "node:util";
|
|
16
|
-
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
17
|
-
import { createHash } from "node:crypto";
|
|
16
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
18
17
|
import { build as viteBuild } from "vite";
|
|
19
18
|
import {
|
|
20
19
|
ASSETS_DIR,
|
|
@@ -25,6 +24,7 @@ import {
|
|
|
25
24
|
loadDocuConfig,
|
|
26
25
|
} from "./paths";
|
|
27
26
|
import { buildThemeCss, getThemeConfig } from "./hydrate";
|
|
27
|
+
import { atomicWriteFile, computeTailwindCacheKey, readGlobalsCss } from "./cache-key";
|
|
28
28
|
import { resolveRoutes } from "./fs-scanner";
|
|
29
29
|
import { normalizeImporterPath } from "./security";
|
|
30
30
|
import type { DocuConfig, DocuRoute } from "./types";
|
|
@@ -59,10 +59,9 @@ function resolveTailwindBin(): string {
|
|
|
59
59
|
return join(dirname(pkgPath), binRel);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** Compute a cache key from globals.css + theme config
|
|
62
|
+
/** Compute a cache key from globals.css + theme + config + toolchain. */
|
|
63
63
|
function tailwindCacheKey(): string {
|
|
64
|
-
const
|
|
65
|
-
const globalsContent = existsSync(globalsPath) ? readFileSync(globalsPath, "utf-8") : "";
|
|
64
|
+
const globalsContent = readGlobalsCss();
|
|
66
65
|
let themeSuffix = "";
|
|
67
66
|
try {
|
|
68
67
|
const themeColors = getThemeConfig();
|
|
@@ -72,10 +71,7 @@ function tailwindCacheKey(): string {
|
|
|
72
71
|
} catch {
|
|
73
72
|
// theme config unavailable — proceed without it
|
|
74
73
|
}
|
|
75
|
-
return
|
|
76
|
-
.update(globalsContent + themeSuffix)
|
|
77
|
-
.digest("hex")
|
|
78
|
-
.slice(0, 16);
|
|
74
|
+
return computeTailwindCacheKey(globalsContent, themeSuffix);
|
|
79
75
|
}
|
|
80
76
|
|
|
81
77
|
/**
|
|
@@ -105,7 +101,7 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
|
|
|
105
101
|
}
|
|
106
102
|
|
|
107
103
|
let cssContent = await readFile(tmpCss, "utf-8");
|
|
108
|
-
await unlink(tmpCss);
|
|
104
|
+
await unlink(tmpCss).catch(() => {});
|
|
109
105
|
|
|
110
106
|
try {
|
|
111
107
|
const themeColors = getThemeConfig();
|
|
@@ -120,11 +116,12 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
|
|
|
120
116
|
|
|
121
117
|
// Use the same input-derived key for lookup and output — if inputs change,
|
|
122
118
|
// the key changes, cache busting works without a separate content hash.
|
|
119
|
+
// Atomic tmp+rename with pid suffix: parallel builds never clobber each other.
|
|
123
120
|
const cssFile = `client-${key}.css`;
|
|
124
121
|
const outPath = join(ASSETS_DIR, cssFile);
|
|
125
122
|
|
|
126
123
|
if (!existsSync(outPath)) {
|
|
127
|
-
await writeFile
|
|
124
|
+
await atomicWriteFile(writeFile, rename, unlink, outPath, cssContent);
|
|
128
125
|
}
|
|
129
126
|
|
|
130
127
|
return { file: cssFile, content: cssContent };
|
package/.docu/node/hydrate.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { mkdir, unlink } from "node:fs/promises";
|
|
3
|
-
import { existsSync
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, unlink, rename } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
5
4
|
import { resolveTheme, generateThemeCss, presetRegistry } from "@docubook/themes-colors";
|
|
6
5
|
import { ASSETS_DIR, cleanOldBundles, LIB_DIR, STYLES_DIR, loadDocuConfig } from "./paths";
|
|
6
|
+
import { atomicWriteFile, computeTailwindCacheKey, readGlobalsCss } from "./cache-key";
|
|
7
7
|
import { resolveRoutes } from "./fs-scanner";
|
|
8
8
|
import type { DocuRoute } from "./types";
|
|
9
9
|
import type { ThemeConfig } from "@docubook/themes-colors";
|
|
@@ -57,10 +57,9 @@ export function computeInlineThemeCss(): string | undefined {
|
|
|
57
57
|
return undefined;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/** Compute Tailwind cache key from globals.css + theme config. */
|
|
60
|
+
/** Compute Tailwind cache key from globals.css + theme + config + toolchain. */
|
|
61
61
|
function twCacheKey(): string {
|
|
62
|
-
const
|
|
63
|
-
const globals = existsSync(globalsPath) ? readFileSync(globalsPath, "utf-8") : "";
|
|
62
|
+
const globals = readGlobalsCss();
|
|
64
63
|
let themeSuffix = "";
|
|
65
64
|
try {
|
|
66
65
|
const themeColors = getThemeConfig();
|
|
@@ -68,10 +67,7 @@ function twCacheKey(): string {
|
|
|
68
67
|
} catch {
|
|
69
68
|
// theme config unavailable — proceed without
|
|
70
69
|
}
|
|
71
|
-
return
|
|
72
|
-
.update(globals + themeSuffix)
|
|
73
|
-
.digest("hex")
|
|
74
|
-
.slice(0, 16);
|
|
70
|
+
return computeTailwindCacheKey(globals, themeSuffix);
|
|
75
71
|
}
|
|
76
72
|
|
|
77
73
|
/** Run Tailwind CLI, caching by content hash. */
|
|
@@ -105,7 +101,7 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
|
|
|
105
101
|
}
|
|
106
102
|
|
|
107
103
|
let cssContent = await Bun.file(tmpCss).text();
|
|
108
|
-
await unlink(tmpCss);
|
|
104
|
+
await unlink(tmpCss).catch(() => {});
|
|
109
105
|
|
|
110
106
|
try {
|
|
111
107
|
const themeColors = getThemeConfig();
|
|
@@ -116,11 +112,14 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
|
|
|
116
112
|
);
|
|
117
113
|
}
|
|
118
114
|
|
|
119
|
-
// Use the same input-derived key for lookup and output — if inputs change,
|
|
120
|
-
// the key changes, cache busting works without a separate content hash.
|
|
121
115
|
const cssFile = `client-${key}.css`;
|
|
122
116
|
const outPath = join(ASSETS_DIR, cssFile);
|
|
123
|
-
|
|
117
|
+
// Atomic tmp+rename with pid suffix: parallel builds never clobber each
|
|
118
|
+
// other, and a crash cannot leave a half-written CSS file behind.
|
|
119
|
+
if (!existsSync(outPath)) {
|
|
120
|
+
const { writeFile } = await import("node:fs/promises");
|
|
121
|
+
await atomicWriteFile(writeFile, rename, unlink, outPath, cssContent);
|
|
122
|
+
}
|
|
124
123
|
|
|
125
124
|
return { file: cssFile, content: cssContent };
|
|
126
125
|
}
|
package/.docu/node/mdx.ts
CHANGED
|
@@ -174,7 +174,10 @@ async function serializeWithDocPlugins(
|
|
|
174
174
|
// Parse-once: when the prePass already extracted the frontmatter + stripped
|
|
175
175
|
// content, reuse it instead of re-parsing (the SSR phase skips extraction).
|
|
176
176
|
const { strippedContent, frontmatter } =
|
|
177
|
-
pre ??
|
|
177
|
+
pre ??
|
|
178
|
+
(opts.frontmatterSchema
|
|
179
|
+
? extractFrontmatterWithContent<Frontmatter>(rawMdx, opts.frontmatterSchema)
|
|
180
|
+
: extractFrontmatterWithContent<Frontmatter>(rawMdx));
|
|
178
181
|
|
|
179
182
|
const defaultRemark = createDefaultRemarkPlugins();
|
|
180
183
|
const defaultRehype = createDefaultRehypePlugins();
|
|
@@ -221,7 +224,9 @@ export async function compileMdx(
|
|
|
221
224
|
const tocs = extractTocsFromRawMdx(rawMdx);
|
|
222
225
|
const frontmatter =
|
|
223
226
|
pre?.frontmatter ??
|
|
224
|
-
|
|
227
|
+
(frontmatterSchema
|
|
228
|
+
? extractFrontmatterWithContent<Frontmatter>(rawMdx, frontmatterSchema).frontmatter
|
|
229
|
+
: extractFrontmatterWithContent<Frontmatter>(rawMdx).frontmatter);
|
|
225
230
|
const serialized = await serializeWithDocPlugins(
|
|
226
231
|
rawMdx,
|
|
227
232
|
{ remarkPlugins, rehypePlugins, frontmatterSchema },
|
|
@@ -304,6 +309,13 @@ export function getPageContent(href: string): string | undefined {
|
|
|
304
309
|
return pageContent.get(href);
|
|
305
310
|
}
|
|
306
311
|
|
|
312
|
+
/** Drop derived page maps under OS memory pressure (Bun 1.4 `memoryPressure`). */
|
|
313
|
+
export function clearDerivedPageCaches(): void {
|
|
314
|
+
pageContent.clear();
|
|
315
|
+
pageStripped.clear();
|
|
316
|
+
pageFrontmatter.clear();
|
|
317
|
+
}
|
|
318
|
+
|
|
307
319
|
export async function compileMdxModule(
|
|
308
320
|
rawMdx: string,
|
|
309
321
|
remarkPlugins?: Pluggable[],
|
package/.docu/node/paths.ts
CHANGED
|
@@ -10,7 +10,28 @@ import type { DocuConfig } from "./types";
|
|
|
10
10
|
|
|
11
11
|
// .docu/node/paths.ts → package root is 2 levels up
|
|
12
12
|
export const FRAMEWORK_ROOT = resolve(import.meta.dirname, "../..");
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find the project root by walking up from cwd until docu.json is found.
|
|
16
|
+
* Falls back to cwd when not found (tests, ad-hoc scripts). Bun 1.4
|
|
17
|
+
* `run --parallel --filter` may change cwd per workspace task, so a bare
|
|
18
|
+
* process.cwd() can point at the wrong package.
|
|
19
|
+
*/
|
|
20
|
+
function findProjectRoot(): string {
|
|
21
|
+
let dir = process.cwd();
|
|
22
|
+
for (let i = 0; i < 6; i++) {
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(join(dir, "docu.json"))) return dir;
|
|
25
|
+
} catch {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
const parent = resolve(dir, "..");
|
|
29
|
+
if (parent === dir) break;
|
|
30
|
+
dir = parent;
|
|
31
|
+
}
|
|
32
|
+
return process.cwd();
|
|
33
|
+
}
|
|
34
|
+
export const PROJECT_ROOT = findProjectRoot();
|
|
14
35
|
|
|
15
36
|
// Framework paths (internal)
|
|
16
37
|
export const PAGES_DIR = join(FRAMEWORK_ROOT, ".docu/pages");
|
|
@@ -29,6 +50,11 @@ export const DOCS_DIR = join(PROJECT_ROOT, "docs");
|
|
|
29
50
|
export const DOCS_ASSETS_DIR = join(PROJECT_ROOT, "docs/assets");
|
|
30
51
|
export const DOCU_CONFIG_PATH = join(PROJECT_ROOT, "docu.json");
|
|
31
52
|
|
|
53
|
+
/** Resolve a project-relative file against the discovered PROJECT_ROOT. */
|
|
54
|
+
export function resolveProjectFile(...segments: string[]): string {
|
|
55
|
+
return join(PROJECT_ROOT, ...segments);
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
// Config singleton
|
|
33
59
|
let _config: DocuConfig | null = null;
|
|
34
60
|
|
|
@@ -408,6 +408,8 @@ export class BuildPluginBuilder implements PluginBuilder {
|
|
|
408
408
|
* Execute the transformHtml chain in pipeline pattern.
|
|
409
409
|
* Each callback receives the **previous** callback's return value (or the
|
|
410
410
|
* original HTML for the first). Every callback **must** return a string.
|
|
411
|
+
* Callbacks returning a non-string (e.g. `undefined`) are skipped with a
|
|
412
|
+
* warning — the current HTML passes through unchanged for that step.
|
|
411
413
|
* Errors inside individual callbacks are caught and logged — the current
|
|
412
414
|
* HTML passes through unchanged for that step.
|
|
413
415
|
*
|
|
@@ -419,7 +421,14 @@ export class BuildPluginBuilder implements PluginBuilder {
|
|
|
419
421
|
let result = html;
|
|
420
422
|
for (let i = 0; i < this._transformHtml.length; i++) {
|
|
421
423
|
try {
|
|
422
|
-
|
|
424
|
+
const next = await this._transformHtml[i](result, context);
|
|
425
|
+
if (typeof next === "string") {
|
|
426
|
+
result = next;
|
|
427
|
+
} else {
|
|
428
|
+
console.warn(
|
|
429
|
+
`[plugin] transformHtml callback #${i + 1} returned invalid type (expected a string), keeping previous HTML`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
423
432
|
} catch (err) {
|
|
424
433
|
console.error(
|
|
425
434
|
`[plugin] transformHtml callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}`
|
package/.docu/node/route.ts
CHANGED
|
@@ -47,6 +47,22 @@ export function getRouteMap(): Map<string, string> {
|
|
|
47
47
|
return map;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Single pagination entry builder (DRY) — one `readPageFrontmatter` call
|
|
52
|
+
* serves both prev + next from the parse-once registry, so no file is
|
|
53
|
+
* re-read or re-parsed. `description` rides along on prev too; the UI
|
|
54
|
+
* keeps the paired prev minimal by design and only renders the rich
|
|
55
|
+
* title + description when prev stands alone (last page, no next).
|
|
56
|
+
*/
|
|
57
|
+
function toPaginationEntry(href: string, routeMap: Map<string, string>) {
|
|
58
|
+
const fm = readPageFrontmatter(href);
|
|
59
|
+
return {
|
|
60
|
+
href,
|
|
61
|
+
title: fm.title || routeMap.get(href) || "",
|
|
62
|
+
description: fm.description || "",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
/**
|
|
51
67
|
* Frontmatter for a page — read from the parse-once registry (populated
|
|
52
68
|
* during compilation) instead of re-reading + re-parsing the file. Falls
|
|
@@ -86,15 +102,7 @@ export function getPreviousNext(pathname: string) {
|
|
|
86
102
|
const routeMap = getRouteMap();
|
|
87
103
|
const first = paths[0];
|
|
88
104
|
if (!first) return { prev: null, next: null };
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
prev: null,
|
|
92
|
-
next: {
|
|
93
|
-
href: first,
|
|
94
|
-
title: fm.title || routeMap.get(first) || "",
|
|
95
|
-
description: fm.description || "",
|
|
96
|
-
},
|
|
97
|
-
};
|
|
105
|
+
return { prev: null, next: toPaginationEntry(first, routeMap) };
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
const paths = flattenRoutes();
|
|
@@ -109,19 +117,9 @@ export function getPreviousNext(pathname: string) {
|
|
|
109
117
|
const prevHref = index > 0 ? paths[index - 1] : null;
|
|
110
118
|
const nextHref = index < paths.length - 1 ? paths[index + 1] : null;
|
|
111
119
|
|
|
112
|
-
const prevFm = prevHref ? readPageFrontmatter(prevHref) : null;
|
|
113
|
-
const nextFm = nextHref ? readPageFrontmatter(nextHref) : null;
|
|
114
120
|
return {
|
|
115
|
-
prev: prevHref
|
|
116
|
-
|
|
117
|
-
: null,
|
|
118
|
-
next: nextHref
|
|
119
|
-
? {
|
|
120
|
-
href: nextHref,
|
|
121
|
-
title: nextFm?.title || routeMap.get(nextHref) || "",
|
|
122
|
-
description: nextFm?.description || "",
|
|
123
|
-
}
|
|
124
|
-
: null,
|
|
121
|
+
prev: prevHref ? toPaginationEntry(prevHref, routeMap) : null,
|
|
122
|
+
next: nextHref ? toPaginationEntry(nextHref, routeMap) : null,
|
|
125
123
|
};
|
|
126
124
|
}
|
|
127
125
|
|
package/.docu/node/security.ts
CHANGED
|
@@ -30,6 +30,15 @@ export function cspHeader(nonce: string, allowEval = false): string {
|
|
|
30
30
|
].join("; ");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Strip `frame-ancestors` for `<meta http-equiv="Content-Security-Policy">`.
|
|
35
|
+
* Browsers ignore `frame-ancestors` in meta CSP (header-only directive)
|
|
36
|
+
* and log a console warning. HTTP header from `cspHeader()` keeps it.
|
|
37
|
+
*/
|
|
38
|
+
export function cspMeta(csp: string): string {
|
|
39
|
+
return csp.replace(/;\s*frame-ancestors 'none'/, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
export function isPathSafe(pathname: string, baseDir: string): boolean {
|
|
34
43
|
const decoded = decodeURIComponent(pathname);
|
|
35
44
|
const resolved = resolve(baseDir, decoded.slice(1));
|
|
@@ -87,12 +96,20 @@ export function normalizeImporterPath(importer: string): string {
|
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
export function injectNonce(html: string, nonce: string): string {
|
|
90
|
-
|
|
99
|
+
const scripts = html.replace(/<script\b(?![^>]*\bsrc\s*=)([^>]*)>/gi, (match) => {
|
|
91
100
|
if (/nonce\s*=/i.test(match)) {
|
|
92
101
|
return match.replace(/nonce="[^"]*"/i, `nonce="${nonce}"`);
|
|
93
102
|
}
|
|
94
103
|
return match.replace(/>$/, ` nonce="${nonce}">`);
|
|
95
104
|
});
|
|
105
|
+
// Keep the <meta> CSP nonce in sync: browsers intersect the meta policy
|
|
106
|
+
// with the response-header policy, so a stale build-time nonce would block
|
|
107
|
+
// the very scripts re-tagged above.
|
|
108
|
+
return scripts.replace(/<meta\b[^>]*Content-Security-Policy[^>]*>/gi, (tag) =>
|
|
109
|
+
tag.replace(/'nonce-[^']*'|&#(?:x27;|39;)nonce-[^&]*(?:&#(?:x27;|39;))/i, (match) =>
|
|
110
|
+
match.startsWith("&#") ? `'nonce-${nonce}'` : `'nonce-${nonce}'`
|
|
111
|
+
)
|
|
112
|
+
);
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
export interface PluginResponseLike {
|