@docubook/flame 2.0.2 → 2.1.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 (72) hide show
  1. package/.docu/components/Context.tsx +6 -5
  2. package/.docu/components/DocsLayout.tsx +7 -4
  3. package/.docu/components/Menu.tsx +10 -9
  4. package/.docu/components/Navbar.tsx +24 -22
  5. package/.docu/components/Pagination.tsx +3 -2
  6. package/.docu/components/Search.tsx +6 -1
  7. package/.docu/components/Sidebar.tsx +22 -21
  8. package/.docu/components/Sublink.tsx +3 -3
  9. package/.docu/lib/build.deno.js +1 -1
  10. package/.docu/lib/{build.impl-CA7XPLz0.js → build.impl-DK485Nx1.js} +60 -31
  11. package/.docu/lib/build.impl-DK485Nx1.js.map +1 -0
  12. package/.docu/lib/build.impl-Dmybgcj2.js +2 -0
  13. package/.docu/lib/build.node.js +1 -1
  14. package/.docu/lib/clean.js +1 -1
  15. package/.docu/lib/deploy.deno.js +1 -1
  16. package/.docu/lib/deploy.node.js +1 -1
  17. package/.docu/lib/{deploy.shared-DjD3qs7t.js → deploy.shared-BW2KXFXs.js} +27 -8
  18. package/.docu/lib/deploy.shared-BW2KXFXs.js.map +1 -0
  19. package/.docu/lib/{html.shared-CsEFmy_Y.js → html.shared-RPCM1y5Q.js} +264 -73
  20. package/.docu/lib/html.shared-RPCM1y5Q.js.map +1 -0
  21. package/.docu/lib/{logger-_e2TNmEq.js → logger-DYFD47f8.js} +3 -3
  22. package/.docu/lib/{logger-_e2TNmEq.js.map → logger-DYFD47f8.js.map} +1 -1
  23. package/.docu/lib/paths-CHXABfXa.js +332 -0
  24. package/.docu/lib/paths-CHXABfXa.js.map +1 -0
  25. package/.docu/lib/preview.deno.js +1 -1
  26. package/.docu/lib/{preview.impl-DiTu2Coo.js → preview.impl-DMXVsGs0.js} +4 -4
  27. package/.docu/lib/preview.impl-DMXVsGs0.js.map +1 -0
  28. package/.docu/lib/preview.node.js +1 -1
  29. package/.docu/lib/{utils-B_CoyKie.js → server-utils-BYWB7A6T.js} +3 -21
  30. package/.docu/lib/server-utils-BYWB7A6T.js.map +1 -0
  31. package/.docu/lib/server.deno.js +1 -1
  32. package/.docu/lib/{server.impl-DPBnKdIh.js → server.impl-DcyZhPA_.js} +24 -19
  33. package/.docu/lib/server.impl-DcyZhPA_.js.map +1 -0
  34. package/.docu/lib/server.node.js +1 -1
  35. package/.docu/node/base-path.ts +306 -0
  36. package/.docu/node/build.impl.ts +50 -17
  37. package/.docu/node/build.ts +50 -17
  38. package/.docu/node/cache-key.ts +92 -4
  39. package/.docu/node/client-routes.ts +16 -1
  40. package/.docu/node/deploy.shared.ts +51 -15
  41. package/.docu/node/deploy.ts +7 -3
  42. package/.docu/node/html.shared.ts +33 -3
  43. package/.docu/node/html.ts +18 -3
  44. package/.docu/node/hydrate.node.ts +6 -0
  45. package/.docu/node/hydrate.ts +16 -1
  46. package/.docu/node/mdx.ts +102 -23
  47. package/.docu/node/paths.ts +115 -0
  48. package/.docu/node/preview.impl.ts +1 -1
  49. package/.docu/node/preview.ts +1 -1
  50. package/.docu/node/search-indexer.ts +20 -3
  51. package/.docu/node/seo.ts +41 -7
  52. package/.docu/node/server-routes.ts +34 -11
  53. package/.docu/node/server-utils.ts +152 -0
  54. package/.docu/node/server.impl.ts +22 -12
  55. package/.docu/node/server.ts +23 -27
  56. package/.docu/node/types.ts +13 -0
  57. package/.docu/node/utils.ts +56 -149
  58. package/.docu/pages/404.tsx +2 -1
  59. package/.docu/pages/index.tsx +6 -13
  60. package/bin/cli.js +5 -0
  61. package/docu.schema.json +10 -1
  62. package/package.json +5 -5
  63. package/template/docu.json +1 -0
  64. package/.docu/lib/build.impl-C97QWe1I.js +0 -2
  65. package/.docu/lib/build.impl-CA7XPLz0.js.map +0 -1
  66. package/.docu/lib/deploy.shared-DjD3qs7t.js.map +0 -1
  67. package/.docu/lib/html.shared-CsEFmy_Y.js.map +0 -1
  68. package/.docu/lib/paths-Cbz-dj4s.js +0 -72
  69. package/.docu/lib/paths-Cbz-dj4s.js.map +0 -1
  70. package/.docu/lib/preview.impl-DiTu2Coo.js.map +0 -1
  71. package/.docu/lib/server.impl-DPBnKdIh.js.map +0 -1
  72. package/.docu/lib/utils-B_CoyKie.js.map +0 -1
