blume 0.6.7 → 0.7.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/dist/cli/index.js +1179 -738
- package/dist/cli/index.js.map +52 -51
- package/dist/types/core/base-path.d.ts +38 -0
- package/dist/types/core/config-input.d.ts +74 -10
- 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 +1 -3
- package/dist/types/core/schema.d.ts +95 -52
- package/dist/types/core/sources/types.d.ts +2 -0
- package/dist/types/core/types.d.ts +6 -1
- package/docs/02-deployment.mdx +16 -1
- package/docs/03-faq.mdx +8 -8
- package/docs/configuration/index.mdx +6 -0
- package/docs/content/components.mdx +29 -2
- package/docs/content/islands.mdx +8 -0
- package/docs/content/syntax.mdx +13 -0
- package/package.json +2 -1
- package/src/ai/agent-readability.ts +7 -2
- package/src/ai/ask.ts +12 -7
- package/src/ai/llms.ts +15 -4
- package/src/ai/mcp/data.ts +8 -4
- package/src/ai/mcp/server.ts +3 -0
- package/src/astro/component-slots.ts +5 -3
- package/src/astro/examples.ts +12 -7
- package/src/astro/generate.ts +317 -144
- package/src/astro/index.ts +5 -1
- package/src/astro/integration.ts +8 -4
- package/src/astro/islands.ts +11 -5
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +8 -3
- package/src/astro/templates.ts +166 -19
- package/src/cli/commands/build.ts +32 -19
- package/src/cli/commands/dev.ts +48 -15
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/validate.ts +1 -0
- package/src/cli/dev-lock.ts +26 -15
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/CodeBlock.astro +3 -0
- package/src/components/content/Component.astro +30 -16
- package/src/components/content/Diff.astro +3 -1
- package/src/components/content/auto-type-table.ts +18 -8
- package/src/components/content/diff.ts +12 -6
- package/src/components/content/mermaid-element.ts +3 -0
- package/src/components/index.ts +23 -1
- package/src/components/islands/ask-ai.tsx +12 -6
- package/src/components/islands/base-path.ts +28 -0
- package/src/components/islands/hooks.ts +16 -1
- package/src/components/layout/Banner.astro +2 -1
- package/src/components/layout/Breadcrumbs.astro +2 -1
- package/src/components/layout/Favicon.astro +3 -2
- package/src/components/layout/Header.astro +2 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +2 -1
- package/src/components/layout/NavSelector.astro +2 -1
- package/src/components/layout/NavTree.astro +5 -4
- package/src/components/layout/PageFeedback.astro +4 -1
- package/src/components/layout/PageLayout.astro +9 -4
- package/src/components/layout/Pagination.astro +3 -2
- package/src/components/layout/RootLayout.astro +7 -4
- package/src/components/layout/Search.astro +13 -5
- package/src/components/layout/nav-utils.ts +18 -10
- package/src/components/layout/search/pagefind.ts +3 -0
- package/src/components/layout/toc-element.ts +7 -1
- package/src/components/openapi/RequestPanel.astro +7 -1
- package/src/components/openapi/snippets.ts +25 -11
- package/src/core/base-path.ts +70 -0
- package/src/core/component-overrides.ts +103 -74
- package/src/core/config-input.ts +81 -15
- package/src/core/config.ts +5 -3
- package/src/core/content.ts +2 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +54 -34
- package/src/core/gitignore.ts +4 -1
- package/src/core/graph.ts +156 -88
- package/src/core/i18n-ui.ts +18 -3
- package/src/core/last-modified.ts +2 -0
- package/src/core/links.ts +38 -18
- package/src/core/manifest.ts +62 -45
- package/src/core/nav-diagnostics.ts +1 -1
- package/src/core/navigation.ts +116 -55
- package/src/core/project-graph.ts +10 -9
- package/src/core/schema.ts +572 -621
- package/src/core/sources/github-releases.ts +2 -1
- package/src/core/sources/mdx-remote.ts +58 -54
- package/src/core/sources/normalize.ts +116 -73
- package/src/core/sources/notion.ts +19 -10
- package/src/core/sources/types.ts +2 -0
- package/src/core/tsconfig-aliases.ts +59 -30
- package/src/core/types.ts +6 -1
- package/src/deploy/redirects.ts +18 -0
- package/src/deploy/robots.ts +6 -1
- package/src/deploy/rss.ts +10 -3
- package/src/deploy/sitemap.ts +14 -10
- package/src/markdown/base-links.ts +58 -0
- package/src/markdown/code-title.ts +11 -14
- package/src/markdown/index.ts +34 -9
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/themes.ts +24 -0
- package/src/openapi/model.ts +3 -1
- package/src/openapi/references.ts +41 -17
- package/src/openapi/render-mdx.ts +11 -6
- package/src/openapi/scalar.ts +32 -16
- package/src/registry/eject.ts +64 -8
- package/src/search/build.ts +3 -0
- package/src/search/documents.ts +2 -2
- package/src/search/sync/typesense.ts +6 -4
- package/src/seo/jsonld.ts +16 -6
- package/src/theme/entry.ts +85 -20
package/src/deploy/rss.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
1
2
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
3
|
import type { PageRecord } from "../core/types.ts";
|
|
3
4
|
import { escapeXml } from "./xml.ts";
|
|
@@ -52,6 +53,11 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
|
|
|
52
53
|
return [];
|
|
53
54
|
}
|
|
54
55
|
const base = site.replace(/\/$/u, "");
|
|
56
|
+
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
|
|
57
|
+
// The feed's own `link`/self URL points at the docs root under that base, while
|
|
58
|
+
// `path` stays base-less (it's also the on-disk output location).
|
|
59
|
+
const deployBase = normalizeBasePath(config.deployment.base);
|
|
60
|
+
const rootLink = `${base}${deployBase}`;
|
|
55
61
|
|
|
56
62
|
const feeds: RssFeed[] = [];
|
|
57
63
|
for (const type of rss.types) {
|
|
@@ -70,7 +76,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
|
|
|
70
76
|
description: page.description,
|
|
71
77
|
// Encode like the sitemap does: a route with spaces or non-ASCII
|
|
72
78
|
// must still yield a valid <link>/<guid> URL after XML decoding.
|
|
73
|
-
link: encodeURI(`${base}${page.route}`),
|
|
79
|
+
link: encodeURI(`${base}${withBasePath(deployBase, page.route)}`),
|
|
74
80
|
title: page.title,
|
|
75
81
|
}))
|
|
76
82
|
.toSorted((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0))
|
|
@@ -79,7 +85,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
|
|
|
79
85
|
feeds.push({
|
|
80
86
|
description: config.description,
|
|
81
87
|
items,
|
|
82
|
-
link:
|
|
88
|
+
link: rootLink,
|
|
83
89
|
path: `/${type}/rss.xml`,
|
|
84
90
|
title: `${config.title} — ${capitalize(type)}`,
|
|
85
91
|
type,
|
|
@@ -105,11 +111,12 @@ const renderItem = (item: RssItem): string => {
|
|
|
105
111
|
|
|
106
112
|
/** Serialize a resolved feed into an RSS 2.0 XML document. */
|
|
107
113
|
export const renderRssFeed = (feed: RssFeed): string => {
|
|
114
|
+
const feedSelfHref = `${feed.link}${feed.path}`;
|
|
108
115
|
const channel = [
|
|
109
116
|
` <title>${escapeXml(feed.title)}</title>`,
|
|
110
117
|
` <link>${escapeXml(feed.link)}</link>`,
|
|
111
118
|
` <description>${escapeXml(feed.description ?? feed.title)}</description>`,
|
|
112
|
-
` <atom:link href="${escapeXml(
|
|
119
|
+
` <atom:link href="${escapeXml(feedSelfHref)}" rel="self" type="application/rss+xml" />`,
|
|
113
120
|
];
|
|
114
121
|
const items = feed.items.map(renderItem).join("\n");
|
|
115
122
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
package/src/deploy/sitemap.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
1
2
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
3
|
import { escapeXml } from "./xml.ts";
|
|
3
4
|
|
|
@@ -24,20 +25,23 @@ export const buildSitemap = (project: BlumeProject): string | null => {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const base = site.replace(/\/$/u, "");
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
28
|
+
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
|
|
29
|
+
const deployBase = normalizeBasePath(project.config.deployment.base);
|
|
30
|
+
const urls: string[] = [];
|
|
31
|
+
for (const page of project.graph.pages) {
|
|
32
|
+
if (page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
32
35
|
// `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
|
|
33
36
|
// then escape XML metacharacters (notably `&`) so a route like
|
|
34
37
|
// `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
|
|
35
38
|
// rejected.
|
|
36
|
-
|
|
37
|
-
(page)
|
|
38
|
-
|
|
39
|
-
)
|
|
40
|
-
|
|
39
|
+
const loc = escapeXml(
|
|
40
|
+
encodeURI(`${base}${withBasePath(deployBase, page.route)}`)
|
|
41
|
+
);
|
|
42
|
+
urls.push(` <url><loc>${loc}</loc>${lastmodTag(page.lastModified)}</url>`);
|
|
43
|
+
}
|
|
44
|
+
urls.sort();
|
|
41
45
|
|
|
42
46
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
43
47
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isInternalPath, withBasePath } from "../core/base-path.ts";
|
|
2
|
+
import type { MdastNode } from "./mdast.ts";
|
|
3
|
+
|
|
4
|
+
interface UrlNode extends MdastNode {
|
|
5
|
+
url?: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The slice of Satteri's MDAST visitor context this plugin needs. Nodes are
|
|
10
|
+
* read-only (the tree compiles to an op-stream), so a URL edit is recorded via
|
|
11
|
+
* `setProperty`, not by mutating the node object.
|
|
12
|
+
*/
|
|
13
|
+
interface MdastUrlContext {
|
|
14
|
+
setProperty: (node: unknown, key: "url", value: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A path whose final segment carries a file extension (`/spec.pdf`, `/logo.svg`)
|
|
19
|
+
* — treated as a public asset, which Blume serves from `public/` at the site
|
|
20
|
+
* root and does *not* move under `basePath`. Bare page links (`/guide`) have no
|
|
21
|
+
* extension. The rare dotted route (`/releases/v1.0`) is left un-based here; the
|
|
22
|
+
* build-time link checker still resolves it against the route set.
|
|
23
|
+
*/
|
|
24
|
+
const ASSET_PATH = /\.[a-z0-9]+$/iu;
|
|
25
|
+
|
|
26
|
+
/** Strip any `#fragment`/`?query` so only the path is extension-tested. */
|
|
27
|
+
const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Satteri MDAST plugin that prepends the site-wide `basePath` to root-relative
|
|
31
|
+
* internal page links (`[x](/guide)` -> `/docs/guide`), so authors write links
|
|
32
|
+
* as if mounted at root. Idempotent (via `withBasePath`) and inert for external
|
|
33
|
+
* URLs, fragments, relative paths, images, and asset links. Only constructed
|
|
34
|
+
* when a base is set (see `markdown/index.ts`).
|
|
35
|
+
*/
|
|
36
|
+
export const baseLinksPlugin = (basePath: string) => {
|
|
37
|
+
const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
|
|
38
|
+
const { url } = node;
|
|
39
|
+
if (
|
|
40
|
+
typeof url === "string" &&
|
|
41
|
+
isInternalPath(url) &&
|
|
42
|
+
!ASSET_PATH.test(pathOf(url))
|
|
43
|
+
) {
|
|
44
|
+
const next = withBasePath(basePath, url);
|
|
45
|
+
if (next !== url) {
|
|
46
|
+
ctx.setProperty(node, "url", next);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
// `link` covers inline links; `definition` covers reference-style link
|
|
51
|
+
// definitions (`[x]: /guide`). `image` is intentionally excluded — images are
|
|
52
|
+
// public assets served at the site root, unaffected by `basePath`.
|
|
53
|
+
return {
|
|
54
|
+
definition: rebase,
|
|
55
|
+
link: rebase,
|
|
56
|
+
name: "blume-base-links",
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -37,6 +37,16 @@ const QUOTED_ATTR = /[\w-]+=(?:"[^"]*"|'[^']*')/gu;
|
|
|
37
37
|
const withoutQuotedAttrs = (raw: string): string =>
|
|
38
38
|
raw.replace(QUOTED_ATTR, " ");
|
|
39
39
|
|
|
40
|
+
// The first bare token is the title (```ts blume.config.ts): a non-empty token
|
|
41
|
+
// that isn't a Shiki line range (`{1,3-5}`), a `key=value` attr, or a reserved
|
|
42
|
+
// `lineNumbers`/`twoslash` keyword.
|
|
43
|
+
const isTitleToken = (token: string): boolean => {
|
|
44
|
+
if (token.length === 0 || token.startsWith("{") || token.includes("=")) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return token !== "lineNumbers" && token !== "twoslash";
|
|
48
|
+
};
|
|
49
|
+
|
|
40
50
|
const parseTitle = (raw: string | undefined): string | undefined => {
|
|
41
51
|
if (!raw) {
|
|
42
52
|
return undefined;
|
|
@@ -46,20 +56,7 @@ const parseTitle = (raw: string | undefined): string | undefined => {
|
|
|
46
56
|
if (attrTitle) {
|
|
47
57
|
return attrTitle;
|
|
48
58
|
}
|
|
49
|
-
|
|
50
|
-
// line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
|
|
51
|
-
// and `twoslash` keywords.
|
|
52
|
-
return withoutQuotedAttrs(raw)
|
|
53
|
-
.trim()
|
|
54
|
-
.split(/\s+/u)
|
|
55
|
-
.find(
|
|
56
|
-
(token) =>
|
|
57
|
-
token.length > 0 &&
|
|
58
|
-
token !== "lineNumbers" &&
|
|
59
|
-
token !== "twoslash" &&
|
|
60
|
-
!token.startsWith("{") &&
|
|
61
|
-
!token.includes("=")
|
|
62
|
-
);
|
|
59
|
+
return withoutQuotedAttrs(raw).trim().split(/\s+/u).find(isTitleToken);
|
|
63
60
|
};
|
|
64
61
|
|
|
65
62
|
const hasLineNumbers = (raw: string | undefined): boolean =>
|
package/src/markdown/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "@shikijs/transformers";
|
|
9
9
|
import { codeToHtml } from "shiki";
|
|
10
10
|
|
|
11
|
+
import { baseLinksPlugin } from "./base-links.ts";
|
|
11
12
|
import { codeTitleTransformer } from "./code-title.ts";
|
|
12
13
|
import { directiveToCalloutPlugin } from "./directives.ts";
|
|
13
14
|
import { headingAnchorPlugin } from "./heading-anchors.ts";
|
|
@@ -16,6 +17,8 @@ import { languageIconTransformer } from "./language-icon.ts";
|
|
|
16
17
|
import { mathPlugin } from "./math.ts";
|
|
17
18
|
import { mermaidPlugin } from "./mermaid.ts";
|
|
18
19
|
import { packageInstallPlugin } from "./package-install.ts";
|
|
20
|
+
import { DEFAULT_CODE_THEMES } from "./themes.ts";
|
|
21
|
+
import type { CodeThemes } from "./themes.ts";
|
|
19
22
|
|
|
20
23
|
/** A Shiki transformer, derived from the upstream factories' return type. */
|
|
21
24
|
type ShikiTransformer = ReturnType<typeof transformerNotationDiff>;
|
|
@@ -54,7 +57,7 @@ type HastPlugin = NonNullable<
|
|
|
54
57
|
*/
|
|
55
58
|
const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
|
|
56
59
|
const plugins: HastPlugin[] = [
|
|
57
|
-
inlineCodeHighlightPlugin() as unknown as HastPlugin,
|
|
60
|
+
inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
|
|
58
61
|
];
|
|
59
62
|
if (options.headingAnchors !== false) {
|
|
60
63
|
plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
|
|
@@ -99,13 +102,6 @@ export const blumeShikiTransformers = (
|
|
|
99
102
|
return transformers;
|
|
100
103
|
};
|
|
101
104
|
|
|
102
|
-
/**
|
|
103
|
-
* The light/dark Shiki themes Blume highlights with. Kept in lockstep with the
|
|
104
|
-
* generated Astro config's `shikiConfig.themes` so code highlighted outside the
|
|
105
|
-
* Markdown pipeline (via {@link highlightCode}) matches fenced code exactly.
|
|
106
|
-
*/
|
|
107
|
-
const CODE_THEMES = { dark: "github-dark", light: "github-light" } as const;
|
|
108
|
-
|
|
109
105
|
const escapeHtml = (value: string): string =>
|
|
110
106
|
value
|
|
111
107
|
.replaceAll("&", "&")
|
|
@@ -147,6 +143,11 @@ const languageAttrTransformer = (lang: string): ShikiTransformer =>
|
|
|
147
143
|
export interface HighlightCodeOptions extends BlumeShikiOptions {
|
|
148
144
|
/** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
|
|
149
145
|
className?: string;
|
|
146
|
+
/**
|
|
147
|
+
* Light/dark Shiki themes (`markdown.codeBlocks.theme`). Defaults to the same
|
|
148
|
+
* github pair fenced code uses, so out-of-pipeline code stays in lockstep.
|
|
149
|
+
*/
|
|
150
|
+
themes?: CodeThemes;
|
|
150
151
|
/** Header title (a filename), matching a fence's `title="..."` meta. */
|
|
151
152
|
title?: string;
|
|
152
153
|
}
|
|
@@ -176,7 +177,7 @@ export const highlightCode = async (
|
|
|
176
177
|
meta: options.title
|
|
177
178
|
? { __raw: `title="${options.title.replaceAll('"', "")}"` }
|
|
178
179
|
: undefined,
|
|
179
|
-
themes:
|
|
180
|
+
themes: options.themes ?? DEFAULT_CODE_THEMES,
|
|
180
181
|
transformers: [
|
|
181
182
|
...blumeShikiTransformers({ icons: options.icons }),
|
|
182
183
|
astroCodeClassTransformer(options.className),
|
|
@@ -200,18 +201,41 @@ const FEATURES = { subscript: true, superscript: true };
|
|
|
200
201
|
|
|
201
202
|
/** Options shared by both processors. */
|
|
202
203
|
export interface BlumeMarkdownOptions {
|
|
204
|
+
/**
|
|
205
|
+
* Light/dark Shiki themes for inline `` `code`{:lang} `` highlighting
|
|
206
|
+
* (`markdown.codeBlocks.theme`). Defaults to the github pair fenced code uses.
|
|
207
|
+
*/
|
|
208
|
+
codeThemes?: CodeThemes;
|
|
203
209
|
/**
|
|
204
210
|
* Wrap `<h2>`–`<h6>` in self-linking anchors (`markdown.headingAnchors`).
|
|
205
211
|
* On unless explicitly `false`.
|
|
206
212
|
*/
|
|
207
213
|
headingAnchors?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* Site-wide route mount point (`""` or `/seg`). When set, root-relative
|
|
216
|
+
* internal page links in content are rewritten under it, so authors write
|
|
217
|
+
* links as if mounted at root.
|
|
218
|
+
*/
|
|
219
|
+
basePath?: string;
|
|
208
220
|
}
|
|
209
221
|
|
|
222
|
+
/**
|
|
223
|
+
* MDAST plugins that apply to both `.md` and `.mdx`. Currently just the
|
|
224
|
+
* base-path link rewrite, added only when a `basePath` is configured.
|
|
225
|
+
*/
|
|
226
|
+
const blumeSharedMdastPlugins = (
|
|
227
|
+
options: BlumeMarkdownOptions
|
|
228
|
+
): MdastPlugin[] =>
|
|
229
|
+
options.basePath
|
|
230
|
+
? [baseLinksPlugin(options.basePath) as unknown as MdastPlugin]
|
|
231
|
+
: [];
|
|
232
|
+
|
|
210
233
|
/** Sätteri processor for plain `.md`, with Blume's curated feature set. */
|
|
211
234
|
export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
|
|
212
235
|
satteri({
|
|
213
236
|
features: { ...FEATURES },
|
|
214
237
|
hastPlugins: blumeHastPlugins(options),
|
|
238
|
+
mdastPlugins: blumeSharedMdastPlugins(options),
|
|
215
239
|
});
|
|
216
240
|
|
|
217
241
|
export type BlumeMdxOptions = BlumeMarkdownOptions;
|
|
@@ -246,5 +270,6 @@ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
|
|
|
246
270
|
directiveToCalloutPlugin(),
|
|
247
271
|
mermaidPlugin(),
|
|
248
272
|
mathPlugin(),
|
|
273
|
+
...blumeSharedMdastPlugins(options),
|
|
249
274
|
] as unknown as MdastPlugin[],
|
|
250
275
|
});
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* added to the pipeline at all).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { DEFAULT_CODE_THEMES } from "./themes.ts";
|
|
16
|
+
import type { CodeThemes } from "./themes.ts";
|
|
17
|
+
|
|
15
18
|
/** A minimal hast node (avoids a hast type dependency). */
|
|
16
19
|
interface HastNode {
|
|
17
20
|
children?: HastNode[];
|
|
@@ -72,7 +75,9 @@ const loadHighlighter = async (): Promise<InlineHighlighter> => {
|
|
|
72
75
|
};
|
|
73
76
|
|
|
74
77
|
/** Build the plugin. Highlights inline `` `code{:lang}` `` snippets. */
|
|
75
|
-
export const inlineCodeHighlightPlugin = (
|
|
78
|
+
export const inlineCodeHighlightPlugin = (
|
|
79
|
+
themes: CodeThemes = DEFAULT_CODE_THEMES
|
|
80
|
+
): InlineCodePlugin => ({
|
|
76
81
|
element: {
|
|
77
82
|
filter: ["code"],
|
|
78
83
|
async visit(node, ctx) {
|
|
@@ -90,7 +95,7 @@ export const inlineCodeHighlightPlugin = (): InlineCodePlugin => ({
|
|
|
90
95
|
defaultColor: false,
|
|
91
96
|
lang: parsed.lang,
|
|
92
97
|
structure: "inline",
|
|
93
|
-
themes
|
|
98
|
+
themes,
|
|
94
99
|
});
|
|
95
100
|
return {
|
|
96
101
|
children: root.children,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The light/dark Shiki themes Blume highlights code with. Every Shiki surface —
|
|
3
|
+
* fenced code (the generated Astro `shikiConfig.themes`), inline `` `code`{:lang} ``,
|
|
4
|
+
* out-of-pipeline `highlightCode`, and `<Diff>` — resolves to the same pair so a
|
|
5
|
+
* project's `markdown.codeBlocks.theme` shifts them all in lockstep. This is the
|
|
6
|
+
* single home for the github fallback used when nothing is configured.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A light/dark Shiki theme pair (`markdown.codeBlocks.theme`). A `type` (not an
|
|
11
|
+
* `interface`) so it keeps the implicit index signature Shiki's `themes`
|
|
12
|
+
* parameter (`Partial<Record<string, …>>`) expects.
|
|
13
|
+
*/
|
|
14
|
+
// oxlint-disable-next-line typescript/consistent-type-definitions -- interface loses the implicit index signature Shiki's `themes` param needs
|
|
15
|
+
export type CodeThemes = {
|
|
16
|
+
dark: string;
|
|
17
|
+
light: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The default pair, used when `markdown.codeBlocks.theme` is unset. */
|
|
21
|
+
export const DEFAULT_CODE_THEMES: CodeThemes = {
|
|
22
|
+
dark: "github-dark",
|
|
23
|
+
light: "github-light",
|
|
24
|
+
};
|
package/src/openapi/model.ts
CHANGED
|
@@ -112,6 +112,7 @@ export const extractOperations = (
|
|
|
112
112
|
): { operations: ApiOperationRef[]; tags: ApiTagRef[] } => {
|
|
113
113
|
const operations: ApiOperationRef[] = [];
|
|
114
114
|
const tagOrder: string[] = [];
|
|
115
|
+
const tagsSeen = new Set<string>();
|
|
115
116
|
const tagMeta = new Map(
|
|
116
117
|
(document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
|
|
117
118
|
);
|
|
@@ -129,7 +130,8 @@ export const extractOperations = (
|
|
|
129
130
|
}
|
|
130
131
|
const tag = operation.tags?.[0] ?? UNTAGGED;
|
|
131
132
|
const tagSlug = slugify(tag) || "operations";
|
|
132
|
-
if (!
|
|
133
|
+
if (!tagsSeen.has(tag)) {
|
|
134
|
+
tagsSeen.add(tag);
|
|
133
135
|
tagOrder.push(tag);
|
|
134
136
|
}
|
|
135
137
|
let key = operationKey(method, path, operation.operationId);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withBasePath } from "../core/base-path.ts";
|
|
1
2
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
3
|
import type { NavTab } from "../core/types.ts";
|
|
3
4
|
|
|
@@ -139,32 +140,55 @@ export const resolveReferences = (
|
|
|
139
140
|
export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
|
|
140
141
|
resolveReferences(config).map((ref) => ({
|
|
141
142
|
label: ref.label,
|
|
142
|
-
|
|
143
|
+
// Blume-rendered operation pages flow through the content pipeline and are
|
|
144
|
+
// mounted under `basePath`, so their tab must be too. Scalar references are
|
|
145
|
+
// a single embedded page injected at the raw `route`, left root-anchored.
|
|
146
|
+
path:
|
|
147
|
+
ref.renderer === "blume"
|
|
148
|
+
? withBasePath(config.basePath, ref.route)
|
|
149
|
+
: ref.route,
|
|
143
150
|
}));
|
|
144
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Accept one resolved reference into the deduped Blume-rendered set, or return
|
|
154
|
+
* null to skip it. Mutates `seen`/`usedSlugs` so repeated routes/slugs collapse.
|
|
155
|
+
*/
|
|
156
|
+
const blumeReferenceOf = (
|
|
157
|
+
ref: ReferenceSource,
|
|
158
|
+
seen: Set<string>,
|
|
159
|
+
usedSlugs: Set<string>
|
|
160
|
+
): ReferenceSource | null => {
|
|
161
|
+
if (ref.kind !== "openapi" || ref.renderer !== "blume") {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (seen.has(ref.route)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
seen.add(ref.route);
|
|
168
|
+
// Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
|
|
169
|
+
// yield `api-v1`). The slug keys the `blume:openapi` data module, so a
|
|
170
|
+
// collision would let one spec silently overwrite the other while the
|
|
171
|
+
// loser's pages still point at the shared key — disambiguate.
|
|
172
|
+
let { slug } = ref;
|
|
173
|
+
let n = 2;
|
|
174
|
+
while (usedSlugs.has(slug)) {
|
|
175
|
+
slug = `${ref.slug}-${n}`;
|
|
176
|
+
n += 1;
|
|
177
|
+
}
|
|
178
|
+
usedSlugs.add(slug);
|
|
179
|
+
return slug === ref.slug ? ref : { ...ref, slug };
|
|
180
|
+
};
|
|
181
|
+
|
|
145
182
|
/** Blume-rendered OpenAPI references, deduped by route (first wins). */
|
|
146
183
|
export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
147
184
|
const seen = new Set<string>();
|
|
148
185
|
const usedSlugs = new Set<string>();
|
|
149
186
|
const result: ReferenceSource[] = [];
|
|
150
187
|
for (const ref of resolveReferences(config)) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (seen.has(ref.route)) {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
seen.add(ref.route);
|
|
158
|
-
// Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
|
|
159
|
-
// yield `api-v1`). The slug keys the `blume:openapi` data module, so a
|
|
160
|
-
// collision would let one spec silently overwrite the other while the
|
|
161
|
-
// loser's pages still point at the shared key — disambiguate.
|
|
162
|
-
let { slug } = ref;
|
|
163
|
-
for (let n = 2; usedSlugs.has(slug); n += 1) {
|
|
164
|
-
slug = `${ref.slug}-${n}`;
|
|
188
|
+
const accepted = blumeReferenceOf(ref, seen, usedSlugs);
|
|
189
|
+
if (accepted) {
|
|
190
|
+
result.push(accepted);
|
|
165
191
|
}
|
|
166
|
-
usedSlugs.add(slug);
|
|
167
|
-
result.push(slug === ref.slug ? ref : { ...ref, slug });
|
|
168
192
|
}
|
|
169
193
|
return result;
|
|
170
194
|
};
|
|
@@ -119,17 +119,22 @@ export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
const tagSections =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
const tagSections: string[] = [];
|
|
123
|
+
for (const tag of sections) {
|
|
124
|
+
if (!operations.some((operation) => operation.tagSlug === tag.slug)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const description = tag.description.trim()
|
|
128
|
+
? [mdxSafe(tag.description.trim())]
|
|
129
|
+
: [];
|
|
130
|
+
tagSections.push(
|
|
127
131
|
[
|
|
128
132
|
`## ${mdxSafe(tag.name)}`,
|
|
129
|
-
...
|
|
133
|
+
...description,
|
|
130
134
|
`<ApiTagOperations source="${spec.slug}" tag="${tag.slug}" />`,
|
|
131
135
|
].join("\n\n")
|
|
132
136
|
);
|
|
137
|
+
}
|
|
133
138
|
return {
|
|
134
139
|
body: [
|
|
135
140
|
withDescription(
|
package/src/openapi/scalar.ts
CHANGED
|
@@ -87,6 +87,35 @@ const specConfiguration = async (
|
|
|
87
87
|
}
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Accept one resolved reference into the deduped Scalar set, or return null to
|
|
92
|
+
* skip it (recording a warning for a route collision). Mutates `seen`.
|
|
93
|
+
*/
|
|
94
|
+
const acceptScalarReference = (
|
|
95
|
+
ref: ReferenceSource,
|
|
96
|
+
seen: Set<string>,
|
|
97
|
+
contentRoutes: ReadonlySet<string>,
|
|
98
|
+
warnings: string[]
|
|
99
|
+
): ReferenceSource | null => {
|
|
100
|
+
if (ref.renderer !== "scalar") {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (seen.has(ref.route)) {
|
|
104
|
+
warnings.push(
|
|
105
|
+
`Two API reference sources resolve to ${ref.route}; keeping the first.`
|
|
106
|
+
);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (contentRoutes.has(ref.route)) {
|
|
110
|
+
warnings.push(
|
|
111
|
+
`API reference route ${ref.route} collides with a content page; skipping the reference there.`
|
|
112
|
+
);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
seen.add(ref.route);
|
|
116
|
+
return ref;
|
|
117
|
+
};
|
|
118
|
+
|
|
90
119
|
/**
|
|
91
120
|
* Build the Scalar reference page(s) for the project. Only Scalar-rendered
|
|
92
121
|
* references are emitted here (Blume-rendered OpenAPI is staged content). Reads
|
|
@@ -105,23 +134,10 @@ export const buildReferenceFiles = async (options: {
|
|
|
105
134
|
const seen = new Set<string>();
|
|
106
135
|
const accepted: ReferenceSource[] = [];
|
|
107
136
|
for (const ref of resolveReferences(config)) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (seen.has(ref.route)) {
|
|
112
|
-
warnings.push(
|
|
113
|
-
`Two API reference sources resolve to ${ref.route}; keeping the first.`
|
|
114
|
-
);
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
if (contentRoutes.has(ref.route)) {
|
|
118
|
-
warnings.push(
|
|
119
|
-
`API reference route ${ref.route} collides with a content page; skipping the reference there.`
|
|
120
|
-
);
|
|
121
|
-
continue;
|
|
137
|
+
const next = acceptScalarReference(ref, seen, contentRoutes, warnings);
|
|
138
|
+
if (next) {
|
|
139
|
+
accepted.push(next);
|
|
122
140
|
}
|
|
123
|
-
seen.add(ref.route);
|
|
124
|
-
accepted.push(ref);
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
const built = await Promise.all(
|