@docubook/flame 1.5.3 → 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.
Files changed (37) hide show
  1. package/.docu/lib/build.deno.js +4 -4
  2. package/.docu/lib/build.impl-N2MBT3CM.js +12 -0
  3. package/.docu/lib/build.node.js +4 -4
  4. package/.docu/lib/{chunk-J5NMYSBJ.js → chunk-4IQXHHPF.js} +5 -2
  5. package/.docu/lib/{chunk-YI76L7LB.js → chunk-IJFMQFWW.js} +69 -25
  6. package/.docu/lib/{chunk-YENO4SVR.js → chunk-JTH4JBBS.js} +38 -5
  7. package/.docu/lib/chunk-TI6M4643.js +204 -0
  8. package/.docu/lib/{chunk-EIKBL7SD.js → chunk-UUS45SUC.js} +2 -2
  9. package/.docu/lib/{chunk-WCMRAAWH.js → chunk-Y2TAEAUQ.js} +2 -2
  10. package/.docu/lib/{chunk-VX4HLVHE.js → chunk-ZA5QB5UK.js} +4 -4
  11. package/.docu/lib/clean.js +2 -2
  12. package/.docu/lib/deploy.deno.js +2 -2
  13. package/.docu/lib/deploy.node.js +2 -2
  14. package/.docu/lib/preview.deno.js +3 -3
  15. package/.docu/lib/preview.node.js +3 -3
  16. package/.docu/lib/server.deno.js +4 -4
  17. package/.docu/lib/server.node.js +4 -4
  18. package/.docu/node/build.impl.ts +15 -3
  19. package/.docu/node/build.ts +6 -0
  20. package/.docu/node/client.ts +38 -13
  21. package/.docu/node/deploy.shared.ts +147 -12
  22. package/.docu/node/deploy.ts +141 -30
  23. package/.docu/node/html.shared.ts +19 -0
  24. package/.docu/node/html.ts +17 -23
  25. package/.docu/node/hydrate.node.ts +74 -39
  26. package/.docu/node/hydrate.ts +74 -48
  27. package/.docu/node/paths.ts +7 -5
  28. package/.docu/node/seo.ts +42 -0
  29. package/.docu/node/server-routes.ts +2 -1
  30. package/.docu/node/types.ts +2 -0
  31. package/bin/cli.js +19 -6
  32. package/docu.schema.json +5 -1
  33. package/package.json +4 -4
  34. package/template/docs/assets/images/og.png +0 -0
  35. package/template/docu.json +2 -1
  36. package/.docu/lib/build.impl-O3Z5XIGM.js +0 -12
  37. package/.docu/lib/chunk-7VXEOH7X.js +0 -79
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  runServer
3
- } from "./chunk-VX4HLVHE.js";
4
- import "./chunk-YI76L7LB.js";
3
+ } from "./chunk-ZA5QB5UK.js";
4
+ import "./chunk-IJFMQFWW.js";
5
5
  import "./chunk-EOK6KATZ.js";
6
- import "./chunk-WCMRAAWH.js";
7
- import "./chunk-J5NMYSBJ.js";
6
+ import "./chunk-Y2TAEAUQ.js";
7
+ import "./chunk-4IQXHHPF.js";
8
8
 
9
9
  // .docu/node/server.deno.ts
10
10
  import { denoAdapter } from "@docubook/runt";
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  runServer
3
- } from "./chunk-VX4HLVHE.js";
4
- import "./chunk-YI76L7LB.js";
3
+ } from "./chunk-ZA5QB5UK.js";
4
+ import "./chunk-IJFMQFWW.js";
5
5
  import "./chunk-EOK6KATZ.js";
6
- import "./chunk-WCMRAAWH.js";
7
- import "./chunk-J5NMYSBJ.js";
6
+ import "./chunk-Y2TAEAUQ.js";
7
+ import "./chunk-4IQXHHPF.js";
8
8
 
9
9
  // .docu/node/server.node.ts
10
10
  import { nodeAdapter } from "@docubook/runt";
