blume 1.0.1 → 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.
@@ -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.1",
3
+ "version": "1.0.2",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -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.replace(/\/+$/u, "")}${path}` : path;
22
+ return input.site ? `${trimEnd(input.site, "/")}${path}` : path;
22
23
  };
23
24
 
24
25
  /**
@@ -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
- const clean = route.replace(/^\/+/u, "").replace(/\/+$/u, "");
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.replace(/^\/+/u, "").replace(/\/+$/u, "");
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 { bannerInitScript, themeInitScript } from "./head-scripts.ts";
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 initialThemeScript = themeInitScript(themeMode);
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
- {bannerScript && <script is:inline set:html={bannerScript} />}
220
- <script is:inline set:html={initialThemeScript} />
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
- // Set the theme before paint so the navbar never flashes the wrong colors
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
- {bannerScript && <script is:inline set:html={bannerScript} />}
102
- <script is:inline set:html={initialThemeScript} />
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 { bannerInitScript, themeInitScript } from "./head-scripts.ts";
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 initialThemeScript = themeInitScript(themeMode);
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
- {bannerScript && <script is:inline set:html={bannerScript} />}
415
- <script is:inline set:html={initialThemeScript} />
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
- * `SplashLayout`). They run synchronously in `<head>`, before first paint, so
4
- * the page never flashes the wrong theme or a since-dismissed banner. Kept in
5
- * one place so the two layouts can't drift on this timing-critical logic.
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 themeInitScript = (
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
- /** Hide a previously-dismissed banner before it can flash in. */
18
- export const bannerInitScript = (key: string): string =>
19
- `(()=>{if(localStorage.getItem("blume-banner:"+${JSON.stringify(key)}))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
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","");})();`;
@@ -21,10 +21,10 @@ export const normalizeBasePath = (input?: string): string => {
21
21
  if (!input) {
22
22
  return "";
23
23
  }
24
- const trimmed = input
25
- .trim()
26
- .replaceAll(/^\/+|\/+$/gu, "")
27
- .replaceAll(/\/{2,}/gu, "/");
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
 
@@ -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, "-").replace(SLUG_EDGES, "");
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.replace(TRAILING_SLASH, "");
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.replace(ROUTE_EDGES, "")) || "reference";
70
+ slugify(trimChar(route, "/")) || "reference";
73
71
 
74
72
  type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
75
73