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.
- package/CHANGELOG.md +35 -0
- package/dist/cli/index.js +189 -88
- package/dist/cli/index.js.map +22 -22
- package/dist/types/core/data.d.ts +2 -0
- package/package.json +1 -1
- package/src/ai/mcp/server.ts +29 -6
- package/src/astro/generate.ts +94 -46
- package/src/astro/templates.ts +59 -15
- 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/RootLayout.astro +13 -2
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/core/data.ts +2 -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/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- 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
|
|
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
|
};
|
|
@@ -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"));
|
|
@@ -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 };
|
|
@@ -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, `<`
|
|
15
|
-
// so an arbitrary spec description can be embedded in the body verbatim
|
|
16
|
-
// breaking compilation. They render as their literal selves.
|
|
17
|
-
|
|
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 "> Note:" text.
|
|
19
|
+
const MDX_UNSAFE = /[<{}]/gu;
|
|
18
20
|
const ENTITIES: Record<string, string> = {
|
|
19
21
|
"<": "<",
|
|
20
|
-
">": ">",
|
|
21
22
|
"{": "{",
|
|
22
23
|
"}": "}",
|
|
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/{petId}`). Matching any balanced
|
|
31
|
-
// backtick run covers `code`, ``code``, and ```fences``` in one shot.
|
|
32
|
-
|
|
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
|