blume 0.6.7 → 0.7.0

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 (108) hide show
  1. package/dist/cli/index.js +1179 -738
  2. package/dist/cli/index.js.map +52 -51
  3. package/dist/types/core/base-path.d.ts +38 -0
  4. package/dist/types/core/config-input.d.ts +74 -10
  5. package/dist/types/core/config.d.ts +3 -2
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +1 -3
  8. package/dist/types/core/schema.d.ts +95 -52
  9. package/dist/types/core/sources/types.d.ts +2 -0
  10. package/dist/types/core/types.d.ts +6 -1
  11. package/docs/02-deployment.mdx +16 -1
  12. package/docs/03-faq.mdx +8 -8
  13. package/docs/configuration/index.mdx +6 -0
  14. package/docs/content/components.mdx +29 -2
  15. package/docs/content/islands.mdx +8 -0
  16. package/docs/content/syntax.mdx +13 -0
  17. package/package.json +2 -1
  18. package/src/ai/agent-readability.ts +7 -2
  19. package/src/ai/ask.ts +12 -7
  20. package/src/ai/llms.ts +15 -4
  21. package/src/ai/mcp/data.ts +8 -4
  22. package/src/ai/mcp/server.ts +3 -0
  23. package/src/astro/component-slots.ts +5 -3
  24. package/src/astro/examples.ts +12 -7
  25. package/src/astro/generate.ts +317 -144
  26. package/src/astro/index.ts +5 -1
  27. package/src/astro/integration.ts +8 -4
  28. package/src/astro/islands.ts +11 -5
  29. package/src/astro/markdown-negotiation.ts +1 -1
  30. package/src/astro/pages.ts +8 -3
  31. package/src/astro/templates.ts +166 -19
  32. package/src/cli/commands/build.ts +32 -19
  33. package/src/cli/commands/dev.ts +48 -15
  34. package/src/cli/commands/doctor.ts +2 -2
  35. package/src/cli/commands/validate.ts +1 -0
  36. package/src/cli/dev-lock.ts +26 -15
  37. package/src/cli/required-secrets.ts +2 -1
  38. package/src/components/content/CodeBlock.astro +3 -0
  39. package/src/components/content/Component.astro +30 -16
  40. package/src/components/content/Diff.astro +3 -1
  41. package/src/components/content/auto-type-table.ts +18 -8
  42. package/src/components/content/diff.ts +12 -6
  43. package/src/components/content/mermaid-element.ts +3 -0
  44. package/src/components/index.ts +23 -1
  45. package/src/components/islands/ask-ai.tsx +12 -6
  46. package/src/components/islands/base-path.ts +28 -0
  47. package/src/components/islands/hooks.ts +16 -1
  48. package/src/components/layout/Banner.astro +2 -1
  49. package/src/components/layout/Breadcrumbs.astro +2 -1
  50. package/src/components/layout/Favicon.astro +3 -2
  51. package/src/components/layout/Header.astro +2 -1
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +2 -1
  54. package/src/components/layout/NavSelector.astro +2 -1
  55. package/src/components/layout/NavTree.astro +5 -4
  56. package/src/components/layout/PageFeedback.astro +4 -1
  57. package/src/components/layout/PageLayout.astro +9 -4
  58. package/src/components/layout/Pagination.astro +3 -2
  59. package/src/components/layout/RootLayout.astro +7 -4
  60. package/src/components/layout/Search.astro +13 -5
  61. package/src/components/layout/nav-utils.ts +18 -10
  62. package/src/components/layout/search/pagefind.ts +3 -0
  63. package/src/components/layout/toc-element.ts +7 -1
  64. package/src/components/openapi/RequestPanel.astro +7 -1
  65. package/src/components/openapi/snippets.ts +25 -11
  66. package/src/core/base-path.ts +70 -0
  67. package/src/core/component-overrides.ts +103 -74
  68. package/src/core/config-input.ts +81 -15
  69. package/src/core/config.ts +5 -3
  70. package/src/core/content.ts +2 -0
  71. package/src/core/data.ts +2 -0
  72. package/src/core/diagnostics.ts +54 -34
  73. package/src/core/gitignore.ts +4 -1
  74. package/src/core/graph.ts +156 -88
  75. package/src/core/i18n-ui.ts +18 -3
  76. package/src/core/last-modified.ts +2 -0
  77. package/src/core/links.ts +38 -18
  78. package/src/core/manifest.ts +62 -45
  79. package/src/core/nav-diagnostics.ts +1 -1
  80. package/src/core/navigation.ts +116 -55
  81. package/src/core/project-graph.ts +10 -9
  82. package/src/core/schema.ts +572 -621
  83. package/src/core/sources/github-releases.ts +2 -1
  84. package/src/core/sources/mdx-remote.ts +58 -54
  85. package/src/core/sources/normalize.ts +116 -73
  86. package/src/core/sources/notion.ts +19 -10
  87. package/src/core/sources/types.ts +2 -0
  88. package/src/core/tsconfig-aliases.ts +59 -30
  89. package/src/core/types.ts +6 -1
  90. package/src/deploy/redirects.ts +18 -0
  91. package/src/deploy/robots.ts +6 -1
  92. package/src/deploy/rss.ts +10 -3
  93. package/src/deploy/sitemap.ts +14 -10
  94. package/src/markdown/base-links.ts +58 -0
  95. package/src/markdown/code-title.ts +11 -14
  96. package/src/markdown/index.ts +34 -9
  97. package/src/markdown/inline-code.ts +7 -2
  98. package/src/markdown/themes.ts +24 -0
  99. package/src/openapi/model.ts +3 -1
  100. package/src/openapi/references.ts +41 -17
  101. package/src/openapi/render-mdx.ts +11 -6
  102. package/src/openapi/scalar.ts +32 -16
  103. package/src/registry/eject.ts +64 -8
  104. package/src/search/build.ts +3 -0
  105. package/src/search/documents.ts +2 -2
  106. package/src/search/sync/typesense.ts +6 -4
  107. package/src/seo/jsonld.ts +16 -6
  108. package/src/theme/entry.ts +85 -20
