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.
Files changed (61) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli/index.js +284 -109
  3. package/dist/cli/index.js.map +33 -33
  4. package/dist/types/ai/component-markdown.d.ts +10 -0
  5. package/dist/types/core/config-input.d.ts +44 -0
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +24 -24
  8. package/dist/types/core/schema.d.ts +282 -114
  9. package/dist/types/core/types.d.ts +14 -0
  10. package/dist/types/openapi/references.d.ts +5 -0
  11. package/docs/advanced/api-reference.mdx +20 -0
  12. package/docs/configuration/index.mdx +27 -0
  13. package/package.json +1 -1
  14. package/src/ai/component-markdown.ts +28 -0
  15. package/src/ai/llms.ts +11 -2
  16. package/src/ai/markdown.ts +12 -6
  17. package/src/ai/mcp/server.ts +29 -6
  18. package/src/astro/examples.ts +13 -0
  19. package/src/astro/generate.ts +141 -58
  20. package/src/astro/templates.ts +65 -21
  21. package/src/audit/checks/duplicates.ts +15 -6
  22. package/src/audit/checks/indexability.ts +11 -2
  23. package/src/audit/checks/network.ts +22 -8
  24. package/src/audit/checks/sitemap.ts +42 -16
  25. package/src/audit/redirects.ts +12 -1
  26. package/src/audit/run.ts +13 -3
  27. package/src/audit/url.ts +21 -2
  28. package/src/cli/commands/audit.ts +21 -6
  29. package/src/cli/commands/dev.ts +19 -2
  30. package/src/components/content/Frame.astro +4 -1
  31. package/src/components/content/Prompt.astro +4 -1
  32. package/src/components/content/Tooltip.astro +4 -1
  33. package/src/components/content/Update.astro +45 -0
  34. package/src/components/islands/ask-ai.tsx +19 -2
  35. package/src/components/islands/hooks.ts +38 -11
  36. package/src/components/layout/Logo.astro +2 -2
  37. package/src/components/layout/RootLayout.astro +27 -7
  38. package/src/components/layout/Search.astro +5 -1
  39. package/src/components/layout/head-scripts.ts +22 -5
  40. package/src/components/openapi/ApiTagOperations.astro +17 -8
  41. package/src/core/config-input.ts +45 -0
  42. package/src/core/data.ts +2 -0
  43. package/src/core/date-format.ts +17 -0
  44. package/src/core/deployment-env.ts +7 -2
  45. package/src/core/graph.ts +7 -1
  46. package/src/core/i18n.ts +10 -2
  47. package/src/core/navigation.ts +7 -3
  48. package/src/core/project-graph.ts +9 -0
  49. package/src/core/schema.ts +64 -0
  50. package/src/core/sources/normalize.ts +69 -8
  51. package/src/core/sources/notion.ts +4 -2
  52. package/src/core/sources/sanity.ts +5 -3
  53. package/src/core/types.ts +16 -0
  54. package/src/markdown/code-title.ts +7 -1
  55. package/src/openapi/model.ts +31 -2
  56. package/src/openapi/references.ts +6 -0
  57. package/src/openapi/render-mdx.ts +12 -7
  58. package/src/openapi/scalar.ts +4 -0
  59. package/src/registry/eject.ts +6 -3
  60. package/src/theme/entry.ts +7 -0
  61. 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>`, before
4
- * first paint, so the page never flashes the wrong theme or a since-dismissed
5
- * banner. Kept in one place so the layouts can't drift on this timing-critical
6
- * logic.
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
- * Both are constants, never built by interpolating config into source text: the
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-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
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
- <span class="font-medium text-foreground text-sm">
33
- {operation.summary || operation.path}
34
- </span>
35
- <code class="ml-auto hidden text-muted-foreground text-xs sm:inline">
36
- {operation.path}
37
- </code>
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
  ))}
@@ -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
- site: (env) => toUrl(env.VERCEL_PROJECT_PRODUCTION_URL ?? env.VERCEL_URL),
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) => toUrl(env.URL ?? env.DEPLOY_PRIME_URL ?? env.DEPLOY_URL),
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: options.navigation.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: `${base.slice(0, -2)}${ext}`,
117
+ navPath:
118
+ i18n.parser === "dir"
119
+ ? detectLocale(shared.split("/"), i18n).rest.join("/")
120
+ : shared,
113
121
  };
114
122
  }
115
123
 
@@ -473,9 +473,13 @@ const normalizeRef = (ref: string): string => {
473
473
  return "/";
474
474
  }
475
475
  const withSlash = ref.startsWith("/") ? ref : `/${ref}`;
476
- const trimmed = withSlash.endsWith("/index")
477
- ? withSlash.slice(0, -"/index".length)
478
- : withSlash;
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
  /**
@@ -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 delimiter = line.trimStart().match(CODE_FENCE)?.groups?.delimiter as
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
- const MD_LINK = /\[[^\]]*\]\((?<target>[^)\s]+)(?:\s+"[^"]*")?\)/gu;
334
+ // The label admits one level of nested brackets so an image-wrapped link
335
+ // (`[![alt](/img.png)](/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 `](` boundary rather than searching for the
339
- // target text from the match start otherwise a label that contains the
340
- // same text (e.g. `[/a/b](/a/b)`) reports the column inside the label. The
341
- // label can't contain `]`, so `](` is unambiguous.
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 (`[![alt](/img.png)](/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 { slugify } from "./normalize.ts";
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
- const slug = slugify(slugProp || title) || page.id;
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
- slugify(slugValue) || slugify(asString(doc._id) ?? "") || "untitled";
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
- const explicit = raw.match(TITLE_ATTR);
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;
@@ -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 = slugify(tag) || "operations";
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
- slug: slugify(name) || "operations",
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 };