blume 1.5.0 → 1.5.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 +32 -0
- package/README.md +16 -12
- package/dist/cli/index.js +449 -135
- package/dist/cli/index.js.map +24 -23
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +54 -2
- package/dist/types/core/data.d.ts +19 -2
- package/dist/types/core/open-in-chat.d.ts +9 -0
- package/dist/types/core/schema.d.ts +48 -1
- package/dist/types/core/types.d.ts +10 -3
- package/dist/types/openapi/references.d.ts +9 -0
- package/dist/types/search/orama-index.d.ts +70 -0
- package/dist/types/theme/fonts.d.ts +11 -2
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/advanced/custom-pages.mdx +5 -1
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +14 -2
- package/docs/configuration/search.mdx +4 -4
- package/docs/configuration/theming.mdx +4 -2
- package/docs/reference/cli.mdx +2 -2
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/ask-context.ts +51 -11
- package/src/ai/mcp/data.ts +3 -2
- package/src/ai/mcp/server.ts +3 -2
- package/src/assets/icon-dark.png +0 -0
- package/src/astro/generate.ts +172 -18
- package/src/astro/templates.ts +89 -15
- package/src/components/content/AccordionItem.astro +4 -0
- package/src/components/content/Update.astro +3 -0
- package/src/components/islands/AskAI.astro +6 -0
- package/src/components/islands/ask-ai.tsx +39 -9
- package/src/components/layout/Analytics.astro +9 -1
- package/src/components/layout/Favicon.astro +29 -8
- package/src/components/layout/Fonts.astro +23 -3
- package/src/components/layout/Header.astro +2 -2
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/PageActions.astro +120 -78
- package/src/components/layout/PageFeedback.astro +12 -3
- package/src/components/layout/PageLayout.astro +79 -5
- package/src/components/layout/ReferenceLayout.astro +12 -9
- package/src/components/layout/RootLayout.astro +153 -121
- package/src/components/layout/Search.astro +41 -26
- package/src/components/layout/drawer-inert.ts +10 -5
- package/src/components/layout/head-scripts.ts +34 -16
- package/src/components/layout/nav-utils.ts +34 -15
- package/src/components/layout/search/orama.ts +3 -2
- package/src/components/openapi/AsyncApiOperation.astro +22 -7
- package/src/components/openapi/MessageComposer.astro +238 -0
- package/src/components/openapi/Operation.astro +26 -12
- package/src/components/openapi/PanelTabs.astro +7 -0
- package/src/components/openapi/Playground.astro +320 -0
- package/src/components/openapi/RequestPanel.astro +1 -0
- package/src/components/openapi/async-snippets.ts +20 -7
- package/src/components/openapi/async.ts +13 -2
- package/src/components/openapi/message-composer.ts +242 -0
- package/src/components/openapi/message-model.ts +108 -0
- package/src/components/openapi/message.ts +153 -0
- package/src/components/openapi/operation-model.ts +260 -0
- package/src/components/openapi/playground-client.ts +486 -0
- package/src/components/openapi/playground-schema.ts +109 -0
- package/src/components/openapi/request.ts +287 -0
- package/src/components/openapi/security.ts +0 -56
- package/src/components/openapi/snippets.ts +23 -136
- package/src/components/openapi/validate-json.ts +144 -0
- package/src/components/openapi/ws-client.ts +194 -0
- package/src/core/config-input.ts +67 -1
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +16 -2
- package/src/core/last-modified.ts +76 -2
- package/src/core/links.ts +30 -4
- package/src/core/navigation.ts +26 -1
- package/src/core/open-in-chat.ts +17 -0
- package/src/core/project-graph.ts +11 -0
- package/src/core/schema.ts +60 -1
- package/src/core/server-features.ts +11 -0
- package/src/core/sources/normalize.ts +10 -2
- package/src/core/types.ts +10 -3
- package/src/deploy/vercel-negotiation.ts +34 -14
- package/src/og/card.ts +3 -1
- package/src/openapi/model.ts +7 -0
- package/src/openapi/proxy.ts +217 -0
- package/src/openapi/references.ts +8 -0
- package/src/openapi/source.ts +13 -0
- package/src/registry/eject.ts +4 -5
- package/src/search/orama-index.ts +109 -36
- package/src/theme/entry.ts +15 -2
- package/src/theme/fonts.ts +75 -3
|
@@ -140,6 +140,18 @@ my-docs/
|
|
|
140
140
|
|
|
141
141
|
SVG wins over PNG over ICO when several are present, and a file in `public/` is preferred over one at the root. If Blume finds no icon, it falls back to its own mark.
|
|
142
142
|
|
|
143
|
+
A dark mark disappears against dark browser chrome, so you can ship a second file for dark mode. Add a `-dark` sibling of your icon file — the same name and directory, with `-dark` before the extension (`icon.png` → `icon-dark.png`) — and Blume emits both icons behind a `prefers-color-scheme` media query, plus a plain light tag for browsers and crawlers that ignore media queries on icons:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
my-docs/
|
|
147
|
+
├─ blume.config.ts
|
|
148
|
+
├─ icon.png ← light mode
|
|
149
|
+
├─ icon-dark.png ← dark mode
|
|
150
|
+
└─ docs/
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Only the sibling of the icon Blume picked is used — a `-dark` file with a different name stays ignored, so an unrelated file can't pair with your mark by accident. The dark file is optional; with only one icon, Blume emits a single tag as before. Blume's own fallback mark ships both variants.
|
|
154
|
+
|
|
143
155
|
### Apple touch icon
|
|
144
156
|
|
|
145
157
|
The icon iOS uses when someone adds your site to their home screen is detected the same way. Drop an `apple-icon` file (`.png`, `.jpg`, or `.jpeg`) — or an `apple-touch-icon.png`, the name most favicon generators emit — in your project root or `public/` directory and Blume wires up `<link rel="apple-touch-icon">` for you. There's no default; if no file is found, no tag is emitted.
|
|
@@ -151,7 +163,7 @@ my-docs/
|
|
|
151
163
|
└─ docs/
|
|
152
164
|
```
|
|
153
165
|
|
|
154
|
-
Put the file in `public/` rather than the project root: iOS ignores the inlined data URI Blume uses for a root-level icon, so only a `public/` file (served at `/apple-icon.png`) reliably reaches the home screen.
|
|
166
|
+
Put the file in `public/` rather than the project root: iOS ignores the inlined data URI Blume uses for a root-level icon, so only a `public/` file (served at `/apple-icon.png`) reliably reaches the home screen. Unlike the favicon, there's no `-dark` sibling here — iOS ignores media queries on home-screen icons, so a dark variant could never be served.
|
|
155
167
|
|
|
156
168
|
### Banner
|
|
157
169
|
|
|
@@ -289,7 +301,7 @@ lastModified: true,
|
|
|
289
301
|
| `{ type: "git" }` | Same as `true`, written explicitly. |
|
|
290
302
|
| `{ type: "frontmatter" }` | Never run git — use only the `lastModified` frontmatter field. |
|
|
291
303
|
|
|
292
|
-
The git source reads the most recent commit that touched each file, so it works in any git repository — including monorepos — and needs the repo's history at build time
|
|
304
|
+
The git source reads the most recent commit that touched each file, so it works in any git repository — including monorepos — and needs the repo's history at build time. CI platforms usually check out a shallow clone, which silently drops most dates (the build warns with `BLUME_SHALLOW_GIT_HISTORY` when that happens): on Vercel, set the `VERCEL_DEEP_CLONE=true` environment variable; with `actions/checkout`, set `fetch-depth: 0`. A page's own `lastModified` frontmatter always wins, which is handy for pinning a date or for files that aren't committed yet:
|
|
293
305
|
|
|
294
306
|
```mdx page.mdx
|
|
295
307
|
---
|
|
@@ -67,9 +67,9 @@ search: {
|
|
|
67
67
|
}
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
####
|
|
70
|
+
#### Non-Latin scripts
|
|
71
71
|
|
|
72
|
-
Orama's standard tokenizer
|
|
72
|
+
Orama's standard tokenizer keeps only basic Latin letters, digits and a handful of accented vowels, so text in any other script — Japanese, Chinese, Korean and Thai, but equally Russian, Greek, Hebrew and Hindi — would otherwise produce no matches at all. Blume handles this for you: when [`i18n.defaultLocale`](/docs/content/i18n) resolves to a non-Latin script, the index switches to a word-segmenting tokenizer (built on the browser- and Node-native `Intl.Segmenter`). Declaring your site's language is all it takes:
|
|
73
73
|
|
|
74
74
|
```ts blume.config.ts lineNumbers
|
|
75
75
|
i18n: {
|
|
@@ -78,7 +78,7 @@ i18n: {
|
|
|
78
78
|
}
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
The same tokenizer serves the search dialog, the MCP server's `search_docs` tool, and Ask AI grounding.
|
|
81
|
+
The same tokenizer serves the search dialog, the MCP server's `search_docs` tool, and Ask AI grounding. The script is what decides, not the language name — `az-Cyrl` is segmented while `sr-Latn` is not — and it is the default locale that decides for the whole index: on a mixed-language site every page shares the default locale's tokenizer. With a non-Latin default that's safe, because Latin words survive segmentation intact, so pages in English stay searchable alongside the default language. The reverse doesn't hold: non-Latin translations on a Latin-default site aren't searchable. Latin-script languages that lean heavily on diacritics (Vietnamese, or Serbian in Latin script) also fare worse on the standard tokenizer, which folds only a few accented vowels and splits words on the rest.
|
|
82
82
|
|
|
83
83
|
Japanese and Chinese go one step further. Segmenting alone indexes a compound term as its parts — 資金決済法 as 資金, 決済 and 法 — which lets a page mentioning each part somewhere outrank the page the term is actually about. Han, Hiragana and Katakana are therefore indexed as overlapping character pairs, and queries on those indexes prefer pages carrying a term's pairs together, loosening to any-pair matching when no page carries them all, so typing a whole sentence still returns its closest pages. Korean and Thai keep their segmented words.
|
|
84
84
|
|
|
@@ -86,7 +86,7 @@ Japanese and Chinese go one step further. Segmenting alone indexes a compound te
|
|
|
86
86
|
|
|
87
87
|
A second keyless, client-side option. It reuses the same `/blume-search.json` index Orama ships and builds a [FlexSearch](https://github.com/nextapps-de/flexsearch) document index in the browser. Works in `blume dev` and `blume build`.
|
|
88
88
|
|
|
89
|
-
FlexSearch has no equivalent segmentation hook, so for sites in
|
|
89
|
+
FlexSearch has no equivalent segmentation hook, so for sites in a non-Latin script prefer Orama (the default) or [Pagefind](#pagefind), whose `pagefind_extended` binary indexes a broad set of languages and segments Chinese, Japanese and Korean natively.
|
|
90
90
|
|
|
91
91
|
```ts blume.config.ts lineNumbers
|
|
92
92
|
search: {
|
|
@@ -15,7 +15,7 @@ theme: {
|
|
|
15
15
|
radius: "md", // none | sm | md | lg
|
|
16
16
|
mode: "system", // system | light | dark
|
|
17
17
|
fonts: { // self-hosted Google Fonts
|
|
18
|
-
display: "inter
|
|
18
|
+
display: "inter",
|
|
19
19
|
body: "inter",
|
|
20
20
|
mono: "ibm-plex-mono",
|
|
21
21
|
},
|
|
@@ -57,12 +57,14 @@ A toggle in the header always lets readers switch, and their choice is remembere
|
|
|
57
57
|
- **`body`** — body text, UI, and prose
|
|
58
58
|
- **`mono`** — code blocks and inline code
|
|
59
59
|
|
|
60
|
+
Headings get display-grade letter-spacing (`-0.05em`) from the theme itself, so whatever you pick for `display` — including text families like the Inter default — reads correctly at heading sizes instead of depending on tracking built into the font.
|
|
61
|
+
|
|
60
62
|
Each defaults to a curated Google Font, so Blume looks intentional out of the box:
|
|
61
63
|
|
|
62
64
|
```ts blume.config.ts lineNumbers
|
|
63
65
|
theme: {
|
|
64
66
|
fonts: {
|
|
65
|
-
display: "inter
|
|
67
|
+
display: "inter", // default
|
|
66
68
|
body: "inter", // default
|
|
67
69
|
mono: "ibm-plex-mono", // default
|
|
68
70
|
},
|
package/docs/reference/cli.mdx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: CLI
|
|
3
|
-
description: Every Blume command and flag explained in one place
|
|
3
|
+
description: Every Blume command and flag explained in one place, along with the options each one accepts.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
```bash
|
|
@@ -123,7 +123,7 @@ Without a project `tsconfig.json`, only the generated runtime is checked.
|
|
|
123
123
|
|
|
124
124
|
- **Internal page links** (`/guides/intro`, `./sibling`) must resolve to a real page — broken ones are reported as errors.
|
|
125
125
|
- **Anchor links** (`#section`, `/guides/intro#setup`) must match a heading on the target page — misses are warnings.
|
|
126
|
-
- **Asset links** (`/logo.png`)
|
|
126
|
+
- **Asset links** are checked where the file lives: an absolute path (`/logo.png`) against the `public/` directory, a relative image embed (``) against the page's own folder. A plain link to a relative path still resolves as a site route — only image embeds go through the image pipeline.
|
|
127
127
|
- **External links** are only checked with `--external` (off by default since it requires the network); dead links (404/410/unreachable) are errors, while rate-limited or transient responses (403/429/5xx/timeout) are warnings.
|
|
128
128
|
|
|
129
129
|
## Auditing the built site
|
package/package.json
CHANGED
|
@@ -70,7 +70,7 @@ The single biggest shift for most sources — especially Mintlify — is that **
|
|
|
70
70
|
- **`navigation`:** `tabs`, `selectors`, `featured` (links pinned above the sidebar on every route), `sidebar` (`{ display, items }` — `display` is the global render mode above; `items` is an explicit tree), `repo`. **Avoid an explicit `navigation.sidebar` unless you have to** — lean on the filesystem-derived sidebar. It only works when the file tree roughly matches the intended sidebar layout, so reshape folders to match first; reach for `sidebar.items` only for a shape files genuinely can't express (see "Config-declared nesting" above).
|
|
71
71
|
- **`search`** (Orama default, Pagefind opt-in), **`ai`** (llms.txt, Ask AI, the MCP server), **`openapi`**, **`redirects`**, **`seo`**, **`markdown`**, **`analytics`**, **`deployment`**, **`i18n`**, **`toc`**, **`lastModified`**, **`github`**.
|
|
72
72
|
- **Don't set `deployment.site`.** Blume auto-fills it: the dev server's `localhost` URL in dev, and the deployment URL (`VERCEL_PROJECT_PRODUCTION_URL`/`VERCEL_URL`) on Vercel. Hardcoding it in `blume.config.ts` overrides that auto-detection and pins the wrong absolute URL (canonical links, sitemap, OG, llms.txt) everywhere but the one host you typed — so leave it unset even when a source config had a `url`/`site` field. (Sitemap still generates in production because the deploy URL is present there.)
|
|
73
|
-
- **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }`
|
|
73
|
+
- **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }` maps to a filename pair: copy the light file to a conventional name (e.g. `public/icon.png`) and the dark file to its `-dark` sibling — same directory and extension, `-dark` before the extension (`public/icon-dark.png`). If the two files have different formats, convert one so the extensions match; only an exact sibling of the resolved icon is picked up.
|
|
74
74
|
|
|
75
75
|
The schema is exported from `blume/schema`; the full field reference is in the `docs/configuration/` directory of the installed `blume` package (see "Full documentation" below for how to locate it).
|
|
76
76
|
|
|
@@ -19,7 +19,7 @@ Resolve `$ref` includes first (Mintlify splits config across files). Map only wh
|
|
|
19
19
|
| `name` / `title` | `title` | |
|
|
20
20
|
| `description` | `description` | |
|
|
21
21
|
| `logo` (string or `{ light, dark, href }`) | `logo` (string, or `{ image: { light, dark, alt }, text, href }`) | Mintlify's `light`/`dark` nest under Blume's `image`; ensure files land in `public/`. **If the SVG is local and monochrome** (a solid black or white mark), rewrite its `fill`/`stroke` to `currentColor` and collapse `{ light, dark }` to the string shorthand `logo: "/logo.svg"` — one file that inherits the theme text color and flips with light/dark automatically. If the logo is a **wordmark** (brand name baked in), set `text: ""` so it doesn't render twice beside `title` |
|
|
22
|
-
| `favicon` (string **or `{ light, dark }`**) | **drop the field** — copy
|
|
22
|
+
| `favicon` (string **or `{ light, dark }`**) | **drop the field** — copy the file(s) into `public/` under conventional names (`favicon.svg`/`icon.png`) | Blume auto-detects by filename; there is **no** favicon config field. A `{ light, dark }` source maps to a filename pair: light file → `public/icon.png`, dark file → its `-dark` sibling `public/icon-dark.png` (same directory and extension — convert if the formats differ) |
|
|
23
23
|
| `colors.primary` + `colors.light` | `theme.accent` (`{ light, dark }`) | accent is now **per-mode**: `accent: { light: <colors.primary>, dark: <colors.light> }`. If Mintlify sets only `primary`, collapse to the string shorthand `accent: "<colors.primary>"` (applies to both modes) |
|
|
24
24
|
| `colors.dark` | `theme.action` | |
|
|
25
25
|
| `appearance.default` | `theme.mode` (`light`/`dark`/`system`) | |
|
package/src/ai/ask-context.ts
CHANGED
|
@@ -22,8 +22,8 @@ export interface AskPage {
|
|
|
22
22
|
export interface AskData {
|
|
23
23
|
/**
|
|
24
24
|
* The site's `i18n.defaultLocale`, when i18n is configured. Selects a
|
|
25
|
-
* word-segmenting Orama tokenizer for
|
|
26
|
-
*
|
|
25
|
+
* word-segmenting Orama tokenizer for every non-Latin script, so retrieval
|
|
26
|
+
* can match CJK, Cyrillic, Greek, Hebrew, or Devanagari content.
|
|
27
27
|
*/
|
|
28
28
|
defaultLocale?: string;
|
|
29
29
|
documents: OramaDoc[];
|
|
@@ -36,6 +36,34 @@ const MAX_RESULTS = 6;
|
|
|
36
36
|
const EXCERPT_CHARS = 2000;
|
|
37
37
|
/** Overall cap on injected documentation characters. */
|
|
38
38
|
const CONTEXT_BUDGET = 10_000;
|
|
39
|
+
/**
|
|
40
|
+
* Smallest excerpt worth injecting. A long page pushed under a tiny residual
|
|
41
|
+
* budget would get a full `## Title (/route)` heading over a fragment of a few
|
|
42
|
+
* dozen characters — a section the model is invited to cite but that grounds
|
|
43
|
+
* nothing. Short pages that fit whole are still injected below this floor.
|
|
44
|
+
*/
|
|
45
|
+
const MIN_EXCERPT_CHARS = 200;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How much retrieved documentation a question carries (the `ai.ask.retrieval`
|
|
49
|
+
* config). Every field falls back to the built-in default, so a partial object
|
|
50
|
+
* only changes what it names. Injected characters dominate time-to-first-token
|
|
51
|
+
* on a self-hosted backend, and the three knobs aren't interchangeable: the
|
|
52
|
+
* budget caps the total, `excerptChars` decides how deep into one long page the
|
|
53
|
+
* excerpt reaches, and `maxResults` decides how many pages retrieval adds (the
|
|
54
|
+
* page the reader is viewing is injected on top of them).
|
|
55
|
+
*/
|
|
56
|
+
export interface AskRetrievalOptions {
|
|
57
|
+
/** Overall cap on injected documentation characters. Defaults to `10000`. */
|
|
58
|
+
contextBudget?: number;
|
|
59
|
+
/** Characters kept per injected excerpt. Defaults to `2000`. */
|
|
60
|
+
excerptChars?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Documents retrieved per question. Defaults to `6`. The current page is
|
|
63
|
+
* injected in addition when it isn't among the hits.
|
|
64
|
+
*/
|
|
65
|
+
maxResults?: number;
|
|
66
|
+
}
|
|
39
67
|
/** Chars of lead-in kept before the matched region, for heading/sentence context. */
|
|
40
68
|
const EXCERPT_LEAD = 160;
|
|
41
69
|
|
|
@@ -176,13 +204,14 @@ export const relevantExcerpt = (
|
|
|
176
204
|
return `${prefix}${slice}${suffix}`;
|
|
177
205
|
};
|
|
178
206
|
|
|
179
|
-
|
|
207
|
+
// Case-insensitive matching via regex rather than `indexOf` on a lowercased
|
|
208
|
+
// copy: length-changing case mappings (Turkish İ → "i" + U+0307) would shift
|
|
209
|
+
// every index in the copy, sliding the excerpt window off the match. Terms
|
|
210
|
+
// come from TERM (letters, marks and digits only), so no regex escaping.
|
|
180
211
|
const positions: number[] = [];
|
|
181
212
|
for (const term of queryTerms(query)) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
positions.push(idx);
|
|
185
|
-
idx = lower.indexOf(term, idx + term.length);
|
|
213
|
+
for (const match of trimmed.matchAll(new RegExp(term, "giu"))) {
|
|
214
|
+
positions.push(match.index);
|
|
186
215
|
}
|
|
187
216
|
}
|
|
188
217
|
// No query terms hit this doc — nothing to center on, so keep the head.
|
|
@@ -232,10 +261,13 @@ export const relevantExcerpt = (
|
|
|
232
261
|
* the base instruction rather than replacing it: the base carries the
|
|
233
262
|
* functional contract (answer only from the excerpts, cite pages as Markdown
|
|
234
263
|
* links) that the panel's citation rendering depends on.
|
|
264
|
+
*
|
|
265
|
+
* `options.retrieval` (the `ai.ask.retrieval` config) sizes how much
|
|
266
|
+
* documentation each question carries; omitted fields keep today's defaults.
|
|
235
267
|
*/
|
|
236
268
|
export const createAskContext = (
|
|
237
269
|
data: AskData,
|
|
238
|
-
options?: { instructions?: string }
|
|
270
|
+
options?: { instructions?: string; retrieval?: AskRetrievalOptions }
|
|
239
271
|
): ((
|
|
240
272
|
messages: AskMessage[],
|
|
241
273
|
page?: AskPage
|
|
@@ -250,6 +282,9 @@ export const createAskContext = (
|
|
|
250
282
|
const instruction = options?.instructions
|
|
251
283
|
? `${BASE_INSTRUCTION}\n\n${options.instructions}`
|
|
252
284
|
: BASE_INSTRUCTION;
|
|
285
|
+
const maxResults = options?.retrieval?.maxResults ?? MAX_RESULTS;
|
|
286
|
+
const excerptChars = options?.retrieval?.excerptChars ?? EXCERPT_CHARS;
|
|
287
|
+
const contextBudget = options?.retrieval?.contextBudget ?? CONTEXT_BUDGET;
|
|
253
288
|
|
|
254
289
|
return async (messages, page) => {
|
|
255
290
|
const list = Array.isArray(messages) ? messages : [];
|
|
@@ -263,22 +298,27 @@ export const createAskContext = (
|
|
|
263
298
|
? byRoute.get(normalizeRoute(page.path))
|
|
264
299
|
: undefined;
|
|
265
300
|
const db = await index();
|
|
266
|
-
const hits = await queryOramaIndex(db, query,
|
|
301
|
+
const hits = await queryOramaIndex(db, query, maxResults, {
|
|
267
302
|
locale: current?.locale || undefined,
|
|
268
303
|
});
|
|
269
304
|
|
|
270
305
|
const seen = new Set<string>();
|
|
271
306
|
const sections: string[] = [];
|
|
272
|
-
let budget =
|
|
307
|
+
let budget = contextBudget;
|
|
273
308
|
const push = (doc: OramaDoc, label: string) => {
|
|
274
309
|
if (seen.has(doc.route) || budget <= 0) {
|
|
275
310
|
return;
|
|
276
311
|
}
|
|
312
|
+
// Skip a page that would be cut to a junk fragment: its excerpt is only
|
|
313
|
+
// useful when it either fits whole or gets at least the minimum window.
|
|
314
|
+
if (budget < MIN_EXCERPT_CHARS && doc.content.trim().length > budget) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
277
317
|
seen.add(doc.route);
|
|
278
318
|
const body = relevantExcerpt(
|
|
279
319
|
doc.content,
|
|
280
320
|
query,
|
|
281
|
-
Math.min(
|
|
321
|
+
Math.min(excerptChars, budget)
|
|
282
322
|
);
|
|
283
323
|
budget -= body.length;
|
|
284
324
|
sections.push(`## ${doc.title} (${doc.route})${label}\n${body}`);
|
package/src/ai/mcp/data.ts
CHANGED
|
@@ -37,8 +37,9 @@ export interface McpData {
|
|
|
37
37
|
base: string;
|
|
38
38
|
/**
|
|
39
39
|
* The site's `i18n.defaultLocale`, when i18n is configured. Selects a
|
|
40
|
-
* word-segmenting Orama tokenizer for
|
|
41
|
-
* `search_docs` can match CJK
|
|
40
|
+
* word-segmenting Orama tokenizer for every non-Latin script, so
|
|
41
|
+
* `search_docs` can match CJK, Cyrillic, Greek, Hebrew, or Devanagari
|
|
42
|
+
* content.
|
|
42
43
|
*/
|
|
43
44
|
defaultLocale?: string;
|
|
44
45
|
/**
|
package/src/ai/mcp/server.ts
CHANGED
|
@@ -311,8 +311,9 @@ export type OramaIndexProvider = () => Promise<
|
|
|
311
311
|
|
|
312
312
|
/**
|
|
313
313
|
* Memoize the search index so every server built from a snapshot shares it.
|
|
314
|
-
* `locale` is the snapshot's `defaultLocale`, forwarded so
|
|
315
|
-
* (Japanese
|
|
314
|
+
* `locale` is the snapshot's `defaultLocale`, forwarded so non-Latin scripts
|
|
315
|
+
* (Japanese and Chinese, but equally Cyrillic, Greek, Hebrew, Devanagari…)
|
|
316
|
+
* get a word-segmenting tokenizer.
|
|
316
317
|
*/
|
|
317
318
|
export const createIndexProvider = (
|
|
318
319
|
documents: OramaDoc[],
|
|
Binary file
|
package/src/astro/generate.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
|
|
|
50
50
|
import { missingFontFiles, resolveOgFonts } from "../og/derive.ts";
|
|
51
51
|
import type { DerivedOgFonts } from "../og/derive.ts";
|
|
52
52
|
import { resolveOgLogo } from "../og/logo.ts";
|
|
53
|
+
import type { OpenApiData } from "../openapi/model.ts";
|
|
53
54
|
import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
|
|
54
55
|
import { buildReferenceFiles } from "../openapi/scalar.ts";
|
|
55
56
|
import { isOpenApiSource } from "../openapi/source.ts";
|
|
@@ -61,7 +62,7 @@ import {
|
|
|
61
62
|
examplesEntryTemplate,
|
|
62
63
|
tailwindEntryTemplate,
|
|
63
64
|
} from "../theme/entry.ts";
|
|
64
|
-
import { buildFontsCss,
|
|
65
|
+
import { buildFontsCss, configuredFonts } from "../theme/fonts.ts";
|
|
65
66
|
import { buildThemeCss } from "../theme/palette.ts";
|
|
66
67
|
import { twoslashCss } from "../theme/twoslash.ts";
|
|
67
68
|
import { planComponentSlots } from "./component-slots.ts";
|
|
@@ -94,6 +95,7 @@ import {
|
|
|
94
95
|
mixedbreadSearchEndpointTemplate,
|
|
95
96
|
notFoundPageTemplate,
|
|
96
97
|
ogEndpointTemplate,
|
|
98
|
+
playgroundProxyTemplate,
|
|
97
99
|
rawMarkdownEndpointTemplate,
|
|
98
100
|
rssEndpointTemplate,
|
|
99
101
|
runtimeDirWithin,
|
|
@@ -964,6 +966,13 @@ const FAVICON_CANDIDATES = [
|
|
|
964
966
|
"icon.ico",
|
|
965
967
|
];
|
|
966
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Derive the `-dark` sibling of an icon filename (`icon.svg` → `icon-dark.svg`),
|
|
971
|
+
* inserting the suffix before the extension.
|
|
972
|
+
*/
|
|
973
|
+
const darkSibling = (name: string): string =>
|
|
974
|
+
name.replace(/\.(?=[^.]+$)/u, "-dark.");
|
|
975
|
+
|
|
967
976
|
/** `<link type>` MIME for the favicon extensions we recognize. */
|
|
968
977
|
const FAVICON_TYPES = new Map([
|
|
969
978
|
["ico", "image/x-icon"],
|
|
@@ -983,8 +992,19 @@ const faviconType = (name: string): string | undefined => {
|
|
|
983
992
|
const inlineDataUri = (file: string, type: string): string =>
|
|
984
993
|
`data:${type};base64,${readFileSync(file).toString("base64")}`;
|
|
985
994
|
|
|
986
|
-
/**
|
|
995
|
+
/**
|
|
996
|
+
* The bundled Blume favicon, inlined as a data URI so it needs no public file.
|
|
997
|
+
* The mark is dark, so it ships with a light-on-transparent dark-scheme variant —
|
|
998
|
+
* otherwise it disappears against dark browser chrome.
|
|
999
|
+
*/
|
|
987
1000
|
const defaultFavicon = (): BlumeFavicon => ({
|
|
1001
|
+
dark: {
|
|
1002
|
+
href: inlineDataUri(
|
|
1003
|
+
join(BLUME_SRC, "assets", "icon-dark.png"),
|
|
1004
|
+
"image/png"
|
|
1005
|
+
),
|
|
1006
|
+
type: "image/png",
|
|
1007
|
+
},
|
|
988
1008
|
href: inlineDataUri(join(BLUME_SRC, "assets", "icon.png"), "image/png"),
|
|
989
1009
|
type: "image/png",
|
|
990
1010
|
});
|
|
@@ -1029,16 +1049,52 @@ const resolveIconFile = (
|
|
|
1029
1049
|
|
|
1030
1050
|
/**
|
|
1031
1051
|
* Resolve the site favicon by convention, falling back to the bundled Blume mark
|
|
1032
|
-
* when the project ships no `icon.*`/`favicon.*` file.
|
|
1052
|
+
* when the project ships no `icon.*`/`favicon.*` file. The dark-scheme variant
|
|
1053
|
+
* is anchored to the resolved icon: its `-dark` sibling (e.g. `icon.svg` →
|
|
1054
|
+
* `icon-dark.svg`) in the same directory — never an unrelated `-dark` file, so a
|
|
1055
|
+
* stale or foreign `favicon-dark.*` can't silently pair with a different mark.
|
|
1056
|
+
* A `-dark` file with no light sibling is the site's only mark and is used for
|
|
1057
|
+
* both schemes, mirroring how `resolveLogo` treats a single-variant image.
|
|
1033
1058
|
*/
|
|
1034
|
-
const resolveFavicon = (project: BlumeProject): BlumeFavicon =>
|
|
1035
|
-
|
|
1059
|
+
const resolveFavicon = (project: BlumeProject): BlumeFavicon => {
|
|
1060
|
+
const { root } = project.context;
|
|
1061
|
+
for (const name of FAVICON_CANDIDATES) {
|
|
1062
|
+
if (existsSync(join(root, "public", name))) {
|
|
1063
|
+
const type = faviconType(name);
|
|
1064
|
+
const sibling = darkSibling(name);
|
|
1065
|
+
return existsSync(join(root, "public", sibling))
|
|
1066
|
+
? { dark: { href: `/${sibling}`, type }, href: `/${name}`, type }
|
|
1067
|
+
: { href: `/${name}`, type };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
for (const name of FAVICON_CANDIDATES) {
|
|
1071
|
+
const file = join(root, name);
|
|
1072
|
+
if (existsSync(file)) {
|
|
1073
|
+
const type = faviconType(name);
|
|
1074
|
+
const mime = type ?? "image/x-icon";
|
|
1075
|
+
const siblingFile = join(root, darkSibling(name));
|
|
1076
|
+
return existsSync(siblingFile)
|
|
1077
|
+
? {
|
|
1078
|
+
dark: { href: inlineDataUri(siblingFile, mime), type },
|
|
1079
|
+
href: inlineDataUri(file, mime),
|
|
1080
|
+
type,
|
|
1081
|
+
}
|
|
1082
|
+
: { href: inlineDataUri(file, mime), type };
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return (
|
|
1086
|
+
resolveIconFile(project, FAVICON_CANDIDATES.map(darkSibling)) ??
|
|
1087
|
+
defaultFavicon()
|
|
1088
|
+
);
|
|
1089
|
+
};
|
|
1036
1090
|
|
|
1037
1091
|
/**
|
|
1038
1092
|
* Resolve the Apple touch icon by convention, or null when the project ships
|
|
1039
1093
|
* none (unlike the favicon, there's no bundled default). Note: iOS ignores
|
|
1040
1094
|
* `data:`-URI apple-touch-icons, so a `public/` file (served by URL) is the
|
|
1041
1095
|
* reliable path; a root-level file is still inlined for symmetry with favicons.
|
|
1096
|
+
* Deliberately no `-dark` sibling detection here: iOS ignores `media` on
|
|
1097
|
+
* `apple-touch-icon` links, so a dark variant could never be served.
|
|
1042
1098
|
*/
|
|
1043
1099
|
const resolveAppleIcon = (project: BlumeProject): BlumeFavicon | null =>
|
|
1044
1100
|
resolveIconFile(project, APPLE_ICON_CANDIDATES);
|
|
@@ -1231,6 +1287,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1231
1287
|
palette: config.seo.og.palette,
|
|
1232
1288
|
site: resolveOgSite(config),
|
|
1233
1289
|
},
|
|
1290
|
+
openInChat: config.ai.openInChat,
|
|
1234
1291
|
repoUrl,
|
|
1235
1292
|
search: {
|
|
1236
1293
|
enabled: config.search.provider !== "none",
|
|
@@ -1255,7 +1312,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1255
1312
|
})),
|
|
1256
1313
|
// CSS variables for Astro's <Font> component; matches the astro.config
|
|
1257
1314
|
// `fonts:` entries derived from the same theme.fonts config.
|
|
1258
|
-
fontCssVars:
|
|
1315
|
+
fontCssVars: configuredFonts(config.theme.fonts),
|
|
1259
1316
|
navigation: withRepoUrl(graph.navigation),
|
|
1260
1317
|
// Per-locale navigation; the catch-all selects the active locale's tree.
|
|
1261
1318
|
navigationByLocale,
|
|
@@ -1394,6 +1451,76 @@ const writeMcpFiles = async (
|
|
|
1394
1451
|
]);
|
|
1395
1452
|
};
|
|
1396
1453
|
|
|
1454
|
+
/**
|
|
1455
|
+
* Decide whether to generate the playground's built-in CORS proxy endpoint.
|
|
1456
|
+
* Only the Blume renderer's playground with `proxy: true` needs it — a proxy
|
|
1457
|
+
* URL string points at an external service, and `false` sends requests
|
|
1458
|
+
* directly. Injected at `/_api-proxy` (rather than written under `pages/`)
|
|
1459
|
+
* because Astro treats `_`-prefixed page files as private; the endpoint's own
|
|
1460
|
+
* `prerender = false` export wins over the injection default.
|
|
1461
|
+
*/
|
|
1462
|
+
const planPlaygroundProxy = (config: ResolvedConfig, srcDir: string) => ({
|
|
1463
|
+
enabled:
|
|
1464
|
+
config.openapi.enabled &&
|
|
1465
|
+
config.openapi.renderer === "blume" &&
|
|
1466
|
+
config.openapi.playground.enabled &&
|
|
1467
|
+
config.openapi.playground.proxy === true,
|
|
1468
|
+
entrypoint: join(srcDir, "blume-openapi", "api-proxy.ts"),
|
|
1469
|
+
pattern: "/_api-proxy",
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* The origins the built-in proxy is allowed to reach: one per absolute
|
|
1474
|
+
* `servers[].url` across the parsed specs. This is the endpoint's whole trust
|
|
1475
|
+
* boundary — the client sends the target as a query parameter, and a reader's
|
|
1476
|
+
* custom base URL is not a documented server — so it is derived here, at build
|
|
1477
|
+
* time, from the same documents the operation pages render.
|
|
1478
|
+
*
|
|
1479
|
+
* Relative (`/v1`) and templated (`{env}.api.example.com`) server URLs carry no
|
|
1480
|
+
* origin to allow and are skipped; AsyncAPI documents declare `servers` as a
|
|
1481
|
+
* map and contribute nothing (the proxy is OpenAPI-only).
|
|
1482
|
+
*/
|
|
1483
|
+
const specOrigins = (data: OpenApiData): string[] => {
|
|
1484
|
+
const origins = new Set<string>();
|
|
1485
|
+
for (const spec of Object.values(data)) {
|
|
1486
|
+
// SAFETY: `document` is arbitrary parsed JSON; the assertion only names
|
|
1487
|
+
// the optional `servers` shape, and every access below re-checks it —
|
|
1488
|
+
// `Array.isArray(servers)` guards the list and `server.url ?? ""` the url.
|
|
1489
|
+
const { servers } = spec.document as { servers?: { url?: string }[] };
|
|
1490
|
+
for (const server of Array.isArray(servers) ? servers : []) {
|
|
1491
|
+
const url = server.url ?? "";
|
|
1492
|
+
// `new URL("https://{region}.api.example.com")` parses — the braces land
|
|
1493
|
+
// in the hostname — so templated URLs need an explicit check or their
|
|
1494
|
+
// junk literal becomes an allowlist entry no real request can match.
|
|
1495
|
+
if (url.includes("{")) {
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
try {
|
|
1499
|
+
origins.add(new URL(url).origin);
|
|
1500
|
+
} catch {
|
|
1501
|
+
// Not an absolute URL: nothing to allow.
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return [...origins].toSorted();
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* The build-time diagnostic for a proxy whose allowlist came out empty. The
|
|
1510
|
+
* allowlist comes solely from absolute `servers[].url` entries — relative and
|
|
1511
|
+
* templated ones carry no origin — and with none at all the endpoint would
|
|
1512
|
+
* 403 every playground send with nothing pointing the author at the spec.
|
|
1513
|
+
*/
|
|
1514
|
+
const proxyAllowlistWarnings = (
|
|
1515
|
+
enabled: boolean,
|
|
1516
|
+
origins: string[]
|
|
1517
|
+
): string[] =>
|
|
1518
|
+
enabled && origins.length === 0
|
|
1519
|
+
? [
|
|
1520
|
+
"openapi.playground.proxy is enabled, but no spec declares an absolute servers[].url (relative and templated URLs carry no origin), so the proxy's allowlist is empty and it will refuse every request. Add an absolute server URL to the spec, or point playground.proxy at an external proxy URL.",
|
|
1521
|
+
]
|
|
1522
|
+
: [];
|
|
1523
|
+
|
|
1397
1524
|
/**
|
|
1398
1525
|
* Write the Ask AI endpoint and, unless the backend runs its own retrieval
|
|
1399
1526
|
* (Inkeep), the grounding snapshot the endpoint queries at request time. A no-op
|
|
@@ -1417,7 +1544,10 @@ const writeAskFiles = async (
|
|
|
1417
1544
|
}
|
|
1418
1545
|
await write(
|
|
1419
1546
|
join(srcDir, "pages", "api", "ask.ts"),
|
|
1420
|
-
askEndpointTemplate(resolveAskBackend(ask), grounded,
|
|
1547
|
+
askEndpointTemplate(resolveAskBackend(ask), grounded, {
|
|
1548
|
+
instructions: ask.instructions,
|
|
1549
|
+
retrieval: ask.retrieval,
|
|
1550
|
+
})
|
|
1421
1551
|
);
|
|
1422
1552
|
};
|
|
1423
1553
|
|
|
@@ -1624,12 +1754,36 @@ export const generateRuntime = async (
|
|
|
1624
1754
|
// private and filtered out anyway, but the intent is the user's pages.
|
|
1625
1755
|
const ogRoutes = customOgRoutes(pages, config.title, config.seo.og.titles);
|
|
1626
1756
|
|
|
1757
|
+
// Whether the generated `/changelog` index exists — shared by the OG endpoint
|
|
1758
|
+
// (which adds the index's own card) and the page write below. Computed here,
|
|
1759
|
+
// before the MCP discovery pages are appended, on the user's own pages.
|
|
1760
|
+
const changelogIndex = hasGeneratedChangelog(project, pages);
|
|
1761
|
+
|
|
1627
1762
|
// The hosted MCP server. The `.well-known` discovery docs are injected as
|
|
1628
1763
|
// prerendered routes alongside user pages; the server endpoint itself is a
|
|
1629
1764
|
// normal (server-rendered) page written by `writeMcpFiles`.
|
|
1630
1765
|
const mcp = planMcp(project, srcDir, pages);
|
|
1631
1766
|
pages.push(...mcp.discoveryPages);
|
|
1632
1767
|
|
|
1768
|
+
// The parsed OpenAPI specs behind the `blume:openapi` alias, also the source
|
|
1769
|
+
// of the proxy's origin allowlist below. The source parsed them during the
|
|
1770
|
+
// scan, so reading them here is free.
|
|
1771
|
+
const openApiSource = project.sources.find(isOpenApiSource);
|
|
1772
|
+
const openApiData = openApiSource ? openApiSource.openApiData() : {};
|
|
1773
|
+
|
|
1774
|
+
// The playground's built-in CORS proxy rides the same injection path as the
|
|
1775
|
+
// MCP discovery docs; the endpoint itself opts out of prerendering.
|
|
1776
|
+
const playgroundProxy = planPlaygroundProxy(config, srcDir);
|
|
1777
|
+
if (playgroundProxy.enabled) {
|
|
1778
|
+
pages.push({
|
|
1779
|
+
entrypoint: playgroundProxy.entrypoint,
|
|
1780
|
+
pattern: playgroundProxy.pattern,
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
// Computed once: the endpoint template bakes it in below, and an empty list
|
|
1784
|
+
// is worth a diagnostic — the proxy would refuse every request it gets.
|
|
1785
|
+
const proxyOrigins = specOrigins(openApiData);
|
|
1786
|
+
|
|
1633
1787
|
const hasStaged = staged.size > 0;
|
|
1634
1788
|
// Only emit a project-scanning `docs` collection when a filesystem source
|
|
1635
1789
|
// actually feeds it. An all-staged project (openapi/notion/…) has only staged
|
|
@@ -1775,17 +1929,20 @@ export const generateRuntime = async (
|
|
|
1775
1929
|
),
|
|
1776
1930
|
writeAskFiles(project, srcDir, write),
|
|
1777
1931
|
writeMcpFiles(project, mcp, write),
|
|
1932
|
+
playgroundProxy.enabled
|
|
1933
|
+
? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
|
|
1934
|
+
: Promise.resolve(false),
|
|
1778
1935
|
]);
|
|
1779
1936
|
|
|
1780
1937
|
if (config.seo.og.enabled) {
|
|
1781
1938
|
await write(
|
|
1782
1939
|
join(srcDir, "pages", "og", "[...slug].png.ts"),
|
|
1783
|
-
ogEndpointTemplate(ogRoutes, projectOgFonts(project))
|
|
1940
|
+
ogEndpointTemplate(ogRoutes, projectOgFonts(project), changelogIndex)
|
|
1784
1941
|
);
|
|
1785
1942
|
}
|
|
1786
1943
|
|
|
1787
1944
|
// Changelog index (`/changelog`), rendered through the Update timeline layout.
|
|
1788
|
-
if (
|
|
1945
|
+
if (changelogIndex) {
|
|
1789
1946
|
await write(
|
|
1790
1947
|
join(srcDir, "pages", "changelog.astro"),
|
|
1791
1948
|
changelogIndexTemplate({
|
|
@@ -1890,6 +2047,7 @@ export const generateRuntime = async (
|
|
|
1890
2047
|
// regenerated each run.
|
|
1891
2048
|
const warnings: string[] = [
|
|
1892
2049
|
...(depsLinkWarning ? [depsLinkWarning] : []),
|
|
2050
|
+
...proxyAllowlistWarnings(playgroundProxy.enabled, proxyOrigins),
|
|
1893
2051
|
...reactCompilerWarnings(config, needsReact, reactCompilerPath),
|
|
1894
2052
|
...mcp.warnings,
|
|
1895
2053
|
...islandDiscovery.warnings,
|
|
@@ -1906,7 +2064,7 @@ export const generateRuntime = async (
|
|
|
1906
2064
|
...pages.map((page) => page.pattern),
|
|
1907
2065
|
...referenceRoutes(config),
|
|
1908
2066
|
]);
|
|
1909
|
-
if (
|
|
2067
|
+
if (changelogIndex) {
|
|
1910
2068
|
navTargetRoutes.add("/changelog");
|
|
1911
2069
|
}
|
|
1912
2070
|
// Curated `search.popular` icons live outside the navigation model, so they
|
|
@@ -1956,20 +2114,16 @@ export const generateRuntime = async (
|
|
|
1956
2114
|
);
|
|
1957
2115
|
}
|
|
1958
2116
|
|
|
1959
|
-
//
|
|
1960
|
-
//
|
|
1961
|
-
//
|
|
1962
|
-
const openApiSource = project.sources.find(isOpenApiSource);
|
|
2117
|
+
// `openapi.json` (the `blume:openapi` alias) is always written — even as `{}`
|
|
2118
|
+
// — so the alias resolves whether or not a reference is enabled; the specs
|
|
2119
|
+
// were parsed during the scan, so this is just serialization.
|
|
1963
2120
|
// These write to distinct trees and never read one another, so they batch.
|
|
1964
2121
|
// `data.json`/`openapi.json` and the manifest are not "structural" for Astro;
|
|
1965
2122
|
// they hot-reload. `writeStagedContent` owns the `.blume/content` tree (its
|
|
1966
2123
|
// own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
|
|
1967
2124
|
await Promise.all([
|
|
1968
2125
|
write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
|
|
1969
|
-
write(
|
|
1970
|
-
openapiPath,
|
|
1971
|
-
`${JSON.stringify(openApiSource ? openApiSource.openApiData() : {})}\n`
|
|
1972
|
-
),
|
|
2126
|
+
write(openapiPath, `${JSON.stringify(openApiData)}\n`),
|
|
1973
2127
|
write(
|
|
1974
2128
|
join(out, "blume.manifest.json"),
|
|
1975
2129
|
`${JSON.stringify(project.manifest, null, 2)}\n`
|