blume 0.3.0 → 0.5.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/dist/cli/index.js +1631 -940
- package/dist/cli/index.js.map +62 -50
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +442 -292
- package/dist/types/core/types.d.ts +7 -0
- package/dist/types/migrate/mintlify/assets.d.ts +8 -0
- package/docs/01-quickstart.mdx +5 -16
- package/docs/02-deployment.mdx +21 -54
- package/docs/advanced/api-reference.mdx +34 -51
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/bridge.mdx +74 -0
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +21 -78
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +119 -0
- package/docs/configuration/ai.mdx +42 -103
- package/docs/configuration/analytics.mdx +20 -38
- package/docs/configuration/customization.mdx +40 -73
- package/docs/configuration/export.mdx +9 -34
- package/docs/configuration/index.mdx +67 -87
- package/docs/configuration/search.mdx +17 -54
- package/docs/configuration/seo.mdx +17 -48
- package/docs/configuration/theming.mdx +20 -42
- package/docs/content/components.mdx +95 -101
- package/docs/content/i18n.mdx +21 -72
- package/docs/content/index.mdx +18 -48
- package/docs/content/islands.mdx +25 -52
- package/docs/content/meta.mdx +23 -50
- package/docs/content/navigation.mdx +23 -62
- package/docs/content/sources.mdx +20 -83
- package/docs/content/syntax.mdx +37 -105
- package/docs/index.mdx +12 -41
- package/docs/reference/cli.mdx +47 -30
- package/docs/reference/frontmatter.mdx +7 -5
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -8
- package/src/astro/integration.ts +26 -3
- package/src/astro/islands.ts +6 -2
- package/src/astro/markdown-negotiation.ts +17 -3
- package/src/astro/pages.ts +6 -1
- package/src/astro/static-assets.ts +117 -0
- package/src/astro/templates.ts +76 -30
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +129 -62
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/commands/dev.ts +11 -2
- package/src/cli/commands/doctor.ts +10 -1
- package/src/cli/commands/eject.ts +3 -1
- package/src/cli/commands/init.ts +21 -1
- package/src/cli/commands/preview.ts +2 -1
- package/src/cli/commands/validate.ts +12 -1
- package/src/cli/dev-lock.ts +92 -0
- package/src/cli/log.ts +11 -0
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +8 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/content/YouTube.astro +35 -0
- package/src/components/content/youtube.ts +46 -0
- package/src/components/islands/ask-ai.tsx +14 -14
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/components/props.ts +3 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +6 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +6 -1
- package/src/core/gitignore.ts +30 -0
- package/src/core/links.ts +60 -19
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +54 -6
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/normalize.ts +6 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/sources/sanity.ts +5 -1
- package/src/core/types.ts +7 -0
- package/src/deploy/rss.ts +1 -8
- package/src/deploy/sitemap.ts +20 -1
- package/src/deploy/xml.ts +8 -0
- package/src/markdown/directives.ts +15 -7
- package/src/markdown/package-commands.ts +26 -4
- package/src/migrate/fumadocs/content.ts +14 -1
- package/src/migrate/fumadocs/groups.ts +7 -0
- package/src/migrate/fumadocs/index.ts +5 -2
- package/src/migrate/mintlify/assets.ts +46 -0
- package/src/migrate/mintlify/config.ts +153 -1
- package/src/migrate/mintlify/content.ts +8 -2
- package/src/migrate/mintlify/index.ts +111 -46
- package/src/migrate/shared.ts +12 -27
- package/src/og/card.ts +14 -2
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +28 -5
- package/src/registry/registry.ts +6 -0
- package/src/registry/rewrite-imports.ts +31 -19
- package/src/search/documents.ts +23 -5
- package/src/search/sync/algolia.ts +5 -1
- package/src/search/sync/typesense.ts +24 -16
- package/src/theme/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
- package/src/theme/palette.ts +26 -7
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client behaviour for the OpenAPI request/response panels. `<blume-panel-tabs>`
|
|
3
|
+
* switches the visible `[data-panel="key"]` region when a `[data-panel-tab="key"]`
|
|
4
|
+
* button is clicked, and an optional `[data-panel-copy]` button copies the active
|
|
5
|
+
* panel's text. Vanilla custom element — no framework, in keeping with the core
|
|
6
|
+
* theme.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
class BlumePanelTabs extends HTMLElement {
|
|
10
|
+
connectedCallback() {
|
|
11
|
+
const tabs = [
|
|
12
|
+
...this.querySelectorAll<HTMLButtonElement>("[data-panel-tab]"),
|
|
13
|
+
];
|
|
14
|
+
const panels = [...this.querySelectorAll<HTMLElement>("[data-panel]")];
|
|
15
|
+
const copy = this.querySelector<HTMLButtonElement>("[data-panel-copy]");
|
|
16
|
+
|
|
17
|
+
const activate = (key: string): void => {
|
|
18
|
+
for (const tab of tabs) {
|
|
19
|
+
tab.setAttribute(
|
|
20
|
+
"aria-selected",
|
|
21
|
+
tab.dataset.panelTab === key ? "true" : "false"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
for (const panel of panels) {
|
|
25
|
+
panel.classList.toggle("hidden", panel.dataset.panel !== key);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (const tab of tabs) {
|
|
30
|
+
tab.addEventListener("click", () => {
|
|
31
|
+
const key = tab.dataset.panelTab;
|
|
32
|
+
if (key) {
|
|
33
|
+
activate(key);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (copy) {
|
|
39
|
+
copy.addEventListener("click", async () => {
|
|
40
|
+
const active = panels.find(
|
|
41
|
+
(panel) => !panel.classList.contains("hidden")
|
|
42
|
+
);
|
|
43
|
+
try {
|
|
44
|
+
await navigator.clipboard.writeText(active?.textContent ?? "");
|
|
45
|
+
copy.dataset.copied = "true";
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
delete copy.dataset.copied;
|
|
48
|
+
}, 1500);
|
|
49
|
+
} catch {
|
|
50
|
+
// Clipboard unavailable (insecure context); silently ignore.
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!customElements.get("blume-panel-tabs")) {
|
|
58
|
+
customElements.define("blume-panel-tabs", BlumePanelTabs);
|
|
59
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { exampleValue, toJson } from "./helpers.ts";
|
|
2
|
+
import type { SchemaLike } from "./helpers.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Request example + code-sample generation for an operation. Kept separate from
|
|
6
|
+
* `helpers.ts` so the schema renderers don't pull in the sample builders. Output
|
|
7
|
+
* is intentionally simple, copy-pasteable starter code — not an exhaustive SDK.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface ParamLike {
|
|
11
|
+
name?: string;
|
|
12
|
+
in?: string;
|
|
13
|
+
required?: boolean;
|
|
14
|
+
schema?: SchemaLike;
|
|
15
|
+
example?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MediaTypeLike {
|
|
19
|
+
schema?: SchemaLike;
|
|
20
|
+
example?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OperationLike {
|
|
24
|
+
parameters?: ParamLike[];
|
|
25
|
+
requestBody?: { content?: Record<string, MediaTypeLike> };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RequestSample {
|
|
29
|
+
method: string;
|
|
30
|
+
url: string;
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
/** JSON-stringified request body, when the operation takes one. */
|
|
33
|
+
body?: string;
|
|
34
|
+
bodyValue?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TRAILING_SLASH = /\/+$/u;
|
|
38
|
+
|
|
39
|
+
const jsonContentType = (
|
|
40
|
+
content: Record<string, MediaTypeLike> | undefined
|
|
41
|
+
): [string, MediaTypeLike] | undefined => {
|
|
42
|
+
const entries = Object.entries(content ?? {});
|
|
43
|
+
return entries.find(([type]) => type.includes("json")) ?? entries[0];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Assemble a representative request from an operation and the spec servers. */
|
|
47
|
+
export const buildRequestSample = (
|
|
48
|
+
operation: OperationLike,
|
|
49
|
+
method: string,
|
|
50
|
+
path: string,
|
|
51
|
+
servers: { url?: string }[],
|
|
52
|
+
schemas: Record<string, SchemaLike>
|
|
53
|
+
): RequestSample => {
|
|
54
|
+
const base = (servers[0]?.url ?? "").replace(TRAILING_SLASH, "");
|
|
55
|
+
const params = operation.parameters ?? [];
|
|
56
|
+
|
|
57
|
+
let resolvedPath = path;
|
|
58
|
+
for (const param of params) {
|
|
59
|
+
if (param.in === "path" && param.name) {
|
|
60
|
+
const value = param.example ?? exampleValue(param.schema, schemas);
|
|
61
|
+
resolvedPath = resolvedPath.replace(
|
|
62
|
+
`{${param.name}}`,
|
|
63
|
+
encodeURIComponent(String(value ?? param.name))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const query = params
|
|
69
|
+
.filter((param) => param.in === "query" && param.required && param.name)
|
|
70
|
+
.map((param) => {
|
|
71
|
+
const value = param.example ?? exampleValue(param.schema, schemas);
|
|
72
|
+
return `${encodeURIComponent(param.name ?? "")}=${encodeURIComponent(
|
|
73
|
+
String(value ?? "")
|
|
74
|
+
)}`;
|
|
75
|
+
});
|
|
76
|
+
const search = query.length > 0 ? `?${query.join("&")}` : "";
|
|
77
|
+
|
|
78
|
+
const headers: Record<string, string> = {};
|
|
79
|
+
for (const param of params) {
|
|
80
|
+
if (param.in === "header" && param.required && param.name) {
|
|
81
|
+
headers[param.name] = String(
|
|
82
|
+
param.example ?? exampleValue(param.schema, schemas) ?? ""
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const media = jsonContentType(operation.requestBody?.content);
|
|
88
|
+
let body: string | undefined;
|
|
89
|
+
let bodyValue: unknown;
|
|
90
|
+
if (media) {
|
|
91
|
+
const [type, mediaType] = media;
|
|
92
|
+
headers["Content-Type"] = type;
|
|
93
|
+
bodyValue = mediaType.example ?? exampleValue(mediaType.schema, schemas);
|
|
94
|
+
body = toJson(bodyValue);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
body,
|
|
99
|
+
bodyValue,
|
|
100
|
+
headers,
|
|
101
|
+
method: method.toUpperCase(),
|
|
102
|
+
url: `${base}${resolvedPath}${search}`,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const headerLines = (
|
|
107
|
+
headers: Record<string, string>,
|
|
108
|
+
format: (key: string, value: string) => string
|
|
109
|
+
): string[] =>
|
|
110
|
+
Object.entries(headers).map(([key, value]) => format(key, value));
|
|
111
|
+
|
|
112
|
+
const curlSnippet = (sample: RequestSample): string => {
|
|
113
|
+
const lines = [
|
|
114
|
+
`curl -X ${sample.method} "${sample.url}"`,
|
|
115
|
+
...headerLines(sample.headers, (key, value) => ` -H "${key}: ${value}"`),
|
|
116
|
+
];
|
|
117
|
+
if (sample.body) {
|
|
118
|
+
lines.push(` -d '${sample.body}'`);
|
|
119
|
+
}
|
|
120
|
+
return lines.join(" \\\n");
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const fetchSnippet = (sample: RequestSample): string => {
|
|
124
|
+
const options = [` method: "${sample.method}"`];
|
|
125
|
+
if (Object.keys(sample.headers).length > 0) {
|
|
126
|
+
const headers = headerLines(
|
|
127
|
+
sample.headers,
|
|
128
|
+
(key, value) => ` "${key}": "${value}"`
|
|
129
|
+
).join(",\n");
|
|
130
|
+
options.push(` headers: {\n${headers}\n }`);
|
|
131
|
+
}
|
|
132
|
+
if (sample.body) {
|
|
133
|
+
options.push(` body: JSON.stringify(${sample.body})`);
|
|
134
|
+
}
|
|
135
|
+
return `const response = await fetch("${sample.url}", {\n${options.join(
|
|
136
|
+
",\n"
|
|
137
|
+
)}\n});`;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Turn a JSON literal into an equivalent Python literal (`true` -> `True`). */
|
|
141
|
+
const toPython = (json: string): string =>
|
|
142
|
+
json
|
|
143
|
+
.replaceAll(/\btrue\b/gu, "True")
|
|
144
|
+
.replaceAll(/\bfalse\b/gu, "False")
|
|
145
|
+
.replaceAll(/\bnull\b/gu, "None");
|
|
146
|
+
|
|
147
|
+
const pythonSnippet = (sample: RequestSample): string => {
|
|
148
|
+
const args = [` "${sample.url}"`];
|
|
149
|
+
if (Object.keys(sample.headers).length > 0) {
|
|
150
|
+
const headers = headerLines(
|
|
151
|
+
sample.headers,
|
|
152
|
+
(key, value) => ` "${key}": "${value}"`
|
|
153
|
+
).join(",\n");
|
|
154
|
+
args.push(` headers={\n${headers}\n }`);
|
|
155
|
+
}
|
|
156
|
+
if (sample.body) {
|
|
157
|
+
args.push(` json=${toPython(sample.body)}`);
|
|
158
|
+
}
|
|
159
|
+
return `import requests\n\nresponse = requests.${sample.method.toLowerCase()}(\n${args.join(
|
|
160
|
+
",\n"
|
|
161
|
+
)},\n)`;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** A code-sample language: config id -> label, Shiki lang, and builder. */
|
|
165
|
+
export interface SampleLanguage {
|
|
166
|
+
id: string;
|
|
167
|
+
label: string;
|
|
168
|
+
lang: string;
|
|
169
|
+
build: (sample: RequestSample) => string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const LANGUAGES: SampleLanguage[] = [
|
|
173
|
+
{ build: curlSnippet, id: "curl", label: "cURL", lang: "bash" },
|
|
174
|
+
{ build: fetchSnippet, id: "js", label: "JavaScript", lang: "js" },
|
|
175
|
+
{ build: pythonSnippet, id: "python", label: "Python", lang: "python" },
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const ALIASES: Record<string, string> = {
|
|
179
|
+
bash: "curl",
|
|
180
|
+
javascript: "js",
|
|
181
|
+
node: "js",
|
|
182
|
+
py: "python",
|
|
183
|
+
shell: "curl",
|
|
184
|
+
typescript: "js",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** The sample languages to render, resolved from config ids (unknown ids dropped). */
|
|
188
|
+
export const sampleLanguages = (ids: string[]): SampleLanguage[] => {
|
|
189
|
+
const wanted = ids.length > 0 ? ids : ["curl", "js", "python"];
|
|
190
|
+
const out: SampleLanguage[] = [];
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
for (const raw of wanted) {
|
|
193
|
+
const id = ALIASES[raw.toLowerCase()] ?? raw.toLowerCase();
|
|
194
|
+
const language = LANGUAGES.find((entry) => entry.id === id);
|
|
195
|
+
if (language && !seen.has(id)) {
|
|
196
|
+
seen.add(id);
|
|
197
|
+
out.push(language);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
};
|
package/src/components/props.ts
CHANGED
|
@@ -65,4 +65,7 @@ export type TileProps = ComponentProps<
|
|
|
65
65
|
export type TooltipProps = ComponentProps<
|
|
66
66
|
typeof import("./content/Tooltip.astro").default
|
|
67
67
|
>;
|
|
68
|
+
export type YouTubeProps = ComponentProps<
|
|
69
|
+
typeof import("./content/YouTube.astro").default
|
|
70
|
+
>;
|
|
68
71
|
export type IconProps = ComponentProps<typeof import("./Icon.astro").default>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { join } from "pathe";
|
|
2
|
+
|
|
3
|
+
/** A static directory served at a URL prefix, in addition to `public/`. */
|
|
4
|
+
export interface AssetMount {
|
|
5
|
+
/** Absolute filesystem path to the source directory (or file). */
|
|
6
|
+
dir: string;
|
|
7
|
+
/** URL path prefix the source is served at, e.g. `/images`. */
|
|
8
|
+
url: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve `content.assets` entries (top-level dirs served at the site root,
|
|
13
|
+
* alongside `public/`) to `{ dir, url }` mounts. Shared by the generated Astro
|
|
14
|
+
* runtime (dev middleware + build copy) and by link validation, so all three
|
|
15
|
+
* agree on where a `/images/foo.png` reference resolves on disk.
|
|
16
|
+
*
|
|
17
|
+
* Each entry is normalized to a leading-slash URL and joined to the project
|
|
18
|
+
* root; leading `./` or `/` and any `..` segments are stripped so a mount can't
|
|
19
|
+
* escape the root or collide with the site's own routing prefix.
|
|
20
|
+
*/
|
|
21
|
+
export const resolveAssetMounts = (
|
|
22
|
+
root: string,
|
|
23
|
+
assets: string[]
|
|
24
|
+
): AssetMount[] =>
|
|
25
|
+
assets.map((entry) => {
|
|
26
|
+
const rel = entry
|
|
27
|
+
.replace(/^[./]+/u, "")
|
|
28
|
+
.replaceAll(/\.\.\/?/gu, "")
|
|
29
|
+
.replace(/\/+$/u, "");
|
|
30
|
+
return { dir: join(root, rel), url: `/${rel}` };
|
|
31
|
+
});
|
package/src/core/bridge.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
|
|
4
4
|
import { join } from "pathe";
|
|
5
5
|
|
|
6
|
+
import { assetSegments } from "../migrate/mintlify/assets.ts";
|
|
6
7
|
import { loadMintlifyConfig } from "../migrate/mintlify/config.ts";
|
|
7
8
|
import { mintlifyI18n } from "../migrate/mintlify/i18n.ts";
|
|
8
9
|
import type { BlumeConfig } from "./schema.ts";
|
|
@@ -64,11 +65,20 @@ export const detectMintlifyBridge = async (
|
|
|
64
65
|
const root_ = config.content?.root ?? ".";
|
|
65
66
|
const exclude = config.content?.exclude ?? [];
|
|
66
67
|
|
|
68
|
+
// Mintlify serves assets from the project root; the bridge never moves files,
|
|
69
|
+
// so referenced root-level asset folders (e.g. `images/`) are served in place
|
|
70
|
+
// via `content.assets` instead. This is the read-only twin of the migrator's
|
|
71
|
+
// relocation — same referenced segments, just no `public/` move.
|
|
72
|
+
const assets = assetSegments(config).filter(
|
|
73
|
+
(segment) => segment !== "public" && existsSync(join(root, segment))
|
|
74
|
+
);
|
|
75
|
+
|
|
67
76
|
return {
|
|
68
77
|
configFile,
|
|
69
78
|
raw: {
|
|
70
79
|
...config,
|
|
71
80
|
content: {
|
|
81
|
+
assets,
|
|
72
82
|
// Mirror the excludes onto `content.exclude` too: the generated Astro
|
|
73
83
|
// `docs` collection globs `content.root` (here the project root) and
|
|
74
84
|
// must skip node_modules/snippets just like the source does.
|
package/src/core/builtin-tags.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
export const BUILTIN_MDX_TAGS = new Set<string>([
|
|
8
8
|
"Accordion",
|
|
9
9
|
"AccordionItem",
|
|
10
|
+
"ApiOverview",
|
|
10
11
|
"AutoTypeTable",
|
|
11
12
|
"Badge",
|
|
12
13
|
"Callout",
|
|
@@ -25,8 +26,12 @@ export const BUILTIN_MDX_TAGS = new Set<string>([
|
|
|
25
26
|
"GithubInfo",
|
|
26
27
|
"Icon",
|
|
27
28
|
"Math",
|
|
29
|
+
"Operation",
|
|
28
30
|
"Panel",
|
|
31
|
+
"ParamField",
|
|
29
32
|
"Prompt",
|
|
33
|
+
"RequestField",
|
|
34
|
+
"ResponseField",
|
|
30
35
|
"Step",
|
|
31
36
|
"Steps",
|
|
32
37
|
"Tab",
|
|
@@ -36,4 +41,5 @@ export const BUILTIN_MDX_TAGS = new Set<string>([
|
|
|
36
41
|
"Tree",
|
|
37
42
|
"TypeTable",
|
|
38
43
|
"Visibility",
|
|
44
|
+
"YouTube",
|
|
39
45
|
]);
|
package/src/core/data.ts
CHANGED
|
@@ -93,6 +93,8 @@ export interface BlumeDataConfig {
|
|
|
93
93
|
favicon: BlumeFavicon;
|
|
94
94
|
feedback: boolean;
|
|
95
95
|
i18n: BlumeDataI18n | null;
|
|
96
|
+
/** Default icon library for bare `icon` names. */
|
|
97
|
+
icons: ResolvedConfig["icons"];
|
|
96
98
|
/** `markdown.imageZoom`: click-to-zoom content images. */
|
|
97
99
|
imageZoom: boolean;
|
|
98
100
|
logo: BlumeLogo | null;
|
package/src/core/diagnostics.ts
CHANGED
|
@@ -84,7 +84,12 @@ const locatePath = (
|
|
|
84
84
|
if (typeof segment !== "string") {
|
|
85
85
|
continue;
|
|
86
86
|
}
|
|
87
|
-
|
|
87
|
+
// The negative lookbehind keeps a segment like `title` from matching the
|
|
88
|
+
// tail of an unrelated key such as `subtitle:`.
|
|
89
|
+
const matcher = new RegExp(
|
|
90
|
+
`(?<![\\w$])${escapeRegExp(segment)}\\s*[:=]`,
|
|
91
|
+
"gu"
|
|
92
|
+
);
|
|
88
93
|
matcher.lastIndex = cursor;
|
|
89
94
|
const match = matcher.exec(source);
|
|
90
95
|
if (!match) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { join } from "pathe";
|
|
5
|
+
|
|
6
|
+
/** A `.gitignore` line, normalized for comparison (trailing slashes dropped). */
|
|
7
|
+
const gitignoreKey = (line: string): string => line.trim().replace(/\/+$/u, "");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ensure `.gitignore` ignores each of `entries`, appending any that are missing
|
|
11
|
+
* (creating the file when absent). Trailing-slash differences (`dist` vs
|
|
12
|
+
* `dist/`) count as already present. Returns the entries actually added.
|
|
13
|
+
*/
|
|
14
|
+
export const ensureGitignore = async (
|
|
15
|
+
root: string,
|
|
16
|
+
entries: string[]
|
|
17
|
+
): Promise<string[]> => {
|
|
18
|
+
const path = join(root, ".gitignore");
|
|
19
|
+
const existing = existsSync(path) ? await readFile(path, "utf-8") : "";
|
|
20
|
+
const present = new Set(
|
|
21
|
+
existing.split("\n").map(gitignoreKey).filter(Boolean)
|
|
22
|
+
);
|
|
23
|
+
const added = entries.filter((entry) => !present.has(gitignoreKey(entry)));
|
|
24
|
+
if (added.length === 0) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const gap = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
28
|
+
await writeFile(path, `${existing}${gap}${added.join("\n")}\n`, "utf-8");
|
|
29
|
+
return added;
|
|
30
|
+
};
|
package/src/core/links.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
|
|
3
|
-
import { join } from "pathe";
|
|
3
|
+
import { basename, join } from "pathe";
|
|
4
4
|
|
|
5
|
+
import type { AssetMount } from "./assets.ts";
|
|
5
6
|
import type {
|
|
6
7
|
ContentGraph,
|
|
7
8
|
Diagnostic,
|
|
@@ -37,20 +38,52 @@ interface ExternalRef extends LinkSite {
|
|
|
37
38
|
/** Lookups derived once from the content graph. */
|
|
38
39
|
interface LinkContext {
|
|
39
40
|
anchors: Map<string, Set<string>>;
|
|
41
|
+
/** `content.assets` mounts served alongside `public/` (checked in place). */
|
|
42
|
+
assetMounts: AssetMount[];
|
|
40
43
|
publicDir: string | null;
|
|
41
44
|
/** Normalized `redirect.from` paths — valid targets that resolve at runtime. */
|
|
42
45
|
redirects: Set<string>;
|
|
43
46
|
routes: Set<string>;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/** Whether a resolved asset path exists under `public/` or an asset mount. */
|
|
50
|
+
const assetIsPresent = (resolved: string, ctx: LinkContext): boolean => {
|
|
51
|
+
if (ctx.publicDir && existsSync(join(ctx.publicDir, resolved))) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return ctx.assetMounts.some(
|
|
55
|
+
(mount) =>
|
|
56
|
+
(resolved === mount.url || resolved.startsWith(`${mount.url}/`)) &&
|
|
57
|
+
existsSync(join(mount.dir, resolved.slice(mount.url.length)))
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
46
61
|
/** Outcome of classifying one link target. */
|
|
47
62
|
type LinkResult = Diagnostic | "asset-unchecked" | null;
|
|
48
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Whether a page is a directory index (`…/index.md(x)`). Its route already *is*
|
|
66
|
+
* its directory, so a relative link must resolve against the route itself, not
|
|
67
|
+
* its parent — otherwise `./sibling` from `guides/index.mdx` (route `/guides`)
|
|
68
|
+
* would resolve to `/sibling` and be falsely flagged as broken.
|
|
69
|
+
*/
|
|
70
|
+
const isIndexPage = (page: PageRecord): boolean => {
|
|
71
|
+
const ref = page.source?.ref ?? page.sourcePath ?? "";
|
|
72
|
+
return /^index\.(?:md|mdx)$/iu.test(basename(ref));
|
|
73
|
+
};
|
|
74
|
+
|
|
49
75
|
/** Resolve a relative link target against the directory of a page route. */
|
|
50
|
-
const resolveRelative = (
|
|
76
|
+
const resolveRelative = (
|
|
77
|
+
pageRoute: string,
|
|
78
|
+
target: string,
|
|
79
|
+
isIndex: boolean
|
|
80
|
+
): string => {
|
|
51
81
|
const segments = pageRoute.split("/").filter(Boolean);
|
|
52
|
-
// Drop
|
|
53
|
-
|
|
82
|
+
// Drop a leaf page's own segment so links resolve against its parent
|
|
83
|
+
// directory. An index page's route already is its directory, so keep it.
|
|
84
|
+
if (!isIndex) {
|
|
85
|
+
segments.pop();
|
|
86
|
+
}
|
|
54
87
|
for (const part of target.split("/")) {
|
|
55
88
|
if (part === "" || part === ".") {
|
|
56
89
|
continue;
|
|
@@ -114,13 +147,28 @@ const checkPathLink = (
|
|
|
114
147
|
site: LinkSite,
|
|
115
148
|
ctx: LinkContext
|
|
116
149
|
): LinkResult => {
|
|
150
|
+
// A real route always wins over the asset-extension heuristic, so a path
|
|
151
|
+
// whose last segment merely contains a dot (e.g. a page at `/releases/v1.0`)
|
|
152
|
+
// isn't misread as a missing asset.
|
|
153
|
+
const route = toRoute(resolved);
|
|
154
|
+
if (ctx.routes.has(route)) {
|
|
155
|
+
return fragment ? checkAnchor(route, fragment, site, ctx) : null;
|
|
156
|
+
}
|
|
157
|
+
// A configured `redirect.from` resolves at runtime, so it's a valid target.
|
|
158
|
+
// Its destination (and any anchor there) is validated on its own page, so we
|
|
159
|
+
// don't follow the redirect to check the fragment here.
|
|
160
|
+
if (ctx.redirects.has(route)) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
117
164
|
if (FILE_EXT.test(resolved) && !DOC_EXT.test(resolved)) {
|
|
118
|
-
if (ctx
|
|
119
|
-
return "asset-unchecked";
|
|
120
|
-
}
|
|
121
|
-
if (existsSync(join(ctx.publicDir, resolved))) {
|
|
165
|
+
if (assetIsPresent(resolved, ctx)) {
|
|
122
166
|
return null;
|
|
123
167
|
}
|
|
168
|
+
// Nowhere to look: no `public/` and no asset mounts configured.
|
|
169
|
+
if (ctx.publicDir === null && ctx.assetMounts.length === 0) {
|
|
170
|
+
return "asset-unchecked";
|
|
171
|
+
}
|
|
124
172
|
return {
|
|
125
173
|
...site,
|
|
126
174
|
code: "BLUME_BROKEN_ASSET",
|
|
@@ -130,16 +178,6 @@ const checkPathLink = (
|
|
|
130
178
|
};
|
|
131
179
|
}
|
|
132
180
|
|
|
133
|
-
const route = toRoute(resolved);
|
|
134
|
-
if (ctx.routes.has(route)) {
|
|
135
|
-
return fragment ? checkAnchor(route, fragment, site, ctx) : null;
|
|
136
|
-
}
|
|
137
|
-
// A configured `redirect.from` resolves at runtime, so it's a valid target.
|
|
138
|
-
// Its destination (and any anchor there) is validated on its own page, so we
|
|
139
|
-
// don't follow the redirect to check the fragment here.
|
|
140
|
-
if (ctx.redirects.has(route)) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
181
|
return {
|
|
144
182
|
...site,
|
|
145
183
|
code: "BLUME_BROKEN_LINK",
|
|
@@ -295,7 +333,7 @@ const classifyLink = (
|
|
|
295
333
|
|
|
296
334
|
const resolved = rawPath.startsWith("/")
|
|
297
335
|
? rawPath
|
|
298
|
-
: resolveRelative(page.route, rawPath);
|
|
336
|
+
: resolveRelative(page.route, rawPath, isIndexPage(page));
|
|
299
337
|
return checkPathLink(resolved, fragment, target, site, ctx);
|
|
300
338
|
};
|
|
301
339
|
|
|
@@ -309,12 +347,15 @@ export const validateLinks = async (
|
|
|
309
347
|
options: {
|
|
310
348
|
publicDir: string | null;
|
|
311
349
|
checkExternal?: boolean;
|
|
350
|
+
/** `content.assets` mounts served alongside `public/`. */
|
|
351
|
+
assetMounts?: AssetMount[];
|
|
312
352
|
/** Configured redirects; their `from` paths count as valid link targets. */
|
|
313
353
|
redirects?: { from: string }[];
|
|
314
354
|
}
|
|
315
355
|
): Promise<Diagnostic[]> => {
|
|
316
356
|
const ctx: LinkContext = {
|
|
317
357
|
anchors: buildAnchorIndex(graph.pages),
|
|
358
|
+
assetMounts: options.assetMounts ?? [],
|
|
318
359
|
publicDir: options.publicDir,
|
|
319
360
|
redirects: new Set(
|
|
320
361
|
(options.redirects ?? []).map((redirect) => toRoute(redirect.from))
|
|
@@ -88,6 +88,8 @@ export const scanProject = async (
|
|
|
88
88
|
refresh?: boolean;
|
|
89
89
|
/** CLI overrides applied over the loaded config (e.g. `--output`). */
|
|
90
90
|
overrides?: ConfigOverrides;
|
|
91
|
+
/** Relocate the generated runtime (e.g. `.blume-verify` for isolation). */
|
|
92
|
+
runtimeDir?: string;
|
|
91
93
|
} = {}
|
|
92
94
|
): Promise<BlumeProject> => {
|
|
93
95
|
const mode = options.mode ?? "dev";
|
|
@@ -97,7 +99,9 @@ export const scanProject = async (
|
|
|
97
99
|
});
|
|
98
100
|
const { bridge } = configResult;
|
|
99
101
|
const config = applyConfigOverrides(configResult.config, options.overrides);
|
|
100
|
-
const context = resolveProjectContext(root, config
|
|
102
|
+
const context = resolveProjectContext(root, config, {
|
|
103
|
+
runtimeDir: options.runtimeDir,
|
|
104
|
+
});
|
|
101
105
|
|
|
102
106
|
// Each source validates itself (e.g. the filesystem source checks its root
|
|
103
107
|
// exists), replacing the single hard `contentRoot` check.
|
package/src/core/project.ts
CHANGED
|
@@ -28,13 +28,27 @@ const firstExisting = (root: string, names: string[]): string | null => {
|
|
|
28
28
|
export const findConfigFile = (root: string): string | null =>
|
|
29
29
|
firstExisting(root, CONFIG_FILENAMES);
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the generated runtime directory for a project. Defaults to
|
|
33
|
+
* `<root>/.blume`; an override (e.g. `.blume-verify` for an isolated build that
|
|
34
|
+
* runs alongside a live `blume dev`) may be relative to the root or absolute.
|
|
35
|
+
*/
|
|
36
|
+
export const resolveRuntimeDir = (
|
|
37
|
+
root: string,
|
|
38
|
+
runtimeDir = ".blume"
|
|
39
|
+
): string =>
|
|
40
|
+
isAbsolute(runtimeDir) ? runtimeDir : join(resolve(root), runtimeDir);
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* Resolve every path Blume needs from a project root and its resolved config.
|
|
33
|
-
* Paths are absolute and normalized.
|
|
44
|
+
* Paths are absolute and normalized. `options.runtimeDir` relocates the whole
|
|
45
|
+
* generated runtime (and its build output) so a verify build/check can run
|
|
46
|
+
* without touching a live dev server's `.blume/` or the real `dist/`.
|
|
34
47
|
*/
|
|
35
48
|
export const resolveProjectContext = (
|
|
36
49
|
root: string,
|
|
37
|
-
config: ResolvedConfig
|
|
50
|
+
config: ResolvedConfig,
|
|
51
|
+
options?: { runtimeDir?: string }
|
|
38
52
|
): ProjectContext => {
|
|
39
53
|
const absoluteRoot = resolve(root);
|
|
40
54
|
const contentRoot = isAbsolute(config.content.root)
|
|
@@ -44,11 +58,19 @@ export const resolveProjectContext = (
|
|
|
44
58
|
const pagesPath = join(absoluteRoot, config.content.pages);
|
|
45
59
|
const pagesRoot = existsSync(pagesPath) ? pagesPath : null;
|
|
46
60
|
|
|
61
|
+
const outDir = resolveRuntimeDir(absoluteRoot, options?.runtimeDir);
|
|
62
|
+
// A relocated runtime keeps its build output self-contained under itself, so a
|
|
63
|
+
// verify build never empties the user's real `<root>/dist`.
|
|
64
|
+
const distDir = options?.runtimeDir
|
|
65
|
+
? join(outDir, "dist")
|
|
66
|
+
: join(absoluteRoot, "dist");
|
|
67
|
+
|
|
47
68
|
return {
|
|
48
69
|
componentsFile: firstExisting(absoluteRoot, COMPONENTS_FILENAMES),
|
|
49
70
|
configFile: findConfigFile(absoluteRoot),
|
|
50
71
|
contentRoot,
|
|
51
|
-
|
|
72
|
+
distDir,
|
|
73
|
+
outDir,
|
|
52
74
|
pagesRoot,
|
|
53
75
|
root: absoluteRoot,
|
|
54
76
|
themeFile: firstExisting(absoluteRoot, THEME_FILENAMES),
|