blume 0.6.1 → 0.6.2

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 (51) hide show
  1. package/dist/cli/index.js +5955 -5733
  2. package/dist/cli/index.js.map +37 -37
  3. package/dist/types/core/config-input.d.ts +1 -11
  4. package/dist/types/core/schema.d.ts +4 -4
  5. package/dist/types/core/sources/types.d.ts +6 -0
  6. package/package.json +1 -1
  7. package/src/astro/generate.ts +23 -13
  8. package/src/astro/markdown-negotiation.ts +12 -3
  9. package/src/astro/templates.ts +28 -7
  10. package/src/cli/commands/dev.ts +30 -14
  11. package/src/cli/commands/doctor.ts +35 -7
  12. package/src/cli/commands/sync.ts +14 -2
  13. package/src/cli/dev-lock.ts +40 -10
  14. package/src/cli/env.ts +5 -1
  15. package/src/components/islands/ask-ai.tsx +3 -1
  16. package/src/components/islands/hooks.ts +5 -1
  17. package/src/components/layout/Header.astro +10 -2
  18. package/src/components/layout/NavSelector.astro +5 -3
  19. package/src/components/layout/PageLayout.astro +2 -1
  20. package/src/components/layout/ReferenceLayout.astro +1 -0
  21. package/src/components/layout/RootLayout.astro +16 -2
  22. package/src/components/layout/Search.astro +8 -3
  23. package/src/components/layout/nav-utils.ts +7 -3
  24. package/src/core/config-input.ts +1 -11
  25. package/src/core/i18n.ts +6 -5
  26. package/src/core/links.ts +16 -1
  27. package/src/core/meta.ts +112 -52
  28. package/src/core/navigation.ts +15 -5
  29. package/src/core/project-graph.ts +68 -2
  30. package/src/core/schema.ts +2 -1
  31. package/src/core/sources/assets.ts +21 -5
  32. package/src/core/sources/cache.ts +19 -1
  33. package/src/core/sources/github-releases.ts +9 -3
  34. package/src/core/sources/mdx-remote.ts +14 -4
  35. package/src/core/sources/normalize.ts +13 -1
  36. package/src/core/sources/notion.ts +43 -7
  37. package/src/core/sources/resolve.ts +44 -1
  38. package/src/core/sources/sanity.ts +9 -3
  39. package/src/core/sources/types.ts +6 -0
  40. package/src/deploy/rss.ts +3 -1
  41. package/src/markdown/code-title.ts +11 -4
  42. package/src/markdown/package-commands.ts +13 -0
  43. package/src/og/card.ts +3 -1
  44. package/src/openapi/model.ts +2 -1
  45. package/src/openapi/parse.ts +9 -1
  46. package/src/openapi/references.ts +11 -1
  47. package/src/openapi/render-mdx.ts +30 -3
  48. package/src/openapi/source.ts +3 -1
  49. package/src/search/documents.ts +4 -1
  50. package/src/theme/entry.ts +3 -0
  51. package/src/theme/icons.ts +7 -11
@@ -145,7 +145,9 @@ export const githubReleasesSource = (
145
145
  return collected.slice(0, max);
146
146
  };
147
147
 
