blume 0.5.2 → 0.5.4
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 +2235 -5942
- package/dist/cli/index.js.map +36 -65
- package/dist/types/core/config.d.ts +0 -8
- package/dist/types/core/data.d.ts +2 -2
- package/dist/types/core/schema.d.ts +58 -453
- package/dist/types/core/types.d.ts +1 -7
- package/docs/advanced/meta.ts +1 -8
- package/docs/advanced/skills.mdx +28 -0
- package/docs/configuration/ai.mdx +3 -1
- package/docs/configuration/index.mdx +13 -17
- package/docs/configuration/theming.mdx +3 -11
- package/docs/content/components.mdx +2 -53
- package/docs/content/i18n.mdx +1 -5
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +0 -1
- package/docs/reference/cli.mdx +12 -13
- package/package.json +4 -6
- package/skills/blume/SKILL.md +71 -0
- package/skills/blume-update-docs/SKILL.md +52 -0
- package/skills/blume-update-docs/references/audit-checklist.md +46 -0
- package/src/ai/mcp/discovery.ts +3 -1
- package/src/ai/mcp/server.ts +3 -1
- package/src/astro/component-slots.ts +10 -2
- package/src/astro/generate.ts +15 -12
- package/src/astro/integration.ts +0 -21
- package/src/astro/templates.ts +62 -37
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +30 -22
- package/src/cli/commands/validate.ts +0 -2
- package/src/cli/dev-lock.ts +4 -2
- package/src/cli/index.ts +0 -2
- package/src/components/BlumePage.astro +0 -6
- package/src/components/Icon.astro +1 -12
- package/src/components/content/AccordionItem.astro +3 -6
- package/src/components/content/Badge.astro +1 -3
- package/src/components/content/Callout.astro +3 -9
- package/src/components/content/Card.astro +2 -3
- package/src/components/content/ColorItem.astro +8 -5
- package/src/components/content/Column.astro +1 -1
- package/src/components/content/Prompt.astro +8 -4
- package/src/components/content/Step.astro +3 -4
- package/src/components/content/Tab.astro +2 -3
- package/src/components/content/Tabs.astro +13 -2
- package/src/components/content/TypeTable.astro +5 -5
- package/src/components/content/Update.astro +1 -1
- package/src/components/content/mermaid-element.ts +20 -2
- package/src/components/islands/ask-ai.tsx +4 -8
- package/src/components/islands/base-path.ts +30 -0
- package/src/components/islands/hooks.ts +12 -8
- package/src/components/layout/Header.astro +26 -3
- package/src/components/layout/Logo.astro +5 -1
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/NavTree.astro +14 -14
- package/src/components/layout/PageActions.astro +17 -11
- package/src/components/layout/PageLayout.astro +42 -0
- package/src/components/layout/ReferenceLayout.astro +1 -0
- package/src/components/layout/RootLayout.astro +31 -2
- package/src/components/layout/Search.astro +9 -6
- package/src/components/layout/nav-utils.ts +9 -4
- package/src/components/layout/search/types.ts +16 -5
- package/src/components/openapi/ApiOverview.astro +4 -50
- package/src/components/openapi/ApiTagOperations.astro +42 -0
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/SchemaProperty.astro +1 -1
- package/src/components/openapi/SchemaTable.astro +3 -3
- package/src/components/openapi/helpers.ts +17 -8
- package/src/components/openapi/snippets.ts +17 -4
- package/src/core/builtin-tags.ts +1 -3
- package/src/core/config.ts +19 -33
- package/src/core/data.ts +2 -2
- package/src/core/graph.ts +7 -5
- package/src/core/links.ts +5 -19
- package/src/core/meta.ts +1 -1
- package/src/core/navigation.ts +5 -6
- package/src/core/project-graph.ts +0 -5
- package/src/core/schema.ts +13 -90
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/normalize.ts +28 -6
- package/src/core/sources/resolve.ts +0 -13
- package/src/core/sources/watch.ts +45 -0
- package/src/core/types.ts +1 -8
- package/src/markdown/code-title.ts +6 -3
- package/src/markdown/package-install.ts +3 -1
- package/src/og/card.ts +16 -4
- package/src/openapi/parse.ts +197 -14
- package/src/openapi/render-mdx.ts +54 -11
- package/src/openapi/source.ts +19 -2
- package/src/search/sync/orama-cloud.ts +2 -0
- package/src/search/sync/typesense.ts +4 -0
- package/src/theme/entry.ts +45 -17
- package/src/theme/icons.ts +22 -104
- package/src/theme/palette.ts +35 -47
- package/src/theme/twoslash.ts +6 -1
- package/dist/types/core/bridge.d.ts +0 -24
- package/dist/types/migrate/mintlify/assets.d.ts +0 -8
- package/dist/types/migrate/mintlify/config.d.ts +0 -16
- package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
- package/docs/advanced/bridge.mdx +0 -76
- package/docs/advanced/migrate.mdx +0 -123
- package/src/astro/static-assets.ts +0 -117
- package/src/cli/commands/migrate.ts +0 -39
- package/src/components/content/ApiField.astro +0 -75
- package/src/components/content/ParamField.astro +0 -39
- package/src/components/content/RequestField.astro +0 -23
- package/src/components/content/ResponseField.astro +0 -23
- package/src/components/content/Warning.astro +0 -9
- package/src/core/assets.ts +0 -31
- package/src/core/bridge.ts +0 -102
- package/src/core/sources/mintlify.ts +0 -204
- package/src/migrate/fumadocs/config.ts +0 -155
- package/src/migrate/fumadocs/content.ts +0 -378
- package/src/migrate/fumadocs/frontmatter.ts +0 -18
- package/src/migrate/fumadocs/groups.ts +0 -237
- package/src/migrate/fumadocs/index.ts +0 -340
- package/src/migrate/fumadocs/meta.ts +0 -244
- package/src/migrate/migrate.ts +0 -53
- package/src/migrate/mintlify/assets.ts +0 -46
- package/src/migrate/mintlify/config.ts +0 -958
- package/src/migrate/mintlify/content.ts +0 -120
- package/src/migrate/mintlify/frontmatter.ts +0 -126
- package/src/migrate/mintlify/i18n.ts +0 -51
- package/src/migrate/mintlify/icons.ts +0 -128
- package/src/migrate/mintlify/index.ts +0 -372
- package/src/migrate/mintlify/snippets.ts +0 -306
- package/src/migrate/mintlify/transform.ts +0 -82
- package/src/migrate/nextra/content.ts +0 -46
- package/src/migrate/nextra/frontmatter.ts +0 -40
- package/src/migrate/nextra/index.ts +0 -374
- package/src/migrate/nextra/meta.ts +0 -266
- package/src/migrate/shared.ts +0 -728
- package/src/migrate/starlight/config.ts +0 -455
- package/src/migrate/starlight/content.ts +0 -78
- package/src/migrate/starlight/frontmatter.ts +0 -111
- package/src/migrate/starlight/i18n.ts +0 -54
- package/src/migrate/starlight/index.ts +0 -131
|
@@ -21,7 +21,7 @@ const displayValue =
|
|
|
21
21
|
|
|
22
22
|
<button
|
|
23
23
|
aria-label={`Copy ${name} color ${displayValue}`}
|
|
24
|
-
class="group flex w-full
|
|
24
|
+
class="group flex w-full cursor-pointer flex-col gap-2.5 text-center focus:outline-none"
|
|
25
25
|
data-blume-color-copy={displayValue}
|
|
26
26
|
style={`--blume-color-light:${lightValue};--blume-color-dark:${darkValue};`}
|
|
27
27
|
type="button"
|
|
@@ -32,7 +32,7 @@ const displayValue =
|
|
|
32
32
|
style="background:var(--blume-color-light)"
|
|
33
33
|
>
|
|
34
34
|
</span>
|
|
35
|
-
<span class="block
|
|
35
|
+
<span class="block leading-tight">
|
|
36
36
|
<span class="block truncate font-semibold text-foreground text-sm">
|
|
37
37
|
{name}
|
|
38
38
|
</span>
|
|
@@ -46,7 +46,8 @@ const displayValue =
|
|
|
46
46
|
</button>
|
|
47
47
|
|
|
48
48
|
<style>
|
|
49
|
-
|
|
49
|
+
/* Dark mode is data-theme="dark" on <html> (see theme/entry.ts), not a class. */
|
|
50
|
+
:global([data-theme="dark"]) [data-blume-color-swatch] {
|
|
50
51
|
background: var(--blume-color-dark, var(--blume-color-light));
|
|
51
52
|
}
|
|
52
53
|
</style>
|
|
@@ -77,10 +78,12 @@ const displayValue =
|
|
|
77
78
|
return;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
// Remember the real value once — capturing at click time would capture
|
|
82
|
+
// "Copied" on a double-click and stick until reload.
|
|
83
|
+
status.dataset.blumeLabel ??= status.textContent ?? "";
|
|
81
84
|
status.textContent = "Copied";
|
|
82
85
|
window.setTimeout(() => {
|
|
83
|
-
status.textContent =
|
|
86
|
+
status.textContent = status.dataset.blumeLabel ?? "";
|
|
84
87
|
}, 1500);
|
|
85
88
|
});
|
|
86
89
|
}
|
|
@@ -45,7 +45,7 @@ const secondaryButton =
|
|
|
45
45
|
>
|
|
46
46
|
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
|
47
47
|
<p
|
|
48
|
-
class="m-0!
|
|
48
|
+
class="m-0! text-foreground text-sm [&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_strong]:font-semibold [&_strong]:text-foreground"
|
|
49
49
|
data-blume-prompt-description
|
|
50
50
|
set:html={descriptionHtml}
|
|
51
51
|
/>
|
|
@@ -110,14 +110,18 @@ const secondaryButton =
|
|
|
110
110
|
return;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
const label = copy.querySelector(
|
|
113
|
+
const label = copy.querySelector<HTMLElement>(
|
|
114
|
+
"[data-blume-prompt-copy-label]"
|
|
115
|
+
);
|
|
114
116
|
if (!label) {
|
|
115
117
|
return;
|
|
116
118
|
}
|
|
117
|
-
|
|
119
|
+
// Remember the real label once — capturing at click time would
|
|
120
|
+
// capture "Copied" on a double-click and stick until reload.
|
|
121
|
+
label.dataset.blumeLabel ??= label.textContent ?? "Copy prompt";
|
|
118
122
|
label.textContent = "Copied";
|
|
119
123
|
setTimeout(() => {
|
|
120
|
-
label.textContent =
|
|
124
|
+
label.textContent = label.dataset.blumeLabel ?? "Copy prompt";
|
|
121
125
|
}, 1500);
|
|
122
126
|
});
|
|
123
127
|
}
|
|
@@ -4,11 +4,10 @@ import { hasIcon } from "../../theme/icons.ts";
|
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
icon?: string;
|
|
7
|
-
iconType?: string;
|
|
8
7
|
title?: string;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
const { icon,
|
|
10
|
+
const { icon, title } = Astro.props;
|
|
12
11
|
const markerClass =
|
|
13
12
|
"absolute top-0 -start-[2.75rem] flex size-6 items-center justify-center rounded-full bg-accent text-center font-semibold text-accent-foreground text-xs";
|
|
14
13
|
const isImageIcon = (value: string): boolean =>
|
|
@@ -18,14 +17,14 @@ const canRenderIcon =
|
|
|
18
17
|
icon !== undefined &&
|
|
19
18
|
(/^\s*<svg[\s\S]*<\/svg>\s*$/u.test(icon) ||
|
|
20
19
|
isImageIcon(icon) ||
|
|
21
|
-
hasIcon(icon
|
|
20
|
+
hasIcon(icon));
|
|
22
21
|
---
|
|
23
22
|
|
|
24
23
|
<div
|
|
25
24
|
class="relative [counter-increment:blume-step]"
|
|
26
25
|
>
|
|
27
26
|
<span class:list={[markerClass, !canRenderIcon && "before:[content:counter(blume-step)]"]}>
|
|
28
|
-
{canRenderIcon && <Icon
|
|
27
|
+
{canRenderIcon && <Icon name={icon} size={14} />}
|
|
29
28
|
</span>
|
|
30
29
|
{title && <p class="mb-1.5 font-semibold">{title}</p>}
|
|
31
30
|
<div class="[&>:first-child]:mt-0! [&>:last-child]:mb-0!">
|
|
@@ -4,13 +4,12 @@ import Icon from "../Icon.astro";
|
|
|
4
4
|
interface Props {
|
|
5
5
|
class?: string;
|
|
6
6
|
icon?: string;
|
|
7
|
-
iconType?: string;
|
|
8
7
|
id?: string;
|
|
9
8
|
style?: string;
|
|
10
9
|
title: string;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
const { class: className, icon,
|
|
12
|
+
const { class: className, icon, id, style, title } = Astro.props;
|
|
14
13
|
const slugify = (value: string): string =>
|
|
15
14
|
value
|
|
16
15
|
.toLowerCase()
|
|
@@ -39,7 +38,7 @@ const tabId = id ?? slugify(title);
|
|
|
39
38
|
{
|
|
40
39
|
icon && (
|
|
41
40
|
<template data-blume-tab-icon>
|
|
42
|
-
<Icon class="shrink-0"
|
|
41
|
+
<Icon class="shrink-0" name={icon} size={14} />
|
|
43
42
|
</template>
|
|
44
43
|
)
|
|
45
44
|
}
|
|
@@ -3,7 +3,7 @@ interface Props {
|
|
|
3
3
|
borderBottom?: boolean;
|
|
4
4
|
defaultTabIndex?: number;
|
|
5
5
|
dropdown?: boolean;
|
|
6
|
-
hash?: boolean;
|
|
6
|
+
hash?: boolean | "false" | "true";
|
|
7
7
|
sync?: boolean;
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -14,6 +14,10 @@ const {
|
|
|
14
14
|
hash = true,
|
|
15
15
|
sync = true,
|
|
16
16
|
} = Astro.props;
|
|
17
|
+
|
|
18
|
+
// MDX string attributes (`hash="false"` from generated markup) must read as
|
|
19
|
+
// their boolean meaning — the string "false" is truthy.
|
|
20
|
+
const hashEnabled = String(hash) !== "false";
|
|
17
21
|
---
|
|
18
22
|
|
|
19
23
|
<blume-tabs
|
|
@@ -21,7 +25,7 @@ const {
|
|
|
21
25
|
data-border-bottom={borderBottom ? "true" : "false"}
|
|
22
26
|
data-default-tab-index={defaultTabIndex}
|
|
23
27
|
data-dropdown={dropdown ? "true" : "false"}
|
|
24
|
-
data-hash={
|
|
28
|
+
data-hash={hashEnabled ? "true" : "false"}
|
|
25
29
|
data-sync={sync ? "true" : "false"}
|
|
26
30
|
>
|
|
27
31
|
<div class:list={[dropdown ? "" : "overflow-x-auto"]}>
|
|
@@ -100,6 +104,13 @@ const {
|
|
|
100
104
|
return;
|
|
101
105
|
}
|
|
102
106
|
if (this.dataset.blumeTabsReady === "true") {
|
|
107
|
+
// The built DOM survives a move; the listeners removed by
|
|
108
|
+
// disconnectedCallback don't — re-attach them (addEventListener with
|
|
109
|
+
// the same reference is idempotent).
|
|
110
|
+
if (this.#syncEnabled()) {
|
|
111
|
+
document.addEventListener(SYNC_EVENT, this.#sync);
|
|
112
|
+
}
|
|
113
|
+
window.addEventListener("hashchange", this.#hashChange);
|
|
103
114
|
return;
|
|
104
115
|
}
|
|
105
116
|
this.dataset.blumeTabsReady = "true";
|
|
@@ -37,19 +37,19 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
|
|
|
37
37
|
{entries.map(([name, info]) => (
|
|
38
38
|
<details class="group/tt border-border border-t [[open]+&]:border-t-transparent open:rounded-blume open:border-transparent open:bg-background open:shadow-sm open:ring-1 open:ring-border">
|
|
39
39
|
<summary class={`${columns} cursor-pointer list-none px-3 py-2.5 [&::-webkit-details-marker]:hidden`}>
|
|
40
|
-
<code class="
|
|
40
|
+
<code class="break-words font-mono text-accent text-xs">
|
|
41
41
|
{name}
|
|
42
42
|
{info.required ? "" : "?"}
|
|
43
43
|
</code>
|
|
44
44
|
{info.typeDescriptionLink ? (
|
|
45
45
|
<a
|
|
46
|
-
class="
|
|
46
|
+
class="break-words font-mono text-foreground text-xs underline-offset-2 hover:underline"
|
|
47
47
|
href={info.typeDescriptionLink}
|
|
48
48
|
>
|
|
49
49
|
{info.type}
|
|
50
50
|
</a>
|
|
51
51
|
) : (
|
|
52
|
-
<code class="
|
|
52
|
+
<code class="break-words font-mono text-foreground text-xs">
|
|
53
53
|
{info.type}
|
|
54
54
|
</code>
|
|
55
55
|
)}
|
|
@@ -81,7 +81,7 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
|
|
|
81
81
|
<div class="space-y-1.5">
|
|
82
82
|
<div class={columns}>
|
|
83
83
|
<span class="text-muted-foreground">Type</span>
|
|
84
|
-
<code class="
|
|
84
|
+
<code class="break-words font-mono text-foreground">
|
|
85
85
|
{info.type}
|
|
86
86
|
</code>
|
|
87
87
|
<span aria-hidden="true" class="size-3.5" />
|
|
@@ -89,7 +89,7 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
|
|
|
89
89
|
{info.default && (
|
|
90
90
|
<div class={columns}>
|
|
91
91
|
<span class="text-muted-foreground">Default</span>
|
|
92
|
-
<code class="
|
|
92
|
+
<code class="break-words font-mono text-foreground">
|
|
93
93
|
{info.default}
|
|
94
94
|
</code>
|
|
95
95
|
<span aria-hidden="true" class="size-3.5" />
|
|
@@ -60,7 +60,7 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
|
|
|
60
60
|
)
|
|
61
61
|
}
|
|
62
62
|
</header>
|
|
63
|
-
<div class="
|
|
63
|
+
<div class="text-muted-foreground text-sm leading-6 [&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_h2]:mt-0! [&_h2]:border-t-0! [&_h2]:pt-0! [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:font-semibold [&_h3]:text-foreground [&_img]:rounded-blume [&_li]:my-1 [&_p:first-child]:mt-0! [&_p:last-child]:mb-0!">
|
|
64
64
|
<slot />
|
|
65
65
|
</div>
|
|
66
66
|
</article>
|
|
@@ -24,6 +24,9 @@ const prefersDark = () => document.documentElement.dataset.theme === "dark";
|
|
|
24
24
|
let counter = 0;
|
|
25
25
|
|
|
26
26
|
class BlumeMermaid extends HTMLElement {
|
|
27
|
+
#observer: MutationObserver | null = null;
|
|
28
|
+
#renderToken = 0;
|
|
29
|
+
|
|
27
30
|
connectedCallback() {
|
|
28
31
|
const source = this.dataset.source ?? "";
|
|
29
32
|
if (!source.trim()) {
|
|
@@ -35,6 +38,8 @@ class BlumeMermaid extends HTMLElement {
|
|
|
35
38
|
this.replaceChildren(output);
|
|
36
39
|
|
|
37
40
|
const render = async () => {
|
|
41
|
+
this.#renderToken += 1;
|
|
42
|
+
const token = this.#renderToken;
|
|
38
43
|
const mermaid = await loadMermaid();
|
|
39
44
|
mermaid.initialize({
|
|
40
45
|
securityLevel: "strict",
|
|
@@ -47,7 +52,11 @@ class BlumeMermaid extends HTMLElement {
|
|
|
47
52
|
`blume-mermaid-${counter}`,
|
|
48
53
|
source
|
|
49
54
|
);
|
|
50
|
-
|
|
55
|
+
// A newer render (rapid theme toggles) superseded this one — dropping
|
|
56
|
+
// the stale result keeps the diagram in the latest theme.
|
|
57
|
+
if (token === this.#renderToken) {
|
|
58
|
+
output.innerHTML = svg;
|
|
59
|
+
}
|
|
51
60
|
} catch {
|
|
52
61
|
output.textContent = "Could not render this diagram.";
|
|
53
62
|
}
|
|
@@ -57,10 +66,19 @@ class BlumeMermaid extends HTMLElement {
|
|
|
57
66
|
render();
|
|
58
67
|
|
|
59
68
|
// Re-render on color-theme changes so the diagram tracks light and dark.
|
|
60
|
-
|
|
69
|
+
// One observer per connection, disconnected on removal — otherwise every
|
|
70
|
+
// DOM move stacks another observer that renders into detached DOM forever.
|
|
71
|
+
this.#observer?.disconnect();
|
|
72
|
+
this.#observer = new MutationObserver(() => render());
|
|
73
|
+
this.#observer.observe(document.documentElement, {
|
|
61
74
|
attributeFilter: ["data-theme"],
|
|
62
75
|
});
|
|
63
76
|
}
|
|
77
|
+
|
|
78
|
+
disconnectedCallback() {
|
|
79
|
+
this.#observer?.disconnect();
|
|
80
|
+
this.#observer = null;
|
|
81
|
+
}
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
if (!customElements.get("blume-mermaid")) {
|
|
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|
|
2
2
|
import type { FormEvent } from "react";
|
|
3
3
|
|
|
4
4
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
5
|
+
import { joinBase, stripBase } from "./base-path.ts";
|
|
5
6
|
|
|
6
7
|
interface ChatMessage {
|
|
7
8
|
id: number;
|
|
@@ -27,16 +28,11 @@ const nextId = (): number => {
|
|
|
27
28
|
|
|
28
29
|
// The endpoint and page path both honor the deployment `base` so grounding works
|
|
29
30
|
// under a non-root base path (the server matches base-less document routes).
|
|
30
|
-
const ASK_ENDPOINT =
|
|
31
|
+
const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
31
32
|
|
|
32
33
|
/** The current route with the deployment base stripped, for page-context lookup. */
|
|
33
|
-
const currentPath = (): string =>
|
|
34
|
-
|
|
35
|
-
const path = window.location.pathname;
|
|
36
|
-
return base.length > 1 && path.startsWith(base)
|
|
37
|
-
? `/${path.slice(base.length)}`
|
|
38
|
-
: path;
|
|
39
|
-
};
|
|
34
|
+
const currentPath = (): string =>
|
|
35
|
+
stripBase(import.meta.env.BASE_URL, window.location.pathname);
|
|
40
36
|
|
|
41
37
|
const BUTTON_CLASS =
|
|
42
38
|
"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";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base-path helpers for client islands. Astro's default `trailingSlash:
|
|
3
|
+
* "ignore"` passes `deployment.base` through as-is, so `BASE_URL` may arrive
|
|
4
|
+
* with or without a trailing slash (`/docs` or `/docs/`); every consumer must
|
|
5
|
+
* treat both forms the same or endpoints/grounding break under a base path.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The base with a guaranteed trailing slash (`/docs` -> `/docs/`). */
|
|
9
|
+
export const withTrailingSlash = (base: string): string =>
|
|
10
|
+
base.endsWith("/") ? base : `${base}/`;
|
|
11
|
+
|
|
12
|
+
/** Join a base-relative path (`api/ask`) onto the deployment base. */
|
|
13
|
+
export const joinBase = (base: string, path: string): string =>
|
|
14
|
+
`${withTrailingSlash(base)}${path}`;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A pathname with the deployment base stripped (`/docs/guide` -> `/guide`),
|
|
18
|
+
* for page-context lookups against base-less document routes.
|
|
19
|
+
*/
|
|
20
|
+
export const stripBase = (base: string, pathname: string): string => {
|
|
21
|
+
const slashed = withTrailingSlash(base);
|
|
22
|
+
if (slashed === "/") {
|
|
23
|
+
return pathname;
|
|
24
|
+
}
|
|
25
|
+
if (pathname.startsWith(slashed)) {
|
|
26
|
+
return `/${pathname.slice(slashed.length)}`;
|
|
27
|
+
}
|
|
28
|
+
// The bare base itself ("/docs") is the base-less root.
|
|
29
|
+
return `${pathname}/` === slashed ? "/" : pathname;
|
|
30
|
+
};
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
|
|
3
3
|
import type { BlumeClientData } from "../../core/data.ts";
|
|
4
4
|
import type { SearchFn, SearchResult } from "../layout/search/types.ts";
|
|
5
|
+
import { joinBase, stripBase } from "./base-path.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* React hooks for Blume islands.
|
|
@@ -117,16 +118,11 @@ export interface UseAskAI {
|
|
|
117
118
|
reset: () => void;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
const ASK_ENDPOINT =
|
|
121
|
+
const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
121
122
|
|
|
122
123
|
/** The current route with the deployment base stripped, for page grounding. */
|
|
123
|
-
const currentPath = (): string =>
|
|
124
|
-
|
|
125
|
-
const path = window.location.pathname;
|
|
126
|
-
return base.length > 1 && path.startsWith(base)
|
|
127
|
-
? `/${path.slice(base.length)}`
|
|
128
|
-
: path;
|
|
129
|
-
};
|
|
124
|
+
const currentPath = (): string =>
|
|
125
|
+
stripBase(import.meta.env.BASE_URL, window.location.pathname);
|
|
130
126
|
|
|
131
127
|
/**
|
|
132
128
|
* Stream answers from the Ask AI endpoint. Mirrors the built-in Ask AI island so
|
|
@@ -158,6 +154,14 @@ export const useAskAI = (): UseAskAI => {
|
|
|
158
154
|
headers: { "content-type": "application/json" },
|
|
159
155
|
method: "POST",
|
|
160
156
|
});
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
// An error body (JSON, HTML error page) must not stream in as the
|
|
159
|
+
// assistant's answer.
|
|
160
|
+
assistant.content =
|
|
161
|
+
"Something went wrong answering that. Please try again.";
|
|
162
|
+
setMessages([...history, { ...assistant }]);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
161
165
|
const reader = response.body?.getReader();
|
|
162
166
|
const decoder = new TextDecoder();
|
|
163
167
|
if (reader) {
|
|
@@ -18,6 +18,7 @@ interface Props {
|
|
|
18
18
|
dark?: string;
|
|
19
19
|
alt: string;
|
|
20
20
|
href: string;
|
|
21
|
+
text?: string;
|
|
21
22
|
} | null;
|
|
22
23
|
navigation: Navigation;
|
|
23
24
|
route: string;
|
|
@@ -57,6 +58,19 @@ const {
|
|
|
57
58
|
const LogoSlot = resolveSlot(layout.Logo, Logo);
|
|
58
59
|
const SearchSlot = resolveSlot(layout.Search, Search);
|
|
59
60
|
|
|
61
|
+
// The mobile nav-toggle opens a drawer of navigation. On docs pages that drawer
|
|
62
|
+
// is the sidebar tree (`hasSidebar`); on chrome-only pages (a landing page via
|
|
63
|
+
// PageLayout) it's a tabs-only drawer the layout renders — so the button is also
|
|
64
|
+
// needed whenever there are tabs to reveal.
|
|
65
|
+
const showNavToggle = hasSidebar || navigation.tabs.length > 0;
|
|
66
|
+
// Where the header's inline tab bar appears. With a sidebar it shares the `md`
|
|
67
|
+
// breakpoint with the docs drawer; without one, the tabs-only drawer is the sole
|
|
68
|
+
// mobile nav below `lg`, so the inline tabs wait until `lg` to avoid duplicating
|
|
69
|
+
// the drawer's links between `md` and `lg`.
|
|
70
|
+
const tabsNavClass = hasSidebar
|
|
71
|
+
? "hidden gap-1 md:flex"
|
|
72
|
+
: "hidden gap-1 lg:flex";
|
|
73
|
+
|
|
60
74
|
const iconButton =
|
|
61
75
|
"inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
|
|
62
76
|
|
|
@@ -68,14 +82,23 @@ const iconButton =
|
|
|
68
82
|
// styles fade while the non-animated ones snap, so we disable all transitions
|
|
69
83
|
// for the swap (next-themes' disableTransitionOnChange trick), force a
|
|
70
84
|
// synchronous style flush, then restore them on the next tick.
|
|
71
|
-
|
|
85
|
+
//
|
|
86
|
+
// The mobile drawer sits below the sticky header, but the banner above the
|
|
87
|
+
// header has no fixed height (its text wraps), so the drawer's top offset can't
|
|
88
|
+
// be static: on open we measure the header's on-screen bottom edge into
|
|
89
|
+
// `--blume-drawer-top` (read by the drawer's `top`/`height`), lock page scroll
|
|
90
|
+
// so the offset can't drift while open, and re-measure when a banner dismiss or
|
|
91
|
+
// a resize/rotation moves the header. Crossing into the desktop breakpoint
|
|
92
|
+
// (64rem, Tailwind `lg`) closes the drawer so the scroll lock can't outlive it.
|
|
93
|
+
const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-blume-header]");if(h){document.documentElement.style.setProperty("--blume-drawer-top",h.getBoundingClientRect().bottom+"px");}};document.addEventListener("click",(e)=>{const t=e.target.closest("[data-blume-theme-toggle]");if(t){const d=document.documentElement;const next=d.dataset.theme==="dark"?"light":"dark";const s=document.createElement("style");s.appendChild(document.createTextNode("*,*::before,*::after{transition:none!important}"));document.head.appendChild(s);d.dataset.theme=next;localStorage.setItem("blume-theme",next);window.getComputedStyle(d).opacity;setTimeout(()=>document.head.removeChild(s),1);}const n=e.target.closest("[data-blume-nav-toggle]");if(n){const d=document.documentElement;const open=d.toggleAttribute("data-blume-nav-open");if(open){dr();}d.style.overflow=open?"hidden":"";}const b=e.target.closest("[data-blume-banner-dismiss]");if(b){const bar=b.closest("[data-blume-banner]");const k=bar&&bar.getAttribute("data-banner-key");if(k){localStorage.setItem("blume-banner:"+k,"1");}document.documentElement.setAttribute("data-blume-banner-hidden","");requestAnimationFrame(dr);}});addEventListener("resize",()=>{const d=document.documentElement;if(!d.hasAttribute("data-blume-nav-open")){return;}if(matchMedia("(min-width:64rem)").matches){d.removeAttribute("data-blume-nav-open");d.style.overflow="";}else{dr();}});})();`;
|
|
72
94
|
---
|
|
73
95
|
|
|
74
96
|
<header
|
|
75
97
|
class="sticky top-0 z-40 flex h-16 items-center gap-3 bg-background/90 px-4 backdrop-blur md:px-6"
|
|
98
|
+
data-blume-header
|
|
76
99
|
>
|
|
77
100
|
{
|
|
78
|
-
|
|
101
|
+
showNavToggle && (
|
|
79
102
|
<button
|
|
80
103
|
aria-label="Toggle navigation"
|
|
81
104
|
class={`${iconButton} lg:hidden`}
|
|
@@ -98,7 +121,7 @@ const clickScript = `document.addEventListener("click",(e)=>{const t=e.target.cl
|
|
|
98
121
|
}
|
|
99
122
|
{
|
|
100
123
|
navigation.tabs.length > 0 && (
|
|
101
|
-
<nav aria-label="Sections" class=
|
|
124
|
+
<nav aria-label="Sections" class={tabsNavClass}>
|
|
102
125
|
{navigation.tabs.map((tab) => (
|
|
103
126
|
<a
|
|
104
127
|
aria-current={
|
|
@@ -19,6 +19,10 @@ const logoLight = logo?.light;
|
|
|
19
19
|
const logoDark = logo?.dark ?? logo?.light;
|
|
20
20
|
const logoAlt = logo?.alt ?? "";
|
|
21
21
|
const brandHref = logo?.href ?? "/";
|
|
22
|
+
// Wordmark beside the mark: the configured `logo.text`, or the site title when
|
|
23
|
+
// it's omitted. An explicit `""` hides it (an image-only logo whose mark already
|
|
24
|
+
// carries the wordmark).
|
|
25
|
+
const brandText = logo?.text ?? site.title;
|
|
22
26
|
---
|
|
23
27
|
|
|
24
28
|
<a
|
|
@@ -46,5 +50,5 @@ const brandHref = logo?.href ?? "/";
|
|
|
46
50
|
</>
|
|
47
51
|
))
|
|
48
52
|
}
|
|
49
|
-
{
|
|
53
|
+
{brandText && <span>{brandText}</span>}
|
|
50
54
|
</a>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
// A top-level navigation selector
|
|
2
|
+
// A top-level navigation selector: a dropdown that switches
|
|
3
3
|
// between partitions of the site — a product, a version, or any grouped set of
|
|
4
4
|
// destinations (`navigation.selectors` in the config). Zero-JS, built on
|
|
5
5
|
// <details>/<summary> like the language switcher.
|
|
@@ -129,13 +129,13 @@ const initialId =
|
|
|
129
129
|
{panel.route ? (
|
|
130
130
|
<a
|
|
131
131
|
aria-current={panel.route === currentRoute ? "page" : undefined}
|
|
132
|
-
class="
|
|
132
|
+
class="flex-1 truncate font-semibold text-foreground text-sm hover:underline"
|
|
133
133
|
href={panel.route}
|
|
134
134
|
>
|
|
135
135
|
{panel.label}
|
|
136
136
|
</a>
|
|
137
137
|
) : (
|
|
138
|
-
<span class="
|
|
138
|
+
<span class="flex-1 truncate font-semibold text-foreground text-sm">
|
|
139
139
|
{panel.label}
|
|
140
140
|
</span>
|
|
141
141
|
)}
|
|
@@ -161,7 +161,7 @@ const initialId =
|
|
|
161
161
|
class="block rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:font-medium aria-[current=page]:text-foreground"
|
|
162
162
|
href={item.route}
|
|
163
163
|
>
|
|
164
|
-
<span class="flex
|
|
164
|
+
<span class="flex items-center gap-2">
|
|
165
165
|
{item.icon && (
|
|
166
166
|
<Icon
|
|
167
167
|
class="shrink-0 text-muted-foreground"
|
|
@@ -169,7 +169,7 @@ const initialId =
|
|
|
169
169
|
size={14}
|
|
170
170
|
/>
|
|
171
171
|
)}
|
|
172
|
-
<span class="
|
|
172
|
+
<span class="flex-1 truncate">{item.label}</span>
|
|
173
173
|
{item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
|
|
174
174
|
{item.deprecated && (
|
|
175
175
|
<span class={deprecatedClass}>deprecated</span>
|
|
@@ -192,7 +192,7 @@ const initialId =
|
|
|
192
192
|
return (
|
|
193
193
|
<li class={spacing}>
|
|
194
194
|
<button
|
|
195
|
-
class="flex w-full
|
|
195
|
+
class="flex w-full items-center gap-2 rounded-[0.65rem] px-2.5 py-1.5 text-left font-medium text-foreground text-sm transition-colors hover:bg-muted"
|
|
196
196
|
data-nav-to={id}
|
|
197
197
|
type="button"
|
|
198
198
|
>
|
|
@@ -203,7 +203,7 @@ const initialId =
|
|
|
203
203
|
size={14}
|
|
204
204
|
/>
|
|
205
205
|
)}
|
|
206
|
-
<span class="
|
|
206
|
+
<span class="flex-1 truncate">{item.label}</span>
|
|
207
207
|
{item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
|
|
208
208
|
<Icon
|
|
209
209
|
class="shrink-0 text-muted-foreground"
|
|
@@ -222,13 +222,13 @@ const initialId =
|
|
|
222
222
|
return (
|
|
223
223
|
<li class={spacing}>
|
|
224
224
|
<details class="group" open={open}>
|
|
225
|
-
<summary class="
|
|
225
|
+
<summary class="flex cursor-pointer list-none items-center gap-1.5 rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden">
|
|
226
226
|
{item.route ? (
|
|
227
227
|
<a
|
|
228
228
|
aria-current={
|
|
229
229
|
item.route === currentRoute ? "page" : undefined
|
|
230
230
|
}
|
|
231
|
-
class="-my-1 flex
|
|
231
|
+
class="-my-1 flex flex-1 items-center gap-1.5 rounded py-1 transition-colors aria-[current=page]:text-foreground aria-[current=page]:font-semibold"
|
|
232
232
|
href={item.route}
|
|
233
233
|
>
|
|
234
234
|
{item.icon && (
|
|
@@ -238,7 +238,7 @@ const initialId =
|
|
|
238
238
|
size={14}
|
|
239
239
|
/>
|
|
240
240
|
)}
|
|
241
|
-
<span class="
|
|
241
|
+
<span class="flex-1 truncate">{item.label}</span>
|
|
242
242
|
{item.badge && (
|
|
243
243
|
<span class={badgeClassFor(item.badge)}>{item.badge}</span>
|
|
244
244
|
)}
|
|
@@ -252,7 +252,7 @@ const initialId =
|
|
|
252
252
|
size={14}
|
|
253
253
|
/>
|
|
254
254
|
)}
|
|
255
|
-
<span class="
|
|
255
|
+
<span class="flex-1 truncate">{item.label}</span>
|
|
256
256
|
{item.badge && (
|
|
257
257
|
<span class={badgeClassFor(item.badge)}>{item.badge}</span>
|
|
258
258
|
)}
|
|
@@ -279,11 +279,11 @@ const initialId =
|
|
|
279
279
|
// `flat` (default): a non-collapsible header with its items beneath.
|
|
280
280
|
return (
|
|
281
281
|
<li class={spacing}>
|
|
282
|
-
<p class="mb-2 flex
|
|
282
|
+
<p class="mb-2 flex items-center gap-1.5 px-2.5 font-medium text-foreground text-sm">
|
|
283
283
|
{item.route ? (
|
|
284
284
|
<a
|
|
285
285
|
aria-current={item.route === currentRoute ? "page" : undefined}
|
|
286
|
-
class="-ml-1 flex
|
|
286
|
+
class="-ml-1 flex flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
|
|
287
287
|
href={item.route}
|
|
288
288
|
>
|
|
289
289
|
{item.icon && (
|
|
@@ -293,7 +293,7 @@ const initialId =
|
|
|
293
293
|
size={14}
|
|
294
294
|
/>
|
|
295
295
|
)}
|
|
296
|
-
<span class="
|
|
296
|
+
<span class="truncate">{item.label}</span>
|
|
297
297
|
{item.badge && (
|
|
298
298
|
<span class={`ml-auto ${badgeClassFor(item.badge)}`}>{item.badge}</span>
|
|
299
299
|
)}
|
|
@@ -307,7 +307,7 @@ const initialId =
|
|
|
307
307
|
size={14}
|
|
308
308
|
/>
|
|
309
309
|
)}
|
|
310
|
-
<span class="
|
|
310
|
+
<span class="truncate">{item.label}</span>
|
|
311
311
|
{item.badge && (
|
|
312
312
|
<span class={`ml-auto ${badgeClassFor(item.badge)}`}>{item.badge}</span>
|
|
313
313
|
)}
|
|
@@ -311,7 +311,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
311
311
|
.replace(/[^a-z0-9]+/gu, "-")
|
|
312
312
|
.replace(/^-+|-+$/gu, "") || "docs";
|
|
313
313
|
|
|
314
|
-
|
|
314
|
+
// Base64 output can contain `+`/`=`, which a query string mangles
|
|
315
|
+
// (`+` decodes as a space) — it must be URL-encoded like any other value.
|
|
316
|
+
const cursorConfig = encodeURIComponent(btoa(JSON.stringify({ url: mcpUrl })));
|
|
315
317
|
root
|
|
316
318
|
.querySelector("[data-mcp-cursor]")
|
|
317
319
|
?.setAttribute(
|
|
@@ -325,20 +327,22 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
325
327
|
`vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: id, type: "http", url: mcpUrl }))}`
|
|
326
328
|
);
|
|
327
329
|
|
|
328
|
-
const flash = (el: Element | null, text: string
|
|
329
|
-
if (el) {
|
|
330
|
-
|
|
331
|
-
setTimeout(() => {
|
|
332
|
-
el.textContent = reset;
|
|
333
|
-
}, 1500);
|
|
330
|
+
const flash = (el: Element | null, text: string) => {
|
|
331
|
+
if (!(el instanceof HTMLElement)) {
|
|
332
|
+
return;
|
|
334
333
|
}
|
|
334
|
+
// Remember the element's own (localized) label once — capturing it at
|
|
335
|
+
// click time would capture "Copied!" on a double-click and stick.
|
|
336
|
+
el.dataset.blumeLabel ??= el.textContent ?? "";
|
|
337
|
+
el.textContent = text;
|
|
338
|
+
setTimeout(() => {
|
|
339
|
+
el.textContent = el.dataset.blumeLabel ?? "";
|
|
340
|
+
}, 1500);
|
|
335
341
|
};
|
|
336
342
|
const copy = async (value: string, el: Element | null) => {
|
|
337
|
-
// Restore the element's own label, so localized text round-trips.
|
|
338
|
-
const reset = el?.textContent ?? "";
|
|
339
343
|
try {
|
|
340
344
|
await navigator.clipboard.writeText(value);
|
|
341
|
-
flash(el, copiedLabel
|
|
345
|
+
flash(el, copiedLabel);
|
|
342
346
|
} catch {
|
|
343
347
|
// Clipboard unavailable; nothing to do.
|
|
344
348
|
}
|
|
@@ -359,10 +363,12 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
359
363
|
}
|
|
360
364
|
|
|
361
365
|
const label = root.querySelector("[data-blume-copy-label]");
|
|
366
|
+
// Captured once — inside the handler a double-click would capture and
|
|
367
|
+
// permanently restore "Copied!".
|
|
368
|
+
const original = label?.textContent ?? "";
|
|
362
369
|
root
|
|
363
370
|
.querySelector("[data-blume-copy-page]")
|
|
364
371
|
?.addEventListener("click", async () => {
|
|
365
|
-
const original = label?.textContent ?? "";
|
|
366
372
|
try {
|
|
367
373
|
const response = await fetch(md);
|
|
368
374
|
await navigator.clipboard.writeText(await response.text());
|