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.
- package/dist/cli/index.js +1179 -738
- package/dist/cli/index.js.map +52 -51
- package/dist/types/core/base-path.d.ts +38 -0
- package/dist/types/core/config-input.d.ts +74 -10
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +1 -3
- package/dist/types/core/schema.d.ts +95 -52
- package/dist/types/core/sources/types.d.ts +2 -0
- package/dist/types/core/types.d.ts +6 -1
- package/docs/02-deployment.mdx +16 -1
- package/docs/03-faq.mdx +8 -8
- package/docs/configuration/index.mdx +6 -0
- package/docs/content/components.mdx +29 -2
- package/docs/content/islands.mdx +8 -0
- package/docs/content/syntax.mdx +13 -0
- package/package.json +2 -1
- package/src/ai/agent-readability.ts +7 -2
- package/src/ai/ask.ts +12 -7
- package/src/ai/llms.ts +15 -4
- package/src/ai/mcp/data.ts +8 -4
- package/src/ai/mcp/server.ts +3 -0
- package/src/astro/component-slots.ts +5 -3
- package/src/astro/examples.ts +12 -7
- package/src/astro/generate.ts +317 -144
- package/src/astro/index.ts +5 -1
- package/src/astro/integration.ts +8 -4
- package/src/astro/islands.ts +11 -5
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +8 -3
- package/src/astro/templates.ts +166 -19
- package/src/cli/commands/build.ts +32 -19
- package/src/cli/commands/dev.ts +48 -15
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/validate.ts +1 -0
- package/src/cli/dev-lock.ts +26 -15
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/CodeBlock.astro +3 -0
- package/src/components/content/Component.astro +30 -16
- package/src/components/content/Diff.astro +3 -1
- package/src/components/content/auto-type-table.ts +18 -8
- package/src/components/content/diff.ts +12 -6
- package/src/components/content/mermaid-element.ts +3 -0
- package/src/components/index.ts +23 -1
- package/src/components/islands/ask-ai.tsx +12 -6
- package/src/components/islands/base-path.ts +28 -0
- package/src/components/islands/hooks.ts +16 -1
- package/src/components/layout/Banner.astro +2 -1
- package/src/components/layout/Breadcrumbs.astro +2 -1
- package/src/components/layout/Favicon.astro +3 -2
- package/src/components/layout/Header.astro +2 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +2 -1
- package/src/components/layout/NavSelector.astro +2 -1
- package/src/components/layout/NavTree.astro +5 -4
- package/src/components/layout/PageFeedback.astro +4 -1
- package/src/components/layout/PageLayout.astro +9 -4
- package/src/components/layout/Pagination.astro +3 -2
- package/src/components/layout/RootLayout.astro +7 -4
- package/src/components/layout/Search.astro +13 -5
- package/src/components/layout/nav-utils.ts +18 -10
- package/src/components/layout/search/pagefind.ts +3 -0
- package/src/components/layout/toc-element.ts +7 -1
- package/src/components/openapi/RequestPanel.astro +7 -1
- package/src/components/openapi/snippets.ts +25 -11
- package/src/core/base-path.ts +70 -0
- package/src/core/component-overrides.ts +103 -74
- package/src/core/config-input.ts +81 -15
- package/src/core/config.ts +5 -3
- package/src/core/content.ts +2 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +54 -34
- package/src/core/gitignore.ts +4 -1
- package/src/core/graph.ts +156 -88
- package/src/core/i18n-ui.ts +18 -3
- package/src/core/last-modified.ts +2 -0
- package/src/core/links.ts +38 -18
- package/src/core/manifest.ts +62 -45
- package/src/core/nav-diagnostics.ts +1 -1
- package/src/core/navigation.ts +116 -55
- package/src/core/project-graph.ts +10 -9
- package/src/core/schema.ts +572 -621
- package/src/core/sources/github-releases.ts +2 -1
- package/src/core/sources/mdx-remote.ts +58 -54
- package/src/core/sources/normalize.ts +116 -73
- package/src/core/sources/notion.ts +19 -10
- package/src/core/sources/types.ts +2 -0
- package/src/core/tsconfig-aliases.ts +59 -30
- package/src/core/types.ts +6 -1
- package/src/deploy/redirects.ts +18 -0
- package/src/deploy/robots.ts +6 -1
- package/src/deploy/rss.ts +10 -3
- package/src/deploy/sitemap.ts +14 -10
- package/src/markdown/base-links.ts +58 -0
- package/src/markdown/code-title.ts +11 -14
- package/src/markdown/index.ts +34 -9
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/themes.ts +24 -0
- package/src/openapi/model.ts +3 -1
- package/src/openapi/references.ts +41 -17
- package/src/openapi/render-mdx.ts +11 -6
- package/src/openapi/scalar.ts +32 -16
- package/src/registry/eject.ts +64 -8
- package/src/search/build.ts +3 -0
- package/src/search/documents.ts +2 -2
- package/src/search/sync/typesense.ts +6 -4
- package/src/seo/jsonld.ts +16 -6
- 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
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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:
|
|
184
|
+
kind: NavSelectorKind;
|
|
180
185
|
items: NavSelectorItem[];
|
|
181
186
|
}
|
|
182
187
|
|
package/src/deploy/redirects.ts
CHANGED
|
@@ -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
|
package/src/deploy/robots.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|