blume 2.2.2 → 2.2.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 +18 -0
- package/THIRD-PARTY-NOTICES.md +51 -0
- package/dist/cli/{chunk-kmswjxj9.js → chunk-0b1a8xqz.js} +3 -3
- package/dist/cli/{chunk-rtvgmg2j.js → chunk-0qx6vwwf.js} +2 -2
- package/dist/cli/{chunk-qcyxkj44.js → chunk-3ps63r91.js} +43 -17
- package/dist/cli/chunk-3ps63r91.js.map +15 -0
- package/dist/cli/{chunk-b680sv30.js → chunk-402xaaxn.js} +10 -8
- package/dist/cli/chunk-402xaaxn.js.map +13 -0
- package/dist/cli/{chunk-k45sq16q.js → chunk-49xa4yf2.js} +39 -23
- package/dist/cli/{chunk-k45sq16q.js.map → chunk-49xa4yf2.js.map} +3 -3
- package/dist/cli/{chunk-j4nfgn2j.js → chunk-51wr1wre.js} +1 -1
- package/dist/cli/{chunk-9jqmq5hv.js → chunk-531qbwn1.js} +6 -6
- package/dist/cli/{chunk-r5nm9w0c.js → chunk-5bp65pv4.js} +107 -37
- package/dist/cli/{chunk-r5nm9w0c.js.map → chunk-5bp65pv4.js.map} +11 -10
- package/dist/cli/{chunk-yg8ckc3g.js → chunk-6nvcjzts.js} +2 -2
- package/dist/cli/{chunk-x7bh6d9j.js → chunk-6v5w8vzd.js} +4 -4
- package/dist/cli/{chunk-f260jdrn.js → chunk-8cshtem6.js} +5 -5
- package/dist/cli/{chunk-ydqzkhjn.js → chunk-8h5ceyk0.js} +6 -6
- package/dist/cli/{chunk-e1n41w9m.js → chunk-8v90n67z.js} +3 -3
- package/dist/cli/{chunk-etckvbrh.js → chunk-98me5yas.js} +14 -14
- package/dist/cli/{chunk-hktq4j5r.js → chunk-9k7gg4ga.js} +16 -10
- package/dist/cli/{chunk-hktq4j5r.js.map → chunk-9k7gg4ga.js.map} +5 -5
- package/dist/cli/{chunk-34wap3ed.js → chunk-9qmc57zw.js} +3 -3
- package/dist/cli/{chunk-beb0z983.js → chunk-a1xmzxk2.js} +16 -16
- package/dist/cli/{chunk-gwy55cx3.js → chunk-ajw85dgf.js} +2 -2
- package/dist/cli/{chunk-69rjf786.js → chunk-ansqj08e.js} +5 -5
- package/dist/cli/{chunk-q1wwrxzw.js → chunk-b0vwhmdf.js} +3 -3
- package/dist/cli/chunk-b0vwhmdf.js.map +10 -0
- package/dist/cli/{chunk-nqna8dzp.js → chunk-b5gztt4j.js} +17 -17
- package/dist/cli/{chunk-fnxgtjeq.js → chunk-bc4jz2m4.js} +1 -1
- package/dist/cli/{chunk-aafhwx74.js → chunk-d4h1y312.js} +1 -1
- package/dist/cli/{chunk-080mfch6.js → chunk-et7zwqkg.js} +15 -15
- package/dist/cli/{chunk-487b95qy.js → chunk-f29q3j13.js} +41 -25
- package/dist/cli/{chunk-487b95qy.js.map → chunk-f29q3j13.js.map} +3 -3
- package/dist/cli/{chunk-c5bjxq8v.js → chunk-f72388jn.js} +2 -2
- package/dist/cli/{chunk-t2h8pwy1.js → chunk-g0hpwbyx.js} +4 -4
- package/dist/cli/{chunk-40vk8kgf.js → chunk-j102xmnk.js} +7 -7
- package/dist/cli/{chunk-ppa6mg4d.js → chunk-m54bz9ng.js} +6 -6
- package/dist/cli/{chunk-h0qwdbem.js → chunk-mt6njy68.js} +2 -2
- package/dist/cli/{chunk-2ssh60qh.js → chunk-n091sgam.js} +6 -6
- package/dist/cli/{chunk-1ch3ct3r.js → chunk-p33bf2nb.js} +14 -14
- package/dist/cli/{chunk-4zxh25hc.js → chunk-saxq5jjr.js} +2 -2
- package/dist/cli/{chunk-x60e15st.js → chunk-sbt1s5j2.js} +17 -17
- package/dist/cli/{chunk-gxhcf2hs.js → chunk-tckd6qwv.js} +2 -2
- package/dist/cli/{chunk-p834rkcn.js → chunk-tf4134kk.js} +5 -5
- package/dist/cli/{chunk-zxd5jrgx.js → chunk-vxdjss7f.js} +76 -34
- package/dist/cli/{chunk-zxd5jrgx.js.map → chunk-vxdjss7f.js.map} +4 -4
- package/dist/cli/{chunk-9h8mh2tk.js → chunk-x2pgg22t.js} +1 -1
- package/dist/cli/{chunk-hn3g2b89.js → chunk-xhrwrzrh.js} +9 -9
- package/dist/cli/index.js +18 -18
- package/dist/types/ai/ask-context.d.ts +8 -0
- package/dist/types/ai/link-text.d.ts +5 -0
- package/dist/types/ai/relative-links.d.ts +26 -6
- package/dist/types/core/config-input.d.ts +41 -1
- package/dist/types/core/data.d.ts +14 -0
- package/dist/types/core/last-modified.d.ts +33 -18
- package/dist/types/core/links.d.ts +16 -0
- package/dist/types/core/schema.d.ts +17 -1
- package/dist/types/core/types.d.ts +10 -0
- package/dist/types/deploy/sitemap.d.ts +9 -0
- package/dist/types/search/documents.d.ts +3 -1
- package/dist/types/search/fold.d.ts +33 -0
- package/dist/types/search/orama-index.d.ts +4 -4
- package/dist/types/seo/jsonld.d.ts +33 -0
- package/docs/configuration/search.mdx +2 -2
- package/docs/configuration/theming.mdx +13 -1
- package/docs/content/frontmatter.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/sources/remote-mdx.mdx +1 -1
- package/docs/discoverability/open-graph.mdx +16 -1
- package/docs/discoverability/sitemap-and-robots.mdx +12 -0
- package/docs/discoverability/structured-data.mdx +44 -0
- package/package.json +4 -3
- package/skills/blume-migrate/references/docus.md +1 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/vitepress.md +2 -2
- package/skills/blume-migrate/references/vuepress.md +1 -1
- package/src/ai/ask-context.ts +52 -14
- package/src/ai/ask-data.ts +4 -1
- package/src/ai/ask-tools.ts +17 -5
- package/src/ai/link-text.ts +6 -0
- package/src/ai/llms.ts +1 -7
- package/src/ai/markdown.ts +8 -4
- package/src/ai/relative-links.ts +155 -48
- package/src/ai/site-skill.ts +1 -4
- package/src/astro/generate.ts +7 -0
- package/src/astro/templates.ts +36 -20
- package/src/components/layout/Header.astro +18 -10
- package/src/components/layout/PageActions.astro +1 -0
- package/src/components/layout/PageLayout.astro +31 -13
- package/src/components/layout/ReferenceLayout.astro +6 -1
- package/src/components/layout/RootLayout.astro +13 -1
- package/src/components/layout/head-scripts.ts +5 -1
- package/src/components/layout/locale-redirect.ts +38 -19
- package/src/components/layout/search/types.ts +12 -4
- package/src/core/config-input.ts +40 -1
- package/src/core/config.ts +5 -2
- package/src/core/data.ts +14 -0
- package/src/core/last-modified.ts +80 -34
- package/src/core/links.ts +43 -2
- package/src/core/manifest.ts +2 -0
- package/src/core/project-graph.ts +83 -43
- package/src/core/schema.ts +48 -9
- package/src/core/types.ts +10 -0
- package/src/deploy/sitemap.ts +127 -49
- package/src/search/documents.ts +34 -11
- package/src/search/fold.ts +175 -0
- package/src/search/orama-index.ts +40 -49
- package/src/seo/jsonld.ts +100 -23
- package/src/theme/chrome-icons.ts +2 -1
- package/src/theme/entry.ts +4 -1
- package/dist/cli/chunk-b680sv30.js.map +0 -13
- package/dist/cli/chunk-q1wwrxzw.js.map +0 -10
- package/dist/cli/chunk-qcyxkj44.js.map +0 -14
- /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-0b1a8xqz.js.map} +0 -0
- /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-0qx6vwwf.js.map} +0 -0
- /package/dist/cli/{chunk-j4nfgn2j.js.map → chunk-51wr1wre.js.map} +0 -0
- /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-531qbwn1.js.map} +0 -0
- /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-6nvcjzts.js.map} +0 -0
- /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6v5w8vzd.js.map} +0 -0
- /package/dist/cli/{chunk-f260jdrn.js.map → chunk-8cshtem6.js.map} +0 -0
- /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-8h5ceyk0.js.map} +0 -0
- /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-8v90n67z.js.map} +0 -0
- /package/dist/cli/{chunk-etckvbrh.js.map → chunk-98me5yas.js.map} +0 -0
- /package/dist/cli/{chunk-34wap3ed.js.map → chunk-9qmc57zw.js.map} +0 -0
- /package/dist/cli/{chunk-beb0z983.js.map → chunk-a1xmzxk2.js.map} +0 -0
- /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-ajw85dgf.js.map} +0 -0
- /package/dist/cli/{chunk-69rjf786.js.map → chunk-ansqj08e.js.map} +0 -0
- /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-b5gztt4j.js.map} +0 -0
- /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-bc4jz2m4.js.map} +0 -0
- /package/dist/cli/{chunk-aafhwx74.js.map → chunk-d4h1y312.js.map} +0 -0
- /package/dist/cli/{chunk-080mfch6.js.map → chunk-et7zwqkg.js.map} +0 -0
- /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-f72388jn.js.map} +0 -0
- /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-g0hpwbyx.js.map} +0 -0
- /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-j102xmnk.js.map} +0 -0
- /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-m54bz9ng.js.map} +0 -0
- /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-mt6njy68.js.map} +0 -0
- /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-n091sgam.js.map} +0 -0
- /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-p33bf2nb.js.map} +0 -0
- /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-saxq5jjr.js.map} +0 -0
- /package/dist/cli/{chunk-x60e15st.js.map → chunk-sbt1s5j2.js.map} +0 -0
- /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-tckd6qwv.js.map} +0 -0
- /package/dist/cli/{chunk-p834rkcn.js.map → chunk-tf4134kk.js.map} +0 -0
- /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-x2pgg22t.js.map} +0 -0
- /package/dist/cli/{chunk-hn3g2b89.js.map → chunk-xhrwrzrh.js.map} +0 -0
|
@@ -14,8 +14,10 @@ import { buildContentGraph } from "./graph.ts";
|
|
|
14
14
|
import { i18nDiagnostics } from "./i18n.ts";
|
|
15
15
|
import { expandIncludes, hasIncludeStatements } from "./includes.ts";
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
datePublishedShallowWarning,
|
|
18
|
+
gitFileDates,
|
|
18
19
|
gitRepositoryRoot,
|
|
20
|
+
isShallowGitRepository,
|
|
19
21
|
lastModifiedShallowWarning,
|
|
20
22
|
realPath,
|
|
21
23
|
resolveLastModifiedConfig,
|
|
@@ -301,6 +303,82 @@ const gitContentRoots = (
|
|
|
301
303
|
.filter((dir) => isWithin(top, realPath(dir)));
|
|
302
304
|
};
|
|
303
305
|
|
|
306
|
+
/** Whether a page's front matter dates its publication itself. */
|
|
307
|
+
const hasPublishedDate = (page: PageRecord): boolean =>
|
|
308
|
+
Boolean(page.meta.date ?? page.meta.changelog?.date);
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Fill in each page's git dates — "last updated" under `lastModified: "git"`,
|
|
312
|
+
* published under `seo.datePublished: "git"` — from one `git log`, and warn
|
|
313
|
+
* when a shallow clone leaves pages undated. Frontmatter always wins; git
|
|
314
|
+
* applies to filesystem entries, other sources supply dates on the entry.
|
|
315
|
+
*/
|
|
316
|
+
const applyGitDates = (
|
|
317
|
+
pages: PageRecord[],
|
|
318
|
+
sources: readonly ContentSource[],
|
|
319
|
+
root: string,
|
|
320
|
+
config: ResolvedConfig
|
|
321
|
+
): Diagnostic[] => {
|
|
322
|
+
const lastModified = resolveLastModifiedConfig(config.lastModified);
|
|
323
|
+
const gitModified = lastModified.enabled && lastModified.source === "git";
|
|
324
|
+
const gitPublished = config.seo.datePublished === "git";
|
|
325
|
+
if (!(gitModified || gitPublished)) {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
const fsPaths = pages
|
|
329
|
+
.map((page) => page.sourcePath)
|
|
330
|
+
.filter((path): path is string => path !== undefined);
|
|
331
|
+
// The git pathspecs must cover where the pages actually live: each local
|
|
332
|
+
// source's own on-disk root — a filesystem source's `root`, or a staged
|
|
333
|
+
// local source's tree (an Obsidian vault) — which diverges from the global
|
|
334
|
+
// `content.root`.
|
|
335
|
+
const gitRoot = gitRepositoryRoot(root);
|
|
336
|
+
const contentRoots = gitContentRoots(sources, gitRoot);
|
|
337
|
+
const gitDates = gitFileDates(root, contentRoots, fsPaths, gitRoot);
|
|
338
|
+
// Only pages the log could have dated count toward the shallow-clone
|
|
339
|
+
// warnings — a page outside every covered root stays undated no matter how
|
|
340
|
+
// deep the clone is, and the warning's suggested fix cannot help it.
|
|
341
|
+
const datable = pages.filter(
|
|
342
|
+
(page) =>
|
|
343
|
+
page.sourcePath &&
|
|
344
|
+
contentRoots.some((dir) => isWithin(dir, page.sourcePath ?? ""))
|
|
345
|
+
);
|
|
346
|
+
const warnings: Diagnostic[] = [];
|
|
347
|
+
if (gitModified) {
|
|
348
|
+
for (const page of pages) {
|
|
349
|
+
if (!page.lastModified && page.sourcePath) {
|
|
350
|
+
page.lastModified = gitDates.modified.get(page.sourcePath);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// A shallow CI clone (Vercel, actions/checkout) silently drops most dates;
|
|
354
|
+
// surface that instead of letting production diverge from local builds.
|
|
355
|
+
warnings.push(
|
|
356
|
+
...lastModifiedShallowWarning(
|
|
357
|
+
root,
|
|
358
|
+
datable.filter((page) => !page.lastModified).length
|
|
359
|
+
)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (gitPublished) {
|
|
363
|
+
// A shallow clone's oldest commit is where it was cut, not where a page
|
|
364
|
+
// began, so it dates nothing rather than every page wrongly.
|
|
365
|
+
const shallow = isShallowGitRepository(root);
|
|
366
|
+
for (const page of pages) {
|
|
367
|
+
if (!(shallow || hasPublishedDate(page)) && page.sourcePath) {
|
|
368
|
+
page.published = gitDates.published.get(page.sourcePath);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (shallow) {
|
|
372
|
+
warnings.push(
|
|
373
|
+
...datePublishedShallowWarning(
|
|
374
|
+
datable.filter((page) => !hasPublishedDate(page)).length
|
|
375
|
+
)
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return warnings;
|
|
380
|
+
};
|
|
381
|
+
|
|
304
382
|
/**
|
|
305
383
|
* Run the full core pipeline for a project root: load config, resolve paths,
|
|
306
384
|
* discover content and folder meta, build the graph, and assemble the manifest.
|
|
@@ -681,47 +759,9 @@ export const scanProject = async (
|
|
|
681
759
|
? allPages.filter((page) => !page.meta.draft)
|
|
682
760
|
: allPages;
|
|
683
761
|
|
|
684
|
-
// Resolve "last updated" dates before the graph is built so
|
|
685
|
-
// (which shares these page objects) picks them up.
|
|
686
|
-
|
|
687
|
-
const lastModified = resolveLastModifiedConfig(config.lastModified);
|
|
688
|
-
const lastModifiedWarnings: Diagnostic[] = [];
|
|
689
|
-
if (lastModified.enabled && lastModified.source === "git") {
|
|
690
|
-
const fsPaths = pages
|
|
691
|
-
.map((page) => page.sourcePath)
|
|
692
|
-
.filter((path): path is string => path !== undefined);
|
|
693
|
-
// The git pathspecs must cover where the pages actually live: each local
|
|
694
|
-
// source's own on-disk root — a filesystem source's `root`, or a staged
|
|
695
|
-
// local source's tree (an Obsidian vault) — which diverges from the global
|
|
696
|
-
// `content.root`.
|
|
697
|
-
const gitRoot = gitRepositoryRoot(context.root);
|
|
698
|
-
const contentRoots = gitContentRoots(sources, gitRoot);
|
|
699
|
-
const gitTimes = gitLastModifiedTimes(
|
|
700
|
-
context.root,
|
|
701
|
-
contentRoots,
|
|
702
|
-
fsPaths,
|
|
703
|
-
gitRoot
|
|
704
|
-
);
|
|
705
|
-
for (const page of pages) {
|
|
706
|
-
if (!page.lastModified && page.sourcePath) {
|
|
707
|
-
page.lastModified = gitTimes.get(page.sourcePath);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
// A shallow CI clone (Vercel, actions/checkout) silently drops most dates;
|
|
711
|
-
// surface that instead of letting production diverge from local builds.
|
|
712
|
-
// Only pages the log could have dated count — a page outside every covered
|
|
713
|
-
// root stays undated no matter how deep the clone is, and the warning's
|
|
714
|
-
// suggested fix cannot help it.
|
|
715
|
-
const undated = pages.filter(
|
|
716
|
-
(page) =>
|
|
717
|
-
page.sourcePath &&
|
|
718
|
-
!page.lastModified &&
|
|
719
|
-
contentRoots.some((dir) => isWithin(dir, page.sourcePath ?? ""))
|
|
720
|
-
).length;
|
|
721
|
-
lastModifiedWarnings.push(
|
|
722
|
-
...lastModifiedShallowWarning(context.root, undated)
|
|
723
|
-
);
|
|
724
|
-
}
|
|
762
|
+
// Resolve "last updated" and publication dates before the graph is built so
|
|
763
|
+
// the manifest (which shares these page objects) picks them up.
|
|
764
|
+
const gitDateWarnings = applyGitDates(pages, sources, context.root, config);
|
|
725
765
|
|
|
726
766
|
// Custom `.astro` pages and the generated changelog index aren't content
|
|
727
767
|
// pages, so name them for navigation: a `/changelog` tab should open the
|
|
@@ -791,7 +831,7 @@ export const scanProject = async (
|
|
|
791
831
|
...graph.diagnostics,
|
|
792
832
|
...i18nWarnings,
|
|
793
833
|
...versionWarnings,
|
|
794
|
-
...
|
|
834
|
+
...gitDateWarnings,
|
|
795
835
|
...svgWarnings,
|
|
796
836
|
...publicOpenApiDiagnostics(config, context.root),
|
|
797
837
|
...redirectPageDiagnostics(
|
package/src/core/schema.ts
CHANGED
|
@@ -207,10 +207,9 @@ const changelogMetaSchema = z.strictObject({
|
|
|
207
207
|
|
|
208
208
|
/**
|
|
209
209
|
* A post author: a bare name/handle, or an object with a name plus optional
|
|
210
|
-
* avatar/URL.
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
* than have a strict scan reject it.
|
|
210
|
+
* avatar/URL. Blume credits the name and URL in the page's JSON-LD; the object
|
|
211
|
+
* is passthrough so richer author metadata (social handles, roles) survives
|
|
212
|
+
* untouched rather than have a strict scan reject it.
|
|
214
213
|
*/
|
|
215
214
|
const authorSchema = z.union([
|
|
216
215
|
z.string(),
|
|
@@ -254,7 +253,7 @@ const pageMetaBaseSchema = z.strictObject({
|
|
|
254
253
|
.optional(),
|
|
255
254
|
/** How this page's endpoint authenticates, over the site's `api.auth`. */
|
|
256
255
|
authMethod: z.enum(AUTH_METHODS).optional(),
|
|
257
|
-
/** Post author(s) for blog/changelog content
|
|
256
|
+
/** Post author(s) for blog/changelog content, credited in JSON-LD. */
|
|
258
257
|
authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
|
|
259
258
|
changelog: changelogMetaSchema.optional(),
|
|
260
259
|
/** Publish date for feed-backed content like blog/changelog. */
|
|
@@ -762,6 +761,7 @@ const themeConfigFields = {
|
|
|
762
761
|
.prefault({}),
|
|
763
762
|
mode: z.enum(["system", "light", "dark"]).default("system"),
|
|
764
763
|
radius: z.enum(["none", "sm", "md", "lg"]).default("md"),
|
|
764
|
+
toggle: z.boolean().default(true),
|
|
765
765
|
};
|
|
766
766
|
|
|
767
767
|
const themeConfigSchema = z.strictObject(
|
|
@@ -1609,8 +1609,9 @@ const ogConfigSchema = z.strictObject({
|
|
|
1609
1609
|
/**
|
|
1610
1610
|
* Generate a per-page Open Graph image. Defaults to on once a deployment
|
|
1611
1611
|
* site URL is known (set or auto-detected) and off otherwise, since
|
|
1612
|
-
* `og:image` must be absolute to be useful to crawlers
|
|
1613
|
-
*
|
|
1612
|
+
* `og:image` must be absolute to be useful to crawlers, and off when `image`
|
|
1613
|
+
* sets a default every page would show instead — resolved in `loadConfig`.
|
|
1614
|
+
* An explicit value here always wins.
|
|
1614
1615
|
*/
|
|
1615
1616
|
enabled: z.boolean().optional(),
|
|
1616
1617
|
/**
|
|
@@ -1619,6 +1620,13 @@ const ogConfigSchema = z.strictObject({
|
|
|
1619
1620
|
* families are fetched from Google Fonts at build.
|
|
1620
1621
|
*/
|
|
1621
1622
|
fonts: z.array(ogFontSchema).optional(),
|
|
1623
|
+
/**
|
|
1624
|
+
* Default social image for pages without their own `seo.image`, in place of
|
|
1625
|
+
* the generated card: one path or URL, or one per locale (resolved like any
|
|
1626
|
+
* localizable label). A root-relative path is served under the deployment
|
|
1627
|
+
* base.
|
|
1628
|
+
*/
|
|
1629
|
+
image: localizableLabelSchema.optional(),
|
|
1622
1630
|
/**
|
|
1623
1631
|
* Local SVG used in the generated card instead of the site logo; `false`
|
|
1624
1632
|
* renders the card without any brand mark.
|
|
@@ -1728,6 +1736,29 @@ type SoftwareResolved = z.output<typeof softwareConfigSchema>;
|
|
|
1728
1736
|
|
|
1729
1737
|
/** Discoverability features: OG images, feeds, sitemap, structured data. */
|
|
1730
1738
|
const seoConfigFields = {
|
|
1739
|
+
/**
|
|
1740
|
+
* Date pages without a front matter `date` by the git commit that added
|
|
1741
|
+
* their source file, following exact renames. Off by default.
|
|
1742
|
+
*/
|
|
1743
|
+
datePublished: z.union([z.literal(false), z.literal("git")]).default(false),
|
|
1744
|
+
/**
|
|
1745
|
+
* Structured-data entities shared with a parent site (`example.com/docs`),
|
|
1746
|
+
* referenced by absolute `@id` instead of defined again (see `seo/jsonld.ts`).
|
|
1747
|
+
*/
|
|
1748
|
+
jsonLd: z
|
|
1749
|
+
.strictObject({
|
|
1750
|
+
/** Credited on pages without their own `authors`. */
|
|
1751
|
+
author: z.strictObject({ "@id": z.url() }).optional(),
|
|
1752
|
+
/** The Organization's `@id`; `seo.organization` still adds its details. */
|
|
1753
|
+
organizationId: z.url().optional(),
|
|
1754
|
+
/** Every page's publisher, over the Organization. */
|
|
1755
|
+
publisher: z.strictObject({ "@id": z.url() }).optional(),
|
|
1756
|
+
/** Emit the WebSite node. Defaults to true unless `websiteId` is set. */
|
|
1757
|
+
website: z.boolean().optional(),
|
|
1758
|
+
/** The WebSite's `@id`, which every page names as `isPartOf`. */
|
|
1759
|
+
websiteId: z.url().optional(),
|
|
1760
|
+
})
|
|
1761
|
+
.prefault({}),
|
|
1731
1762
|
/**
|
|
1732
1763
|
* Meta tags written into every page's head, name to content: site
|
|
1733
1764
|
* verification, `theme-color`, and anything else Blume has no setting for.
|
|
@@ -1754,8 +1785,16 @@ const seoConfigFields = {
|
|
|
1754
1785
|
/** Generate robots.txt (with a Sitemap reference when available). */
|
|
1755
1786
|
robots: z.boolean().default(true),
|
|
1756
1787
|
rss: rssConfigSchema.prefault({}),
|
|
1757
|
-
/**
|
|
1758
|
-
|
|
1788
|
+
/**
|
|
1789
|
+
* Generate sitemap.xml (requires deployment.site). `{ alternates: true }`
|
|
1790
|
+
* also lists each page's translations as hreflang links.
|
|
1791
|
+
*/
|
|
1792
|
+
sitemap: z
|
|
1793
|
+
.union([
|
|
1794
|
+
z.boolean(),
|
|
1795
|
+
z.strictObject({ alternates: z.boolean().default(false) }),
|
|
1796
|
+
])
|
|
1797
|
+
.default(true),
|
|
1759
1798
|
/** The documented product, as a homepage `SoftwareApplication` node. */
|
|
1760
1799
|
software: z
|
|
1761
1800
|
.union([z.boolean(), softwareConfigSchema])
|
package/src/core/types.ts
CHANGED
|
@@ -256,6 +256,11 @@ export interface PageRecord {
|
|
|
256
256
|
unknownProps?: ElementUse[];
|
|
257
257
|
/** Resolved "last updated" ISO date, when the feature is enabled. */
|
|
258
258
|
lastModified?: string;
|
|
259
|
+
/**
|
|
260
|
+
* The git commit that added the source file, as an ISO date, when
|
|
261
|
+
* `seo.datePublished` is `"git"` and front matter carries no `date`.
|
|
262
|
+
*/
|
|
263
|
+
published?: string;
|
|
259
264
|
/** Absolute paths of files this page `<include>`s, transitively. Drives the
|
|
260
265
|
* dev-server invalidation edge from a partial to the pages that splice it. */
|
|
261
266
|
includes?: string[];
|
|
@@ -484,6 +489,11 @@ export interface RouteManifestEntry {
|
|
|
484
489
|
aiExclude?: boolean;
|
|
485
490
|
/** Resolved "last updated" ISO date, when the feature is enabled. */
|
|
486
491
|
lastModified?: string;
|
|
492
|
+
/**
|
|
493
|
+
* The git commit that added the source file, as an ISO date, when
|
|
494
|
+
* `seo.datePublished` is `"git"` and front matter carries no `date`.
|
|
495
|
+
*/
|
|
496
|
+
published?: string;
|
|
487
497
|
}
|
|
488
498
|
|
|
489
499
|
/** The generated runtime contract between core and the Astro project. */
|
package/src/deploy/sitemap.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { isHiddenPage } from "../core/hidden-pages.ts";
|
|
15
15
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
16
16
|
import { siteRoot } from "../core/site-url.ts";
|
|
17
|
+
import type { RouteAlternate } from "../core/types.ts";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Astro's reserved error routes. A user-authored override (`pages/404.astro`,
|
|
@@ -79,13 +80,95 @@ export const describeSitemapFiles = (files: readonly SitemapFile[]): string =>
|
|
|
79
80
|
const URLS_PER_FILE = 50_000;
|
|
80
81
|
|
|
81
82
|
const renderUrlset = (
|
|
82
|
-
urls: string[]
|
|
83
|
+
urls: string[],
|
|
84
|
+
alternatesEnabled: boolean
|
|
83
85
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
|
84
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
86
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${alternatesEnabled ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ""}>
|
|
85
87
|
${urls.join("\n")}
|
|
86
88
|
</urlset>
|
|
87
89
|
`;
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The content pages crawlers may index under their own URL: drafts, hidden,
|
|
93
|
+
* `noindex` (the page's or its archived version's), and error pages can't, nor
|
|
94
|
+
* can a page whose own `seo.canonical` names another URL.
|
|
95
|
+
*/
|
|
96
|
+
const indexablePages = (
|
|
97
|
+
project: BlumeProject
|
|
98
|
+
): BlumeProject["graph"]["pages"] => {
|
|
99
|
+
const deployBase = normalizeBasePath(project.config.deployment.options.base);
|
|
100
|
+
// A non-empty version always names a configured archived entry — that is
|
|
101
|
+
// the only way detection assigns one.
|
|
102
|
+
const noindexVersions = new Set(
|
|
103
|
+
(project.config.versions?.archived ?? []).flatMap((version) =>
|
|
104
|
+
version.noindex ? [version.id] : []
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
return project.graph.pages.filter(
|
|
108
|
+
(page) =>
|
|
109
|
+
!page.meta.draft &&
|
|
110
|
+
!isHiddenPage(page, project.graph) &&
|
|
111
|
+
!page.meta.seo.noindex &&
|
|
112
|
+
!noindexVersions.has(page.version) &&
|
|
113
|
+
!ERROR_ROUTES.has(page.route) &&
|
|
114
|
+
!canonicalElsewhere(
|
|
115
|
+
page.meta.seo.canonical,
|
|
116
|
+
mountBasePath(deployBase, page.route)
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The content pages the sitemap lists: the indexable ones, less archived-version
|
|
123
|
+
* pages whose canonical points at a still-existing latest equivalent —
|
|
124
|
+
* listing a URL whose canonical says "index the other page" invites the
|
|
125
|
+
* noindexed-page-in-sitemap incoherence Docusaurus is known for. A page that
|
|
126
|
+
* exists only in an archived version stays listed (self-canonical), and a
|
|
127
|
+
* page's own `seo.canonical` wins over the version's default, as it does in
|
|
128
|
+
* the page head (see `canonicalElsewhere`).
|
|
129
|
+
*/
|
|
130
|
+
const listedPages = (project: BlumeProject): BlumeProject["graph"]["pages"] => {
|
|
131
|
+
const latestVersions = new Set(
|
|
132
|
+
(project.config.versions?.archived ?? []).flatMap((version) =>
|
|
133
|
+
version.canonical === "latest" ? [version.id] : []
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
const currentKeys = new Set(
|
|
137
|
+
project.graph.pages.flatMap((page) =>
|
|
138
|
+
page.version === "" ? [`${page.versionKey}\u0000${page.locale}`] : []
|
|
139
|
+
)
|
|
140
|
+
);
|
|
141
|
+
return indexablePages(project).filter(
|
|
142
|
+
(page) =>
|
|
143
|
+
!(
|
|
144
|
+
latestVersions.has(page.version) &&
|
|
145
|
+
!page.meta.seo.canonical &&
|
|
146
|
+
currentKeys.has(`${page.versionKey}\u0000${page.locale}`)
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Each route's hreflang set, keyed by route: its indexable translations. The
|
|
153
|
+
* page head links it, and so does the sitemap under `seo.sitemap.alternates`,
|
|
154
|
+
* so the two never disagree, and neither points crawlers at a page they're
|
|
155
|
+
* told not to index. A page that can't be indexed joins no set — except an
|
|
156
|
+
* i18n fallback copy, which links the translations it stands in for.
|
|
157
|
+
*/
|
|
158
|
+
export const hreflangAlternates = (
|
|
159
|
+
project: BlumeProject
|
|
160
|
+
): Map<string, RouteAlternate[]> => {
|
|
161
|
+
const indexable = new Set(indexablePages(project).map((page) => page.route));
|
|
162
|
+
return new Map(
|
|
163
|
+
project.manifest.routes.map((route) => [
|
|
164
|
+
route.path,
|
|
165
|
+
route.fallback || indexable.has(route.path)
|
|
166
|
+
? route.alternates.filter((alternate) => indexable.has(alternate.path))
|
|
167
|
+
: [],
|
|
168
|
+
])
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
89
172
|
/**
|
|
90
173
|
* Build the sitemap files from the route manifest plus the routes the manifest
|
|
91
174
|
* can't see: custom `.astro` pages (most importantly a custom landing `/`) and
|
|
@@ -112,43 +195,38 @@ export const buildSitemapFiles = (
|
|
|
112
195
|
// top of every one, even a route whose first segment matches it: under base
|
|
113
196
|
// `/guides`, `guides/setup.md` is served at `/guides/guides/setup`.
|
|
114
197
|
const deployBase = normalizeBasePath(project.config.deployment.options.base);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
(versions?.archived ?? []).map((version) => [version.id, version])
|
|
126
|
-
);
|
|
127
|
-
const currentKeys = versions
|
|
128
|
-
? new Set(
|
|
129
|
-
project.graph.pages.flatMap((page) =>
|
|
130
|
-
page.version === "" ? [`${page.versionKey}\u0000${page.locale}`] : []
|
|
131
|
-
)
|
|
132
|
-
)
|
|
133
|
-
: null;
|
|
134
|
-
const archivedExcluded = (page: (typeof project.graph.pages)[number]) => {
|
|
135
|
-
if (!page.version) {
|
|
136
|
-
return false;
|
|
198
|
+
const alternatesEnabled =
|
|
199
|
+
project.config.seo.sitemap !== true &&
|
|
200
|
+
project.config.seo.sitemap.alternates;
|
|
201
|
+
const hreflang = alternatesEnabled ? hreflangAlternates(project) : null;
|
|
202
|
+
// A page's translations as `xhtml:link`s, plus `x-default` at the default
|
|
203
|
+
// locale's — the same set the page head links, for pages in 2+ locales.
|
|
204
|
+
const alternateTags = (route: string): string => {
|
|
205
|
+
const alternates = hreflang?.get(route) ?? [];
|
|
206
|
+
if (alternates.length < 2) {
|
|
207
|
+
return "";
|
|
137
208
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const archived = archivedById.get(page.version);
|
|
141
|
-
return Boolean(
|
|
142
|
-
archived?.noindex ||
|
|
143
|
-
(archived?.canonical === "latest" &&
|
|
144
|
-
!page.meta.seo.canonical &&
|
|
145
|
-
currentKeys?.has(`${page.versionKey}\u0000${page.locale}`))
|
|
209
|
+
const defaultPage = alternates.find(
|
|
210
|
+
(entry) => entry.locale === project.config.i18n?.defaultLocale
|
|
146
211
|
);
|
|
212
|
+
const links = defaultPage
|
|
213
|
+
? [...alternates, { ...defaultPage, locale: "x-default" }]
|
|
214
|
+
: alternates;
|
|
215
|
+
return links
|
|
216
|
+
.map(
|
|
217
|
+
(entry) =>
|
|
218
|
+
`<xhtml:link rel="alternate" hreflang="${escapeXml(entry.locale)}" href="${escapeXml(encodeURI(`${base}${mountBasePath(deployBase, entry.path)}`))}"/>`
|
|
219
|
+
)
|
|
220
|
+
.join("");
|
|
147
221
|
};
|
|
148
222
|
|
|
149
223
|
const seen = new Set<string>();
|
|
150
224
|
const urls: string[] = [];
|
|
151
|
-
const pushUrl = (
|
|
225
|
+
const pushUrl = (
|
|
226
|
+
route: string,
|
|
227
|
+
lastModified?: string,
|
|
228
|
+
alternates = ""
|
|
229
|
+
): void => {
|
|
152
230
|
// `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
|
|
153
231
|
// then escape XML metacharacters (notably `&`) so a route like
|
|
154
232
|
// `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
|
|
@@ -158,21 +236,16 @@ export const buildSitemapFiles = (
|
|
|
158
236
|
return;
|
|
159
237
|
}
|
|
160
238
|
seen.add(loc);
|
|
161
|
-
urls.push(
|
|
239
|
+
urls.push(
|
|
240
|
+
` <url><loc>${loc}</loc>${lastmodTag(lastModified)}${alternates}</url>`
|
|
241
|
+
);
|
|
162
242
|
};
|
|
163
|
-
for (const page of project
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
page.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
ERROR_ROUTES.has(page.route) ||
|
|
170
|
-
archivedExcluded(page) ||
|
|
171
|
-
canonicalElsewhere(page.meta.seo.canonical, served)
|
|
172
|
-
) {
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
pushUrl(served, page.lastModified);
|
|
243
|
+
for (const page of listedPages(project)) {
|
|
244
|
+
pushUrl(
|
|
245
|
+
mountBasePath(deployBase, page.route),
|
|
246
|
+
page.lastModified,
|
|
247
|
+
alternateTags(page.route)
|
|
248
|
+
);
|
|
176
249
|
}
|
|
177
250
|
// Custom `.astro` pages and the generated changelog index mount outside
|
|
178
251
|
// `basePath` (they're injected at their pattern — see `blumeIntegration`), so
|
|
@@ -192,7 +265,9 @@ export const buildSitemapFiles = (
|
|
|
192
265
|
urls.sort();
|
|
193
266
|
|
|
194
267
|
if (urls.length <= URLS_PER_FILE) {
|
|
195
|
-
return [
|
|
268
|
+
return [
|
|
269
|
+
{ name: "sitemap.xml", xml: renderUrlset(urls, alternatesEnabled) },
|
|
270
|
+
];
|
|
196
271
|
}
|
|
197
272
|
|
|
198
273
|
const chunks: SitemapFile[] = [];
|
|
@@ -201,7 +276,10 @@ export const buildSitemapFiles = (
|
|
|
201
276
|
const name = `sitemap-${chunks.length + 1}.xml`;
|
|
202
277
|
chunks.push({
|
|
203
278
|
name,
|
|
204
|
-
xml: renderUrlset(
|
|
279
|
+
xml: renderUrlset(
|
|
280
|
+
urls.slice(start, start + URLS_PER_FILE),
|
|
281
|
+
alternatesEnabled
|
|
282
|
+
),
|
|
205
283
|
});
|
|
206
284
|
// Chunks sit next to sitemap.xml, so their URLs layer the same deployment
|
|
207
285
|
// base robots.txt uses for the index.
|
package/src/search/documents.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type {
|
|
|
11
11
|
DownlevelWalk,
|
|
12
12
|
MdastNode as DownlevelNode,
|
|
13
13
|
} from "../ai/component-markdown.ts";
|
|
14
|
+
import { relativeLinkRewriter } from "../ai/relative-links.ts";
|
|
15
|
+
import type { RelativeLinkRewriter } from "../ai/relative-links.ts";
|
|
14
16
|
import { projectComponentSerializers } from "../ai/serializers.ts";
|
|
15
17
|
import { applyAudienceVisibility } from "../ai/visibility.ts";
|
|
16
18
|
import type { VisibilityAudience } from "../ai/visibility.ts";
|
|
@@ -399,17 +401,20 @@ interface BuildOptions {
|
|
|
399
401
|
|
|
400
402
|
/**
|
|
401
403
|
* Read a page's body and reduce it per the extraction options. The
|
|
402
|
-
* `"markdown"` body is agent-facing
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
404
|
+
* `"markdown"` body, the one `rewriteLinks` comes with, is agent-facing: its
|
|
405
|
+
* page links are rewritten, then its components downleveled in the source
|
|
406
|
+
* with the serializers every agent surface uses (front matter in scope for
|
|
407
|
+
* prop expressions), in the order llms-full.txt takes. The plain body is
|
|
408
|
+
* parsed as written and walked, with the same serializers applied per
|
|
409
|
+
* component on the way, so the index reads a `<Card>` or `<TypeTable>` as
|
|
410
|
+
* the text it shows.
|
|
407
411
|
*/
|
|
408
412
|
const pageBody = async (
|
|
409
413
|
project: BlumeProject,
|
|
410
414
|
page: PageRecord | undefined,
|
|
411
|
-
options: Pick<BuildOptions, "audience"
|
|
412
|
-
components: Record<string, ComponentMarkdown
|
|
415
|
+
options: Pick<BuildOptions, "audience"> | undefined,
|
|
416
|
+
components: Record<string, ComponentMarkdown>,
|
|
417
|
+
rewriteLinks: RelativeLinkRewriter | undefined
|
|
413
418
|
): Promise<string> => {
|
|
414
419
|
if (!page) {
|
|
415
420
|
return "";
|
|
@@ -419,8 +424,12 @@ const pageBody = async (
|
|
|
419
424
|
parsed.content,
|
|
420
425
|
options?.audience ?? "web"
|
|
421
426
|
);
|
|
422
|
-
if (
|
|
423
|
-
return downlevelComponents(
|
|
427
|
+
if (rewriteLinks) {
|
|
428
|
+
return downlevelComponents(
|
|
429
|
+
rewriteLinks(visible, page).trim(),
|
|
430
|
+
components,
|
|
431
|
+
parsed.data
|
|
432
|
+
);
|
|
424
433
|
}
|
|
425
434
|
return toPlainText(visible, page.format, {
|
|
426
435
|
downlevel: {
|
|
@@ -460,7 +469,9 @@ const rankingFields = (
|
|
|
460
469
|
* same serializers the `.md` mirror, llms-full.txt and MCP `get_page` use: a
|
|
461
470
|
* page whose body is a `<CardGroup>` of `<Card>`s is prose to a reader and bare
|
|
462
471
|
* JSX to anything reading the source, and section landing pages are exactly
|
|
463
|
-
* that shape.
|
|
472
|
+
* that shape. Its page links point at the pages they mean, as they do there
|
|
473
|
+
* too, as the routes the assistant's snapshot keys those pages by (see
|
|
474
|
+
* `relativeLinkRewriter`'s `assistant`): the model cites the links it reads.
|
|
464
475
|
*
|
|
465
476
|
* `audience` resolves `<Visibility>` blocks before extraction: `"web"`
|
|
466
477
|
* (default) keeps web-only content and drops agents-only blocks — the site
|
|
@@ -518,6 +529,12 @@ export const buildSearchDocuments = async (
|
|
|
518
529
|
// Built once — `downlevelComponents` rebuilds its registry per call
|
|
519
530
|
// otherwise.
|
|
520
531
|
const components = projectComponentSerializers(project);
|
|
532
|
+
// The `"markdown"` body is the assistant's: the model cites the links it
|
|
533
|
+
// reads, so a page link points at the page the snapshot holds.
|
|
534
|
+
const rewriteLinks =
|
|
535
|
+
options?.content === "markdown"
|
|
536
|
+
? relativeLinkRewriter(project, { assistant: true })
|
|
537
|
+
: undefined;
|
|
521
538
|
|
|
522
539
|
// A page in no sidebar group falls under the "Docs" section, in its own
|
|
523
540
|
// language: the label is a UI string, resolved once per locale.
|
|
@@ -542,7 +559,13 @@ export const buildSearchDocuments = async (
|
|
|
542
559
|
return await Promise.all(
|
|
543
560
|
indexable.map(async (route) => {
|
|
544
561
|
const page = pageById.get(route.id);
|
|
545
|
-
const body = await pageBody(
|
|
562
|
+
const body = await pageBody(
|
|
563
|
+
project,
|
|
564
|
+
page,
|
|
565
|
+
options,
|
|
566
|
+
components,
|
|
567
|
+
rewriteLinks
|
|
568
|
+
);
|
|
546
569
|
const tags = page?.meta?.search?.tags;
|
|
547
570
|
const crumb = crumbs.get(route.path);
|
|
548
571
|
const facets = page ? pageFacets(page, project.config) : undefined;
|