blume 1.1.3 → 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 (37) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli/index.js +189 -88
  3. package/dist/cli/index.js.map +22 -22
  4. package/dist/types/core/data.d.ts +2 -0
  5. package/package.json +1 -1
  6. package/src/ai/mcp/server.ts +29 -6
  7. package/src/astro/generate.ts +94 -46
  8. package/src/astro/templates.ts +59 -15
  9. package/src/audit/checks/duplicates.ts +15 -6
  10. package/src/audit/checks/indexability.ts +11 -2
  11. package/src/audit/checks/network.ts +22 -8
  12. package/src/audit/checks/sitemap.ts +42 -16
  13. package/src/audit/redirects.ts +12 -1
  14. package/src/audit/run.ts +13 -3
  15. package/src/audit/url.ts +21 -2
  16. package/src/cli/commands/audit.ts +21 -6
  17. package/src/cli/commands/dev.ts +19 -2
  18. package/src/components/content/Frame.astro +4 -1
  19. package/src/components/content/Prompt.astro +4 -1
  20. package/src/components/content/Tooltip.astro +4 -1
  21. package/src/components/content/Update.astro +45 -0
  22. package/src/components/islands/ask-ai.tsx +19 -2
  23. package/src/components/islands/hooks.ts +38 -11
  24. package/src/components/layout/RootLayout.astro +13 -2
  25. package/src/components/layout/Search.astro +5 -1
  26. package/src/components/layout/head-scripts.ts +22 -5
  27. package/src/core/data.ts +2 -0
  28. package/src/core/deployment-env.ts +7 -2
  29. package/src/core/graph.ts +7 -1
  30. package/src/core/i18n.ts +10 -2
  31. package/src/core/navigation.ts +7 -3
  32. package/src/core/sources/normalize.ts +69 -8
  33. package/src/core/sources/notion.ts +4 -2
  34. package/src/core/sources/sanity.ts +5 -3
  35. package/src/markdown/code-title.ts +7 -1
  36. package/src/openapi/model.ts +31 -2
  37. package/src/openapi/render-mdx.ts +12 -7
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
  };
@@ -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"));
@@ -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 };
@@ -11,13 +11,14 @@ import type { ApiOperationRef, ApiSpecData } from "./model.ts";
11
11
  * omit their own top heading.
12
12
  */
13
13
 
14
- // Neutralize the few characters MDX treats specially (`{` expressions, `<` JSX)
15
- // so an arbitrary spec description can be embedded in the body verbatim without
16
- // breaking compilation. They render as their literal selves.
17
- const MDX_UNSAFE = /[<>{}]/gu;
14
+ // Neutralize the few characters MDX treats specially (`{` expressions, `<`
15
+ // JSX) so an arbitrary spec description can be embedded in the body verbatim
16
+ // without breaking compilation. They render as their literal selves. `>` is
17
+ // deliberately not escaped: it isn't MDX-special on its own, and escaping it
18
+ // turns a `> Note:` blockquote into literal "&gt; Note:" text.
19
+ const MDX_UNSAFE = /[<{}]/gu;
18
20
  const ENTITIES: Record<string, string> = {
19
21
  "<": "&lt;",
20
- ">": "&gt;",
21
22
  "{": "&#123;",
22
23
  "}": "&#125;",
23
24
  };
@@ -28,8 +29,12 @@ const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
28
29
  // Backtick code — inline spans and fences alike — is already literal in MDX,
29
30
  // and entities are NOT decoded inside it, so escaping there would render the
30
31
  // 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;
32
+ // backtick run covers `code`, ``code``, and ```fences``` in one shot. Both
33
+ // runs are pinned by the backtick lookarounds: CommonMark pairs a span only
34
+ // with an *equal-length* run, so without them a lone backtick would "close" on
35
+ // the first backtick of a longer fence run — leaving `{` in the real prose
36
+ // unescaped (a compile error) and escaping entities into the fence body.
37
+ const BACKTICK_CODE = /(?<!`)(?<bt>`+)(?!`)[\s\S]*?(?<!`)\k<bt>(?!`)/gu;
33
38
 
34
39
  const escapeProse = (text: string): string =>
35
40
  text