148
- const load = async (): Promise<SourceLoadResult> => {
148
+ const load = async (
149
+ refresh = ctx.refresh ?? true
150
+ ): Promise<SourceLoadResult> => {
149
151
  try {
150
152
  const result = await loadWithCache(
151
153
  options.name,
@@ -154,7 +156,7 @@ export const githubReleasesSource = (
154
156
  const releases = await fetchReleases();
155
157
  return releases.map(releaseToEntry);
156
158
  },
157
- ctx.refresh ?? true
159
+ refresh
158
160
  );
159
161
  snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
160
162
  return result;
@@ -194,7 +196,11 @@ export const githubReleasesSource = (
194
196
  read,
195
197
  staged: true,
196
198
  watch: options.pollInterval
197
- ? pollingWatch(load, options.pollInterval)
199
+ ? pollingWatch(
200
+ () => load(true),
201
+ options.pollInterval,
202
+ () => load()
203
+ )
198
204
  : undefined,
199
205
  };
200
206
  };
@@ -45,10 +45,14 @@ const globToRegExp = (pattern: string): RegExp => {
45
45
  const char = pattern[i] ?? "";
46
46
  if (char === "*") {
47
47
  if (pattern[i + 1] === "*") {
48
- source += ".*";
49
48
  i += 2;
49
+ // `**/` spans zero or more whole segments — `docs/**/guide.md` must
50
+ // match `docs/guide.md` and `docs/a/guide.md` but not `docs/subguide.md`.
50
51
  if (pattern[i] === "/") {
51
52
  i += 1;
53
+ source += "(?:.*/)?";
54
+ } else {
55
+ source += ".*";
52
56
  }
53
57
  continue;
54
58
  }
@@ -205,7 +209,9 @@ export const mdxRemoteSource = (
205
209
  };
206
210
  };
207
211
 
208
- const load = async (): Promise<SourceLoadResult> => {
212
+ const load = async (
213
+ refresh = ctx.refresh ?? true
214
+ ): Promise<SourceLoadResult> => {
209
215
  const skipped: Diagnostic[] = [];
210
216
  const result = await loadWithCache(
211
217
  options.name,
@@ -245,7 +251,7 @@ export const mdxRemoteSource = (
245
251
  }
246
252
  return entries;
247
253
  },
248
- ctx.refresh ?? true
254
+ refresh
249
255
  );
250
256
  snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
251
257
  return {
@@ -271,7 +277,11 @@ export const mdxRemoteSource = (
271
277
  read,
272
278
  staged: true,
273
279
  watch: options.pollInterval
274
- ? pollingWatch(load, options.pollInterval)
280
+ ? pollingWatch(
281
+ () => load(true),
282
+ options.pollInterval,
283
+ () => load()
284
+ )
275
285
  : undefined,
276
286
  };
277
287
  };
@@ -81,7 +81,9 @@ const mapRoute = (
81
81
  };
82
82
 
83
83
  const CODE_FENCE = /^```/u;
84
- const ATX_HEADING = /^(?<hashes>#{1,6})\s+(?<text>.+?)\s*#*$/u;
84
+ // A closing hash sequence must be preceded by whitespace (CommonMark), so a
85
+ // heading like `## What is C#` keeps its trailing `#`.
86
+ const ATX_HEADING = /^(?<hashes>#{1,6})\s+(?<text>.+?)(?:\s+#+)?\s*$/u;
85
87
 
86
88
  /**
87
89
  * Extract ATX headings from a markdown body, skipping fenced code blocks. Each
@@ -260,6 +262,16 @@ export const normalizeEntry = (
260
262
 
261
263
  const meta = result.data;
262
264
 
265
+ // Top-level `hidden`/`noindex` are accepted as shorthands for their nested
266
+ // equivalents — the schema declares them, so silently ignoring them would
267
+ // strand authors with no diagnostic.
268
+ if (meta.hidden) {
269
+ meta.sidebar.hidden = true;
270
+ }
271
+ if (meta.noindex) {
272
+ meta.seo.noindex = true;
273
+ }
274
+
263
275
  // Locale and the locale-stripped nav path come from the entry's ref (a leading
264
276
  // dir, or a filename suffix under the `dot` parser), not the slug — the slug is
265
277
  // the logical, locale-agnostic path within a locale. A shared `$` file maps to
@@ -335,10 +335,40 @@ export const notionSource = (
335
335
  blocks: NotionBlock[]
336
336
  ): Promise<string> => {
337
337
  const parts = await Promise.all(
338
- blocks.map(
339
- (block) =>
340
- renderLeaf(block) ?? renderContainer(client, block, renderBlocks)
341
- )
338
+ blocks.map(async (block) => {
339
+ const leaf = renderLeaf(block);
340
+ if (leaf === null) {
341
+ return renderContainer(client, block, renderBlocks);
342
+ }
343
+ // Leaf blocks can still carry children (nested list items, indented
344
+ // paragraphs); dropping them would silently lose content.
345
+ if (!block.has_children) {
346
+ return leaf;
347
+ }
348
+ const nested = await renderBlocks(
349
+ client,
350
+ await childrenOf(client, block.id)
351
+ );
352
+ if (!nested) {
353
+ return leaf;
354
+ }
355
+ if (isListItem(block)) {
356
+ // Indent past the list marker so the children belong to the item
357
+ // (`1. ` needs three columns, `- `/`- [x] ` two).
358
+ const indent = " ".repeat(
359
+ block.type === "numbered_list_item" ? 3 : 2
360
+ );
361
+ const indented = nested
362
+ .split("\n")
363
+ .map((line) => (line ? `${indent}${line}` : line))
364
+ .join("\n");
365
+ return `${leaf}\n${indented}`;
366
+ }
367
+ // Other leaves (paragraph, quote) keep their children as following
368
+ // sibling blocks — the indentation semantics are lost but the content
369
+ // survives.
370
+ return `${leaf}\n\n${nested}`;
371
+ })
342
372
  );
343
373
  // Join with a blank line, except between consecutive list items, which stay
344
374
  // tight so they render as a single list rather than separate loose ones.
@@ -430,7 +460,9 @@ export const notionSource = (
430
460
  };
431
461
  };
432
462
 
433
- const load = async (): Promise<SourceLoadResult> => {
463
+ const load = async (
464
+ refresh = ctx?.refresh ?? true
465
+ ): Promise<SourceLoadResult> => {
434
466
  const assetDiagnostics: Diagnostic[] = [];
435
467
  const result = await loadWithCache(
436
468
  options.name,
@@ -453,7 +485,7 @@ export const notionSource = (
453
485
  }
454
486
  return built.map((item) => item.entry);
455
487
  },
456
- ctx?.refresh ?? true
488
+ refresh
457
489
  );
458
490
  snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
459
491
  return {
@@ -478,7 +510,11 @@ export const notionSource = (
478
510
  read,
479
511
  staged: true,
480
512
  watch: options.pollInterval
481
- ? pollingWatch(load, options.pollInterval)
513
+ ? pollingWatch(
514
+ () => load(true),
515
+ options.pollInterval,
516
+ () => load()
517
+ )
482
518
  : undefined,
483
519
  };
484
520
  };
@@ -1,4 +1,4 @@
1
- import { join } from "pathe";
1
+ import { isAbsolute, join, resolve } from "pathe";
2
2
 
3
3
  import { blumeReferences } from "../../openapi/references.ts";
4
4
  import { openApiSource } from "../../openapi/source.ts";
@@ -125,6 +125,49 @@ const buildSource = (
125
125
  );
126
126
  };
127
127
 
128
+ /** Resolve a source `root` against the project root (absolute passes through). */
129
+ const resolveRoot = (projectRoot: string, root: string): string =>
130
+ isAbsolute(root) ? root : join(resolve(projectRoot), root);
131
+
132
+ /**
133
+ * The generated `docs` glob collection: its base directory and the include /
134
+ * exclude globs applied under it. Astro's glob loader ids each entry by its path
135
+ * relative to `base`, and a filesystem source ids each entry relative to its own
136
+ * root — so the two only agree when the collection is rooted at that source. A
137
+ * project with exactly one filesystem source therefore roots the collection at
138
+ * *that* source (honoring a non-default `root`), rather than the global
139
+ * `content.root`. With no sources (the implicit source) or several, the base
140
+ * stays `content.root`; a second filesystem source rooted elsewhere can't share
141
+ * one base and is caught by the entry-id guard in `scanProject`.
142
+ */
143
+ export interface DocsCollection {
144
+ base: string;
145
+ include: string[];
146
+ exclude: string[];
147
+ }
148
+
149
+ export const resolveDocsCollection = (
150
+ config: ResolvedConfig,
151
+ context: ProjectContext
152
+ ): DocsCollection => {
153
+ const filesystem = (config.content.sources ?? []).filter(
154
+ (def) => def.type === "filesystem"
155
+ );
156
+ const only = filesystem.length === 1 ? filesystem[0] : undefined;
157
+ if (only) {
158
+ return {
159
+ base: resolveRoot(context.root, only.root),
160
+ exclude: only.exclude,
161
+ include: only.include,
162
+ };
163
+ }
164
+ return {
165
+ base: context.contentRoot,
166
+ exclude: config.content.exclude,
167
+ include: config.content.include,
168
+ };
169
+ };
170
+
128
171
  /** The base name to allocate for a source config (before deduplication). */
129
172
  const baseName = (def: ContentSourceConfig): string => {
130
173
  if (def.type === "custom") {
@@ -183,7 +183,9 @@ export const sanitySource = (
183
183
  };
184
184
  };
185
185
 
186
- const load = async (): Promise<SourceLoadResult> => {
186
+ const load = async (
187
+ refresh = ctx?.refresh ?? true
188
+ ): Promise<SourceLoadResult> => {
187
189
  const result = await loadWithCache(
188
190
  options.name,
189
191
  cache,
@@ -194,7 +196,7 @@ export const sanitySource = (
194
196
  );
195
197
  return docs.map(toEntry);
196
198
  },
197
- ctx?.refresh ?? true
199
+ refresh
198
200
  );
199
201
  snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
200
202
  return result;
@@ -216,7 +218,11 @@ export const sanitySource = (
216
218
  read,
217
219
  staged: true,
218
220
  watch: options.pollInterval
219
- ? pollingWatch(load, options.pollInterval)
221
+ ? pollingWatch(
222
+ () => load(true),
223
+ options.pollInterval,
224
+ () => load()
225
+ )
220
226
  : undefined,
221
227
  };
222
228
  };
@@ -84,6 +84,12 @@ export interface ContentSource {
84
84
  readonly staged: boolean;
85
85
  /** Optional route prefix; the source's routes namespace under `/<prefix>/`. */
86
86
  readonly prefix?: string;
87
+ /**
88
+ * Resolved on-disk root, set by filesystem-backed sources only. Drives
89
+ * folder-meta discovery (scan under this root) and the docs-collection base;
90
+ * omitted by remote/CMS/staged sources that have no local tree.
91
+ */
92
+ readonly contentRoot?: string;
87
93
  /** Pull every entry. Called once per scan. */
88
94
  load: () => Promise<SourceLoadResult>;
89
95
  /** Validate the source is usable; throws a BlumeError when not. */
package/src/deploy/rss.ts CHANGED
@@ -68,7 +68,9 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
68
68
  .map((page) => ({
69
69
  date: pageDate(page),
70
70
  description: page.description,
71
- link: `${base}${page.route}`,
71
+ // Encode like the sitemap does: a route with spaces or non-ASCII
72
+ // must still yield a valid <link>/<guid> URL after XML decoding.
73
+ link: encodeURI(`${base}${page.route}`),
72
74
  title: page.title,
73
75
  }))
74
76
  .toSorted((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0))
@@ -26,9 +26,16 @@ export interface CodeTitleTransformer {
26
26
  }
27
27
 
28
28
  // The body excludes only the delimiting quote, so `title="foo's file.ts"`
29
- // (an apostrophe inside double quotes) still matches.
30
- const TITLE_ATTR = /title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
29
+ // (an apostrophe inside double quotes) still matches. The left boundary stops
30
+ // `subtitle="..."` (or any `*title=` attr) from reading as a title.
31
+ const TITLE_ATTR = /(?:^|\s)title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
31
32
  const LINE_NUMBERS = /(?:^|\s)lineNumbers(?=\s|$)/u;
33
+ // Any quoted `key="..."` attr — blanked before keyword/bare-token scans so a
34
+ // quoted value can't leak tokens (`title="enable lineNumbers later"`).
35
+ const QUOTED_ATTR = /[\w-]+=(?:"[^"]*"|'[^']*')/gu;
36
+
37
+ const withoutQuotedAttrs = (raw: string): string =>
38
+ raw.replace(QUOTED_ATTR, " ");
32
39
 
33
40
  const parseTitle = (raw: string | undefined): string | undefined => {
34
41
  if (!raw) {
@@ -42,7 +49,7 @@ const parseTitle = (raw: string | undefined): string | undefined => {
42
49
  // The first bare token is the title (```ts blume.config.ts), skipping Shiki
43
50
  // line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
44
51
  // and `twoslash` keywords.
45
- return raw
52
+ return withoutQuotedAttrs(raw)
46
53
  .trim()
47
54
  .split(/\s+/u)
48
55
  .find(
@@ -56,7 +63,7 @@ const parseTitle = (raw: string | undefined): string | undefined => {
56
63
  };
57
64
 
58
65
  const hasLineNumbers = (raw: string | undefined): boolean =>
59
- Boolean(raw && LINE_NUMBERS.test(raw));
66
+ Boolean(raw && LINE_NUMBERS.test(withoutQuotedAttrs(raw)));
60
67
 
61
68
  /** Build the transformer. Runs after Shiki's built-in `data-language` hook. */
62
69
  export const codeTitleTransformer = (): CodeTitleTransformer => ({
@@ -98,6 +98,19 @@ const parseIntent = (input: string): Intent => {
98
98
  if (!verb) {
99
99
  return { args: [], operation: "install" };
100
100
  }
101
+ // Yarn Classic spells global installs `yarn global <add|remove> …`; map it
102
+ // onto the flag-style intent so every manager renders its own global form
103
+ // instead of falling into the run-as-script branch.
104
+ if (first === "yarn" && verb === "global") {
105
+ const [globalVerb, ...globalArgs] = verbArgs;
106
+ const globalOp = globalVerb ? normalizeVerb(globalVerb) : null;
107
+ if (globalOp === "add" || globalOp === "remove") {
108
+ return {
109
+ args: [...normalizeFlags(globalArgs), "-g"],
110
+ operation: globalOp,
111
+ };
112
+ }
113
+ }
101
114
  const operation = normalizeVerb(verb);
102
115
  if (operation === null) {
103
116
  // Unknown subcommand (e.g. `npm test`); run it as a script.
package/src/og/card.ts CHANGED
@@ -145,7 +145,9 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
145
145
  const accent = resolveAccent(options.accent ?? "blue");
146
146
  const brand = options.brand?.trim();
147
147
  const logo = options.logo?.trim();
148
- const initial = brand ? brand.charAt(0).toUpperCase() : "";
148
+ // Slice by code point, not code unit — `charAt(0)` would split a leading
149
+ // surrogate pair (an emoji brand initial) into a lone half that renders blank.
150
+ const initial = brand ? ([...brand][0]?.toUpperCase() ?? "") : "";
149
151
  const description = options.description?.trim();
150
152
  const repo = options.repo?.trim();
151
153
  const site = options.site?.trim();
@@ -144,7 +144,8 @@ export const extractOperations = (
144
144
  method,
145
145
  operationId: operation.operationId,
146
146
  path,
147
- route: `${baseRoute}/${tagSlug}/${key}`,
147
+ // A root-mounted reference (`route: "/"`) must not emit `//tag/key`.
148
+ route: `${baseRoute === "/" ? "" : baseRoute}/${tagSlug}/${key}`,
148
149
  summary: operation.summary ?? "",
149
150
  tag,
150
151
  tagSlug,
@@ -26,6 +26,9 @@ const URL_SPEC = /^https?:\/\//u;
26
26
  const FETCH_TIMEOUT_MS = 15_000;
27
27
  const MAX_ATTEMPTS = 3;
28
28
  const BASE_BACKOFF_MS = 500;
29
+ // Honor Retry-After only up to a sane ceiling: a server answering with
30
+ // `Retry-After: 3600` must not stall a build for an hour per attempt.
31
+ const MAX_RETRY_WAIT_MS = 10_000;
29
32
  const SECOND_MS = 1000;
30
33
  // Worth another try: request timeout, too-early, rate-limited, and the 5xx range.
31
34
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
@@ -138,7 +141,12 @@ const fetchSpecText = async (spec: string): Promise<string> => {
138
141
  throw last.error;
139
142
  }
140
143
  // oxlint-disable-next-line no-await-in-loop -- back off before retrying
141
- await sleep(last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt);
144
+ await sleep(
145
+ Math.min(
146
+ last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt,
147
+ MAX_RETRY_WAIT_MS
148
+ )
149
+ );
142
150
  }
143
151
  throw last.error;
144
152
  };
@@ -145,6 +145,7 @@ export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
145
145
  /** Blume-rendered OpenAPI references, deduped by route (first wins). */
146
146
  export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
147
147
  const seen = new Set<string>();
148
+ const usedSlugs = new Set<string>();
148
149
  const result: ReferenceSource[] = [];
149
150
  for (const ref of resolveReferences(config)) {
150
151
  if (ref.kind !== "openapi" || ref.renderer !== "blume") {
@@ -154,7 +155,16 @@ export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
154
155
  continue;
155
156
  }
156
157
  seen.add(ref.route);
157
- result.push(ref);
158
+ // Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
159
+ // yield `api-v1`). The slug keys the `blume:openapi` data module, so a
160
+ // collision would let one spec silently overwrite the other while the
161
+ // loser's pages still point at the shared key — disambiguate.
162
+ let { slug } = ref;
163
+ for (let n = 2; usedSlugs.has(slug); n += 1) {
164
+ slug = `${ref.slug}-${n}`;
165
+ }
166
+ usedSlugs.add(slug);
167
+ result.push(slug === ref.slug ? ref : { ...ref, slug });
158
168
  }
159
169
  return result;
160
170
  };
@@ -25,7 +25,13 @@ const ENTITIES: Record<string, string> = {
25
25
  // SDK…" is common spec prose). Entity-escape the keyword's first letter so the
26
26
  // construct can't match; it still renders as the literal word.
27
27
  const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
28
- const mdxSafe = (text: string): string =>
28
+ // Backtick code inline spans and fences alike — is already literal in MDX,
29
+ // and entities are NOT decoded inside it, so escaping there would render the
30
+ // entity text verbatim (`/pets/&#123;petId&#125;`). Matching any balanced
31
+ // backtick run covers `code`, ``code``, and ```fences``` in one shot.
32
+ const BACKTICK_CODE = /(?<bt>`+)[\s\S]*?\k<bt>/gu;
33
+
34
+ const escapeProse = (text: string): string =>
29
35
  text
30
36
  .replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char)
31
37
  .replace(
@@ -33,6 +39,19 @@ const mdxSafe = (text: string): string =>
33
39
  (keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
34
40
  );
35
41
 
42
+ /** Escape MDX-special syntax in prose while leaving backtick code verbatim. */
43
+ const mdxSafe = (text: string): string => {
44
+ let out = "";
45
+ let cursor = 0;
46
+ for (const match of text.matchAll(BACKTICK_CODE)) {
47
+ const start = match.index ?? 0;
48
+ out += escapeProse(text.slice(cursor, start));
49
+ out += match[0];
50
+ cursor = start + match[0].length;
51
+ }
52
+ return out + escapeProse(text.slice(cursor));
53
+ };
54
+
36
55
  /** Frontmatter + body for one operation or overview page. */
37
56
  export interface RenderedPage {
38
57
  data: Record<string, unknown>;
@@ -80,8 +99,16 @@ export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
80
99
  // markdown pipeline gives them ids, permalink anchors, and table-of-contents
81
100
  // entries; only the operation-link list defers to a component.
82
101
  const operations = Object.values(spec.operations);
83
- const sections = [...spec.tags];
84
- const known = new Set(spec.tags.map((tag) => tag.slug));
102
+ // Dedupe by slug: two declared tags that slugify identically (`Store` and
103
+ // `store`) must render one section, not the same operation list twice.
104
+ const sections: typeof spec.tags = [];
105
+ const known = new Set<string>();
106
+ for (const tag of spec.tags) {
107
+ if (!known.has(tag.slug)) {
108
+ known.add(tag.slug);
109
+ sections.push(tag);
110
+ }
111
+ }
85
112
  for (const operation of operations) {
86
113
  if (!known.has(operation.tagSlug)) {
87
114
  known.add(operation.tagSlug);
@@ -59,8 +59,10 @@ const specEntries = (
59
59
  );
60
60
  // Overview last so an operation sets the section's routePath before the index
61
61
  // page is inserted (the group's routePath is derived from its first child).
62
+ // A root-mounted reference refs `index.mdx`, not `/index.mdx`.
63
+ const base = routeToRef(spec.route);
62
64
  entries.push(
63
- toEntry(overviewMdx(spec), `${routeToRef(spec.route)}/index.mdx`)
65
+ toEntry(overviewMdx(spec), base ? `${base}/index.mdx` : "index.mdx")
64
66
  );
65
67
  return entries;
66
68
  };
@@ -39,7 +39,10 @@ export interface SearchRecord {
39
39
 
40
40
  const CODE_FENCE = /```[\s\S]*?```/gu;
41
41
  const INLINE_CODE = /`(?<code>[^`]+)`/gu;
42
- const HTML_OR_JSX = /<[^>]+>/gu;
42
+ // Tag-shaped only: a name (or closing slash/fragment) right after `<`, and no
43
+ // newline inside. A bare `<` in prose ("costs < 5 credits") must not swallow
44
+ // everything up to some later `>` — potentially whole paragraphs.
45
+ const HTML_OR_JSX = /<\/?[a-zA-Z][^\n<>]*>|<\/?>/gu;
43
46
  const IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
44
47
  const LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
45
48
  const HEADING_MARK = /^#{1,6}\s+/gmu;
@@ -435,6 +435,9 @@ blume-diff {
435
435
  font-size: 0.8125rem;
436
436
  }
437
437
 
438
+ /* GFM renders cells as <td><code> directly, which the descendant form alone
439
+ never matches (a cell is not its own descendant). */
440
+ .prose :where(td, th) > code,
438
441
  .prose :where(td, th) :not(pre) > code {
439
442
  white-space: nowrap;
440
443
  }
@@ -79,14 +79,10 @@ export const resolveIcon = (name: string): ResolvedIcon | null => {
79
79
  return fromSet(DEFAULT_SET, normalized);
80
80
  };
81
81
 
82
- /** Whether a name resolves to a known Lucide icon. */
83
- export const hasIcon = (name: string): boolean => {
84
- if (resolveIcon(name)) {
85
- return true;
86
- }
87
- const normalized = normalize(name);
88
- const bare = normalized.includes(":")
89
- ? normalized.slice(normalized.indexOf(":") + 1)
90
- : normalized;
91
- return Object.values(SETS).some((set) => getIconData(set, bare) !== null);
92
- };
82
+ /**
83
+ * Whether a name resolves to a renderable icon. Exactly mirrors
84
+ * {@link resolveIcon}: a laxer check (e.g. matching the bare name under an
85
+ * unknown prefix) would let callers suppress their fallback and diagnostics
86
+ * for a name `<Icon>` then renders as nothing.
87
+ */
88
+ export const hasIcon = (name: string): boolean => resolveIcon(name) !== null;