blume 0.6.0 → 0.6.2
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 +6070 -5718
- package/dist/cli/index.js.map +41 -40
- package/dist/types/core/config-input.d.ts +749 -0
- package/dist/types/core/config.d.ts +126 -3
- package/dist/types/core/schema.d.ts +10 -27
- package/dist/types/core/sources/types.d.ts +6 -0
- package/dist/types/index.d.ts +2 -1
- package/docs/advanced/changelog.mdx +10 -2
- package/docs/configuration/index.mdx +0 -2
- package/docs/content/syntax.mdx +4 -8
- package/package.json +1 -1
- package/src/astro/generate.ts +59 -24
- package/src/astro/markdown-negotiation.ts +12 -3
- package/src/astro/templates.ts +94 -12
- package/src/cli/commands/build.ts +26 -1
- package/src/cli/commands/dev.ts +30 -14
- package/src/cli/commands/doctor.ts +35 -7
- package/src/cli/commands/sync.ts +14 -2
- package/src/cli/dev-lock.ts +40 -10
- package/src/cli/env.ts +5 -1
- package/src/components/content/Update.astro +12 -2
- package/src/components/content/changelog-element.ts +62 -0
- package/src/components/islands/ask-ai.tsx +3 -1
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/layout/Header.astro +10 -2
- package/src/components/layout/NavSelector.astro +5 -3
- package/src/components/layout/PageLayout.astro +2 -1
- package/src/components/layout/ReferenceLayout.astro +1 -0
- package/src/components/layout/RootLayout.astro +47 -10
- package/src/components/layout/Search.astro +8 -3
- package/src/components/layout/nav-utils.ts +7 -3
- package/src/core/config-input.ts +923 -0
- package/src/core/config.ts +126 -3
- package/src/core/i18n.ts +6 -5
- package/src/core/links.ts +16 -1
- package/src/core/meta.ts +112 -52
- package/src/core/navigation.ts +15 -5
- package/src/core/project-graph.ts +68 -2
- package/src/core/schema.ts +8 -14
- package/src/core/sources/assets.ts +21 -5
- package/src/core/sources/cache.ts +19 -1
- package/src/core/sources/github-releases.ts +9 -3
- package/src/core/sources/mdx-remote.ts +14 -4
- package/src/core/sources/normalize.ts +13 -1
- package/src/core/sources/notion.ts +43 -7
- package/src/core/sources/resolve.ts +44 -1
- package/src/core/sources/sanity.ts +9 -3
- package/src/core/sources/types.ts +6 -0
- package/src/deploy/adapter-output.ts +82 -0
- package/src/deploy/rss.ts +3 -1
- package/src/index.ts +1 -1
- package/src/markdown/code-title.ts +11 -4
- package/src/markdown/index.ts +28 -30
- package/src/markdown/math.ts +3 -2
- package/src/markdown/package-commands.ts +13 -0
- package/src/og/card.ts +3 -1
- package/src/openapi/model.ts +2 -1
- package/src/openapi/parse.ts +9 -1
- package/src/openapi/references.ts +11 -1
- package/src/openapi/render-mdx.ts +30 -3
- package/src/openapi/source.ts +3 -1
- package/src/registry/eject.ts +21 -14
- package/src/search/documents.ts +4 -1
- package/src/theme/entry.ts +10 -3
- package/src/theme/icons.ts +7 -11
package/src/core/config.ts
CHANGED
|
@@ -1,16 +1,139 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
+
import type { BlumeConfig } from "./config-input.ts";
|
|
3
4
|
import { applyDeploymentEnv } from "./deployment-env.ts";
|
|
4
5
|
import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
|
|
5
6
|
import { createModuleLoader } from "./load-module.ts";
|
|
6
7
|
import { findConfigFile } from "./project.ts";
|
|
7
8
|
import { blumeConfigSchema } from "./schema.ts";
|
|
8
|
-
import type {
|
|
9
|
+
import type { ResolvedConfig } from "./schema.ts";
|
|
9
10
|
import type { Diagnostic } from "./types.ts";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Define a Blume site's configuration with full type-checking and editor
|
|
14
|
+
* autocomplete. Place the call in `blume.config.ts` at your project root and
|
|
15
|
+
* `export default` the result:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { defineConfig } from "blume";
|
|
19
|
+
*
|
|
20
|
+
* export default defineConfig({
|
|
21
|
+
* title: "Acme Docs",
|
|
22
|
+
* description: "Everything you need to build with Acme.",
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Every field is optional — an empty `defineConfig({})` produces a working
|
|
27
|
+
* site from the Markdown/MDX in your `docs/` directory. Configure only what you
|
|
28
|
+
* want to change; sensible defaults fill in the rest.
|
|
29
|
+
*
|
|
30
|
+
* This is an identity helper: it returns its input unchanged and exists purely
|
|
31
|
+
* for type inference (and as a stable home for future plugin hooks). The object
|
|
32
|
+
* is validated against the Blume schema when the CLI loads it.
|
|
33
|
+
*
|
|
34
|
+
* ## Top-level fields
|
|
35
|
+
*
|
|
36
|
+
* **Site identity**
|
|
37
|
+
* - `title` — site title, shown in the header, `<title>`, and OG images.
|
|
38
|
+
* Defaults to `"Documentation"`.
|
|
39
|
+
* - `description` — default meta description, used where a page sets none.
|
|
40
|
+
* - `logo` — brand mark. A string is an image path/URL; the object form splits
|
|
41
|
+
* an `image` mark from wordmark `text` and can override the brand `href`.
|
|
42
|
+
* - `banner` — site-wide announcement bar; a string, or `{ content, link,
|
|
43
|
+
* dismissible }`.
|
|
44
|
+
*
|
|
45
|
+
* **Content & navigation**
|
|
46
|
+
* - `content` — where content lives (`root`, defaults to `docs`) and pluggable
|
|
47
|
+
* `sources` (filesystem, remote MDX, GitHub Releases, Sanity, Notion, or a
|
|
48
|
+
* custom `ContentSource`). Omit `sources` and the top-level `root` becomes one
|
|
49
|
+
* implicit filesystem source.
|
|
50
|
+
* - `navigation` — sidebar, header `tabs`, `selectors` (version/language/product
|
|
51
|
+
* switchers), pinned `featured` links, and the `repo` link toggle. Omit
|
|
52
|
+
* `sidebar` to generate it from the content tree.
|
|
53
|
+
* - `redirects` — `{ from, to, status }` rules (301 by default).
|
|
54
|
+
* - `github` — `{ owner, repo, branch, dir }`, powering "Edit this page" links
|
|
55
|
+
* and the header repo link.
|
|
56
|
+
*
|
|
57
|
+
* **Appearance**
|
|
58
|
+
* - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
|
|
59
|
+
* `mode` (`system`/`light`/`dark`), `background`, and `strict` token mode.
|
|
60
|
+
* - `markdown` — `code` (language icons, inline highlighting, line wrap),
|
|
61
|
+
* `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
|
|
62
|
+
* - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
|
|
63
|
+
* - `lastModified` — "Last updated" stamps from `git` history or frontmatter.
|
|
64
|
+
* - `feedback` — the per-page "Was this helpful?" widget (on by default).
|
|
65
|
+
* - `export` — reader-facing PDF/EPUB export actions (off by default).
|
|
66
|
+
*
|
|
67
|
+
* **Reference docs**
|
|
68
|
+
* - `openapi` — native OpenAPI reference: one real page per operation, woven
|
|
69
|
+
* into the sidebar and search. Point `sources`/`spec` at your spec.
|
|
70
|
+
* - `asyncapi` — AsyncAPI reference via the embedded Scalar renderer.
|
|
71
|
+
*
|
|
72
|
+
* **Search & AI**
|
|
73
|
+
* - `search` — search backend `provider` (`orama` by default; `pagefind`,
|
|
74
|
+
* `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
|
|
75
|
+
* credential block.
|
|
76
|
+
* - `ai` — `ask` (the Ask AI chat endpoint and its provider/model) and `llmsTxt`
|
|
77
|
+
* (emit `llms.txt`).
|
|
78
|
+
* - `mcp` — expose the docs as an MCP server for connecting agents.
|
|
79
|
+
*
|
|
80
|
+
* **SEO, feeds & analytics**
|
|
81
|
+
* - `seo` — `og` images, `sitemap`, `robots`, `rss` feeds, `structuredData`
|
|
82
|
+
* JSON-LD, `agentReadability`, and robots `contentSignals`.
|
|
83
|
+
* - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
|
|
84
|
+
* GA, …).
|
|
85
|
+
*
|
|
86
|
+
* **Deployment & i18n**
|
|
87
|
+
* - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
|
|
88
|
+
* `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
|
|
89
|
+
* (`static`/`server`), and `base` path. Auto-detected on Vercel/Netlify/
|
|
90
|
+
* Cloudflare from the platform env.
|
|
91
|
+
* - `i18n` — opt-in multi-locale: `locales`, `defaultLocale`, `parser`
|
|
92
|
+
* (`dir` vs filename `dot` suffix), and per-locale UI overrides.
|
|
93
|
+
*
|
|
94
|
+
* - `examples` — where `<Component path>` previews resolve their source from
|
|
95
|
+
* (defaults to `examples/`; supports a glob for colocated registries).
|
|
96
|
+
*
|
|
97
|
+
* @example Zero-config — just render the Markdown under `docs/`.
|
|
98
|
+
* ```ts
|
|
99
|
+
* export default defineConfig({});
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @example A production docs site with theming, search, and deployment.
|
|
103
|
+
* ```ts
|
|
104
|
+
* export default defineConfig({
|
|
105
|
+
* title: "Acme Docs",
|
|
106
|
+
* description: "Build faster with Acme.",
|
|
107
|
+
* logo: { image: "/logo.svg", text: "Acme" },
|
|
108
|
+
* github: { owner: "acme", repo: "acme" },
|
|
109
|
+
* theme: { accent: "violet", fonts: { body: "inter" }, radius: "lg" },
|
|
110
|
+
* navigation: {
|
|
111
|
+
* tabs: [
|
|
112
|
+
* { label: "Guides", path: "/guides" },
|
|
113
|
+
* { label: "API", path: "/api" },
|
|
114
|
+
* ],
|
|
115
|
+
* },
|
|
116
|
+
* search: { provider: "orama" },
|
|
117
|
+
* deployment: { site: "https://docs.acme.com", adapter: "vercel" },
|
|
118
|
+
* });
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @example An OpenAPI reference with the Ask AI assistant enabled.
|
|
122
|
+
* ```ts
|
|
123
|
+
* export default defineConfig({
|
|
124
|
+
* title: "Acme API",
|
|
125
|
+
* openapi: {
|
|
126
|
+
* enabled: true,
|
|
127
|
+
* route: "/reference",
|
|
128
|
+
* sources: [{ label: "Core", spec: "./openapi.json" }],
|
|
129
|
+
* },
|
|
130
|
+
* ai: { ask: { enabled: true }, llmsTxt: true },
|
|
131
|
+
* });
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* @param config - The site configuration. All fields are optional.
|
|
135
|
+
* @returns The same config object, typed for inference.
|
|
136
|
+
* @see https://useblume.dev/docs for the full configuration reference.
|
|
14
137
|
*/
|
|
15
138
|
export const defineConfig = (config: BlumeConfig): BlumeConfig => config;
|
|
16
139
|
|
package/src/core/i18n.ts
CHANGED
|
@@ -111,13 +111,14 @@ export const localePlacement = (
|
|
|
111
111
|
|
|
112
112
|
if (i18n.parser === "dot") {
|
|
113
113
|
const lastDot = base.lastIndexOf(".");
|
|
114
|
-
// Only a dot inside the filename (not a directory) is a locale suffix.
|
|
114
|
+
// Only a dot inside the filename (not a directory) is a locale suffix. Any
|
|
115
|
+
// configured locale counts — including the default, so the symmetric
|
|
116
|
+
// authoring `intro.en.mdx` + `intro.fr.mdx` shares one translation key
|
|
117
|
+
// instead of routing the default file to a literal `/intro.en`.
|
|
115
118
|
if (lastDot > base.lastIndexOf("/")) {
|
|
116
119
|
const suffix = base.slice(lastDot + 1);
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
);
|
|
120
|
-
if (isNonDefault) {
|
|
120
|
+
const matched = i18n.locales.some((locale) => locale.code === suffix);
|
|
121
|
+
if (matched) {
|
|
121
122
|
return {
|
|
122
123
|
locales: [suffix],
|
|
123
124
|
navPath: `${base.slice(0, lastDot)}${ext}`,
|
package/src/core/links.ts
CHANGED
|
@@ -12,6 +12,15 @@ import type {
|
|
|
12
12
|
const HTTP = /^https?:\/\//iu;
|
|
13
13
|
const PROTOCOL_RELATIVE = /^\/\//u;
|
|
14
14
|
const SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
|
|
15
|
+
|
|
16
|
+
/** Percent-decode a link piece; malformed sequences stay verbatim. */
|
|
17
|
+
const decodePercent = (value: string): string => {
|
|
18
|
+
try {
|
|
19
|
+
return decodeURIComponent(value);
|
|
20
|
+
} catch {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
15
24
|
const DOC_EXT = /\.(?:md|mdx)$/iu;
|
|
16
25
|
const FILE_EXT = /\.[a-z0-9]+$/iu;
|
|
17
26
|
|
|
@@ -309,12 +318,18 @@ const classifyLink = (
|
|
|
309
318
|
}
|
|
310
319
|
|
|
311
320
|
const hashIndex = target.indexOf("#");
|
|
312
|
-
|
|
321
|
+
// Browser-copied links arrive percent-encoded (`/caf%C3%A9`, `#caf%C3%A9`)
|
|
322
|
+
// while routes and anchor slugs are stored decoded — decode before comparing
|
|
323
|
+
// so valid links aren't reported broken.
|
|
324
|
+
const fragment = decodePercent(
|
|
325
|
+
hashIndex === -1 ? "" : target.slice(hashIndex + 1)
|
|
326
|
+
);
|
|
313
327
|
let rawPath = hashIndex === -1 ? target : target.slice(0, hashIndex);
|
|
314
328
|
const queryIndex = rawPath.indexOf("?");
|
|
315
329
|
if (queryIndex !== -1) {
|
|
316
330
|
rawPath = rawPath.slice(0, queryIndex);
|
|
317
331
|
}
|
|
332
|
+
rawPath = decodePercent(rawPath);
|
|
318
333
|
|
|
319
334
|
if (rawPath === "") {
|
|
320
335
|
return fragment ? checkAnchor(page.route, fragment, site, ctx) : null;
|
package/src/core/meta.ts
CHANGED
|
@@ -21,75 +21,135 @@ const META_FILES = [
|
|
|
21
21
|
const resolveMeta = async (mod: unknown): Promise<unknown> =>
|
|
22
22
|
typeof mod === "function" ? await (mod as () => unknown)() : mod;
|
|
23
23
|
|
|
24
|
+
/** A filesystem content source to scan for folder meta: its on-disk root and
|
|
25
|
+
* optional route prefix. The prefix is folded into every key so meta lines up
|
|
26
|
+
* with the sidebar group path, which carries the same prefix. */
|
|
27
|
+
export interface FolderMetaSource {
|
|
28
|
+
root: string;
|
|
29
|
+
prefix?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
|
|
33
|
+
* The folder-meta key for a directory. Mirrors the sidebar group path: the
|
|
34
|
+
* source's route prefix (`docs`) followed by the directory relative to the
|
|
35
|
+
* source root (`provider`) — so `docs/provider/meta.ts` under a `prefix: "docs"`
|
|
36
|
+
* source keys to `docs/provider`, exactly the group path navigation builds.
|
|
37
|
+
*/
|
|
38
|
+
const metaKeyFor = (prefix: string | undefined, dir: string): string => {
|
|
39
|
+
const clean = prefix ? prefix.replaceAll(/^\/+|\/+$/gu, "") : "";
|
|
40
|
+
if (!clean) {
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
return dir ? `${clean}/${dir}` : clean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Discover `meta.{ts,js,mjs}` files across the given filesystem sources. Keys are
|
|
48
|
+
* the source's route prefix joined with the directory relative to that source's
|
|
49
|
+
* root (`""`/the prefix itself for a source's root directory), so meta matches
|
|
50
|
+
* the prefixed sidebar group path. A bare string is shorthand for a single,
|
|
51
|
+
* unprefixed source (the default project layout). `meta.$.*` files are returned
|
|
52
|
+
* in `shared` — folder meta that applies to that directory in every locale (a
|
|
53
|
+
* locale-specific `meta.*` overrides it). Each file default-exports an object or
|
|
54
|
+
* a (sync/async) function returning one.
|
|
55
|
+
*
|
|
56
|
+
* `localeDirs` names the top-level locale directories of a `dir`-parser i18n
|
|
57
|
+
* project. Navigation looks locale meta up as `locale/<group path>` where the
|
|
58
|
+
* group path starts with the source prefix, so a locale directory found at a
|
|
59
|
+
* source root is hoisted in front of the prefix (`docs/fr/guides/meta.ts` keys
|
|
60
|
+
* to `fr/docs/guides`, not `docs/fr/guides`).
|
|
30
61
|
*/
|
|
31
62
|
export const discoverFolderMeta = async (
|
|
32
|
-
|
|
63
|
+
sources: string | FolderMetaSource[],
|
|
64
|
+
options: { localeDirs?: readonly string[] } = {}
|
|
33
65
|
): Promise<{
|
|
34
66
|
meta: Map<string, FolderMeta>;
|
|
35
67
|
shared: Map<string, FolderMeta>;
|
|
36
68
|
diagnostics: Diagnostic[];
|
|
37
69
|
}> => {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Never descend into dependencies or build output — relevant when the
|
|
42
|
-
// content root is the project root (e.g. a `.`-rooted or all-staged project).
|
|
43
|
-
ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
|
|
44
|
-
onlyFiles: true,
|
|
45
|
-
});
|
|
70
|
+
const list: FolderMetaSource[] =
|
|
71
|
+
typeof sources === "string" ? [{ root: sources }] : sources;
|
|
72
|
+
const localeDirs = new Set(options.localeDirs);
|
|
46
73
|
|
|
47
74
|
const load = createModuleLoader();
|
|
48
|
-
const loaded = await Promise.all(
|
|
49
|
-
files.map(
|
|
50
|
-
async (
|
|
51
|
-
file
|
|
52
|
-
): Promise<
|
|
53
|
-
| { ok: true; file: string; value: unknown }
|
|
54
|
-
| { ok: false; file: string; error: Error }
|
|
55
|
-
> => {
|
|
56
|
-
try {
|
|
57
|
-
return { file, ok: true, value: await resolveMeta(await load(file)) };
|
|
58
|
-
} catch (error) {
|
|
59
|
-
return { error: error as Error, file, ok: false };
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
)
|
|
63
|
-
);
|
|
64
|
-
|
|
65
75
|
const meta = new Map<string, FolderMeta>();
|
|
66
76
|
const shared = new Map<string, FolderMeta>();
|
|
67
77
|
const diagnostics: Diagnostic[] = [];
|
|
68
78
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
// Scan every source under its own root so a source rooted outside
|
|
80
|
+
// `content.root` still contributes its folder meta.
|
|
81
|
+
const perSource = await Promise.all(
|
|
82
|
+
list.map(async (source) => {
|
|
83
|
+
const files = await glob(META_FILES, {
|
|
84
|
+
absolute: true,
|
|
85
|
+
cwd: source.root,
|
|
86
|
+
// Never descend into dependencies or build output — relevant when the
|
|
87
|
+
// root is the project root (e.g. a `.`-rooted or all-staged project).
|
|
88
|
+
ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
|
|
89
|
+
onlyFiles: true,
|
|
78
90
|
});
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
const loaded = await Promise.all(
|
|
92
|
+
files.map(
|
|
93
|
+
async (
|
|
94
|
+
file
|
|
95
|
+
): Promise<
|
|
96
|
+
| { ok: true; file: string; value: unknown }
|
|
97
|
+
| { ok: false; file: string; error: Error }
|
|
98
|
+
> => {
|
|
99
|
+
try {
|
|
100
|
+
return {
|
|
101
|
+
file,
|
|
102
|
+
ok: true,
|
|
103
|
+
value: await resolveMeta(await load(file)),
|
|
104
|
+
};
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return { error: error as Error, file, ok: false };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
return { loaded, source };
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
for (const { loaded, source } of perSource) {
|
|
116
|
+
for (const entry of loaded) {
|
|
117
|
+
const dir = relative(source.root, dirname(entry.file));
|
|
118
|
+
const [head, ...tail] = dir.split("/");
|
|
119
|
+
// A locale directory sits between the source root and the folder, but the
|
|
120
|
+
// lookup key carries the locale in front of the (prefixed) group path.
|
|
121
|
+
const key =
|
|
122
|
+
head && localeDirs.has(head)
|
|
123
|
+
? `${head}/${metaKeyFor(source.prefix, tail.join("/"))}`.replace(
|
|
124
|
+
/\/$/u,
|
|
125
|
+
""
|
|
126
|
+
)
|
|
127
|
+
: metaKeyFor(source.prefix, dir);
|
|
81
128
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
target.set(dir, result.data);
|
|
86
|
-
} else {
|
|
87
|
-
diagnostics.push(
|
|
88
|
-
...diagnosticsFromZod(result.error, {
|
|
89
|
-
code: "BLUME_META_INVALID",
|
|
129
|
+
if (!entry.ok) {
|
|
130
|
+
diagnostics.push({
|
|
131
|
+
code: "BLUME_META_LOAD_FAILED",
|
|
90
132
|
file: entry.file,
|
|
91
|
-
|
|
92
|
-
|
|
133
|
+
message: `Could not load meta file: ${entry.error.message}`,
|
|
134
|
+
severity: "error",
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const result = folderMetaSchema.safeParse(entry.value);
|
|
140
|
+
if (result.success) {
|
|
141
|
+
const target = basename(entry.file).startsWith("meta.$.")
|
|
142
|
+
? shared
|
|
143
|
+
: meta;
|
|
144
|
+
target.set(key, result.data);
|
|
145
|
+
} else {
|
|
146
|
+
diagnostics.push(
|
|
147
|
+
...diagnosticsFromZod(result.error, {
|
|
148
|
+
code: "BLUME_META_INVALID",
|
|
149
|
+
file: entry.file,
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
}
|
|
93
153
|
}
|
|
94
154
|
}
|
|
95
155
|
|
package/src/core/navigation.ts
CHANGED
|
@@ -228,6 +228,7 @@ const buildFileSystemSidebar = (
|
|
|
228
228
|
// Group by the locale-stripped path so the locale dir is not a nav group.
|
|
229
229
|
const parts = page.navPath.split("/");
|
|
230
230
|
const filename = parts.at(-1) ?? page.navPath;
|
|
231
|
+
const stem = filename.replace(extname(filename), "");
|
|
231
232
|
const dirs = parts.slice(0, -1);
|
|
232
233
|
|
|
233
234
|
// Each group's URL path is the matching prefix of the page's route. navPath
|
|
@@ -235,13 +236,22 @@ const buildFileSystemSidebar = (
|
|
|
235
236
|
// align the folder segments from the right (the extra leading segments are
|
|
236
237
|
// that prefix). Under such a prefix the path won't match a logical tab path,
|
|
237
238
|
// so tab-scoping simply no-ops — same as the header's active-tab logic.
|
|
238
|
-
|
|
239
|
-
|
|
239
|
+
// An index page's route IS its folder's route (no page segment to drop),
|
|
240
|
+
// and `(group)` folders contribute no route segment at all.
|
|
241
|
+
const routeSegments = page.route.split("/").filter(Boolean);
|
|
242
|
+
const folderParts =
|
|
243
|
+
stem === "index" ? routeSegments : routeSegments.slice(0, -1);
|
|
244
|
+
const routeDirCount = dirs.filter((dir) => !GROUP_FOLDER.test(dir)).length;
|
|
245
|
+
const offset = Math.max(0, folderParts.length - routeDirCount);
|
|
240
246
|
|
|
241
247
|
let parent = root;
|
|
242
|
-
|
|
248
|
+
let consumed = offset;
|
|
249
|
+
for (const dir of dirs) {
|
|
243
250
|
parent = ensureGroup(parent, dir);
|
|
244
|
-
|
|
251
|
+
if (!GROUP_FOLDER.test(dir)) {
|
|
252
|
+
consumed += 1;
|
|
253
|
+
}
|
|
254
|
+
parent.routePath ??= `/${folderParts.slice(0, consumed).join("/")}`;
|
|
245
255
|
}
|
|
246
256
|
|
|
247
257
|
parent.children.push({
|
|
@@ -249,7 +259,7 @@ const buildFileSystemSidebar = (
|
|
|
249
259
|
deprecated: page.meta.deprecated || undefined,
|
|
250
260
|
description: page.description,
|
|
251
261
|
icon: page.meta.sidebar.icon,
|
|
252
|
-
key: segmentKey(
|
|
262
|
+
key: segmentKey(stem),
|
|
253
263
|
kind: "page",
|
|
254
264
|
label: page.meta.sidebar.label ?? page.title,
|
|
255
265
|
order: pageOrder(page, filename),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { relative } from "pathe";
|
|
2
|
+
|
|
1
3
|
import { loadConfig } from "./config.ts";
|
|
2
4
|
import { buildContentGraph } from "./graph.ts";
|
|
3
5
|
import { i18nDiagnostics } from "./i18n.ts";
|
|
@@ -7,10 +9,11 @@ import {
|
|
|
7
9
|
} from "./last-modified.ts";
|
|
8
10
|
import { buildManifest } from "./manifest.ts";
|
|
9
11
|
import { discoverFolderMeta } from "./meta.ts";
|
|
12
|
+
import type { FolderMetaSource } from "./meta.ts";
|
|
10
13
|
import { resolveProjectContext } from "./project.ts";
|
|
11
14
|
import type { ResolvedConfig } from "./schema.ts";
|
|
12
15
|
import { normalizeEntry } from "./sources/normalize.ts";
|
|
13
|
-
import { resolveSources } from "./sources/resolve.ts";
|
|
16
|
+
import { resolveDocsCollection, resolveSources } from "./sources/resolve.ts";
|
|
14
17
|
import type { ContentSource } from "./sources/types.ts";
|
|
15
18
|
import type {
|
|
16
19
|
BlumeManifest,
|
|
@@ -70,6 +73,48 @@ export interface BlumeProject {
|
|
|
70
73
|
sources: ContentSource[];
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Guard the invariant that ties a filesystem page to the `docs` collection:
|
|
78
|
+
* Astro ids each collection entry by its path relative to the collection base,
|
|
79
|
+
* so `getEntry("docs", entryId)` only resolves when that entry id equals
|
|
80
|
+
* `relative(base, file)`. A filesystem source ids entries relative to its own
|
|
81
|
+
* root; when that root can't be the collection base (e.g. a second filesystem
|
|
82
|
+
* source rooted elsewhere), the ids diverge and every one of that source's pages
|
|
83
|
+
* would 404 in dev (a static build silently masks it). Emit a hard error naming
|
|
84
|
+
* the mismatch so it can't ship, instead of a silent runtime failure. One
|
|
85
|
+
* diagnostic per file (locale duplicates share a source path).
|
|
86
|
+
*/
|
|
87
|
+
const entryIdDiagnostics = (
|
|
88
|
+
pages: PageRecord[],
|
|
89
|
+
collectionBase: string
|
|
90
|
+
): Diagnostic[] => {
|
|
91
|
+
const diagnostics: Diagnostic[] = [];
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
for (const page of pages) {
|
|
94
|
+
// Only filesystem entries render through the base-rooted `docs` collection;
|
|
95
|
+
// staged sources carry their own id and collection.
|
|
96
|
+
if (page.collection || !page.sourcePath || seen.has(page.sourcePath)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
seen.add(page.sourcePath);
|
|
100
|
+
const expected = relative(collectionBase, page.sourcePath)
|
|
101
|
+
.split("\\")
|
|
102
|
+
.join("/");
|
|
103
|
+
const entryId = page.entryId ?? page.source.ref;
|
|
104
|
+
if (expected !== entryId) {
|
|
105
|
+
diagnostics.push({
|
|
106
|
+
code: "BLUME_ENTRY_ID_MISMATCH",
|
|
107
|
+
file: page.sourcePath,
|
|
108
|
+
message: `Content source "${page.source.name}" is rooted outside the docs collection base, so ${page.route} resolves entry id "${entryId}" but the collection would generate "${expected}" — the page would 404 at runtime.`,
|
|
109
|
+
severity: "error",
|
|
110
|
+
suggestion:
|
|
111
|
+
"Give each filesystem source a root under content.root, or use a single filesystem source so the collection can root at it.",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return diagnostics;
|
|
116
|
+
};
|
|
117
|
+
|
|
73
118
|
/**
|
|
74
119
|
* Run the full core pipeline for a project root: load config, resolve paths,
|
|
75
120
|
* discover content and folder meta, build the graph, and assemble the manifest.
|
|
@@ -110,13 +155,33 @@ export const scanProject = async (
|
|
|
110
155
|
source.validate?.();
|
|
111
156
|
}
|
|
112
157
|
|
|
158
|
+
// Folder meta is discovered per filesystem source, under each source's own
|
|
159
|
+
// root and keyed by its route prefix, so a prefixed/root-differing source's
|
|
160
|
+
// `meta.ts` still lines up with its (prefixed) sidebar group path.
|
|
161
|
+
const metaSources: FolderMetaSource[] = sources
|
|
162
|
+
.filter((source) => !source.staged && source.contentRoot)
|
|
163
|
+
.map((source) => ({
|
|
164
|
+
prefix: source.prefix,
|
|
165
|
+
root: source.contentRoot ?? "",
|
|
166
|
+
}));
|
|
167
|
+
|
|
113
168
|
// Run every source's `load()` in parallel, then funnel each entry through the
|
|
114
169
|
// shared `normalizeEntry` so route mapping is identical regardless of origin.
|
|
170
|
+
// Under the `dir` parser, non-default locales are top-level directories whose
|
|
171
|
+
// meta keys must carry the locale in front of the source prefix (see
|
|
172
|
+
// `discoverFolderMeta`).
|
|
173
|
+
const localeDirs =
|
|
174
|
+
config.i18n && config.i18n.parser === "dir"
|
|
175
|
+
? config.i18n.locales
|
|
176
|
+
.map((locale) => locale.code)
|
|
177
|
+
.filter((code) => code !== config.i18n?.defaultLocale)
|
|
178
|
+
: undefined;
|
|
179
|
+
|
|
115
180
|
const [loaded, folderMeta] = await Promise.all([
|
|
116
181
|
Promise.all(
|
|
117
182
|
sources.map(async (source) => ({ source, ...(await source.load()) }))
|
|
118
183
|
),
|
|
119
|
-
discoverFolderMeta(
|
|
184
|
+
discoverFolderMeta(metaSources, { localeDirs }),
|
|
120
185
|
]);
|
|
121
186
|
|
|
122
187
|
const allPages: PageRecord[] = [];
|
|
@@ -180,6 +245,7 @@ export const scanProject = async (
|
|
|
180
245
|
diagnostics: [
|
|
181
246
|
...contentDiagnostics,
|
|
182
247
|
...folderMeta.diagnostics,
|
|
248
|
+
...entryIdDiagnostics(pages, resolveDocsCollection(config, context).base),
|
|
183
249
|
...graph.diagnostics,
|
|
184
250
|
...i18nWarnings,
|
|
185
251
|
],
|
package/src/core/schema.ts
CHANGED
|
@@ -109,7 +109,8 @@ const pageMetaBaseSchema = z
|
|
|
109
109
|
sidebar: sidebarMetaSchema.default({}),
|
|
110
110
|
slug: z.string().optional(),
|
|
111
111
|
title: z.string().optional(),
|
|
112
|
-
|
|
112
|
+
// No default: an absent `type` must fall through to `content.defaultType`.
|
|
113
|
+
type: z.string().optional(),
|
|
113
114
|
})
|
|
114
115
|
.strict();
|
|
115
116
|
|
|
@@ -925,12 +926,6 @@ const codeConfigSchema = z
|
|
|
925
926
|
* …). On by default; recognized languages only.
|
|
926
927
|
*/
|
|
927
928
|
icons: z.boolean().default(true),
|
|
928
|
-
/**
|
|
929
|
-
* Syntax-highlight inline `` `code{:lang}` `` snippets. Off by default — most
|
|
930
|
-
* inline code (flags, file names) reads better plain; opt a snippet in with
|
|
931
|
-
* a trailing `{:lang}` marker.
|
|
932
|
-
*/
|
|
933
|
-
inline: z.boolean().default(false),
|
|
934
929
|
/**
|
|
935
930
|
* Wrap long lines instead of scrolling horizontally. Off by default, so
|
|
936
931
|
* code keeps its original line breaks and overflows into a scroll area.
|
|
@@ -955,11 +950,6 @@ const markdownConfigSchema = z
|
|
|
955
950
|
* opt a single image out with `data-no-zoom`.
|
|
956
951
|
*/
|
|
957
952
|
imageZoom: z.boolean().default(true),
|
|
958
|
-
/**
|
|
959
|
-
* Enable LaTeX math (`$…$` inline, `$$…$$` block) rendered with KaTeX.
|
|
960
|
-
* Off by default since `$` is common in prose, shell, and code. MDX only.
|
|
961
|
-
*/
|
|
962
|
-
math: z.boolean().default(false),
|
|
963
953
|
})
|
|
964
954
|
.strict();
|
|
965
955
|
|
|
@@ -1097,8 +1087,12 @@ export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
|
|
|
1097
1087
|
export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
|
|
1098
1088
|
/** A configured locale with display metadata. */
|
|
1099
1089
|
export type LocaleConfig = z.infer<typeof localeSchema>;
|
|
1100
|
-
/**
|
|
1101
|
-
|
|
1090
|
+
/**
|
|
1091
|
+
* User-authored config, straight off the schema. The public, hand-documented
|
|
1092
|
+
* authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
|
|
1093
|
+
* guard keeps structurally identical to this.
|
|
1094
|
+
*/
|
|
1095
|
+
export type BlumeConfigInput = z.input<typeof blumeConfigSchema>;
|
|
1102
1096
|
/** A configured search backend. */
|
|
1103
1097
|
export type SearchProvider = (typeof searchProviders)[number];
|
|
1104
1098
|
/** Resolved robots.txt `Content-Signal` preferences (`null` when disabled). */
|
|
@@ -8,6 +8,11 @@ import { hashText } from "./cache.ts";
|
|
|
8
8
|
const MD_IMAGE = /!\[(?<alt>[^\]]*)\]\((?<url>[^)\s]+)\)/gu;
|
|
9
9
|
const REMOTE = /^https?:\/\//u;
|
|
10
10
|
const SAFE_EXT = /^\.[a-z0-9]+$/iu;
|
|
11
|
+
const CODE_FENCE_BLOCK =
|
|
12
|
+
/^(?<fence>`{3,}|~{3,})[^\n]*\n[\s\S]*?^\k<fence>[^\n]*(?=\n|$)/gmu;
|
|
13
|
+
// NUL delimiters cannot appear in authored markdown, so tokens never collide.
|
|
14
|
+
// oxlint-disable-next-line no-control-regex -- the NUL is the collision guard.
|
|
15
|
+
const FENCE_TOKEN = /\u0000blume-fence-(?<index>\d+)\u0000/gu;
|
|
11
16
|
|
|
12
17
|
/** Where to write downloaded assets and how to reference them publicly. */
|
|
13
18
|
export interface AssetContext {
|
|
@@ -37,8 +42,17 @@ export const materializeAssets = async (
|
|
|
37
42
|
const doFetch = ctx.fetchImpl ?? globalThis.fetch;
|
|
38
43
|
const diagnostics: Diagnostic[] = [];
|
|
39
44
|
|
|
45
|
+
// Mask fenced code blocks so an image URL inside a code sample is neither
|
|
46
|
+
// downloaded nor rewritten — the sample must keep showing what the author
|
|
47
|
+
// wrote.
|
|
48
|
+
const fences: string[] = [];
|
|
49
|
+
const masked = markdown.replace(CODE_FENCE_BLOCK, (block) => {
|
|
50
|
+
fences.push(block);
|
|
51
|
+
return `\u0000blume-fence-${fences.length - 1}\u0000`;
|
|
52
|
+
});
|
|
53
|
+
|
|
40
54
|
const urls = new Set<string>();
|
|
41
|
-
for (const match of
|
|
55
|
+
for (const match of masked.matchAll(MD_IMAGE)) {
|
|
42
56
|
const url = match.groups?.url;
|
|
43
57
|
if (url && REMOTE.test(url)) {
|
|
44
58
|
urls.add(url);
|
|
@@ -68,10 +82,12 @@ export const materializeAssets = async (
|
|
|
68
82
|
})
|
|
69
83
|
);
|
|
70
84
|
|
|
71
|
-
const rewritten =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
const rewritten = masked
|
|
86
|
+
.replaceAll(MD_IMAGE, (match, alt, url) => {
|
|
87
|
+
const local = rewrites.get(url);
|
|
88
|
+
return local ? `` : match;
|
|
89
|
+
})
|
|
90
|
+
.replaceAll(FENCE_TOKEN, (token, index) => fences[Number(index)] ?? token);
|
|
75
91
|
|
|
76
92
|
return { diagnostics, markdown: rewritten };
|
|
77
93
|
};
|