@docubook/flame 1.5.4 → 1.6.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.
@@ -31,7 +31,7 @@ import { loadPlugins } from "./plugin-loader";
31
31
  import { BuildPluginBuilder } from "./plugin-builder";
32
32
  import { scanMdxFiles } from "./utils";
33
33
  import type { BuildCache, CliArgs } from "./types";
34
- import { generateNonce } from "./security";
34
+ import { generateNonce, cspHeader } from "./security";
35
35
  import type { PageMeta, PageContext } from "./plugin";
36
36
  import { buildSeoMeta } from "./seo";
37
37
  import DocsPage from "../pages/docs/[[...slug]]";
@@ -149,12 +149,14 @@ async function renderDocsPage(
149
149
  const depth = slug ? slug.split("/").length : 1;
150
150
  const favicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
151
151
  const seo = buildSeoMeta(docuConfig, frontmatter, slug || "");
152
+ const csp = cspHeader(nonce, process.env.NODE_ENV !== "production");
152
153
  let html = htmlShell({
153
154
  title,
154
155
  description,
155
156
  body,
156
157
  favicon,
157
158
  seo,
159
+ csp,
158
160
  css: assetManifest.css,
159
161
  js: assetManifest.js,
160
162
  nonce,
@@ -349,15 +351,17 @@ export async function runBuild(): Promise<void> {
349
351
  const landingPage = React.createElement(IndexPage);
350
352
  const landingFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
351
353
  const landingSeo = buildSeoMeta(docuConfig, docuConfig.meta as Record<string, unknown>, "");
354
+ const landingNonce = generateNonce();
352
355
  const landingHtml = htmlShell({
353
356
  title: docuConfig.meta?.title || "DocuBook",
354
357
  description: docuConfig.meta?.description || "",
355
358
  body: renderToString(landingPage),
356
359
  favicon: landingFavicon,
357
360
  seo: landingSeo,
361
+ csp: cspHeader(landingNonce, process.env.NODE_ENV !== "production"),
358
362
  css: assetManifest.css,
359
363
  js: assetManifest.js,
360
- nonce: generateNonce(),
364
+ nonce: landingNonce,
361
365
  themeCss: inlineThemeCss,
362
366
  });
363
367
  await writeFile(join(DIST_DIR, "index.html"), landingHtml);
@@ -368,15 +372,17 @@ export async function runBuild(): Promise<void> {
368
372
  React.createElement(NotFoundPage)
369
373
  );
370
374
  const notFoundFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
375
+ const notFoundNonce = generateNonce();
371
376
  const notFoundHtml = htmlShell({
372
377
  title: "404 - Not Found",
373
378
  description: "",
374
379
  body: renderToString(notFoundPage),
375
380
  favicon: notFoundFavicon,
376
381
  headExtra: ['<meta name="robots" content="noindex,follow">'],
382
+ csp: cspHeader(notFoundNonce, process.env.NODE_ENV !== "production"),
377
383
  css: assetManifest.css,
378
384
  js: assetManifest.js,
379
- nonce: generateNonce(),
385
+ nonce: notFoundNonce,
380
386
  themeCss: inlineThemeCss,
381
387
  });
382
388
  await writeFile(join(DIST_DIR, "404.html"), notFoundHtml);
@@ -25,6 +25,8 @@ function mountIsland(
25
25
  }
26
26
 
27
27
  function mountIslands() {
28
+ // forceCreate: SSR sidebar renders <Menu> only; client renders full <Sidebar>
29
+ // (DesktopSidebar + MobileBar) — structure mismatch forces full createRoot.
28
30
  mountIsland(
29
31
  "sidebar-island",
30
32
  (el) => {
@@ -38,18 +40,16 @@ function mountIslands() {
38
40
  true
39
41
  );
40
42
 
41
- mountIsland(
42
- "mobile-bar-island",
43
- (el) => {
44
- const tocs: TocItem[] = safeParseTocs(el.dataset.tocs);
45
- return React.createElement(MobileBar, {
46
- tocs,
47
- title: el.dataset.title || "",
48
- repoUrl: el.dataset.repo || "",
49
- });
50
- },
51
- true
52
- );
43
+ // mobile-bar-island SSR div is empty (data attributes only),
44
+ // so hydrateRoot child check falls through to createRoot automatically.
45
+ mountIsland("mobile-bar-island", (el) => {
46
+ const tocs: TocItem[] = safeParseTocs(el.dataset.tocs);
47
+ return React.createElement(MobileBar, {
48
+ tocs,
49
+ title: el.dataset.title || "",
50
+ repoUrl: el.dataset.repo || "",
51
+ });
52
+ });
53
53
 
54
54
  mountIsland("toc-island", (el) => {
55
55
  const tocs: TocItem[] = safeParseTocs(el.dataset.tocs);
@@ -69,7 +69,8 @@ function hydrateMdxContent() {
69
69
  try {
70
70
  const compiledSource = JSON.parse(sourceEl.textContent || "");
71
71
  const components = createMdxComponents();
72
- createRoot(island).render(
72
+ hydrateRoot(
73
+ island,
73
74
  React.createElement(MDXRemote, { compiledSource, scope: {}, frontmatter: {}, components })
74
75
  );
75
76
  } catch (e) {
@@ -82,3 +83,27 @@ if (document.readyState === "loading") {
82
83
  } else {
83
84
  mountIslands();
84
85
  }
86
+
87
+ // ── Hash scroll compensation ──────────────────────────────────────
88
+ // After hydration, lazy-rendered content (Mermaid via IntersectionObserver) can
89
+ // shift layout and push the hash target (#section-2) off-screen.
90
+ // Poll with rAF for ~1s and re-scroll if the target is below viewport.
91
+ function scrollToHashOnLoad() {
92
+ const hash = window.location.hash;
93
+ if (!hash || hash === "#") return;
94
+ const id = hash.slice(1);
95
+ const deadline = performance.now() + 1000;
96
+ function tick() {
97
+ const el = document.getElementById(id);
98
+ if (el) {
99
+ const top = el.getBoundingClientRect().top;
100
+ if (top <= window.innerHeight - 100 && top >= 0) return; // already in view
101
+ el.scrollIntoView();
102
+ return; // scrolled once, done
103
+ }
104
+ if (performance.now() < deadline) requestAnimationFrame(tick);
105
+ }
106
+ requestAnimationFrame(tick);
107
+ }
108
+
109
+ scrollToHashOnLoad();
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Runtime-neutral deploy — mirror of `deploy.ts` (Bun-only, protected) for
3
- * Node.js and Deno. Instead of spawning `bun run build`, it runs the neutral
4
- * build in-process, then prepares `.docu/dist` for GitHub Pages.
2
+ * Runtime-neutral deploy — 3 modes:
3
+ * 1. `flame deploy` → build + generate GitHub Actions workflow
4
+ * 2. `flame deploy --docker` → build + generate Docker deployment files
5
+ * 3. `flame deploy --docker --silent` → same as #2, minimal output
6
+ *
7
+ * Mirror of deploy.ts (Bun-only, protected) for Node.js and Deno.
8
+ * Runs the neutral build in-process, then prepares .docu/dist.
5
9
  */
6
10
 
7
11
  import { writeFile, mkdir } from "node:fs/promises";
@@ -12,26 +16,157 @@ import { DIST_DIR, PROJECT_ROOT } from "./paths";
12
16
  const WORKFLOW_DIR = join(PROJECT_ROOT, ".github/workflows");
13
17
  const WORKFLOW_FILE = join(WORKFLOW_DIR, "deploy.yml");
14
18
 
15
- export async function runDeploy(): Promise<void> {
16
- console.log("📦 Building for production...\n");
19
+ export const HEADERS_FILE = `/*
20
+ X-Frame-Options: DENY
21
+ X-Content-Type-Options: nosniff
22
+ Referrer-Policy: strict-origin-when-cross-origin
23
+
24
+ /assets/*
25
+ Cache-Control: public, max-age=31536000, immutable
26
+ `;
27
+
28
+ const isDocker = !!process.env.FLAME_DEPLOY_DOCKER;
29
+ const isSilent = !!process.env.FLAME_DEPLOY_SILENT;
17
30
 
31
+ /** Logger that no-ops all non-error output in silent mode. */
32
+ const log = isSilent
33
+ ? { info: () => {}, ok: () => {}, created: () => {}, out: () => {} }
34
+ : {
35
+ info: (m: string) => console.log(m),
36
+ ok: () => console.log("\n✅ Ready to deploy!"),
37
+ created: (m: string) => console.log(m),
38
+ out: (m: string) => console.log(m),
39
+ };
40
+
41
+ async function runBuild() {
18
42
  process.env.NODE_ENV = "production";
43
+ if (isSilent) {
44
+ process.env.FLAME_BUILD_SILENT = "1";
45
+ process.env.LOG_LEVEL = "error";
46
+ }
19
47
  const { runBuildCli } = await import("./build.impl");
20
48
  await runBuildCli();
49
+ }
21
50
 
22
- // Add .nojekyll
23
- await writeFile(join(DIST_DIR, ".nojekyll"), "");
51
+ async function writeDockerFiles() {
52
+ const dockerDir = PROJECT_ROOT;
53
+
54
+ if (!existsSync(join(dockerDir, "Dockerfile"))) {
55
+ await writeFile(
56
+ join(dockerDir, "Dockerfile"),
57
+ `FROM node:22-alpine AS builder
58
+ WORKDIR /app
59
+ COPY package.json package-lock.json ./
60
+ RUN npm ci
61
+ COPY . .
62
+ RUN npm run build
63
+
64
+ FROM nginx:alpine
65
+ COPY --from=builder /app/.docu/dist /usr/share/nginx/html
66
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
67
+ USER nginx
68
+ EXPOSE 80
69
+ CMD ["nginx", "-g", "daemon off;"]
70
+ `
71
+ );
72
+ log.created("📄 Created Dockerfile");
73
+ }
74
+
75
+ if (!existsSync(join(dockerDir, "nginx.conf"))) {
76
+ await writeFile(
77
+ join(dockerDir, "nginx.conf"),
78
+ `server {
79
+ listen 80;
80
+ server_name _;
81
+ root /usr/share/nginx/html;
82
+ index index.html;
83
+
84
+ gzip on;
85
+ gzip_types text/html text/css application/javascript image/svg+xml;
86
+
87
+ # Security headers (HSTS effective when HTTPS is terminated upstream)
88
+ add_header X-Frame-Options "DENY" always;
89
+ add_header X-Content-Type-Options "nosniff" always;
90
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
91
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
92
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
93
+
94
+ location /assets/ {
95
+ expires 1y;
96
+ add_header Cache-Control "public, immutable";
97
+ add_header X-Frame-Options "DENY" always;
98
+ add_header X-Content-Type-Options "nosniff" always;
99
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
100
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
101
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
102
+ }
103
+
104
+ location /docs/assets/ {
105
+ expires 7d;
106
+ add_header Cache-Control "public";
107
+ add_header X-Frame-Options "DENY" always;
108
+ add_header X-Content-Type-Options "nosniff" always;
109
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
110
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
111
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
112
+ }
113
+
114
+ location / {
115
+ try_files $uri $uri.html $uri/ =404;
116
+ }
117
+ }
118
+ `
119
+ );
120
+ log.created("📄 Created nginx.conf");
121
+ }
24
122
 
25
- // Generate GitHub Actions workflow
123
+ if (!existsSync(join(dockerDir, ".dockerignore"))) {
124
+ await writeFile(
125
+ join(dockerDir, ".dockerignore"),
126
+ `node_modules
127
+ .git
128
+ *.DS_Store
129
+ .docu/dist
130
+ .docu/lib
131
+ .env
132
+ .env.*
133
+ .npmrc
134
+ *.log
135
+ `
136
+ );
137
+ log.created("📄 Created .dockerignore");
138
+ }
139
+ }
140
+
141
+ async function writeGhaWorkflow() {
26
142
  if (!existsSync(WORKFLOW_FILE)) {
27
143
  await mkdir(WORKFLOW_DIR, { recursive: true });
28
144
  await writeFile(WORKFLOW_FILE, GITHUB_ACTIONS_WORKFLOW);
29
- console.log("\n📄 Created .github/workflows/deploy.yml");
145
+ log.created("📄 Created .github/workflows/deploy.yml");
146
+ }
147
+ }
148
+
149
+ export async function runDeploy(): Promise<void> {
150
+ log.info("📦 Building for production...\n");
151
+ await runBuild();
152
+
153
+ // Common: .nojekyll + _headers
154
+ await writeFile(join(DIST_DIR, ".nojekyll"), "");
155
+ await writeFile(join(DIST_DIR, "_headers"), HEADERS_FILE);
156
+
157
+ if (isDocker) {
158
+ await writeDockerFiles();
159
+ } else {
160
+ await writeGhaWorkflow();
30
161
  }
31
162
 
32
- console.log("\n✅ Ready to deploy!");
33
- console.log(" Output: .docu/dist/");
34
- console.log(" Push to GitHub and enable Pages (Settings → Pages → Source: GitHub Actions)");
163
+ log.ok();
164
+ log.out(" Output: .docu/dist/");
165
+ if (isDocker) {
166
+ log.out(" Run: docker build -t my-docs . && docker run -p 80:80 my-docs");
167
+ } else {
168
+ log.out(" Push to GitHub and enable Pages (Settings → Pages → Source: GitHub Actions)");
169
+ }
35
170
  }
36
171
 
37
172
  const GITHUB_ACTIONS_WORKFLOW = `name: Deploy to GitHub Pages
@@ -1,56 +1,165 @@
1
1
  /**
2
- * Deploy script - prepares .docu/dist for GitHub Pages
2
+ * Deploy script — 3 modes:
3
+ * 1. `flame deploy` → build + generate GitHub Actions workflow
4
+ * 2. `flame deploy --docker` → build + generate Docker deployment files
5
+ * 3. `flame deploy --docker --silent` → same as #2, minimal output
3
6
  *
4
- * Usage: bun deploy
5
- * Runs build, adds .nojekyll, and generates GitHub Actions workflow.
7
+ * Bun-native path — uses Bun.write() and Bun.spawn().
6
8
  */
7
9
 
8
- import { writeFile, mkdir } from "node:fs/promises";
10
+ import { mkdir } from "node:fs/promises";
9
11
  import { existsSync } from "node:fs";
10
12
  import { join } from "node:path";
11
13
  import { DIST_DIR, PROJECT_ROOT } from "./paths";
14
+ import { HEADERS_FILE } from "./deploy.shared";
15
+
16
+ export { HEADERS_FILE };
12
17
 
13
18
  const WORKFLOW_DIR = join(PROJECT_ROOT, ".github/workflows");
14
19
  const WORKFLOW_FILE = join(WORKFLOW_DIR, "deploy.yml");
15
20
 
16
- const HEADERS_FILE = `/assets/*
17
- Cache-Control: public, max-age=31536000, immutable
21
+ const isDocker = !!process.env.FLAME_DEPLOY_DOCKER;
22
+ const isSilent = !!process.env.FLAME_DEPLOY_SILENT;
18
23
 
19
- /assets/chunks/*
20
- Cache-Control: public, max-age=31536000, immutable
21
- `;
24
+ /** Logger that no-ops all non-error output in silent mode. */
25
+ const log = isSilent
26
+ ? { info: () => {}, ok: () => {}, created: () => {}, out: () => {} }
27
+ : {
28
+ info: (m: string) => console.log(m),
29
+ ok: () => console.log("\n✅ Ready to deploy!"),
30
+ created: (m: string) => console.log(m),
31
+ out: (m: string) => console.log(m),
32
+ };
22
33
 
23
- async function deploy() {
24
- console.log("📦 Building for production...\n");
25
-
26
- // Run build
34
+ async function runBuild() {
27
35
  const build = Bun.spawn(["bun", "run", "build"], {
28
- stdout: "inherit",
29
- stderr: "inherit",
36
+ stdout: isSilent ? "ignore" : "inherit",
37
+ stderr: isSilent ? "ignore" : "inherit",
30
38
  });
31
39
  const exitCode = await build.exited;
32
40
  if (exitCode !== 0) {
33
41
  console.error("\n❌ Build failed");
34
42
  process.exit(1);
35
43
  }
44
+ }
36
45
 
37
- // Add .nojekyll
38
- await writeFile(join(DIST_DIR, ".nojekyll"), "");
46
+ export const NGINX_CONF = `server {
47
+ listen 80;
48
+ server_name _;
49
+ root /usr/share/nginx/html;
50
+ index index.html;
51
+
52
+ gzip on;
53
+ gzip_types text/html text/css application/javascript image/svg+xml;
54
+
55
+ # Security headers (server-level, inherited by all locations)
56
+ # HSTS effective when HTTPS is terminated upstream (reverse proxy / LB)
57
+ add_header X-Frame-Options "DENY" always;
58
+ add_header X-Content-Type-Options "nosniff" always;
59
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
60
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
61
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
62
+
63
+ location /assets/ {
64
+ expires 1y;
65
+ add_header Cache-Control "public, immutable";
66
+ add_header X-Frame-Options "DENY" always;
67
+ add_header X-Content-Type-Options "nosniff" always;
68
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
69
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
70
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
71
+ }
39
72
 
40
- // _headers: long-cache immutable assets for Netlify/Cloudflare Pages.
41
- // GitHub Pages ignores it (its CDN caches separately) — harmless to emit.
42
- await writeFile(join(DIST_DIR, "_headers"), HEADERS_FILE);
73
+ location /docs/assets/ {
74
+ expires 7d;
75
+ add_header Cache-Control "public";
76
+ add_header X-Frame-Options "DENY" always;
77
+ add_header X-Content-Type-Options "nosniff" always;
78
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
79
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
80
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
81
+ }
43
82
 
44
- // Generate GitHub Actions workflow
83
+ location / {
84
+ try_files $uri $uri.html $uri/ =404;
85
+ }
86
+ }
87
+ `;
88
+
89
+ export const DOCKERFILE_BUN = `FROM oven/bun:1 AS builder
90
+ WORKDIR /app
91
+ COPY package.json bun.lock ./
92
+ RUN bun install --frozen-lockfile
93
+ COPY . .
94
+ RUN bun run build
95
+
96
+ FROM nginx:alpine
97
+ COPY --from=builder /app/.docu/dist /usr/share/nginx/html
98
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
99
+ USER nginx
100
+ EXPOSE 80
101
+ CMD ["nginx", "-g", "daemon off;"]
102
+ `;
103
+
104
+ export const DOCKERIGNORE = `node_modules
105
+ .git
106
+ *.DS_Store
107
+ .docu/dist
108
+ .docu/lib
109
+ .env
110
+ .env.*
111
+ .npmrc
112
+ *.log
113
+ `;
114
+
115
+ async function writeDockerFiles() {
116
+ const dockerDir = PROJECT_ROOT;
117
+
118
+ if (!existsSync(join(dockerDir, "Dockerfile"))) {
119
+ await Bun.write(join(dockerDir, "Dockerfile"), DOCKERFILE_BUN);
120
+ log.created("📄 Created Dockerfile");
121
+ }
122
+
123
+ if (!existsSync(join(dockerDir, "nginx.conf"))) {
124
+ await Bun.write(join(dockerDir, "nginx.conf"), NGINX_CONF);
125
+ log.created("📄 Created nginx.conf");
126
+ }
127
+
128
+ if (!existsSync(join(dockerDir, ".dockerignore"))) {
129
+ await Bun.write(join(dockerDir, ".dockerignore"), DOCKERIGNORE);
130
+ log.created("📄 Created .dockerignore");
131
+ }
132
+ }
133
+
134
+ async function writeGhaWorkflow() {
45
135
  if (!existsSync(WORKFLOW_FILE)) {
46
136
  await mkdir(WORKFLOW_DIR, { recursive: true });
47
- await writeFile(WORKFLOW_FILE, GITHUB_ACTIONS_WORKFLOW);
48
- console.log("\n📄 Created .github/workflows/deploy.yml");
137
+ await Bun.write(WORKFLOW_FILE, GITHUB_ACTIONS_WORKFLOW);
138
+ log.created("📄 Created .github/workflows/deploy.yml");
139
+ }
140
+ }
141
+
142
+ async function deploy() {
143
+ log.info("📦 Building for production...\n");
144
+ await runBuild();
145
+
146
+ // Common: .nojekyll + _headers
147
+ await Bun.write(join(DIST_DIR, ".nojekyll"), "");
148
+ await Bun.write(join(DIST_DIR, "_headers"), HEADERS_FILE);
149
+
150
+ if (isDocker) {
151
+ await writeDockerFiles();
152
+ } else {
153
+ await writeGhaWorkflow();
49
154
  }
50
155
 
51
- console.log("\n✅ Ready to deploy!");
52
- console.log(" Output: .docu/dist/");
53
- console.log(" Push to GitHub and enable Pages (Settings → Pages → Source: GitHub Actions)");
156
+ log.ok();
157
+ log.out(" Output: .docu/dist/");
158
+ if (isDocker) {
159
+ log.out(" Run: docker build -t my-docs . && docker run -p 80:80 my-docs");
160
+ } else {
161
+ log.out(" Push to GitHub and enable Pages (Settings → Pages → Source: GitHub Actions)");
162
+ }
54
163
  }
55
164
 
56
165
  const GITHUB_ACTIONS_WORKFLOW = `name: Deploy to GitHub Pages
@@ -103,7 +212,9 @@ jobs:
103
212
  uses: actions/deploy-pages@v4
104
213
  `;
105
214
 
106
- deploy().catch((err) => {
107
- console.error("Deploy failed:", err);
108
- process.exit(1);
109
- });
215
+ if (import.meta.main) {
216
+ deploy().catch((err) => {
217
+ console.error("Deploy failed:", err);
218
+ process.exit(1);
219
+ });
220
+ }
@@ -79,6 +79,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
79
79
  <title>${escapeHtml(title)}</title>
80
80
  <meta name="description" content="${escapeHtml(description)}">
81
81
  ${favicon ? `<link rel="icon" type="image/x-icon" href="${escapeHtml(resolvePath(favicon))}">` : ""}${themeStyle}
82
+ <link rel="preload" href="${escapeHtml(assetPrefix + css)}" as="style">
82
83
  <link rel="stylesheet" href="${escapeHtml(assetPrefix + css)}">
83
84
  ${csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">` : ""}
84
85
  ${seoTags}
@@ -86,6 +87,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
86
87
  </head>
87
88
  <body>
88
89
  <div id="root">${body}</div>
90
+ <link rel="modulepreload" href="${escapeHtml(assetPrefix + js)}">
89
91
  <script type="module"${nonceAttr} src="${escapeHtml(assetPrefix + js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
90
92
  </body>
91
93
  </html>`;
@@ -1,30 +1,5 @@
1
- import type { SeoMeta } from "./seo";
2
-
3
- export interface HtmlShellOptions {
4
- title: string;
5
- description: string;
6
- body: string;
7
- favicon: string;
8
- css: string;
9
- js: string;
10
- nonce?: string;
11
- /**
12
- * Content-Security-Policy value (from `cspHeader()` in security.ts).
13
- * When provided, injects `<meta http-equiv="Content-Security-Policy">` in `<head>`.
14
- * Essential for static deployment where HTTP headers cannot be set.
15
- */
16
- csp?: string;
17
- extraScripts?: string;
18
- themeCss?: string;
19
- /** Depth from document root (0=root, 1=subdir, 2=sub/subdir). Used for relative asset paths. */
20
- depth?: number;
21
- /** HTML strings to inject before `</head>` (from plugin `injectHead` hooks). */
22
- headExtra?: string[];
23
- /** HTML strings to inject before `</body>`, after the main script (from plugin `injectBody` hooks). */
24
- bodyExtra?: string[];
25
- /** SEO meta tags derived from config + frontmatter */
26
- seo?: SeoMeta;
27
- }
1
+ import type { HtmlShellOptions } from "./html.shared";
2
+ export type { HtmlShellOptions };
28
3
 
29
4
  export function htmlShell(opts: HtmlShellOptions): string {
30
5
  const {
@@ -69,6 +44,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
69
44
  <title>${Bun.escapeHTML(title)}</title>
70
45
  <meta name="description" content="${Bun.escapeHTML(description)}">
71
46
  ${favicon ? `<link rel="icon" type="image/x-icon" href="${Bun.escapeHTML(resolvePath(favicon))}">` : ""}${themeStyle}
47
+ <link rel="preload" href="${Bun.escapeHTML(assetPrefix + css)}" as="style">
72
48
  <link rel="stylesheet" href="${Bun.escapeHTML(assetPrefix + css)}">
73
49
  ${csp ? `<meta http-equiv="Content-Security-Policy" content="${Bun.escapeHTML(csp)}">` : ""}
74
50
  ${seoTags}
@@ -76,6 +52,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
76
52
  </head>
77
53
  <body>
78
54
  <div id="root">${body}</div>
55
+ <link rel="modulepreload" href="${Bun.escapeHTML(assetPrefix + js)}">
79
56
  <script type="module"${nonceAttr} src="${Bun.escapeHTML(assetPrefix + js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
80
57
  </body>
81
58
  </html>`;