blume 1.6.2 → 1.6.4
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 +25 -0
- package/dist/cli/index.js +307 -68
- package/dist/cli/index.js.map +28 -23
- package/dist/types/ai/component-markdown.d.ts +14 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/02-deployment.mdx +5 -5
- package/docs/{07-faq.mdx → 08-faq.mdx} +7 -7
- package/docs/advanced/blog.mdx +3 -3
- package/docs/advanced/changelog.mdx +2 -2
- package/docs/advanced/custom-pages.mdx +4 -4
- package/docs/advanced/meta.ts +1 -1
- package/docs/configuration/ask-ai.mdx +179 -0
- package/docs/configuration/index.mdx +8 -7
- package/docs/configuration/meta.ts +1 -2
- package/docs/configuration/search.mdx +1 -1
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +1 -1
- package/docs/content/i18n.mdx +7 -1
- package/docs/content/index.mdx +1 -1
- package/docs/content/navigation.mdx +2 -2
- package/docs/content/syntax.mdx +1 -1
- package/docs/discoverability/agent-discovery.mdx +196 -0
- package/docs/discoverability/index.mdx +48 -0
- package/docs/discoverability/json-api.mdx +58 -0
- package/docs/discoverability/llms-txt.mdx +68 -0
- package/docs/discoverability/markdown.mdx +76 -0
- package/docs/discoverability/mcp.mdx +64 -0
- package/docs/discoverability/meta.ts +18 -0
- package/docs/discoverability/metadata.mdx +82 -0
- package/docs/discoverability/open-graph.mdx +113 -0
- package/docs/discoverability/rss.mdx +24 -0
- package/docs/discoverability/sitemap-and-robots.mdx +95 -0
- package/docs/discoverability/structured-data.mdx +51 -0
- package/docs/index.mdx +5 -5
- package/docs/reference/eval.mdx +1 -1
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +1 -0
- package/package.json +18 -18
- package/src/ai/component-markdown.ts +17 -2
- package/src/ai/llms.ts +3 -10
- package/src/ai/markdown.ts +3 -10
- package/src/ai/openapi-components.ts +123 -0
- package/src/ai/serializers.ts +24 -0
- package/src/astro/templates.ts +42 -12
- package/src/audit/checks/links.ts +1 -8
- package/src/audit/checks/llms.ts +5 -4
- package/src/audit/redirects.ts +4 -3
- package/src/audit/run.ts +6 -8
- package/src/audit/url.ts +33 -0
- package/src/cli/commands/validate.ts +1 -0
- package/src/components/content/Component.astro +65 -68
- package/src/components/content/Tabs.astro +24 -9
- package/src/components/content/example-pane.ts +6 -0
- package/src/components/layout/LocaleLinks.astro +42 -0
- package/src/components/layout/PageLayout.astro +5 -3
- package/src/components/layout/ReferenceLayout.astro +5 -0
- package/src/components/layout/RootLayout.astro +110 -39
- package/src/components/layout/search-locale.ts +13 -0
- package/src/components/openapi/ApiOverview.astro +7 -39
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/AsyncApiOperation.astro +3 -2
- package/src/components/openapi/GraphqlOperation.astro +3 -2
- package/src/components/openapi/Operation.astro +3 -2
- package/src/core/i18n.ts +13 -2
- package/src/core/links.ts +33 -1
- package/src/core/locale-links.ts +163 -0
- package/src/core/sources/normalize.ts +57 -7
- package/src/markdown/package-commands.ts +27 -3
- package/src/openapi/graphql.ts +29 -0
- package/src/openapi/model.ts +69 -0
- package/src/openapi/render-mdx.ts +3 -2
- package/src/openapi/signature.ts +18 -0
- package/src/search/documents.ts +4 -9
- package/src/theme/code-block-padding.ts +0 -8
- package/src/theme/entry.ts +33 -27
- package/src/translate/anchors.ts +91 -0
- package/src/translate/validate.ts +8 -3
- package/docs/configuration/ai.mdx +0 -613
- package/docs/configuration/seo.mdx +0 -364
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isInternalPath,
|
|
3
|
+
normalizePath,
|
|
4
|
+
stripBasePath,
|
|
5
|
+
withBasePath,
|
|
6
|
+
} from "./base-path.ts";
|
|
7
|
+
import { localePrefix, localizeRoute } from "./i18n.ts";
|
|
8
|
+
import type { LocaleRouting } from "./i18n.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Locale-aware resolution of content links.
|
|
12
|
+
*
|
|
13
|
+
* Authors write internal links as if mounted at the default locale's root
|
|
14
|
+
* (`[x](/guide)`, `<Card href="/guide">`), and translated pages are usually
|
|
15
|
+
* copies of the source with the same links — so read on `/fr/…`, a link would
|
|
16
|
+
* otherwise drop the reader back into the default language. These helpers move
|
|
17
|
+
* such a link to the reader's locale (`/fr/guide`) when that route is served
|
|
18
|
+
* (a real translation or a materialized fallback page), and leave it alone
|
|
19
|
+
* otherwise: an explicit cross-locale link (`/de/guide`), a custom `.astro`
|
|
20
|
+
* page or generated route that has no per-locale variant, or a missing
|
|
21
|
+
* translation on a site with fallbacks disabled all keep their authored
|
|
22
|
+
* target rather than pointing at a 404.
|
|
23
|
+
*
|
|
24
|
+
* The rewrite runs at render time (`components/layout/LocaleLinks.astro`)
|
|
25
|
+
* because content is compiled once per file but served per route: a fallback
|
|
26
|
+
* route renders the fallback locale's file under the missing locale's URL, and
|
|
27
|
+
* a shared `page.$.mdx` renders under every locale. The link checker applies
|
|
28
|
+
* the same resolution so anchors are validated against the page a reader lands
|
|
29
|
+
* on.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** A route set, as the runtime (`Set`) or the checker (a predicate) sees it. */
|
|
33
|
+
export interface RouteSet {
|
|
34
|
+
has: (route: string) => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LocalizeLinkOptions {
|
|
38
|
+
/** Site-wide route mount point (`""` or `/seg`); routes carry it. */
|
|
39
|
+
basePath: string;
|
|
40
|
+
i18n: LocaleRouting;
|
|
41
|
+
/** Locale of the page the link is rendered on. */
|
|
42
|
+
locale: string;
|
|
43
|
+
/** Every served route, base-prefixed like `path` (no `deployment.base`). */
|
|
44
|
+
routes: RouteSet;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mirrors the asset heuristic in `markdown/base-links.ts`: a path whose final
|
|
49
|
+
* segment carries a file extension is a `public/` asset (or a raw `.md` twin),
|
|
50
|
+
* served at the site root and never localized.
|
|
51
|
+
*/
|
|
52
|
+
const ASSET_PATH = /\.[a-z0-9]+$/iu;
|
|
53
|
+
|
|
54
|
+
/** Decode a percent-encoded path for a route lookup; leave junk as-is. */
|
|
55
|
+
const decodePercent = (value: string): string => {
|
|
56
|
+
try {
|
|
57
|
+
return decodeURIComponent(value);
|
|
58
|
+
} catch {
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Whether `path` (base-stripped) already sits under some locale's prefix. */
|
|
64
|
+
const hasLocalePrefix = (path: string, i18n: LocaleRouting): boolean =>
|
|
65
|
+
i18n.locales.some((locale) => {
|
|
66
|
+
const prefix = localePrefix(locale.code, i18n);
|
|
67
|
+
return prefix !== "" && (path === prefix || path.startsWith(`${prefix}/`));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Move a base-prefixed, fragment-less internal path into `locale` when that
|
|
72
|
+
* route is served, else return it unchanged. Idempotent: a path already under
|
|
73
|
+
* any configured locale prefix is never re-prefixed.
|
|
74
|
+
*/
|
|
75
|
+
export const localizeLinkPath = (
|
|
76
|
+
path: string,
|
|
77
|
+
options: LocalizeLinkOptions
|
|
78
|
+
): string => {
|
|
79
|
+
const { basePath, i18n, locale, routes } = options;
|
|
80
|
+
if (localePrefix(locale, i18n) === "") {
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
const rest = normalizePath(stripBasePath(basePath, path));
|
|
84
|
+
if (hasLocalePrefix(rest, i18n)) {
|
|
85
|
+
return path;
|
|
86
|
+
}
|
|
87
|
+
const localized = withBasePath(basePath, localizeRoute(rest, locale, i18n));
|
|
88
|
+
// Routes are stored decoded; a browser-copied `/caf%C3%A9` must still find
|
|
89
|
+
// its translation, but the emitted href keeps the author's encoding.
|
|
90
|
+
return routes.has(localized) || routes.has(decodePercent(localized))
|
|
91
|
+
? localized
|
|
92
|
+
: path;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export interface LocalizeHrefOptions extends LocalizeLinkOptions {
|
|
96
|
+
/** `deployment.base` (Astro's `BASE_URL`), layered over `basePath` in hrefs. */
|
|
97
|
+
deployBase: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Localize a rendered `href`: external URLs, relative paths, bare fragments, and
|
|
102
|
+
* asset links pass through; a root-relative page link keeps its `?query` and
|
|
103
|
+
* `#fragment` and its `deployment.base` layer around the localized path.
|
|
104
|
+
*/
|
|
105
|
+
export const localizeHref = (
|
|
106
|
+
href: string,
|
|
107
|
+
options: LocalizeHrefOptions
|
|
108
|
+
): string => {
|
|
109
|
+
if (!isInternalPath(href)) {
|
|
110
|
+
return href;
|
|
111
|
+
}
|
|
112
|
+
const suffixAt = href.search(/[#?]/u);
|
|
113
|
+
const path = suffixAt === -1 ? href : href.slice(0, suffixAt);
|
|
114
|
+
const suffix = suffixAt === -1 ? "" : href.slice(suffixAt);
|
|
115
|
+
if (ASSET_PATH.test(path)) {
|
|
116
|
+
return href;
|
|
117
|
+
}
|
|
118
|
+
const based = stripBasePath(options.deployBase, path);
|
|
119
|
+
const localized = localizeLinkPath(based, options);
|
|
120
|
+
if (localized === based) {
|
|
121
|
+
return href;
|
|
122
|
+
}
|
|
123
|
+
return `${withBasePath(options.deployBase, localized)}${suffix}`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Every `<a …>` opening tag; `\s` keeps `<abbr>`/`<astro-island>` out. */
|
|
127
|
+
const ANCHOR_TAG = /<a\s[^>]*>/giu;
|
|
128
|
+
/** The tag's `href` attribute, double- or single-quoted. */
|
|
129
|
+
const HREF_ATTR = /(?<attr>\shref=)(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/iu;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rewrite every `<a href>` in rendered content HTML through `rewrite`. Code
|
|
133
|
+
* blocks are HTML-escaped (`<a`), and island props live on
|
|
134
|
+
* `<astro-island>`, so neither is touched.
|
|
135
|
+
*/
|
|
136
|
+
export const localizeContentLinks = (
|
|
137
|
+
html: string,
|
|
138
|
+
rewrite: (href: string) => string
|
|
139
|
+
): string =>
|
|
140
|
+
html.replace(ANCHOR_TAG, (tag) =>
|
|
141
|
+
tag.replace(HREF_ATTR, (_match, attr: string, dq?: string, sq?: string) => {
|
|
142
|
+
const quote = dq === undefined ? "'" : '"';
|
|
143
|
+
return `${attr}${quote}${rewrite(dq ?? sq ?? "")}${quote}`;
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The served route set for `blume:data`'s routes, built once per routes array
|
|
149
|
+
* (the virtual module is evaluated once, so every page render shares it).
|
|
150
|
+
*/
|
|
151
|
+
const routeSets = new WeakMap<readonly { path: string }[], Set<string>>();
|
|
152
|
+
|
|
153
|
+
export const routeSetFor = (
|
|
154
|
+
routes: readonly { path: string }[]
|
|
155
|
+
): Set<string> => {
|
|
156
|
+
const cached = routeSets.get(routes);
|
|
157
|
+
if (cached) {
|
|
158
|
+
return cached;
|
|
159
|
+
}
|
|
160
|
+
const built = new Set(routes.map((route) => route.path));
|
|
161
|
+
routeSets.set(routes, built);
|
|
162
|
+
return built;
|
|
163
|
+
};
|
|
@@ -280,6 +280,8 @@ interface HeadingScanState {
|
|
|
280
280
|
fence: FenceState;
|
|
281
281
|
/** 1-based body line of the line being scanned. */
|
|
282
282
|
line: number;
|
|
283
|
+
/** Where each heading in `headings` sits, index-aligned. */
|
|
284
|
+
sites: HeadingSite[];
|
|
283
285
|
/** Consecutive paragraph lines — the candidate text for a setext underline. */
|
|
284
286
|
paragraph: string[];
|
|
285
287
|
/** Body line of the first line in `paragraph`. */
|
|
@@ -399,23 +401,43 @@ const refDefinitionLabels = (lines: readonly string[]): Set<string> => {
|
|
|
399
401
|
* regardless of TOC visibility. A heading that is nothing but markers keeps
|
|
400
402
|
* them as literal text, mirroring the renderer.
|
|
401
403
|
*/
|
|
404
|
+
/** A scanned heading plus whether its id came from an author pin. */
|
|
405
|
+
interface ScannedHeading {
|
|
406
|
+
heading: Heading;
|
|
407
|
+
pinned: boolean;
|
|
408
|
+
}
|
|
409
|
+
|
|
402
410
|
const toHeading = (
|
|
403
411
|
depth: number,
|
|
404
412
|
raw: string,
|
|
405
413
|
slugger: GithubSlugger,
|
|
406
414
|
isRefDefined: (label: string) => boolean
|
|
407
|
-
):
|
|
415
|
+
): ScannedHeading => {
|
|
408
416
|
const unescaped = raw.replaceAll(ESCAPED_PUNCTUATION, "$<char>");
|
|
409
417
|
const markers = parseHeadingMarkers(unescaped, isRefDefined);
|
|
410
418
|
const text = markers.text.trim();
|
|
411
419
|
if (text === "" && (markers.id !== undefined || markers.toc !== undefined)) {
|
|
412
|
-
return {
|
|
420
|
+
return {
|
|
421
|
+
heading: { depth, slug: slugger.slug(unescaped), text: unescaped },
|
|
422
|
+
pinned: false,
|
|
423
|
+
};
|
|
413
424
|
}
|
|
414
425
|
if (markers.id !== undefined) {
|
|
415
426
|
occupySlug(slugger, markers.id);
|
|
416
|
-
return { depth, slug: markers.id, text };
|
|
427
|
+
return { heading: { depth, slug: markers.id, text }, pinned: true };
|
|
417
428
|
}
|
|
418
|
-
return { depth, slug: slugger.slug(text), text };
|
|
429
|
+
return { heading: { depth, slug: slugger.slug(text), text }, pinned: false };
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** Record a heading and where a trailing marker would be written for it. */
|
|
433
|
+
const pushHeading = (
|
|
434
|
+
headings: Heading[],
|
|
435
|
+
state: HeadingScanState,
|
|
436
|
+
scanned: ScannedHeading,
|
|
437
|
+
line: number
|
|
438
|
+
): void => {
|
|
439
|
+
headings.push(scanned.heading);
|
|
440
|
+
state.sites.push({ line, pinned: scanned.pinned });
|
|
419
441
|
};
|
|
420
442
|
|
|
421
443
|
/** Record a heading's unescaped trailing `{#id}` so `.mdx` pages can be warned. */
|
|
@@ -444,7 +466,12 @@ const scanContentLine = (
|
|
|
444
466
|
if (atx?.groups) {
|
|
445
467
|
const depth = atx.groups.hashes?.length ?? 1;
|
|
446
468
|
const text = (atx.groups.text ?? "").trim();
|
|
447
|
-
|
|
469
|
+
pushHeading(
|
|
470
|
+
headings,
|
|
471
|
+
state,
|
|
472
|
+
toHeading(depth, text, slugger, isRefDefined),
|
|
473
|
+
state.line
|
|
474
|
+
);
|
|
448
475
|
noteCurlyMarker(text, state.line, state);
|
|
449
476
|
state.paragraph = [];
|
|
450
477
|
return;
|
|
@@ -455,7 +482,14 @@ const scanContentLine = (
|
|
|
455
482
|
// a multi-line paragraph renders as one heading, soft breaks as spaces.
|
|
456
483
|
const depth = setext.groups.marker?.startsWith("=") ? 1 : 2;
|
|
457
484
|
const text = state.paragraph.join(" ").trim();
|
|
458
|
-
|
|
485
|
+
// A setext heading's markers trail its last text line, just above the
|
|
486
|
+
// underline — that is where a pin is appended.
|
|
487
|
+
pushHeading(
|
|
488
|
+
headings,
|
|
489
|
+
state,
|
|
490
|
+
toHeading(depth, text, slugger, isRefDefined),
|
|
491
|
+
state.line - 1
|
|
492
|
+
);
|
|
459
493
|
noteCurlyMarker(text, state.paragraphStart, state);
|
|
460
494
|
state.paragraph = [];
|
|
461
495
|
return;
|
|
@@ -515,6 +549,14 @@ const scanHeadingLine = (
|
|
|
515
549
|
scanContentLine(line, state, slugger, headings, isRefDefined);
|
|
516
550
|
};
|
|
517
551
|
|
|
552
|
+
/** Where a heading's text ends in the scanned text, for appending a marker. */
|
|
553
|
+
export interface HeadingSite {
|
|
554
|
+
/** 1-based line (of the text passed to `scanBody`) that any trailing marker ends. */
|
|
555
|
+
line: number;
|
|
556
|
+
/** True when the heading already pins its id with `[#id]`/`{#id}`. */
|
|
557
|
+
pinned: boolean;
|
|
558
|
+
}
|
|
559
|
+
|
|
518
560
|
/** Everything one walk over a body yields for the anchor index. */
|
|
519
561
|
export interface BodyScan {
|
|
520
562
|
/**
|
|
@@ -527,6 +569,8 @@ export interface BodyScan {
|
|
|
527
569
|
/** Headings whose trailing `{#id}` marker is unescaped, for `.mdx` pages. */
|
|
528
570
|
curlyMarkers: CurlyMarker[];
|
|
529
571
|
headings: Heading[];
|
|
572
|
+
/** Index-aligned with `headings`: where each one's markers would go. */
|
|
573
|
+
sites: HeadingSite[];
|
|
530
574
|
}
|
|
531
575
|
|
|
532
576
|
/**
|
|
@@ -546,6 +590,7 @@ export const scanBody = (body: string): BodyScan => {
|
|
|
546
590
|
paragraphStart: 0,
|
|
547
591
|
promptDepth: 0,
|
|
548
592
|
promptTag: false,
|
|
593
|
+
sites: [],
|
|
549
594
|
};
|
|
550
595
|
|
|
551
596
|
const { lines, offset } = linesWithoutFrontMatter(body);
|
|
@@ -568,7 +613,12 @@ export const scanBody = (body: string): BodyScan => {
|
|
|
568
613
|
anchors.add(id);
|
|
569
614
|
}
|
|
570
615
|
}
|
|
571
|
-
return {
|
|
616
|
+
return {
|
|
617
|
+
anchors: [...anchors],
|
|
618
|
+
curlyMarkers: state.curlyMarkers,
|
|
619
|
+
headings,
|
|
620
|
+
sites: state.sites,
|
|
621
|
+
};
|
|
572
622
|
};
|
|
573
623
|
|
|
574
624
|
export const extractHeadings = (body: string): Heading[] =>
|
|
@@ -2,7 +2,14 @@ import type { Agent, Command } from "package-manager-detector";
|
|
|
2
2
|
import { resolveCommand } from "package-manager-detector/commands";
|
|
3
3
|
|
|
4
4
|
/** Supported package managers, in the order tabs are displayed. */
|
|
5
|
-
export const PACKAGE_MANAGERS = [
|
|
5
|
+
export const PACKAGE_MANAGERS = [
|
|
6
|
+
"npm",
|
|
7
|
+
"pnpm",
|
|
8
|
+
"yarn",
|
|
9
|
+
"bun",
|
|
10
|
+
"nub",
|
|
11
|
+
"aube",
|
|
12
|
+
] as const;
|
|
6
13
|
|
|
7
14
|
export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
|
|
8
15
|
|
|
@@ -15,14 +22,29 @@ export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
|
|
|
15
22
|
* npm's form, matching `ni`'s table.
|
|
16
23
|
*/
|
|
17
24
|
const AGENT_FOR = {
|
|
25
|
+
aube: "aube",
|
|
18
26
|
bun: "bun",
|
|
19
27
|
npm: "npm",
|
|
28
|
+
nub: "nub",
|
|
20
29
|
pnpm: "pnpm",
|
|
21
30
|
yarn: "yarn@berry",
|
|
22
31
|
} satisfies Record<PackageManager, Agent>;
|
|
23
32
|
|
|
24
33
|
/** Words that mark the input as an explicit command rather than a bare list. */
|
|
25
|
-
const MANAGER_PREFIXES = new Set([
|
|
34
|
+
const MANAGER_PREFIXES = new Set([
|
|
35
|
+
"aube",
|
|
36
|
+
"bun",
|
|
37
|
+
"bunx",
|
|
38
|
+
"npm",
|
|
39
|
+
"npx",
|
|
40
|
+
"nub",
|
|
41
|
+
"nubx",
|
|
42
|
+
"pnpm",
|
|
43
|
+
"yarn",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Standalone runner binaries that spell `<manager> exec` as one word. */
|
|
47
|
+
const EXEC_BINARIES = new Set(["bunx", "npx", "nubx"]);
|
|
26
48
|
|
|
27
49
|
const WHITESPACE = /\s+/u;
|
|
28
50
|
const WHITESPACE_RUN = /\s+/gu;
|
|
@@ -104,7 +126,7 @@ const parseIntent = (input: string): Intent => {
|
|
|
104
126
|
if (!MANAGER_PREFIXES.has(first)) {
|
|
105
127
|
return { args: normalizeFlags(tokens), operation: "add" };
|
|
106
128
|
}
|
|
107
|
-
if (first
|
|
129
|
+
if (EXEC_BINARIES.has(first)) {
|
|
108
130
|
return { args: rest, operation: "exec" };
|
|
109
131
|
}
|
|
110
132
|
|
|
@@ -193,8 +215,10 @@ export const toPackageCommands = (input: string) => {
|
|
|
193
215
|
const normalize = (command: string): string =>
|
|
194
216
|
command.replaceAll(WHITESPACE_RUN, " ").trim();
|
|
195
217
|
return {
|
|
218
|
+
aube: normalize(buildCommand("aube", intent)),
|
|
196
219
|
bun: normalize(buildCommand("bun", intent)),
|
|
197
220
|
npm: normalize(buildCommand("npm", intent)),
|
|
221
|
+
nub: normalize(buildCommand("nub", intent)),
|
|
198
222
|
pnpm: normalize(buildCommand("pnpm", intent)),
|
|
199
223
|
yarn: normalize(buildCommand("yarn", intent)),
|
|
200
224
|
} satisfies Record<PackageManager, string>;
|
package/src/openapi/graphql.ts
CHANGED
|
@@ -124,6 +124,35 @@ export const isGraphqlOperationKind = (
|
|
|
124
124
|
): method is GraphqlOperationKind =>
|
|
125
125
|
GRAPHQL_OPERATION_KINDS.some((kind) => kind === method);
|
|
126
126
|
|
|
127
|
+
/** The SDL keyword that declares each named-type kind: `type Pet`, `enum Status`. */
|
|
128
|
+
const GRAPHQL_TYPE_KEYWORDS = {
|
|
129
|
+
enum: "enum",
|
|
130
|
+
input: "input",
|
|
131
|
+
interface: "interface",
|
|
132
|
+
object: "type",
|
|
133
|
+
scalar: "scalar",
|
|
134
|
+
union: "union",
|
|
135
|
+
} satisfies Record<GraphqlTypeKind, string>;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A GraphQL member in the schema's own notation — `query pets` for a root
|
|
139
|
+
* field, `type Pet` / `input PetInput` / `enum Status` for a named type. The
|
|
140
|
+
* rendered page shows the same pair as a kind badge beside the name; the
|
|
141
|
+
* text form spells the kind the way SDL does, so a reader who knows GraphQL
|
|
142
|
+
* and not Blume's badges reads it right.
|
|
143
|
+
*/
|
|
144
|
+
export const graphqlSignature = (
|
|
145
|
+
operation: Pick<ApiOperationRef, "method" | "path">
|
|
146
|
+
): string => {
|
|
147
|
+
// SAFETY: the GraphQL extractor only ever assigns member kinds as the
|
|
148
|
+
// method (see `extractGraphqlOperations`).
|
|
149
|
+
const member = operation.method as GraphqlMember;
|
|
150
|
+
const keyword = isGraphqlOperationKind(member)
|
|
151
|
+
? member
|
|
152
|
+
: GRAPHQL_TYPE_KEYWORDS[member];
|
|
153
|
+
return `${keyword} ${operation.path}`;
|
|
154
|
+
};
|
|
155
|
+
|
|
127
156
|
// Deterministic name order for type pages, independent of schema declaration
|
|
128
157
|
// order and of the platform's collation (localeCompare varies across ICU
|
|
129
158
|
// builds; codepoint order does not).
|
package/src/openapi/model.ts
CHANGED
|
@@ -143,6 +143,75 @@ export interface ApiSpecData {
|
|
|
143
143
|
/** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
|
|
144
144
|
export type OpenApiData = Record<string, ApiSpecData>;
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The spec a `<Operation source>` / `<ApiOverview source>` names, or nothing.
|
|
148
|
+
* `blume:openapi` crosses a JSON boundary as a plain object, so a lookup must
|
|
149
|
+
* be an own-property one: `source="toString"` would otherwise resolve to the
|
|
150
|
+
* inherited function, which is truthy and carries no `operations`, and every
|
|
151
|
+
* consumer would throw where it means to decline. Shared by the components
|
|
152
|
+
* and the agent-surface serializers so they miss the same way.
|
|
153
|
+
*/
|
|
154
|
+
export const specOf = (
|
|
155
|
+
specs: OpenApiData,
|
|
156
|
+
source: string
|
|
157
|
+
): ApiSpecData | undefined =>
|
|
158
|
+
Object.hasOwn(specs, source) ? specs[source] : undefined;
|
|
159
|
+
|
|
160
|
+
/** The operation an `<Operation id>` names within its spec, or nothing. */
|
|
161
|
+
export const operationOf = (
|
|
162
|
+
spec: ApiSpecData,
|
|
163
|
+
id: string
|
|
164
|
+
): ApiOperationRef | undefined =>
|
|
165
|
+
Object.hasOwn(spec.operations, id) ? spec.operations[id] : undefined;
|
|
166
|
+
|
|
167
|
+
/** The addresses an API overview lists, and what to call them. */
|
|
168
|
+
export interface SpecAddresses {
|
|
169
|
+
/** `Base URL` (OpenAPI), `Servers` (AsyncAPI) or `Endpoint` (GraphQL). */
|
|
170
|
+
label: string;
|
|
171
|
+
addresses: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Where the API lives, flattened into one list for the overview page. OpenAPI
|
|
176
|
+
* declares `servers` as an array of URLs; AsyncAPI as a named map of
|
|
177
|
+
* host/protocol/pathname; a GraphQL schema names no server, so its configured
|
|
178
|
+
* live endpoint stands in.
|
|
179
|
+
*/
|
|
180
|
+
export const specAddresses = (spec: ApiSpecData): SpecAddresses => {
|
|
181
|
+
const addresses: string[] = [];
|
|
182
|
+
if (spec.kind === "graphql") {
|
|
183
|
+
if (spec.endpoint) {
|
|
184
|
+
addresses.push(spec.endpoint);
|
|
185
|
+
}
|
|
186
|
+
return { addresses, label: "Endpoint" };
|
|
187
|
+
}
|
|
188
|
+
if (spec.kind === "asyncapi") {
|
|
189
|
+
// SAFETY: an `asyncapi` spec's document is the AsyncAPI shape (`parse.ts`
|
|
190
|
+
// routes each kind to its own parser).
|
|
191
|
+
const servers = (spec.document as AsyncApiDocument).servers ?? {};
|
|
192
|
+
for (const server of Object.values(servers)) {
|
|
193
|
+
if (server?.host) {
|
|
194
|
+
addresses.push(
|
|
195
|
+
`${server.protocol ? `${server.protocol}://` : ""}${server.host}${server.pathname ?? ""}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { addresses, label: "Servers" };
|
|
200
|
+
}
|
|
201
|
+
// Hand-written specs sometimes declare `servers` as a bare object; degrade
|
|
202
|
+
// to no addresses instead of throwing mid-build.
|
|
203
|
+
// SAFETY: the remaining kind is OpenAPI, whose document declares `servers`
|
|
204
|
+
// as an array of server objects; the array check below guards a spec that
|
|
205
|
+
// wrote something else there.
|
|
206
|
+
const declared = (spec.document as { servers?: { url?: string }[] }).servers;
|
|
207
|
+
for (const server of Array.isArray(declared) ? declared : []) {
|
|
208
|
+
if (server.url) {
|
|
209
|
+
addresses.push(server.url);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { addresses, label: "Base URL" };
|
|
213
|
+
};
|
|
214
|
+
|
|
146
215
|
// The runtime object check stands guard because the document was parsed from
|
|
147
216
|
// arbitrary YAML/JSON: a spec can put a scalar where the type promises an
|
|
148
217
|
// operation object.
|
|
@@ -8,6 +8,7 @@ import type { GraphqlMember } from "./graphql.ts";
|
|
|
8
8
|
import { isGraphqlOperationKind } from "./graphql.ts";
|
|
9
9
|
import type { ApiOperationRef, ApiSpecData } from "./model.ts";
|
|
10
10
|
import type { ReferenceSource } from "./references.ts";
|
|
11
|
+
import { operationSignature } from "./signature.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Lower a parsed spec into MDX for the staged content source. Each operation and
|
|
@@ -226,11 +227,11 @@ export const operationMdx = (
|
|
|
226
227
|
): RenderedPage => {
|
|
227
228
|
const method = operation.method.toUpperCase();
|
|
228
229
|
const graphql = spec.kind === "graphql";
|
|
229
|
-
// A GraphQL page IS its field/type — `
|
|
230
|
+
// A GraphQL page IS its field/type — `query pets` would double the badge the
|
|
230
231
|
// page already renders; the other kinds title an endpoint or channel action.
|
|
231
232
|
const fallbackTitle = graphql
|
|
232
233
|
? operation.path
|
|
233
|
-
:
|
|
234
|
+
: operationSignature(spec, operation);
|
|
234
235
|
const title = operation.summary || fallbackTitle;
|
|
235
236
|
// Skip the body description when it only repeats the summary (the `<h1>`) —
|
|
236
237
|
// common in specs that set summary and description to the same string.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { graphqlSignature } from "./graphql.ts";
|
|
2
|
+
import type { ApiOperationRef, ApiSpecData } from "./model.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one-line name of what an operation page documents, in the notation of
|
|
6
|
+
* its spec kind: `GET /pets/{id}` for an HTTP endpoint, `SEND user/signup`
|
|
7
|
+
* for an AsyncAPI channel action, `query pets` or `type Pet` for a GraphQL
|
|
8
|
+
* member. The page titles an untitled operation with it and the agent
|
|
9
|
+
* surfaces (`<route>.md`, llms-full.txt, MCP `get_page`) downlevel
|
|
10
|
+
* `<Operation>` to it, so the two can never disagree about the endpoint.
|
|
11
|
+
*/
|
|
12
|
+
export const operationSignature = (
|
|
13
|
+
spec: Pick<ApiSpecData, "kind">,
|
|
14
|
+
operation: Pick<ApiOperationRef, "method" | "path">
|
|
15
|
+
): string =>
|
|
16
|
+
spec.kind === "graphql"
|
|
17
|
+
? graphqlSignature(operation)
|
|
18
|
+
: `${operation.method.toUpperCase()} ${operation.path}`;
|
package/src/search/documents.ts
CHANGED
|
@@ -5,13 +5,13 @@ import {
|
|
|
5
5
|
componentRegistry,
|
|
6
6
|
downlevelComponentNode,
|
|
7
7
|
downlevelComponents,
|
|
8
|
-
exampleComponentSerializers,
|
|
9
8
|
} from "../ai/component-markdown.ts";
|
|
10
9
|
import type {
|
|
11
10
|
ComponentMarkdown,
|
|
12
11
|
DownlevelWalk,
|
|
13
12
|
MdastNode as DownlevelNode,
|
|
14
13
|
} from "../ai/component-markdown.ts";
|
|
14
|
+
import { projectComponentSerializers } from "../ai/serializers.ts";
|
|
15
15
|
import { applyAudienceVisibility } from "../ai/visibility.ts";
|
|
16
16
|
import type { VisibilityAudience } from "../ai/visibility.ts";
|
|
17
17
|
import matter from "../core/frontmatter.ts";
|
|
@@ -437,14 +437,9 @@ export const buildSearchDocuments = async (
|
|
|
437
437
|
return page ? contentIndexable(page, project.config) : false;
|
|
438
438
|
});
|
|
439
439
|
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
// per call otherwise.
|
|
444
|
-
const components = {
|
|
445
|
-
...exampleComponentSerializers(project.examples ?? {}),
|
|
446
|
-
...project.config.ai.markdownComponents,
|
|
447
|
-
};
|
|
440
|
+
// Built once — `downlevelComponents` rebuilds its registry per call
|
|
441
|
+
// otherwise.
|
|
442
|
+
const components = projectComponentSerializers(project);
|
|
448
443
|
|
|
449
444
|
return await Promise.all(
|
|
450
445
|
indexable.map(async (route) => {
|
|
@@ -6,11 +6,3 @@
|
|
|
6
6
|
|
|
7
7
|
/** Top and bottom inset of a plain prose block with no chrome, in rem. */
|
|
8
8
|
export const CODE_PADDING_BLOCK_REM = 1;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Top inset of a flush block — inside tabs or a `not-prose` component, or an
|
|
12
|
-
* untitled block with no language bar — where the layout's copy button is
|
|
13
|
-
* absolutely positioned over the first line: `top-2.5` plus a 1.875rem button
|
|
14
|
-
* lands at 2.5rem, so the first line starts there.
|
|
15
|
-
*/
|
|
16
|
-
export const FLUSH_CODE_PADDING_TOP_REM = 2.5;
|
package/src/theme/entry.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { CODE_PADDING_BLOCK_REM } from "./code-block-padding.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bottom inset of the scrolling code element, in rem: keeps a horizontal
|
|
5
|
+
* scrollbar thumb off the last line's descenders. It is carved out of the
|
|
6
|
+
* pre's block padding rather than added to it, so a block's height is the
|
|
7
|
+
* same whether or not it scrolls.
|
|
8
|
+
*/
|
|
9
|
+
const CODE_SCROLL_INSET_REM = 0.375;
|
|
5
10
|
|
|
6
11
|
interface TailwindEntryOptions {
|
|
7
12
|
/**
|
|
@@ -498,8 +503,9 @@ blume-diff {
|
|
|
498
503
|
max-height: 24rem;
|
|
499
504
|
overflow: auto;
|
|
500
505
|
/* The small bottom inset keeps the horizontal thumb off the last line's
|
|
501
|
-
descenders now that scrollbars are visible.
|
|
502
|
-
|
|
506
|
+
descenders now that scrollbars are visible. The pre gives up the same
|
|
507
|
+
amount below (next rule), so the inset adds no height to the block. */
|
|
508
|
+
padding: 0 1.25rem ${CODE_SCROLL_INSET_REM}rem;
|
|
503
509
|
/* Thin theme-colored scrollbars, matching the sidebar treatment, so a
|
|
504
510
|
height-capped block reads as scrollable instead of simply ending.
|
|
505
511
|
Safari before 18.2 supports neither property and falls back to the
|
|
@@ -508,6 +514,14 @@ blume-diff {
|
|
|
508
514
|
scrollbar-width: thin;
|
|
509
515
|
}
|
|
510
516
|
|
|
517
|
+
/* The scroller's bottom inset comes out of the pre's own block padding: the
|
|
518
|
+
text still sits one full inset above the frame's bottom edge, the thumb sits
|
|
519
|
+
in the gap, and a one-line command is no taller than it was before the
|
|
520
|
+
scroller existed. */
|
|
521
|
+
.prose :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *):has(> code)) {
|
|
522
|
+
padding-bottom: calc(${CODE_PADDING_BLOCK_REM}rem - ${CODE_SCROLL_INSET_REM}rem);
|
|
523
|
+
}
|
|
524
|
+
|
|
511
525
|
/* The dark border token is too close to the page background to read as a
|
|
512
526
|
scrollbar thumb; derive a brighter one from the muted foreground instead. */
|
|
513
527
|
:root[data-theme="dark"]
|
|
@@ -614,27 +628,19 @@ blume-tabs pre[data-language],
|
|
|
614
628
|
padding-top: ${CODE_PADDING_BLOCK_REM}rem;
|
|
615
629
|
}
|
|
616
630
|
|
|
617
|
-
/*
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
\`.astro-code\`, no language bar, but the injector gives it a button all
|
|
631
|
-
the same — keyed on that injected button, which is exactly what separates
|
|
632
|
-
it from the playground's pre. API panel blocks match, but their own
|
|
633
|
-
!important padding wins and they hide the injected button. */
|
|
634
|
-
[data-blume-code-copy] :is(blume-tabs, .not-prose) pre.astro-code,
|
|
635
|
-
[data-blume-code-copy] .prose pre.astro-code:not([data-language]),
|
|
636
|
-
[data-blume-code-copy] .prose pre:not([data-language]):has(> [data-blume-copy]) {
|
|
637
|
-
padding-top: ${FLUSH_CODE_PADDING_TOP_REM}rem;
|
|
631
|
+
/* Where no language bar holds the copy button — a flush block in tabs or a
|
|
632
|
+
not-prose component, a bar-less block in prose — the docs layout still pins
|
|
633
|
+
one over the block's top-right corner, so a first line long enough to reach
|
|
634
|
+
it would end underneath. A code switcher (CodeGroup, ts2js) hosts the button
|
|
635
|
+
in its tab strip instead and needs nothing here; for every block that keeps
|
|
636
|
+
an overlay button, give the scrolling code element enough end padding that
|
|
637
|
+
the line's tail clears the button at the end of its scroll. Keyed on the
|
|
638
|
+
injected button itself (which the playground's client-created response pre
|
|
639
|
+
never gets), and \`.prose\`-scoped to outrank the base \`:where(pre code)\`
|
|
640
|
+
inset. Not a vertical inset — that reserved a strip above every one-line
|
|
641
|
+
command and read as a rendering bug. */
|
|
642
|
+
.prose :is(blume-tabs pre, .not-prose pre, pre:not([data-language])):not(.twoslash, .twoslash pre, blume-panel-tabs *):has(> [data-blume-copy]) > code {
|
|
643
|
+
padding-inline-end: 3.5rem;
|
|
638
644
|
}
|
|
639
645
|
|
|
640
646
|
blume-tabs pre[data-language]::before,
|