@@ -88,7 +88,8 @@ const releaseToEntry = (release: GithubRelease): SourceEntry => {
88
88
  type: "changelog",
89
89
  };
90
90
  const raw = matter.stringify(`${body}\n`, data);
91
- const ref = `${slugifyTag(release.tag_name) || `release-${release.id}`}.md`;
91
+ const fallbackRef = `release-${release.id}`;
92
+ const ref = `${slugifyTag(release.tag_name) || fallbackRef}.md`;
92
93
  return {
93
94
  body: { format: "md", text: body },
94
95
  data,
@@ -37,49 +37,48 @@ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/u;
37
37
  const escapeChar = (char: string): string =>
38
38
  REGEX_SPECIAL.test(char) ? `\\${char}` : char;
39
39
 
40
+ /** Translate one glob token at `i` into RegExp source + the next index. */
41
+ const globToken = (
42
+ pattern: string,
43
+ i: number
44
+ ): { source: string; next: number } => {
45
+ const char = pattern[i] ?? "";
46
+ if (char === "*") {
47
+ if (pattern[i + 1] === "*") {
48
+ // `**/` spans zero or more whole segments — `docs/**/guide.md` must
49
+ // match `docs/guide.md` and `docs/a/guide.md` but not `docs/subguide.md`.
50
+ if (pattern[i + 2] === "/") {
51
+ return { next: i + 3, source: "(?:.*/)?" };
52
+ }
53
+ return { next: i + 2, source: ".*" };
54
+ }
55
+ return { next: i + 1, source: "[^/]*" };
56
+ }
57
+ if (char === "?") {
58
+ return { next: i + 1, source: "[^/]" };
59
+ }
60
+ if (char === "{") {
61
+ const end = pattern.indexOf("}", i);
62
+ if (end !== -1) {
63
+ const options = pattern
64
+ .slice(i + 1, end)
65
+ .split(",")
66
+ .map((part) => [...part].map(escapeChar).join(""))
67
+ .join("|");
68
+ return { next: end + 1, source: `(?:${options})` };
69
+ }
70
+ }
71
+ return { next: i + 1, source: escapeChar(char) };
72
+ };
73
+
40
74
  /** Compile a glob (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */
41
75
  const globToRegExp = (pattern: string): RegExp => {
42
76
  let source = "";
43
77
  let i = 0;
44
78
  while (i < pattern.length) {
45
- const char = pattern[i] ?? "";
46
- if (char === "*") {
47
- if (pattern[i + 1] === "*") {
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`.
51
- if (pattern[i] === "/") {
52
- i += 1;
53
- source += "(?:.*/)?";
54
- } else {
55
- source += ".*";
56
- }
57
- continue;
58
- }
59
- source += "[^/]*";
60
- i += 1;
61
- continue;
62
- }
63
- if (char === "?") {
64
- source += "[^/]";
65
- i += 1;
66
- continue;
67
- }
68
- if (char === "{") {
69
- const end = pattern.indexOf("}", i);
70
- if (end !== -1) {
71
- const options = pattern
72
- .slice(i + 1, end)
73
- .split(",")
74
- .map((part) => [...part].map(escapeChar).join(""))
75
- .join("|");
76
- source += `(?:${options})`;
77
- i = end + 1;
78
- continue;
79
- }
80
- }
81
- source += escapeChar(char);
82
- i += 1;
79
+ const token = globToken(pattern, i);
80
+ source += token.source;
81
+ i = token.next;
83
82
  }
84
83
  return new RegExp(`^${source}$`, "u");
85
84
  };
@@ -137,15 +136,22 @@ const enumerateGithub = async (
137
136
  truncated?: boolean;
138
137
  };
139
138
  const prefix = base ? `${base}/` : "";
140
- const refs = (body.tree ?? [])
141
- .filter((node) => node.type === "blob" && node.path.startsWith(prefix))
142
- .map((node) => node.path.slice(prefix.length))
143
- .filter((rel) => matchesInclude(rel, include))
144
- .map((rel) => ({
145
- editUrl: `https://github.com/${owner}/${repo}/edit/${ref}/${prefix}${rel}`,
146
- fetchUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${prefix}${rel}`,
147
- ref: rel,
148
- }));
139
+ const refs = (body.tree ?? []).flatMap((node) => {
140
+ if (!(node.type === "blob" && node.path.startsWith(prefix))) {
141
+ return [];
142
+ }
143
+ const rel = node.path.slice(prefix.length);
144
+ if (!matchesInclude(rel, include)) {
145
+ return [];
146
+ }
147
+ return [
148
+ {
149
+ editUrl: `https://github.com/${owner}/${repo}/edit/${ref}/${prefix}${rel}`,
150
+ fetchUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${prefix}${rel}`,
151
+ ref: rel,
152
+ },
153
+ ];
154
+ });
149
155
  // GitHub caps the recursive tree response (~100k entries / 7MB) and flags it
150
156
  // with `truncated`; ignoring it would silently import only part of the repo.
151
157
  return { refs, truncated: body.truncated === true };
@@ -173,13 +179,11 @@ export const mdxRemoteSource = (
173
179
  }
174
180
  if (options.files && options.url) {
175
181
  const base = options.url.replace(/\/$/u, "");
176
- const refs = options.files
177
- .filter((ref) => matchesInclude(ref, options.include))
178
- .map((ref) => ({
179
- editUrl: `${base}/${ref}`,
180
- fetchUrl: `${base}/${ref}`,
181
- ref,
182
- }));
182
+ const refs = options.files.flatMap((ref) =>
183
+ matchesInclude(ref, options.include)
184
+ ? [{ editUrl: `${base}/${ref}`, fetchUrl: `${base}/${ref}`, ref }]
185
+ : []
186
+ );
183
187
  return { refs, truncated: false };
184
188
  }
185
189
  throw new BlumeError({
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import GithubSlugger from "github-slugger";
4
4
  import { extname } from "pathe";
5
5
 
6
+ import { withBasePath } from "../base-path.ts";
6
7
  import { diagnosticsFromZod } from "../diagnostics.ts";
7
8
  import { localePlacement, localizeRoute } from "../i18n.ts";
8
9
  import { pageMetaSchema } from "../schema.ts";
@@ -45,6 +46,29 @@ const titleCase = (value: string): string =>
45
46
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
46
47
  .join(" ");
47
48
 
49
+ /** Fold one raw path part into the accumulating route segments/groups. */
50
+ const addRouteSegment = (
51
+ part: string,
52
+ segments: string[],
53
+ groups: string[]
54
+ ): void => {
55
+ // A leading/trailing/double slash yields an empty part; keeping it would
56
+ // produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
57
+ if (part === "") {
58
+ return;
59
+ }
60
+ const group = groupLabel(part);
61
+ if (group !== null) {
62
+ groups.push(group);
63
+ return;
64
+ }
65
+ const clean = stripNumericPrefix(part);
66
+ if (clean === "index") {
67
+ return;
68
+ }
69
+ segments.push(clean);
70
+ };
71
+
48
72
  /** Convert a content-root-relative path into URL + nav metadata. */
49
73
  const mapRoute = (
50
74
  relativePath: string
@@ -59,21 +83,7 @@ const mapRoute = (
59
83
  const groups: string[] = [];
60
84
 
61
85
  for (const part of rawParts) {
62
- // A leading/trailing/double slash yields an empty part; keeping it would
63
- // produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
64
- if (part === "") {
65
- continue;
66
- }
67
- const group = groupLabel(part);
68
- if (group !== null) {
69
- groups.push(group);
70
- continue;
71
- }
72
- const clean = stripNumericPrefix(part);
73
- if (clean === "index") {
74
- continue;
75
- }
76
- segments.push(clean);
86
+ addRouteSegment(part, segments, groups);
77
87
  }
78
88
 
79
89
  const route = segments.length === 0 ? "/" : `/${segments.join("/")}`;
@@ -95,25 +105,35 @@ const ATX_HEADING = /^(?<hashes>#{1,6})\s+(?<text>.+?)(?:\s+#+)?\s*$/u;
95
105
  * (a hand slugify collapses `--`; github-slugger keeps it) and resolves repeated
96
106
  * headings the same way (`setup`, `setup-1`).
97
107
  */
108
+ /** Scan one line for an ATX heading; returns the next fenced-block state. */
109
+ const scanHeadingLine = (
110
+ line: string,
111
+ inFence: boolean,
112
+ slugger: GithubSlugger,
113
+ headings: Heading[]
114
+ ): boolean => {
115
+ if (CODE_FENCE.test(line.trimStart())) {
116
+ return !inFence;
117
+ }
118
+ if (inFence) {
119
+ return inFence;
120
+ }
121
+ const match = line.match(ATX_HEADING);
122
+ if (match?.groups) {
123
+ const depth = match.groups.hashes?.length ?? 1;
124
+ const text = (match.groups.text ?? "").trim();
125
+ headings.push({ depth, slug: slugger.slug(text), text });
126
+ }
127
+ return inFence;
128
+ };
129
+
98
130
  export const extractHeadings = (body: string): Heading[] => {
99
131
  const headings: Heading[] = [];
100
132
  const slugger = new GithubSlugger();
101
133
  let inFence = false;
102
134
 
103
135
  for (const line of body.split("\n")) {
104
- if (CODE_FENCE.test(line.trimStart())) {
105
- inFence = !inFence;
106
- continue;
107
- }
108
- if (inFence) {
109
- continue;
110
- }
111
- const match = line.match(ATX_HEADING);
112
- if (match?.groups) {
113
- const depth = match.groups.hashes?.length ?? 1;
114
- const text = (match.groups.text ?? "").trim();
115
- headings.push({ depth, slug: slugger.slug(text), text });
116
- }
136
+ inFence = scanHeadingLine(line, inFence, slugger, headings);
117
137
  }
118
138
 
119
139
  return headings;
@@ -126,6 +146,43 @@ const INLINE_CODE = /`[^`]*`/gu;
126
146
  * Extract link targets from a markdown body for later validation, recording the
127
147
  * 1-based line/column of each target. Skips fenced code blocks and inline code.
128
148
  */
149
+ /** Scan one line for link targets; returns the next fenced-block state. */
150
+ const scanLinkLine = (
151
+ line: string,
152
+ lineNumber: number,
153
+ inFence: boolean,
154
+ links: PageLink[]
155
+ ): boolean => {
156
+ if (CODE_FENCE.test(line.trimStart())) {
157
+ return !inFence;
158
+ }
159
+ if (inFence) {
160
+ return inFence;
161
+ }
162
+ // Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
163
+ // with same-length padding so recorded columns stay accurate.
164
+ const masked = line.replaceAll(INLINE_CODE, (span) =>
165
+ " ".repeat(span.length)
166
+ );
167
+ for (const match of masked.matchAll(MD_LINK)) {
168
+ const target = match.groups?.target;
169
+ if (target === undefined || match.index === undefined) {
170
+ continue;
171
+ }
172
+ // Locate the target from the `](` boundary rather than searching for the
173
+ // target text from the match start — otherwise a label that contains the
174
+ // same text (e.g. `[/a/b](/a/b)`) reports the column inside the label. The
175
+ // label can't contain `]`, so `](` is unambiguous.
176
+ const targetOffset = match.index + match[0].indexOf("](") + "](".length;
177
+ links.push({
178
+ column: targetOffset + 1,
179
+ line: lineNumber,
180
+ target,
181
+ });
182
+ }
183
+ return inFence;
184
+ };
185
+
129
186
  export const extractLinks = (body: string): PageLink[] => {
130
187
  const links: PageLink[] = [];
131
188
  let inFence = false;
@@ -133,34 +190,7 @@ export const extractLinks = (body: string): PageLink[] => {
133
190
 
134
191
  for (const line of body.split("\n")) {
135
192
  lineNumber += 1;
136
- if (CODE_FENCE.test(line.trimStart())) {
137
- inFence = !inFence;
138
- continue;
139
- }
140
- if (inFence) {
141
- continue;
142
- }
143
- // Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
144
- // with same-length padding so recorded columns stay accurate.
145
- const masked = line.replaceAll(INLINE_CODE, (span) =>
146
- " ".repeat(span.length)
147
- );
148
- for (const match of masked.matchAll(MD_LINK)) {
149
- const target = match.groups?.target;
150
- if (target === undefined || match.index === undefined) {
151
- continue;
152
- }
153
- // Locate the target from the `](` boundary rather than searching for the
154
- // target text from the match start — otherwise a label that contains the
155
- // same text (e.g. `[/a/b](/a/b)`) reports the column inside the label. The
156
- // label can't contain `]`, so `](` is unambiguous.
157
- const targetOffset = match.index + match[0].indexOf("](") + "](".length;
158
- links.push({
159
- column: targetOffset + 1,
160
- line: lineNumber,
161
- target,
162
- });
163
- }
193
+ inFence = scanLinkLine(line, lineNumber, inFence, links);
164
194
  }
165
195
 
166
196
  return links;
@@ -179,26 +209,33 @@ const JSX_OPEN = /<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
179
209
  * strings so code samples and prose don't count. Powers the missing-component
180
210
  * diagnostic.
181
211
  */
212
+ /** Scan one line for JSX component tags; returns the next fenced-block state. */
213
+ const scanTagLine = (
214
+ line: string,
215
+ inFence: boolean,
216
+ tags: Set<string>
217
+ ): boolean => {
218
+ if (CODE_FENCE.test(line.trimStart())) {
219
+ return !inFence;
220
+ }
221
+ if (inFence) {
222
+ return inFence;
223
+ }
224
+ const clean = line.replaceAll(INLINE_CODE, "").replaceAll(DOUBLE_QUOTED, "");
225
+ for (const match of clean.matchAll(JSX_OPEN)) {
226
+ const tag = match.groups?.tag;
227
+ if (tag) {
228
+ tags.add(tag);
229
+ }
230
+ }
231
+ return inFence;
232
+ };
233
+
182
234
  export const extractComponentTags = (body: string): string[] => {
183
235
  const tags = new Set<string>();
184
236
  let inFence = false;
185
237
  for (const line of body.split("\n")) {
186
- if (CODE_FENCE.test(line.trimStart())) {
187
- inFence = !inFence;
188
- continue;
189
- }
190
- if (inFence) {
191
- continue;
192
- }
193
- const clean = line
194
- .replaceAll(INLINE_CODE, "")
195
- .replaceAll(DOUBLE_QUOTED, "");
196
- for (const match of clean.matchAll(JSX_OPEN)) {
197
- const tag = match.groups?.tag;
198
- if (tag) {
199
- tags.add(tag);
200
- }
201
- }
238
+ inFence = scanTagLine(line, inFence, tags);
202
239
  }
203
240
  return [...tags];
204
241
  };
@@ -323,10 +360,16 @@ export const normalizeEntry = (
323
360
 
324
361
  // One record per locale this entry maps to (one normally; every locale for a
325
362
  // shared `$` file). All share the same id, source ref, and translation key.
363
+ // `basePath` is applied outermost — after locale prefixing — so the route
364
+ // reads `{basePath}/{locale?}/{prefix?}/…`; `navPath` and `translationKey`
365
+ // stay base-less so the nav tree and translation matching are unaffected.
326
366
  const pages = locales.map((locale) => ({
327
367
  ...base,
328
368
  locale,
329
- route: i18n ? localizeRoute(logicalRoute, locale, i18n) : logicalRoute,
369
+ route: withBasePath(
370
+ ctx.basePath ?? "",
371
+ i18n ? localizeRoute(logicalRoute, locale, i18n) : logicalRoute
372
+ ),
330
373
  }));
331
374
 
332
375
  return { diagnostics: [], pages };
@@ -153,7 +153,7 @@ const withNotionRetry = async <T>(call: () => Promise<T>): Promise<T> => {
153
153
  let lastError: unknown;
154
154
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
155
155
  try {
156
- // oxlint-disable-next-line no-await-in-loop -- sequential retry attempts
156
+ // oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential retry attempts, not independent
157
157
  return await call();
158
158
  } catch (error) {
159
159
  lastError = error;
@@ -372,9 +372,10 @@ export const notionSource = (
372
372
  );
373
373
  // Join with a blank line, except between consecutive list items, which stay
374
374
  // tight so they render as a single list rather than separate loose ones.
375
- const pairs = blocks
376
- .map((block, i) => ({ block, text: parts[i] ?? "" }))
377
- .filter((pair) => pair.text);
375
+ const pairs = blocks.flatMap((block, i) => {
376
+ const text = parts[i] ?? "";
377
+ return text ? [{ block, text }] : [];
378
+ });
378
379
  return pairs
379
380
  .map((pair, i) => {
380
381
  if (i === 0) {
@@ -460,6 +461,19 @@ export const notionSource = (
460
461
  };
461
462
  };
462
463
 
464
+ // Hoisted out of `load` so the retry closure doesn't nest past the linter's
465
+ // 4-level limit (source factory → queryDatabase → withNotionRetry callback).
466
+ const queryDatabase = (
467
+ client: NotionClientLike,
468
+ cursor?: string
469
+ ): Promise<NotionList<NotionPage>> =>
470
+ withNotionRetry(() =>
471
+ client.databases.query({
472
+ database_id: options.database,
473
+ start_cursor: cursor,
474
+ })
475
+ );
476
+
463
477
  const load = async (
464
478
  refresh = ctx?.refresh ?? true
465
479
  ): Promise<SourceLoadResult> => {
@@ -470,12 +484,7 @@ export const notionSource = (
470
484
  async () => {
471
485
  const client = await resolveClient();
472
486
  const pages = await collectAll((cursor) =>
473
- withNotionRetry(() =>
474
- client.databases.query({
475
- database_id: options.database,
476
- start_cursor: cursor,
477
- })
478
- )
487
+ queryDatabase(client, cursor)
479
488
  );
480
489
  const built = await Promise.all(
481
490
  pages.map((page) => toEntry(client, page))
@@ -106,6 +106,8 @@ export interface ContentSource {
106
106
  /** Context passed to `normalizeEntry`, describing the owning source. */
107
107
  export interface NormalizeContext {
108
108
  source: { name: string; prefix?: string; staged: boolean };
109
+ /** Site-wide route mount point (`""` or `/seg`), prepended to every route. */
110
+ basePath?: string;
109
111
  defaultType: string;
110
112
  i18n?: ResolvedI18nConfig;
111
113
  }
@@ -21,38 +21,65 @@ import { dirname, isAbsolute, join, resolve } from "pathe";
21
21
  * aliases (the prior behavior).
22
22
  */
23
23
 
24
+ interface ScanStep {
25
+ append: string;
26
+ inString: boolean;
27
+ next: number;
28
+ }
29
+
30
+ /** Scan one character (or comment/escape run) starting at `index`. */
31
+ const scanJsonChar = (
32
+ text: string,
33
+ index: number,
34
+ inString: boolean
35
+ ): ScanStep => {
36
+ const char = text[index];
37
+ if (inString) {
38
+ if (char === "\\") {
39
+ return {
40
+ append: char + (text[index + 1] ?? ""),
41
+ inString: true,
42
+ next: index + 2,
43
+ };
44
+ }
45
+ return { append: char ?? "", inString: char !== '"', next: index + 1 };
46
+ }
47
+ if (char === '"') {
48
+ return { append: char, inString: true, next: index + 1 };
49
+ }
50
+ if (char === "/" && text[index + 1] === "/") {
51
+ const newline = text.indexOf("\n", index + 2);
52
+ return {
53
+ append: "",
54
+ inString: false,
55
+ next: newline === -1 ? text.length : newline,
56
+ };
57
+ }
58
+ if (char === "/" && text[index + 1] === "*") {
59
+ const end = text.indexOf("*/", index + 2);
60
+ return {
61
+ append: "",
62
+ inString: false,
63
+ next: end === -1 ? text.length : end + 2,
64
+ };
65
+ }
66
+ return { append: char ?? "", inString: false, next: index + 1 };
67
+ };
68
+
24
69
  /** Strip `//` line and `/* *\/` block comments that sit outside strings. */
25
70
  const stripJsonComments = (text: string): string => {
26
71
  let out = "";
27
72
  let inString = false;
28
- for (let index = 0; index < text.length; index += 1) {
29
- const char = text[index];
30
- if (inString) {
31
- out += char;
32
- if (char === "\\") {
33
- out += text[index + 1] ?? "";
34
- index += 1;
35
- } else if (char === '"') {
36
- inString = false;
37
- }
38
- continue;
39
- }
40
- if (char === '"') {
41
- inString = true;
42
- out += char;
43
- continue;
44
- }
45
- if (char === "/" && text[index + 1] === "/") {
46
- const newline = text.indexOf("\n", index + 2);
47
- index = newline === -1 ? text.length : newline - 1;
48
- continue;
49
- }
50
- if (char === "/" && text[index + 1] === "*") {
51
- const end = text.indexOf("*/", index + 2);
52
- index = end === -1 ? text.length : end + 1;
53
- continue;
54
- }
55
- out += char;
73
+ let index = 0;
74
+ while (index < text.length) {
75
+ const {
76
+ append,
77
+ inString: nextInString,
78
+ next,
79
+ } = scanJsonChar(text, index, inString);
80
+ out += append;
81
+ inString = nextInString;
82
+ index = next;
56
83
  }
57
84
  return out;
58
85
  };
@@ -97,10 +124,12 @@ const resolveExtends = (spec: string, fromDir: string): string | null => {
97
124
  }
98
125
  // A bare specifier points at a package's shared config (e.g. `@tsconfig/*`).
99
126
  try {
100
- const require_ = createRequire(pathToFileURL(join(fromDir, "_.js")).href);
127
+ const requireFromDir = createRequire(
128
+ pathToFileURL(join(fromDir, "_.js")).href
129
+ );
101
130
  for (const sub of [`${spec}/tsconfig.json`, spec]) {
102
131
  try {
103
- return require_.resolve(sub);
132
+ return requireFromDir.resolve(sub);
104
133
  } catch {
105
134
  // try the next candidate
106
135
  }
package/src/core/types.ts CHANGED
@@ -173,10 +173,15 @@ export interface NavSelectorItem {
173
173
  tag?: string;
174
174
  }
175
175
 
176
+ /** Context-partition selector kinds (a versioned/localized/multi-product site). */
177
+ type NavSelectorContextKind = "product" | "version";
178
+ /** What a top-level partition selector switches between. */
179
+ type NavSelectorKind = "dropdown" | "language" | NavSelectorContextKind;
180
+
176
181
  /** Top-level partition selectors (products, versions, languages). */
177
182
  export interface NavSelector {
178
183
  label: string;
179
- kind: "dropdown" | "language" | "product" | "version";
184
+ kind: NavSelectorKind;
180
185
  items: NavSelectorItem[];
181
186
  }
182
187
 
@@ -1,3 +1,4 @@
1
+ import { withBasePath } from "../core/base-path.ts";
1
2
  import type { ResolvedConfig } from "../core/schema.ts";
2
3
 
3
4
  /**
@@ -10,6 +11,23 @@ import type { ResolvedConfig } from "../core/schema.ts";
10
11
 
11
12
  type Redirect = ResolvedConfig["redirects"][number];
12
13
 
14
+ /**
15
+ * Prepend the site-wide `basePath` to each redirect's internal `from`/`to`
16
+ * (both are authored as if mounted at root); external `to` URLs pass through.
17
+ * Idempotent, so re-basing an already-based redirect is safe.
18
+ */
19
+ export const applyBaseToRedirects = (
20
+ redirects: Redirect[],
21
+ basePath: string
22
+ ): Redirect[] =>
23
+ basePath
24
+ ? redirects.map((redirect) => ({
25
+ ...redirect,
26
+ from: withBasePath(basePath, redirect.from),
27
+ to: withBasePath(basePath, redirect.to),
28
+ }))
29
+ : redirects;
30
+
13
31
  /** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
14
32
  export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
15
33
  `${redirects
@@ -1,3 +1,4 @@
1
+ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
1
2
  import type { BlumeProject } from "../core/project-graph.ts";
2
3
  import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
3
4
 
@@ -47,7 +48,11 @@ export const buildRobots = (project: BlumeProject): string | null => {
47
48
 
48
49
  const { site } = config.deployment;
49
50
  if (site && config.seo.sitemap) {
50
- lines.push("", `Sitemap: ${site.replace(/\/$/u, "")}/sitemap.xml`);
51
+ const sitemapPath = withBasePath(
52
+ normalizeBasePath(config.deployment.base),
53
+ "/sitemap.xml"
54
+ );
55
+ lines.push("", `Sitemap: ${site.replace(/\/$/u, "")}${sitemapPath}`);
51
56
  }
52
57
  return `${lines.join("\n")}\n`;
53
58
  };