blume 1.1.2 → 1.1.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 +47 -0
- package/dist/cli/index.js +284 -109
- package/dist/cli/index.js.map +33 -33
- package/dist/types/ai/component-markdown.d.ts +10 -0
- package/dist/types/core/config-input.d.ts +44 -0
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +24 -24
- package/dist/types/core/schema.d.ts +282 -114
- package/dist/types/core/types.d.ts +14 -0
- package/dist/types/openapi/references.d.ts +5 -0
- package/docs/advanced/api-reference.mdx +20 -0
- package/docs/configuration/index.mdx +27 -0
- package/package.json +1 -1
- package/src/ai/component-markdown.ts +28 -0
- package/src/ai/llms.ts +11 -2
- package/src/ai/markdown.ts +12 -6
- package/src/ai/mcp/server.ts +29 -6
- package/src/astro/examples.ts +13 -0
- package/src/astro/generate.ts +141 -58
- package/src/astro/templates.ts +65 -21
- package/src/audit/checks/duplicates.ts +15 -6
- package/src/audit/checks/indexability.ts +11 -2
- package/src/audit/checks/network.ts +22 -8
- package/src/audit/checks/sitemap.ts +42 -16
- package/src/audit/redirects.ts +12 -1
- package/src/audit/run.ts +13 -3
- package/src/audit/url.ts +21 -2
- package/src/cli/commands/audit.ts +21 -6
- package/src/cli/commands/dev.ts +19 -2
- package/src/components/content/Frame.astro +4 -1
- package/src/components/content/Prompt.astro +4 -1
- package/src/components/content/Tooltip.astro +4 -1
- package/src/components/content/Update.astro +45 -0
- package/src/components/islands/ask-ai.tsx +19 -2
- package/src/components/islands/hooks.ts +38 -11
- package/src/components/layout/Logo.astro +2 -2
- package/src/components/layout/RootLayout.astro +27 -7
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/components/openapi/ApiTagOperations.astro +17 -8
- package/src/core/config-input.ts +45 -0
- package/src/core/data.ts +2 -0
- package/src/core/date-format.ts +17 -0
- package/src/core/deployment-env.ts +7 -2
- package/src/core/graph.ts +7 -1
- package/src/core/i18n.ts +10 -2
- package/src/core/navigation.ts +7 -3
- package/src/core/project-graph.ts +9 -0
- package/src/core/schema.ts +64 -0
- package/src/core/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/core/types.ts +16 -0
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- package/src/openapi/references.ts +6 -0
- package/src/openapi/render-mdx.ts +12 -7
- package/src/openapi/scalar.ts +4 -0
- package/src/registry/eject.ts +6 -3
- package/src/theme/entry.ts +7 -0
- package/src/theme/twoslash.ts +10 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pre-paint inline scripts shared by the document layouts (`RootLayout`,
|
|
3
|
-
* `PageLayout`, `ReferenceLayout`). They run synchronously in `<head>`,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* `PageLayout`, `ReferenceLayout`). They run synchronously — in `<head>`, or
|
|
4
|
+
* immediately after the markup they act on — before that content paints, so the
|
|
5
|
+
* page never flashes the wrong theme, a since-dismissed banner, or a sidebar
|
|
6
|
+
* scrolled away from the current page. Kept in one place so the layouts can't
|
|
7
|
+
* drift on this timing-critical logic.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* All are constants, never built by interpolating config into source text: any
|
|
9
10
|
* values they need ride in as `data-*` attributes on the script tag and are read
|
|
10
11
|
* back through `document.currentScript`. Baking a config string into JS — even
|
|
11
12
|
* via `JSON.stringify` — is code construction, and JSON escaping does not cover
|
|
@@ -27,3 +28,19 @@ export const THEME_INIT_SCRIPT = `(()=>{const m=document.currentScript?.dataset.
|
|
|
27
28
|
* Reads `data-key` — the banner's dismissal key.
|
|
28
29
|
*/
|
|
29
30
|
export const BANNER_INIT_SCRIPT = `(()=>{const k=document.currentScript?.dataset.key;if(k&&localStorage.getItem("blume-banner:"+k))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Center the current page's sidebar link before the sidebar paints. Every
|
|
34
|
+
* navigation is a full page load, and the sidebar is its own scroll container,
|
|
35
|
+
* so without this it is reborn scrolled to the top on every click — on a long
|
|
36
|
+
* sidebar the viewport visibly jumps away from the link you just clicked.
|
|
37
|
+
*
|
|
38
|
+
* Runs inline immediately after the sidebar `<aside>` (not in `<head>`: it
|
|
39
|
+
* needs that markup parsed). The lookup is scoped to the page tree
|
|
40
|
+
* (`data-blume-nav-tree`) because the drawer also holds the mobile tabs list,
|
|
41
|
+
* whose active tab is `aria-current` too. `getClientRects()` skips links that
|
|
42
|
+
* aren't rendered — `hidden` drill-in panels and breakpoint-hidden duplicates —
|
|
43
|
+
* and the script no-ops when the active link is already inside the visible
|
|
44
|
+
* scroll area, so a short sidebar never moves.
|
|
45
|
+
*/
|
|
46
|
+
export const SIDEBAR_SCROLL_INIT_SCRIPT = `(()=>{const n=document.querySelector("[data-blume-nav-drawer]");const s=n&&(n.querySelector("[data-blume-nav-tree]")||n);if(!s)return;let l=null;for(const a of s.querySelectorAll('a[aria-current="page"]')){if(a.getClientRects().length){l=a;break;}}if(!l)return;const r=n.getBoundingClientRect();const t=l.getBoundingClientRect();if(t.top>=r.top&&t.bottom<=r.bottom)return;n.scrollTop+=t.top-r.top-(n.clientHeight-t.height)/2;})();`;
|
|
@@ -25,16 +25,25 @@ const operations = Object.values(specs[source]?.operations ?? {}).filter(
|
|
|
25
25
|
{operations.map((operation) => (
|
|
26
26
|
<li>
|
|
27
27
|
<a
|
|
28
|
-
class="flex items-
|
|
28
|
+
class="flex items-start gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
|
|
29
29
|
href={withBase(operation.route)}
|
|
30
30
|
>
|
|
31
|
-
<MethodBadge method={operation.method} />
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
<MethodBadge class="mt-0.5 shrink-0" method={operation.method} />
|
|
32
|
+
{/* Title over path, stacked, so a long summary and a long route each
|
|
33
|
+
get the full row width instead of being squeezed side by side. */}
|
|
34
|
+
<div class="flex flex-col gap-0.5">
|
|
35
|
+
<span class="break-words font-medium text-foreground text-sm">
|
|
36
|
+
{operation.summary || operation.path}
|
|
37
|
+
</span>
|
|
38
|
+
{/* The path doubles as the label when the spec sets no summary, so
|
|
39
|
+
only repeat it as the reference line when it adds information;
|
|
40
|
+
break-all keeps a long route wrapping inside the card. */}
|
|
41
|
+
{operation.summary && (
|
|
42
|
+
<code class="break-all text-muted-foreground text-xs">
|
|
43
|
+
{operation.path}
|
|
44
|
+
</code>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
38
47
|
</a>
|
|
39
48
|
</li>
|
|
40
49
|
))}
|
package/src/core/config-input.ts
CHANGED
|
@@ -897,6 +897,13 @@ export interface OpenApiConfig {
|
|
|
897
897
|
renderer?: "blume" | "scalar";
|
|
898
898
|
/** Where the reference mounts. Defaults to `/reference`. */
|
|
899
899
|
route?: string;
|
|
900
|
+
/**
|
|
901
|
+
* Extra Scalar options forwarded verbatim to the embedded `<ScalarComponent>`
|
|
902
|
+
* (Scalar renderer only) — e.g. `localization`, `agent`,
|
|
903
|
+
* `hideTestRequestButton`, `orderSchemaPropertiesBy`. These win over Blume's
|
|
904
|
+
* derived spec/theme config, so it's a full escape hatch to Scalar's API.
|
|
905
|
+
*/
|
|
906
|
+
scalar?: Record<string, unknown>;
|
|
900
907
|
/** One or more specs; each renders on its own route by default. */
|
|
901
908
|
sources?: OpenApiSource[];
|
|
902
909
|
/** Shorthand for a single source: `sources: [{ spec }]`. */
|
|
@@ -915,6 +922,12 @@ export interface AsyncApiConfig {
|
|
|
915
922
|
enabled?: boolean;
|
|
916
923
|
/** Where the reference mounts. Defaults to `/events`. */
|
|
917
924
|
route?: string;
|
|
925
|
+
/**
|
|
926
|
+
* Extra Scalar options forwarded verbatim to the embedded `<ScalarComponent>`.
|
|
927
|
+
* These win over Blume's derived spec/theme config — a full escape hatch to
|
|
928
|
+
* Scalar's API.
|
|
929
|
+
*/
|
|
930
|
+
scalar?: Record<string, unknown>;
|
|
918
931
|
/** One or more specs. */
|
|
919
932
|
sources?: OpenApiSource[];
|
|
920
933
|
/** Shorthand for a single source. */
|
|
@@ -1003,6 +1016,33 @@ export type LastModifiedConfig =
|
|
|
1003
1016
|
type?: "git" | "frontmatter";
|
|
1004
1017
|
};
|
|
1005
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* Date presentation for the "last updated" stamp and the changelog timeline —
|
|
1021
|
+
* a curated pass-through to `Intl.DateTimeFormat`, shared by both surfaces.
|
|
1022
|
+
* Defaults to `{ dateStyle: "long" }`. Dates render in UTC unless `timeZone` is
|
|
1023
|
+
* set. `dateStyle` is a preset and can't be combined with the component fields.
|
|
1024
|
+
*/
|
|
1025
|
+
export interface DateFormatConfig {
|
|
1026
|
+
/** Preset date length; mutually exclusive with the component fields below. */
|
|
1027
|
+
dateStyle?: "full" | "long" | "medium" | "short";
|
|
1028
|
+
/** Weekday representation. */
|
|
1029
|
+
weekday?: "long" | "short" | "narrow";
|
|
1030
|
+
/** Era representation (e.g. the Japanese imperial era). */
|
|
1031
|
+
era?: "long" | "short" | "narrow";
|
|
1032
|
+
/** Year representation. */
|
|
1033
|
+
year?: "numeric" | "2-digit";
|
|
1034
|
+
/** Month representation. */
|
|
1035
|
+
month?: "numeric" | "2-digit" | "long" | "short" | "narrow";
|
|
1036
|
+
/** Day representation. */
|
|
1037
|
+
day?: "numeric" | "2-digit";
|
|
1038
|
+
/** IANA time zone (e.g. `Asia/Tokyo`). Defaults to `UTC`. */
|
|
1039
|
+
timeZone?: string;
|
|
1040
|
+
/** Calendar system (e.g. `japanese`, `buddhist`). */
|
|
1041
|
+
calendar?: string;
|
|
1042
|
+
/** Numbering system (e.g. `latn`, `arab`). */
|
|
1043
|
+
numberingSystem?: string;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1006
1046
|
/**
|
|
1007
1047
|
* On-page table of contents. `true`/`false` toggles it; the object form narrows
|
|
1008
1048
|
* the heading range. Defaults to on, H2–H3.
|
|
@@ -1046,6 +1086,11 @@ export interface BlumeConfig {
|
|
|
1046
1086
|
basePath?: string;
|
|
1047
1087
|
/** Where content lives and how it's discovered. */
|
|
1048
1088
|
content?: ContentConfig;
|
|
1089
|
+
/**
|
|
1090
|
+
* Date presentation for the "last updated" stamp and the changelog timeline.
|
|
1091
|
+
* Pass-through `Intl.DateTimeFormat` options; defaults to `{ dateStyle: "long" }`.
|
|
1092
|
+
*/
|
|
1093
|
+
dateFormat?: DateFormatConfig;
|
|
1049
1094
|
/** Where and how the site deploys (site URL, adapter, output mode). */
|
|
1050
1095
|
deployment?: DeploymentConfig;
|
|
1051
1096
|
/** Default meta description, used where a page sets none. */
|
package/src/core/data.ts
CHANGED
|
@@ -104,6 +104,8 @@ export interface BlumeDataConfig {
|
|
|
104
104
|
codeThemes: ResolvedConfig["markdown"]["codeBlocks"]["theme"];
|
|
105
105
|
/** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
|
|
106
106
|
codeWrap: boolean;
|
|
107
|
+
/** `dateFormat`: `Intl.DateTimeFormat` options for the date stamps. */
|
|
108
|
+
dateFormat: ResolvedConfig["dateFormat"];
|
|
107
109
|
description: string | undefined;
|
|
108
110
|
favicon: BlumeFavicon;
|
|
109
111
|
feedback: boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResolvedDateFormat } from "./schema.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The default date presentation — the long form (`July 21, 2026`,
|
|
5
|
+
* `2026年7月21日`) both stamps used before `dateFormat` was configurable.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_DATE_FORMAT: ResolvedDateFormat = { dateStyle: "long" };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a configured `dateFormat` into `Intl.DateTimeFormat` options for the
|
|
11
|
+
* per-page "last updated" stamp and the changelog timeline. Both surfaces call
|
|
12
|
+
* this so they format alike. Dates render in UTC unless the config names a
|
|
13
|
+
* `timeZone`, so a stamp reads the same regardless of the build machine's zone.
|
|
14
|
+
*/
|
|
15
|
+
export const resolveDateFormatOptions = (
|
|
16
|
+
format: ResolvedDateFormat = DEFAULT_DATE_FORMAT
|
|
17
|
+
): Intl.DateTimeFormatOptions => ({ timeZone: "UTC", ...format });
|
|
@@ -30,12 +30,17 @@ const PLATFORMS: Platform[] = [
|
|
|
30
30
|
{
|
|
31
31
|
adapter: "vercel",
|
|
32
32
|
detect: (env) => Boolean(env.VERCEL),
|
|
33
|
-
|
|
33
|
+
// Fall through per *resolved* value, not per variable — a platform can set
|
|
34
|
+
// a var to the empty string, which `??` on the raw values treats as
|
|
35
|
+
// present, dead-ending the chain and silently losing the site URL.
|
|
36
|
+
site: (env) =>
|
|
37
|
+
toUrl(env.VERCEL_PROJECT_PRODUCTION_URL) ?? toUrl(env.VERCEL_URL),
|
|
34
38
|
},
|
|
35
39
|
{
|
|
36
40
|
adapter: "netlify",
|
|
37
41
|
detect: (env) => Boolean(env.NETLIFY),
|
|
38
|
-
site: (env) =>
|
|
42
|
+
site: (env) =>
|
|
43
|
+
toUrl(env.URL) ?? toUrl(env.DEPLOY_PRIME_URL) ?? toUrl(env.DEPLOY_URL),
|
|
39
44
|
},
|
|
40
45
|
{
|
|
41
46
|
adapter: "cloudflare",
|
package/src/core/graph.ts
CHANGED
|
@@ -116,7 +116,13 @@ const buildLocaleNavigation = (
|
|
|
116
116
|
basePath: options.basePath ?? "",
|
|
117
117
|
diagnostics,
|
|
118
118
|
display: options.navigation.sidebar.display,
|
|
119
|
-
featured
|
|
119
|
+
// Internal featured hrefs are localized like tab paths — a pinned
|
|
120
|
+
// `/changelog` link rendered on `/fr/…` pages must stay inside the
|
|
121
|
+
// reader's locale, not kick them back to the default one.
|
|
122
|
+
featured: options.navigation.featured?.map((link) => ({
|
|
123
|
+
...link,
|
|
124
|
+
href: localizePath(link.href),
|
|
125
|
+
})),
|
|
120
126
|
folderMeta: options.folderMeta,
|
|
121
127
|
// The localized tree root ("/" for the hidden default, "/fr" otherwise):
|
|
122
128
|
// the tab pointing here spans the whole tree and must not be treated as a
|
package/src/core/i18n.ts
CHANGED
|
@@ -105,11 +105,19 @@ export const localePlacement = (
|
|
|
105
105
|
): { navPath: string; locales: string[] } => {
|
|
106
106
|
const base = rel.slice(0, rel.length - ext.length);
|
|
107
107
|
|
|
108
|
-
// Shared `$` file: the same content in every locale.
|
|
108
|
+
// Shared `$` file: the same content in every locale. A shared file placed
|
|
109
|
+
// inside a locale directory (`fr/changelog.$.mdx`) still sheds that
|
|
110
|
+
// directory from its nav path — otherwise every locale's record would route
|
|
111
|
+
// under `/fr/…`, nesting the default locale inside the French namespace and
|
|
112
|
+
// the French copy at `/fr/fr/…`.
|
|
109
113
|
if (base.endsWith(".$")) {
|
|
114
|
+
const shared = `${base.slice(0, -2)}${ext}`;
|
|
110
115
|
return {
|
|
111
116
|
locales: i18n.locales.map((locale) => locale.code),
|
|
112
|
-
navPath:
|
|
117
|
+
navPath:
|
|
118
|
+
i18n.parser === "dir"
|
|
119
|
+
? detectLocale(shared.split("/"), i18n).rest.join("/")
|
|
120
|
+
: shared,
|
|
113
121
|
};
|
|
114
122
|
}
|
|
115
123
|
|
package/src/core/navigation.ts
CHANGED
|
@@ -473,9 +473,13 @@ const normalizeRef = (ref: string): string => {
|
|
|
473
473
|
return "/";
|
|
474
474
|
}
|
|
475
475
|
const withSlash = ref.startsWith("/") ? ref : `/${ref}`;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
476
|
+
// Routes are stored slashless (`/guides`, not `/guides/`); a hand-written
|
|
477
|
+
// `"guides/"` ref must still find its page instead of being silently
|
|
478
|
+
// dropped from the sidebar.
|
|
479
|
+
const noTrailing = withSlash.replace(/\/+$/u, "");
|
|
480
|
+
const trimmed = noTrailing.endsWith("/index")
|
|
481
|
+
? noTrailing.slice(0, -"/index".length)
|
|
482
|
+
: noTrailing;
|
|
479
483
|
// "/index" trims to "" — that's the root, not an empty route.
|
|
480
484
|
return trimmed === "" ? "/" : trimmed;
|
|
481
485
|
};
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
BlumeManifest,
|
|
21
21
|
ContentGraph,
|
|
22
22
|
Diagnostic,
|
|
23
|
+
ExampleLookup,
|
|
23
24
|
PageRecord,
|
|
24
25
|
ProjectContext,
|
|
25
26
|
} from "./types.ts";
|
|
@@ -74,6 +75,14 @@ export interface BlumeProject {
|
|
|
74
75
|
droppedPages: number;
|
|
75
76
|
/** The instantiated content sources, for lazy entry reads (search/AI/raw). */
|
|
76
77
|
sources: ContentSource[];
|
|
78
|
+
/**
|
|
79
|
+
* Discovered `examples/` sources keyed by `<Component path>`, attached by the
|
|
80
|
+
* runtime/eject layer after {@link scanProject} (example discovery is an Astro
|
|
81
|
+
* concern, so core doesn't run it). Undefined until then; the agent-facing
|
|
82
|
+
* Markdown downleveler reads it to turn `<Component path="…" />` into the
|
|
83
|
+
* example's source. Empty when the project has no examples.
|
|
84
|
+
*/
|
|
85
|
+
examples?: ExampleLookup;
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
/**
|
package/src/core/schema.ts
CHANGED
|
@@ -1028,6 +1028,49 @@ const lastModifiedConfigSchema = z.union([
|
|
|
1028
1028
|
z.strictObject({ type: z.enum(["git", "frontmatter"]).default("git") }),
|
|
1029
1029
|
]);
|
|
1030
1030
|
|
|
1031
|
+
/**
|
|
1032
|
+
* How the "last updated" stamp and the changelog timeline render their dates —
|
|
1033
|
+
* a curated pass-through to `Intl.DateTimeFormat`, shared by both surfaces so
|
|
1034
|
+
* they read alike. Defaults to `{ dateStyle: "long" }`. Both stamps format in
|
|
1035
|
+
* UTC unless a `timeZone` is given, so a date reads the same regardless of the
|
|
1036
|
+
* build machine's zone. `dateStyle` is a preset that can't be combined with the
|
|
1037
|
+
* individual component fields (`year`, `month`, …), matching `Intl`'s own rule.
|
|
1038
|
+
*/
|
|
1039
|
+
const dateFormatConfigSchema = z
|
|
1040
|
+
.strictObject({
|
|
1041
|
+
/** Calendar system (e.g. `japanese`, `buddhist`). */
|
|
1042
|
+
calendar: z.string().optional(),
|
|
1043
|
+
/** Preset date length; mutually exclusive with the component fields. */
|
|
1044
|
+
dateStyle: z.enum(["full", "long", "medium", "short"]).optional(),
|
|
1045
|
+
/** Day representation. */
|
|
1046
|
+
day: z.enum(["numeric", "2-digit"]).optional(),
|
|
1047
|
+
/** Era representation (e.g. the Japanese imperial era). */
|
|
1048
|
+
era: z.enum(["long", "short", "narrow"]).optional(),
|
|
1049
|
+
/** Month representation. */
|
|
1050
|
+
month: z.enum(["numeric", "2-digit", "long", "short", "narrow"]).optional(),
|
|
1051
|
+
/** Numbering system (e.g. `latn`, `arab`). */
|
|
1052
|
+
numberingSystem: z.string().optional(),
|
|
1053
|
+
/** IANA time zone (e.g. `Asia/Tokyo`). Defaults to `UTC`. */
|
|
1054
|
+
timeZone: z.string().optional(),
|
|
1055
|
+
/** Weekday representation. */
|
|
1056
|
+
weekday: z.enum(["long", "short", "narrow"]).optional(),
|
|
1057
|
+
/** Year representation. */
|
|
1058
|
+
year: z.enum(["numeric", "2-digit"]).optional(),
|
|
1059
|
+
})
|
|
1060
|
+
.refine(
|
|
1061
|
+
(value) =>
|
|
1062
|
+
value.dateStyle === undefined ||
|
|
1063
|
+
(value.weekday === undefined &&
|
|
1064
|
+
value.era === undefined &&
|
|
1065
|
+
value.year === undefined &&
|
|
1066
|
+
value.month === undefined &&
|
|
1067
|
+
value.day === undefined),
|
|
1068
|
+
{
|
|
1069
|
+
message:
|
|
1070
|
+
"dateFormat.dateStyle can't be combined with weekday/era/year/month/day; use one or the other.",
|
|
1071
|
+
}
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1031
1074
|
/** Code-block rendering options (`markdown.code`). */
|
|
1032
1075
|
const codeConfigSchema = z.strictObject({
|
|
1033
1076
|
/**
|
|
@@ -1085,6 +1128,16 @@ const openapiSourceSchema = z.strictObject({
|
|
|
1085
1128
|
|
|
1086
1129
|
export type OpenApiSource = z.infer<typeof openapiSourceSchema>;
|
|
1087
1130
|
|
|
1131
|
+
/**
|
|
1132
|
+
* Arbitrary Scalar API-reference options forwarded verbatim to the generated
|
|
1133
|
+
* `<ScalarComponent>` (Scalar renderer only). A passthrough map — Blume doesn't
|
|
1134
|
+
* mirror Scalar's full config surface — so keys like `localization`, `agent`,
|
|
1135
|
+
* `hideTestRequestButton`, or `orderSchemaPropertiesBy` all flow through. These
|
|
1136
|
+
* take precedence over Blume's own derived config (spec, theme), so this is a
|
|
1137
|
+
* full escape hatch; the dedicated `theme` field is the ergonomic shorthand.
|
|
1138
|
+
*/
|
|
1139
|
+
const scalarConfigSchema = z.record(z.string(), z.unknown()).optional();
|
|
1140
|
+
|
|
1088
1141
|
/**
|
|
1089
1142
|
* OpenAPI reference. By default (`renderer: "blume"`) Blume parses the spec with
|
|
1090
1143
|
* Scalar's parser and renders its own UI: one real page per operation, grouped
|
|
@@ -1102,6 +1155,8 @@ const openapiConfigSchema = z.strictObject({
|
|
|
1102
1155
|
renderer: z.enum(["blume", "scalar"]).default("blume"),
|
|
1103
1156
|
/** Where the reference mounts. */
|
|
1104
1157
|
route: z.string().default("/reference"),
|
|
1158
|
+
/** Extra Scalar config forwarded to `<ScalarComponent>` (Scalar renderer only). */
|
|
1159
|
+
scalar: scalarConfigSchema,
|
|
1105
1160
|
/** One or more specs; each renders on its own route by default. */
|
|
1106
1161
|
sources: z.array(openapiSourceSchema).default([]),
|
|
1107
1162
|
/** Shorthand for a single source: `sources: [{ spec }]`. */
|
|
@@ -1117,6 +1172,8 @@ const openapiConfigSchema = z.strictObject({
|
|
|
1117
1172
|
const asyncapiConfigSchema = z.strictObject({
|
|
1118
1173
|
enabled: z.boolean().default(false),
|
|
1119
1174
|
route: z.string().default("/events"),
|
|
1175
|
+
/** Extra Scalar config forwarded to `<ScalarComponent>`. */
|
|
1176
|
+
scalar: scalarConfigSchema,
|
|
1120
1177
|
sources: z.array(openapiSourceSchema).default([]),
|
|
1121
1178
|
spec: z.string().optional(),
|
|
1122
1179
|
theme: z.string().optional(),
|
|
@@ -1205,6 +1262,11 @@ export const blumeConfigSchema = z.strictObject({
|
|
|
1205
1262
|
.optional()
|
|
1206
1263
|
.transform((value) => normalizeBasePath(value)),
|
|
1207
1264
|
content: contentConfigSchema.default({}),
|
|
1265
|
+
/**
|
|
1266
|
+
* Date presentation for the "last updated" stamp and the changelog timeline.
|
|
1267
|
+
* Pass-through `Intl.DateTimeFormat` options; defaults to `{ dateStyle: "long" }`.
|
|
1268
|
+
*/
|
|
1269
|
+
dateFormat: dateFormatConfigSchema.default({ dateStyle: "long" }),
|
|
1208
1270
|
deployment: deploymentConfigSchema.default({}),
|
|
1209
1271
|
description: z.string().optional(),
|
|
1210
1272
|
/**
|
|
@@ -1237,6 +1299,8 @@ export const blumeConfigSchema = z.strictObject({
|
|
|
1237
1299
|
|
|
1238
1300
|
/** Resolved config: every field present after defaults are applied. */
|
|
1239
1301
|
export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
|
|
1302
|
+
/** Resolved `dateFormat`: the `Intl.DateTimeFormat` options both date stamps share. */
|
|
1303
|
+
export type ResolvedDateFormat = z.infer<typeof dateFormatConfigSchema>;
|
|
1240
1304
|
/** Resolved `frontmatter.extend`: custom key → user-supplied schema. */
|
|
1241
1305
|
export type FrontmatterExtend = Record<string, StandardSchema>;
|
|
1242
1306
|
/** Resolved i18n block (present only when the project opts into i18n). */
|
|
@@ -38,6 +38,15 @@ export const slugify = (text: string): string =>
|
|
|
38
38
|
.replaceAll(/-+/gu, "-")
|
|
39
39
|
.replaceAll(/^-|-$/gu, "");
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* {@link slugify} for a slug that may span path segments (`guides/setup`).
|
|
43
|
+
* `slugify` deletes `/` along with all other punctuation, which would mash
|
|
44
|
+
* `guides/setup` into `guidessetup` — and collide it with a genuine `guidessetup`
|
|
45
|
+
* document. Each segment is slugged on its own and the separators kept.
|
|
46
|
+
*/
|
|
47
|
+
export const slugifyPath = (text: string): string =>
|
|
48
|
+
text.split("/").map(slugify).filter(Boolean).join("/");
|
|
49
|
+
|
|
41
50
|
/** Title-case a slug segment for display. */
|
|
42
51
|
const titleCase = (value: string): string =>
|
|
43
52
|
value
|
|
@@ -104,13 +113,24 @@ type FenceState = "```" | "~~~" | null;
|
|
|
104
113
|
* the state untouched.
|
|
105
114
|
*/
|
|
106
115
|
const nextFenceState = (line: string, fence: FenceState): FenceState => {
|
|
107
|
-
const
|
|
116
|
+
const trimmed = line.trimStart();
|
|
117
|
+
const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter as
|
|
108
118
|
| Exclude<FenceState, null>
|
|
109
119
|
| undefined;
|
|
110
120
|
if (delimiter === undefined) {
|
|
111
121
|
return fence;
|
|
112
122
|
}
|
|
113
123
|
if (fence === null) {
|
|
124
|
+
// A backtick fence's info string cannot itself contain a backtick
|
|
125
|
+
// (CommonMark) — a line-leading ```inline``` span is a paragraph, and
|
|
126
|
+
// opening a phantom fence on it would swallow every heading and link
|
|
127
|
+
// after it. Tilde fences carry no such rule.
|
|
128
|
+
if (delimiter === "```") {
|
|
129
|
+
const run = trimmed.match(/^`+/u)?.[0].length ?? 0;
|
|
130
|
+
if (trimmed.slice(run).includes("`")) {
|
|
131
|
+
return fence;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
114
134
|
return delimiter;
|
|
115
135
|
}
|
|
116
136
|
return fence === delimiter ? null : fence;
|
|
@@ -159,6 +179,13 @@ const linesWithoutFrontMatter = (body: string): string[] => {
|
|
|
159
179
|
if (!/^-{3}\s*$/u.test(lines[0] ?? "")) {
|
|
160
180
|
return lines;
|
|
161
181
|
}
|
|
182
|
+
// A blank line directly after the dashes means the body *opens* with a
|
|
183
|
+
// thematic break, not front matter — YAML metadata starts on the very next
|
|
184
|
+
// line. Treating it as an unclosed block ate everything up to the next
|
|
185
|
+
// `---`/`...` line of an already-stripped body.
|
|
186
|
+
if ((lines[1] ?? "").trim() === "") {
|
|
187
|
+
return lines;
|
|
188
|
+
}
|
|
162
189
|
const close = lines.findIndex(
|
|
163
190
|
(line, index) => index > 0 && FRONT_MATTER_CLOSE.test(line)
|
|
164
191
|
);
|
|
@@ -304,9 +331,26 @@ export const extractHeadings = (body: string): Heading[] => {
|
|
|
304
331
|
return headings;
|
|
305
332
|
};
|
|
306
333
|
|
|
307
|
-
|
|
334
|
+
// The label admits one level of nested brackets so an image-wrapped link
|
|
335
|
+
// (`[](/target)`) matches as the *outer* link — with a flat
|
|
336
|
+
// `[^\]]*` label the match stopped at the image's `]` and the outer target was
|
|
337
|
+
// never seen. The target admits one level of balanced parens so a Wikipedia-
|
|
338
|
+
// style URL (`/wiki/Foo_(bar)`) isn't truncated at its first `)`.
|
|
339
|
+
const MD_LINK =
|
|
340
|
+
/\[(?<label>(?:[^[\]]|\[[^\]]*\])*)\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
|
|
341
|
+
// An image inside a link label; its target was matched (and so validated) as a
|
|
342
|
+
// link of its own before labels admitted nesting, and still should be.
|
|
343
|
+
const MD_IMAGE =
|
|
344
|
+
/!\[[^\]]*\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
|
|
308
345
|
const INLINE_CODE = /`[^`]*`/gu;
|
|
309
346
|
|
|
347
|
+
/** Column (0-based, within `matched`) where a link/image match's target starts. */
|
|
348
|
+
const targetOffsetIn = (
|
|
349
|
+
matched: string,
|
|
350
|
+
target: string,
|
|
351
|
+
title: string | undefined
|
|
352
|
+
): number => matched.length - 1 - (title?.length ?? 0) - target.length;
|
|
353
|
+
|
|
310
354
|
/**
|
|
311
355
|
* Extract link targets from a markdown body for later validation, recording the
|
|
312
356
|
* 1-based line/column of each target. Skips fenced code blocks and inline code.
|
|
@@ -335,16 +379,33 @@ const scanLinkLine = (
|
|
|
335
379
|
if (target === undefined || match.index === undefined) {
|
|
336
380
|
continue;
|
|
337
381
|
}
|
|
338
|
-
// Locate the target from the
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
const targetOffset = match.index + match[0].indexOf("](") + "](".length;
|
|
382
|
+
// Locate the target by arithmetic from the match end rather than searching
|
|
383
|
+
// for its text — a label that contains the same text (e.g. `[/a/b](/a/b)`)
|
|
384
|
+
// would otherwise report the column inside the label.
|
|
385
|
+
const targetOffset = targetOffsetIn(match[0], target, match.groups?.title);
|
|
343
386
|
links.push({
|
|
344
|
-
column: targetOffset + 1,
|
|
387
|
+
column: match.index + targetOffset + 1,
|
|
345
388
|
line: lineNumber,
|
|
346
389
|
target,
|
|
347
390
|
});
|
|
391
|
+
// An image nested in the label (`[](/target)`) carries its
|
|
392
|
+
// own target; surface it too so a missing image is still caught.
|
|
393
|
+
const label = match[0].slice(0, targetOffset - "](".length);
|
|
394
|
+
for (const image of label.matchAll(MD_IMAGE)) {
|
|
395
|
+
const imageTarget = image.groups?.target;
|
|
396
|
+
if (imageTarget === undefined || image.index === undefined) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
links.push({
|
|
400
|
+
column:
|
|
401
|
+
match.index +
|
|
402
|
+
image.index +
|
|
403
|
+
targetOffsetIn(image[0], imageTarget, image.groups?.title) +
|
|
404
|
+
1,
|
|
405
|
+
line: lineNumber,
|
|
406
|
+
target: imageTarget,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
348
409
|
}
|
|
349
410
|
return next;
|
|
350
411
|
};
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
pollingWatch,
|
|
13
13
|
snapshotCache,
|
|
14
14
|
} from "./cache.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { slugifyPath } from "./normalize.ts";
|
|
16
16
|
import type {
|
|
17
17
|
ContentSource,
|
|
18
18
|
SourceContext,
|
|
@@ -432,7 +432,9 @@ export const notionSource = (
|
|
|
432
432
|
const slugProp = richToMarkdown(
|
|
433
433
|
page.properties[props.slug ?? "Slug"]?.rich_text
|
|
434
434
|
);
|
|
435
|
-
|
|
435
|
+
// Path-aware: a `guides/setup` slug keeps its `/` (per-segment slugging)
|
|
436
|
+
// instead of mashing into `guidessetup`.
|
|
437
|
+
const slug = slugifyPath(slugProp || title) || page.id;
|
|
436
438
|
return { data, slug };
|
|
437
439
|
};
|
|
438
440
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
pollingWatch,
|
|
9
9
|
snapshotCache,
|
|
10
10
|
} from "./cache.ts";
|
|
11
|
-
import { slugify } from "./normalize.ts";
|
|
11
|
+
import { slugify, slugifyPath } from "./normalize.ts";
|
|
12
12
|
import { portableTextToMarkdown } from "./portable-text.ts";
|
|
13
13
|
import type { PortableTextBlock } from "./portable-text.ts";
|
|
14
14
|
import type {
|
|
@@ -142,9 +142,11 @@ export const sanitySource = (
|
|
|
142
142
|
"untitled";
|
|
143
143
|
// Fall back to the unique `_id` when a slug (e.g. a non-ASCII `slug.current`)
|
|
144
144
|
// slugifies to empty, so distinct documents don't all collapse to the same
|
|
145
|
-
// `untitled.md` ref and silently overwrite each other.
|
|
145
|
+
// `untitled.md` ref and silently overwrite each other. Path-aware: a
|
|
146
|
+
// `guides/setup` slug keeps its `/` (per-segment slugging) instead of
|
|
147
|
+
// mashing into `guidessetup`.
|
|
146
148
|
const slug =
|
|
147
|
-
|
|
149
|
+
slugifyPath(slugValue) || slugify(asString(doc._id) ?? "") || "untitled";
|
|
148
150
|
|
|
149
151
|
const data: Record<string, unknown> = {};
|
|
150
152
|
const title = asString(getPath(doc, fields.title ?? "title"));
|
package/src/core/types.ts
CHANGED
|
@@ -31,6 +31,22 @@ export interface Diagnostic {
|
|
|
31
31
|
docsUrl?: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** One discovered `examples/` file reduced to what Markdown downleveling needs. */
|
|
35
|
+
export interface ExampleMarkdownEntry {
|
|
36
|
+
/** Shiki language for the fenced block — the file's extension. */
|
|
37
|
+
lang: string;
|
|
38
|
+
/** Raw example source, shown verbatim in the agent-facing code fence. */
|
|
39
|
+
source: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Discovered examples keyed by their `<Component path>` (the file's location
|
|
44
|
+
* under `examples/`, sans extension). Lets the agent-facing Markdown downlevel
|
|
45
|
+
* `<Component path="…" />` to the example's source, since the live preview
|
|
46
|
+
* can't survive the trip to plain Markdown.
|
|
47
|
+
*/
|
|
48
|
+
export type ExampleLookup = Record<string, ExampleMarkdownEntry>;
|
|
49
|
+
|
|
34
50
|
/** A heading extracted from page content, used for the TOC and search. */
|
|
35
51
|
export interface Heading {
|
|
36
52
|
depth: number;
|
|
@@ -51,7 +51,13 @@ const parseTitle = (raw: string | undefined): string | undefined => {
|
|
|
51
51
|
if (!raw) {
|
|
52
52
|
return undefined;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// Blank every *other* quoted attr first, so a `title="…"` embedded in
|
|
55
|
+
// another attribute's value (`caption='set title="X" here'`) can't be
|
|
56
|
+
// promoted to the block title.
|
|
57
|
+
const scrubbed = raw.replace(QUOTED_ATTR, (attr) =>
|
|
58
|
+
attr.startsWith("title=") ? attr : " "
|
|
59
|
+
);
|
|
60
|
+
const explicit = scrubbed.match(TITLE_ATTR);
|
|
55
61
|
const attrTitle = explicit?.groups?.dq ?? explicit?.groups?.sq;
|
|
56
62
|
if (attrTitle) {
|
|
57
63
|
return attrTitle;
|
package/src/openapi/model.ts
CHANGED
|
@@ -101,6 +101,32 @@ export type OpenApiData = Record<string, ApiSpecData>;
|
|
|
101
101
|
const isOperation = (value: unknown): value is OperationObject =>
|
|
102
102
|
typeof value === "object" && value !== null;
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Assign each distinct tag name a unique slug. `slugify` can collapse
|
|
106
|
+
* different names onto one value — any two all-non-ASCII tags (`ペット`,
|
|
107
|
+
* `注文`) both fall through to the `operations` fallback — and a shared slug
|
|
108
|
+
* silently merges the tags' routes, sidebar groups, and overview sections.
|
|
109
|
+
* Collisions gain `-2`, `-3`, … in first-seen order.
|
|
110
|
+
*/
|
|
111
|
+
const tagSlugger = (): ((name: string) => string) => {
|
|
112
|
+
const assigned = new Map<string, string>();
|
|
113
|
+
const taken = new Set<string>();
|
|
114
|
+
return (name) => {
|
|
115
|
+
const existing = assigned.get(name);
|
|
116
|
+
if (existing) {
|
|
117
|
+
return existing;
|
|
118
|
+
}
|
|
119
|
+
const base = slugify(name) || "operations";
|
|
120
|
+
let slug = base;
|
|
121
|
+
for (let suffix = 2; taken.has(slug); suffix += 1) {
|
|
122
|
+
slug = `${base}-${suffix}`;
|
|
123
|
+
}
|
|
124
|
+
taken.add(slug);
|
|
125
|
+
assigned.set(name, slug);
|
|
126
|
+
return slug;
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
104
130
|
/**
|
|
105
131
|
* Flatten a 3.1 document into a route-mapped operation list and its ordered
|
|
106
132
|
* tags. Operations inherit the first tag they declare; keys are de-duplicated so
|
|
@@ -119,6 +145,7 @@ export const extractOperations = (
|
|
|
119
145
|
);
|
|
120
146
|
const seen = new Set<string>();
|
|
121
147
|
const warnings: string[] = [];
|
|
148
|
+
const slugForTag = tagSlugger();
|
|
122
149
|
|
|
123
150
|
for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
|
|
124
151
|
const item = rawItem as PathItemObject | undefined;
|
|
@@ -137,7 +164,7 @@ export const extractOperations = (
|
|
|
137
164
|
continue;
|
|
138
165
|
}
|
|
139
166
|
const tag = operation.tags?.[0] ?? UNTAGGED;
|
|
140
|
-
const tagSlug =
|
|
167
|
+
const tagSlug = slugForTag(tag);
|
|
141
168
|
if (!tagsSeen.has(tag)) {
|
|
142
169
|
tagsSeen.add(tag);
|
|
143
170
|
tagOrder.push(tag);
|
|
@@ -166,7 +193,9 @@ export const extractOperations = (
|
|
|
166
193
|
const tags: ApiTagRef[] = tagOrder.map((name) => ({
|
|
167
194
|
description: tagMeta.get(name) ?? "",
|
|
168
195
|
name,
|
|
169
|
-
|
|
196
|
+
// The same slugger instance, so every tag resolves to the slug its
|
|
197
|
+
// operations were routed under.
|
|
198
|
+
slug: slugForTag(name),
|
|
170
199
|
}));
|
|
171
200
|
|
|
172
201
|
return { operations, tags, warnings };
|