blume 0.1.5 → 0.3.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 +2123 -555
- package/dist/cli/index.js.map +39 -25
- 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 +136 -508
- package/dist/types/core/types.d.ts +2 -2
- package/docs/02-deployment.mdx +21 -2
- package/docs/advanced/changelog.mdx +28 -1
- package/docs/advanced/custom-pages.mdx +63 -2
- package/docs/configuration/ai.mdx +20 -3
- package/docs/configuration/customization.mdx +103 -5
- package/docs/configuration/index.mdx +25 -11
- package/docs/configuration/search.mdx +13 -1
- package/docs/configuration/seo.mdx +5 -0
- package/docs/configuration/theming.mdx +51 -0
- package/docs/content/components.mdx +18 -0
- package/docs/content/islands.mdx +73 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/content/sources.mdx +43 -0
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +3 -12
- package/docs/reference/cli.mdx +49 -1
- package/docs/reference/frontmatter.mdx +9 -1
- 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 +162 -26
- package/src/astro/integration.ts +59 -0
- package/src/astro/pages.ts +5 -12
- package/src/astro/templates.ts +102 -45
- package/src/blume-modules.d.ts +25 -0
- package/src/cli/commands/build.ts +186 -1
- package/src/cli/commands/check.ts +62 -0
- package/src/cli/commands/dev.ts +21 -1
- package/src/cli/commands/doctor.ts +23 -6
- package/src/cli/commands/init.ts +163 -15
- package/src/cli/commands/validate.ts +16 -2
- package/src/cli/env.ts +84 -0
- package/src/cli/index.ts +20 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +30 -1
- package/src/cli/prepare.ts +22 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +107 -0
- package/src/components/content/CodeBlock.astro +7 -2
- package/src/components/index.ts +3 -3
- package/src/components/islands/ask-ai.tsx +15 -1
- 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 +68 -0
- package/src/core/builtin-tags.ts +39 -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 +90 -1
- package/src/core/graph.ts +7 -0
- package/src/core/nav-diagnostics.ts +205 -0
- package/src/core/project-graph.ts +40 -1
- package/src/core/schema.ts +54 -96
- package/src/core/sources/github-releases.ts +200 -0
- package/src/core/sources/normalize.ts +51 -0
- package/src/core/sources/resolve.ts +16 -0
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -0
- package/src/markdown/index.ts +24 -0
- package/src/migrate/mintlify/config.ts +1 -176
- package/src/migrate/starlight/config.ts +0 -4
- package/src/og/card.ts +163 -38
- package/src/registry/eject.ts +39 -9
- package/src/registry/registry.ts +166 -0
- package/src/runtime/index.ts +61 -0
- package/src/vite-env.d.ts +14 -0
- package/docs/changelog/v0-1-0.mdx +0 -12
- package/docs/changelog/v0-2-0.mdx +0 -16
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
|
|
54
|
+
interface Props {
|
|
55
|
+
/** Astro collection the entry lives in. Defaults to `"docs"`. */
|
|
56
|
+
collection?: string;
|
|
57
|
+
/** Extra MDX components merged over Blume's built-ins (overrides, islands). */
|
|
58
|
+
components?: Record<string, unknown>;
|
|
59
|
+
/** Content entry id — e.g. a `BlumeRoute.entryId` from `getBlumeCollection`. */
|
|
60
|
+
id: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { collection = "docs", components: extra = {}, id } = Astro.props;
|
|
64
|
+
|
|
65
|
+
const Color = Object.assign(ColorRoot, { Item: ColorItem, Row: ColorRow });
|
|
66
|
+
const Tree = Object.assign(TreeRoot, { File: TreeFile, Folder: TreeFolder });
|
|
67
|
+
|
|
68
|
+
const components = {
|
|
69
|
+
Accordion,
|
|
70
|
+
AccordionItem,
|
|
71
|
+
AutoTypeTable,
|
|
72
|
+
Badge,
|
|
73
|
+
Callout,
|
|
74
|
+
Card,
|
|
75
|
+
CardGroup,
|
|
76
|
+
CodeBlock,
|
|
77
|
+
CodeGroup,
|
|
78
|
+
Color,
|
|
79
|
+
Column,
|
|
80
|
+
Columns,
|
|
81
|
+
Component,
|
|
82
|
+
Diff,
|
|
83
|
+
Expandable,
|
|
84
|
+
FileTree,
|
|
85
|
+
Frame,
|
|
86
|
+
GithubInfo,
|
|
87
|
+
Icon,
|
|
88
|
+
Panel,
|
|
89
|
+
Prompt,
|
|
90
|
+
Step,
|
|
91
|
+
Steps,
|
|
92
|
+
Tab,
|
|
93
|
+
Tabs,
|
|
94
|
+
Tile,
|
|
95
|
+
Tooltip,
|
|
96
|
+
Tree,
|
|
97
|
+
TypeTable,
|
|
98
|
+
Visibility,
|
|
99
|
+
...extra,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const entry = await getEntry(collection, id);
|
|
103
|
+
const rendered = entry ? await render(entry) : null;
|
|
104
|
+
const Content = rendered?.Content ?? null;
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
{Content && <Content components={components} />}
|
|
@@ -19,10 +19,15 @@ interface Props {
|
|
|
19
19
|
lang?: string;
|
|
20
20
|
/** Show the brand language icon in the header (default on, like fences). */
|
|
21
21
|
icons?: boolean;
|
|
22
|
+
/** Header title (a filename); falls back to the language label like fences. */
|
|
23
|
+
title?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
const { code, lang = "txt", icons } = Astro.props;
|
|
25
|
-
const html = await highlightCode(code.replace(/\n+$/u, ""), lang, {
|
|
26
|
+
const { code, lang = "txt", icons, title } = Astro.props;
|
|
27
|
+
const html = await highlightCode(code.replace(/\n+$/u, ""), lang, {
|
|
28
|
+
icons,
|
|
29
|
+
title,
|
|
30
|
+
});
|
|
26
31
|
---
|
|
27
32
|
|
|
28
33
|
<div class="prose max-w-none"><Fragment set:html={html} /></div>
|
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,9 +85,10 @@ 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",
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A no-op layout slot: renders nothing regardless of props. Used as the built-in
|
|
3
|
+
// fallback for the content-injection slots (Footer, PageHeader, PageFooter) that
|
|
4
|
+
// have no default, so an unconfigured slot renders empty while `resolveSlot`
|
|
5
|
+
// still returns a valid component and the generated pages stay type-safe.
|
|
6
|
+
---
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
+
import type { ComponentOverride } from "../../core/define-components.ts";
|
|
2
3
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
3
4
|
import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
|
|
4
5
|
import { GITHUB_MARK } from "../github-mark.ts";
|
|
5
6
|
import Icon from "../Icon.astro";
|
|
6
7
|
import LanguageSwitcher from "./LanguageSwitcher.astro";
|
|
8
|
+
import Logo from "./Logo.astro";
|
|
9
|
+
import NavSelector from "./NavSelector.astro";
|
|
10
|
+
import { resolveSlot } from "./overrides.ts";
|
|
7
11
|
import Search from "./Search.astro";
|
|
8
12
|
|
|
9
13
|
interface Props {
|
|
@@ -27,6 +31,12 @@ interface Props {
|
|
|
27
31
|
localeSwitch?: LocaleSwitchOption[];
|
|
28
32
|
/** Active locale for per-language search filtering. */
|
|
29
33
|
searchLocale?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Layout-slot overrides forwarded from the root layout. The header honors
|
|
36
|
+
* `Logo` and `Search` here so those pieces can be replaced without swapping
|
|
37
|
+
* the whole header.
|
|
38
|
+
*/
|
|
39
|
+
layout?: Record<string, ComponentOverride>;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
const {
|
|
@@ -41,15 +51,11 @@ const {
|
|
|
41
51
|
switcherStrings,
|
|
42
52
|
localeSwitch,
|
|
43
53
|
searchLocale,
|
|
54
|
+
layout = {},
|
|
44
55
|
} = Astro.props;
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const logoSvg = logo?.svg;
|
|
49
|
-
const logoLight = logo?.light;
|
|
50
|
-
const logoDark = logo?.dark ?? logo?.light;
|
|
51
|
-
const logoAlt = logo?.alt ?? "";
|
|
52
|
-
const brandHref = logo?.href ?? "/";
|
|
57
|
+
const LogoSlot = resolveSlot(layout.Logo, Logo);
|
|
58
|
+
const SearchSlot = resolveSlot(layout.Search, Search);
|
|
53
59
|
|
|
54
60
|
const iconButton =
|
|
55
61
|
"inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
|
|
@@ -80,37 +86,16 @@ const clickScript = `document.addEventListener("click",(e)=>{const t=e.target.cl
|
|
|
80
86
|
</button>
|
|
81
87
|
)
|
|
82
88
|
}
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
/>
|
|
94
|
-
)
|
|
95
|
-
}
|
|
96
|
-
{
|
|
97
|
-
!logoSvg &&
|
|
98
|
-
logoLight &&
|
|
99
|
-
(logoLight === logoDark ? (
|
|
100
|
-
<img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
|
|
101
|
-
) : (
|
|
102
|
-
<>
|
|
103
|
-
<img alt={logoAlt} class="h-5 w-auto dark:hidden" src={logoLight} />
|
|
104
|
-
<img
|
|
105
|
-
alt={logoAlt}
|
|
106
|
-
class="hidden h-5 w-auto dark:block"
|
|
107
|
-
src={logoDark}
|
|
108
|
-
/>
|
|
109
|
-
</>
|
|
110
|
-
))
|
|
111
|
-
}
|
|
112
|
-
{site.title}
|
|
113
|
-
</a>
|
|
89
|
+
<LogoSlot logo={logo} site={site} />
|
|
90
|
+
{
|
|
91
|
+
navigation.selectors.length > 0 && (
|
|
92
|
+
<div class="flex items-center gap-1.5">
|
|
93
|
+
{navigation.selectors.map((selector) => (
|
|
94
|
+
<NavSelector route={route} selector={selector} />
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
114
99
|
{
|
|
115
100
|
navigation.tabs.length > 0 && (
|
|
116
101
|
<nav aria-label="Sections" class="hidden gap-1 md:flex">
|
|
@@ -144,7 +129,7 @@ const clickScript = `document.addEventListener("click",(e)=>{const t=e.target.cl
|
|
|
144
129
|
}
|
|
145
130
|
{
|
|
146
131
|
searchEnabled && (
|
|
147
|
-
<
|
|
132
|
+
<SearchSlot
|
|
148
133
|
askEnabled={askEnabled}
|
|
149
134
|
locale={searchLocale}
|
|
150
135
|
navigation={navigation}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The site brand: the logo mark (inline SVG so a `currentColor` mark follows the
|
|
3
|
+
// theme, or light/dark `<img>` variants) followed by the site title, wrapped in
|
|
4
|
+
// a link to the brand href. Extracted from the header so it can be replaced on
|
|
5
|
+
// its own through the `Logo` layout slot (`defineComponents({ layout: { Logo } })`).
|
|
6
|
+
import type { BlumeLogo } from "../../core/data.ts";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
site: { title: string };
|
|
10
|
+
logo?: BlumeLogo | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { site, logo } = Astro.props;
|
|
14
|
+
|
|
15
|
+
// The logo is resolved upstream: an inline `svg` (so a currentColor mark follows
|
|
16
|
+
// the theme) or image URL(s) for an <img>.
|
|
17
|
+
const logoSvg = logo?.svg;
|
|
18
|
+
const logoLight = logo?.light;
|
|
19
|
+
const logoDark = logo?.dark ?? logo?.light;
|
|
20
|
+
const logoAlt = logo?.alt ?? "";
|
|
21
|
+
const brandHref = logo?.href ?? "/";
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<a
|
|
25
|
+
class="inline-flex items-center gap-2 font-semibold text-base text-foreground"
|
|
26
|
+
href={brandHref}
|
|
27
|
+
>
|
|
28
|
+
{
|
|
29
|
+
logoSvg && (
|
|
30
|
+
<span
|
|
31
|
+
aria-hidden="true"
|
|
32
|
+
class="inline-flex h-5 items-center [&>svg]:h-5 [&>svg]:w-auto"
|
|
33
|
+
set:html={logoSvg}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
{
|
|
38
|
+
!logoSvg &&
|
|
39
|
+
logoLight &&
|
|
40
|
+
(logoLight === logoDark ? (
|
|
41
|
+
<img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
|
|
42
|
+
) : (
|
|
43
|
+
<>
|
|
44
|
+
<img alt={logoAlt} class="h-5 w-auto dark:hidden" src={logoLight} />
|
|
45
|
+
<img alt={logoAlt} class="hidden h-5 w-auto dark:block" src={logoDark} />
|
|
46
|
+
</>
|
|
47
|
+
))
|
|
48
|
+
}
|
|
49
|
+
{site.title}
|
|
50
|
+
</a>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A top-level navigation selector (Mintlify-style): a dropdown that switches
|
|
3
|
+
// between partitions of the site — a product, a version, or any grouped set of
|
|
4
|
+
// destinations (`navigation.selectors` in the config). Zero-JS, built on
|
|
5
|
+
// <details>/<summary> like the language switcher.
|
|
6
|
+
import type { NavSelector } from "../../core/types.ts";
|
|
7
|
+
import Icon from "../Icon.astro";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
selector: NavSelector;
|
|
11
|
+
route: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { selector, route } = Astro.props;
|
|
15
|
+
|
|
16
|
+
// The active item is the deepest path that prefixes the current route, falling
|
|
17
|
+
// back to the first item so the summary always shows something meaningful.
|
|
18
|
+
const active =
|
|
19
|
+
selector.items.find((item) => item.path === route) ??
|
|
20
|
+
selector.items
|
|
21
|
+
.filter((item) => route.startsWith(item.path))
|
|
22
|
+
.toSorted((a, b) => b.path.length - a.path.length)[0] ??
|
|
23
|
+
selector.items[0];
|
|
24
|
+
|
|
25
|
+
const iconButton =
|
|
26
|
+
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground";
|
|
27
|
+
const menuRowClass =
|
|
28
|
+
"flex w-full items-start gap-2.5 rounded-md px-2 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=true]:text-foreground";
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
selector.items.length > 0 && (
|
|
33
|
+
<details class="group relative">
|
|
34
|
+
<summary
|
|
35
|
+
aria-label={selector.label}
|
|
36
|
+
class={`${iconButton} list-none [&::-webkit-details-marker]:hidden`}
|
|
37
|
+
>
|
|
38
|
+
{active?.icon && <Icon name={active.icon} size={16} />}
|
|
39
|
+
<span class="max-sm:hidden">{active?.label ?? selector.label}</span>
|
|
40
|
+
<Icon
|
|
41
|
+
class="transition-transform group-open:rotate-180"
|
|
42
|
+
name="chevron-down"
|
|
43
|
+
size={14}
|
|
44
|
+
/>
|
|
45
|
+
</summary>
|
|
46
|
+
<div class="absolute start-0 z-50 mt-2 min-w-56 rounded-blume border border-border bg-background p-1 shadow-xl">
|
|
47
|
+
{selector.items.map((item) => (
|
|
48
|
+
<a
|
|
49
|
+
aria-current={item.path === active?.path ? "true" : undefined}
|
|
50
|
+
class={menuRowClass}
|
|
51
|
+
href={item.path}
|
|
52
|
+
>
|
|
53
|
+
{item.icon && <Icon class="mt-0.5" name={item.icon} size={16} />}
|
|
54
|
+
<span class="flex-1">
|
|
55
|
+
<span class="flex items-center gap-2">
|
|
56
|
+
{item.label}
|
|
57
|
+
{item.tag && (
|
|
58
|
+
<span class="rounded-full bg-muted px-1.5 py-0.5 text-[0.65rem] text-muted-foreground">
|
|
59
|
+
{item.tag}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</span>
|
|
63
|
+
{item.description && (
|
|
64
|
+
<span class="mt-0.5 block text-muted-foreground text-xs">
|
|
65
|
+
{item.description}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</span>
|
|
69
|
+
{item.path === active?.path && <Icon name="check" size={14} />}
|
|
70
|
+
</a>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
</details>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// </PageLayout>
|
|
18
18
|
import type {
|
|
19
19
|
BlumeBanner,
|
|
20
|
+
BlumeClientData,
|
|
20
21
|
BlumeDataConfig,
|
|
21
22
|
BlumeFavicon,
|
|
22
23
|
BlumeLogo,
|
|
@@ -69,6 +70,12 @@ interface Props {
|
|
|
69
70
|
ui?: UIStrings;
|
|
70
71
|
/** Language-switcher entries for the header. */
|
|
71
72
|
localeSwitch?: LocaleSwitchOption[];
|
|
73
|
+
/**
|
|
74
|
+
* Snapshot for React island hooks (`blume/hooks`). Pass it when a custom page
|
|
75
|
+
* hosts islands that read `useBlume()`/`usePage()`:
|
|
76
|
+
* `clientData={{ config: data.config, navigation: data.navigation, page: { route: "/", title } }}`.
|
|
77
|
+
*/
|
|
78
|
+
clientData?: BlumeClientData | null;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
const {
|
|
@@ -93,8 +100,13 @@ const {
|
|
|
93
100
|
dir = "ltr",
|
|
94
101
|
ui,
|
|
95
102
|
localeSwitch,
|
|
103
|
+
clientData,
|
|
96
104
|
} = Astro.props;
|
|
97
105
|
|
|
106
|
+
const clientDataJson = clientData
|
|
107
|
+
? JSON.stringify(clientData).replaceAll("<", "\\u003c")
|
|
108
|
+
: null;
|
|
109
|
+
|
|
98
110
|
const strings = ui ?? EN_UI;
|
|
99
111
|
// Filter search to the active language only when the site is multi-locale.
|
|
100
112
|
const searchLocale =
|
|
@@ -110,8 +122,17 @@ const route = page?.route ?? "/";
|
|
|
110
122
|
const ogSlug = route === "/" ? "index" : route.slice(1);
|
|
111
123
|
const resolvedCanonical =
|
|
112
124
|
canonical ?? (siteUrl ? `${siteUrl}${route === "/" ? "" : route}` : null);
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
// An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
|
|
126
|
+
// `public/`) is resolved against the site URL so crawlers get an absolute
|
|
127
|
+
// `og:image`; an already-absolute URL passes through untouched. Otherwise fall
|
|
128
|
+
// back to the generated OG card for this route.
|
|
129
|
+
const absolutizeOgImage = (value: string): string =>
|
|
130
|
+
value.startsWith("/") && siteUrl ? `${siteUrl}${value}` : value;
|
|
131
|
+
const resolvedOgImage = ogImage
|
|
132
|
+
? absolutizeOgImage(ogImage)
|
|
133
|
+
: ogEnabled && siteUrl
|
|
134
|
+
? `${siteUrl}/og/${ogSlug}.png`
|
|
135
|
+
: null;
|
|
115
136
|
|
|
116
137
|
const initialThemeScript = themeInitScript(themeMode);
|
|
117
138
|
const bannerScript = banner?.dismissible
|
|
@@ -169,5 +190,20 @@ const bannerScript = banner?.dismissible
|
|
|
169
190
|
</Header>
|
|
170
191
|
<main id="blume-content"><slot /></main>
|
|
171
192
|
<slot name="footer" />
|
|
193
|
+
{
|
|
194
|
+
clientDataJson && (
|
|
195
|
+
<script
|
|
196
|
+
id="blume-client-data"
|
|
197
|
+
is:inline
|
|
198
|
+
set:html={clientDataJson}
|
|
199
|
+
type="application/json"
|
|
200
|
+
/>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
<script>
|
|
204
|
+
// Dev-only: friendly hint after a React island hydration mismatch;
|
|
205
|
+
// tree-shaken out of production builds.
|
|
206
|
+
import "./hydration-hint.ts";
|
|
207
|
+
</script>
|
|
172
208
|
</body>
|
|
173
209
|
</html>
|