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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getBlumeVersion } from "../core/version.ts";
|
|
2
|
+
|
|
3
|
+
const ESC = String.fromCodePoint(27);
|
|
4
|
+
const DIM = `${ESC}[2m`;
|
|
5
|
+
const RED = `${ESC}[31m`;
|
|
6
|
+
const BOLD = `${ESC}[1m`;
|
|
7
|
+
const RESET = `${ESC}[0m`;
|
|
8
|
+
|
|
9
|
+
const ISSUES_URL = "https://github.com/haydenbleasel/blume/issues";
|
|
10
|
+
|
|
11
|
+
// Absolute paths into the hidden generated runtime (`…/.blume/…`), including any
|
|
12
|
+
// trailing `:line:col`, stopping at whitespace or a closing paren.
|
|
13
|
+
const BLUME_FRAME = /(?<abs>\/[^\s()]*\/\.blume\/[^\s()]*)/gu;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Rewrite `.blume/` frames in a stack so the generated runtime reads clearly:
|
|
17
|
+
* the machine-absolute prefix is dropped to a project-relative `.blume/…` path
|
|
18
|
+
* and tagged `(generated)`, keeping the reader oriented instead of staring at a
|
|
19
|
+
* long path into a hidden directory. Frames in the user's own source (custom
|
|
20
|
+
* pages keep their real location; wrappers import user files by their real path)
|
|
21
|
+
* are untouched, so the actionable frame stays intact.
|
|
22
|
+
*/
|
|
23
|
+
export const remapBlumeStack = (stack: string): string =>
|
|
24
|
+
stack.replaceAll(BLUME_FRAME, (match) => {
|
|
25
|
+
const marker = match.indexOf("/.blume/");
|
|
26
|
+
return `${match.slice(marker + 1)} (generated)`;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Print an unexpected (non-{@link BlumeError}) failure in a stable, reportable
|
|
31
|
+
* shape instead of a bare stack trace: a fixed `BLUME_INTERNAL` code, the
|
|
32
|
+
* message, a trimmed stack, and an environment dump for bug reports. Callers
|
|
33
|
+
* exit after this — it doesn't exit itself, so it's testable.
|
|
34
|
+
*/
|
|
35
|
+
export const reportInternalError = (error: unknown): void => {
|
|
36
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
37
|
+
const lines = [
|
|
38
|
+
`${RED}${BOLD}BLUME_INTERNAL${RESET} An unexpected error occurred.`,
|
|
39
|
+
` ${err.message}`,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// A few frames are enough to locate the fault without burying the report;
|
|
43
|
+
// `.blume/` frames are relativized so the hidden runtime reads clearly.
|
|
44
|
+
const stack = remapBlumeStack(err.stack ?? "")
|
|
45
|
+
.split("\n")
|
|
46
|
+
.slice(1, 5)
|
|
47
|
+
.map((line) => line.trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
if (stack.length > 0) {
|
|
50
|
+
lines.push("", `${DIM}${stack.join("\n")}${RESET}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push(
|
|
54
|
+
"",
|
|
55
|
+
"This is likely a bug in Blume. Please report it with the details below:",
|
|
56
|
+
` ${DIM}Blume: ${getBlumeVersion()}`,
|
|
57
|
+
` Node: ${process.version}`,
|
|
58
|
+
` Platform: ${process.platform} ${process.arch}${RESET}`,
|
|
59
|
+
` ${ISSUES_URL}`
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
63
|
+
};
|
package/src/cli/log.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { consola } from "consola";
|
|
2
|
+
import { relative } from "pathe";
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
countBySeverity,
|
|
6
|
+
enrichDiagnostic,
|
|
5
7
|
formatDiagnostic,
|
|
6
8
|
hasErrors,
|
|
7
9
|
} from "../core/diagnostics.ts";
|
|
@@ -9,6 +11,42 @@ import type { Diagnostic } from "../core/types.ts";
|
|
|
9
11
|
|
|
10
12
|
export const logger = consola.withTag("blume");
|
|
11
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Resolve once stdout has drained. `process.exit` doesn't flush a piped stdout,
|
|
16
|
+
* so await this before exiting non-zero after writing machine-readable output
|
|
17
|
+
* (e.g. `--json`), otherwise the payload can be truncated in CI.
|
|
18
|
+
*/
|
|
19
|
+
export const flushStdout = (): Promise<void> =>
|
|
20
|
+
// oxlint-disable-next-line promise/avoid-new -- adapt stdout's write callback
|
|
21
|
+
new Promise((resolve) => {
|
|
22
|
+
process.stdout.write("", () => resolve());
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Print diagnostics as a JSON document on stdout for CI and editors: each is
|
|
27
|
+
* enriched with its `docsUrl` and its `file` made root-relative. Returns whether
|
|
28
|
+
* any were errors, matching {@link reportDiagnostics}.
|
|
29
|
+
*/
|
|
30
|
+
export const reportDiagnosticsJson = (
|
|
31
|
+
diagnostics: Diagnostic[],
|
|
32
|
+
root?: string
|
|
33
|
+
): boolean => {
|
|
34
|
+
const enriched = diagnostics.map((diagnostic) => {
|
|
35
|
+
const withDocs = enrichDiagnostic(diagnostic);
|
|
36
|
+
return withDocs.file && root
|
|
37
|
+
? { ...withDocs, file: relative(root, withDocs.file) }
|
|
38
|
+
: withDocs;
|
|
39
|
+
});
|
|
40
|
+
process.stdout.write(
|
|
41
|
+
`${JSON.stringify(
|
|
42
|
+
{ diagnostics: enriched, summary: countBySeverity(diagnostics) },
|
|
43
|
+
null,
|
|
44
|
+
2
|
|
45
|
+
)}\n`
|
|
46
|
+
);
|
|
47
|
+
return hasErrors(diagnostics);
|
|
48
|
+
};
|
|
49
|
+
|
|
12
50
|
/** Print a batch of diagnostics and return whether any were errors. */
|
|
13
51
|
export const reportDiagnostics = (
|
|
14
52
|
diagnostics: Diagnostic[],
|
|
@@ -19,7 +57,9 @@ export const reportDiagnostics = (
|
|
|
19
57
|
}
|
|
20
58
|
|
|
21
59
|
for (const diagnostic of diagnostics) {
|
|
22
|
-
process.stderr.write(
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`${formatDiagnostic(enrichDiagnostic(diagnostic), root)}\n`
|
|
62
|
+
);
|
|
23
63
|
}
|
|
24
64
|
|
|
25
65
|
const counts = countBySeverity(diagnostics);
|
package/src/cli/prepare.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { generateRuntime } from "../astro/generate.ts";
|
|
2
2
|
import { BlumeError, hasErrors } from "../core/diagnostics.ts";
|
|
3
3
|
import { scanProject } from "../core/project-graph.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
BlumeProject,
|
|
6
|
+
BuildMode,
|
|
7
|
+
ConfigOverrides,
|
|
8
|
+
} from "../core/project-graph.ts";
|
|
5
9
|
import { serverFeatures } from "../core/server-features.ts";
|
|
6
10
|
import { loadEnvFiles } from "./env.ts";
|
|
11
|
+
import { reportInternalError } from "./internal-error.ts";
|
|
7
12
|
import { logger, reportDiagnostics } from "./log.ts";
|
|
13
|
+
import { checkRequiredSecrets } from "./required-secrets.ts";
|
|
8
14
|
|
|
9
15
|
export interface PrepareOptions {
|
|
10
16
|
root: string;
|
|
@@ -16,6 +22,8 @@ export interface PrepareOptions {
|
|
|
16
22
|
preview?: boolean;
|
|
17
23
|
/** Force remote sources to re-fetch instead of serving the cached snapshot. */
|
|
18
24
|
refresh?: boolean;
|
|
25
|
+
/** CLI config overrides (e.g. `--output`, `--content-dir`). */
|
|
26
|
+
overrides?: ConfigOverrides;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/**
|
|
@@ -34,15 +42,17 @@ export const prepareProject = async (
|
|
|
34
42
|
project = await scanProject(options.root, {
|
|
35
43
|
devServerUrl: options.devServerUrl,
|
|
36
44
|
mode: options.mode,
|
|
45
|
+
overrides: options.overrides,
|
|
37
46
|
preview: options.preview,
|
|
38
47
|
refresh: options.refresh,
|
|
39
48
|
});
|
|
40
49
|
} catch (error) {
|
|
41
50
|
if (error instanceof BlumeError) {
|
|
42
51
|
reportDiagnostics([error.diagnostic], options.root);
|
|
43
|
-
|
|
52
|
+
} else {
|
|
53
|
+
reportInternalError(error);
|
|
44
54
|
}
|
|
45
|
-
|
|
55
|
+
process.exit(1);
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
// Hard gate: server-only features cannot ship in a static build.
|
|
@@ -81,5 +91,9 @@ export const prepareProject = async (
|
|
|
81
91
|
for (const warning of warnings) {
|
|
82
92
|
logger.warn(warning);
|
|
83
93
|
}
|
|
94
|
+
|
|
95
|
+
// Fail-fast on missing runtime secrets: warn now, not at the first request.
|
|
96
|
+
reportDiagnostics(checkRequiredSecrets(project.config), options.root);
|
|
97
|
+
|
|
84
98
|
return project;
|
|
85
99
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolveAskBackend } from "../ai/ask.ts";
|
|
2
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
|
+
import type { Diagnostic } from "../core/types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Warn early when an enabled feature needs a secret env var that isn't set, so
|
|
7
|
+
* the failure surfaces at `blume dev`/`build` instead of at the first request in
|
|
8
|
+
* production. These are runtime secrets (the endpoint reads them on the server),
|
|
9
|
+
* so this warns rather than hard-fails — the value may live only in the deploy
|
|
10
|
+
* environment. Build-time secrets (search-index sync) already warn during sync.
|
|
11
|
+
*/
|
|
12
|
+
export const checkRequiredSecrets = (config: ResolvedConfig): Diagnostic[] => {
|
|
13
|
+
const diagnostics: Diagnostic[] = [];
|
|
14
|
+
const requireSecret = (feature: string, env: string, note?: string): void => {
|
|
15
|
+
if (process.env[env]) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
diagnostics.push({
|
|
19
|
+
code: "BLUME_MISSING_SECRET",
|
|
20
|
+
message: `${feature} is enabled but ${env} is not set${note ? ` (${note})` : ""}.`,
|
|
21
|
+
severity: "warning",
|
|
22
|
+
suggestion: `Set ${env} in .env.local for local dev, or in your host's environment for production.`,
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (config.ai.ask?.enabled) {
|
|
27
|
+
const backend = resolveAskBackend(config.ai.ask);
|
|
28
|
+
if (backend.kind === "gateway") {
|
|
29
|
+
requireSecret(
|
|
30
|
+
"Ask AI (AI Gateway)",
|
|
31
|
+
"AI_GATEWAY_API_KEY",
|
|
32
|
+
"on Vercel the gateway can also authenticate via OIDC"
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
requireSecret("Ask AI", backend.apiKeyEnv);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (config.search.provider === "mixedbread") {
|
|
40
|
+
requireSecret("Mixedbread search", "MIXEDBREAD_API_KEY");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return diagnostics;
|
|
44
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Render a Blume content entry's body inside a custom page — e.g. to feature a
|
|
3
|
+
// doc on a landing page, or build a bespoke index that still shows real content.
|
|
4
|
+
//
|
|
5
|
+
// import BlumePage from "blume/components/BlumePage.astro";
|
|
6
|
+
// import data from "blume:data";
|
|
7
|
+
// import { getBlumeCollection } from "blume/runtime";
|
|
8
|
+
// const [intro] = getBlumeCollection(data, { prefix: "/docs" });
|
|
9
|
+
// ---
|
|
10
|
+
// <BlumePage id={intro.entryId} />
|
|
11
|
+
//
|
|
12
|
+
// Blume's built-in MDX components are wired in so callouts, cards, steps, etc.
|
|
13
|
+
// render as they do on a normal page. Pass `components` to add your own overrides
|
|
14
|
+
// or islands (which live in the generated runtime and aren't imported here). The
|
|
15
|
+
// heavy, opt-in `<Math>` component is not included by default — pass it if the
|
|
16
|
+
// embedded content uses math.
|
|
17
|
+
import { getEntry, render } from "astro:content";
|
|
18
|
+
|
|
19
|
+
import Icon from "./Icon.astro";
|
|
20
|
+
import Accordion from "./content/Accordion.astro";
|
|
21
|
+
import AccordionItem from "./content/AccordionItem.astro";
|
|
22
|
+
import AutoTypeTable from "./content/AutoTypeTable.astro";
|
|
23
|
+
import Badge from "./content/Badge.astro";
|
|
24
|
+
import Callout from "./content/Callout.astro";
|
|
25
|
+
import Card from "./content/Card.astro";
|
|
26
|
+
import CardGroup from "./content/CardGroup.astro";
|
|
27
|
+
import CodeBlock from "./content/CodeBlock.astro";
|
|
28
|
+
import CodeGroup from "./content/CodeGroup.astro";
|
|
29
|
+
import ColorItem from "./content/ColorItem.astro";
|
|
30
|
+
import ColorRoot from "./content/Color.astro";
|
|
31
|
+
import ColorRow from "./content/ColorRow.astro";
|
|
32
|
+
import Column from "./content/Column.astro";
|
|
33
|
+
import Columns from "./content/Columns.astro";
|
|
34
|
+
import Component from "./content/Component.astro";
|
|
35
|
+
import Diff from "./content/Diff.astro";
|
|
36
|
+
import Expandable from "./content/Expandable.astro";
|
|
37
|
+
import FileTree from "./content/FileTree.astro";
|
|
38
|
+
import Frame from "./content/Frame.astro";
|
|
39
|
+
import GithubInfo from "./content/GithubInfo.astro";
|
|
40
|
+
import Panel from "./content/Panel.astro";
|
|
41
|
+
import Prompt from "./content/Prompt.astro";
|
|
42
|
+
import Step from "./content/Step.astro";
|
|
43
|
+
import Steps from "./content/Steps.astro";
|
|
44
|
+
import Tab from "./content/Tab.astro";
|
|
45
|
+
import Tabs from "./content/Tabs.astro";
|
|
46
|
+
import Tile from "./content/Tile.astro";
|
|
47
|
+
import Tooltip from "./content/Tooltip.astro";
|
|
48
|
+
import TreeRoot from "./content/Tree.astro";
|
|
49
|
+
import TreeFile from "./content/TreeFile.astro";
|
|
50
|
+
import TreeFolder from "./content/TreeFolder.astro";
|
|
51
|
+
import TypeTable from "./content/TypeTable.astro";
|
|
52
|
+
import Visibility from "./content/Visibility.astro";
|
|
53
|
+
import YouTube from "./content/YouTube.astro";
|
|
54
|
+
|
|
55
|
+
interface Props {
|
|
56
|
+
/** Astro collection the entry lives in. Defaults to `"docs"`. */
|
|
57
|
+
collection?: string;
|
|
58
|
+
/** Extra MDX components merged over Blume's built-ins (overrides, islands). */
|
|
59
|
+
components?: Record<string, unknown>;
|
|
60
|
+
/** Content entry id — e.g. a `BlumeRoute.entryId` from `getBlumeCollection`. */
|
|
61
|
+
id: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { collection = "docs", components: extra = {}, id } = Astro.props;
|
|
65
|
+
|
|
66
|
+
const Color = Object.assign(ColorRoot, { Item: ColorItem, Row: ColorRow });
|
|
67
|
+
const Tree = Object.assign(TreeRoot, { File: TreeFile, Folder: TreeFolder });
|
|
68
|
+
|
|
69
|
+
const components = {
|
|
70
|
+
Accordion,
|
|
71
|
+
AccordionItem,
|
|
72
|
+
AutoTypeTable,
|
|
73
|
+
Badge,
|
|
74
|
+
Callout,
|
|
75
|
+
Card,
|
|
76
|
+
CardGroup,
|
|
77
|
+
CodeBlock,
|
|
78
|
+
CodeGroup,
|
|
79
|
+
Color,
|
|
80
|
+
Column,
|
|
81
|
+
Columns,
|
|
82
|
+
Component,
|
|
83
|
+
Diff,
|
|
84
|
+
Expandable,
|
|
85
|
+
FileTree,
|
|
86
|
+
Frame,
|
|
87
|
+
GithubInfo,
|
|
88
|
+
Icon,
|
|
89
|
+
Panel,
|
|
90
|
+
Prompt,
|
|
91
|
+
Step,
|
|
92
|
+
Steps,
|
|
93
|
+
Tab,
|
|
94
|
+
Tabs,
|
|
95
|
+
Tile,
|
|
96
|
+
Tooltip,
|
|
97
|
+
Tree,
|
|
98
|
+
TypeTable,
|
|
99
|
+
Visibility,
|
|
100
|
+
YouTube,
|
|
101
|
+
...extra,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const entry = await getEntry(collection, id);
|
|
105
|
+
const rendered = entry ? await render(entry) : null;
|
|
106
|
+
const Content = rendered?.Content ?? null;
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
{Content && <Content components={components} />}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { parseYouTubeId, youtubeEmbedUrl } from "./youtube.ts";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** A YouTube video id (e.g. `dQw4w9WgXcQ`). */
|
|
6
|
+
id?: string;
|
|
7
|
+
/** Start playback this many seconds in. */
|
|
8
|
+
start?: number;
|
|
9
|
+
/** Accessible title for the embedded player. */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** A full YouTube URL to extract the id from, as an alternative to `id`. */
|
|
12
|
+
url?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { id, start, title = "YouTube video player", url } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const videoId = parseYouTubeId(id ?? url ?? "");
|
|
18
|
+
const src = videoId ? youtubeEmbedUrl(videoId, { start }) : null;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
src && (
|
|
23
|
+
<div class="not-prose my-6 aspect-video overflow-hidden rounded-blume border border-border bg-muted/30">
|
|
24
|
+
<iframe
|
|
25
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
26
|
+
allowfullscreen
|
|
27
|
+
class="h-full w-full"
|
|
28
|
+
loading="lazy"
|
|
29
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
30
|
+
src={src}
|
|
31
|
+
title={title}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the `<YouTube>` content component. Kept in a sibling `.ts` (like
|
|
3
|
+
* `diff.ts`/`github-info.ts`) so the id parsing and embed-URL building are pure,
|
|
4
|
+
* unit-testable functions — the `.astro` file stays a thin presentational shell.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// A YouTube video id is 11 characters of [A-Za-z0-9_-].
|
|
8
|
+
const BARE_ID = /^[\w-]{11}$/u;
|
|
9
|
+
|
|
10
|
+
// Pull the id out of any common YouTube URL: youtu.be/<id>, watch?v=<id>,
|
|
11
|
+
// /embed/<id>, /shorts/<id>, /live/<id>.
|
|
12
|
+
const URL_ID =
|
|
13
|
+
/(?:youtu\.be\/|\/embed\/|\/shorts\/|\/live\/|[?&]v=)(?<id>[\w-]{11})/u;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a YouTube video id from either a bare id or a full URL. Returns `null`
|
|
17
|
+
* when nothing that looks like an id can be found, so the component can render
|
|
18
|
+
* nothing rather than a broken embed.
|
|
19
|
+
*/
|
|
20
|
+
export const parseYouTubeId = (input: string): string | null => {
|
|
21
|
+
const value = input.trim();
|
|
22
|
+
if (!value) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (BARE_ID.test(value)) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
return URL_ID.exec(value)?.groups?.id ?? null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a privacy-enhanced (`youtube-nocookie.com`) embed URL, optionally
|
|
33
|
+
* starting at `start` seconds.
|
|
34
|
+
*/
|
|
35
|
+
export const youtubeEmbedUrl = (
|
|
36
|
+
id: string,
|
|
37
|
+
options: { start?: number } = {}
|
|
38
|
+
): string => {
|
|
39
|
+
const base = `https://www.youtube-nocookie.com/embed/${id}`;
|
|
40
|
+
const { start } = options;
|
|
41
|
+
if (start && start > 0) {
|
|
42
|
+
const params = new URLSearchParams({ start: String(Math.floor(start)) });
|
|
43
|
+
return `${base}?${params.toString()}`;
|
|
44
|
+
}
|
|
45
|
+
return base;
|
|
46
|
+
};
|
package/src/components/index.ts
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* Public component contracts.
|
|
3
3
|
*
|
|
4
4
|
* Prop types for built-in components are exported here so users can type their
|
|
5
|
-
* overrides (`import type { CalloutProps } from "blume/components"`)
|
|
6
|
-
*
|
|
7
|
-
* below are stable today.
|
|
5
|
+
* overrides (`import type { CalloutProps } from "blume/components"`), alongside
|
|
6
|
+
* the override descriptor types.
|
|
8
7
|
*/
|
|
9
8
|
export type {
|
|
10
9
|
ComponentOverride,
|
|
@@ -12,3 +11,4 @@ export type {
|
|
|
12
11
|
IslandDescriptor,
|
|
13
12
|
} from "../core/define-components.ts";
|
|
14
13
|
export type { HydrationMode } from "../core/schema.ts";
|
|
14
|
+
export type * from "./props.ts";
|
|
@@ -25,6 +25,19 @@ const nextId = (): number => {
|
|
|
25
25
|
return idCounter;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
// The endpoint and page path both honor the deployment `base` so grounding works
|
|
29
|
+
// under a non-root base path (the server matches base-less document routes).
|
|
30
|
+
const ASK_ENDPOINT = `${import.meta.env.BASE_URL}api/ask`.replace("//", "/");
|
|
31
|
+
|
|
32
|
+
/** The current route with the deployment base stripped, for page-context lookup. */
|
|
33
|
+
const currentPath = (): string => {
|
|
34
|
+
const base = import.meta.env.BASE_URL;
|
|
35
|
+
const path = window.location.pathname;
|
|
36
|
+
return base.length > 1 && path.startsWith(base)
|
|
37
|
+
? `/${path.slice(base.length)}`
|
|
38
|
+
: path;
|
|
39
|
+
};
|
|
40
|
+
|
|
28
41
|
const BUTTON_CLASS =
|
|
29
42
|
"inline-flex h-9 cursor-pointer items-center gap-2 rounded-blume border border-border bg-muted px-2.5 text-muted-foreground text-sm hover:border-accent disabled:opacity-50";
|
|
30
43
|
|
|
@@ -72,28 +85,29 @@ const AskAI = ({ strings }: { strings?: UIStrings["ask"] }) => {
|
|
|
72
85
|
setBusy(true);
|
|
73
86
|
|
|
74
87
|
try {
|
|
75
|
-
const response = await fetch(
|
|
88
|
+
const response = await fetch(ASK_ENDPOINT, {
|
|
76
89
|
body: JSON.stringify({
|
|
77
90
|
messages: history.map((m) => ({ content: m.content, role: m.role })),
|
|
91
|
+
page: { path: currentPath() },
|
|
78
92
|
}),
|
|
79
93
|
headers: { "content-type": "application/json" },
|
|
80
94
|
method: "POST",
|
|
81
95
|
});
|
|
82
|
-
|
|
96
|
+
// A 4xx/5xx still has a body; without this guard its error text would be
|
|
97
|
+
// decoded and shown as the assistant's answer instead of the error notice.
|
|
98
|
+
if (!(response.ok && response.body)) {
|
|
99
|
+
throw new Error(`Ask AI request failed (${response.status}).`);
|
|
100
|
+
}
|
|
101
|
+
const reader = response.body.getReader();
|
|
83
102
|
const decoder = new TextDecoder();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
setMessages((current) => [
|
|
93
|
-
...current.slice(0, -1),
|
|
94
|
-
{ ...assistant },
|
|
95
|
-
]);
|
|
96
|
-
}
|
|
103
|
+
let done = false;
|
|
104
|
+
while (!done) {
|
|
105
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential stream reads
|
|
106
|
+
const chunk = await reader.read();
|
|
107
|
+
({ done } = chunk);
|
|
108
|
+
if (chunk.value) {
|
|
109
|
+
assistant.content += decoder.decode(chunk.value);
|
|
110
|
+
setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
113
|
} catch {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { BlumeClientData } from "../../core/data.ts";
|
|
4
|
+
import type { SearchFn, SearchResult } from "../layout/search/types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* React hooks for Blume islands.
|
|
8
|
+
*
|
|
9
|
+
* Islands hydrate independently (there's no shared React root spanning them), so
|
|
10
|
+
* project data can't come through context. Instead the layout serializes a small
|
|
11
|
+
* snapshot into a `<script type="application/json" id="blume-client-data">` tag,
|
|
12
|
+
* and {@link useBlume}/{@link usePage} read it after mount. {@link useSearch} and
|
|
13
|
+
* {@link useAskAI} wrap the generated search client and the Ask AI endpoint.
|
|
14
|
+
*
|
|
15
|
+
* Import them from `blume/hooks`:
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* import { useBlume, usePage } from "blume/hooks";
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type { BlumeClientData } from "../../core/data.ts";
|
|
23
|
+
|
|
24
|
+
let cachedData: BlumeClientData | null = null;
|
|
25
|
+
|
|
26
|
+
/** Read + parse the injected snapshot (memoized); null on the server. */
|
|
27
|
+
const readClientData = (): BlumeClientData | null => {
|
|
28
|
+
if (cachedData) {
|
|
29
|
+
return cachedData;
|
|
30
|
+
}
|
|
31
|
+
if (typeof document === "undefined") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const element = document.querySelector("#blume-client-data");
|
|
35
|
+
if (!element?.textContent) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
cachedData = JSON.parse(element.textContent) as BlumeClientData;
|
|
40
|
+
return cachedData;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the injected snapshot after mount. `null` on the server and on the first
|
|
48
|
+
* client render (so hydration matches), then the data once mounted.
|
|
49
|
+
*/
|
|
50
|
+
const useClientData = (): BlumeClientData | null => {
|
|
51
|
+
const [data, setData] = useState<BlumeClientData | null>(null);
|
|
52
|
+
useEffect(() => setData(readClientData()), []);
|
|
53
|
+
return data;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Site config + navigation for the current page, or `null` before mount. */
|
|
57
|
+
export const useBlume = (): Pick<
|
|
58
|
+
BlumeClientData,
|
|
59
|
+
"config" | "navigation"
|
|
60
|
+
> | null => {
|
|
61
|
+
const data = useClientData();
|
|
62
|
+
return data ? { config: data.config, navigation: data.navigation } : null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The current page's route + title, or `null` before mount. */
|
|
66
|
+
export const usePage = (): BlumeClientData["page"] | null =>
|
|
67
|
+
useClientData()?.page ?? null;
|
|
68
|
+
|
|
69
|
+
/** State + actions returned by {@link useSearch}. */
|
|
70
|
+
export interface UseSearch {
|
|
71
|
+
loading: boolean;
|
|
72
|
+
results: SearchResult | null;
|
|
73
|
+
search: (
|
|
74
|
+
query: string,
|
|
75
|
+
options?: { locale?: string; section?: string }
|
|
76
|
+
) => Promise<SearchResult>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Query the site's configured search provider. The provider client is created
|
|
81
|
+
* lazily on the first search, so islands that never search ship no extra weight.
|
|
82
|
+
*/
|
|
83
|
+
export const useSearch = (): UseSearch => {
|
|
84
|
+
const [results, setResults] = useState<SearchResult | null>(null);
|
|
85
|
+
const [loading, setLoading] = useState(false);
|
|
86
|
+
const searchFn = useRef<SearchFn | null>(null);
|
|
87
|
+
|
|
88
|
+
const search = useCallback<UseSearch["search"]>(async (query, options) => {
|
|
89
|
+
if (!searchFn.current) {
|
|
90
|
+
const { createSearch } = await import("blume:search-client");
|
|
91
|
+
searchFn.current = await createSearch();
|
|
92
|
+
}
|
|
93
|
+
setLoading(true);
|
|
94
|
+
try {
|
|
95
|
+
const result = await searchFn.current(query, options);
|
|
96
|
+
setResults(result);
|
|
97
|
+
return result;
|
|
98
|
+
} finally {
|
|
99
|
+
setLoading(false);
|
|
100
|
+
}
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
return { loading, results, search };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** A single Ask AI chat message. */
|
|
107
|
+
export interface AskMessage {
|
|
108
|
+
content: string;
|
|
109
|
+
role: "assistant" | "user";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** State + actions returned by {@link useAskAI}. */
|
|
113
|
+
export interface UseAskAI {
|
|
114
|
+
ask: (question: string) => Promise<void>;
|
|
115
|
+
loading: boolean;
|
|
116
|
+
messages: AskMessage[];
|
|
117
|
+
reset: () => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ASK_ENDPOINT = `${import.meta.env.BASE_URL}api/ask`.replace("//", "/");
|
|
121
|
+
|
|
122
|
+
/** The current route with the deployment base stripped, for page grounding. */
|
|
123
|
+
const currentPath = (): string => {
|
|
124
|
+
const base = import.meta.env.BASE_URL;
|
|
125
|
+
const path = window.location.pathname;
|
|
126
|
+
return base.length > 1 && path.startsWith(base)
|
|
127
|
+
? `/${path.slice(base.length)}`
|
|
128
|
+
: path;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Stream answers from the Ask AI endpoint. Mirrors the built-in Ask AI island so
|
|
133
|
+
* a custom chat UI shares the same grounded, page-aware backend.
|
|
134
|
+
*/
|
|
135
|
+
export const useAskAI = (): UseAskAI => {
|
|
136
|
+
const [messages, setMessages] = useState<AskMessage[]>([]);
|
|
137
|
+
const [loading, setLoading] = useState(false);
|
|
138
|
+
|
|
139
|
+
const ask = useCallback<UseAskAI["ask"]>(
|
|
140
|
+
async (question) => {
|
|
141
|
+
const trimmed = question.trim();
|
|
142
|
+
if (!trimmed || loading) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const history: AskMessage[] = [
|
|
146
|
+
...messages,
|
|
147
|
+
{ content: trimmed, role: "user" },
|
|
148
|
+
];
|
|
149
|
+
const assistant: AskMessage = { content: "", role: "assistant" };
|
|
150
|
+
setMessages([...history, assistant]);
|
|
151
|
+
setLoading(true);
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(ASK_ENDPOINT, {
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
messages: history,
|
|
156
|
+
page: { path: currentPath() },
|
|
157
|
+
}),
|
|
158
|
+
headers: { "content-type": "application/json" },
|
|
159
|
+
method: "POST",
|
|
160
|
+
});
|
|
161
|
+
const reader = response.body?.getReader();
|
|
162
|
+
const decoder = new TextDecoder();
|
|
163
|
+
if (reader) {
|
|
164
|
+
let done = false;
|
|
165
|
+
while (!done) {
|
|
166
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential stream reads
|
|
167
|
+
const chunk = await reader.read();
|
|
168
|
+
({ done } = chunk);
|
|
169
|
+
if (chunk.value) {
|
|
170
|
+
assistant.content += decoder.decode(chunk.value);
|
|
171
|
+
setMessages((current) => [
|
|
172
|
+
...current.slice(0, -1),
|
|
173
|
+
{ ...assistant },
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
setLoading(false);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
[loading, messages]
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const reset = useCallback(() => setMessages([]), []);
|
|
186
|
+
|
|
187
|
+
return { ask, loading, messages, reset };
|
|
188
|
+
};
|