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,23 @@
|
|
|
1
|
+
import { searchProviderMeta } from "../search/providers.ts";
|
|
2
|
+
import type { ResolvedConfig } from "./schema.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* List the enabled features that require Astro server output. Static builds
|
|
6
|
+
* fail clearly when any of these are enabled.
|
|
7
|
+
*/
|
|
8
|
+
export const serverFeatures = (config: ResolvedConfig): string[] => {
|
|
9
|
+
const features: string[] = [];
|
|
10
|
+
if (config.ai.ask?.enabled) {
|
|
11
|
+
features.push("Ask AI");
|
|
12
|
+
}
|
|
13
|
+
// The hosted MCP server is a live JSON-RPC endpoint, so it needs a runtime.
|
|
14
|
+
if (config.mcp.enabled) {
|
|
15
|
+
features.push("MCP server");
|
|
16
|
+
}
|
|
17
|
+
// Mixedbread (and any future provider) that proxies queries through a secret
|
|
18
|
+
// server endpoint can't run on a static build.
|
|
19
|
+
if (searchProviderMeta(config.search.provider).requiresServer) {
|
|
20
|
+
features.push(`Search (${config.search.provider})`);
|
|
21
|
+
}
|
|
22
|
+
return features;
|
|
23
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { extname, join } from "pathe";
|
|
4
|
+
|
|
5
|
+
import type { Diagnostic } from "../types.ts";
|
|
6
|
+
import { hashText } from "./cache.ts";
|
|
7
|
+
|
|
8
|
+
const MD_IMAGE = /!\[(?<alt>[^\]]*)\]\((?<url>[^)\s]+)\)/gu;
|
|
9
|
+
const REMOTE = /^https?:\/\//u;
|
|
10
|
+
const SAFE_EXT = /^\.[a-z0-9]+$/iu;
|
|
11
|
+
|
|
12
|
+
/** Where to write downloaded assets and how to reference them publicly. */
|
|
13
|
+
export interface AssetContext {
|
|
14
|
+
assetsDir: string;
|
|
15
|
+
assetsBaseUrl: string;
|
|
16
|
+
/** Injected for tests; defaults to the global `fetch`. */
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Pick a file extension from a URL, defaulting to `.png`. */
|
|
21
|
+
const extFor = (url: string): string => {
|
|
22
|
+
const clean = url.split("?")[0] ?? url;
|
|
23
|
+
const ext = extname(clean);
|
|
24
|
+
return SAFE_EXT.test(ext) ? ext.toLowerCase() : ".png";
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Download remote images referenced in a Markdown body into the asset dir and
|
|
29
|
+
* rewrite their `src` to the local public path. Remote CMS URLs (notably
|
|
30
|
+
* Notion's signed, expiring links) would otherwise rot a static build. Assets
|
|
31
|
+
* are content-addressed by URL hash, so repeated builds are stable and deduped.
|
|
32
|
+
*/
|
|
33
|
+
export const materializeAssets = async (
|
|
34
|
+
markdown: string,
|
|
35
|
+
ctx: AssetContext
|
|
36
|
+
): Promise<{ markdown: string; diagnostics: Diagnostic[] }> => {
|
|
37
|
+
const doFetch = ctx.fetchImpl ?? globalThis.fetch;
|
|
38
|
+
const diagnostics: Diagnostic[] = [];
|
|
39
|
+
|
|
40
|
+
const urls = new Set<string>();
|
|
41
|
+
for (const match of markdown.matchAll(MD_IMAGE)) {
|
|
42
|
+
const url = match.groups?.url;
|
|
43
|
+
if (url && REMOTE.test(url)) {
|
|
44
|
+
urls.add(url);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rewrites = new Map<string, string>();
|
|
49
|
+
await Promise.all(
|
|
50
|
+
[...urls].map(async (url) => {
|
|
51
|
+
try {
|
|
52
|
+
const res = await doFetch(url);
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new Error(`${res.status}`);
|
|
55
|
+
}
|
|
56
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
57
|
+
const file = `${hashText(url)}${extFor(url)}`;
|
|
58
|
+
await mkdir(ctx.assetsDir, { recursive: true });
|
|
59
|
+
await writeFile(join(ctx.assetsDir, file), bytes);
|
|
60
|
+
rewrites.set(url, `${ctx.assetsBaseUrl}/${file}`);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
diagnostics.push({
|
|
63
|
+
code: "BLUME_ASSET_FETCH_FAILED",
|
|
64
|
+
message: `Failed to download asset ${url}: ${(error as Error).message}`,
|
|
65
|
+
severity: "warning",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const rewritten = markdown.replaceAll(MD_IMAGE, (match, alt, url) => {
|
|
72
|
+
const local = rewrites.get(url);
|
|
73
|
+
return local ? `` : match;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return { diagnostics, markdown: rewritten };
|
|
77
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { join } from "pathe";
|
|
4
|
+
|
|
5
|
+
import { BlumeError } from "../diagnostics.ts";
|
|
6
|
+
import type { Diagnostic } from "../types.ts";
|
|
7
|
+
import type { SourceEntry, SourceLoadResult } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Small, stable content hash for cache/HMR bookkeeping (non-bitwise). */
|
|
10
|
+
export const hashText = (text: string): string => {
|
|
11
|
+
let hash = 5381;
|
|
12
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
13
|
+
hash = (hash * 33 + (text.codePointAt(i) ?? 0)) % 2_147_483_647;
|
|
14
|
+
}
|
|
15
|
+
return hash.toString(36);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** A stable digest of a source's entries, for change detection while polling. */
|
|
19
|
+
export const entriesDigest = (entries: SourceEntry[]): string =>
|
|
20
|
+
hashText(
|
|
21
|
+
entries
|
|
22
|
+
.map((entry) => `${entry.ref}:${entry.hash ?? hashText(entry.body.text)}`)
|
|
23
|
+
.join("|")
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build an opt-in polling watcher for a remote source: re-`load()` on an
|
|
28
|
+
* interval and fire `onChange` only when the entry digest changes, so a remote
|
|
29
|
+
* source can hot-reload in dev without refetching the world on every keystroke.
|
|
30
|
+
*/
|
|
31
|
+
export const pollingWatch =
|
|
32
|
+
(
|
|
33
|
+
load: () => Promise<SourceLoadResult>,
|
|
34
|
+
intervalSeconds: number
|
|
35
|
+
): ((onChange: () => void) => () => void) =>
|
|
36
|
+
(onChange) => {
|
|
37
|
+
let last = "";
|
|
38
|
+
const tick = async (): Promise<void> => {
|
|
39
|
+
try {
|
|
40
|
+
const { entries } = await load();
|
|
41
|
+
const next = entriesDigest(entries);
|
|
42
|
+
if (last && next !== last) {
|
|
43
|
+
onChange();
|
|
44
|
+
}
|
|
45
|
+
last = next;
|
|
46
|
+
} catch {
|
|
47
|
+
// Ignore transient poll failures; the cache keeps serving last-known-good.
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const timer = setInterval(() => {
|
|
51
|
+
void tick();
|
|
52
|
+
}, intervalSeconds * 1000);
|
|
53
|
+
return () => clearInterval(timer);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A per-source snapshot of the last successful fetch, for offline tolerance. */
|
|
57
|
+
export interface SnapshotCache {
|
|
58
|
+
read: () => Promise<SourceEntry[]>;
|
|
59
|
+
write: (entries: SourceEntry[]) => Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build a JSON snapshot cache under `<cacheDir>/entries.json`. */
|
|
63
|
+
export const snapshotCache = (cacheDir: string): SnapshotCache => {
|
|
64
|
+
const file = join(cacheDir, "entries.json");
|
|
65
|
+
return {
|
|
66
|
+
read: async () => {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(await readFile(file, "utf-8")) as SourceEntry[];
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
write: async (entries) => {
|
|
74
|
+
try {
|
|
75
|
+
await mkdir(cacheDir, { recursive: true });
|
|
76
|
+
await writeFile(file, `${JSON.stringify(entries)}\n`, "utf-8");
|
|
77
|
+
} catch {
|
|
78
|
+
// Cache is best-effort; a write failure must not fail the build.
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run a remote `fetchEntries`, caching the result. When `refresh` is false and a
|
|
86
|
+
* snapshot exists, serve it without fetching (cache-first dev). On fetch failure,
|
|
87
|
+
* serve the last-known-good snapshot with a warning so a CMS/network outage
|
|
88
|
+
* doesn't fail the build; if there is no snapshot either, surface a hard error.
|
|
89
|
+
*/
|
|
90
|
+
export const loadWithCache = async (
|
|
91
|
+
name: string,
|
|
92
|
+
cache: SnapshotCache,
|
|
93
|
+
fetchEntries: () => Promise<SourceEntry[]>,
|
|
94
|
+
refresh = true
|
|
95
|
+
): Promise<SourceLoadResult> => {
|
|
96
|
+
if (!refresh) {
|
|
97
|
+
const cached = await cache.read();
|
|
98
|
+
if (cached.length > 0) {
|
|
99
|
+
return { diagnostics: [], entries: cached };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const entries = await fetchEntries();
|
|
104
|
+
await cache.write(entries);
|
|
105
|
+
return { diagnostics: [], entries };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const fallback = await cache.read();
|
|
108
|
+
if (fallback.length > 0) {
|
|
109
|
+
const diagnostic: Diagnostic = {
|
|
110
|
+
code: "BLUME_SOURCE_OFFLINE",
|
|
111
|
+
message: `Source "${name}" could not be fetched (${(error as Error).message}); served ${fallback.length} cached entries.`,
|
|
112
|
+
severity: "warning",
|
|
113
|
+
};
|
|
114
|
+
return { diagnostics: [diagnostic], entries: fallback };
|
|
115
|
+
}
|
|
116
|
+
throw new BlumeError({
|
|
117
|
+
code: "BLUME_SOURCE_FETCH_FAILED",
|
|
118
|
+
message: `Source "${name}" failed to load and no cache is available: ${(error as Error).message}`,
|
|
119
|
+
severity: "error",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, watch as fsWatch } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import { extname, isAbsolute, join, relative, resolve } from "pathe";
|
|
6
|
+
import { glob } from "tinyglobby";
|
|
7
|
+
|
|
8
|
+
import { BlumeError } from "../diagnostics.ts";
|
|
9
|
+
import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/** Options for the built-in filesystem source. */
|
|
12
|
+
export interface FilesystemSourceOptions {
|
|
13
|
+
/** Stable source name; namespaces ids and diagnostics. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Optional route prefix. */
|
|
16
|
+
prefix?: string;
|
|
17
|
+
/** Content root, absolute or relative to `projectRoot`. */
|
|
18
|
+
root: string;
|
|
19
|
+
include: string[];
|
|
20
|
+
exclude: string[];
|
|
21
|
+
/** Absolute project root, used to resolve a relative `root`. */
|
|
22
|
+
projectRoot: string;
|
|
23
|
+
/** When set, a missing root throws this code instead of the generic one. */
|
|
24
|
+
missingCode?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The built-in content source: discover and read `.md`/`.mdx` files under a
|
|
29
|
+
* content root. This is the extraction of the original `discoverContent()` scan
|
|
30
|
+
* into a `ContentSource`; every other adapter follows the same contract.
|
|
31
|
+
*/
|
|
32
|
+
export const filesystemSource = (
|
|
33
|
+
options: FilesystemSourceOptions
|
|
34
|
+
): ContentSource & { readonly contentRoot: string } => {
|
|
35
|
+
const contentRoot = isAbsolute(options.root)
|
|
36
|
+
? options.root
|
|
37
|
+
: join(resolve(options.projectRoot), options.root);
|
|
38
|
+
|
|
39
|
+
const load = async (): Promise<SourceLoadResult> => {
|
|
40
|
+
const files = await glob(options.include, {
|
|
41
|
+
absolute: true,
|
|
42
|
+
cwd: contentRoot,
|
|
43
|
+
ignore: options.exclude,
|
|
44
|
+
onlyFiles: true,
|
|
45
|
+
});
|
|
46
|
+
files.sort();
|
|
47
|
+
|
|
48
|
+
const entries = await Promise.all(
|
|
49
|
+
files.map(async (file): Promise<SourceEntry> => {
|
|
50
|
+
const source = await readFile(file, "utf-8");
|
|
51
|
+
const ext = extname(file).toLowerCase();
|
|
52
|
+
const format = ext === ".mdx" ? "mdx" : "md";
|
|
53
|
+
const parsed = matter(source);
|
|
54
|
+
return {
|
|
55
|
+
body: { format, text: parsed.content },
|
|
56
|
+
data: parsed.data,
|
|
57
|
+
ref: relative(contentRoot, file),
|
|
58
|
+
sourcePath: file,
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return { diagnostics: [], entries };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const validate = (): void => {
|
|
67
|
+
if (!existsSync(contentRoot)) {
|
|
68
|
+
throw new BlumeError({
|
|
69
|
+
code: options.missingCode ?? "BLUME_CONTENT_ROOT_MISSING",
|
|
70
|
+
file: contentRoot,
|
|
71
|
+
message: `Content root not found: ${options.root}`,
|
|
72
|
+
severity: "error",
|
|
73
|
+
suggestion: `Create a "${options.root}" folder with at least one .md or .mdx file, or set content.root in blume.config.ts.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const watch = (onChange: () => void): (() => void) => {
|
|
79
|
+
if (!existsSync(contentRoot)) {
|
|
80
|
+
return () => {
|
|
81
|
+
// Nothing to dispose when the root doesn't exist yet.
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const watcher = fsWatch(contentRoot, { recursive: true }, onChange);
|
|
85
|
+
return () => watcher.close();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
contentRoot,
|
|
90
|
+
load,
|
|
91
|
+
name: options.name,
|
|
92
|
+
prefix: options.prefix,
|
|
93
|
+
read: (ref: string): Promise<string> =>
|
|
94
|
+
readFile(join(contentRoot, ref), "utf-8"),
|
|
95
|
+
staged: false,
|
|
96
|
+
validate,
|
|
97
|
+
watch,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
|
|
3
|
+
import { BlumeError } from "../diagnostics.ts";
|
|
4
|
+
import {
|
|
5
|
+
hashText,
|
|
6
|
+
loadWithCache,
|
|
7
|
+
pollingWatch,
|
|
8
|
+
snapshotCache,
|
|
9
|
+
} from "./cache.ts";
|
|
10
|
+
import type {
|
|
11
|
+
ContentSource,
|
|
12
|
+
SourceContext,
|
|
13
|
+
SourceEntry,
|
|
14
|
+
SourceLoadResult,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
|
|
17
|
+
/** Options for the built-in remote Markdown/MDX source. */
|
|
18
|
+
export interface MdxRemoteSourceOptions {
|
|
19
|
+
name: string;
|
|
20
|
+
prefix?: string;
|
|
21
|
+
/** Raw base URL for `files`, e.g. `https://raw.githubusercontent.com/o/r/main/docs`. */
|
|
22
|
+
url?: string;
|
|
23
|
+
/** Explicit source-relative file paths to fetch from `url`. */
|
|
24
|
+
files?: string[];
|
|
25
|
+
/** Enumerate a GitHub repo subtree via the git-trees API. */
|
|
26
|
+
github?: { owner: string; repo: string; ref: string; path: string };
|
|
27
|
+
include: string[];
|
|
28
|
+
/** Opt-in dev polling interval (seconds); omit to freeze for the session. */
|
|
29
|
+
pollInterval?: number;
|
|
30
|
+
/** Injected for tests; defaults to the global `fetch`. */
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/u;
|
|
35
|
+
|
|
36
|
+
/** Escape a literal character for embedding in a RegExp. */
|
|
37
|
+
const escapeChar = (char: string): string =>
|
|
38
|
+
REGEX_SPECIAL.test(char) ? `\\${char}` : char;
|
|
39
|
+
|
|
40
|
+
/** Compile a glob (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */
|
|
41
|
+
const globToRegExp = (pattern: string): RegExp => {
|
|
42
|
+
let source = "";
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < pattern.length) {
|
|
45
|
+
const char = pattern[i] ?? "";
|
|
46
|
+
if (char === "*") {
|
|
47
|
+
if (pattern[i + 1] === "*") {
|
|
48
|
+
source += ".*";
|
|
49
|
+
i += 2;
|
|
50
|
+
if (pattern[i] === "/") {
|
|
51
|
+
i += 1;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
source += "[^/]*";
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (char === "?") {
|
|
60
|
+
source += "[^/]";
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (char === "{") {
|
|
65
|
+
const end = pattern.indexOf("}", i);
|
|
66
|
+
if (end !== -1) {
|
|
67
|
+
const options = pattern
|
|
68
|
+
.slice(i + 1, end)
|
|
69
|
+
.split(",")
|
|
70
|
+
.map((part) => [...part].map(escapeChar).join(""))
|
|
71
|
+
.join("|");
|
|
72
|
+
source += `(?:${options})`;
|
|
73
|
+
i = end + 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
source += escapeChar(char);
|
|
78
|
+
i += 1;
|
|
79
|
+
}
|
|
80
|
+
return new RegExp(`^${source}$`, "u");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Whether a ref matches any of the include globs. */
|
|
84
|
+
const matchesInclude = (ref: string, patterns: string[]): boolean =>
|
|
85
|
+
patterns.some((pattern) => globToRegExp(pattern).test(ref));
|
|
86
|
+
|
|
87
|
+
/** A file to fetch: its source-local ref plus where to read it from. */
|
|
88
|
+
interface RemoteRef {
|
|
89
|
+
ref: string;
|
|
90
|
+
fetchUrl: string;
|
|
91
|
+
editUrl?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const githubHeaders = (): Record<string, string> => {
|
|
95
|
+
const token = process.env.GITHUB_TOKEN;
|
|
96
|
+
return token ? { authorization: `Bearer ${token}` } : {};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
interface GithubTreeEntry {
|
|
100
|
+
path: string;
|
|
101
|
+
type: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Enumerate a GitHub repo subtree, mapping blobs to remote refs. */
|
|
105
|
+
const enumerateGithub = async (
|
|
106
|
+
github: { owner: string; repo: string; ref: string; path: string },
|
|
107
|
+
include: string[],
|
|
108
|
+
doFetch: typeof fetch
|
|
109
|
+
): Promise<RemoteRef[]> => {
|
|
110
|
+
const { owner, repo, ref } = github;
|
|
111
|
+
const base = github.path.replaceAll(/^\/|\/$/gu, "");
|
|
112
|
+
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
|
|
113
|
+
const res = await doFetch(treeUrl, { headers: githubHeaders() });
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
throw new Error(`${treeUrl} -> ${res.status}`);
|
|
116
|
+
}
|
|
117
|
+
const body = (await res.json()) as { tree?: GithubTreeEntry[] };
|
|
118
|
+
const prefix = base ? `${base}/` : "";
|
|
119
|
+
return (body.tree ?? [])
|
|
120
|
+
.filter((node) => node.type === "blob" && node.path.startsWith(prefix))
|
|
121
|
+
.map((node) => node.path.slice(prefix.length))
|
|
122
|
+
.filter((rel) => matchesInclude(rel, include))
|
|
123
|
+
.map((rel) => ({
|
|
124
|
+
editUrl: `https://github.com/${owner}/${repo}/edit/${ref}/${prefix}${rel}`,
|
|
125
|
+
fetchUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${prefix}${rel}`,
|
|
126
|
+
ref: rel,
|
|
127
|
+
}));
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remote Markdown/MDX content source. Fetches raw `.md`/`.mdx` over HTTP and
|
|
132
|
+
* passes the text straight through `normalizeEntry`. A snapshot under
|
|
133
|
+
* `.blume/cache/<source>/` makes rebuilds offline-tolerant.
|
|
134
|
+
*/
|
|
135
|
+
export const mdxRemoteSource = (
|
|
136
|
+
options: MdxRemoteSourceOptions,
|
|
137
|
+
ctx: SourceContext
|
|
138
|
+
): ContentSource => {
|
|
139
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
140
|
+
const cache = snapshotCache(ctx.cacheDir);
|
|
141
|
+
let snapshot = new Map<string, SourceEntry>();
|
|
142
|
+
|
|
143
|
+
const enumerate = async (): Promise<RemoteRef[]> => {
|
|
144
|
+
if (options.github) {
|
|
145
|
+
return await enumerateGithub(options.github, options.include, doFetch);
|
|
146
|
+
}
|
|
147
|
+
if (options.files && options.url) {
|
|
148
|
+
const base = options.url.replace(/\/$/u, "");
|
|
149
|
+
return options.files
|
|
150
|
+
.filter((ref) => matchesInclude(ref, options.include))
|
|
151
|
+
.map((ref) => ({
|
|
152
|
+
editUrl: `${base}/${ref}`,
|
|
153
|
+
fetchUrl: `${base}/${ref}`,
|
|
154
|
+
ref,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
throw new BlumeError({
|
|
158
|
+
code: "BLUME_SOURCE_MISCONFIGURED",
|
|
159
|
+
message: `Source "${options.name}" needs either { github } or { url, files }.`,
|
|
160
|
+
severity: "error",
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const fetchEntry = async (item: RemoteRef): Promise<SourceEntry> => {
|
|
165
|
+
const res = await doFetch(item.fetchUrl, { headers: githubHeaders() });
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
throw new Error(`${item.fetchUrl} -> ${res.status}`);
|
|
168
|
+
}
|
|
169
|
+
const text = await res.text();
|
|
170
|
+
const parsed = matter(text);
|
|
171
|
+
const format = item.ref.toLowerCase().endsWith(".mdx") ? "mdx" : "md";
|
|
172
|
+
return {
|
|
173
|
+
body: { format, text: parsed.content },
|
|
174
|
+
data: parsed.data,
|
|
175
|
+
editUrl: item.editUrl,
|
|
176
|
+
hash: hashText(text),
|
|
177
|
+
raw: text,
|
|
178
|
+
ref: item.ref,
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const load = async (): Promise<SourceLoadResult> => {
|
|
183
|
+
const result = await loadWithCache(
|
|
184
|
+
options.name,
|
|
185
|
+
cache,
|
|
186
|
+
async () => {
|
|
187
|
+
const refs = await enumerate();
|
|
188
|
+
return await Promise.all(refs.map(fetchEntry));
|
|
189
|
+
},
|
|
190
|
+
ctx.refresh ?? true
|
|
191
|
+
);
|
|
192
|
+
snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
|
|
193
|
+
return result;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const read = async (ref: string): Promise<string> => {
|
|
197
|
+
const cached = snapshot.get(ref);
|
|
198
|
+
if (cached) {
|
|
199
|
+
return cached.raw ?? cached.body.text;
|
|
200
|
+
}
|
|
201
|
+
const all = await cache.read();
|
|
202
|
+
const entry = all.find((e) => e.ref === ref);
|
|
203
|
+
return entry?.raw ?? entry?.body.text ?? "";
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
load,
|
|
208
|
+
name: options.name,
|
|
209
|
+
prefix: options.prefix,
|
|
210
|
+
read,
|
|
211
|
+
staged: true,
|
|
212
|
+
watch: options.pollInterval
|
|
213
|
+
? pollingWatch(load, options.pollInterval)
|
|
214
|
+
: undefined,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, watch as fsWatch } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import { isAbsolute, join, relative, resolve } from "pathe";
|
|
6
|
+
import { glob } from "tinyglobby";
|
|
7
|
+
|
|
8
|
+
import { transformMintlifyContent } from "../../migrate/mintlify/transform.ts";
|
|
9
|
+
import { BlumeError } from "../diagnostics.ts";
|
|
10
|
+
import type { Diagnostic } from "../types.ts";
|
|
11
|
+
import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/** Options for the Mintlify bridge content source. */
|
|
14
|
+
export interface MintlifySourceOptions {
|
|
15
|
+
/** Stable source name; namespaces ids and diagnostics. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Optional route prefix. */
|
|
18
|
+
prefix?: string;
|
|
19
|
+
/** Content root, absolute or relative to `projectRoot` (Mintlify: `.`). */
|
|
20
|
+
root: string;
|
|
21
|
+
include: string[];
|
|
22
|
+
exclude: string[];
|
|
23
|
+
/** `docs.json` variables, inlined into content (`{{name}}`) at scan time. */
|
|
24
|
+
variables: Record<string, string>;
|
|
25
|
+
/** Absolute path of the `docs.json`/`mint.json`, watched for changes in dev. */
|
|
26
|
+
configFile?: string;
|
|
27
|
+
/** Absolute project root, used to resolve a relative `root`. */
|
|
28
|
+
projectRoot: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Folders Mintlify projects keep alongside content that are never pages:
|
|
33
|
+
* snippets are inlined as includes, and build/tooling dirs are noise. Merged
|
|
34
|
+
* with the user's `exclude` so bridge mode behaves like the one-shot migrator.
|
|
35
|
+
*/
|
|
36
|
+
const MINTLIFY_SOURCE_IGNORES = [
|
|
37
|
+
"node_modules/**",
|
|
38
|
+
".blume/**",
|
|
39
|
+
"dist/**",
|
|
40
|
+
"build/**",
|
|
41
|
+
"public/**",
|
|
42
|
+
"snippets/**",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The Mintlify bridge content source. Reads an unconverted Mintlify project in
|
|
47
|
+
* place and transforms each page to Blume MDX at scan time (callouts → `:::`
|
|
48
|
+
* directives, snippet/variable inlining, etc.) via `transformMintlifyContent`.
|
|
49
|
+
* Staged: the transformed bodies are materialized under `.blume/content` and
|
|
50
|
+
* rendered through Astro's `staged` collection, so the rewrites actually reach
|
|
51
|
+
* the output. Components Blume already ships (Card, Tabs, Steps, …) render as-is.
|
|
52
|
+
*/
|
|
53
|
+
export const mintlifySource = (
|
|
54
|
+
options: MintlifySourceOptions
|
|
55
|
+
): ContentSource & { readonly contentRoot: string } => {
|
|
56
|
+
const contentRoot = isAbsolute(options.root)
|
|
57
|
+
? options.root
|
|
58
|
+
: join(resolve(options.projectRoot), options.root);
|
|
59
|
+
const ignore = [...new Set([...options.exclude, ...MINTLIFY_SOURCE_IGNORES])];
|
|
60
|
+
|
|
61
|
+
const transform = (
|
|
62
|
+
raw: string,
|
|
63
|
+
file: string
|
|
64
|
+
): ReturnType<typeof transformMintlifyContent> =>
|
|
65
|
+
transformMintlifyContent(raw, {
|
|
66
|
+
filePath: file,
|
|
67
|
+
root: resolve(options.projectRoot),
|
|
68
|
+
variables: options.variables,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const load = async (): Promise<SourceLoadResult> => {
|
|
72
|
+
const files = await glob(options.include, {
|
|
73
|
+
absolute: true,
|
|
74
|
+
cwd: contentRoot,
|
|
75
|
+
ignore,
|
|
76
|
+
onlyFiles: true,
|
|
77
|
+
});
|
|
78
|
+
files.sort();
|
|
79
|
+
|
|
80
|
+
const unsupported = new Set<string>();
|
|
81
|
+
const entries = await Promise.all(
|
|
82
|
+
files.map(async (file): Promise<SourceEntry> => {
|
|
83
|
+
const result = await transform(await readFile(file, "utf-8"), file);
|
|
84
|
+
for (const name of result.unsupported) {
|
|
85
|
+
unsupported.add(name);
|
|
86
|
+
}
|
|
87
|
+
const parsed = matter(result.content);
|
|
88
|
+
// Force MDX: Mintlify pages are MDX-authored and the rewrites emit `:::`
|
|
89
|
+
// directives + JSX, neither of which the plain `.md` processor expands.
|
|
90
|
+
return {
|
|
91
|
+
body: { format: "mdx", text: parsed.content },
|
|
92
|
+
data: parsed.data,
|
|
93
|
+
raw: result.content,
|
|
94
|
+
ref: relative(contentRoot, file),
|
|
95
|
+
sourcePath: file,
|
|
96
|
+
};
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const diagnostics: Diagnostic[] =
|
|
101
|
+
unsupported.size > 0
|
|
102
|
+
? [
|
|
103
|
+
{
|
|
104
|
+
code: "BLUME_MINTLIFY_UNSUPPORTED",
|
|
105
|
+
message: `Mintlify components without a Blume equivalent were left as-is: ${[...unsupported].toSorted().join(", ")}. Use the OpenAPI reference for API parameters.`,
|
|
106
|
+
severity: "warning",
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
: [];
|
|
110
|
+
|
|
111
|
+
return { diagnostics, entries };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const validate = (): void => {
|
|
115
|
+
if (!existsSync(contentRoot)) {
|
|
116
|
+
throw new BlumeError({
|
|
117
|
+
code: "BLUME_CONTENT_ROOT_MISSING",
|
|
118
|
+
file: contentRoot,
|
|
119
|
+
message: `Content root not found: ${options.root}`,
|
|
120
|
+
severity: "error",
|
|
121
|
+
suggestion: `Run "blume dev" from the directory that contains docs.json.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const watch = (onChange: () => void): (() => void) => {
|
|
127
|
+
const disposers: (() => void)[] = [];
|
|
128
|
+
if (existsSync(contentRoot)) {
|
|
129
|
+
const watcher = fsWatch(contentRoot, { recursive: true }, onChange);
|
|
130
|
+
disposers.push(() => watcher.close());
|
|
131
|
+
}
|
|
132
|
+
// Watch docs.json directly: it lives at the content root but a non-recursive
|
|
133
|
+
// single-file watch fires reliably on edits that recursive dir-watch can miss.
|
|
134
|
+
if (options.configFile && existsSync(options.configFile)) {
|
|
135
|
+
const watcher = fsWatch(options.configFile, onChange);
|
|
136
|
+
disposers.push(() => watcher.close());
|
|
137
|
+
}
|
|
138
|
+
return () => {
|
|
139
|
+
for (const dispose of disposers) {
|
|
140
|
+
dispose();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const read = async (ref: string): Promise<string> => {
|
|
146
|
+
const file = join(contentRoot, ref);
|
|
147
|
+
const result = await transform(await readFile(file, "utf-8"), file);
|
|
148
|
+
return result.content;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
contentRoot,
|
|
153
|
+
load,
|
|
154
|
+
name: options.name,
|
|
155
|
+
prefix: options.prefix,
|
|
156
|
+
read,
|
|
157
|
+
staged: true,
|
|
158
|
+
validate,
|
|
159
|
+
watch,
|
|
160
|
+
};
|
|
161
|
+
};
|