blume 0.6.1 → 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 +5955 -5733
- package/dist/cli/index.js.map +37 -37
- package/dist/types/core/config-input.d.ts +1 -11
- package/dist/types/core/schema.d.ts +4 -4
- package/dist/types/core/sources/types.d.ts +6 -0
- package/package.json +1 -1
- package/src/astro/generate.ts +23 -13
- package/src/astro/markdown-negotiation.ts +12 -3
- package/src/astro/templates.ts +28 -7
- 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/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 +16 -2
- package/src/components/layout/Search.astro +8 -3
- package/src/components/layout/nav-utils.ts +7 -3
- package/src/core/config-input.ts +1 -11
- 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 +2 -1
- 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/rss.ts +3 -1
- package/src/markdown/code-title.ts +11 -4
- 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/search/documents.ts +4 -1
- package/src/theme/entry.ts +3 -0
- package/src/theme/icons.ts +7 -11
|
@@ -32,6 +32,7 @@ import Favicon from "./Favicon.astro";
|
|
|
32
32
|
import Fonts from "./Fonts.astro";
|
|
33
33
|
import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
|
|
34
34
|
import Header from "./Header.astro";
|
|
35
|
+
import { isUnderPath } from "./nav-utils.ts";
|
|
35
36
|
|
|
36
37
|
interface Props {
|
|
37
38
|
site: { title: string; description?: string };
|
|
@@ -209,7 +210,7 @@ const bannerScript = banner?.dismissible
|
|
|
209
210
|
<a
|
|
210
211
|
aria-current={
|
|
211
212
|
route === tab.path ||
|
|
212
|
-
(tab.path !== "/" && route
|
|
213
|
+
(tab.path !== "/" && isUnderPath(route, tab.path))
|
|
213
214
|
? "page"
|
|
214
215
|
: undefined
|
|
215
216
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
findBreadcrumbs,
|
|
24
24
|
flattenPages,
|
|
25
25
|
getPagination,
|
|
26
|
+
isUnderPath,
|
|
26
27
|
sidebarForRoute,
|
|
27
28
|
} from "./nav-utils.ts";
|
|
28
29
|
import NavTree from "./NavTree.astro";
|
|
@@ -427,7 +428,7 @@ const bannerScript = banner?.dismissible
|
|
|
427
428
|
<a
|
|
428
429
|
aria-current={
|
|
429
430
|
page.route === tab.path ||
|
|
430
|
-
(tab.path !== "/" && page.route
|
|
431
|
+
(tab.path !== "/" && isUnderPath(page.route, tab.path))
|
|
431
432
|
? "page"
|
|
432
433
|
: undefined
|
|
433
434
|
}
|
|
@@ -601,7 +602,20 @@ const bannerScript = banner?.dismissible
|
|
|
601
602
|
button.setAttribute("aria-label", "Copy code");
|
|
602
603
|
button.innerHTML = svg("copy");
|
|
603
604
|
button.addEventListener("click", async () => {
|
|
604
|
-
const
|
|
605
|
+
const code = pre.querySelector("code");
|
|
606
|
+
let text = code?.textContent ?? "";
|
|
607
|
+
// Twoslash nests each hover popup's type signature and docs inside
|
|
608
|
+
// the <code>; copying textContent verbatim would interleave them
|
|
609
|
+
// with the source. Strip the popups from a clone first.
|
|
610
|
+
if (code?.querySelector(".twoslash-popup-container")) {
|
|
611
|
+
const clone = code.cloneNode(true) as HTMLElement;
|
|
612
|
+
for (const popup of clone.querySelectorAll(
|
|
613
|
+
".twoslash-popup-container"
|
|
614
|
+
)) {
|
|
615
|
+
popup.remove();
|
|
616
|
+
}
|
|
617
|
+
text = clone.textContent ?? "";
|
|
618
|
+
}
|
|
605
619
|
try {
|
|
606
620
|
await navigator.clipboard.writeText(text);
|
|
607
621
|
} catch {
|
|
@@ -133,7 +133,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
133
133
|
<script
|
|
134
134
|
data-blume-search-popular
|
|
135
135
|
is:inline
|
|
136
|
-
set:html={JSON.stringify(popular)}
|
|
136
|
+
set:html={JSON.stringify(popular).replaceAll("<", "\\u003c")}
|
|
137
137
|
type="application/json"
|
|
138
138
|
/>
|
|
139
139
|
|
|
@@ -190,6 +190,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
190
190
|
selectables: Selectable[] = [];
|
|
191
191
|
selectedIndex = -1;
|
|
192
192
|
activeSection: string | null = null;
|
|
193
|
+
renderGeneration = 0;
|
|
193
194
|
previewOn = true;
|
|
194
195
|
devOnlyMsg = "Search is available in the production build.";
|
|
195
196
|
noResultsMsg = "No results found.";
|
|
@@ -339,6 +340,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
339
340
|
|
|
340
341
|
async render() {
|
|
341
342
|
const query = this.input.value.trim();
|
|
343
|
+
this.renderGeneration += 1;
|
|
344
|
+
const generation = this.renderGeneration;
|
|
342
345
|
this.selectables = [];
|
|
343
346
|
this.selectedIndex = -1;
|
|
344
347
|
this.results.replaceChildren();
|
|
@@ -364,8 +367,10 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
364
367
|
locale: localeFilter,
|
|
365
368
|
section: this.activeSection ?? undefined,
|
|
366
369
|
});
|
|
367
|
-
//
|
|
368
|
-
|
|
370
|
+
// Any newer render — a keystroke, a section pill, a locale toggle —
|
|
371
|
+
// supersedes this one mid-await, even for the same query text;
|
|
372
|
+
// appending the stale hits would duplicate rows and desync selection.
|
|
373
|
+
if (generation !== this.renderGeneration) {
|
|
369
374
|
return;
|
|
370
375
|
}
|
|
371
376
|
|
|
@@ -71,9 +71,13 @@ export const findBreadcrumbs = (nodes: NavNode[], route: string): Crumb[] => {
|
|
|
71
71
|
return search(nodes, []) ?? [];
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Whether `route` is the section root `base` or nested beneath it. Requires a
|
|
76
|
+
* path boundary, so `/api-reference` is not under `/api`. The root `/` spans
|
|
77
|
+
* every route.
|
|
78
|
+
*/
|
|
79
|
+
export const isUnderPath = (route: string, base: string): boolean =>
|
|
80
|
+
base === "/" || route === base || route.startsWith(`${base}/`);
|
|
77
81
|
|
|
78
82
|
/**
|
|
79
83
|
* The tab whose `path` is the longest prefix of `route`, mirroring the header's
|
package/src/core/config-input.ts
CHANGED
|
@@ -714,18 +714,13 @@ export interface GithubConfig {
|
|
|
714
714
|
export interface CodeConfig {
|
|
715
715
|
/** Show a brand language icon in the code-block header. Defaults to `true`. */
|
|
716
716
|
icons?: boolean;
|
|
717
|
-
/**
|
|
718
|
-
* Syntax-highlight inline `` `code{:lang}` `` snippets. Defaults to `false`;
|
|
719
|
-
* opt a snippet in with a trailing `{:lang}` marker.
|
|
720
|
-
*/
|
|
721
|
-
inline?: boolean;
|
|
722
717
|
/** Wrap long lines instead of scrolling horizontally. Defaults to `false`. */
|
|
723
718
|
wrap?: boolean;
|
|
724
719
|
}
|
|
725
720
|
|
|
726
721
|
/** Markdown / MDX rendering behavior. */
|
|
727
722
|
export interface MarkdownConfig {
|
|
728
|
-
/** Code-block rendering: language icons,
|
|
723
|
+
/** Code-block rendering: language icons, line wrap. */
|
|
729
724
|
code?: CodeConfig;
|
|
730
725
|
/** Syntax-highlighting themes for fenced code blocks. */
|
|
731
726
|
codeBlocks?: {
|
|
@@ -744,11 +739,6 @@ export interface MarkdownConfig {
|
|
|
744
739
|
headingAnchors?: boolean;
|
|
745
740
|
/** Make content images click-to-zoom (lightbox). Defaults to `true`. */
|
|
746
741
|
imageZoom?: boolean;
|
|
747
|
-
/**
|
|
748
|
-
* Enable LaTeX math (`$…$` inline, `$$…$$` block) via KaTeX. Defaults to
|
|
749
|
-
* `false` since `$` is common in prose and shell. MDX only.
|
|
750
|
-
*/
|
|
751
|
-
math?: boolean;
|
|
752
742
|
}
|
|
753
743
|
|
|
754
744
|
// ---------------------------------------------------------------------------
|
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
|
|
|
@@ -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
|
};
|
|
@@ -27,14 +27,32 @@ export const entriesDigest = (entries: SourceEntry[]): string =>
|
|
|
27
27
|
* Build an opt-in polling watcher for a remote source: re-`load()` on an
|
|
28
28
|
* interval and fire `onChange` only when the entry digest changes, so a remote
|
|
29
29
|
* source can hot-reload in dev without refetching the world on every keystroke.
|
|
30
|
+
*
|
|
31
|
+
* `load` must fetch fresh (bypassing the cache-first dev path) — polling the
|
|
32
|
+
* cache-first loader would serve the identical snapshot on every tick and
|
|
33
|
+
* never observe a remote change. `seed` (the source's regular, cache-first
|
|
34
|
+
* loader) establishes the baseline digest from what dev actually served, so a
|
|
35
|
+
* remote change landing before the first tick still fires.
|
|
30
36
|
*/
|
|
31
37
|
export const pollingWatch =
|
|
32
38
|
(
|
|
33
39
|
load: () => Promise<SourceLoadResult>,
|
|
34
|
-
intervalSeconds: number
|
|
40
|
+
intervalSeconds: number,
|
|
41
|
+
seed?: () => Promise<SourceLoadResult>
|
|
35
42
|
): ((onChange: () => void) => () => void) =>
|
|
36
43
|
(onChange) => {
|
|
37
44
|
let last = "";
|
|
45
|
+
if (seed) {
|
|
46
|
+
const seedBaseline = async (): Promise<void> => {
|
|
47
|
+
try {
|
|
48
|
+
const { entries } = await seed();
|
|
49
|
+
last ||= entriesDigest(entries);
|
|
50
|
+
} catch {
|
|
51
|
+
// Fall back to first-tick seeding.
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
void seedBaseline();
|
|
55
|
+
}
|
|
38
56
|
const tick = async (): Promise<void> => {
|
|
39
57
|
try {
|
|
40
58
|
const { entries } = await load();
|