@docubook/flame 2.0.3 → 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.
- package/.docu/components/Context.tsx +6 -5
- package/.docu/components/DocsLayout.tsx +7 -4
- package/.docu/components/Menu.tsx +10 -9
- package/.docu/components/Navbar.tsx +24 -22
- package/.docu/components/Pagination.tsx +3 -2
- package/.docu/components/Search.tsx +6 -1
- package/.docu/components/Sidebar.tsx +22 -21
- package/.docu/components/Sublink.tsx +3 -3
- package/.docu/lib/build.deno.js +1 -1
- package/.docu/lib/{build.impl-BSt6Vikt.js → build.impl-DK485Nx1.js} +60 -31
- package/.docu/lib/build.impl-DK485Nx1.js.map +1 -0
- package/.docu/lib/build.impl-Dmybgcj2.js +2 -0
- package/.docu/lib/build.node.js +1 -1
- package/.docu/lib/clean.js +1 -1
- package/.docu/lib/deploy.deno.js +1 -1
- package/.docu/lib/deploy.node.js +1 -1
- package/.docu/lib/{deploy.shared-D6S_FE7d.js → deploy.shared-BW2KXFXs.js} +27 -8
- package/.docu/lib/deploy.shared-BW2KXFXs.js.map +1 -0
- package/.docu/lib/{html.shared-CsEFmy_Y.js → html.shared-RPCM1y5Q.js} +264 -73
- package/.docu/lib/html.shared-RPCM1y5Q.js.map +1 -0
- package/.docu/lib/{logger-DFKQUy8M.js → logger-DYFD47f8.js} +3 -3
- package/.docu/lib/{logger-DFKQUy8M.js.map → logger-DYFD47f8.js.map} +1 -1
- package/.docu/lib/paths-CHXABfXa.js +332 -0
- package/.docu/lib/paths-CHXABfXa.js.map +1 -0
- package/.docu/lib/preview.deno.js +1 -1
- package/.docu/lib/{preview.impl-BfESMUM7.js → preview.impl-DMXVsGs0.js} +4 -4
- package/.docu/lib/preview.impl-DMXVsGs0.js.map +1 -0
- package/.docu/lib/preview.node.js +1 -1
- package/.docu/lib/{utils-B_CoyKie.js → server-utils-BYWB7A6T.js} +3 -21
- package/.docu/lib/server-utils-BYWB7A6T.js.map +1 -0
- package/.docu/lib/server.deno.js +1 -1
- package/.docu/lib/{server.impl-CZ4eMe5L.js → server.impl-DcyZhPA_.js} +24 -19
- package/.docu/lib/server.impl-DcyZhPA_.js.map +1 -0
- package/.docu/lib/server.node.js +1 -1
- package/.docu/node/base-path.ts +306 -0
- package/.docu/node/build.impl.ts +50 -17
- package/.docu/node/build.ts +50 -17
- package/.docu/node/cache-key.ts +92 -4
- package/.docu/node/client-routes.ts +16 -1
- package/.docu/node/deploy.shared.ts +51 -15
- package/.docu/node/deploy.ts +7 -3
- package/.docu/node/html.shared.ts +33 -3
- package/.docu/node/html.ts +18 -3
- package/.docu/node/hydrate.node.ts +6 -0
- package/.docu/node/hydrate.ts +16 -1
- package/.docu/node/mdx.ts +102 -23
- package/.docu/node/paths.ts +115 -0
- package/.docu/node/preview.impl.ts +1 -1
- package/.docu/node/preview.ts +1 -1
- package/.docu/node/search-indexer.ts +20 -3
- package/.docu/node/seo.ts +41 -7
- package/.docu/node/server-routes.ts +34 -11
- package/.docu/node/server-utils.ts +152 -0
- package/.docu/node/server.impl.ts +22 -12
- package/.docu/node/server.ts +23 -27
- package/.docu/node/types.ts +13 -0
- package/.docu/node/utils.ts +56 -149
- package/.docu/pages/404.tsx +2 -1
- package/.docu/pages/index.tsx +6 -13
- package/bin/cli.js +5 -0
- package/docu.schema.json +10 -1
- package/package.json +5 -5
- package/template/docu.json +1 -0
- package/.docu/lib/build.impl-BSt6Vikt.js.map +0 -1
- package/.docu/lib/build.impl-xNDEh8DJ.js +0 -2
- package/.docu/lib/deploy.shared-D6S_FE7d.js.map +0 -1
- package/.docu/lib/html.shared-CsEFmy_Y.js.map +0 -1
- package/.docu/lib/paths-Cbz-dj4s.js +0 -72
- package/.docu/lib/paths-Cbz-dj4s.js.map +0 -1
- package/.docu/lib/preview.impl-BfESMUM7.js.map +0 -1
- package/.docu/lib/server.impl-CZ4eMe5L.js.map +0 -1
- package/.docu/lib/utils-B_CoyKie.js.map +0 -1
package/.docu/node/deploy.ts
CHANGED
|
@@ -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"),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = "";
|
package/.docu/node/html.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
},
|
package/.docu/node/hydrate.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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 (/^
|
|
26
|
-
if (!value.startsWith("/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return
|
|
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 (``)
|
|
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:
|
|
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
|
-
*
|
|
53
|
-
*
|
|
120
|
+
* Links cover both shapes — plain markdown `[text](/docs/page)` and images and
|
|
121
|
+
* media (`` → `<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.
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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 `
|
|
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 =
|
|
171
|
+
const fixed = resolveContentHref(attr.value);
|
|
93
172
|
if (fixed) attr.value = fixed;
|
|
94
173
|
}
|
|
95
174
|
}
|
package/.docu/node/paths.ts
CHANGED
|
@@ -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 {
|
package/.docu/node/preview.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|