blume 0.5.2 → 0.5.3
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 +2755 -2564
- package/dist/cli/index.js.map +33 -31
- package/dist/types/core/package-json.d.ts +12 -0
- package/dist/types/migrate/shared.d.ts +153 -0
- package/docs/advanced/migrate.mdx +1 -0
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/sources.mdx +1 -1
- package/package.json +1 -1
- 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/static-assets.ts +10 -3
- package/src/astro/templates.ts +53 -21
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +31 -17
- package/src/cli/dev-lock.ts +4 -2
- package/src/components/content/ColorItem.astro +6 -3
- package/src/components/content/Prompt.astro +7 -3
- package/src/components/content/Tabs.astro +13 -2
- 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/PageActions.astro +17 -11
- package/src/components/layout/Search.astro +4 -1
- package/src/components/layout/search/types.ts +16 -5
- 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/config.ts +15 -6
- package/src/core/graph.ts +6 -1
- package/src/core/navigation.ts +5 -1
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/mintlify.ts +14 -28
- package/src/core/sources/normalize.ts +28 -6
- package/src/core/sources/watch.ts +44 -0
- package/src/markdown/code-title.ts +6 -3
- package/src/markdown/package-install.ts +3 -1
- package/src/migrate/fumadocs/content.ts +3 -5
- package/src/migrate/fumadocs/index.ts +24 -9
- package/src/migrate/mintlify/config.ts +2 -6
- package/src/migrate/mintlify/index.ts +119 -32
- package/src/migrate/mintlify/snippets.ts +17 -8
- package/src/migrate/nextra/index.ts +16 -1
- package/src/migrate/shared.ts +77 -4
- package/src/migrate/starlight/content.ts +3 -6
- package/src/og/card.ts +16 -4
- package/src/openapi/render-mdx.ts +10 -1
- package/src/search/sync/orama-cloud.ts +2 -0
- package/src/search/sync/typesense.ts +4 -0
- package/src/theme/icons.ts +13 -4
- package/src/theme/palette.ts +38 -17
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a valid npm package name from a directory name, falling back to
|
|
3
|
+
* `docs` when nothing usable remains.
|
|
4
|
+
*/
|
|
5
|
+
export declare const toPackageName: (raw: string) => string;
|
|
6
|
+
/**
|
|
7
|
+
* A minimal, runnable `package.json` body for a Blume project: the `blume`
|
|
8
|
+
* dependency pinned to the installed version plus `dev`/`build`/`doctor`
|
|
9
|
+
* scripts, so `npm install && npm run dev` works immediately. Shared by
|
|
10
|
+
* `blume init` and the migrators, which scaffold one when a project has none.
|
|
11
|
+
*/
|
|
12
|
+
export declare const blumePackageJson: (name: string) => string;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { BlumeConfig } from "../core/schema.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Whether `candidate` resolves to a path inside `root` (or is `root` itself).
|
|
4
|
+
* Guards migrators against `../` traversal in author-controlled source paths
|
|
5
|
+
* (`pages` entries, `<include>` targets) that would otherwise read or move
|
|
6
|
+
* files outside the docs tree.
|
|
7
|
+
*/
|
|
8
|
+
export declare const isInsideRoot: (root: string, candidate: string) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Framework-agnostic helpers shared by more than one migrator. Each piece here
|
|
11
|
+
* was generalized from a migrator-specific implementation so Mintlify, Nextra,
|
|
12
|
+
* and future migrators converge on a single copy.
|
|
13
|
+
*/
|
|
14
|
+
/** Serialize a `BlumeConfig` to a `blume.config.ts` at the project root. */
|
|
15
|
+
export declare const writeBlumeConfig: (root: string, config: BlumeConfig) => Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Rewrite a migrated project's npm scripts off the old framework's CLI. A
|
|
18
|
+
* `dev`/`build`/`start` script whose command invokes `cli` (e.g. `/\bnext\b/`)
|
|
19
|
+
* is repointed at the matching Blume command (`start` -> `blume preview`); a
|
|
20
|
+
* script whose command matches `remove` (e.g. a `fumadocs-mdx` postinstall) is
|
|
21
|
+
* dropped. Scripts that don't match either are left untouched, so custom tasks
|
|
22
|
+
* survive. Returns true when `package.json` changed.
|
|
23
|
+
*/
|
|
24
|
+
export declare const rewriteFrameworkScripts: (root: string, cli: RegExp, remove?: RegExp) => Promise<boolean>;
|
|
25
|
+
/** Of the candidate project-relative paths, the ones that still exist — the old
|
|
26
|
+
* framework files a migration leaves behind for the user to remove by hand. */
|
|
27
|
+
export declare const leftoverFiles: (root: string, candidates: string[]) => string[];
|
|
28
|
+
/**
|
|
29
|
+
* Scaffold a minimal, runnable `package.json` when the migrated project has
|
|
30
|
+
* none. Config-only sources (e.g. a Mintlify `docs.json`) ship no npm manifest,
|
|
31
|
+
* so a fresh migration has nothing to run `blume dev` with; this writes a stub
|
|
32
|
+
* with `blume` as a dependency and `dev`/`build`/`doctor` scripts, making
|
|
33
|
+
* `npm install && npm run dev` work immediately. A pre-existing `package.json`
|
|
34
|
+
* is left untouched — {@link rewriteFrameworkScripts} repoints those instead.
|
|
35
|
+
* Returns true when a file was created.
|
|
36
|
+
*/
|
|
37
|
+
export declare const ensurePackageJson: (root: string) => Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Remove every match of an import pattern, collapsing the blank gap each
|
|
40
|
+
* removal leaves behind — without touching blank runs elsewhere in the
|
|
41
|
+
* document (double blank lines inside code fences are real content that a
|
|
42
|
+
* whole-document `\n{3,}` collapse used to corrupt).
|
|
43
|
+
*/
|
|
44
|
+
export declare const stripImports: (source: string, pattern: RegExp) => string;
|
|
45
|
+
export interface CalloutRewriteOptions {
|
|
46
|
+
/** Directive for a type-bearing tag with no `type` attribute (e.g. bare `<Callout>`). */
|
|
47
|
+
defaultDirective: string;
|
|
48
|
+
/** Tag names whose directive is fixed by the tag itself (e.g. `<Warning>`). */
|
|
49
|
+
tagDirectives: Record<string, string>;
|
|
50
|
+
/** Component tag names to convert. */
|
|
51
|
+
tags: string[];
|
|
52
|
+
/** `type="…"` values mapped to Blume directive names. */
|
|
53
|
+
typeDirectives: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
/** Read a quoted string attribute (`name="…"` or `name='…'`) from a tag. */
|
|
56
|
+
export declare const attribute: (attrs: string, name: string) => string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Find the `>` that closes an opening JSX tag, honoring quotes and `{…}`
|
|
59
|
+
* expression attributes (so a `>` inside `icon={"<svg…>"}` is not mistaken for
|
|
60
|
+
* the tag end). Returns -1 if unterminated.
|
|
61
|
+
*/
|
|
62
|
+
export declare const findOpenTagEnd: (source: string, from: number) => number;
|
|
63
|
+
/**
|
|
64
|
+
* Convert callout-style JSX components into Blume `:::` directives. Uses a
|
|
65
|
+
* quote/brace-aware tag scanner so callouts carrying JSX-expression attributes
|
|
66
|
+
* (e.g. inline-SVG icons) convert cleanly; non-convertible attributes (icons,
|
|
67
|
+
* colors, emoji) are dropped. A tag whose resolved directive is unknown is left
|
|
68
|
+
* untouched.
|
|
69
|
+
*/
|
|
70
|
+
export declare const rewriteCallouts: (source: string, options: CalloutRewriteOptions) => string;
|
|
71
|
+
/**
|
|
72
|
+
* Remove frontmatter keys Blume's strict page schema would reject (e.g. stray
|
|
73
|
+
* `og:*`/`twitter:*` metatags) so the migrated page validates, reporting what
|
|
74
|
+
* was dropped. Validation errors other than stray keys are left for `blume dev`
|
|
75
|
+
* to surface.
|
|
76
|
+
*/
|
|
77
|
+
export declare const stripUnknownPageMeta: (data: Record<string, unknown>) => {
|
|
78
|
+
data: Record<string, unknown>;
|
|
79
|
+
removed: string[];
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Rename a JSX tag (open and close) while preserving its attributes. The
|
|
83
|
+
* trailing lookahead means a longer tag (e.g. `CardGrid`) is never matched by a
|
|
84
|
+
* rule for its shorter prefix (`Card`), so prefix-sharing renames can be chained
|
|
85
|
+
* — run the item-level rename before the container rename.
|
|
86
|
+
*/
|
|
87
|
+
export declare const renameTag: (source: string, from: string, to: string) => string;
|
|
88
|
+
/**
|
|
89
|
+
* Static readers for JS/TS config files (Nextra `_meta`, Starlight
|
|
90
|
+
* `astro.config`). Config is parsed by walking the source as text — quote-,
|
|
91
|
+
* comment-, and bracket-aware — rather than executing user code, matching the
|
|
92
|
+
* other migrators (which never eval). Values that aren't pure literals (an
|
|
93
|
+
* identifier, call, JSX, or interpolated template) are reported as `UNPARSEABLE`
|
|
94
|
+
* so the caller can drop the field and warn.
|
|
95
|
+
*/
|
|
96
|
+
/** Index of a string within a JS source: the close quote matching `s[open]`. */
|
|
97
|
+
export declare const findStringEnd: (s: string, open: number) => number;
|
|
98
|
+
export declare const unescapeString: (inner: string) => string;
|
|
99
|
+
export interface ObjectScanResult {
|
|
100
|
+
end: number;
|
|
101
|
+
entries: string[];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Walk a `{…}` object literal starting at `openIndex`, returning the matching
|
|
105
|
+
* close-brace index and the raw `key: value` text of each top-level entry.
|
|
106
|
+
* Quote-, comment-, and bracket-aware so commas/braces nested in strings,
|
|
107
|
+
* arrays, or child objects don't split entries. Returns null if unterminated.
|
|
108
|
+
*/
|
|
109
|
+
export declare const scanObject: (source: string, openIndex: number) => ObjectScanResult | null;
|
|
110
|
+
export interface ArrayScanResult {
|
|
111
|
+
elements: string[];
|
|
112
|
+
end: number;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Walk a `[…]` array literal starting at `openIndex`, returning the matching
|
|
116
|
+
* close-bracket index and the raw text of each top-level element. The sibling of
|
|
117
|
+
* {@link scanObject}; a trailing comma yields no empty element.
|
|
118
|
+
*/
|
|
119
|
+
export declare const scanArray: (source: string, openIndex: number) => ArrayScanResult | null;
|
|
120
|
+
/**
|
|
121
|
+
* Strip `//` and block comments so they don't leak into entry text (the scanner
|
|
122
|
+
* splits on slices, so an inter-entry comment would otherwise attach to the next
|
|
123
|
+
* entry). String literals are preserved verbatim.
|
|
124
|
+
*/
|
|
125
|
+
export declare const stripJsComments: (source: string) => string;
|
|
126
|
+
export interface KeyValue {
|
|
127
|
+
key: string;
|
|
128
|
+
value: string;
|
|
129
|
+
}
|
|
130
|
+
/** Split a raw `key: value` entry at its top-level colon. */
|
|
131
|
+
export declare const splitKeyValue: (entry: string) => KeyValue | null;
|
|
132
|
+
/** Read an object key, unquoting it when it is a string literal. */
|
|
133
|
+
export declare const parseKey: (key: string) => string;
|
|
134
|
+
/** Read a clean string literal value, or null if it's an expression. */
|
|
135
|
+
export declare const readString: (value: string) => string | null;
|
|
136
|
+
/** A value that isn't a pure literal (identifier, call, JSX, computed, …). */
|
|
137
|
+
export declare const UNPARSEABLE: unique symbol;
|
|
138
|
+
export type LiteralValue = LiteralValue[] | boolean | null | number | string | typeof UNPARSEABLE | {
|
|
139
|
+
[key: string]: LiteralValue;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Evaluate a JS literal expression (string / number / boolean / null / array /
|
|
143
|
+
* object) into its value without executing it. Anything else resolves to
|
|
144
|
+
* {@link UNPARSEABLE}; inside arrays the sentinel keeps the element's position,
|
|
145
|
+
* inside objects it stays as the field's value so the caller can warn and drop.
|
|
146
|
+
*/
|
|
147
|
+
export declare const parseLiteral: (source: string) => LiteralValue;
|
|
148
|
+
/** Narrow a parsed literal to a string. */
|
|
149
|
+
export declare const asLiteralString: (value: LiteralValue | undefined) => string | undefined;
|
|
150
|
+
/** Narrow a parsed literal to a plain object (not an array or `UNPARSEABLE`). */
|
|
151
|
+
export declare const isLiteralObject: (value: LiteralValue | undefined) => value is Record<string, LiteralValue>;
|
|
152
|
+
/** Narrow a parsed literal to an array. */
|
|
153
|
+
export declare const asLiteralArray: (value: LiteralValue | undefined) => LiteralValue[] | undefined;
|
|
@@ -59,6 +59,7 @@ Reads `docs.json` (or legacy `mint.json`) and rewrites every page **in place**
|
|
|
59
59
|
- Snippets under `/snippets` are inlined as includes; component snippets (e.g. `.jsx`) are kept and their imports rewritten to relative paths.
|
|
60
60
|
- A Mintlify `openapi` spec — declared top-level or on a nav group (a path, URL, or `{ source, directory }`) — maps to Blume's [native OpenAPI reference](/docs/advanced/api-reference) (`openapi.sources`), which renders one real page per operation. A group's `directory` becomes the reference's route; endpoint refs like `GET /users` are dropped since Blume generates them from the spec.
|
|
61
61
|
- `docs.json` `variables` are inlined into content — Blume has no runtime `{{variable}}` substitution.
|
|
62
|
+
- The source `docs.json`/`mint.json` is removed once `blume.config.ts` is safely written. Leaving it around would keep the project a [Bridge mode](/docs/advanced/bridge) candidate — a later run without a Blume config would silently serve the un-migrated Mintlify site.
|
|
62
63
|
- Multi-language projects map to [`i18n.locales`](/docs/content/i18n); the language nav selector is dropped in favor of Blume's locale switching.
|
|
63
64
|
- Icons resolve against the real bundled libraries — Font Awesome (free), Lucide, and Tabler. The migrator sets [`icons.library: fontawesome`](/docs/content/components#default-library) (Mintlify's default), so Font Awesome names (`shield-halved`, `gauge-high`, `layer-group`, …) and `iconType` styles render unchanged. Pro-only FA styles (`light`/`thin`/`duotone`/`sharp-solid`) fall back to solid.
|
|
64
65
|
- Fonts map to [`theme.fonts`](/docs/configuration/theming) when the family is one of Blume's curated Google Fonts (`fonts.family`, or a `heading`/`body` split); a family outside that set is warned about, not guessed. Header links (`navbar.links`/`navbar.primary`) and footer socials (`footer.socials`) have no `blume.config` equivalent yet, so they're reported as warnings rather than dropped silently — re-add them with [`navigation.tabs`](/docs/content/navigation) or a Header/Footer [layout override](/docs/advanced/custom-pages). The contextual page menu and last-updated timestamp are already Blume defaults.
|
|
@@ -133,7 +133,7 @@ Keys are read with `process.env`, which covers the Node, Vercel, and Netlify ada
|
|
|
133
133
|
|
|
134
134
|
### Rate limiting
|
|
135
135
|
|
|
136
|
-
The `POST /api/ask` endpoint is **unauthenticated** — it has to be, so the in-page assistant can call it. Blume validates each request
|
|
136
|
+
The `POST /api/ask` endpoint is **unauthenticated** — it has to be, so the in-page assistant can call it. Blume validates each request — rejecting malformed bodies, capping it to 1–40 messages, and accepting only `user`/`assistant` roles so a caller can't inject their own system prompt and repurpose the route as a general LLM proxy — to bound how much a single call can spend against your model, but it can't stop someone from calling the endpoint repeatedly. If cost abuse is a concern, put the route behind a rate limiter — your host's (e.g. Vercel's) edge rate limiting, a middleware, or your model provider's per-key spend limits.
|
|
137
137
|
|
|
138
138
|
## MCP server
|
|
139
139
|
|
|
@@ -146,7 +146,7 @@ Drop a `theme.css` in your project root to override any design token. It's the l
|
|
|
146
146
|
}
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
Set a token under `:root` for light mode and under `:root[data-theme="dark"]` for dark mode.
|
|
149
|
+
Set a token under `:root` for light mode and under `:root[data-theme="dark"]` for dark mode. Color tokens have distinct built-in dark values declared at the dark selector's higher specificity, so a `:root`-only override of `--blume-accent`, `--blume-background`, and friends applies to light mode only — declare the dark block too when both modes should change.
|
|
150
150
|
|
|
151
151
|
### Design tokens
|
|
152
152
|
|
package/docs/content/i18n.mdx
CHANGED
|
@@ -85,7 +85,7 @@ i18n: {
|
|
|
85
85
|
|
|
86
86
|
## Per-locale navigation
|
|
87
87
|
|
|
88
|
-
Each language gets its own sidebar, built from that locale's files — so translations can diverge in structure, ordering, or labels. Folder [`meta.ts`](/docs/content/meta) files resolve per locale, too: put a `meta.ts` under `fr/guides/` to order the French group independently. Everything else about [navigation](/docs/content/navigation) works the same, per language.
|
|
88
|
+
Each language gets its own sidebar, built from that locale's files — so translations can diverge in structure, ordering, or labels. Folder [`meta.ts`](/docs/content/meta) files resolve per locale, too: under the default `dir` parser, put a `meta.ts` under `fr/guides/` to order the French group independently. Under the `dot` parser translations sit next to the originals, so a folder's `meta.ts` applies to every locale. Everything else about [navigation](/docs/content/navigation) works the same, per language.
|
|
89
89
|
|
|
90
90
|
## Fallbacks
|
|
91
91
|
|
package/docs/content/sources.mdx
CHANGED
|
@@ -56,7 +56,7 @@ The built-in `mdx-remote` source fetches raw `.md`/`.mdx` over HTTP. Enumerate f
|
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
A private repo's token is read from the `GITHUB_TOKEN` environment variable — it is never inlined into your config or generated output.
|
|
59
|
+
A private repo's token is read from the `GITHUB_TOKEN` environment variable — it is never inlined into your config or generated output, and it is only ever sent to GitHub's own hosts (`api.github.com`, `raw.githubusercontent.com`), never to a custom `url` base.
|
|
60
60
|
|
|
61
61
|
Remote pages are rendered with full MDX-plus-component fidelity: their bodies are materialized into a hidden staging directory and rendered through Astro alongside your local docs, so callouts, tabs, and every other Blume component keep working.
|
|
62
62
|
|
package/package.json
CHANGED
package/src/ai/mcp/discovery.ts
CHANGED
|
@@ -10,7 +10,9 @@ export interface McpDiscoveryInput {
|
|
|
10
10
|
|
|
11
11
|
/** The MCP server's address — absolute when a site is configured. */
|
|
12
12
|
const serverUrl = (input: McpDiscoveryInput): string =>
|
|
13
|
-
|
|
13
|
+
// Concatenate rather than `new URL(route, site)` — a root-absolute route
|
|
14
|
+
// would drop the base path of a subpath deployment (`acme.com/docs`).
|
|
15
|
+
input.site ? `${input.site.replace(/\/+$/u, "")}${input.route}` : input.route;
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* The `/.well-known/mcp.json` discovery document: the minimal pointer agents use
|
package/src/ai/mcp/server.ts
CHANGED
|
@@ -90,7 +90,9 @@ const normalizeRoute = (input: string): string => {
|
|
|
90
90
|
|
|
91
91
|
/** Build the absolute (or root-relative) URL for a route. */
|
|
92
92
|
const urlFor = (route: string, site: string | null): string =>
|
|
93
|
-
|
|
93
|
+
// Concatenate rather than `new URL(route, site)` — a root-absolute route
|
|
94
|
+
// would drop the base path of a subpath deployment (`acme.com/docs`).
|
|
95
|
+
site ? `${site.replace(/\/+$/u, "")}${route}` : route;
|
|
94
96
|
|
|
95
97
|
const text = (value: string, isError = false) => ({
|
|
96
98
|
content: [{ text: value, type: "text" as const }],
|
|
@@ -35,6 +35,12 @@ export const mdxComponents = {};
|
|
|
35
35
|
export const layoutOverrides = {};
|
|
36
36
|
`;
|
|
37
37
|
|
|
38
|
+
// A user-supplied attribute value interpolated into a generated .astro tag: a
|
|
39
|
+
// stray quote or newline would produce a malformed component and an opaque
|
|
40
|
+
// Astro parse error pointing at the generated file, not the user's config.
|
|
41
|
+
const attributeValue = (value: string): string =>
|
|
42
|
+
value.replaceAll(/["\n\r]/gu, " ").trim();
|
|
43
|
+
|
|
38
44
|
/** Astro client directive for a hydrated override. */
|
|
39
45
|
const directiveFor = (override: NormalizedOverride): string => {
|
|
40
46
|
const framework = override.source?.framework;
|
|
@@ -47,11 +53,13 @@ const directiveFor = (override: NormalizedOverride): string => {
|
|
|
47
53
|
}
|
|
48
54
|
case "media": {
|
|
49
55
|
return override.media
|
|
50
|
-
? `client:media="${override.media}"`
|
|
56
|
+
? `client:media="${attributeValue(override.media)}"`
|
|
51
57
|
: "client:load";
|
|
52
58
|
}
|
|
53
59
|
case "only": {
|
|
54
|
-
return framework
|
|
60
|
+
return framework
|
|
61
|
+
? `client:only="${attributeValue(framework)}"`
|
|
62
|
+
: "client:load";
|
|
55
63
|
}
|
|
56
64
|
default: {
|
|
57
65
|
return "client:load";
|
|
@@ -41,9 +41,16 @@ const isContained = (parent: string, child: string): boolean => {
|
|
|
41
41
|
|
|
42
42
|
/** Resolve a request URL to an on-disk file within one of the mounts, if any. */
|
|
43
43
|
const resolveRequest = (url: string, mounts: AssetMount[]): string | null => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
let pathname: string;
|
|
45
|
+
try {
|
|
46
|
+
pathname = decodeURIComponent(
|
|
47
|
+
(url.split("?")[0] ?? "").split("#")[0] ?? ""
|
|
48
|
+
);
|
|
49
|
+
} catch {
|
|
50
|
+
// Malformed percent-encoding (`/images/%zz`) throws URIError; treat it as
|
|
51
|
+
// a plain miss (404) rather than a middleware exception.
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
47
54
|
for (const mount of mounts) {
|
|
48
55
|
if (pathname !== mount.url && !pathname.startsWith(`${mount.url}/`)) {
|
|
49
56
|
continue;
|
package/src/astro/templates.ts
CHANGED
|
@@ -374,6 +374,15 @@ export default defineConfig({
|
|
|
374
374
|
fs: {
|
|
375
375
|
allow: ${JSON.stringify(fsAllow)},
|
|
376
376
|
},
|
|
377
|
+
// Keep the file watcher out of Astro's own cache dir. In a migrated
|
|
378
|
+
// (root-rooted) project the docs collection is rooted at the project dir,
|
|
379
|
+
// so its glob-loader watcher would otherwise fire on every write Astro
|
|
380
|
+
// makes under .blume/.astro (data-store.json, content module manifests,
|
|
381
|
+
// self-hosted fonts) -- pure noise the loader logs as "No entry type
|
|
382
|
+
// found". Vite appends this to its default ignores.
|
|
383
|
+
watch: {
|
|
384
|
+
ignored: ${JSON.stringify([join(context.outDir, ".astro", "**")])},
|
|
385
|
+
},
|
|
377
386
|
},
|
|
378
387
|
},
|
|
379
388
|
});
|
|
@@ -503,20 +512,35 @@ export const askEndpointTemplate = (
|
|
|
503
512
|
}
|
|
504
513
|
// Validate the client-supplied body and cap its size. The endpoint is
|
|
505
514
|
// unauthenticated, so bounding message count/length limits how much a caller
|
|
506
|
-
// can spend against the model per request
|
|
507
|
-
//
|
|
515
|
+
// can spend against the model per request, and restricting roles to
|
|
516
|
+
// user/assistant keeps callers from injecting their own system prompt and
|
|
517
|
+
// repurposing the endpoint as a general LLM proxy; front it with a rate
|
|
518
|
+
// limiter (or your provider's limits) for stronger protection.
|
|
508
519
|
const validate = ` const body = await request.json().catch(() => null);
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
+
const raw = body?.messages;
|
|
521
|
+
const valid =
|
|
522
|
+
Array.isArray(raw) &&
|
|
523
|
+
raw.length > 0 &&
|
|
524
|
+
raw.length <= 40 &&
|
|
525
|
+
raw.every(
|
|
526
|
+
(m: unknown) =>
|
|
527
|
+
typeof m === "object" &&
|
|
528
|
+
m !== null &&
|
|
529
|
+
("role" in m && (m.role === "user" || m.role === "assistant")) &&
|
|
530
|
+
("content" in m && typeof m.content === "string")
|
|
531
|
+
) &&
|
|
532
|
+
JSON.stringify(raw).length <= 24_000;
|
|
533
|
+
if (!valid) {
|
|
534
|
+
return new Response(
|
|
535
|
+
"Invalid request: send 1-40 user/assistant messages with string content.",
|
|
536
|
+
{ status: 400 }
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
// Re-build the array so only role/content ever reach the model.
|
|
540
|
+
const messages = raw.map((m: { role: "user" | "assistant"; content: string }) => ({
|
|
541
|
+
content: m.content,
|
|
542
|
+
role: m.role,
|
|
543
|
+
}));`;
|
|
520
544
|
const stream = grounded
|
|
521
545
|
? ` const system =
|
|
522
546
|
(await ground(messages, body.page)) ??
|
|
@@ -570,10 +594,16 @@ const SEARCH_CLIENT_HEADER = "// Generated by Blume. Do not edit.\n";
|
|
|
570
594
|
const searchClientImport = (module: string): string =>
|
|
571
595
|
`import { createSearch as create } from "blume/components/layout/search/${module}.ts";\n`;
|
|
572
596
|
|
|
597
|
+
// Joins a base-relative path onto BASE_URL, which arrives with or without a
|
|
598
|
+
// trailing slash (Astro's default trailingSlash: "ignore" passes `/docs`
|
|
599
|
+
// through bare — naive concatenation would yield `/docsblume-search.json`).
|
|
600
|
+
const SEARCH_BASE_IMPORT =
|
|
601
|
+
'import { joinBase } from "blume/components/islands/base-path.ts";\n';
|
|
602
|
+
|
|
573
603
|
/** A client that loads a static `blume-search.json` index (Orama, FlexSearch). */
|
|
574
604
|
const staticSearchClient = (module: string): string =>
|
|
575
|
-
`${SEARCH_CLIENT_HEADER}${searchClientImport(module)}
|
|
576
|
-
const indexUrl =
|
|
605
|
+
`${SEARCH_CLIENT_HEADER}${searchClientImport(module)}${SEARCH_BASE_IMPORT}
|
|
606
|
+
const indexUrl = joinBase(import.meta.env.BASE_URL, "blume-search.json");
|
|
577
607
|
|
|
578
608
|
export const createSearch = () => create({ indexUrl });
|
|
579
609
|
`;
|
|
@@ -633,16 +663,16 @@ export const searchClientTemplate = (config: ResolvedConfig): string => {
|
|
|
633
663
|
}
|
|
634
664
|
|
|
635
665
|
if (search.provider === "mixedbread") {
|
|
636
|
-
return `${SEARCH_CLIENT_HEADER}${searchClientImport("endpoint")}
|
|
637
|
-
const api =
|
|
666
|
+
return `${SEARCH_CLIENT_HEADER}${searchClientImport("endpoint")}${SEARCH_BASE_IMPORT}
|
|
667
|
+
const api = joinBase(import.meta.env.BASE_URL, "api/search");
|
|
638
668
|
|
|
639
669
|
export const createSearch = () => create({ api });
|
|
640
670
|
`;
|
|
641
671
|
}
|
|
642
672
|
|
|
643
673
|
if (search.provider === "pagefind") {
|
|
644
|
-
return `${SEARCH_CLIENT_HEADER}${searchClientImport("pagefind")}
|
|
645
|
-
const url =
|
|
674
|
+
return `${SEARCH_CLIENT_HEADER}${searchClientImport("pagefind")}${SEARCH_BASE_IMPORT}
|
|
675
|
+
const url = joinBase(import.meta.env.BASE_URL, "pagefind/pagefind.js");
|
|
646
676
|
|
|
647
677
|
export const createSearch = () => create({ url });
|
|
648
678
|
`;
|
|
@@ -671,8 +701,10 @@ const client = new Mixedbread({ apiKey: process.env.MIXEDBREAD_API_KEY ?? "" });
|
|
|
671
701
|
const STORE_ID = ${JSON.stringify(storeId)};
|
|
672
702
|
|
|
673
703
|
export const POST: APIRoute = async ({ request }) => {
|
|
674
|
-
|
|
675
|
-
|
|
704
|
+
// The endpoint is public: a malformed body must 200-empty, not 500.
|
|
705
|
+
const body = await request.json().catch(() => null);
|
|
706
|
+
const query = body?.query;
|
|
707
|
+
if (!query || typeof query !== "string") {
|
|
676
708
|
return new Response("[]", {
|
|
677
709
|
headers: { "Content-Type": "application/json" },
|
|
678
710
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap an async task so it never runs concurrently with itself. Triggering the
|
|
3
|
+
* returned function while a run is in flight coalesces into a single trailing
|
|
4
|
+
* run after the current one settles.
|
|
5
|
+
*
|
|
6
|
+
* Dev regeneration (`scanProject` + `generateRuntime`) is expensive on a large
|
|
7
|
+
* project — a full content re-scan that allocates big strings. A plain debounce
|
|
8
|
+
* still lets a fast burst of watch events (or, before it was fixed, a `.blume/`
|
|
9
|
+
* watch storm) start a new scan before the previous finished, piling up
|
|
10
|
+
* overlapping scans until the heap is exhausted (observed as an OOM after
|
|
11
|
+
* minutes of looping). Single-flighting bounds it to one scan at a time while
|
|
12
|
+
* still guaranteeing a final run reflects the latest change.
|
|
13
|
+
*
|
|
14
|
+
* The task must not reject: a rejection would surface as an unhandled promise
|
|
15
|
+
* rejection, so callers handle their own errors and always resolve.
|
|
16
|
+
*/
|
|
17
|
+
export const coalescedRunner = (task: () => Promise<void>): (() => void) => {
|
|
18
|
+
let inFlight: Promise<void> | null = null;
|
|
19
|
+
let pending = false;
|
|
20
|
+
|
|
21
|
+
// Drain any run requested during the current run, then release the lock. The
|
|
22
|
+
// `inFlight` promise is assigned synchronously by the caller below, so a
|
|
23
|
+
// re-entrant trigger sees the lock immediately and only sets `pending`.
|
|
24
|
+
const cycle = async (): Promise<void> => {
|
|
25
|
+
try {
|
|
26
|
+
do {
|
|
27
|
+
pending = false;
|
|
28
|
+
// oxlint-disable-next-line no-await-in-loop -- serialized by design
|
|
29
|
+
await task();
|
|
30
|
+
} while (pending);
|
|
31
|
+
} finally {
|
|
32
|
+
inFlight = null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
if (inFlight) {
|
|
38
|
+
pending = true;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
inFlight = cycle();
|
|
42
|
+
};
|
|
43
|
+
};
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { generateRuntime } from "../../astro/generate.ts";
|
|
|
7
7
|
import { showBlumeErrorOverlay } from "../../astro/integration.ts";
|
|
8
8
|
import { scanProject } from "../../core/project-graph.ts";
|
|
9
9
|
import { parsePort } from "../args.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { coalescedRunner } from "../coalesce.ts";
|
|
11
|
+
import { acquireDevLock, isDevLocked } from "../dev-lock.ts";
|
|
11
12
|
import { logger } from "../log.ts";
|
|
12
13
|
import { prepareProject } from "../prepare.ts";
|
|
13
14
|
|
|
@@ -62,7 +63,15 @@ export const devCommand = defineCommand({
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// Claim the shared `.blume` dir so a concurrent build/eject/sync refuses
|
|
65
|
-
// rather than regenerating or deleting it out from under this server.
|
|
66
|
+
// rather than regenerating or deleting it out from under this server. A
|
|
67
|
+
// second dev server would fight over the same generated tree the same
|
|
68
|
+
// way, so it must refuse too instead of silently clobbering the lock.
|
|
69
|
+
if (isDevLocked(project.context.outDir)) {
|
|
70
|
+
logger.error(
|
|
71
|
+
"Another `blume dev` is already running in this project; two dev servers would corrupt the shared .blume dir. Stop the other one first (or delete .blume/dev.lock if it crashed)."
|
|
72
|
+
);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
66
75
|
const releaseLock = acquireDevLock(project.context.outDir);
|
|
67
76
|
process.on("exit", releaseLock);
|
|
68
77
|
|
|
@@ -82,26 +91,31 @@ export const devCommand = defineCommand({
|
|
|
82
91
|
|
|
83
92
|
// Watch user inputs and regenerate the runtime data on change. Astro/Vite
|
|
84
93
|
// hot-reloads the generated data module so nav and routes stay in sync.
|
|
94
|
+
// `coalescedRunner` single-flights the scan so a burst of watch events can
|
|
95
|
+
// never stack overlapping regenerations (a large project's scan can outlast
|
|
96
|
+
// the debounce; piled-up scans exhaust the heap).
|
|
97
|
+
const runRegenerate = coalescedRunner(async () => {
|
|
98
|
+
try {
|
|
99
|
+
const next = await scanProject(root, {
|
|
100
|
+
devServerUrl,
|
|
101
|
+
mode: "dev",
|
|
102
|
+
overrides,
|
|
103
|
+
preview,
|
|
104
|
+
});
|
|
105
|
+
await generateRuntime(next);
|
|
106
|
+
// Surface any content/config errors in the browser overlay too.
|
|
107
|
+
showBlumeErrorOverlay(next.diagnostics);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logger.error(`Regeneration failed: ${(error as Error).message}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
85
113
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
86
114
|
const regenerate = () => {
|
|
87
115
|
if (timer) {
|
|
88
116
|
clearTimeout(timer);
|
|
89
117
|
}
|
|
90
|
-
timer = setTimeout(
|
|
91
|
-
try {
|
|
92
|
-
const next = await scanProject(root, {
|
|
93
|
-
devServerUrl,
|
|
94
|
-
mode: "dev",
|
|
95
|
-
overrides,
|
|
96
|
-
preview,
|
|
97
|
-
});
|
|
98
|
-
await generateRuntime(next);
|
|
99
|
-
// Surface any content/config errors in the browser overlay too.
|
|
100
|
-
showBlumeErrorOverlay(next.diagnostics);
|
|
101
|
-
} catch (error) {
|
|
102
|
-
logger.error(`Regeneration failed: ${(error as Error).message}`);
|
|
103
|
-
}
|
|
104
|
-
}, 80);
|
|
118
|
+
timer = setTimeout(runRegenerate, 80);
|
|
105
119
|
};
|
|
106
120
|
|
|
107
121
|
// Content is watched per source (filesystem uses fs.watch; remote sources
|
package/src/cli/dev-lock.ts
CHANGED
|
@@ -38,8 +38,10 @@ export const isDevLocked = (outDir: string): boolean => {
|
|
|
38
38
|
// Signal 0 probes liveness without actually signaling the process.
|
|
39
39
|
process.kill(pid, 0);
|
|
40
40
|
return true;
|
|
41
|
-
} catch {
|
|
42
|
-
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// EPERM means the process exists but belongs to another user — still
|
|
43
|
+
// live, so the lock must hold (only ESRCH proves it's gone).
|
|
44
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
43
45
|
}
|
|
44
46
|
};
|
|
45
47
|
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -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";
|