blume 1.0.0 → 1.0.2
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 +16 -0
- package/dist/cli/index.js +29 -18
- package/dist/cli/index.js.map +8 -7
- package/dist/types/core/trim.d.ts +16 -0
- package/package.json +2 -2
- package/src/ai/mcp/discovery.ts +2 -1
- package/src/astro/templates.ts +4 -5
- package/src/components/layout/PageLayout.astro +8 -7
- package/src/components/layout/ReferenceLayout.astro +8 -10
- package/src/components/layout/RootLayout.astro +8 -7
- package/src/components/layout/head-scripts.ts +20 -10
- package/src/core/base-path.ts +4 -4
- package/src/core/trim.ts +32 -0
- package/src/openapi/references.ts +4 -6
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-character trimming, done without regular expressions.
|
|
3
|
+
*
|
|
4
|
+
* The obvious spellings — `/^\/+/`, `/\/+$/`, `/^\/+|\/+$/g` — take quadratic
|
|
5
|
+
* time on a run of the trimmed character, because a failed match at one start
|
|
6
|
+
* position tells the engine nothing about the next one. Every caller here trims
|
|
7
|
+
* a value that comes from outside Blume (a configured route, a spec URL, a site
|
|
8
|
+
* origin), so the slow path is reachable from user input rather than only from
|
|
9
|
+
* our own literals. These loops are linear and allocation-free.
|
|
10
|
+
*/
|
|
11
|
+
/** Drop every leading `char` (`"///a"` -> `"a"` for `"/"`). */
|
|
12
|
+
export declare const trimStart: (text: string, char: string) => string;
|
|
13
|
+
/** Drop every trailing `char` (`"a///"` -> `"a"` for `"/"`). */
|
|
14
|
+
export declare const trimEnd: (text: string, char: string) => string;
|
|
15
|
+
/** Drop every leading *and* trailing `char` (`"///a///"` -> `"a"`). */
|
|
16
|
+
export declare const trimChar: (text: string, char: string) => string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blume",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Documentation that's fast, AI-ready, and zero-config.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"directory": "packages/blume"
|
|
23
23
|
},
|
|
24
24
|
"bin": {
|
|
25
|
-
"blume": "
|
|
25
|
+
"blume": "bin/blume.mjs"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"dist",
|
package/src/ai/mcp/discovery.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withBasePath } from "../../core/base-path.ts";
|
|
2
|
+
import { trimEnd } from "../../core/trim.ts";
|
|
2
3
|
import { MCP_TOOLS } from "./tools.ts";
|
|
3
4
|
|
|
4
5
|
/** Inputs needed to describe the MCP server in discovery documents. */
|
|
@@ -18,7 +19,7 @@ const serverUrl = (input: McpDiscoveryInput): string => {
|
|
|
18
19
|
const path = withBasePath(input.base, input.route);
|
|
19
20
|
// Concatenate rather than `new URL(path, site)` — a root-absolute path
|
|
20
21
|
// would drop the base path of a subpath deployment (`acme.com/docs`).
|
|
21
|
-
return input.site ? `${input.site
|
|
22
|
+
return input.site ? `${trimEnd(input.site, "/")}${path}` : path;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
/**
|
package/src/astro/templates.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { AskBackend } from "../ai/ask.ts";
|
|
|
8
8
|
import { normalizeBasePath } from "../core/base-path.ts";
|
|
9
9
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
10
10
|
import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
|
|
11
|
+
import { trimChar } from "../core/trim.ts";
|
|
11
12
|
import type { ProjectContext } from "../core/types.ts";
|
|
12
13
|
import { applyBaseToRedirects } from "../deploy/redirects.ts";
|
|
13
14
|
import { hasScalarReferences } from "../openapi/references.ts";
|
|
@@ -879,10 +880,8 @@ export function GET({ props }) {
|
|
|
879
880
|
`;
|
|
880
881
|
|
|
881
882
|
/** The `src/pages` file that serves a route, e.g. `/mcp` -> `mcp.ts`. */
|
|
882
|
-
export const mcpPageFile = (route: string): string =>
|
|
883
|
-
|
|
884
|
-
return `${clean}.ts`;
|
|
885
|
-
};
|
|
883
|
+
export const mcpPageFile = (route: string): string =>
|
|
884
|
+
`${trimChar(route, "/")}.ts`;
|
|
886
885
|
|
|
887
886
|
/**
|
|
888
887
|
* Generate the hosted MCP server endpoint (e.g. `.blume/src/pages/mcp.ts`). A
|
|
@@ -891,7 +890,7 @@ export const mcpPageFile = (route: string): string => {
|
|
|
891
890
|
* the docs over Streamable HTTP.
|
|
892
891
|
*/
|
|
893
892
|
export const mcpEndpointTemplate = (route: string): string => {
|
|
894
|
-
const clean = route
|
|
893
|
+
const clean = trimChar(route, "/");
|
|
895
894
|
const up = "../".repeat(clean.split("/").length);
|
|
896
895
|
return `// Generated by Blume. Do not edit.
|
|
897
896
|
import type { APIRoute } from "astro";
|
|
@@ -37,7 +37,7 @@ import Analytics from "./Analytics.astro";
|
|
|
37
37
|
import Banner from "./Banner.astro";
|
|
38
38
|
import Favicon from "./Favicon.astro";
|
|
39
39
|
import Fonts from "./Fonts.astro";
|
|
40
|
-
import {
|
|
40
|
+
import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
|
|
41
41
|
import Header from "./Header.astro";
|
|
42
42
|
import { isUnderPath } from "./nav-utils.ts";
|
|
43
43
|
|
|
@@ -169,10 +169,7 @@ const twitterCard = resolvedOgImage ? "summary_large_image" : "summary";
|
|
|
169
169
|
const xSite = normalizeXHandle(x?.handle);
|
|
170
170
|
const xCreator = normalizeXHandle(x?.creator);
|
|
171
171
|
|
|
172
|
-
const
|
|
173
|
-
const bannerScript = banner?.dismissible
|
|
174
|
-
? bannerInitScript(banner.key)
|
|
175
|
-
: null;
|
|
172
|
+
const bannerKey = banner?.dismissible ? banner.key : null;
|
|
176
173
|
---
|
|
177
174
|
|
|
178
175
|
<!doctype html>
|
|
@@ -216,8 +213,12 @@ const bannerScript = banner?.dismissible
|
|
|
216
213
|
{description && <meta content={description} name="twitter:description" />}
|
|
217
214
|
{xSite && <meta content={xSite} name="twitter:site" />}
|
|
218
215
|
{xCreator && <meta content={xCreator} name="twitter:creator" />}
|
|
219
|
-
{
|
|
220
|
-
|
|
216
|
+
{
|
|
217
|
+
bannerKey && (
|
|
218
|
+
<script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
<script data-mode={themeMode} is:inline set:html={THEME_INIT_SCRIPT} />
|
|
221
222
|
<Analytics analytics={analytics} />
|
|
222
223
|
</head>
|
|
223
224
|
<body class="bg-background font-sans text-foreground antialiased">
|
|
@@ -7,6 +7,7 @@ import Analytics from "./Analytics.astro";
|
|
|
7
7
|
import Banner from "./Banner.astro";
|
|
8
8
|
import Favicon from "./Favicon.astro";
|
|
9
9
|
import Fonts from "./Fonts.astro";
|
|
10
|
+
import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
|
|
10
11
|
import Header from "./Header.astro";
|
|
11
12
|
|
|
12
13
|
// A minimal shell for the Scalar API/AsyncAPI reference: Blume's banner + navbar
|
|
@@ -80,14 +81,7 @@ const {
|
|
|
80
81
|
|
|
81
82
|
const strings = ui ?? EN_UI;
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
// (mirrors RootLayout's pre-paint script).
|
|
85
|
-
const initialThemeScript = `(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
|
|
86
|
-
|
|
87
|
-
// Pre-paint: hide a previously-dismissed banner before it flashes in.
|
|
88
|
-
const bannerScript = banner?.dismissible
|
|
89
|
-
? `(()=>{if(localStorage.getItem("blume-banner:"+${JSON.stringify(banner.key)}))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`
|
|
90
|
-
: null;
|
|
84
|
+
const bannerKey = banner?.dismissible ? banner.key : null;
|
|
91
85
|
---
|
|
92
86
|
|
|
93
87
|
<!doctype html>
|
|
@@ -98,8 +92,12 @@ const bannerScript = banner?.dismissible
|
|
|
98
92
|
<title>{pageTitle}</title>
|
|
99
93
|
<Favicon favicon={favicon} appleIcon={appleIcon} />
|
|
100
94
|
<Fonts cssVars={fontCssVars ?? []} />
|
|
101
|
-
{
|
|
102
|
-
|
|
95
|
+
{
|
|
96
|
+
bannerKey && (
|
|
97
|
+
<script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
<script data-mode={themeMode} is:inline set:html={THEME_INIT_SCRIPT} />
|
|
103
101
|
<Analytics analytics={analytics} />
|
|
104
102
|
</head>
|
|
105
103
|
<body class="bg-background font-sans text-foreground antialiased">
|
|
@@ -23,7 +23,7 @@ import Breadcrumbs from "./Breadcrumbs.astro";
|
|
|
23
23
|
import Empty from "./Empty.astro";
|
|
24
24
|
import Favicon from "./Favicon.astro";
|
|
25
25
|
import Fonts from "./Fonts.astro";
|
|
26
|
-
import {
|
|
26
|
+
import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
|
|
27
27
|
import Header from "./Header.astro";
|
|
28
28
|
import Icon from "../Icon.astro";
|
|
29
29
|
import {
|
|
@@ -329,10 +329,7 @@ const structuredDataJson = structuredData
|
|
|
329
329
|
? JSON.stringify(structuredData).replaceAll("<", "\\u003c")
|
|
330
330
|
: null;
|
|
331
331
|
|
|
332
|
-
const
|
|
333
|
-
const bannerScript = banner?.dismissible
|
|
334
|
-
? bannerInitScript(banner.key)
|
|
335
|
-
: null;
|
|
332
|
+
const bannerKey = banner?.dismissible ? banner.key : null;
|
|
336
333
|
---
|
|
337
334
|
|
|
338
335
|
<!doctype html>
|
|
@@ -411,8 +408,12 @@ const bannerScript = banner?.dismissible
|
|
|
411
408
|
/>
|
|
412
409
|
)
|
|
413
410
|
}
|
|
414
|
-
{
|
|
415
|
-
|
|
411
|
+
{
|
|
412
|
+
bannerKey && (
|
|
413
|
+
<script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
<script data-mode={themeMode} is:inline set:html={THEME_INIT_SCRIPT} />
|
|
416
417
|
<Analytics analytics={analytics} />
|
|
417
418
|
</head>
|
|
418
419
|
<body
|
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pre-paint inline scripts shared by the document layouts (`RootLayout`,
|
|
3
|
-
* `
|
|
4
|
-
* the page never flashes the wrong theme or a since-dismissed
|
|
5
|
-
* one place so the
|
|
3
|
+
* `PageLayout`, `ReferenceLayout`). They run synchronously in `<head>`, before
|
|
4
|
+
* first paint, so the page never flashes the wrong theme or a since-dismissed
|
|
5
|
+
* banner. Kept in one place so the layouts can't drift on this timing-critical
|
|
6
|
+
* logic.
|
|
7
|
+
*
|
|
8
|
+
* Both are constants, never built by interpolating config into source text: the
|
|
9
|
+
* values they need ride in as `data-*` attributes on the script tag and are read
|
|
10
|
+
* back through `document.currentScript`. Baking a config string into JS — even
|
|
11
|
+
* via `JSON.stringify` — is code construction, and JSON escaping does not cover
|
|
12
|
+
* a script context (`</script>`, U+2028/U+2029 all survive it). Attributes are
|
|
13
|
+
* HTML-escaped by Astro, so the value can never be parsed as code.
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
17
|
* Set `data-theme` from the stored preference (or the configured default, or the
|
|
10
18
|
* OS setting for `"system"`) before the body paints, avoiding a theme flash.
|
|
19
|
+
*
|
|
20
|
+
* Reads `data-mode` — `"system" | "light" | "dark"`.
|
|
11
21
|
*/
|
|
12
|
-
export const
|
|
13
|
-
themeMode: "system" | "light" | "dark"
|
|
14
|
-
): string =>
|
|
15
|
-
`(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
|
|
22
|
+
export const THEME_INIT_SCRIPT = `(()=>{const m=document.currentScript?.dataset.mode??"system";const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
|
|
16
23
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Hide a previously-dismissed banner before it can flash in.
|
|
26
|
+
*
|
|
27
|
+
* Reads `data-key` — the banner's dismissal key.
|
|
28
|
+
*/
|
|
29
|
+
export const BANNER_INIT_SCRIPT = `(()=>{const k=document.currentScript?.dataset.key;if(k&&localStorage.getItem("blume-banner:"+k))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
|
package/src/core/base-path.ts
CHANGED
|
@@ -21,10 +21,10 @@ export const normalizeBasePath = (input?: string): string => {
|
|
|
21
21
|
if (!input) {
|
|
22
22
|
return "";
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
// Splitting on "/" and dropping the empty parts trims the edges and collapses
|
|
25
|
+
// inner runs in one linear pass; the regex spellings of both are quadratic on
|
|
26
|
+
// a long run of slashes (see `core/trim.ts`).
|
|
27
|
+
const trimmed = input.trim().split("/").filter(Boolean).join("/");
|
|
28
28
|
return trimmed === "" ? "" : `/${trimmed}`;
|
|
29
29
|
};
|
|
30
30
|
|
package/src/core/trim.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-character trimming, done without regular expressions.
|
|
3
|
+
*
|
|
4
|
+
* The obvious spellings — `/^\/+/`, `/\/+$/`, `/^\/+|\/+$/g` — take quadratic
|
|
5
|
+
* time on a run of the trimmed character, because a failed match at one start
|
|
6
|
+
* position tells the engine nothing about the next one. Every caller here trims
|
|
7
|
+
* a value that comes from outside Blume (a configured route, a spec URL, a site
|
|
8
|
+
* origin), so the slow path is reachable from user input rather than only from
|
|
9
|
+
* our own literals. These loops are linear and allocation-free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Drop every leading `char` (`"///a"` -> `"a"` for `"/"`). */
|
|
13
|
+
export const trimStart = (text: string, char: string): string => {
|
|
14
|
+
let start = 0;
|
|
15
|
+
while (start < text.length && text[start] === char) {
|
|
16
|
+
start += 1;
|
|
17
|
+
}
|
|
18
|
+
return text.slice(start);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Drop every trailing `char` (`"a///"` -> `"a"` for `"/"`). */
|
|
22
|
+
export const trimEnd = (text: string, char: string): string => {
|
|
23
|
+
let end = text.length;
|
|
24
|
+
while (end > 0 && text[end - 1] === char) {
|
|
25
|
+
end -= 1;
|
|
26
|
+
}
|
|
27
|
+
return text.slice(0, end);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Drop every leading *and* trailing `char` (`"///a///"` -> `"a"`). */
|
|
31
|
+
export const trimChar = (text: string, char: string): string =>
|
|
32
|
+
trimEnd(trimStart(text, char), char);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withBasePath } from "../core/base-path.ts";
|
|
2
2
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
|
+
import { trimChar, trimEnd } from "../core/trim.ts";
|
|
3
4
|
import type { NavTab } from "../core/types.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -52,24 +53,21 @@ export interface ReferenceSource {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
55
|
-
const SLUG_EDGES = /^-+|-+$/gu;
|
|
56
|
-
const ROUTE_EDGES = /^\/+|\/+$/gu;
|
|
57
|
-
const TRAILING_SLASH = /\/+$/u;
|
|
58
56
|
|
|
59
57
|
export const slugify = (text: string): string =>
|
|
60
|
-
text.toLowerCase().replace(NON_SLUG, "-")
|
|
58
|
+
trimChar(text.toLowerCase().replace(NON_SLUG, "-"), "-");
|
|
61
59
|
|
|
62
60
|
/** Normalize a configured route to a single leading slash, no trailing slash. */
|
|
63
61
|
export const normalizeRoute = (route: string): string => {
|
|
64
62
|
const trimmed = route.trim();
|
|
65
63
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
66
|
-
const noTrailing = withSlash
|
|
64
|
+
const noTrailing = trimEnd(withSlash, "/");
|
|
67
65
|
return noTrailing === "" ? "/" : noTrailing;
|
|
68
66
|
};
|
|
69
67
|
|
|
70
68
|
/** A stable per-reference token from its route: `/api/events` -> `api-events`. */
|
|
71
69
|
const routeSlug = (route: string): string =>
|
|
72
|
-
slugify(route
|
|
70
|
+
slugify(trimChar(route, "/")) || "reference";
|
|
73
71
|
|
|
74
72
|
type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
|
|
75
73
|
|