@docubook/flame 1.2.0 → 1.3.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.
@@ -0,0 +1,265 @@
1
+ import type { Pluggable } from "unified";
2
+ import type { DocuConfig } from "./types";
3
+
4
+ // ─── Config Types ───────────────────────────────────────
5
+
6
+ /**
7
+ * Plugin entry in `docu.json` config.
8
+ * - `string`: plugin package name or relative path (no options)
9
+ * - `[string, object]`: plugin package name + factory options
10
+ *
11
+ * @example "@docubook/plugin-sitemap"
12
+ * @example ["@docubook/plugin-search-algolia", { appId: "xxx" }]
13
+ */
14
+ export type PluginEntry = string | [string, Record<string, unknown>];
15
+
16
+ // ─── Context Types ──────────────────────────────────────
17
+
18
+ /**
19
+ * Context passed to content-transform hooks for a single page.
20
+ */
21
+ export interface PageContext {
22
+ /** Relative slug path: "getting-started/introduction" */
23
+ slug: string;
24
+ /** Absolute file path on disk */
25
+ filePath: string;
26
+ /** Parsed frontmatter metadata */
27
+ frontmatter: Record<string, unknown>;
28
+ /** Raw MDX/MD content (only available in `transformFrontmatter` when enabled) */
29
+ content?: string;
30
+ /** Resolved site configuration */
31
+ config: DocuConfig;
32
+ }
33
+
34
+ /**
35
+ * Metadata for a single built page, aggregated and passed to `onEnd`.
36
+ */
37
+ export interface PageMeta {
38
+ /** URL slug: "getting-started/introduction" */
39
+ slug: string;
40
+ /** Page title from frontmatter or filename */
41
+ title: string;
42
+ /** Absolute source file path */
43
+ filePath: string;
44
+ /** Relative output path under dist */
45
+ outputPath: string;
46
+ }
47
+
48
+ /**
49
+ * Context passed to the dev server request handler.
50
+ */
51
+ export interface DevServerContext {
52
+ /** Dev server port */
53
+ port: number;
54
+ /** Dev server hostname */
55
+ hostname: string;
56
+ }
57
+
58
+ // ─── PluginBuilder Interface ────────────────────────────
59
+
60
+ /**
61
+ * Builder object passed to each plugin's `setup()` function.
62
+ *
63
+ * Follows Bun's `PluginBuilder` convention:
64
+ * - Plugins register lifecycle callbacks through typed methods
65
+ * - Callbacks are executed sequentially in registration order
66
+ * - `config` provides read-only access to the resolved build config
67
+ */
68
+ export interface PluginBuilder {
69
+ /** Resolved DocuBook configuration (read-only after setup phase). */
70
+ config: DocuConfig;
71
+
72
+ // ─── Build Lifecycle ───────────────────────────────────
73
+
74
+ /**
75
+ * Register a callback to run once before the build starts.
76
+ * Use for: validating config, initializing resources, fetching remote data.
77
+ *
78
+ * @param callback - Receives the resolved config. May return a Promise.
79
+ *
80
+ * @example
81
+ * build.onStart((config) => {
82
+ * if (!config.meta.baseURL) throw new Error("baseURL required");
83
+ * });
84
+ */
85
+ onStart(callback: (config: DocuConfig) => void | Promise<void>): void;
86
+
87
+ /**
88
+ * Register a callback to run once after all pages are built.
89
+ * Use for: generating sitemaps, RSS feeds, manifests, post-build reports.
90
+ *
91
+ * @param callback - Receives config and aggregated page metadata. May return a Promise.
92
+ *
93
+ * @example
94
+ * build.onEnd((config, pages) => {
95
+ * const xml = generateSitemap(pages, config.meta.baseURL);
96
+ * await Bun.write(join(DIST_DIR, "sitemap.xml"), xml);
97
+ * });
98
+ */
99
+ onEnd(callback: (config: DocuConfig, pages: PageMeta[]) => void | Promise<void>): void;
100
+
101
+ // ─── Content Transform ─────────────────────────────────
102
+
103
+ /**
104
+ * Register a callback to transform raw file content before MDX compilation.
105
+ * Similar to Bun's `build.onLoad()` — filtered by file path pattern.
106
+ *
107
+ * Use for: preprocessing MDX/MD, injecting frontmatter, code transformations.
108
+ *
109
+ * @param args.filter - RegExp matched against the file's relative path
110
+ * @param args.namespace - Optional namespace prefix (reserved for future use)
111
+ * @param callback - Receives file path and raw content. Return new contents or void.
112
+ *
113
+ * @example
114
+ * build.onLoad({ filter: /\.md$/ }, ({ path, content }) => {
115
+ * return { contents: `<!-- auto-processed -->\n${content}` };
116
+ * });
117
+ */
118
+ onLoad(
119
+ args: { filter: RegExp; namespace?: string },
120
+ callback: (args: {
121
+ path: string;
122
+ content: string;
123
+ }) => { contents?: string; loader?: "js" | "ts" | "mdx" } | void
124
+ ): void;
125
+
126
+ /**
127
+ * Register a callback to mutate frontmatter before MDX compilation.
128
+ * Callbacks are chained: the return value of one is passed as input to the next.
129
+ *
130
+ * Use for: injecting reading-time, validating fields, adding computed metadata.
131
+ *
132
+ * @param callback - Receives frontmatter object and page context. Return mutated frontmatter or void.
133
+ *
134
+ * @example
135
+ * build.transformFrontmatter((fm, ctx) => {
136
+ * const wordCount = ctx.content!.split(/\s+/).length;
137
+ * return { ...fm, readingTime: `${Math.ceil(wordCount / 200)} min read` };
138
+ * });
139
+ */
140
+ transformFrontmatter(
141
+ callback: (
142
+ frontmatter: Record<string, unknown>,
143
+ context: Pick<PageContext, "slug" | "filePath" | "content">
144
+ ) => Record<string, unknown> | void
145
+ ): void;
146
+
147
+ /**
148
+ * Register a callback to transform the final HTML string per page.
149
+ * This is the **last** hook before the HTML is written to disk.
150
+ *
151
+ * Use for: post-processing, minification, link rewriting, custom injection.
152
+ *
153
+ * @param callback - Receives HTML string and full page context. Return modified HTML.
154
+ *
155
+ * @example
156
+ * build.transformHtml((html, ctx) => {
157
+ * return html.replace(/https?:\/\/old-domain\.com\//g, "/");
158
+ * });
159
+ */
160
+ transformHtml(callback: (html: string, context: PageContext) => string | Promise<string>): void;
161
+
162
+ // ─── Head & Script Injection ───────────────────────────
163
+
164
+ /**
165
+ * Register a callback that returns HTML strings to inject inside `<head>`.
166
+ * Results from all plugins are merged and deduplicated.
167
+ *
168
+ * Use for: analytics snippets, meta tags, stylesheet links.
169
+ *
170
+ * @param callback - Returns a single HTML string or an array. Called once per page.
171
+ *
172
+ * @example
173
+ * build.injectHead(() => {
174
+ * return `<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXX"></script>`;
175
+ * });
176
+ */
177
+ injectHead(callback: (context: PageContext) => string | string[]): void;
178
+
179
+ /**
180
+ * Register a callback that returns HTML strings to inject before `</body>`.
181
+ * Results from all plugins are merged and deduplicated.
182
+ *
183
+ * Use for: chat widgets, live-script loaders, deferred scripts.
184
+ *
185
+ * @param callback - Returns a single HTML string or an array. Called once per page.
186
+ */
187
+ injectBody(callback: (context: PageContext) => string | string[]): void;
188
+
189
+ // ─── MDX Pipeline Extension ────────────────────────────
190
+
191
+ /**
192
+ * Register additional remark (Markdown) plugins for the MDX compilation pipeline.
193
+ * Plugins from all plugins are merged and applied **after** the default set.
194
+ *
195
+ * @example
196
+ * build.remarkPlugins(() => [require("remark-custom-heading-id")]);
197
+ */
198
+ remarkPlugins(callback: () => Pluggable[]): void;
199
+
200
+ /**
201
+ * Register additional rehype (HTML) plugins for the MDX compilation pipeline.
202
+ * Plugins from all plugins are merged and applied **after** the default set.
203
+ *
204
+ * @example
205
+ * build.rehypePlugins(() => [require("rehype-autolink-headings")]);
206
+ */
207
+ rehypePlugins(callback: () => Pluggable[]): void;
208
+
209
+ // ─── Dev Server ────────────────────────────────────────
210
+
211
+ /**
212
+ * Register a callback to intercept incoming requests during development.
213
+ * The **first** callback to return a `Response` short-circuits all subsequent handlers.
214
+ *
215
+ * Use for: custom API routes, mock data endpoints, redirects, request logging.
216
+ *
217
+ * @param callback - Receives the Request and dev server context. Return Response or void.
218
+ *
219
+ * @example
220
+ * build.handleRequest((req, ctx) => {
221
+ * if (new URL(req.url).pathname === "/api/status") {
222
+ * return new Response(JSON.stringify({ ok: true }), {
223
+ * headers: { "Content-Type": "application/json" },
224
+ * });
225
+ * }
226
+ * });
227
+ */
228
+ handleRequest(
229
+ callback: (
230
+ req: Request,
231
+ context: DevServerContext
232
+ ) => Response | void | Promise<Response | void>
233
+ ): void;
234
+ }
235
+
236
+ // ─── Plugin Interface ────────────────────────────────────
237
+
238
+ /**
239
+ * A DocuBook plugin.
240
+ *
241
+ * Follows Bun's `BunPlugin` convention:
242
+ * - `name`: unique identifier for the plugin
243
+ * - `setup(build)`: called once to register lifecycle hooks via the provided `PluginBuilder`
244
+ *
245
+ * @example
246
+ * const myPlugin: DocuBookPlugin = {
247
+ * name: "analytics",
248
+ * setup(build) {
249
+ * build.injectHead(() => `<script>...</script>`);
250
+ * build.onEnd(() => console.log("build done"));
251
+ * },
252
+ * };
253
+ */
254
+ export interface DocuBookPlugin {
255
+ /** Unique plugin name. Used in error messages and logs. */
256
+ name: string;
257
+
258
+ /**
259
+ * Called once after all plugins are loaded and before the build begins.
260
+ * Register all lifecycle hooks via the `build` (PluginBuilder) parameter.
261
+ *
262
+ * @param build - The PluginBuilder instance for this build session.
263
+ */
264
+ setup(build: PluginBuilder): void | Promise<void>;
265
+ }
@@ -3,19 +3,10 @@ import { resolve } from "node:path";
3
3
  import { logger } from "./logger";