@@ -10,11 +10,12 @@
10
10
  import { mkdir } from "node:fs/promises";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
- import { DIST_DIR, PROJECT_ROOT } from "./paths";
13
+ import { DIST_DIR, PROJECT_ROOT, servedBasePath } from "./paths";
14
14
  import {
15
15
  DOCKERFILE_BUN,
16
16
  DOCKERFILE_MARKER,
17
17
  generateDockerfile,
18
+ buildNginxConf,
18
19
  HEADERS_FILE,
19
20
  NGINX_CONF,
20
21
  DOCKERIGNORE,
@@ -69,7 +70,7 @@ async function writeDockerFiles() {
69
70
  }
70
71
 
71
72
  if (!existsSync(join(dockerDir, "nginx.conf"))) {
72
- await Bun.write(join(dockerDir, "nginx.conf"), NGINX_CONF);
73
+ await Bun.write(join(dockerDir, "nginx.conf"), buildNginxConf(servedBasePath()));
73
74
  log.created("📄 Created nginx.conf");
74
75
  }
75
76
 
@@ -213,7 +214,10 @@ jobs:
213
214
  uses: actions/deploy-pages@v4
214
215
  `;
215
216
 
216
- if (import.meta.main) {
217
+ // The CLI dynamically imports this entry point, so import.meta.main is false
218
+ // when running `flame deploy` under Bun. The CLI sets FLAME_CLI_ENTRY before
219
+ // importing; direct `bun deploy.ts` still uses Bun's main-module detection.
220
+ if (import.meta.main || process.env.FLAME_CLI_ENTRY === "1") {
217
221
  deploy().catch((err) => {
218
222
  console.error("Deploy failed:", err);
219
223
  process.exit(1);
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { escapeHtml } from "./escapeHtml";
10
10
  import { cspMeta } from "./security";
11
+ import { rebaseContentPath } from "./utils";
12
+ import { DEFAULT_BASE_PATH } from "./paths";
11
13
 
12
14
  import type { SeoMeta } from "./seo";
13
15
 
@@ -38,6 +40,21 @@ export interface HtmlShellOptions {
38
40
  /** Root-absolute asset URLs (`/assets/...`). Required for pages served at
39
41
  * arbitrary paths (404 fallback) — relative depth is wrong there. */
40
42
  absoluteAssets?: boolean;
43
+ /**
44
+ * URL prefix the site is served under (`""` at the deployment root, else
45
+ * `/prefix` with no trailing slash). Used to re-base author-written content
46
+ * paths (favicon, og-image) that carry the default `/docs` prefix. Bundle
47
+ * assets live at the dist root (`/assets/`) and never take the prefix.
48
+ * Defaults to `/docs` so callers that never pass a prefix keep the
49
+ * historical output byte-identical.
50
+ */
51
+ basePath?: string;
52
+ /**
53
+ * Path the host serves the dist root under (`""` at an origin root, `/repo`
54
+ * for a GitHub Pages project site). Only root-absolute references need it —
55
+ * relative ones already line up because pages and assets share it.
56
+ */
57
+ deployPath?: string;
41
58
  }
42
59
 
43
60
  export function htmlShell(opts: HtmlShellOptions): string {
@@ -56,18 +73,31 @@ export function htmlShell(opts: HtmlShellOptions): string {
56
73
  headExtra,
57
74
  bodyExtra,
58
75
  absoluteAssets = false,
76
+ basePath = DEFAULT_BASE_PATH,
77
+ deployPath = "",
59
78
  } = opts;
60
79
  const nonceAttr = nonce ? ` nonce="${escapeHtml(nonce)}"` : "";
61
80
  const themeStyle = themeCss ? `\n <style${nonceAttr}>${escapeHtml(themeCss)}</style>` : "";
62
81
  const headInjection = headExtra?.length ? `\n ${headExtra.join("\n ")}` : "";
63
82
  const bodyInjection = bodyExtra?.length ? `\n ${bodyExtra.join("\n ")}` : "";
64
83
  const depthPrefix = depth === 0 ? "" : "../".repeat(depth);
65
- const assetPrefix = absoluteAssets ? "/assets/" : depthPrefix + "assets/";
84
+ // Bundle assets (JS, CSS, chunks) are written to the dist root and are
85
+ // independent of the docs prefix; pages climb out of the prefix with `../`.
86
+ // Depth-independent URLs (404 fallback) still carry the host's deployment
87
+ // path, which relative climbs cancel out.
88
+ const assetPrefix = absoluteAssets ? `${deployPath}/assets/` : depthPrefix + "assets/";
66
89
  const clientScript = js
67
90
  ? `\n <link rel="modulepreload" href="${escapeHtml(assetPrefix + js)}">\n <script type="module"${nonceAttr} src="${escapeHtml(assetPrefix + js)}"></script>`
68
91
  : "";
69
- const resolvePath = (path: string) =>
70
- absoluteAssets ? path : path.startsWith("/") ? depthPrefix + path.slice(1) : path;
92
+ const resolvePath = (path: string) => {
93
+ if (!path.startsWith("/")) return path;
94
+ // Author-written content paths (favicon, logo) are root-relative as they
95
+ // appear on disk, e.g. "/docs/assets/images/favicon.ico". Re-base a leading
96
+ // default prefix onto the configured one, otherwise the asset 404s at the
97
+ // old path after a subpath change.
98
+ const rebased = rebaseContentPath(path, basePath);
99
+ return absoluteAssets ? `${deployPath}${rebased}` : depthPrefix + rebased.slice(1);
100
+ };
71
101
 
72
102
  // Build SEO meta tags (OG, Twitter, canonical)
73
103
  let seoTags = "";
@@ -1,6 +1,8 @@
1
1
  import type { HtmlShellOptions } from "./html.shared";
2
2
  export type { HtmlShellOptions };
3
3
  import { cspMeta } from "./security";
4
+ import { rebaseContentPath } from "./utils";
5
+ import { DEFAULT_BASE_PATH } from "./paths";
4
6
 
5
7
  export function htmlShell(opts: HtmlShellOptions): string {
6
8
  const {
@@ -18,18 +20,31 @@ export function htmlShell(opts: HtmlShellOptions): string {
18
20
  headExtra,
19
21
  bodyExtra,
20
22
  absoluteAssets = false,
23
+ basePath = DEFAULT_BASE_PATH,
24
+ deployPath = "",
21
25
  } = opts;
22
26
  const nonceAttr = nonce ? ` nonce="${Bun.escapeHTML(nonce)}"` : "";
23
27
  const themeStyle = themeCss ? `\n <style${nonceAttr}>${Bun.escapeHTML(themeCss)}</style>` : "";
24
28
  const headInjection = headExtra?.length ? `\n ${headExtra.join("\n ")}` : "";
25
29
  const bodyInjection = bodyExtra?.length ? `\n ${bodyExtra.join("\n ")}` : "";
26
30
  const depthPrefix = depth === 0 ? "" : "../".repeat(depth);
27
- const assetPrefix = absoluteAssets ? "/assets/" : depthPrefix + "assets/";
31
+ // Bundle assets (JS, CSS, chunks) are written to the dist root and are
32
+ // independent of the docs prefix; pages climb out of the prefix with `../`.
33
+ // Depth-independent URLs (404 fallback) still carry the host's deployment
34
+ // path, which relative climbs cancel out.
35
+ const assetPrefix = absoluteAssets ? `${deployPath}/assets/` : depthPrefix + "assets/";
28
36
  const clientScript = js
29
37
  ? `\n <link rel="modulepreload" href="${Bun.escapeHTML(assetPrefix + js)}">\n <script type="module"${nonceAttr} src="${Bun.escapeHTML(assetPrefix + js)}"></script>`
30
38
  : "";
31
- const resolvePath = (path: string) =>
32
- absoluteAssets ? path : path.startsWith("/") ? depthPrefix + path.slice(1) : path;
39
+ const resolvePath = (path: string) => {
40
+ if (!path.startsWith("/")) return path;
41
+ // Author-written content paths (favicon, logo) are root-relative as they
42
+ // appear on disk, e.g. "/docs/assets/images/favicon.ico". Re-base a leading
43
+ // default prefix onto the configured one, otherwise the asset 404s at the
44
+ // old path after a subpath change.
45
+ const rebased = rebaseContentPath(path, basePath);
46
+ return absoluteAssets ? `${deployPath}${rebased}` : depthPrefix + rebased.slice(1);
47
+ };
33
48
 
34
49
  // Build SEO meta tags (OG, Twitter, canonical)
35
50
  let seoTags = "";
@@ -14,6 +14,8 @@ import {
14
14
  LIB_DIR,
15
15
  STYLES_DIR,
16
16
  loadDocuConfig,
17
+ resolveBasePath,
18
+ servedDeployPath,
17
19
  } from "./paths";
18
20
  import { buildThemeCss, createMdxModuleEntries, getThemeConfig } from "./hydrate";
19
21
  import { atomicWriteFile, computeTailwindCacheKey, readStyleCss } from "./cache-key";
@@ -232,6 +234,10 @@ export async function buildClientBundle(
232
234
  `const docuConfig = ${JSON.stringify(resolved)};`,
233
235
  `export const routes = docuConfig.routes || [];`,
234
236
  `export const config = docuConfig;`,
237
+ `export const basePath = ${JSON.stringify(resolveBasePath(config))};`,
238
+ // Dev serves the project from the origin root, so the host's
239
+ // deployment path only reaches the production bundle.
240
+ `export const deployPath = ${JSON.stringify(servedDeployPath())};`,
235
241
  ].join("\n");
236
242
  },
237
243
  },
@@ -3,7 +3,15 @@ import { basename, join } from "node:path";
3
3
  import { mkdir, rename, unlink } from "node:fs/promises";
4
4
  import { existsSync } from "node:fs";
5
5
  import { resolveTheme, generateThemeCss, presetRegistry } from "@docubook/themes-colors";
6
- import { ASSETS_DIR, cleanOldBundles, LIB_DIR, STYLES_DIR, loadDocuConfig } from "./paths";
6
+ import {
7
+ ASSETS_DIR,
8
+ cleanOldBundles,
9
+ LIB_DIR,
10
+ STYLES_DIR,
11
+ loadDocuConfig,
12
+ resolveBasePath,
13
+ servedDeployPath,
14
+ } from "./paths";
7
15
  import { atomicWriteFile, computeTailwindCacheKey, readStyleCss } from "./cache-key";
8
16
  import { resolveRoutes } from "./fs-scanner";
9
17
  import type { AssetManifest, DocuRoute } from "./types";
@@ -161,6 +169,13 @@ export async function buildClientBundle(
161
169
  `const docuConfig = ${JSON.stringify(resolved)};`,
162
170
  `export const routes = docuConfig.routes || [];`,
163
171
  `export const config = docuConfig;`,
172
+ // The client bundle cannot read docu.json at runtime, so the
173
+ // resolved prefix is inlined here — same value the server-side
174
+ // render and the static build use.
175
+ `export const basePath = ${JSON.stringify(resolveBasePath(config))};`,
176
+ // Dev serves the project from the origin root, so the host's
177
+ // deployment path only reaches the production bundle.
178
+ `export const deployPath = ${JSON.stringify(servedDeployPath())};`,
164
179
  ].join("\n"),
165
180
  loader: "ts",
166
181
  };
package/.docu/node/mdx.ts CHANGED
@@ -12,23 +12,93 @@ import {
12
12
  } from "@docubook/core";
13
13
  import { createMdxComponents } from "@docubook/markdown";
14
14
  import { getGitLastModified, getGitLastModifiedBatch, getFilesystemMtime } from "./git";
15
+ import { basePath, servedDeployPath } from "./paths";
16
+ import { isDocsPath, rebaseContentPath } from "./base-path";
15
17
 
16
18
  /**
17
- * Return the value with `.html` appended, or null if the value should be left
18
- * unchanged. Rules:
19
- * - Must be a string
20
- * - Must start with /docs/ (the /docs root index needs no suffix)
21
- * - Must not be an external URL, contain a fragment, or already end in .html
19
+ * Does an author-written absolute path belong to the docs site?
20
+ *
21
+ * At a root deployment the docs own `/`, so every in-site path is a candidate
22
+ * (the historical rule for authored content); otherwise the shared
23
+ * {@link isDocsPath} gate applies and sibling app routes stay untouched.
24
+ */
25
+ function isContentPath(pathname: string, resolvedBasePath: string): boolean {
26
+ return resolvedBasePath.length === 0 || isDocsPath(pathname, resolvedBasePath);
27
+ }
28
+
29
+ /** Split an authored value into its pathname and any `?query`/`#fragment`. */
30
+ function splitContentPath(value: string): { pathname: string; suffix: string } {
31
+ const cut = value.search(/[?#]/);
32
+ if (cut === -1) return { pathname: value.replace(/\/+$/, "") || "/", suffix: "" };
33
+ return {
34
+ pathname: value.slice(0, cut).replace(/\/+$/, "") || "/",
35
+ suffix: value.slice(cut),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Resolve an author-written content **link** for the deployment.
41
+ *
42
+ * Authors link to sibling pages the way they sit on disk (`/docs/guide/y`). The
43
+ * static build serves those pages as flat `.html` files, and a non-default
44
+ * `meta.basePath` — or a host that publishes the dist under a path (a GitHub
45
+ * Pages project site) — moves the authored segment. Re-base it onto the
46
+ * configured prefix, add the deployment path, and keep the `.html` suffix on
47
+ * page links. Returns null when nothing moves, so the default `/docs`
48
+ * deployment keeps emitting the same HTML.
22
49
  */
23
- function appendHtml(value: unknown): string | null {
50
+ export function resolveContentHref(
51
+ value: unknown,
52
+ resolvedBasePath: string = basePath(),
53
+ deploymentPath: string = servedDeployPath()
54
+ ): string | null {
24
55
  if (typeof value !== "string") return null;
25
- if (/^https?:\/\//.test(value)) return null;
26
- if (!value.startsWith("/docs/")) return null;
27
- if (value.includes("#")) return null;
28
- if (value.endsWith(".html")) return null;
29
- return `${value}.html`;
56
+ if (/^[a-z][a-z\d+.-]*:|^\/\//i.test(value)) return null; // external / protocol-relative
57
+ if (!value.startsWith("/") || value === "/") return null;
58
+
59
+ const { pathname, suffix } = splitContentPath(value);
60
+ if (!isContentPath(pathname, resolvedBasePath)) return null;
61
+
62
+ const rebased = rebaseContentPath(pathname, resolvedBasePath);
63
+ // The docs root is a directory index, fragments keep the authored
64
+ // extensionless form (hosts with `try_files` serve the pair), and files keep
65
+ // their own suffix.
66
+ const isDirectory =
67
+ rebased === "/" || rebased === resolvedBasePath || /\.[a-z0-9]+$/i.test(rebased);
68
+ const target = isDirectory || suffix.includes("#") ? rebased : `${rebased}.html`;
69
+ const resolved = `${deploymentPath}${target}${suffix}`;
70
+ return resolved === value ? null : resolved;
30
71
  }
31
72
 
73
+ /**
74
+ * Resolve an author-written content **asset** path (`![alt](/docs/assets/x.png)`)
75
+ * for the deployment. Same re-basing rules as {@link resolveContentHref}, minus
76
+ * the `.html` suffix — assets are files, not routes.
77
+ */
78
+ export function resolveContentSrc(
79
+ value: unknown,
80
+ resolvedBasePath: string = basePath(),
81
+ deploymentPath: string = servedDeployPath()
82
+ ): string | null {
83
+ if (typeof value !== "string") return null;
84
+ if (/^[a-z][a-z\d+.-]*:|^\/\//i.test(value)) return null;
85
+ if (!value.startsWith("/") || value === "/") return null;
86
+
87
+ const { pathname, suffix } = splitContentPath(value);
88
+ if (!isContentPath(pathname, resolvedBasePath)) return null;
89
+
90
+ const resolved = `${deploymentPath}${rebaseContentPath(pathname, resolvedBasePath)}${suffix}`;
91
+ return resolved === value ? null : resolved;
92
+ }
93
+
94
+ /** Element → attribute pairs whose author-written values are content assets. */
95
+ const CONTENT_ASSET_ATTRS: Record<string, string> = {
96
+ img: "src",
97
+ source: "src",
98
+ video: "src",
99
+ audio: "src",
100
+ };
101
+
32
102
  interface HastNode {
33
103
  type: string;
34
104
  tagName?: string;
@@ -44,20 +114,28 @@ interface MdastNode {
44
114
  }
45
115
 
46
116
  /**
47
- * Rehype plugin: append `.html` to internal `/docs/` hrefs on HTML `<a>` nodes.
48
- *
49
- * This covers standard markdown links: `[text](/docs/page)` → `<a href="…">`.
50
- * It runs in the HAST (HTML AST) phase, where `<a>` elements are real nodes.
117
+ * Rehype plugin: point author-written hrefs at the deployed docs paths, and
118
+ * author-written asset paths at the deployed asset tree.
51
119
  *
52
- * Skips: external URLs, anchor-only links, paths that already end in `.html`,
53
- * and the `/docs` root index (no trailing slash segment).
120
+ * Links cover both shapes — plain markdown `[text](/docs/page)` and images and
121
+ * media (`![alt](/docs/assets/x.png)` → `<img src>`) — which only exist in the
122
+ * HAST phase. Skips: external URLs, anchors, the root index, and anything the
123
+ * resolver leaves unchanged.
54
124
  */
55
125
  function rehypeDocsHtmlLinks() {
56
126
  return (tree: HastNode) => {
57
127
  function walk(node: HastNode): void {
58
- if (node.type === "element" && node.tagName === "a") {
59
- const fixed = appendHtml(node.properties?.href);
60
- if (fixed) node.properties!.href = fixed;
128
+ if (node.type === "element" && node.properties) {
129
+ if (node.tagName === "a") {
130
+ const fixed = resolveContentHref(node.properties.href);
131
+ if (fixed) node.properties.href = fixed;
132
+ } else {
133
+ const attr = node.tagName ? CONTENT_ASSET_ATTRS[node.tagName] : undefined;
134
+ if (attr) {
135
+ const fixed = resolveContentSrc(node.properties[attr]);
136
+ if (fixed) node.properties[attr] = fixed;
137
+ }
138
+ }
61
139
  }
62
140
  if (node.children) {
63
141
  for (const child of node.children) walk(child);
@@ -69,7 +147,7 @@ function rehypeDocsHtmlLinks() {
69
147
  }
70
148
 
71
149
  /**
72
- * Remark plugin: append `.html` to internal `/docs/` hrefs on MDX JSX nodes.
150
+ * Remark plugin: append `.html` to internal docs hrefs on MDX JSX nodes.
73
151
  *
74
152
  * MDX JSX elements (`<Card href="…">`, `<LinkCard href="…">`, etc.) live in
75
153
  * the MDAST as `mdxJsxFlowElement` / `mdxJsxTextElement` nodes. They are
@@ -78,7 +156,8 @@ function rehypeDocsHtmlLinks() {
78
156
  * intercepts them at the MDAST phase where their `attributes` array is still
79
157
  * accessible and mutable.
80
158
  *
81
- * Skips: same rules as `appendHtml` (external URLs, anchors, already `.html`).
159
+ * Skips: same rules as `resolveContentHref` (external URLs, anchors, already
160
+ * resolved paths).
82
161
  */
83
162
  function remarkMdxJsxDocsHtmlLinks() {
84
163
  return (tree: MdastNode) => {
@@ -89,7 +168,7 @@ function remarkMdxJsxDocsHtmlLinks() {
89
168
  ) {
90
169
  for (const attr of node.attributes) {
91
170
  if (attr.type === "mdxJsxAttribute" && attr.name === "href") {
92
- const fixed = appendHtml(attr.value);
171
+ const fixed = resolveContentHref(attr.value);
93
172
  if (fixed) attr.value = fixed;
94
173
  }
95
174
  }
@@ -2,6 +2,19 @@ import { resolve, join } from "node:path";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { readdir, rm, unlink } from "node:fs/promises";
4
4
  import type { DocuConfig } from "./types";
5
+ import { resolveBasePath, resolveDeployPath, validateBasePath } from "./base-path";
6
+ import type { BasePathIssue } from "./base-path";
7
+
8
+ export {
9
+ DEFAULT_BASE_PATH,
10
+ normalizeBasePath,
11
+ canonicalBasePath,
12
+ resolveBasePath,
13
+ resolveDeployPath,
14
+ docsDepth,
15
+ validateBasePath,
16
+ } from "./base-path";
17
+ export type { BasePathIssue } from "./base-path";
5
18
 
6
19
  /**
7
20
  * FRAMEWORK_ROOT: Where the package code lives (.docu/components, .docu/pages, .docu/styles, .docu/node)
@@ -84,3 +97,105 @@ export function loadDocuConfig(): DocuConfig {
84
97
  _config = JSON.parse(readFileSync(DOCU_CONFIG_PATH, "utf-8"));
85
98
  return _config!;
86
99
  }
100
+
101
+ /** Default favicon — resolves against the runtime `meta.basePath`. Lives here
102
+ * (not in `./base-path`) because it needs the config-loaded `basePath()`. */
103
+ export function defaultFavicon(): string {
104
+ return `${basePath()}/assets/images/favicon.ico`;
105
+ }
106
+
107
+ /**
108
+ * @deprecated Read the value at call time via {@link defaultFavicon} — this
109
+ * constant is frozen at import and cannot follow `meta.basePath`.
110
+ */
111
+ export const DEFAULT_FAVICON = "/docs/assets/images/favicon.ico";
112
+
113
+ /**
114
+ * Cached base path for the current project. Callers that already hold a config
115
+ * may pass it in; otherwise the config singleton is loaded on first use.
116
+ *
117
+ * Safe to read eagerly at module scope even though `loadDocuConfig()` is only
118
+ * invoked inside a build: `PROJECT_ROOT` is resolved at import time, and the
119
+ * fallback (missing or malformed docu.json) is the `/docs` default, so the
120
+ * value never depends on call order.
121
+ */
122
+ export function basePath(): string {
123
+ let config: DocuConfig | null = null;
124
+ try {
125
+ config = loadDocuConfig();
126
+ } catch {
127
+ // config absent/unreadable (tests, ad-hoc scripts) — use the default
128
+ }
129
+ return resolveBasePath(config);
130
+ }
131
+
132
+ /** Build-output directory for docs pages, e.g. `.docu/dist/docs` or `dist/` at the root. */
133
+ export function docsOutDir(distDir: string, resolvedBasePath: string = basePath()): string {
134
+ return resolvedBasePath.length === 0 ? distDir : join(distDir, resolvedBasePath.slice(1));
135
+ }
136
+
137
+ /** Serve-prefix counterpart of {@link docsOutDir}: `""` at the root, else
138
+ * `/prefix` with no trailing slash. */
139
+ export function servedBasePath(resolvedBasePath: string = basePath()): string {
140
+ return resolvedBasePath;
141
+ }
142
+
143
+ /**
144
+ * Deployment-root path contributed by the host — `""` when the origin serves
145
+ * the dist at `/`, `/repo` for a GitHub Pages project site. Root-absolute
146
+ * references (404 fallback assets, the search index) are built from it.
147
+ */
148
+ export function deployPath(): string {
149
+ let config: DocuConfig | null = null;
150
+ try {
151
+ config = loadDocuConfig();
152
+ } catch {
153
+ // config absent/unreadable (tests, ad-hoc scripts) — assume an origin root
154
+ }
155
+ return resolveDeployPath(config);
156
+ }
157
+
158
+ /**
159
+ * {@link deployPath} for the current mode: a host path only exists in built
160
+ * output. Dev serves the project from the origin root, so writing it into dev
161
+ * HTML (or a dev client bundle) would point at directories the dev server does
162
+ * not own.
163
+ */
164
+ export function servedDeployPath(): string {
165
+ return process.env.NODE_ENV === "production" ? deployPath() : "";
166
+ }
167
+
168
+ /**
169
+ * Fail fast on a `meta.basePath` that cannot be honored (a non-string value) and
170
+ * hand back the normalization warnings for the caller to log — case, whitespace
171
+ * and unservable characters are normalized by `resolveBasePath`, not rejected.
172
+ * Called from the build and dev-server entries so the message lands before any
173
+ * output is written.
174
+ */
175
+ export function assertValidBasePath(): BasePathIssue[] {
176
+ let config: DocuConfig | null = null;
177
+ try {
178
+ config = loadDocuConfig();
179
+ } catch {
180
+ return [];
181
+ }
182
+
183
+ const issues = validateBasePath(config?.meta?.basePath);
184
+ const errors = issues.filter((issue) => issue.level === "error");
185
+ if (errors.length > 0) {
186
+ throw new Error(
187
+ [
188
+ `Invalid "meta.basePath" in ${DOCU_CONFIG_PATH}:`,
189
+ ...errors.map((issue) => ` • ${issue.message}`),
190
+ ].join("\n")
191
+ );
192
+ }
193
+ return issues.filter((issue) => issue.level === "warning");
194
+ }
195
+
196
+ /**
197
+ * Build-output directory for docs pages — derived from `meta.basePath`.
198
+ * Declared after the resolver above: `const` bindings initialize in order, and
199
+ * `DOCS_OUT_DIR` reads `DEFAULT_BASE_PATH` through the chain.
200
+ */
201
+ export const DOCS_OUT_DIR = docsOutDir(DIST_DIR);
@@ -9,7 +9,7 @@ import { resolve } from "node:path";
9
9
  import type { RuntimeAdapter, ServerHandle } from "./runtime";
10
10
  import { logger } from "./logger";
11
11
  import { DIST_DIR } from "./paths";
12
- import { getContentType } from "./utils";
12
+ import { getContentType } from "./server-utils";
13
13
  import { SECURITY_HEADERS, generateNonce, cspHeader, injectNonce } from "./security";
14
14
 
15
15
  function resolveFile(pathname: string): string | null {
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { logger } from "./logger";
4
4
  import { DIST_DIR } from "./paths";
5
- import { getContentType } from "./utils";
5
+ import { getContentType } from "./server-utils";
6
6
  import { SECURITY_HEADERS, generateNonce, cspHeader, injectNonce } from "./security";
7
7
 
8
8
  const PORT = process.env.PORT || "4173";
@@ -16,8 +16,9 @@ import { resolve, join } from "node:path";
16
16
  import { getPageContent } from "./mdx";
17
17
  import { extractFrontmatterWithContent } from "@docubook/core";
18
18
  import { frontmatterField } from "./mdx";
19
- import { DOCS_DIR, ASSETS_DIR, loadDocuConfig } from "./paths";
20
- import { scanMdxFiles, docsHtmlHref } from "./utils";
19
+ import { DOCS_DIR, ASSETS_DIR, loadDocuConfig, basePath, deployPath } from "./paths";
20
+ import { scanMdxFiles } from "./server-utils";
21
+ import { docsHtmlHref } from "./utils";
21
22
 
22
23
  const docuConfig = loadDocuConfig();
23
24
 
@@ -72,10 +73,26 @@ export function stripJsx(content: string): string {
72
73
  return result;
73
74
  }
74
75
 
76
+ /**
77
+ * Absolute URL for a search record.
78
+ *
79
+ * Records are clicked straight from the search modal, so they must carry both
80
+ * prefixes: the docs prefix inside the dist and the host's deployment path
81
+ * (e.g. `/repo` on a GitHub Pages project site), which no relative depth can
82
+ * express.
83
+ */
84
+ export function searchRecordUrl(
85
+ filePath: string,
86
+ resolvedBasePath: string = basePath(),
87
+ deploymentPath: string = deployPath()
88
+ ): string {
89
+ return docsHtmlHref(`${deploymentPath}${resolvedBasePath}/${filePath}`);
90
+ }
91
+
75
92
  export function extractRecords(filePath: string, raw: string): SearchRecord[] {
76
93
  const { frontmatter, strippedContent: content } = extractFrontmatterWithContent<Frontmatter>(raw);
77
94
  const records: SearchRecord[] = [];
78
- const url = docsHtmlHref(`/docs/${filePath}`);
95
+ const url = searchRecordUrl(filePath);
79
96
  const lvl0 = getSectionTitle(filePath);
80
97
  const lvl1 = frontmatterField(frontmatter, "title") || null;
81
98
 
package/.docu/node/seo.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { DocuConfig } from "./types";
2
2
  import { frontmatterField } from "./mdx";
3
+ import { resolveBasePath, resolveDeployPath } from "./paths";
4
+ import { rebaseContentPath } from "./utils";
3
5
 
4
6
  export interface SeoMeta {
5
7
  /** Absolute canonical URL */
@@ -13,6 +15,9 @@ export interface SeoMeta {
13
15
  /**
14
16
  * Build SEO metadata from config and per-page frontmatter.
15
17
  * All fields are derived from existing data — no extra config required.
18
+ *
19
+ * All paths are prefixed with the configured base path so canonical and OG
20
+ * URLs stay correct when the site is served under a subpath.
16
21
  */
17
22
  export function buildSeoMeta(
18
23
  config: DocuConfig,
@@ -20,7 +25,8 @@ export function buildSeoMeta(
20
25
  slug: string
21
26
  ): SeoMeta {
22
27
  const baseURL = config.meta?.baseURL?.replace(/\/+$/, "") || "";
23
- const url = slug ? `${baseURL}/docs/${slug}` : `${baseURL}/`;
28
+ const prefix = resolveBasePath(config);
29
+ const url = slug ? `${baseURL}${prefix}/${slug}` : `${baseURL}/`;
24
30
 
25
31
  const result: SeoMeta = {
26
32
  url,
@@ -30,13 +36,41 @@ export function buildSeoMeta(
30
36
  // Per-page image from frontmatter, fallback to global default from config
31
37
  const image = frontmatterField(frontmatter, "image") || config.meta?.ogImage;
32
38
  if (image) {
33
- // Resolve using URL constructor — handles absolute, root-relative, and relative paths
34
- try {
35
- result.image = new URL(image, image.startsWith("/") ? baseURL : `${baseURL}/docs/`).href;
36
- } catch {
37
- result.image = image;
38
- }
39
+ result.image = resolveOgImage(image, baseURL, prefix, resolveDeployPath(config));
39
40
  }
40
41
 
41
42
  return result;
42
43
  }
44
+
45
+ /**
46
+ * Resolve an OG image path to an absolute URL.
47
+ *
48
+ * Handles four shapes:
49
+ * - absolute (`https://…`) — used verbatim
50
+ * - root-relative already carrying the prefix (`/repo/assets/og.png`) — used as is
51
+ * - root-relative still carrying the default prefix (`/docs/assets/og.png`) —
52
+ * re-based onto `prefix`, so an existing `docu.json` keeps working after the
53
+ * site moves to a different subpath
54
+ * - bare relative (`og.png`) — resolved against the prefix directory
55
+ */
56
+ function resolveOgImage(
57
+ image: string,
58
+ baseURL: string,
59
+ prefix: string,
60
+ deploymentPath: string
61
+ ): string {
62
+ try {
63
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(image)) return new URL(image).href;
64
+ // Content assets live under the docs prefix *inside* the deployment root,
65
+ // which `baseURL` already spells out (schema: "origin plus deployment
66
+ // root"), so resolve the path relative to it. The deployment path is only
67
+ // used to spot an author who spelled the host's segment out themselves.
68
+ const rebased = rebaseContentPath(image.startsWith("/") ? image : `${prefix}/${image}`, prefix);
69
+ const withinRoot = rebased.startsWith(`${deploymentPath}/`)
70
+ ? rebased.slice(deploymentPath.length)
71
+ : rebased;
72
+ return new URL(withinRoot.replace(/^\/+/, ""), `${baseURL}/`).href;
73
+ } catch {
74
+ return image;
75
+ }
76
+ }