blume 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/blume.mjs +18 -0
- package/dist/cli/index.js +11989 -0
- package/dist/cli/index.js.map +141 -0
- package/docs/01-quickstart.mdx +99 -0
- package/docs/02-deployment.mdx +129 -0
- package/docs/advanced/api-reference.mdx +114 -0
- package/docs/advanced/blog.mdx +121 -0
- package/docs/advanced/changelog.mdx +113 -0
- package/docs/advanced/custom-pages.mdx +187 -0
- package/docs/advanced/meta.ts +7 -0
- package/docs/changelog/v0-1-0.mdx +12 -0
- package/docs/changelog/v0-2-0.mdx +16 -0
- package/docs/configuration/ai.mdx +228 -0
- package/docs/configuration/analytics.mdx +98 -0
- package/docs/configuration/customization.mdx +91 -0
- package/docs/configuration/export.mdx +70 -0
- package/docs/configuration/index.mdx +271 -0
- package/docs/configuration/meta.ts +15 -0
- package/docs/configuration/search.mdx +172 -0
- package/docs/configuration/seo.mdx +196 -0
- package/docs/configuration/theming.mdx +178 -0
- package/docs/content/components.mdx +565 -0
- package/docs/content/i18n.mdx +205 -0
- package/docs/content/index.mdx +161 -0
- package/docs/content/islands.mdx +94 -0
- package/docs/content/meta.mdx +119 -0
- package/docs/content/meta.ts +15 -0
- package/docs/content/navigation.mdx +168 -0
- package/docs/content/sources.mdx +216 -0
- package/docs/content/syntax.mdx +445 -0
- package/docs/index.mdx +112 -0
- package/docs/reference/cli.mdx +43 -0
- package/docs/reference/frontmatter.mdx +74 -0
- package/docs/reference/meta.ts +7 -0
- package/package.json +140 -6
- package/src/ai/ask.ts +93 -0
- package/src/ai/llms.ts +65 -0
- package/src/ai/markdown.ts +31 -0
- package/src/ai/mcp/data.ts +74 -0
- package/src/ai/mcp/discovery.ts +49 -0
- package/src/ai/mcp/server.ts +225 -0
- package/src/ai/mcp/tools.ts +47 -0
- package/src/assets/icon.png +0 -0
- package/src/astro/generate.ts +878 -0
- package/src/astro/index.ts +4 -0
- package/src/astro/integration.ts +74 -0
- package/src/astro/islands.ts +131 -0
- package/src/astro/markdown-negotiation.ts +68 -0
- package/src/astro/pages.ts +28 -0
- package/src/astro/templates.ts +1199 -0
- package/src/cli/commands/add.ts +81 -0
- package/src/cli/commands/build.ts +103 -0
- package/src/cli/commands/dev.ts +108 -0
- package/src/cli/commands/doctor.ts +74 -0
- package/src/cli/commands/eject.ts +57 -0
- package/src/cli/commands/init.ts +98 -0
- package/src/cli/commands/migrate.ts +39 -0
- package/src/cli/commands/preview.ts +39 -0
- package/src/cli/commands/sync.ts +52 -0
- package/src/cli/commands/validate.ts +60 -0
- package/src/cli/index.ts +35 -0
- package/src/cli/log.ts +37 -0
- package/src/cli/prepare.ts +80 -0
- package/src/components/Icon.astro +99 -0
- package/src/components/content/Accordion.astro +8 -0
- package/src/components/content/AccordionItem.astro +121 -0
- package/src/components/content/AutoTypeTable.astro +51 -0
- package/src/components/content/Badge.astro +124 -0
- package/src/components/content/Callout.astro +73 -0
- package/src/components/content/Card.astro +104 -0
- package/src/components/content/CardGroup.astro +14 -0
- package/src/components/content/CodeGroup.astro +13 -0
- package/src/components/content/Color.astro +15 -0
- package/src/components/content/ColorItem.astro +87 -0
- package/src/components/content/ColorRow.astro +10 -0
- package/src/components/content/Column.astro +6 -0
- package/src/components/content/Columns.astro +9 -0
- package/src/components/content/Expandable.astro +11 -0
- package/src/components/content/FileTree.astro +8 -0
- package/src/components/content/Frame.astro +70 -0
- package/src/components/content/GithubInfo.astro +110 -0
- package/src/components/content/Math.astro +24 -0
- package/src/components/content/Panel.astro +20 -0
- package/src/components/content/Prompt.astro +129 -0
- package/src/components/content/Step.astro +34 -0
- package/src/components/content/Steps.astro +20 -0
- package/src/components/content/Tab.astro +40 -0
- package/src/components/content/Tabs.astro +273 -0
- package/src/components/content/Tile.astro +42 -0
- package/src/components/content/Tooltip.astro +68 -0
- package/src/components/content/Tree.astro +300 -0
- package/src/components/content/TreeFile.astro +15 -0
- package/src/components/content/TreeFolder.astro +62 -0
- package/src/components/content/TypeTable.astro +106 -0
- package/src/components/content/Update.astro +66 -0
- package/src/components/content/Visibility.astro +12 -0
- package/src/components/content/Warning.astro +9 -0
- package/src/components/content/auto-type-table.ts +141 -0
- package/src/components/content/github-info.ts +79 -0
- package/src/components/content/mermaid-element.ts +68 -0
- package/src/components/github-mark.ts +9 -0
- package/src/components/index.ts +14 -0
- package/src/components/islands/AskAI.astro +12 -0
- package/src/components/islands/ask-ai.tsx +156 -0
- package/src/components/layout/Analytics.astro +63 -0
- package/src/components/layout/Banner.astro +50 -0
- package/src/components/layout/Breadcrumbs.astro +31 -0
- package/src/components/layout/Favicon.astro +15 -0
- package/src/components/layout/Fonts.astro +14 -0
- package/src/components/layout/Header.astro +188 -0
- package/src/components/layout/LanguageSwitcher.astro +56 -0
- package/src/components/layout/NavTree.astro +462 -0
- package/src/components/layout/PageActions.astro +438 -0
- package/src/components/layout/PageFeedback.astro +58 -0
- package/src/components/layout/Pagination.astro +56 -0
- package/src/components/layout/ReferenceLayout.astro +102 -0
- package/src/components/layout/RootLayout.astro +533 -0
- package/src/components/layout/Search.astro +608 -0
- package/src/components/layout/TableOfContents.astro +68 -0
- package/src/components/layout/analytics-client.ts +38 -0
- package/src/components/layout/nav-utils.ts +87 -0
- package/src/components/layout/overrides.ts +32 -0
- package/src/components/layout/search/algolia.ts +43 -0
- package/src/components/layout/search/endpoint.ts +22 -0
- package/src/components/layout/search/flexsearch.ts +52 -0
- package/src/components/layout/search/orama-cloud.ts +41 -0
- package/src/components/layout/search/orama.ts +26 -0
- package/src/components/layout/search/pagefind.ts +43 -0
- package/src/components/layout/search/types.ts +163 -0
- package/src/components/layout/search/typesense.ts +60 -0
- package/src/components/layout/toc-element.ts +108 -0
- package/src/core/bridge.ts +92 -0
- package/src/core/config.ts +112 -0
- package/src/core/content.ts +50 -0
- package/src/core/define-components.ts +34 -0
- package/src/core/define-meta.ts +20 -0
- package/src/core/deployment-env.ts +73 -0
- package/src/core/diagnostics.ts +104 -0
- package/src/core/graph.ts +128 -0
- package/src/core/i18n-ui.ts +171 -0
- package/src/core/i18n.ts +169 -0
- package/src/core/last-modified.ts +88 -0
- package/src/core/links.ts +336 -0
- package/src/core/load-module.ts +15 -0
- package/src/core/manifest.ts +126 -0
- package/src/core/meta.ts +97 -0
- package/src/core/navigation.ts +392 -0
- package/src/core/package-root.ts +37 -0
- package/src/core/project-graph.ts +153 -0
- package/src/core/project.ts +56 -0
- package/src/core/schema.ts +1057 -0
- package/src/core/server-features.ts +23 -0
- package/src/core/sources/assets.ts +77 -0
- package/src/core/sources/cache.ts +122 -0
- package/src/core/sources/filesystem.ts +99 -0
- package/src/core/sources/mdx-remote.ts +216 -0
- package/src/core/sources/mintlify.ts +161 -0
- package/src/core/sources/normalize.ts +227 -0
- package/src/core/sources/notion.ts +440 -0
- package/src/core/sources/portable-text.ts +143 -0
- package/src/core/sources/read.ts +36 -0
- package/src/core/sources/resolve.ts +158 -0
- package/src/core/sources/sanity.ts +218 -0
- package/src/core/sources/types.ts +105 -0
- package/src/core/types.ts +261 -0
- package/src/core/ui-packs/ar.ts +47 -0
- package/src/core/ui-packs/bg.ts +47 -0
- package/src/core/ui-packs/bn.ts +47 -0
- package/src/core/ui-packs/ca.ts +47 -0
- package/src/core/ui-packs/cs.ts +47 -0
- package/src/core/ui-packs/da.ts +47 -0
- package/src/core/ui-packs/de.ts +47 -0
- package/src/core/ui-packs/el.ts +47 -0
- package/src/core/ui-packs/es.ts +47 -0
- package/src/core/ui-packs/fa.ts +47 -0
- package/src/core/ui-packs/fi.ts +47 -0
- package/src/core/ui-packs/fr.ts +47 -0
- package/src/core/ui-packs/he.ts +47 -0
- package/src/core/ui-packs/hi.ts +47 -0
- package/src/core/ui-packs/hr.ts +47 -0
- package/src/core/ui-packs/hu.ts +47 -0
- package/src/core/ui-packs/id.ts +47 -0
- package/src/core/ui-packs/index.ts +87 -0
- package/src/core/ui-packs/it.ts +47 -0
- package/src/core/ui-packs/ja.ts +47 -0
- package/src/core/ui-packs/ko.ts +47 -0
- package/src/core/ui-packs/nl.ts +47 -0
- package/src/core/ui-packs/no.ts +47 -0
- package/src/core/ui-packs/pl.ts +47 -0
- package/src/core/ui-packs/pt-br.ts +47 -0
- package/src/core/ui-packs/pt.ts +47 -0
- package/src/core/ui-packs/ro.ts +47 -0
- package/src/core/ui-packs/ru.ts +47 -0
- package/src/core/ui-packs/sk.ts +47 -0
- package/src/core/ui-packs/sr.ts +47 -0
- package/src/core/ui-packs/sv.ts +47 -0
- package/src/core/ui-packs/th.ts +47 -0
- package/src/core/ui-packs/tr.ts +47 -0
- package/src/core/ui-packs/uk.ts +47 -0
- package/src/core/ui-packs/vi.ts +47 -0
- package/src/core/ui-packs/zh-tw.ts +47 -0
- package/src/core/ui-packs/zh.ts +47 -0
- package/src/core/version.ts +23 -0
- package/src/deploy/robots.ts +20 -0
- package/src/deploy/rss.ts +128 -0
- package/src/deploy/sitemap.ts +28 -0
- package/src/index.ts +27 -0
- package/src/markdown/code-title.ts +71 -0
- package/src/markdown/directives.ts +83 -0
- package/src/markdown/heading-anchors.ts +137 -0
- package/src/markdown/index.ts +159 -0
- package/src/markdown/inline-code.ts +108 -0
- package/src/markdown/language-icon.ts +172 -0
- package/src/markdown/math.ts +32 -0
- package/src/markdown/mdast.ts +48 -0
- package/src/markdown/mermaid.ts +37 -0
- package/src/markdown/package-commands.ts +159 -0
- package/src/markdown/package-install.ts +40 -0
- package/src/migrate/fumadocs/config.ts +106 -0
- package/src/migrate/fumadocs/content.ts +365 -0
- package/src/migrate/fumadocs/frontmatter.ts +18 -0
- package/src/migrate/fumadocs/index.ts +252 -0
- package/src/migrate/fumadocs/meta.ts +114 -0
- package/src/migrate/migrate.ts +53 -0
- package/src/migrate/mintlify/config.ts +1040 -0
- package/src/migrate/mintlify/content.ts +98 -0
- package/src/migrate/mintlify/frontmatter.ts +126 -0
- package/src/migrate/mintlify/i18n.ts +51 -0
- package/src/migrate/mintlify/icons.ts +128 -0
- package/src/migrate/mintlify/index.ts +266 -0
- package/src/migrate/mintlify/snippets.ts +305 -0
- package/src/migrate/mintlify/transform.ts +81 -0
- package/src/migrate/nextra/content.ts +46 -0
- package/src/migrate/nextra/frontmatter.ts +40 -0
- package/src/migrate/nextra/index.ts +374 -0
- package/src/migrate/nextra/meta.ts +266 -0
- package/src/migrate/shared.ts +623 -0
- package/src/migrate/starlight/config.ts +459 -0
- package/src/migrate/starlight/content.ts +78 -0
- package/src/migrate/starlight/frontmatter.ts +111 -0
- package/src/migrate/starlight/i18n.ts +54 -0
- package/src/migrate/starlight/index.ts +131 -0
- package/src/og/card.ts +92 -0
- package/src/og/index.ts +2 -0
- package/src/openapi/scalar.ts +246 -0
- package/src/registry/eject.ts +263 -0
- package/src/registry/registry.ts +100 -0
- package/src/registry/rewrite-imports.ts +39 -0
- package/src/runtime/index.ts +14 -0
- package/src/search/build.ts +23 -0
- package/src/search/documents.ts +165 -0
- package/src/search/orama-index.ts +66 -0
- package/src/search/providers.ts +91 -0
- package/src/search/sync/algolia.ts +30 -0
- package/src/search/sync/index.ts +50 -0
- package/src/search/sync/orama-cloud.ts +40 -0
- package/src/search/sync/typesense.ts +65 -0
- package/src/seo/jsonld.ts +113 -0
- package/src/theme/entry.ts +608 -0
- package/src/theme/fonts.ts +198 -0
- package/src/theme/icons.ts +184 -0
- package/src/theme/palette.ts +143 -0
- package/src/theme/twoslash.ts +81 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { join } from "pathe";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ContentGraph,
|
|
7
|
+
Diagnostic,
|
|
8
|
+
PageLink,
|
|
9
|
+
PageRecord,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const HTTP = /^https?:\/\//iu;
|
|
13
|
+
const PROTOCOL_RELATIVE = /^\/\//u;
|
|
14
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
|
|
15
|
+
const DOC_EXT = /\.(?:md|mdx)$/iu;
|
|
16
|
+
const FILE_EXT = /\.[a-z0-9]+$/iu;
|
|
17
|
+
|
|
18
|
+
const EXTERNAL_CONCURRENCY = 8;
|
|
19
|
+
const EXTERNAL_TIMEOUT_MS = 10_000;
|
|
20
|
+
const STATUS_NOT_FOUND = 404;
|
|
21
|
+
const STATUS_GONE = 410;
|
|
22
|
+
const STATUS_METHOD_NOT_ALLOWED = 405;
|
|
23
|
+
const STATUS_NOT_IMPLEMENTED = 501;
|
|
24
|
+
|
|
25
|
+
/** Source position shared by every diagnostic raised for a link. */
|
|
26
|
+
interface LinkSite {
|
|
27
|
+
column: number;
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An external link occurrence queued for a network probe. */
|
|
33
|
+
interface ExternalRef extends LinkSite {
|
|
34
|
+
url: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Lookups derived once from the content graph. */
|
|
38
|
+
interface LinkContext {
|
|
39
|
+
anchors: Map<string, Set<string>>;
|
|
40
|
+
publicDir: string | null;
|
|
41
|
+
routes: Set<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Outcome of classifying one link target. */
|
|
45
|
+
type LinkResult = Diagnostic | "asset-unchecked" | null;
|
|
46
|
+
|
|
47
|
+
/** Resolve a relative link target against the directory of a page route. */
|
|
48
|
+
const resolveRelative = (pageRoute: string, target: string): string => {
|
|
49
|
+
const segments = pageRoute.split("/").filter(Boolean);
|
|
50
|
+
// Drop the page's own segment so links resolve against its parent directory.
|
|
51
|
+
segments.pop();
|
|
52
|
+
for (const part of target.split("/")) {
|
|
53
|
+
if (part === "" || part === ".") {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (part === "..") {
|
|
57
|
+
segments.pop();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
segments.push(part);
|
|
61
|
+
}
|
|
62
|
+
return `/${segments.join("/")}`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Normalize an internal path to its canonical route form. */
|
|
66
|
+
const toRoute = (path: string): string => {
|
|
67
|
+
let route = path.replace(DOC_EXT, "");
|
|
68
|
+
if (route.endsWith("/index")) {
|
|
69
|
+
route = route.slice(0, -"/index".length);
|
|
70
|
+
}
|
|
71
|
+
if (route.length > 1 && route.endsWith("/")) {
|
|
72
|
+
route = route.slice(0, -1);
|
|
73
|
+
}
|
|
74
|
+
return route === "" ? "/" : route;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Build a map of route -> set of heading anchor slugs. */
|
|
78
|
+
const buildAnchorIndex = (pages: PageRecord[]): Map<string, Set<string>> => {
|
|
79
|
+
const anchors = new Map<string, Set<string>>();
|
|
80
|
+
for (const page of pages) {
|
|
81
|
+
anchors.set(
|
|
82
|
+
page.route,
|
|
83
|
+
new Set(page.headings.map((heading) => heading.slug))
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return anchors;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Verify a fragment resolves to a heading on the target route. */
|
|
90
|
+
const checkAnchor = (
|
|
91
|
+
route: string,
|
|
92
|
+
fragment: string,
|
|
93
|
+
site: LinkSite,
|
|
94
|
+
ctx: LinkContext
|
|
95
|
+
): Diagnostic | null => {
|
|
96
|
+
if (ctx.anchors.get(route)?.has(fragment.toLowerCase())) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
...site,
|
|
101
|
+
code: "BLUME_BROKEN_ANCHOR",
|
|
102
|
+
message: `No heading on ${route} matches anchor #${fragment}.`,
|
|
103
|
+
severity: "warning",
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Validate a resolved internal path: asset, route, then optional anchor. */
|
|
108
|
+
const checkPathLink = (
|
|
109
|
+
resolved: string,
|
|
110
|
+
fragment: string,
|
|
111
|
+
target: string,
|
|
112
|
+
site: LinkSite,
|
|
113
|
+
ctx: LinkContext
|
|
114
|
+
): LinkResult => {
|
|
115
|
+
if (FILE_EXT.test(resolved) && !DOC_EXT.test(resolved)) {
|
|
116
|
+
if (ctx.publicDir === null) {
|
|
117
|
+
return "asset-unchecked";
|
|
118
|
+
}
|
|
119
|
+
if (existsSync(join(ctx.publicDir, resolved))) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
...site,
|
|
124
|
+
code: "BLUME_BROKEN_ASSET",
|
|
125
|
+
message: `Asset ${resolved} was not found in the public directory.`,
|
|
126
|
+
severity: "warning",
|
|
127
|
+
suggestion: `Add the file at public${resolved} or fix the link.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const route = toRoute(resolved);
|
|
132
|
+
if (!ctx.routes.has(route)) {
|
|
133
|
+
return {
|
|
134
|
+
...site,
|
|
135
|
+
code: "BLUME_BROKEN_LINK",
|
|
136
|
+
message: `Broken link to ${target}: no page resolves to ${route}.`,
|
|
137
|
+
severity: "error",
|
|
138
|
+
suggestion: "Check the path, or create the target page.",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return fragment ? checkAnchor(route, fragment, site, ctx) : null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Probe a URL with the given method, normalizing failures to a result. */
|
|
145
|
+
const request = async (
|
|
146
|
+
url: string,
|
|
147
|
+
method: "GET" | "HEAD"
|
|
148
|
+
): Promise<{
|
|
149
|
+
ok: boolean;
|
|
150
|
+
status?: number;
|
|
151
|
+
timedOut?: boolean;
|
|
152
|
+
error?: string;
|
|
153
|
+
}> => {
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const timer = setTimeout(() => controller.abort(), EXTERNAL_TIMEOUT_MS);
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(url, {
|
|
158
|
+
method,
|
|
159
|
+
redirect: "follow",
|
|
160
|
+
signal: controller.signal,
|
|
161
|
+
});
|
|
162
|
+
return { ok: response.ok, status: response.status };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
165
|
+
return { ok: false, timedOut: true };
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
error: error instanceof Error ? error.message : String(error),
|
|
169
|
+
ok: false,
|
|
170
|
+
};
|
|
171
|
+
} finally {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Probe a single URL: HEAD first, falling back to GET when needed. */
|
|
177
|
+
const probe = async (
|
|
178
|
+
url: string
|
|
179
|
+
): Promise<Awaited<ReturnType<typeof request>>> => {
|
|
180
|
+
const head = await request(url, "HEAD");
|
|
181
|
+
const retry =
|
|
182
|
+
head.status === STATUS_METHOD_NOT_ALLOWED ||
|
|
183
|
+
head.status === STATUS_NOT_IMPLEMENTED ||
|
|
184
|
+
(!head.ok && head.status === undefined && !head.timedOut);
|
|
185
|
+
return retry ? await request(url, "GET") : head;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Grade a probe result into a diagnostic severity + detail, or null if OK. */
|
|
189
|
+
const gradeExternal = (
|
|
190
|
+
result: Awaited<ReturnType<typeof request>>
|
|
191
|
+
): { severity: Diagnostic["severity"]; detail: string } | null => {
|
|
192
|
+
if (result.ok) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (result.timedOut) {
|
|
196
|
+
return { detail: "request timed out", severity: "warning" };
|
|
197
|
+
}
|
|
198
|
+
if (result.status === undefined) {
|
|
199
|
+
return { detail: result.error ?? "unreachable", severity: "error" };
|
|
200
|
+
}
|
|
201
|
+
if (result.status === STATUS_NOT_FOUND || result.status === STATUS_GONE) {
|
|
202
|
+
return { detail: `HTTP ${result.status}`, severity: "error" };
|
|
203
|
+
}
|
|
204
|
+
return { detail: `HTTP ${result.status}`, severity: "warning" };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Probe queued external links with bounded concurrency. */
|
|
208
|
+
const checkExternalLinks = async (
|
|
209
|
+
refs: ExternalRef[]
|
|
210
|
+
): Promise<Diagnostic[]> => {
|
|
211
|
+
const unique = [...new Set(refs.map((ref) => ref.url))];
|
|
212
|
+
const results = new Map<string, Awaited<ReturnType<typeof probe>>>();
|
|
213
|
+
|
|
214
|
+
let cursor = 0;
|
|
215
|
+
const worker = async (): Promise<void> => {
|
|
216
|
+
while (cursor < unique.length) {
|
|
217
|
+
const url = unique[cursor];
|
|
218
|
+
cursor += 1;
|
|
219
|
+
if (url !== undefined) {
|
|
220
|
+
// oxlint-disable-next-line no-await-in-loop -- bounded-concurrency pool
|
|
221
|
+
results.set(url, await probe(url));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
await Promise.all(
|
|
226
|
+
Array.from(
|
|
227
|
+
{ length: Math.min(EXTERNAL_CONCURRENCY, unique.length) },
|
|
228
|
+
worker
|
|
229
|
+
)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const diagnostics: Diagnostic[] = [];
|
|
233
|
+
for (const ref of refs) {
|
|
234
|
+
const result = results.get(ref.url);
|
|
235
|
+
const grade = result ? gradeExternal(result) : null;
|
|
236
|
+
if (grade) {
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
code: "BLUME_DEAD_LINK",
|
|
239
|
+
column: ref.column,
|
|
240
|
+
file: ref.file,
|
|
241
|
+
line: ref.line,
|
|
242
|
+
message: `External link ${ref.url} is unreachable (${grade.detail}).`,
|
|
243
|
+
severity: grade.severity,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return diagnostics;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** Classify a single link, queueing external refs via `onExternal`. */
|
|
251
|
+
const classifyLink = (
|
|
252
|
+
page: PageRecord,
|
|
253
|
+
link: PageLink,
|
|
254
|
+
ctx: LinkContext,
|
|
255
|
+
onExternal: (ref: ExternalRef) => void
|
|
256
|
+
): LinkResult => {
|
|
257
|
+
const { target } = link;
|
|
258
|
+
const site: LinkSite = {
|
|
259
|
+
column: link.column,
|
|
260
|
+
file: page.sourcePath ?? page.id,
|
|
261
|
+
line: link.line,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
if (HTTP.test(target) || PROTOCOL_RELATIVE.test(target)) {
|
|
265
|
+
onExternal({
|
|
266
|
+
...site,
|
|
267
|
+
url: PROTOCOL_RELATIVE.test(target) ? `https:${target}` : target,
|
|
268
|
+
});
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
if (SCHEME.test(target)) {
|
|
272
|
+
// mailto:, tel:, and other non-HTTP schemes are not validated.
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const hashIndex = target.indexOf("#");
|
|
277
|
+
const fragment = hashIndex === -1 ? "" : target.slice(hashIndex + 1);
|
|
278
|
+
let rawPath = hashIndex === -1 ? target : target.slice(0, hashIndex);
|
|
279
|
+
const queryIndex = rawPath.indexOf("?");
|
|
280
|
+
if (queryIndex !== -1) {
|
|
281
|
+
rawPath = rawPath.slice(0, queryIndex);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (rawPath === "") {
|
|
285
|
+
return fragment ? checkAnchor(page.route, fragment, site, ctx) : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const resolved = rawPath.startsWith("/")
|
|
289
|
+
? rawPath
|
|
290
|
+
: resolveRelative(page.route, rawPath);
|
|
291
|
+
return checkPathLink(resolved, fragment, target, site, ctx);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Validate every link discovered in the content graph: internal page links and
|
|
296
|
+
* anchors against the route map, asset links against the public dir, and
|
|
297
|
+
* (opt-in) external links over the network.
|
|
298
|
+
*/
|
|
299
|
+
export const validateLinks = async (
|
|
300
|
+
graph: ContentGraph,
|
|
301
|
+
options: { publicDir: string | null; checkExternal?: boolean }
|
|
302
|
+
): Promise<Diagnostic[]> => {
|
|
303
|
+
const ctx: LinkContext = {
|
|
304
|
+
anchors: buildAnchorIndex(graph.pages),
|
|
305
|
+
publicDir: options.publicDir,
|
|
306
|
+
routes: new Set(graph.routes.keys()),
|
|
307
|
+
};
|
|
308
|
+
const diagnostics: Diagnostic[] = [];
|
|
309
|
+
const external: ExternalRef[] = [];
|
|
310
|
+
let uncheckedAssets = 0;
|
|
311
|
+
|
|
312
|
+
for (const page of graph.pages) {
|
|
313
|
+
for (const link of page.links) {
|
|
314
|
+
const result = classifyLink(page, link, ctx, (ref) => external.push(ref));
|
|
315
|
+
if (result === "asset-unchecked") {
|
|
316
|
+
uncheckedAssets += 1;
|
|
317
|
+
} else if (result) {
|
|
318
|
+
diagnostics.push(result);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (uncheckedAssets > 0) {
|
|
324
|
+
diagnostics.push({
|
|
325
|
+
code: "BLUME_ASSETS_UNCHECKED",
|
|
326
|
+
message: `${uncheckedAssets} asset link(s) not checked: no public/ directory found.`,
|
|
327
|
+
severity: "info",
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (options.checkExternal && external.length > 0) {
|
|
332
|
+
diagnostics.push(...(await checkExternalLinks(external)));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return diagnostics;
|
|
336
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createJiti } from "jiti";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a loader for user-authored ESM/TS modules (`blume.config.ts`,
|
|
5
|
+
* `meta.ts`). One jiti instance is reused across every file the returned loader
|
|
6
|
+
* is called with. `moduleCache: false` ensures edits are picked up on each load,
|
|
7
|
+
* which is what makes dev-server regeneration reflect config/meta changes.
|
|
8
|
+
*/
|
|
9
|
+
export const createModuleLoader = (): ((file: string) => Promise<unknown>) => {
|
|
10
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
|
11
|
+
return async (file: string) => {
|
|
12
|
+
const loaded = await jiti.import<{ default?: unknown }>(file);
|
|
13
|
+
return loaded?.default ?? loaded;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { localizeRoute, resolveFallbackLocale } from "./i18n.ts";
|
|
2
|
+
import type { ResolvedConfig } from "./schema.ts";
|
|
3
|
+
import type {
|
|
4
|
+
BlumeManifest,
|
|
5
|
+
ContentGraph,
|
|
6
|
+
PageRecord,
|
|
7
|
+
ProjectContext,
|
|
8
|
+
RouteAlternate,
|
|
9
|
+
RouteManifestEntry,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
import { getBlumeVersion } from "./version.ts";
|
|
12
|
+
|
|
13
|
+
/** The current manifest schema version. */
|
|
14
|
+
export const MANIFEST_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a page may be indexed on its own merits — not author-excluded and not
|
|
18
|
+
* hidden (unless hidden pages are opted in). This is independent of whether the
|
|
19
|
+
* site search provider is enabled, so features like the MCP server can index
|
|
20
|
+
* docs even when on-page search is off.
|
|
21
|
+
*/
|
|
22
|
+
export const contentIndexable = (
|
|
23
|
+
page: PageRecord,
|
|
24
|
+
config: ResolvedConfig
|
|
25
|
+
): boolean =>
|
|
26
|
+
!page.meta.search.exclude &&
|
|
27
|
+
(!page.meta.sidebar.hidden || config.search.indexing.includeHiddenPages);
|
|
28
|
+
|
|
29
|
+
/** Build the runtime manifest that bridges core and the generated Astro app. */
|
|
30
|
+
export const buildManifest = (options: {
|
|
31
|
+
context: ProjectContext;
|
|
32
|
+
config: ResolvedConfig;
|
|
33
|
+
graph: ContentGraph;
|
|
34
|
+
}): BlumeManifest => {
|
|
35
|
+
const { context, config, graph } = options;
|
|
36
|
+
const searchEnabled = config.search.provider !== "none";
|
|
37
|
+
const { i18n } = config;
|
|
38
|
+
|
|
39
|
+
// Real translations per logical page, for `hreflang` and the switcher. Built
|
|
40
|
+
// only under i18n; a single-locale page has no alternates.
|
|
41
|
+
const alternatesByKey = new Map<string, RouteAlternate[]>();
|
|
42
|
+
if (i18n) {
|
|
43
|
+
for (const page of graph.pages) {
|
|
44
|
+
const list = alternatesByKey.get(page.translationKey) ?? [];
|
|
45
|
+
list.push({ locale: page.locale, path: page.route });
|
|
46
|
+
alternatesByKey.set(page.translationKey, list);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const routes: RouteManifestEntry[] = graph.pages.map((page) => ({
|
|
51
|
+
alternates: alternatesByKey.get(page.translationKey) ?? [],
|
|
52
|
+
collection: page.collection ?? "docs",
|
|
53
|
+
contentType: page.contentType,
|
|
54
|
+
draft: page.meta.draft,
|
|
55
|
+
editUrl: page.editUrl,
|
|
56
|
+
entryId: page.entryId ?? page.source.ref,
|
|
57
|
+
hidden: page.meta.sidebar.hidden,
|
|
58
|
+
id: page.id,
|
|
59
|
+
indexable: searchEnabled && contentIndexable(page, config),
|
|
60
|
+
lastModified: page.lastModified,
|
|
61
|
+
locale: page.locale,
|
|
62
|
+
path: page.route,
|
|
63
|
+
source: page.source,
|
|
64
|
+
sourcePath: page.sourcePath,
|
|
65
|
+
title: page.title,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Fallback materialization: render the fallback locale's content at the
|
|
69
|
+
// localized URL for any translation a non-default locale is missing, so static
|
|
70
|
+
// output is fully prerendered (render-fallback, no client redirect). Fallback
|
|
71
|
+
// routes are not indexed and carry no `hreflang` of their own.
|
|
72
|
+
if (i18n) {
|
|
73
|
+
const fallback = resolveFallbackLocale(i18n);
|
|
74
|
+
if (fallback) {
|
|
75
|
+
const fallbackPages = new Map(
|
|
76
|
+
graph.pages
|
|
77
|
+
.filter((page) => page.locale === fallback)
|
|
78
|
+
.map((page) => [page.translationKey, page] as const)
|
|
79
|
+
);
|
|
80
|
+
for (const { code } of i18n.locales) {
|
|
81
|
+
if (code === fallback) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const present = new Set(
|
|
85
|
+
graph.pages
|
|
86
|
+
.filter((page) => page.locale === code)
|
|
87
|
+
.map((page) => page.translationKey)
|
|
88
|
+
);
|
|
89
|
+
for (const [key, source] of fallbackPages) {
|
|
90
|
+
if (present.has(key)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
routes.push({
|
|
94
|
+
alternates: alternatesByKey.get(key) ?? [],
|
|
95
|
+
collection: source.collection ?? "docs",
|
|
96
|
+
contentType: source.contentType,
|
|
97
|
+
draft: source.meta.draft,
|
|
98
|
+
editUrl: source.editUrl,
|
|
99
|
+
entryId: source.entryId ?? source.source.ref,
|
|
100
|
+
fallback: true,
|
|
101
|
+
hidden: source.meta.sidebar.hidden,
|
|
102
|
+
id: source.id,
|
|
103
|
+
indexable: false,
|
|
104
|
+
lastModified: source.lastModified,
|
|
105
|
+
locale: code,
|
|
106
|
+
path: localizeRoute(key, code, i18n),
|
|
107
|
+
source: source.source,
|
|
108
|
+
sourcePath: source.sourcePath,
|
|
109
|
+
title: source.title,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
routes.sort((a, b) => a.path.localeCompare(b.path));
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
blumeVersion: getBlumeVersion(),
|
|
120
|
+
contentRoot: context.contentRoot,
|
|
121
|
+
output: config.deployment.output,
|
|
122
|
+
projectRoot: context.root,
|
|
123
|
+
routes,
|
|
124
|
+
version: MANIFEST_VERSION,
|
|
125
|
+
};
|
|
126
|
+
};
|
package/src/core/meta.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { basename, dirname, relative } from "pathe";
|
|
2
|
+
import { glob } from "tinyglobby";
|
|
3
|
+
|
|
4
|
+
import { diagnosticsFromZod } from "./diagnostics.ts";
|
|
5
|
+
import { createModuleLoader } from "./load-module.ts";
|
|
6
|
+
import { folderMetaSchema } from "./schema.ts";
|
|
7
|
+
import type { FolderMeta } from "./schema.ts";
|
|
8
|
+
import type { Diagnostic } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const META_FILES = [
|
|
11
|
+
"**/meta.ts",
|
|
12
|
+
"**/meta.js",
|
|
13
|
+
"**/meta.mjs",
|
|
14
|
+
// Shared, locale-agnostic folder meta (applies to every locale).
|
|
15
|
+
"**/meta.$.ts",
|
|
16
|
+
"**/meta.$.js",
|
|
17
|
+
"**/meta.$.mjs",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Resolve a meta module's default export, calling it if it is a factory. */
|
|
21
|
+
const resolveMeta = async (mod: unknown): Promise<unknown> =>
|
|
22
|
+
typeof mod === "function" ? await (mod as () => unknown)() : mod;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Discover `meta.{ts,js,mjs}` files under the content root. Keys are the
|
|
26
|
+
* directory path relative to the content root (`""` for the root directory).
|
|
27
|
+
* `meta.$.*` files are returned in `shared` — folder meta that applies to that
|
|
28
|
+
* directory in every locale (a locale-specific `meta.*` overrides it). Each file
|
|
29
|
+
* default-exports an object or a (sync/async) function returning one.
|
|
30
|
+
*/
|
|
31
|
+
export const discoverFolderMeta = async (
|
|
32
|
+
contentRoot: string
|
|
33
|
+
): Promise<{
|
|
34
|
+
meta: Map<string, FolderMeta>;
|
|
35
|
+
shared: Map<string, FolderMeta>;
|
|
36
|
+
diagnostics: Diagnostic[];
|
|
37
|
+
}> => {
|
|
38
|
+
const files = await glob(META_FILES, {
|
|
39
|
+
absolute: true,
|
|
40
|
+
cwd: contentRoot,
|
|
41
|
+
// Never descend into dependencies or build output — relevant when the
|
|
42
|
+
// content root is the project root (e.g. a migrated Mintlify project).
|
|
43
|
+
ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
|
|
44
|
+
onlyFiles: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
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
|
+
const meta = new Map<string, FolderMeta>();
|
|
66
|
+
const shared = new Map<string, FolderMeta>();
|
|
67
|
+
const diagnostics: Diagnostic[] = [];
|
|
68
|
+
|
|
69
|
+
for (const entry of loaded) {
|
|
70
|
+
const dir = relative(contentRoot, dirname(entry.file));
|
|
71
|
+
|
|
72
|
+
if (!entry.ok) {
|
|
73
|
+
diagnostics.push({
|
|
74
|
+
code: "BLUME_META_LOAD_FAILED",
|
|
75
|
+
file: entry.file,
|
|
76
|
+
message: `Could not load meta file: ${entry.error.message}`,
|
|
77
|
+
severity: "error",
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = folderMetaSchema.safeParse(entry.value);
|
|
83
|
+
if (result.success) {
|
|
84
|
+
const target = basename(entry.file).startsWith("meta.$.") ? shared : meta;
|
|
85
|
+
target.set(dir, result.data);
|
|
86
|
+
} else {
|
|
87
|
+
diagnostics.push(
|
|
88
|
+
...diagnosticsFromZod(result.error, {
|
|
89
|
+
code: "BLUME_META_INVALID",
|
|
90
|
+
file: entry.file,
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { diagnostics, meta, shared };
|
|
97
|
+
};
|