blume 0.7.0 → 1.0.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/CHANGELOG.md +666 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1852 -380
- package/dist/cli/index.js.map +98 -91
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +47 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +7 -0
- package/dist/types/core/i18n-ui.d.ts +526 -132
- package/dist/types/core/schema.d.ts +293 -146
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/dist/types/seo/x-handle.d.ts +12 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +8 -8
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/api-reference.mdx +1 -1
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +35 -5
- package/docs/content/components.mdx +2 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +6 -4
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +9 -7
- package/docs/reference/frontmatter.mdx +1 -1
- package/package.json +22 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +9 -8
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +135 -26
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +25 -4
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +21 -7
- package/src/ai/mcp/tools.ts +1 -1
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +77 -45
- package/src/astro/integration.ts +1 -1
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +81 -19
- package/src/astro/templates.ts +150 -19
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +120 -23
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +26 -5
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +43 -2
- package/src/cli/dev-lock.ts +8 -4
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/cli/internal-error.ts +9 -4
- package/src/cli/prepare.ts +3 -2
- package/src/components/Icon.astro +2 -1
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +7 -1
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/mermaid-element.ts +7 -2
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +86 -11
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Breadcrumbs.astro +11 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +19 -5
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +85 -9
- package/src/components/layout/Pagination.astro +10 -4
- package/src/components/layout/ReferenceLayout.astro +20 -2
- package/src/components/layout/RootLayout.astro +142 -12
- package/src/components/layout/Search.astro +117 -27
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +1 -1
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +1 -1
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/core/base-path.ts +24 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/config-input.ts +48 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +4 -0
- package/src/core/frontmatter.ts +7 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +54 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/last-modified.ts +13 -6
- package/src/core/links.ts +32 -8
- package/src/core/navigation.ts +29 -4
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +15 -6
- package/src/core/schema.ts +71 -2
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/mdx-remote.ts +23 -14
- package/src/core/sources/normalize.ts +152 -50
- package/src/core/sources/notion.ts +8 -8
- package/src/core/ui-packs/ar.ts +8 -0
- package/src/core/ui-packs/bg.ts +8 -0
- package/src/core/ui-packs/bn.ts +8 -0
- package/src/core/ui-packs/ca.ts +8 -0
- package/src/core/ui-packs/cs.ts +8 -0
- package/src/core/ui-packs/da.ts +8 -0
- package/src/core/ui-packs/de.ts +8 -0
- package/src/core/ui-packs/el.ts +8 -0
- package/src/core/ui-packs/es.ts +8 -0
- package/src/core/ui-packs/fa.ts +8 -0
- package/src/core/ui-packs/fi.ts +8 -0
- package/src/core/ui-packs/fr.ts +9 -1
- package/src/core/ui-packs/he.ts +8 -0
- package/src/core/ui-packs/hi.ts +8 -0
- package/src/core/ui-packs/hr.ts +8 -0
- package/src/core/ui-packs/hu.ts +8 -0
- package/src/core/ui-packs/id.ts +8 -0
- package/src/core/ui-packs/it.ts +8 -0
- package/src/core/ui-packs/ja.ts +8 -0
- package/src/core/ui-packs/ko.ts +8 -0
- package/src/core/ui-packs/nl.ts +8 -0
- package/src/core/ui-packs/no.ts +8 -0
- package/src/core/ui-packs/pl.ts +8 -0
- package/src/core/ui-packs/pt-br.ts +8 -0
- package/src/core/ui-packs/pt.ts +8 -0
- package/src/core/ui-packs/ro.ts +8 -0
- package/src/core/ui-packs/ru.ts +8 -0
- package/src/core/ui-packs/sk.ts +8 -0
- package/src/core/ui-packs/sr.ts +8 -0
- package/src/core/ui-packs/sv.ts +8 -0
- package/src/core/ui-packs/th.ts +8 -0
- package/src/core/ui-packs/tr.ts +8 -0
- package/src/core/ui-packs/uk.ts +8 -0
- package/src/core/ui-packs/vi.ts +8 -0
- package/src/core/ui-packs/zh-tw.ts +8 -0
- package/src/core/ui-packs/zh.ts +8 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +7 -2
- package/src/deploy/sitemap.ts +53 -11
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +10 -8
- package/src/markdown/index.ts +15 -3
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/package-commands.ts +10 -4
- package/src/og/card.ts +4 -2
- package/src/og/dimensions.ts +12 -0
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/render-mdx.ts +62 -1
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +184 -12
- package/src/registry/registry.ts +0 -3
- package/src/search/documents.ts +34 -2
- package/src/seo/jsonld.ts +20 -13
- package/src/seo/x-handle.ts +18 -0
|
@@ -51,6 +51,10 @@ const hashEnabled = String(hash) !== "false";
|
|
|
51
51
|
"w-full rounded-blume border border-border bg-background px-3 py-2 font-medium text-foreground text-sm";
|
|
52
52
|
const SYNC_EVENT = "blume-tabs-change";
|
|
53
53
|
|
|
54
|
+
// Disambiguates generated trigger/panel ids when the same tab labels appear
|
|
55
|
+
// in several <Tabs> instances on one page.
|
|
56
|
+
let instanceCount = 0;
|
|
57
|
+
|
|
54
58
|
const panelTitle = (panel: HTMLElement, index: number) =>
|
|
55
59
|
panel.dataset.title ?? `Tab ${index + 1}`;
|
|
56
60
|
const panelId = (panel: HTMLElement) => panel.dataset.tabId ?? panel.id;
|
|
@@ -119,7 +123,8 @@ const hashEnabled = String(hash) !== "false";
|
|
|
119
123
|
if (this.dataset.dropdown === "true") {
|
|
120
124
|
this.#select = document.createElement("select");
|
|
121
125
|
this.#select.className = SELECT_CLASS;
|
|
122
|
-
|
|
126
|
+
// Neutral default: tab sets aren't necessarily code examples.
|
|
127
|
+
this.#select.setAttribute("aria-label", "Select tab");
|
|
123
128
|
panels.forEach((panel, index) => {
|
|
124
129
|
const option = document.createElement("option");
|
|
125
130
|
option.value = String(index);
|
|
@@ -131,6 +136,7 @@ const hashEnabled = String(hash) !== "false";
|
|
|
131
136
|
});
|
|
132
137
|
list.appendChild(this.#select);
|
|
133
138
|
} else {
|
|
139
|
+
const instance = ++instanceCount;
|
|
134
140
|
panels.forEach((panel, index) => {
|
|
135
141
|
const trigger = document.createElement("button");
|
|
136
142
|
trigger.type = "button";
|
|
@@ -138,9 +144,15 @@ const hashEnabled = String(hash) !== "false";
|
|
|
138
144
|
trigger.className = TRIGGER_CLASS;
|
|
139
145
|
appendTriggerContent(trigger, panel, index);
|
|
140
146
|
trigger.tabIndex = index === 0 ? 0 : -1;
|
|
141
|
-
|
|
142
|
-
|
|
147
|
+
// Wire tab and panel both ways. An author-supplied panel id is kept;
|
|
148
|
+
// otherwise derive unique ids from the label slug per instance.
|
|
149
|
+
const slug = panelId(panel) || `tab-${index + 1}`;
|
|
150
|
+
if (!panel.id) {
|
|
151
|
+
panel.id = `blume-tabpanel-${slug}-${instance}`;
|
|
143
152
|
}
|
|
153
|
+
trigger.id = `blume-tab-${slug}-${instance}`;
|
|
154
|
+
trigger.setAttribute("aria-controls", panel.id);
|
|
155
|
+
panel.setAttribute("aria-labelledby", trigger.id);
|
|
144
156
|
trigger.setAttribute(
|
|
145
157
|
"aria-selected",
|
|
146
158
|
index === 0 ? "true" : "false"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Icon from "../Icon.astro";
|
|
3
|
+
import { contentHref } from "./base-href.ts";
|
|
3
4
|
|
|
4
5
|
const { description, href, title } = Astro.props;
|
|
5
6
|
const Tag = href ? "a" : "div";
|
|
@@ -11,7 +12,7 @@ const previewPattern =
|
|
|
11
12
|
<Tag
|
|
12
13
|
class="not-prose block overflow-hidden rounded-blume border border-border bg-background text-inherit no-underline! transition-colors hover:no-underline! hover:[&[href]]:border-accent"
|
|
13
14
|
data-blume-tile
|
|
14
|
-
href={href}
|
|
15
|
+
href={href === undefined ? undefined : contentHref(href)}
|
|
15
16
|
rel={external ? "noreferrer" : undefined}
|
|
16
17
|
target={external ? "_blank" : undefined}
|
|
17
18
|
>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
|
|
4
|
+
import { contentHref } from "./base-href.ts";
|
|
5
|
+
|
|
4
6
|
interface Props {
|
|
5
7
|
cta?: string;
|
|
6
8
|
headline?: string;
|
|
@@ -43,7 +45,7 @@ const labelHtml = Astro.slots.has("default")
|
|
|
43
45
|
cta && href && (
|
|
44
46
|
<a
|
|
45
47
|
class="mt-2 inline-flex font-medium text-accent text-xs hover:underline"
|
|
46
|
-
href={href}
|
|
48
|
+
href={contentHref(href)}
|
|
47
49
|
rel={external ? "noreferrer" : undefined}
|
|
48
50
|
target={external ? "_blank" : undefined}
|
|
49
51
|
>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { contentHref } from "./base-href.ts";
|
|
3
|
+
|
|
2
4
|
interface RssMetadata {
|
|
3
5
|
description?: string;
|
|
4
6
|
title?: string;
|
|
@@ -6,7 +8,12 @@ interface RssMetadata {
|
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
description?: string;
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Link the heading to a dedicated page; falls back to the in-page anchor.
|
|
13
|
+
* A root-relative route gains the composed `deployment.base` + `basePath`
|
|
14
|
+
* prefix at emit time (like a markdown link); anchors and external URLs
|
|
15
|
+
* pass through.
|
|
16
|
+
*/
|
|
10
17
|
href?: string;
|
|
11
18
|
id?: string;
|
|
12
19
|
label?: string;
|
|
@@ -47,7 +54,7 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
|
|
|
47
54
|
<header class="md:border-border md:border-e md:pe-4">
|
|
48
55
|
<a
|
|
49
56
|
class="font-semibold text-foreground text-sm no-underline hover:text-accent"
|
|
50
|
-
href={href ?? `#${id}`}
|
|
57
|
+
href={contentHref(href ?? `#${id}`)}
|
|
51
58
|
>
|
|
52
59
|
{updateLabel}
|
|
53
60
|
</a>
|
|
@@ -144,7 +144,13 @@ export const extractTypeTable = async (
|
|
|
144
144
|
default: defaultValue || undefined,
|
|
145
145
|
description: description || undefined,
|
|
146
146
|
name: symbol.getName(),
|
|
147
|
-
|
|
147
|
+
// Optionality lives on the checker's symbol, not the backing
|
|
148
|
+
// declaration: mapped/utility types synthesize members whose
|
|
149
|
+
// declaration has no question token (`Partial<Base>` would read as
|
|
150
|
+
// all-required), and `Required<Base>` strips optionality while the
|
|
151
|
+
// declaration keeps its `?` — the token is wrong in both directions.
|
|
152
|
+
// oxlint-disable-next-line no-bitwise -- SymbolFlags is a bitfield
|
|
153
|
+
required: (symbol.flags & ts.SymbolFlags.Optional) === 0,
|
|
148
154
|
type: typeText,
|
|
149
155
|
};
|
|
150
156
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import data from "blume:data";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isInternalPath,
|
|
5
|
+
normalizeBasePath,
|
|
6
|
+
withComposedBasePath,
|
|
7
|
+
} from "../../core/base-path.ts";
|
|
8
|
+
|
|
9
|
+
// Mirrors `markdown/base-links.ts`: a path whose final segment carries a file
|
|
10
|
+
// extension is a `public/` asset, served at the site root and never moved
|
|
11
|
+
// under `basePath`.
|
|
12
|
+
const ASSET_PATH = /\.[a-z0-9]+$/iu;
|
|
13
|
+
|
|
14
|
+
/** Strip any `#fragment`/`?query` so only the path is extension-tested. */
|
|
15
|
+
const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rebase a component-emitted `href` the way `markdown/base-links.ts` rebases
|
|
19
|
+
* `[x](/guide)`: a root-relative internal page link gains the composed
|
|
20
|
+
* `deployment.base` + `basePath` prefix, so authors write component links
|
|
21
|
+
* (`<Card href="/guide">`) under the same "as if mounted at root" contract as
|
|
22
|
+
* markdown links. Idempotent per layer (a hand-written `/docs/x` isn't
|
|
23
|
+
* double-prefixed) and inert for external URLs, fragments, relative paths, and
|
|
24
|
+
* asset links.
|
|
25
|
+
*/
|
|
26
|
+
export const contentHref = (href: string): string =>
|
|
27
|
+
isInternalPath(href) && !ASSET_PATH.test(pathOf(href))
|
|
28
|
+
? withComposedBasePath(
|
|
29
|
+
normalizeBasePath(import.meta.env.BASE_URL),
|
|
30
|
+
data.config.basePath,
|
|
31
|
+
href
|
|
32
|
+
)
|
|
33
|
+
: href;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client
|
|
2
|
+
* Client behavior for the `<blume-changelog>` custom element wrapping the
|
|
3
3
|
* generated changelog timeline when its releases are semver-versioned. The
|
|
4
4
|
* newest major line stays visible; every older major is collapsed into a group
|
|
5
5
|
* revealed one major at a time by the "Show N.x releases" button at the bottom.
|
|
@@ -24,6 +24,10 @@ class BlumeChangelog extends HTMLElement {
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Localized button template from the generated page markup (the
|
|
28
|
+
// data-attribute channel); `{version}` is replaced with the major line.
|
|
29
|
+
const template = this.dataset.i18nMore || "Show {version} releases";
|
|
30
|
+
|
|
27
31
|
for (const group of groups) {
|
|
28
32
|
group.hidden = true;
|
|
29
33
|
// Focusable only programmatically, so revealing a group can move focus to
|
|
@@ -35,7 +39,10 @@ class BlumeChangelog extends HTMLElement {
|
|
|
35
39
|
const sync = () => {
|
|
36
40
|
const next = groups[revealed];
|
|
37
41
|
if (next) {
|
|
38
|
-
button.textContent =
|
|
42
|
+
button.textContent = template.replace(
|
|
43
|
+
"{version}",
|
|
44
|
+
next.dataset.changelogLabel ?? ""
|
|
45
|
+
);
|
|
39
46
|
button.hidden = false;
|
|
40
47
|
} else {
|
|
41
48
|
button.hidden = true;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client
|
|
2
|
+
* Client behavior for the `<blume-mermaid>` custom element emitted by the
|
|
3
3
|
* Mermaid markdown plugin. Mermaid is lazy-loaded — it needs a DOM and is large,
|
|
4
4
|
* so the dependency only downloads on pages that actually contain a diagram —
|
|
5
5
|
* and each diagram re-renders when the color theme flips so it tracks light/dark.
|
|
@@ -61,7 +61,12 @@ class BlumeMermaid extends HTMLElement {
|
|
|
61
61
|
output.innerHTML = svg;
|
|
62
62
|
}
|
|
63
63
|
} catch {
|
|
64
|
-
|
|
64
|
+
// Localized message stamped on <body> by RootLayout's markup (the
|
|
65
|
+
// data-attribute channel); English fallback when the attribute is
|
|
66
|
+
// missing (a stale snapshot or a custom layout).
|
|
67
|
+
output.textContent =
|
|
68
|
+
document.body.dataset.i18nDiagramError ||
|
|
69
|
+
"Could not render this diagram.";
|
|
65
70
|
}
|
|
66
71
|
output.removeAttribute("aria-busy");
|
|
67
72
|
};
|
|
@@ -63,13 +63,16 @@ const icons = {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/* The table of contents only shows at xl; reclaim its column for the article
|
|
66
|
-
while the panel is open so the shrunken content still has room to breathe.
|
|
66
|
+
while the panel is open so the shrunken content still has room to breathe.
|
|
67
|
+
Scoped to grids that actually have a TOC column: a "bare" layout (the
|
|
68
|
+
changelog index) is single-column, and forcing the sidebar tracks onto it
|
|
69
|
+
would squeeze its only item — <main> — into the 17.5rem track. */
|
|
67
70
|
@media (min-width: 1280px) {
|
|
68
71
|
body[data-blume-ask="open"] [data-blume-toc] {
|
|
69
72
|
display: none;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
body[data-blume-ask="open"] [data-blume-doc-grid] {
|
|
75
|
+
body[data-blume-ask="open"] [data-blume-doc-grid]:has([data-blume-toc]) {
|
|
73
76
|
grid-template-columns: 17.5rem minmax(0, 1fr);
|
|
74
77
|
}
|
|
75
78
|
}
|
|
@@ -5,7 +5,7 @@ import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
6
6
|
|
|
7
7
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
8
|
-
import { joinBase, stripBase } from "./base-path.ts";
|
|
8
|
+
import { joinBase, prefixBase, stripBase } from "./base-path.ts";
|
|
9
9
|
|
|
10
10
|
interface ChatMessage {
|
|
11
11
|
content: string;
|
|
@@ -43,6 +43,7 @@ const EMPTY_ICONS: AskIcons = {
|
|
|
43
43
|
|
|
44
44
|
// English fallback so the island renders even if no dictionary is passed.
|
|
45
45
|
const DEFAULT_ASK: UIStrings["ask"] = {
|
|
46
|
+
ai: "AI",
|
|
46
47
|
clear: "Clear conversation",
|
|
47
48
|
close: "Close",
|
|
48
49
|
copy: "Copy conversation",
|
|
@@ -53,6 +54,7 @@ const DEFAULT_ASK: UIStrings["ask"] = {
|
|
|
53
54
|
send: "Send",
|
|
54
55
|
tip: "Tip: You can open and close chat with",
|
|
55
56
|
title: "Ask AI",
|
|
57
|
+
you: "You",
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
let idCounter = 0;
|
|
@@ -72,6 +74,17 @@ const currentPath = (): string =>
|
|
|
72
74
|
// GitHub-flavored markdown with soft line breaks, matching how the docs read.
|
|
73
75
|
marked.setOptions({ breaks: true, gfm: true });
|
|
74
76
|
|
|
77
|
+
// The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
|
|
78
|
+
// link targets to served URLs so citations resolve under `deployment.base`.
|
|
79
|
+
// `prefixBase` leaves external URLs and fragments untouched and is idempotent.
|
|
80
|
+
marked.use({
|
|
81
|
+
walkTokens: (token) => {
|
|
82
|
+
if (token.type === "link") {
|
|
83
|
+
token.href = prefixBase(import.meta.env.BASE_URL, token.href);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
75
88
|
const renderMarkdown = (content: string): string =>
|
|
76
89
|
DOMPurify.sanitize(marked.parse(content, { async: false }));
|
|
77
90
|
|
|
@@ -97,6 +110,14 @@ const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
|
|
|
97
110
|
// Stable empty default so an unset `suggestions` prop doesn't re-render.
|
|
98
111
|
const EMPTY_SUGGESTIONS: Suggestion[] = [];
|
|
99
112
|
|
|
113
|
+
// The toggle shortcut accepts both ⌘I and Ctrl+I; show the right modifier per
|
|
114
|
+
// platform (same detection Search.astro uses for its ⌘K hint). Guarded so the
|
|
115
|
+
// island still server-renders, where `navigator` doesn't exist; the hint itself
|
|
116
|
+
// only renders client-side, inside the portaled panel.
|
|
117
|
+
const IS_APPLE =
|
|
118
|
+
typeof navigator !== "undefined" &&
|
|
119
|
+
/mac|iphone|ipad|ipod/iu.test(navigator.platform);
|
|
120
|
+
|
|
100
121
|
// Ghost icon button, matching the header's theme toggle and repo link.
|
|
101
122
|
const TRIGGER_CLASS =
|
|
102
123
|
"inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
|
|
@@ -120,7 +141,9 @@ const AskAI = ({
|
|
|
120
141
|
strings?: UIStrings["ask"];
|
|
121
142
|
suggestions?: Suggestion[];
|
|
122
143
|
}) => {
|
|
123
|
-
|
|
144
|
+
// Merge per key (not `strings ?? …`) so a dictionary from a stale snapshot
|
|
145
|
+
// that predates newer keys still resolves every label to its English default.
|
|
146
|
+
const t = { ...DEFAULT_ASK, ...strings };
|
|
124
147
|
const [mounted, setMounted] = useState(false);
|
|
125
148
|
const [open, setOpen] = useState(false);
|
|
126
149
|
const [input, setInput] = useState("");
|
|
@@ -128,6 +151,16 @@ const AskAI = ({
|
|
|
128
151
|
const [busy, setBusy] = useState(false);
|
|
129
152
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
130
153
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
154
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
155
|
+
// Where focus came from when the panel opened, restored on close.
|
|
156
|
+
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
157
|
+
// The stream writes into the conversation via functional updates, so "Clear
|
|
158
|
+
// conversation" mid-answer must revoke the in-flight stream's right to write
|
|
159
|
+
// — otherwise its next chunk re-appends the assistant bubble onto the
|
|
160
|
+
// emptied list as an orphaned answer. Clearing bumps the generation (stale
|
|
161
|
+
// streams stop writing) and aborts the request (the stream stops arriving).
|
|
162
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
163
|
+
const generationRef = useRef(0);
|
|
131
164
|
|
|
132
165
|
// Portal target (document.body) only exists after mount; guards SSR. The
|
|
133
166
|
// one-time false→true flip is deliberate, so the initial `false` is required.
|
|
@@ -164,10 +197,24 @@ const AskAI = ({
|
|
|
164
197
|
// Drive the desktop content push from a body attribute (see AskAI.astro CSS).
|
|
165
198
|
useEffect(() => {
|
|
166
199
|
if (open) {
|
|
200
|
+
returnFocusRef.current =
|
|
201
|
+
document.activeElement instanceof HTMLElement
|
|
202
|
+
? document.activeElement
|
|
203
|
+
: null;
|
|
167
204
|
document.body.dataset.blumeAsk = "open";
|
|
168
205
|
inputRef.current?.focus();
|
|
169
206
|
} else {
|
|
170
207
|
delete document.body.dataset.blumeAsk;
|
|
208
|
+
// Return focus to the element that opened the panel (or the trigger when
|
|
209
|
+
// it's gone), so closing doesn't strand keyboard focus in an inert tree.
|
|
210
|
+
// `returnFocusRef` is only set on open, so initial mount is a no-op.
|
|
211
|
+
if (returnFocusRef.current) {
|
|
212
|
+
const target = returnFocusRef.current.isConnected
|
|
213
|
+
? returnFocusRef.current
|
|
214
|
+
: triggerRef.current;
|
|
215
|
+
returnFocusRef.current = null;
|
|
216
|
+
target?.focus();
|
|
217
|
+
}
|
|
171
218
|
}
|
|
172
219
|
return () => {
|
|
173
220
|
delete document.body.dataset.blumeAsk;
|
|
@@ -199,6 +246,9 @@ const AskAI = ({
|
|
|
199
246
|
setMessages([...history, assistant]);
|
|
200
247
|
setInput("");
|
|
201
248
|
setBusy(true);
|
|
249
|
+
const generation = generationRef.current;
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
abortRef.current = controller;
|
|
202
252
|
|
|
203
253
|
try {
|
|
204
254
|
const response = await fetch(ASK_ENDPOINT, {
|
|
@@ -208,6 +258,7 @@ const AskAI = ({
|
|
|
208
258
|
}),
|
|
209
259
|
headers: { "content-type": "application/json" },
|
|
210
260
|
method: "POST",
|
|
261
|
+
signal: controller.signal,
|
|
211
262
|
});
|
|
212
263
|
// A 4xx/5xx still has a body; without this guard its error text would be
|
|
213
264
|
// decoded and shown as the assistant's answer instead of the error notice.
|
|
@@ -226,25 +277,45 @@ const AskAI = ({
|
|
|
226
277
|
// must not flush as U+FFFD garbage.
|
|
227
278
|
// oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
|
|
228
279
|
assistant.content += decoder.decode(chunk.value, { stream: true });
|
|
229
|
-
|
|
280
|
+
if (generationRef.current === generation) {
|
|
281
|
+
setMessages((current) => [
|
|
282
|
+
...current.slice(0, -1),
|
|
283
|
+
{ ...assistant },
|
|
284
|
+
]);
|
|
285
|
+
}
|
|
230
286
|
}
|
|
231
287
|
}
|
|
232
288
|
} catch {
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
289
|
+
// A cleared (aborted) stream must not resurrect its bubble as an error.
|
|
290
|
+
if (generationRef.current === generation) {
|
|
291
|
+
// oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
|
|
292
|
+
assistant.content = t.error;
|
|
293
|
+
setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
|
|
294
|
+
}
|
|
236
295
|
} finally {
|
|
237
296
|
setBusy(false);
|
|
238
297
|
}
|
|
239
298
|
};
|
|
240
299
|
|
|
300
|
+
const clearConversation = () => {
|
|
301
|
+
generationRef.current += 1;
|
|
302
|
+
abortRef.current?.abort();
|
|
303
|
+
setMessages([]);
|
|
304
|
+
};
|
|
305
|
+
|
|
241
306
|
const onSubmit = (event: FormEvent) => {
|
|
242
307
|
event.preventDefault();
|
|
243
308
|
void runQuestion(input);
|
|
244
309
|
};
|
|
245
310
|
|
|
246
311
|
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
247
|
-
|
|
312
|
+
// `isComposing` guards IME input: Enter confirming a CJK conversion must
|
|
313
|
+
// commit the text, not submit the question.
|
|
314
|
+
if (
|
|
315
|
+
event.key === "Enter" &&
|
|
316
|
+
!event.shiftKey &&
|
|
317
|
+
!event.nativeEvent.isComposing
|
|
318
|
+
) {
|
|
248
319
|
event.preventDefault();
|
|
249
320
|
void runQuestion(input);
|
|
250
321
|
}
|
|
@@ -252,7 +323,7 @@ const AskAI = ({
|
|
|
252
323
|
|
|
253
324
|
const copyConversation = () => {
|
|
254
325
|
const text = messages
|
|
255
|
-
.map((m) => `${m.role === "user" ?
|
|
326
|
+
.map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
|
|
256
327
|
.join("\n\n");
|
|
257
328
|
void navigator.clipboard?.writeText(text);
|
|
258
329
|
};
|
|
@@ -263,6 +334,9 @@ const AskAI = ({
|
|
|
263
334
|
<aside
|
|
264
335
|
aria-hidden={open ? undefined : "true"}
|
|
265
336
|
aria-label={t.title}
|
|
337
|
+
// The closed panel is only translated off-screen; `inert` drops its
|
|
338
|
+
// buttons/textarea from the tab order and the accessibility tree.
|
|
339
|
+
inert={!open}
|
|
266
340
|
className={`fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-border border-s bg-background shadow-2xl transition-transform duration-200 ease-out ${
|
|
267
341
|
open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
|
|
268
342
|
}`}
|
|
@@ -283,7 +357,7 @@ const AskAI = ({
|
|
|
283
357
|
aria-label={t.clear}
|
|
284
358
|
className={ICON_BUTTON_CLASS}
|
|
285
359
|
disabled={!hasMessages}
|
|
286
|
-
onClick={
|
|
360
|
+
onClick={clearConversation}
|
|
287
361
|
type="button"
|
|
288
362
|
>
|
|
289
363
|
<Glyph path={icons.clear} />
|
|
@@ -355,7 +429,7 @@ const AskAI = ({
|
|
|
355
429
|
<p className="mt-3 flex items-center gap-1.5 px-2 text-muted-foreground text-sm">
|
|
356
430
|
{t.tip}
|
|
357
431
|
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
|
|
358
|
-
⌘
|
|
432
|
+
{IS_APPLE ? "⌘" : "Ctrl"}
|
|
359
433
|
</kbd>
|
|
360
434
|
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
|
|
361
435
|
I
|
|
@@ -371,7 +445,7 @@ const AskAI = ({
|
|
|
371
445
|
>
|
|
372
446
|
<textarea
|
|
373
447
|
aria-label={t.label}
|
|
374
|
-
className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm outline-none placeholder:text-muted-foreground"
|
|
448
|
+
className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm pointer-coarse:text-base outline-none placeholder:text-muted-foreground"
|
|
375
449
|
onChange={(event) => setInput(event.target.value)}
|
|
376
450
|
onKeyDown={onInputKeyDown}
|
|
377
451
|
placeholder={t.placeholder}
|
|
@@ -398,6 +472,7 @@ const AskAI = ({
|
|
|
398
472
|
aria-label={t.title}
|
|
399
473
|
className={TRIGGER_CLASS}
|
|
400
474
|
onClick={() => setOpen((value) => !value)}
|
|
475
|
+
ref={triggerRef}
|
|
401
476
|
type="button"
|
|
402
477
|
>
|
|
403
478
|
<Glyph path={icons.chat} size={18} />
|
|
@@ -89,23 +89,36 @@ export const useSearch = (): UseSearch => {
|
|
|
89
89
|
const [results, setResults] = useState<SearchResult | null>(null);
|
|
90
90
|
const [loading, setLoading] = useState(false);
|
|
91
91
|
const searchFn = useRef<SearchFn | null>(null);
|
|
92
|
+
const generation = useRef(0);
|
|
92
93
|
|
|
93
94
|
// Retained for the compiler-off opt-out path (`react: { compiler: false }`):
|
|
94
95
|
// this useCallback keeps a stable `search` identity for consumers that use it
|
|
95
96
|
// as an effect/memo dependency. With the compiler on it's redundant but inert.
|
|
96
97
|
// oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
|
|
97
98
|
const search = useCallback<UseSearch["search"]>(async (query, options) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
// Stale-response guard, mirroring the built-in dialog's renderGeneration:
|
|
100
|
+
// provider responses can land out of order, so only the latest call may
|
|
101
|
+
// commit results or clear `loading` — otherwise the last response to land
|
|
102
|
+
// wins over the last query typed ("a" clobbering "ab").
|
|
103
|
+
generation.current += 1;
|
|
104
|
+
const { current } = generation;
|
|
105
|
+
// Before the lazy client import: the first search's heaviest phase is
|
|
106
|
+
// creating the provider client (index download), and it must show loading.
|
|
102
107
|
setLoading(true);
|
|
103
108
|
try {
|
|
109
|
+
if (!searchFn.current) {
|
|
110
|
+
const { createSearch } = await import("blume:search-client");
|
|
111
|
+
searchFn.current = await createSearch();
|
|
112
|
+
}
|
|
104
113
|
const result = await searchFn.current(query, options);
|
|
105
|
-
|
|
114
|
+
if (current === generation.current) {
|
|
115
|
+
setResults(result);
|
|
116
|
+
}
|
|
106
117
|
return result;
|
|
107
118
|
} finally {
|
|
108
|
-
|
|
119
|
+
if (current === generation.current) {
|
|
120
|
+
setLoading(false);
|
|
121
|
+
}
|
|
109
122
|
}
|
|
110
123
|
}, []);
|
|
111
124
|
|
|
@@ -128,6 +141,9 @@ export interface UseAskAI {
|
|
|
128
141
|
|
|
129
142
|
const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
130
143
|
|
|
144
|
+
/** Shown as the assistant's answer when the request fails or throws. */
|
|
145
|
+
const ASK_ERROR = "Something went wrong answering that. Please try again.";
|
|
146
|
+
|
|
131
147
|
/** The current route with the deployment base stripped, for page grounding. */
|
|
132
148
|
const currentPath = (): string =>
|
|
133
149
|
stripBase(import.meta.env.BASE_URL, window.location.pathname);
|
|
@@ -169,8 +185,7 @@ export const useAskAI = (): UseAskAI => {
|
|
|
169
185
|
if (!response.ok) {
|
|
170
186
|
// An error body (JSON, HTML error page) must not stream in as the
|
|
171
187
|
// assistant's answer.
|
|
172
|
-
assistant.content =
|
|
173
|
-
"Something went wrong answering that. Please try again.";
|
|
188
|
+
assistant.content = ASK_ERROR;
|
|
174
189
|
setMessages([...history, { ...assistant }]);
|
|
175
190
|
return;
|
|
176
191
|
}
|
|
@@ -195,6 +210,11 @@ export const useAskAI = (): UseAskAI => {
|
|
|
195
210
|
}
|
|
196
211
|
}
|
|
197
212
|
}
|
|
213
|
+
} catch {
|
|
214
|
+
// A thrown fetch (offline, DNS failure, CORS) must not strand the
|
|
215
|
+
// pre-appended empty assistant message as a stuck placeholder.
|
|
216
|
+
assistant.content = ASK_ERROR;
|
|
217
|
+
setMessages([...history, { ...assistant }]);
|
|
198
218
|
} finally {
|
|
199
219
|
setLoading(false);
|
|
200
220
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
|
+
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
2
4
|
import { withBase } from "../islands/base-path.ts";
|
|
3
5
|
import Icon from "../Icon.astro";
|
|
4
6
|
|
|
@@ -14,9 +16,15 @@ interface Props {
|
|
|
14
16
|
key: string;
|
|
15
17
|
}
|
|
16
18
|
| null;
|
|
19
|
+
/** Localized banner labels. */
|
|
20
|
+
strings?: UIStrings["banner"];
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
const { banner } = Astro.props;
|
|
23
|
+
const { banner, strings } = Astro.props;
|
|
24
|
+
// Merge over the English baseline per key (rather than `strings ?? …`) so a
|
|
25
|
+
// partial — or missing — strings object still resolves every label to a
|
|
26
|
+
// default, matching the pattern PageFeedback and Search use.
|
|
27
|
+
const b = { ...EN_UI.banner, ...strings };
|
|
20
28
|
---
|
|
21
29
|
|
|
22
30
|
{
|
|
@@ -38,7 +46,7 @@ const { banner } = Astro.props;
|
|
|
38
46
|
)}
|
|
39
47
|
{banner.dismissible && (
|
|
40
48
|
<button
|
|
41
|
-
aria-label=
|
|
49
|
+
aria-label={b.dismiss}
|
|
42
50
|
class="absolute end-1.5 inline-flex size-7 items-center justify-center rounded-full text-background/80 transition-colors hover:bg-white/10 hover:text-background dark:text-foreground/70 dark:hover:bg-foreground/10 dark:hover:text-foreground"
|
|
43
51
|
data-blume-banner-dismiss
|
|
44
52
|
type="button"
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
|
+
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
2
4
|
import { withBase } from "../islands/base-path.ts";
|
|
3
5
|
import type { Crumb } from "./nav-utils.ts";
|
|
4
6
|
|
|
5
7
|
interface Props {
|
|
6
8
|
/** Full breadcrumb trail from the site root to the current page. */
|
|
7
9
|
crumbs: Crumb[];
|
|
10
|
+
/** Localized nav labels (the landmark's `aria-label`); English when omitted. */
|
|
11
|
+
strings?: UIStrings["nav"];
|
|
8
12
|
/** Left-align full width (for the wide API layout) instead of the prose measure. */
|
|
9
13
|
wide?: boolean;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
const { crumbs, wide = false } = Astro.props;
|
|
16
|
+
const { crumbs, strings, wide = false } = Astro.props;
|
|
17
|
+
|
|
18
|
+
// Merge over the English defaults so a label missing from a translation (or
|
|
19
|
+
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
20
|
+
// blank — the PageActions pattern.
|
|
21
|
+
const n = { ...EN_UI.nav, ...strings };
|
|
13
22
|
|
|
14
23
|
// The built-in shows a single "eyebrow" crumb — the parent group — rather than
|
|
15
24
|
// the whole trail. An override receives the complete list and can render more.
|
|
@@ -19,7 +28,7 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
|
|
|
19
28
|
{
|
|
20
29
|
eyebrowCrumb && (
|
|
21
30
|
<nav
|
|
22
|
-
aria-label=
|
|
31
|
+
aria-label={n.breadcrumb}
|
|
23
32
|
class:list={[
|
|
24
33
|
"mb-2 text-muted-foreground text-sm",
|
|
25
34
|
wide ? "max-w-none" : "mx-auto max-w-[42rem]",
|