4
4
  import { DIST_DIR } from "./paths";
5
5
  import { getContentType } from "./utils";
6
+ import { SECURITY_HEADERS, generateNonce, cspHeader, injectNonce } from "./security";
6
7
 
7
8
  const PORT = process.env.PORT || "4173";
8
9
 
9
- const SECURITY_HEADERS: Record<string, string> = {
10
- "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
11
- "X-Frame-Options": "DENY",
12
- "X-Content-Type-Options": "nosniff",
13
- "Referrer-Policy": "strict-origin-when-cross-origin",
14
- "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
15
- "Content-Security-Policy":
16
- "default-src 'self'; script-src 'self' 'sha256-4XrQ+xtH2goQdpEv7dRmUWACF3uhF9KRPpgayFex7QU=' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'",
17
- };
18
-
19
10
  logger.buildStart();
20
11
 
21
12
  if (!existsSync(DIST_DIR)) {
@@ -50,22 +41,42 @@ const notFoundPath = resolve(DIST_DIR, "404.html");
50
41
  const server = Bun.serve({
51
42
  port: PORT,
52
43
 
53
- fetch(req) {
44
+ async fetch(req) {
54
45
  const url = new URL(req.url);
55
46
  const pathname = decodeURIComponent(url.pathname);
56
47
 
57
48
  const filePath = resolveFile(pathname);
49
+
58
50
  if (filePath) {
59
51
  const contentType = getContentType(filePath);
60
- const headers: Record<string, string> = { "Content-Type": contentType };
61
- if (contentType === "text/html") Object.assign(headers, SECURITY_HEADERS);
62
- return new Response(Bun.file(filePath), { headers });
52
+ if (contentType === "text/html") {
53
+ const nonce = generateNonce();
54
+ const html = await Bun.file(filePath).text();
55
+ const modified = injectNonce(html, nonce);
56
+ return new Response(modified, {
57
+ headers: {
58
+ "Content-Type": "text/html",
59
+ ...SECURITY_HEADERS,
60
+ "Content-Security-Policy": cspHeader(nonce, true),
61
+ },
62
+ });
63
+ }
64
+ return new Response(Bun.file(filePath), {
65
+ headers: { "Content-Type": contentType },
66
+ });
63
67
  }
64
68
 
65
69
  if (existsSync(notFoundPath)) {
66
- return new Response(Bun.file(notFoundPath), {
70
+ const nonce = generateNonce();
71
+ const html = await Bun.file(notFoundPath).text();
72
+ const modified = injectNonce(html, nonce);
73
+ return new Response(modified, {
67
74
  status: 404,
68
- headers: { "Content-Type": "text/html", ...SECURITY_HEADERS },
75
+ headers: {
76
+ "Content-Type": "text/html",
77
+ ...SECURITY_HEADERS,
78
+ "Content-Security-Policy": cspHeader(nonce, true),
79
+ },
69
80
  });
70
81
  }
71
82
 
@@ -11,10 +11,11 @@
11
11
  * content = first paragraph/list items after each heading
12
12
  */
13
13
 
14
- import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
14
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
15
15
  import { resolve, join } from "node:path";
16
16
  import { extractFrontmatterWithContent } from "@docubook/core";
17
17
  import { DOCS_DIR, ASSETS_DIR, loadDocuConfig } from "./paths";
18
+ import { scanMdxFiles } from "./utils";
18
19
 
19
20
  const docuConfig = loadDocuConfig();
20
21
 
@@ -183,29 +184,6 @@ export function extractRecords(filePath: string, raw: string): SearchRecord[] {
183
184
  return records;
184
185
  }
185
186
 
186
- async function scanMdxFiles(dir: string, base = ""): Promise<{ path: string; absPath: string }[]> {
187
- const files: { path: string; absPath: string }[] = [];
188
- const entries = await readdir(dir, { withFileTypes: true });
189
-
190
- for (const entry of entries) {
191
- const fullPath = join(dir, entry.name);
192
- const relPath = base ? `${base}/${entry.name}` : entry.name;
193
-
194
- if (entry.isDirectory()) {
195
- if (entry.name === "assets" || entry.name.startsWith(".")) continue;
196
- files.push(...(await scanMdxFiles(fullPath, relPath)));
197
- } else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
198
- if (entry.name === "index.mdx" && !base) continue;
199
- let path = relPath.replace(/\.(mdx|md)$/, "");
200
- if (/\/index$/.test(path)) {
201
- path = path.replace(/\/index$/, "");
202
- }
203
- files.push({ path, absPath: fullPath });
204
- }
205
- }
206
- return files;
207
- }
208
-
209
187
  export async function generateSearchIndex(docsDir?: string, outputDir?: string): Promise<number> {
210
188
  const docs = resolve(docsDir || DOCS_DIR);
211
189
  const dist = resolve(outputDir || ASSETS_DIR);
@@ -1,3 +1,6 @@
1
+ import { resolve } from "node:path";
2
+ import { realpathSync } from "node:fs";
3
+
1
4
  export const SECURITY_HEADERS: Record<string, string> = {
2
5
  "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
3
6
  "X-Frame-Options": "DENY",
@@ -10,10 +13,13 @@ export function generateNonce(): string {
10
13
  return crypto.randomUUID();
11
14
  }
12
15
 
13
- export function cspHeader(nonce: string): string {
16
+ export function cspHeader(nonce: string, allowEval = false): string {
17
+ const scriptSrc = allowEval
18
+ ? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval'`
19
+ : `script-src 'self' 'nonce-${nonce}'`;
14
20
  return [
15
21
  "default-src 'self'",
16
- `script-src 'self' 'nonce-${nonce}' 'unsafe-eval'`,
22
+ scriptSrc,
17
23
  "style-src 'self' 'unsafe-inline'",
18
24
  "img-src 'self' https: data:",
19
25
  "font-src 'self' data:",
@@ -23,13 +29,78 @@ export function cspHeader(nonce: string): string {
23
29
  ].join("; ");
24
30
  }
25
31
 
26
- export function htmlResponse(html: string, nonce: string, status = 200): Response {
32
+ export function isPathSafe(pathname: string, baseDir: string): boolean {
33
+ const decoded = decodeURIComponent(pathname);
34
+ const resolved = resolve(baseDir, decoded.slice(1));
35
+ const baseDirSlash = baseDir.endsWith("/") ? baseDir : baseDir + "/";
36
+ if (!(resolved === baseDir || resolved.startsWith(baseDirSlash))) {
37
+ return false;
38
+ }
39
+
40
+ if (resolved === baseDir) return true;
41
+ try {
42
+ const real = realpathSync(resolved);
43
+ const realBase = realpathSync(baseDir);
44
+ const realBaseDirSlash = realBase.endsWith("/") ? realBase : realBase + "/";
45
+ return real.startsWith(realBaseDirSlash);
46
+ } catch (err) {
47
+ const nodeErr = err as NodeJS.ErrnoException;
48
+ if (nodeErr.code === "ENOENT") {
49
+ return true;
50
+ }
51
+ console.error(
52
+ `[security] isPathSafe error for pathname="${pathname}": ${nodeErr.message} (code=${nodeErr.code})`
53
+ );
54
+ return false;
55
+ }
56
+ }
57
+
58
+ export function isSlugSafe(slug: string, docsDir: string): boolean {
59
+ const resolved = resolve(docsDir, slug);
60
+ const docsDirSlash = docsDir.endsWith("/") ? docsDir : docsDir + "/";
61
+ if (!(resolved === docsDir || resolved.startsWith(docsDirSlash))) {
62
+ return false;
63
+ }
64
+
65
+ if (resolved === docsDir) return true;
66
+ try {
67
+ const real = realpathSync(resolved);
68
+ const realDocsDir = realpathSync(docsDir);
69
+ const realDocsDirSlash = realDocsDir.endsWith("/") ? realDocsDir : realDocsDir + "/";
70
+ return real.startsWith(realDocsDirSlash);
71
+ } catch (err) {
72
+ const nodeErr = err as NodeJS.ErrnoException;
73
+ if (nodeErr.code === "ENOENT") {
74
+ return true;
75
+ }
76
+ console.error(
77
+ `[security] isSlugSafe error for slug="${slug}": ${nodeErr.message} (code=${nodeErr.code})`
78
+ );
79
+ return false;
80
+ }
81
+ }
82
+
83
+ export function injectNonce(html: string, nonce: string): string {
84
+ return html.replace(/<script\b(?![^>]*\bsrc\s*=)([^>]*)>/gi, (match) => {
85
+ if (/nonce\s*=/i.test(match)) {
86
+ return match.replace(/nonce="[^"]*"/i, `nonce="${nonce}"`);
87
+ }
88
+ return match.replace(/>$/, ` nonce="${nonce}">`);
89
+ });
90
+ }
91
+
92
+ export function htmlResponse(
93
+ html: string,
94
+ nonce: string,
95
+ status = 200,
96
+ allowEval = false
97
+ ): Response {
27
98
  return new Response(html, {
28
99
  status,
29
100
  headers: {
30
101
  "Content-Type": "text/html",
31
102
  ...SECURITY_HEADERS,
32
- "Content-Security-Policy": cspHeader(nonce),
103
+ "Content-Security-Policy": cspHeader(nonce, allowEval),
33
104
  },
34
105
  });
35
106
  }