@@ -31,8 +31,9 @@ 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
+ import { buildSeoMeta } from "./seo";
36
37
  import DocsPage from "../pages/docs/[[...slug]]";
37
38
  import IndexPage from "../pages/index";
38
39
  import NotFoundPage from "../pages/404";
@@ -147,11 +148,15 @@ async function renderDocsPage(
147
148
 
148
149
  const depth = slug ? slug.split("/").length : 1;
149
150
  const favicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
151
+ const seo = buildSeoMeta(docuConfig, frontmatter, slug || "");
152
+ const csp = cspHeader(nonce, process.env.NODE_ENV !== "production");
150
153
  let html = htmlShell({
151
154
  title,
152
155
  description,
153
156
  body,
154
157
  favicon,
158
+ seo,
159
+ csp,
155
160
  css: assetManifest.css,
156
161
  js: assetManifest.js,
157
162
  nonce,
@@ -345,14 +350,18 @@ export async function runBuild(): Promise<void> {
345
350
 
346
351
  const landingPage = React.createElement(IndexPage);
347
352
  const landingFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
353
+ const landingSeo = buildSeoMeta(docuConfig, docuConfig.meta as Record<string, unknown>, "");
354
+ const landingNonce = generateNonce();
348
355
  const landingHtml = htmlShell({
349
356
  title: docuConfig.meta?.title || "DocuBook",
350
357
  description: docuConfig.meta?.description || "",
351
358
  body: renderToString(landingPage),
352
359
  favicon: landingFavicon,
360
+ seo: landingSeo,
361
+ csp: cspHeader(landingNonce, process.env.NODE_ENV !== "production"),
353
362
  css: assetManifest.css,
354
363
  js: assetManifest.js,
355
- nonce: generateNonce(),
364
+ nonce: landingNonce,
356
365
  themeCss: inlineThemeCss,
357
366
  });
358
367
  await writeFile(join(DIST_DIR, "index.html"), landingHtml);
@@ -363,14 +372,17 @@ export async function runBuild(): Promise<void> {
363
372
  React.createElement(NotFoundPage)
364
373
  );
365
374
  const notFoundFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
375
+ const notFoundNonce = generateNonce();
366
376
  const notFoundHtml = htmlShell({
367
377
  title: "404 - Not Found",
368
378
  description: "",
369
379
  body: renderToString(notFoundPage),
370
380
  favicon: notFoundFavicon,
381
+ headExtra: ['<meta name="robots" content="noindex,follow">'],
382
+ csp: cspHeader(notFoundNonce, process.env.NODE_ENV !== "production"),
371
383
  css: assetManifest.css,
372
384
  js: assetManifest.js,
373
- nonce: generateNonce(),
385
+ nonce: notFoundNonce,
374
386
  themeCss: inlineThemeCss,
375
387
  });
376
388
  await writeFile(join(DIST_DIR, "404.html"), notFoundHtml);
@@ -25,6 +25,7 @@ import { scanMdxFiles } from "./utils";
25
25
  import type { BuildCache, CliArgs } from "./types";
26
26
  import { generateNonce } from "./security";
27
27
  import type { PageMeta, PageContext } from "./plugin";
28
+ import { buildSeoMeta } from "./seo";
28
29
  import DocsPage from "../pages/docs/[[...slug]]";
29
30
  import IndexPage from "../pages/index";
30
31
  import NotFoundPage from "../pages/404";
@@ -140,11 +141,13 @@ async function renderDocsPage(
140
141
 
141
142
  const depth = slug ? slug.split("/").length : 1;
142
143
  const favicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
144
+ const seo = buildSeoMeta(docuConfig, frontmatter, slug || "");
143
145
  let html = htmlShell({
144
146
  title,
145
147
  description,
146
148
  body,
147
149
  favicon,
150
+ seo,
148
151
  css: assetManifest.css,
149
152
  js: assetManifest.js,
150
153
  nonce,
@@ -335,11 +338,13 @@ async function build() {
335
338
 
336
339
  const landingPage = React.createElement(IndexPage);
337
340
  const landingFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
341
+ const landingSeo = buildSeoMeta(docuConfig, {}, "");
338
342
  const landingHtml = htmlShell({
339
343
  title: docuConfig.meta?.title || "DocuBook",
340
344
  description: docuConfig.meta?.description || "",
341
345
  body: renderToString(landingPage),
342
346
  favicon: landingFavicon,
347
+ seo: landingSeo,
343
348
  css: assetManifest.css,
344
349
  js: assetManifest.js,
345
350
  nonce: generateNonce(),
@@ -358,6 +363,7 @@ async function build() {
358
363
  description: "",
359
364
  body: renderToString(notFoundPage),
360
365
  favicon: notFoundFavicon,
366
+ headExtra: ['<meta name="robots" content="noindex,follow">'],
361
367
  css: assetManifest.css,
362
368
  js: assetManifest.js,
363
369
  nonce: generateNonce(),
@@ -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
+ }
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { escapeHtml } from "./escapeHtml";
10
10
 
11
+ import type { SeoMeta } from "./seo";
12
+
11
13
  export interface HtmlShellOptions {
12
14
  title: string;
13
15
  description: string;
@@ -30,6 +32,8 @@ export interface HtmlShellOptions {
30
32
  headExtra?: string[];
31
33
  /** HTML strings to inject before `</body>`, after the main script (from plugin `injectBody` hooks). */
32
34
  bodyExtra?: string[];
35
+ /** SEO meta tags derived from config + frontmatter */
36
+ seo?: SeoMeta;
33
37
  }
34
38
 
35
39
  export function htmlShell(opts: HtmlShellOptions): string {
@@ -55,6 +59,18 @@ export function htmlShell(opts: HtmlShellOptions): string {
55
59
  const depthPrefix = depth === 0 ? "" : "../".repeat(depth);
56
60
  const assetPrefix = depthPrefix + "assets/";
57
61
  const resolvePath = (path: string) => (path.startsWith("/") ? depthPrefix + path.slice(1) : path);
62
+
63
+ // Build SEO meta tags (OG, Twitter, canonical)
64
+ let seoTags = "";
65
+ if (opts.seo) {
66
+ const s = opts.seo;
67
+ const e = escapeHtml;
68
+ seoTags = `\n <meta property="og:title" content="${e(title)}" />\n <meta property="og:description" content="${e(description)}" />\n <meta property="og:url" content="${e(s.url)}" />\n <meta property="og:type" content="website" />\n <meta property="og:site_name" content="${e(s.siteName)}" />\n <meta name="twitter:card" content="summary_large_image" />\n <link rel="canonical" href="${e(s.url)}" />`;
69
+ if (s.image) {
70
+ seoTags += `\n <meta property="og:image" content="${e(s.image)}" />`;
71
+ }
72
+ }
73
+
58
74
  return `<!DOCTYPE html>
59
75
  <html lang="en">
60
76
  <head>
@@ -63,12 +79,15 @@ export function htmlShell(opts: HtmlShellOptions): string {
63
79
  <title>${escapeHtml(title)}</title>
64
80
  <meta name="description" content="${escapeHtml(description)}">
65
81
  ${favicon ? `<link rel="icon" type="image/x-icon" href="${escapeHtml(resolvePath(favicon))}">` : ""}${themeStyle}
82
+ <link rel="preload" href="${escapeHtml(assetPrefix + css)}" as="style">
66
83
  <link rel="stylesheet" href="${escapeHtml(assetPrefix + css)}">
67
84
  ${csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">` : ""}
85
+ ${seoTags}
68
86
  <script${nonceAttr}>try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}</script>${headInjection}
69
87
  </head>
70
88
  <body>
71
89
  <div id="root">${body}</div>
90
+ <link rel="modulepreload" href="${escapeHtml(assetPrefix + js)}">
72
91
  <script type="module"${nonceAttr} src="${escapeHtml(assetPrefix + js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
73
92
  </body>
74
93
  </html>`;