blume 0.2.0 → 0.4.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 +2429 -792
- package/dist/cli/index.js.map +63 -44
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/define-components.d.ts +9 -2
- package/dist/types/core/diagnostics.d.ts +5 -0
- package/dist/types/core/schema.d.ts +313 -778
- package/dist/types/core/types.d.ts +2 -2
- package/dist/types/migrate/mintlify/assets.d.ts +8 -0
- package/docs/01-quickstart.mdx +5 -16
- package/docs/02-deployment.mdx +26 -40
- package/docs/advanced/api-reference.mdx +10 -37
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +66 -61
- package/docs/configuration/ai.mdx +47 -91
- package/docs/configuration/analytics.mdx +20 -38
- package/docs/configuration/customization.mdx +92 -27
- package/docs/configuration/export.mdx +9 -34
- package/docs/configuration/index.mdx +78 -85
- package/docs/configuration/search.mdx +17 -54
- package/docs/configuration/seo.mdx +18 -44
- package/docs/configuration/theming.mdx +20 -42
- package/docs/content/components.mdx +42 -101
- package/docs/content/i18n.mdx +21 -72
- package/docs/content/index.mdx +18 -48
- package/docs/content/islands.mdx +79 -33
- package/docs/content/meta.mdx +23 -50
- package/docs/content/navigation.mdx +42 -56
- package/docs/content/sources.mdx +20 -83
- package/docs/content/syntax.mdx +37 -105
- package/docs/index.mdx +13 -51
- package/docs/reference/cli.mdx +49 -18
- package/docs/reference/frontmatter.mdx +2 -5
- package/package.json +3 -1
- package/src/ai/ask-context.ts +131 -0
- package/src/ai/ask-data.ts +25 -0
- package/src/astro/component-slots.ts +165 -0
- package/src/astro/generate.ts +132 -13
- package/src/astro/integration.ts +85 -3
- package/src/astro/islands.ts +6 -2
- package/src/astro/markdown-negotiation.ts +17 -3
- package/src/astro/pages.ts +11 -13
- package/src/astro/static-assets.ts +117 -0
- package/src/astro/templates.ts +120 -50
- package/src/blume-modules.d.ts +25 -0
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +209 -1
- package/src/cli/commands/check.ts +62 -0
- package/src/cli/commands/dev.ts +32 -3
- package/src/cli/commands/doctor.ts +32 -6
- package/src/cli/commands/eject.ts +3 -1
- package/src/cli/commands/init.ts +184 -16
- package/src/cli/commands/preview.ts +2 -1
- package/src/cli/commands/validate.ts +27 -2
- package/src/cli/dev-lock.ts +84 -0
- package/src/cli/index.ts +15 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +41 -1
- package/src/cli/prepare.ts +17 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +109 -0
- package/src/components/content/YouTube.astro +35 -0
- package/src/components/content/youtube.ts +46 -0
- package/src/components/index.ts +3 -3
- package/src/components/islands/ask-ai.tsx +29 -15
- package/src/components/islands/hooks.ts +188 -0
- package/src/components/layout/Empty.astro +6 -0
- package/src/components/layout/Header.astro +24 -39
- package/src/components/layout/Logo.astro +50 -0
- package/src/components/layout/NavSelector.astro +75 -0
- package/src/components/layout/PageLayout.astro +38 -2
- package/src/components/layout/RootLayout.astro +70 -4
- package/src/components/layout/hydration-hint.ts +30 -0
- package/src/components/layout/overrides.ts +6 -4
- package/src/components/props.ts +71 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +40 -0
- package/src/core/component-diagnostics.ts +44 -0
- package/src/core/component-overrides.ts +478 -0
- package/src/core/config.ts +8 -0
- package/src/core/data.ts +14 -0
- package/src/core/define-components.ts +9 -2
- package/src/core/diagnostics.ts +95 -1
- package/src/core/gitignore.ts +30 -0
- package/src/core/graph.ts +7 -0
- package/src/core/links.ts +60 -19
- package/src/core/nav-diagnostics.ts +205 -0
- package/src/core/project-graph.ts +40 -1
- package/src/core/schema.ts +35 -96
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/normalize.ts +57 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/sanity.ts +5 -1
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -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 +1 -176
- package/src/migrate/mintlify/index.ts +53 -45
- package/src/migrate/shared.ts +12 -27
- package/src/migrate/starlight/config.ts +0 -4
- package/src/og/card.ts +175 -38
- package/src/registry/eject.ts +52 -12
- package/src/registry/registry.ts +172 -0
- package/src/registry/rewrite-imports.ts +31 -19
- package/src/runtime/index.ts +61 -0
- 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/palette.ts +26 -7
- package/src/vite-env.d.ts +14 -0
package/src/core/diagnostics.ts
CHANGED
|
@@ -17,10 +17,99 @@ export class BlumeError extends Error {
|
|
|
17
17
|
export const createDiagnostic = (diagnostic: Diagnostic): Diagnostic =>
|
|
18
18
|
diagnostic;
|
|
19
19
|
|
|
20
|
+
/** Docs site base; diagnostic help links resolve against it. */
|
|
21
|
+
const DOCS_BASE = "https://useblume.dev";
|
|
22
|
+
|
|
23
|
+
/** Diagnostic code → the docs page that explains it. */
|
|
24
|
+
const DOCS_PATHS: Record<string, string> = {
|
|
25
|
+
BLUME_ADAPTER_REQUIRED: "/docs/deployment",
|
|
26
|
+
BLUME_ASSETS_UNCHECKED: "/docs/reference/cli",
|
|
27
|
+
BLUME_ASSET_FETCH_FAILED: "/docs/content/sources",
|
|
28
|
+
BLUME_BROKEN_ANCHOR: "/docs/reference/cli",
|
|
29
|
+
BLUME_BROKEN_ASSET: "/docs/reference/cli",
|
|
30
|
+
BLUME_BROKEN_LINK: "/docs/reference/cli",
|
|
31
|
+
BLUME_CONFIG_INVALID: "/docs/configuration",
|
|
32
|
+
BLUME_CONFIG_LOAD_FAILED: "/docs/configuration",
|
|
33
|
+
BLUME_CONTENT_ROOT_MISSING: "/docs/content/sources",
|
|
34
|
+
BLUME_DEAD_LINK: "/docs/reference/cli",
|
|
35
|
+
BLUME_DUPLICATE_ROUTE: "/docs/content/navigation",
|
|
36
|
+
BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
|
|
37
|
+
BLUME_META_INVALID: "/docs/content/meta",
|
|
38
|
+
BLUME_META_LOAD_FAILED: "/docs/content/meta",
|
|
39
|
+
BLUME_MISSING_SECRET: "/docs/deployment",
|
|
40
|
+
BLUME_NAV_DUPLICATE_LABEL: "/docs/content/navigation",
|
|
41
|
+
BLUME_NAV_HIDDEN_IN_SIDEBAR: "/docs/content/navigation",
|
|
42
|
+
BLUME_NAV_MISSING_PAGE: "/docs/content/navigation",
|
|
43
|
+
BLUME_NODE_VERSION: "/docs/quickstart",
|
|
44
|
+
BLUME_SERVER_FEATURE_REQUIRED: "/docs/deployment",
|
|
45
|
+
BLUME_SOURCE_FETCH_FAILED: "/docs/content/sources",
|
|
46
|
+
BLUME_SOURCE_MISCONFIGURED: "/docs/content/sources",
|
|
47
|
+
BLUME_SOURCE_OFFLINE: "/docs/content/sources",
|
|
48
|
+
BLUME_SOURCE_SDK_MISSING: "/docs/content/sources",
|
|
49
|
+
BLUME_SOURCE_UNAVAILABLE: "/docs/content/sources",
|
|
50
|
+
BLUME_UNKNOWN_COMPONENT: "/docs/configuration/customization",
|
|
51
|
+
BLUME_UNKNOWN_ICON: "/docs/content/navigation",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The docs URL that explains a diagnostic code, if one is mapped. */
|
|
55
|
+
export const resolveDocsUrl = (code: string): string | undefined => {
|
|
56
|
+
const path = DOCS_PATHS[code];
|
|
57
|
+
return path ? `${DOCS_BASE}${path}` : undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Fill in `docsUrl` from the code map where a diagnostic doesn't set its own. */
|
|
61
|
+
export const enrichDiagnostic = (diagnostic: Diagnostic): Diagnostic =>
|
|
62
|
+
diagnostic.docsUrl
|
|
63
|
+
? diagnostic
|
|
64
|
+
: { ...diagnostic, docsUrl: resolveDocsUrl(diagnostic.code) };
|
|
65
|
+
|
|
66
|
+
const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
|
|
67
|
+
const escapeRegExp = (value: string): string =>
|
|
68
|
+
value.replaceAll(REGEXP_SPECIAL, String.raw`\$&`);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort source position for a Zod issue path (e.g. `["seo", "title"]`) in
|
|
72
|
+
* the raw config / frontmatter text. Narrows key-by-key — finding each string
|
|
73
|
+
* segment as a `key:`/`key =` at or after the previous match — so a nested key
|
|
74
|
+
* lands under its parent. Array indices are skipped. Returns 1-based line/column,
|
|
75
|
+
* or undefined when nothing matches.
|
|
76
|
+
*/
|
|
77
|
+
const locatePath = (
|
|
78
|
+
source: string,
|
|
79
|
+
path: readonly (string | number)[]
|
|
80
|
+
): { column: number; line: number } | undefined => {
|
|
81
|
+
let cursor = 0;
|
|
82
|
+
let found = -1;
|
|
83
|
+
for (const segment of path) {
|
|
84
|
+
if (typeof segment !== "string") {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
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
|
+
);
|
|
93
|
+
matcher.lastIndex = cursor;
|
|
94
|
+
const match = matcher.exec(source);
|
|
95
|
+
if (!match) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
found = match.index;
|
|
99
|
+
cursor = matcher.lastIndex;
|
|
100
|
+
}
|
|
101
|
+
if (found < 0) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const before = source.slice(0, found);
|
|
105
|
+
const lastNewline = before.lastIndexOf("\n");
|
|
106
|
+
return { column: found - lastNewline, line: before.split("\n").length };
|
|
107
|
+
};
|
|
108
|
+
|
|
20
109
|
/** Convert a ZodError into Blume diagnostics, anchored to a file. */
|
|
21
110
|
export const diagnosticsFromZod = (
|
|
22
111
|
error: ZodError,
|
|
23
|
-
options: { code: string; file?: string }
|
|
112
|
+
options: { code: string; file?: string; source?: string }
|
|
24
113
|
): Diagnostic[] =>
|
|
25
114
|
error.issues.map((issue) => {
|
|
26
115
|
const schemaPath = issue.path.join(".");
|
|
@@ -28,9 +117,14 @@ export const diagnosticsFromZod = (
|
|
|
28
117
|
"received" in issue
|
|
29
118
|
? ` (received: ${JSON.stringify(issue.received)})`
|
|
30
119
|
: "";
|
|
120
|
+
const position = options.source
|
|
121
|
+
? locatePath(options.source, issue.path)
|
|
122
|
+
: undefined;
|
|
31
123
|
return {
|
|
32
124
|
code: options.code,
|
|
125
|
+
column: position?.column,
|
|
33
126
|
file: options.file,
|
|
127
|
+
line: position?.line,
|
|
34
128
|
message: schemaPath
|
|
35
129
|
? `${schemaPath}: ${issue.message}${received}`
|
|
36
130
|
: `${issue.message}${received}`,
|
|
@@ -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/graph.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { localizeRoute, resolveFallbackLocale } from "./i18n.ts";
|
|
2
|
+
import { validateNavIcons, validateNavStructure } from "./nav-diagnostics.ts";
|
|
2
3
|
import { buildNavigation } from "./navigation.ts";
|
|
3
4
|
import type {
|
|
4
5
|
FolderMeta,
|
|
@@ -118,6 +119,12 @@ export const buildContentGraph = (
|
|
|
118
119
|
});
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// Icon typos, duplicate labels, and hidden-page-in-sidebar are validated on
|
|
123
|
+
// the built navigation. Missing-target detection needs the full route set
|
|
124
|
+
// (incl. custom + generated pages), so it runs later in generateRuntime.
|
|
125
|
+
diagnostics.push(...validateNavIcons(navigation));
|
|
126
|
+
diagnostics.push(...validateNavStructure(navigation, pages));
|
|
127
|
+
|
|
121
128
|
return {
|
|
122
129
|
diagnostics,
|
|
123
130
|
navigation,
|
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))
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { hasIcon } from "../theme/icons.ts";
|
|
2
|
+
import type { Diagnostic, NavNode, Navigation, PageRecord } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Navigation diagnostics: catch icon typos and structural mistakes (missing
|
|
6
|
+
* pages, duplicate labels) that otherwise fail silently — a wrong icon just
|
|
7
|
+
* doesn't render, a bad tab path just 404s. Run over the built navigation so it
|
|
8
|
+
* covers every source (config, folder meta, frontmatter) at once.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const IMAGE_ICON =
|
|
12
|
+
/^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)|\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
|
|
13
|
+
|
|
14
|
+
/** Whether an icon string is an asset (image/URL/inline SVG), not a set name. */
|
|
15
|
+
const isAssetIcon = (value: string): boolean =>
|
|
16
|
+
value.startsWith("<") || IMAGE_ICON.test(value);
|
|
17
|
+
|
|
18
|
+
/** Flatten a sidebar tree to every node, descending into groups. */
|
|
19
|
+
const flattenNodes = (nodes: NavNode[]): NavNode[] =>
|
|
20
|
+
nodes.flatMap((node) =>
|
|
21
|
+
node.kind === "group" ? [node, ...flattenNodes(node.children)] : [node]
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** Every icon string referenced anywhere in the navigation, with a label. */
|
|
25
|
+
const collectIcons = (
|
|
26
|
+
navigation: Navigation
|
|
27
|
+
): { icon: string; where: string }[] => {
|
|
28
|
+
const icons: { icon: string; where: string }[] = [];
|
|
29
|
+
const push = (icon: string | undefined, where: string): void => {
|
|
30
|
+
if (icon) {
|
|
31
|
+
icons.push({ icon, where });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
for (const tab of navigation.tabs) {
|
|
35
|
+
push(tab.icon, `tab "${tab.label}"`);
|
|
36
|
+
for (const item of tab.items ?? []) {
|
|
37
|
+
push(item.icon, `tab item "${item.label}"`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const selector of navigation.selectors) {
|
|
41
|
+
for (const item of selector.items) {
|
|
42
|
+
push(item.icon, `selector "${item.label}"`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const sidebars = [
|
|
46
|
+
navigation.sidebar,
|
|
47
|
+
...navigation.sidebarVariants.map((variant) => variant.sidebar),
|
|
48
|
+
];
|
|
49
|
+
for (const sidebar of sidebars) {
|
|
50
|
+
for (const node of flattenNodes(sidebar)) {
|
|
51
|
+
push(node.icon, `"${node.label}"`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return icons;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Warn about icon names that aren't in Blume's set (skipping image/SVG icons). */
|
|
58
|
+
export const validateNavIcons = (navigation: Navigation): Diagnostic[] => {
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const diagnostics: Diagnostic[] = [];
|
|
61
|
+
for (const { icon, where } of collectIcons(navigation)) {
|
|
62
|
+
if (isAssetIcon(icon) || hasIcon(icon) || seen.has(icon)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
seen.add(icon);
|
|
66
|
+
diagnostics.push({
|
|
67
|
+
code: "BLUME_UNKNOWN_ICON",
|
|
68
|
+
message: `Unknown icon "${icon}" (${where}) — it isn't in Blume's icon set.`,
|
|
69
|
+
severity: "warning",
|
|
70
|
+
suggestion:
|
|
71
|
+
"Use a built-in icon name, an image path/URL, or inline SVG markup.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return diagnostics;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Whether an internal path resolves to a page or a section that has pages. */
|
|
78
|
+
const resolvesToPages = (routes: Set<string>, path: string): boolean =>
|
|
79
|
+
routes.has(path) || [...routes].some((route) => route.startsWith(`${path}/`));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Warn when a config-linked tab/selector target has no matching page. `routes`
|
|
83
|
+
* must be the full set of servable routes — content, custom `.astro` pages, and
|
|
84
|
+
* generated routes — so this runs where all three are known (`generateRuntime`),
|
|
85
|
+
* not in the content-only graph build.
|
|
86
|
+
*/
|
|
87
|
+
export const validateNavTargets = (
|
|
88
|
+
navigation: Navigation,
|
|
89
|
+
routes: Set<string>
|
|
90
|
+
): Diagnostic[] => {
|
|
91
|
+
const targets: { label: string; path: string }[] = [
|
|
92
|
+
...navigation.tabs.map((tab) => ({ label: tab.label, path: tab.path })),
|
|
93
|
+
...navigation.selectors.flatMap((selector) =>
|
|
94
|
+
selector.items.map((item) => ({ label: item.label, path: item.path }))
|
|
95
|
+
),
|
|
96
|
+
];
|
|
97
|
+
const diagnostics: Diagnostic[] = [];
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
for (const { label, path } of targets) {
|
|
100
|
+
// Only internal, non-anchor paths can be checked against routes.
|
|
101
|
+
if (!path.startsWith("/") || path.startsWith("/#") || seen.has(path)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!resolvesToPages(routes, path.split("#")[0] ?? path)) {
|
|
105
|
+
seen.add(path);
|
|
106
|
+
diagnostics.push({
|
|
107
|
+
code: "BLUME_NAV_MISSING_PAGE",
|
|
108
|
+
message: `Navigation entry "${label}" points to ${path}, but no page matches it.`,
|
|
109
|
+
severity: "warning",
|
|
110
|
+
suggestion: "Fix the path, or add a page at that route.",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return diagnostics;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Warn about two nav items sharing a label at the same sidebar level. */
|
|
118
|
+
const duplicateLabelDiagnostics = (navigation: Navigation): Diagnostic[] => {
|
|
119
|
+
const diagnostics: Diagnostic[] = [];
|
|
120
|
+
const checkLevel = (nodes: NavNode[], where: string): void => {
|
|
121
|
+
const counts = new Map<string, number>();
|
|
122
|
+
for (const node of nodes) {
|
|
123
|
+
counts.set(node.label, (counts.get(node.label) ?? 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
for (const [label, count] of counts) {
|
|
126
|
+
if (count > 1) {
|
|
127
|
+
diagnostics.push({
|
|
128
|
+
code: "BLUME_NAV_DUPLICATE_LABEL",
|
|
129
|
+
message: `Duplicate sidebar label "${label}" appears ${count} times ${where}.`,
|
|
130
|
+
severity: "warning",
|
|
131
|
+
suggestion: "Give the entries distinct titles.",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const node of nodes) {
|
|
136
|
+
if (node.kind === "group") {
|
|
137
|
+
checkLevel(node.children, `under "${node.label}"`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const sidebars: { nodes: NavNode[]; where: string }[] = [
|
|
142
|
+
{ nodes: navigation.sidebar, where: "at the top level" },
|
|
143
|
+
...navigation.sidebarVariants.map((variant) => ({
|
|
144
|
+
nodes: variant.sidebar,
|
|
145
|
+
where: `in the "${variant.path}" section`,
|
|
146
|
+
})),
|
|
147
|
+
];
|
|
148
|
+
for (const { nodes, where } of sidebars) {
|
|
149
|
+
checkLevel(nodes, where);
|
|
150
|
+
}
|
|
151
|
+
return diagnostics;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Warn when a page shown in the sidebar is marked hidden (so pagination hits it). */
|
|
155
|
+
const hiddenInSidebarDiagnostics = (
|
|
156
|
+
navigation: Navigation,
|
|
157
|
+
pages: PageRecord[]
|
|
158
|
+
): Diagnostic[] => {
|
|
159
|
+
const hidden = new Set(
|
|
160
|
+
pages.filter((page) => page.meta.sidebar.hidden).map((page) => page.id)
|
|
161
|
+
);
|
|
162
|
+
if (hidden.size === 0) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const sidebars = [
|
|
166
|
+
navigation.sidebar,
|
|
167
|
+
...navigation.sidebarVariants.map((variant) => variant.sidebar),
|
|
168
|
+
];
|
|
169
|
+
const diagnostics: Diagnostic[] = [];
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
for (const sidebar of sidebars) {
|
|
172
|
+
for (const node of flattenNodes(sidebar)) {
|
|
173
|
+
if (
|
|
174
|
+
node.kind === "page" &&
|
|
175
|
+
hidden.has(node.pageId) &&
|
|
176
|
+
!seen.has(node.pageId)
|
|
177
|
+
) {
|
|
178
|
+
seen.add(node.pageId);
|
|
179
|
+
diagnostics.push({
|
|
180
|
+
code: "BLUME_NAV_HIDDEN_IN_SIDEBAR",
|
|
181
|
+
message: `Page "${node.label}" is marked hidden but appears in the sidebar (and its pagination).`,
|
|
182
|
+
severity: "warning",
|
|
183
|
+
suggestion:
|
|
184
|
+
"Remove it from the navigation config, or unset sidebar.hidden.",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return diagnostics;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Structural navigation diagnostics that need only the built navigation +
|
|
194
|
+
* content pages: duplicate sidebar labels at a level, and hidden pages that
|
|
195
|
+
* still surface in the sidebar (so pagination lands on them). Target existence
|
|
196
|
+
* is checked separately by {@link validateNavTargets}, which needs the full
|
|
197
|
+
* route set.
|
|
198
|
+
*/
|
|
199
|
+
export const validateNavStructure = (
|
|
200
|
+
navigation: Navigation,
|
|
201
|
+
pages: PageRecord[]
|
|
202
|
+
): Diagnostic[] => [
|
|
203
|
+
...duplicateLabelDiagnostics(navigation),
|
|
204
|
+
...hiddenInSidebarDiagnostics(navigation, pages),
|
|
205
|
+
];
|
|
@@ -24,6 +24,41 @@ import type {
|
|
|
24
24
|
/** Build mode: drafts are kept in `dev` and dropped in `build`. */
|
|
25
25
|
export type BuildMode = "dev" | "build";
|
|
26
26
|
|
|
27
|
+
/** CLI-supplied overrides applied over the loaded config (see `scanProject`). */
|
|
28
|
+
export interface ConfigOverrides {
|
|
29
|
+
/** Override `content.root` (`blume dev --content-dir`). */
|
|
30
|
+
contentRoot?: string;
|
|
31
|
+
/** Override `deployment.adapter` (`blume build --adapter`). */
|
|
32
|
+
adapter?: ResolvedConfig["deployment"]["adapter"];
|
|
33
|
+
/** Override `deployment.base` (`blume build --base`). */
|
|
34
|
+
base?: string;
|
|
35
|
+
/** Override `deployment.output` (`blume build --output`). */
|
|
36
|
+
output?: ResolvedConfig["deployment"]["output"];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Apply CLI config overrides onto a resolved config (returns a new object). */
|
|
40
|
+
const applyConfigOverrides = (
|
|
41
|
+
config: ResolvedConfig,
|
|
42
|
+
overrides?: ConfigOverrides
|
|
43
|
+
): ResolvedConfig => {
|
|
44
|
+
if (!overrides) {
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...config,
|
|
49
|
+
content: {
|
|
50
|
+
...config.content,
|
|
51
|
+
root: overrides.contentRoot ?? config.content.root,
|
|
52
|
+
},
|
|
53
|
+
deployment: {
|
|
54
|
+
...config.deployment,
|
|
55
|
+
adapter: overrides.adapter ?? config.deployment.adapter,
|
|
56
|
+
base: overrides.base ?? config.deployment.base,
|
|
57
|
+
output: overrides.output ?? config.deployment.output,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
27
62
|
/** Everything Blume knows about a project after a full scan. */
|
|
28
63
|
export interface BlumeProject {
|
|
29
64
|
mode: BuildMode;
|
|
@@ -51,13 +86,17 @@ export const scanProject = async (
|
|
|
51
86
|
mode?: BuildMode;
|
|
52
87
|
preview?: boolean;
|
|
53
88
|
refresh?: boolean;
|
|
89
|
+
/** CLI overrides applied over the loaded config (e.g. `--output`). */
|
|
90
|
+
overrides?: ConfigOverrides;
|
|
54
91
|
} = {}
|
|
55
92
|
): Promise<BlumeProject> => {
|
|
56
93
|
const mode = options.mode ?? "dev";
|
|
57
94
|
const preview = options.preview ?? false;
|
|
58
|
-
const
|
|
95
|
+
const configResult = await loadConfig(root, {
|
|
59
96
|
devServerUrl: options.devServerUrl,
|
|
60
97
|
});
|
|
98
|
+
const { bridge } = configResult;
|
|
99
|
+
const config = applyConfigOverrides(configResult.config, options.overrides);
|
|
61
100
|
const context = resolveProjectContext(root, config);
|
|
62
101
|
|
|
63
102
|
// Each source validates itself (e.g. the filesystem source checks its root
|