blume 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +618 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1487 -360
- package/dist/cli/index.js.map +91 -85
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +36 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +476 -132
- package/dist/types/core/schema.d.ts +216 -145
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +8 -8
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +2 -2
- package/docs/content/components.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +4 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +8 -6
- package/package.json +14 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +9 -8
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +135 -26
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +25 -4
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +21 -7
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +76 -45
- package/src/astro/integration.ts +1 -1
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +81 -19
- package/src/astro/templates.ts +99 -12
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +99 -19
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +26 -5
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +43 -2
- package/src/cli/dev-lock.ts +8 -4
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +7 -1
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/mermaid-element.ts +7 -2
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +56 -6
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +17 -3
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +45 -8
- package/src/components/layout/ReferenceLayout.astro +8 -1
- package/src/components/layout/RootLayout.astro +67 -9
- package/src/components/layout/Search.astro +94 -22
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +1 -1
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +1 -1
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/core/base-path.ts +24 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/config-input.ts +37 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +2 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +45 -0
- package/src/core/last-modified.ts +13 -6
- package/src/core/links.ts +32 -8
- package/src/core/navigation.ts +29 -4
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +15 -6
- package/src/core/schema.ts +36 -2
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/mdx-remote.ts +23 -14
- package/src/core/sources/normalize.ts +152 -50
- package/src/core/sources/notion.ts +8 -8
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +1 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +1 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +1 -0
- package/src/core/ui-packs/el.ts +1 -0
- package/src/core/ui-packs/es.ts +1 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -1
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +1 -0
- package/src/core/ui-packs/hu.ts +1 -0
- package/src/core/ui-packs/id.ts +1 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +1 -0
- package/src/core/ui-packs/ko.ts +1 -0
- package/src/core/ui-packs/nl.ts +1 -0
- package/src/core/ui-packs/no.ts +1 -0
- package/src/core/ui-packs/pl.ts +1 -0
- package/src/core/ui-packs/pt-br.ts +1 -0
- package/src/core/ui-packs/pt.ts +1 -0
- package/src/core/ui-packs/ro.ts +1 -0
- package/src/core/ui-packs/ru.ts +1 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +1 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +1 -0
- package/src/core/ui-packs/uk.ts +1 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +7 -2
- package/src/deploy/sitemap.ts +53 -11
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +10 -8
- package/src/markdown/index.ts +15 -3
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/package-commands.ts +10 -4
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +184 -12
- package/src/registry/registry.ts +0 -3
- package/src/search/documents.ts +34 -2
- package/src/seo/jsonld.ts +13 -12
|
@@ -17,12 +17,15 @@ type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
|
|
|
17
17
|
*
|
|
18
18
|
* `vercel` writes a Build Output API v3 tree at `.vercel/output`; only that
|
|
19
19
|
* subtree is moved, so a `vercel pull`-ed `.vercel/project.json` sitting at the
|
|
20
|
-
* project root survives the relocation. `netlify`
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* project root survives the relocation. `netlify` writes a Frameworks API tree
|
|
21
|
+
* at `.netlify/v1` (its `.netlify/build` sibling is only the intermediate SSR
|
|
22
|
+
* bundle, already traced into `v1/functions`); only `v1` is moved, so the
|
|
23
|
+
* `.netlify/state.json` written by `netlify link` survives too. `node` and
|
|
24
|
+
* `cloudflare` emit into `dist/` (already at the project root), so they are
|
|
25
|
+
* absent here and need no relocation.
|
|
23
26
|
*/
|
|
24
27
|
export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
|
|
25
|
-
netlify: ".netlify",
|
|
28
|
+
netlify: ".netlify/v1",
|
|
26
29
|
vercel: ".vercel/output",
|
|
27
30
|
};
|
|
28
31
|
|
|
@@ -30,7 +33,10 @@ export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
|
|
|
30
33
|
* Directory whose contents the deploy platform serves as static files. Build
|
|
31
34
|
* artifacts (robots.txt, sitemap.xml, llms.txt, …) must be written here to be
|
|
32
35
|
* served. For a Vercel server build that is the adapter's
|
|
33
|
-
* `.vercel/output/static`;
|
|
36
|
+
* `.vercel/output/static`; for a Node server build it is Astro's
|
|
37
|
+
* `build.client` dir (`dist/client/`), the only directory the standalone
|
|
38
|
+
* server's static handler reads from. Netlify publishes `dist/` itself and
|
|
39
|
+
* Cloudflare serves the `outDir` root, so every other build serves `dist/`.
|
|
34
40
|
*/
|
|
35
41
|
export const deployStaticDir = (
|
|
36
42
|
config: ResolvedConfig,
|
|
@@ -40,7 +46,11 @@ export const deployStaticDir = (
|
|
|
40
46
|
if (output === "server" && adapter === "vercel") {
|
|
41
47
|
return join(context.root, ".vercel", "output", "static");
|
|
42
48
|
}
|
|
43
|
-
|
|
49
|
+
const dist = context.distDir ?? join(context.root, "dist");
|
|
50
|
+
if (output === "server" && adapter === "node") {
|
|
51
|
+
return join(dist, "client");
|
|
52
|
+
}
|
|
53
|
+
return dist;
|
|
44
54
|
};
|
|
45
55
|
|
|
46
56
|
/** Outcome of {@link surfaceAdapterOutput}, for logging and `.gitignore`. */
|
|
@@ -76,7 +86,7 @@ export const surfaceAdapterOutput = async (
|
|
|
76
86
|
await cp(from, to, { recursive: true });
|
|
77
87
|
await rm(from, { force: true, recursive: true });
|
|
78
88
|
// The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
|
|
79
|
-
// never the moved sub-path —
|
|
80
|
-
// too and must also be ignored.
|
|
89
|
+
// never the moved sub-path — the platform's own state (`.vercel/project.json`,
|
|
90
|
+
// `.netlify/state.json`) lives there too and must also be ignored.
|
|
81
91
|
return { from, ignore: `${rel.split("/")[0]}/`, moved: true, to };
|
|
82
92
|
};
|
package/src/deploy/redirects.ts
CHANGED
|
@@ -34,14 +34,19 @@ export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
|
|
|
34
34
|
.map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
|
|
35
35
|
.join("\n")}\n`;
|
|
36
36
|
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* `vercel.json` contents with a `redirects` array. Uses `statusCode` (Vercel's
|
|
39
|
+
* alternative to the boolean `permanent`) so the configured code ships exactly:
|
|
40
|
+
* `permanent` would silently coerce a 301 to 308 and a 302 to 307, diverging
|
|
41
|
+
* from the `_redirects` file, which preserves exact codes.
|
|
42
|
+
*/
|
|
38
43
|
export const buildVercelConfig = (redirects: Redirect[]): string =>
|
|
39
44
|
`${JSON.stringify(
|
|
40
45
|
{
|
|
41
46
|
redirects: redirects.map((redirect) => ({
|
|
42
47
|
destination: redirect.to,
|
|
43
|
-
permanent: redirect.status === 301 || redirect.status === 308,
|
|
44
48
|
source: redirect.from,
|
|
49
|
+
statusCode: redirect.status,
|
|
45
50
|
})),
|
|
46
51
|
},
|
|
47
52
|
null,
|
package/src/deploy/sitemap.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
customStaticRoutes,
|
|
3
|
+
discoverPagesSync,
|
|
4
|
+
hasGeneratedChangelog,
|
|
5
|
+
} from "../astro/pages.ts";
|
|
1
6
|
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
2
7
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
3
8
|
import { escapeXml } from "./xml.ts";
|
|
4
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Astro's reserved error routes. A user-authored override (`pages/404.astro`,
|
|
12
|
+
* `pages/500.astro`, or a `404.md` content page — see `writeNotFoundPage` in
|
|
13
|
+
* `astro/generate.ts`) is neither dynamic nor private, so it would otherwise
|
|
14
|
+
* be emitted — but error pages aren't crawlable destinations and must stay out
|
|
15
|
+
* of the sitemap.
|
|
16
|
+
*/
|
|
17
|
+
const ERROR_ROUTES = new Set(["/404", "/500"]);
|
|
18
|
+
|
|
5
19
|
/** A `<lastmod>` element (W3C date) when the page has a valid modified date. */
|
|
6
20
|
const lastmodTag = (value: string | undefined): string => {
|
|
7
21
|
if (!value) {
|
|
@@ -14,9 +28,11 @@ const lastmodTag = (value: string | undefined): string => {
|
|
|
14
28
|
};
|
|
15
29
|
|
|
16
30
|
/**
|
|
17
|
-
* Build a sitemap.xml from the route manifest
|
|
18
|
-
*
|
|
19
|
-
*
|
|
31
|
+
* Build a sitemap.xml from the route manifest plus the routes the manifest
|
|
32
|
+
* can't see: custom `.astro` pages (most importantly a custom landing `/`) and
|
|
33
|
+
* the generated `/changelog` index. Returns null when the sitemap is disabled
|
|
34
|
+
* or no `site` is configured (absolute URLs are required for a valid sitemap).
|
|
35
|
+
* Drafts, hidden, and `noindex` pages are excluded.
|
|
20
36
|
*/
|
|
21
37
|
export const buildSitemap = (project: BlumeProject): string | null => {
|
|
22
38
|
const { site } = project.config.deployment;
|
|
@@ -27,19 +43,45 @@ export const buildSitemap = (project: BlumeProject): string | null => {
|
|
|
27
43
|
const base = site.replace(/\/$/u, "");
|
|
28
44
|
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
|
|
29
45
|
const deployBase = normalizeBasePath(project.config.deployment.base);
|
|
46
|
+
const seen = new Set<string>();
|
|
30
47
|
const urls: string[] = [];
|
|
31
|
-
|
|
32
|
-
if (page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex) {
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
48
|
+
const pushUrl = (route: string, lastModified?: string): void => {
|
|
35
49
|
// `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
|
|
36
50
|
// then escape XML metacharacters (notably `&`) so a route like
|
|
37
51
|
// `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
|
|
38
52
|
// rejected.
|
|
39
|
-
const loc = escapeXml(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
const loc = escapeXml(encodeURI(`${base}${route}`));
|
|
54
|
+
if (seen.has(loc)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
seen.add(loc);
|
|
58
|
+
urls.push(` <url><loc>${loc}</loc>${lastmodTag(lastModified)}</url>`);
|
|
59
|
+
};
|
|
60
|
+
for (const page of project.graph.pages) {
|
|
61
|
+
if (
|
|
62
|
+
page.meta.draft ||
|
|
63
|
+
page.meta.sidebar.hidden ||
|
|
64
|
+
page.meta.seo.noindex ||
|
|
65
|
+
ERROR_ROUTES.has(page.route)
|
|
66
|
+
) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
pushUrl(withBasePath(deployBase, page.route), page.lastModified);
|
|
70
|
+
}
|
|
71
|
+
// Custom `.astro` pages and the generated changelog index mount outside
|
|
72
|
+
// `basePath` (they're injected at their pattern — see `blumeIntegration`), so
|
|
73
|
+
// only the deployment base layers onto their URLs.
|
|
74
|
+
const userPages = project.context.pagesRoot
|
|
75
|
+
? discoverPagesSync(project.context.pagesRoot)
|
|
76
|
+
: [];
|
|
77
|
+
const extraRoutes = customStaticRoutes(userPages).filter(
|
|
78
|
+
(route) => !ERROR_ROUTES.has(route)
|
|
79
|
+
);
|
|
80
|
+
if (hasGeneratedChangelog(project, userPages)) {
|
|
81
|
+
extraRoutes.push("/changelog");
|
|
82
|
+
}
|
|
83
|
+
for (const route of extraRoutes) {
|
|
84
|
+
pushUrl(withBasePath(deployBase, route));
|
|
43
85
|
}
|
|
44
86
|
urls.sort();
|
|
45
87
|
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isInternalPath,
|
|
1
|
+
import { isInternalPath, withComposedBasePath } from "../core/base-path.ts";
|
|
2
2
|
import type { MdastNode } from "./mdast.ts";
|
|
3
3
|
|
|
4
4
|
interface UrlNode extends MdastNode {
|
|
@@ -27,13 +27,15 @@ const ASSET_PATH = /\.[a-z0-9]+$/iu;
|
|
|
27
27
|
const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Satteri MDAST plugin that prepends the
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* Satteri MDAST plugin that prepends the served-URL base — `deployment.base`
|
|
31
|
+
* layered over the site-wide `basePath` — to root-relative internal page links
|
|
32
|
+
* (`[x](/guide)` -> `/base/docs/guide`), so authors write links as if mounted
|
|
33
|
+
* at root. Idempotent per layer (via `withComposedBasePath`, so a hand-written
|
|
34
|
+
* `/docs/x` isn't double-prefixed) and inert for external URLs, fragments,
|
|
35
|
+
* relative paths, images, and asset links. Only constructed when a base is set
|
|
36
|
+
* (see `markdown/index.ts`).
|
|
35
37
|
*/
|
|
36
|
-
export const baseLinksPlugin = (basePath: string) => {
|
|
38
|
+
export const baseLinksPlugin = (deployBase: string, basePath: string) => {
|
|
37
39
|
const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
|
|
38
40
|
const { url } = node;
|
|
39
41
|
if (
|
|
@@ -41,7 +43,7 @@ export const baseLinksPlugin = (basePath: string) => {
|
|
|
41
43
|
isInternalPath(url) &&
|
|
42
44
|
!ASSET_PATH.test(pathOf(url))
|
|
43
45
|
) {
|
|
44
|
-
const next =
|
|
46
|
+
const next = withComposedBasePath(deployBase, basePath, url);
|
|
45
47
|
if (next !== url) {
|
|
46
48
|
ctx.setProperty(node, "url", next);
|
|
47
49
|
}
|
package/src/markdown/index.ts
CHANGED
|
@@ -217,17 +217,29 @@ export interface BlumeMarkdownOptions {
|
|
|
217
217
|
* links as if mounted at root.
|
|
218
218
|
*/
|
|
219
219
|
basePath?: string;
|
|
220
|
+
/**
|
|
221
|
+
* Astro's `deployment.base` subdirectory (`""` or `/seg`), layered on top of
|
|
222
|
+
* `basePath` when links are rewritten. Kept separate so a hand-written
|
|
223
|
+
* `basePath` link isn't double-prefixed (see `withComposedBasePath`).
|
|
224
|
+
*/
|
|
225
|
+
deployBase?: string;
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
/**
|
|
223
229
|
* MDAST plugins that apply to both `.md` and `.mdx`. Currently just the
|
|
224
|
-
* base-path link rewrite, added only when a `basePath` is
|
|
230
|
+
* base-path link rewrite, added only when a `basePath` or `deployBase` is
|
|
231
|
+
* configured.
|
|
225
232
|
*/
|
|
226
233
|
const blumeSharedMdastPlugins = (
|
|
227
234
|
options: BlumeMarkdownOptions
|
|
228
235
|
): MdastPlugin[] =>
|
|
229
|
-
options.basePath
|
|
230
|
-
? [
|
|
236
|
+
options.basePath || options.deployBase
|
|
237
|
+
? [
|
|
238
|
+
baseLinksPlugin(
|
|
239
|
+
options.deployBase ?? "",
|
|
240
|
+
options.basePath ?? ""
|
|
241
|
+
) as unknown as MdastPlugin,
|
|
242
|
+
]
|
|
231
243
|
: [];
|
|
232
244
|
|
|
233
245
|
/** Sätteri processor for plain `.md`, with Blume's curated feature set. */
|
|
@@ -104,8 +104,13 @@ export const inlineCodeHighlightPlugin = (
|
|
|
104
104
|
type: "element",
|
|
105
105
|
};
|
|
106
106
|
} catch {
|
|
107
|
-
// Unknown language or load failure:
|
|
108
|
-
//
|
|
107
|
+
// Unknown language or load failure: still strip the marker — the
|
|
108
|
+
// literal `{:lang}` must not ship in the page — and fall back to
|
|
109
|
+
// plain, unhighlighted inline code.
|
|
110
|
+
return {
|
|
111
|
+
...node,
|
|
112
|
+
children: [{ type: "text", value: parsed.code }],
|
|
113
|
+
};
|
|
109
114
|
}
|
|
110
115
|
},
|
|
111
116
|
},
|
|
@@ -149,10 +149,16 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
|
|
|
149
149
|
return `${manager} dlx ${args}`;
|
|
150
150
|
}
|
|
151
151
|
case "ci": {
|
|
152
|
-
// `npm ci` maps to a frozen, lockfile-faithful install elsewhere.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
// `npm ci` maps to a frozen, lockfile-faithful install elsewhere. Yarn
|
|
153
|
+
// Berry's flag is `--immutable` (`--frozen-lockfile` was removed in
|
|
154
|
+
// Yarn 4), matching the Berry-only `yarn dlx` the `exec` case emits.
|
|
155
|
+
if (manager === "npm") {
|
|
156
|
+
return "npm ci";
|
|
157
|
+
}
|
|
158
|
+
if (manager === "yarn") {
|
|
159
|
+
return "yarn install --immutable";
|
|
160
|
+
}
|
|
161
|
+
return `${manager} install --frozen-lockfile`;
|
|
156
162
|
}
|
|
157
163
|
case "remove": {
|
|
158
164
|
if (manager === "npm") {
|
package/src/openapi/model.ts
CHANGED
|
@@ -104,12 +104,13 @@ const isOperation = (value: unknown): value is OperationObject =>
|
|
|
104
104
|
/**
|
|
105
105
|
* Flatten a 3.1 document into a route-mapped operation list and its ordered
|
|
106
106
|
* tags. Operations inherit the first tag they declare; keys are de-duplicated so
|
|
107
|
-
* a repeated `operationId` still yields distinct routes.
|
|
107
|
+
* a repeated `operationId` still yields distinct routes. `warnings` reports
|
|
108
|
+
* anything skipped (a `$ref` path item), so missing operations aren't silent.
|
|
108
109
|
*/
|
|
109
110
|
export const extractOperations = (
|
|
110
111
|
document: ApiDocument,
|
|
111
112
|
baseRoute: string
|
|
112
|
-
): { operations: ApiOperationRef[]; tags: ApiTagRef[] } => {
|
|
113
|
+
): { operations: ApiOperationRef[]; tags: ApiTagRef[]; warnings: string[] } => {
|
|
113
114
|
const operations: ApiOperationRef[] = [];
|
|
114
115
|
const tagOrder: string[] = [];
|
|
115
116
|
const tagsSeen = new Set<string>();
|
|
@@ -117,10 +118,17 @@ export const extractOperations = (
|
|
|
117
118
|
(document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
|
|
118
119
|
);
|
|
119
120
|
const seen = new Set<string>();
|
|
121
|
+
const warnings: string[] = [];
|
|
120
122
|
|
|
121
123
|
for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
|
|
122
124
|
const item = rawItem as PathItemObject | undefined;
|
|
123
|
-
if (!item
|
|
125
|
+
if (!item) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if ("$ref" in item) {
|
|
129
|
+
warnings.push(
|
|
130
|
+
`Path "${path}" is a $ref to a shared path item; referenced path items are not resolved, so its operations are missing from the reference. Inline the path item under "paths" to render it.`
|
|
131
|
+
);
|
|
124
132
|
continue;
|
|
125
133
|
}
|
|
126
134
|
for (const method of HTTP_METHODS) {
|
|
@@ -161,7 +169,7 @@ export const extractOperations = (
|
|
|
161
169
|
slug: slugify(name) || "operations",
|
|
162
170
|
}));
|
|
163
171
|
|
|
164
|
-
return { operations, tags };
|
|
172
|
+
return { operations, tags, warnings };
|
|
165
173
|
};
|
|
166
174
|
|
|
167
175
|
/** Resolve the operation object for a ref out of its document. */
|
package/src/openapi/parse.ts
CHANGED
|
@@ -47,6 +47,19 @@ export interface ParsedSpec {
|
|
|
47
47
|
warnings: string[];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The spec was read successfully but its contents aren't an OpenAPI document
|
|
52
|
+
* (an empty file, or YAML that parses to a scalar or list — say, a README
|
|
53
|
+
* pointed at by mistake). Kept distinct from read/fetch failures so callers
|
|
54
|
+
* can suggest fixing the file instead of checking reachability.
|
|
55
|
+
*/
|
|
56
|
+
export class InvalidSpecError extends Error {
|
|
57
|
+
constructor(message: string) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "InvalidSpecError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
/** Where and whether to cache a remote spec's text between runs. */
|
|
51
64
|
export interface SpecFetchOptions {
|
|
52
65
|
/** Dir for a last-good on-disk copy of a remote spec (offline fallback). */
|
|
@@ -235,5 +248,13 @@ export const parseSpec = async (
|
|
|
235
248
|
const { text, warnings } = await readSpecText(spec, root, options);
|
|
236
249
|
const normalized = normalize(text);
|
|
237
250
|
const { specification } = upgrade(normalized);
|
|
251
|
+
// `normalize` yields undefined for anything that isn't a YAML/JSON mapping
|
|
252
|
+
// (empty file, scalar, list) and `upgrade(undefined)` yields a null
|
|
253
|
+
// specification — reject it here so the renderer never sees a non-document.
|
|
254
|
+
if (specification === null || typeof specification !== "object") {
|
|
255
|
+
throw new InvalidSpecError(
|
|
256
|
+
`${spec} is not a valid OpenAPI document (expected a YAML or JSON object).`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
238
259
|
return { document: specification as ApiDocument, warnings };
|
|
239
260
|
};
|
|
@@ -31,6 +31,12 @@ export interface ReferenceSource {
|
|
|
31
31
|
slug: string;
|
|
32
32
|
/** Normalized route the reference mounts at, e.g. `/reference`. */
|
|
33
33
|
route: string;
|
|
34
|
+
/**
|
|
35
|
+
* Site-wide `basePath` the rendered pages are mounted under (`""` when
|
|
36
|
+
* none). Kept separate from `route` — the content pipeline applies it to
|
|
37
|
+
* staged entries itself — so consumers prefix only the URLs they emit.
|
|
38
|
+
*/
|
|
39
|
+
basePath: string;
|
|
34
40
|
label: string;
|
|
35
41
|
/** Local path or `http(s)` URL, verbatim from config. */
|
|
36
42
|
spec: string;
|
|
@@ -38,6 +44,11 @@ export interface ReferenceSource {
|
|
|
38
44
|
theme?: string;
|
|
39
45
|
/** Display options carried through to the Blume renderer. */
|
|
40
46
|
display: ReferenceDisplay;
|
|
47
|
+
/**
|
|
48
|
+
* Warnings recorded while deduping — another source's route collided with
|
|
49
|
+
* this one and was dropped. Surfaced as diagnostics when the source loads.
|
|
50
|
+
*/
|
|
51
|
+
collisions?: string[];
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
@@ -78,7 +89,8 @@ const referencesFor = (
|
|
|
78
89
|
block: Block,
|
|
79
90
|
defaultLabel: string,
|
|
80
91
|
renderer: ReferenceRenderer,
|
|
81
|
-
display: ReferenceDisplay
|
|
92
|
+
display: ReferenceDisplay,
|
|
93
|
+
basePath: string
|
|
82
94
|
): ReferenceSource[] => {
|
|
83
95
|
if (!block.enabled) {
|
|
84
96
|
return [];
|
|
@@ -102,6 +114,7 @@ const referencesFor = (
|
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
return {
|
|
117
|
+
basePath,
|
|
105
118
|
display,
|
|
106
119
|
kind,
|
|
107
120
|
label,
|
|
@@ -131,9 +144,17 @@ export const resolveReferences = (
|
|
|
131
144
|
{
|
|
132
145
|
codeSamples: config.openapi.codeSamples,
|
|
133
146
|
expandSchemas: config.openapi.expandSchemas,
|
|
134
|
-
}
|
|
147
|
+
},
|
|
148
|
+
config.basePath
|
|
149
|
+
),
|
|
150
|
+
...referencesFor(
|
|
151
|
+
"asyncapi",
|
|
152
|
+
config.asyncapi,
|
|
153
|
+
"Events",
|
|
154
|
+
"scalar",
|
|
155
|
+
NO_DISPLAY,
|
|
156
|
+
config.basePath
|
|
135
157
|
),
|
|
136
|
-
...referencesFor("asyncapi", config.asyncapi, "Events", "scalar", NO_DISPLAY),
|
|
137
158
|
];
|
|
138
159
|
|
|
139
160
|
/** Nav tabs (header links) for every reference, regardless of renderer. */
|
|
@@ -152,19 +173,24 @@ export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
|
|
|
152
173
|
/**
|
|
153
174
|
* Accept one resolved reference into the deduped Blume-rendered set, or return
|
|
154
175
|
* null to skip it. Mutates `seen`/`usedSlugs` so repeated routes/slugs collapse.
|
|
176
|
+
* A dropped route collision is recorded on the kept reference (mirroring the
|
|
177
|
+
* Scalar path's warning) — losing a whole spec's pages must not be silent.
|
|
155
178
|
*/
|
|
156
179
|
const blumeReferenceOf = (
|
|
157
180
|
ref: ReferenceSource,
|
|
158
|
-
seen:
|
|
181
|
+
seen: Map<string, ReferenceSource>,
|
|
159
182
|
usedSlugs: Set<string>
|
|
160
183
|
): ReferenceSource | null => {
|
|
161
184
|
if (ref.kind !== "openapi" || ref.renderer !== "blume") {
|
|
162
185
|
return null;
|
|
163
186
|
}
|
|
164
|
-
|
|
187
|
+
const kept = seen.get(ref.route);
|
|
188
|
+
if (kept) {
|
|
189
|
+
(kept.collisions ??= []).push(
|
|
190
|
+
`Two API reference sources resolve to ${ref.route}; keeping the first.`
|
|
191
|
+
);
|
|
165
192
|
return null;
|
|
166
193
|
}
|
|
167
|
-
seen.add(ref.route);
|
|
168
194
|
// Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
|
|
169
195
|
// yield `api-v1`). The slug keys the `blume:openapi` data module, so a
|
|
170
196
|
// collision would let one spec silently overwrite the other while the
|
|
@@ -176,12 +202,16 @@ const blumeReferenceOf = (
|
|
|
176
202
|
n += 1;
|
|
177
203
|
}
|
|
178
204
|
usedSlugs.add(slug);
|
|
179
|
-
|
|
205
|
+
const accepted = slug === ref.slug ? ref : { ...ref, slug };
|
|
206
|
+
// Keep the accepted object (not the original) so a later collision's warning
|
|
207
|
+
// lands on the reference the caller actually receives.
|
|
208
|
+
seen.set(ref.route, accepted);
|
|
209
|
+
return accepted;
|
|
180
210
|
};
|
|
181
211
|
|
|
182
212
|
/** Blume-rendered OpenAPI references, deduped by route (first wins). */
|
|
183
213
|
export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
184
|
-
const seen = new
|
|
214
|
+
const seen = new Map<string, ReferenceSource>();
|
|
185
215
|
const usedSlugs = new Set<string>();
|
|
186
216
|
const result: ReferenceSource[] = [];
|
|
187
217
|
for (const ref of resolveReferences(config)) {
|
package/src/openapi/source.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withBasePath } from "../core/base-path.ts";
|
|
1
2
|
import matter from "../core/frontmatter.ts";
|
|
2
3
|
import { hashText } from "../core/sources/cache.ts";
|
|
3
4
|
import type {
|
|
@@ -9,7 +10,7 @@ import type {
|
|
|
9
10
|
import type { Diagnostic } from "../core/types.ts";
|
|
10
11
|
import { extractOperations } from "./model.ts";
|
|
11
12
|
import type { ApiOperationRef, ApiSpecData, OpenApiData } from "./model.ts";
|
|
12
|
-
import { parseSpec } from "./parse.ts";
|
|
13
|
+
import { InvalidSpecError, parseSpec } from "./parse.ts";
|
|
13
14
|
import type { ReferenceSource } from "./references.ts";
|
|
14
15
|
import { operationMdx, overviewMdx } from "./render-mdx.ts";
|
|
15
16
|
import type { RenderedPage } from "./render-mdx.ts";
|
|
@@ -90,7 +91,11 @@ export const openApiSource = (
|
|
|
90
91
|
ctx.projectRoot,
|
|
91
92
|
{ cacheDir: ctx.cacheDir, refresh: ctx.refresh }
|
|
92
93
|
);
|
|
93
|
-
const {
|
|
94
|
+
const {
|
|
95
|
+
operations,
|
|
96
|
+
tags,
|
|
97
|
+
warnings: extractWarnings,
|
|
98
|
+
} = extractOperations(document, reference.route);
|
|
94
99
|
const info = document.info ?? { title: reference.label, version: "" };
|
|
95
100
|
const spec: ApiSpecData = {
|
|
96
101
|
codeSamples: reference.display.codeSamples,
|
|
@@ -98,8 +103,18 @@ export const openApiSource = (
|
|
|
98
103
|
document,
|
|
99
104
|
expandSchemas: reference.display.expandSchemas,
|
|
100
105
|
label: reference.label,
|
|
106
|
+
// Operation pages flow through the content pipeline, which mounts them
|
|
107
|
+
// under the site-wide `basePath` (staged entry refs below stay
|
|
108
|
+
// base-less); serialize the served route so components link to the
|
|
109
|
+
// pages' real URLs.
|
|
101
110
|
operations: Object.fromEntries(
|
|
102
|
-
operations.map((operation) => [
|
|
111
|
+
operations.map((operation) => [
|
|
112
|
+
operation.key,
|
|
113
|
+
{
|
|
114
|
+
...operation,
|
|
115
|
+
route: withBasePath(reference.basePath, operation.route),
|
|
116
|
+
},
|
|
117
|
+
])
|
|
103
118
|
),
|
|
104
119
|
route: reference.route,
|
|
105
120
|
slug: reference.slug,
|
|
@@ -108,11 +123,32 @@ export const openApiSource = (
|
|
|
108
123
|
version: info.version ?? "",
|
|
109
124
|
};
|
|
110
125
|
return {
|
|
111
|
-
diagnostics:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
diagnostics: [
|
|
127
|
+
...warnings.map((message) => ({
|
|
128
|
+
code: "BLUME_OPENAPI_STALE",
|
|
129
|
+
message,
|
|
130
|
+
severity: "warning" as const,
|
|
131
|
+
})),
|
|
132
|
+
...extractWarnings.map((message) => ({
|
|
133
|
+
code: "BLUME_OPENAPI_REF_PATH_ITEM",
|
|
134
|
+
message: `In OpenAPI spec "${reference.spec}": ${message}`,
|
|
135
|
+
severity: "warning" as const,
|
|
136
|
+
})),
|
|
137
|
+
// A document with no operations (say, a config file that happens to
|
|
138
|
+
// parse as YAML) would otherwise build a nav tab onto an empty
|
|
139
|
+
// reference with no hint why.
|
|
140
|
+
...(operations.length === 0
|
|
141
|
+
? [
|
|
142
|
+
{
|
|
143
|
+
code: "BLUME_OPENAPI_EMPTY",
|
|
144
|
+
message: `OpenAPI spec "${reference.spec}" for ${reference.route} declares no operations; its API reference is empty.`,
|
|
145
|
+
severity: "warning" as const,
|
|
146
|
+
suggestion:
|
|
147
|
+
"Check the spec points at an OpenAPI document with operations under `paths`.",
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
: []),
|
|
151
|
+
],
|
|
116
152
|
entries: specEntries(spec, operations),
|
|
117
153
|
slug: reference.slug,
|
|
118
154
|
spec,
|
|
@@ -125,8 +161,12 @@ export const openApiSource = (
|
|
|
125
161
|
// so fail loudly in build (blocks under --strict) while staying a warning
|
|
126
162
|
// in dev so offline work still runs.
|
|
127
163
|
severity: ctx.mode === "build" ? "error" : "warning",
|
|
164
|
+
// A readable-but-invalid file is a content problem, not a network one;
|
|
165
|
+
// only point at reachability for actual fetch/read failures.
|
|
128
166
|
suggestion:
|
|
129
|
-
|
|
167
|
+
error instanceof InvalidSpecError
|
|
168
|
+
? "Point the spec at an OpenAPI document (a YAML or JSON file with an object at the top level)."
|
|
169
|
+
: "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
|
|
130
170
|
};
|
|
131
171
|
}
|
|
132
172
|
};
|
|
@@ -134,7 +174,16 @@ export const openApiSource = (
|
|
|
134
174
|
const load = async (): Promise<SourceLoadResult> => {
|
|
135
175
|
const results = await Promise.all(references.map(loadReference));
|
|
136
176
|
const entries: SourceEntry[] = [];
|
|
137
|
-
|
|
177
|
+
// Route collisions recorded while deduping (see `blumeReferences`): a
|
|
178
|
+
// dropped source loses a whole spec's pages, so warn even when the kept
|
|
179
|
+
// spec loads cleanly.
|
|
180
|
+
const diagnostics: Diagnostic[] = references.flatMap((reference) =>
|
|
181
|
+
(reference.collisions ?? []).map((message) => ({
|
|
182
|
+
code: "BLUME_OPENAPI_ROUTE_COLLISION",
|
|
183
|
+
message,
|
|
184
|
+
severity: "warning" as const,
|
|
185
|
+
}))
|
|
186
|
+
);
|
|
138
187
|
const data: OpenApiData = {};
|
|
139
188
|
for (const result of results) {
|
|
140
189
|
if ("severity" in result) {
|