blume 2.2.2 → 2.2.3
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 +10 -0
- package/dist/cli/{chunk-080mfch6.js → chunk-0wt5amrk.js} +15 -15
- package/dist/cli/{chunk-k45sq16q.js → chunk-0xyedh7k.js} +39 -23
- package/dist/cli/{chunk-k45sq16q.js.map → chunk-0xyedh7k.js.map} +3 -3
- package/dist/cli/{chunk-f260jdrn.js → chunk-0ynr0fr7.js} +5 -5
- package/dist/cli/{chunk-e1n41w9m.js → chunk-1ay3hzn0.js} +3 -3
- package/dist/cli/{chunk-b680sv30.js → chunk-2f6btenb.js} +10 -8
- package/dist/cli/chunk-2f6btenb.js.map +13 -0
- package/dist/cli/{chunk-gwy55cx3.js → chunk-426rytg6.js} +2 -2
- package/dist/cli/{chunk-9h8mh2tk.js → chunk-5f96yq2c.js} +1 -1
- package/dist/cli/{chunk-x60e15st.js → chunk-5xq12jz7.js} +17 -17
- package/dist/cli/{chunk-x7bh6d9j.js → chunk-6xag1c83.js} +4 -4
- package/dist/cli/{chunk-487b95qy.js → chunk-88q28zav.js} +41 -25
- package/dist/cli/{chunk-487b95qy.js.map → chunk-88q28zav.js.map} +3 -3
- package/dist/cli/{chunk-yg8ckc3g.js → chunk-88zkpg89.js} +2 -2
- package/dist/cli/{chunk-ppa6mg4d.js → chunk-8fm3b4vm.js} +6 -6
- package/dist/cli/{chunk-rtvgmg2j.js → chunk-8h2t2vyj.js} +2 -2
- package/dist/cli/{chunk-2ssh60qh.js → chunk-982qfeny.js} +6 -6
- package/dist/cli/{chunk-t2h8pwy1.js → chunk-9emfspcp.js} +4 -4
- package/dist/cli/{chunk-9jqmq5hv.js → chunk-akj2p7k1.js} +6 -6
- package/dist/cli/{chunk-beb0z983.js → chunk-b664x65a.js} +16 -16
- package/dist/cli/{chunk-kmswjxj9.js → chunk-b7n58hgj.js} +3 -3
- package/dist/cli/{chunk-fnxgtjeq.js → chunk-c0qddrxr.js} +1 -1
- package/dist/cli/{chunk-p834rkcn.js → chunk-g62vnvaw.js} +5 -5
- package/dist/cli/{chunk-zxd5jrgx.js → chunk-gc7knd2s.js} +76 -34
- package/dist/cli/{chunk-zxd5jrgx.js.map → chunk-gc7knd2s.js.map} +4 -4
- package/dist/cli/{chunk-r5nm9w0c.js → chunk-hwjspffr.js} +105 -36
- package/dist/cli/{chunk-r5nm9w0c.js.map → chunk-hwjspffr.js.map} +11 -10
- package/dist/cli/{chunk-40vk8kgf.js → chunk-p4fe7tax.js} +7 -7
- package/dist/cli/{chunk-34wap3ed.js → chunk-psg2za5z.js} +3 -3
- package/dist/cli/{chunk-hktq4j5r.js → chunk-pxp1y6yz.js} +16 -10
- package/dist/cli/{chunk-hktq4j5r.js.map → chunk-pxp1y6yz.js.map} +4 -4
- package/dist/cli/{chunk-qcyxkj44.js → chunk-qax1aekz.js} +43 -17
- package/dist/cli/chunk-qax1aekz.js.map +15 -0
- package/dist/cli/{chunk-h0qwdbem.js → chunk-r5ajrw1g.js} +2 -2
- package/dist/cli/{chunk-q1wwrxzw.js → chunk-rc2qk3qx.js} +3 -3
- package/dist/cli/chunk-rc2qk3qx.js.map +10 -0
- package/dist/cli/{chunk-69rjf786.js → chunk-ry2ymbxf.js} +5 -5
- package/dist/cli/{chunk-gxhcf2hs.js → chunk-t43g7v22.js} +2 -2
- package/dist/cli/{chunk-ydqzkhjn.js → chunk-x0jqyq1g.js} +6 -6
- package/dist/cli/{chunk-aafhwx74.js → chunk-x25s4gph.js} +1 -1
- package/dist/cli/{chunk-nqna8dzp.js → chunk-xkx216rd.js} +17 -17
- package/dist/cli/{chunk-etckvbrh.js → chunk-xv8nkt56.js} +14 -14
- package/dist/cli/{chunk-hn3g2b89.js → chunk-yrmw5n2b.js} +9 -9
- package/dist/cli/{chunk-4zxh25hc.js → chunk-zgca0vxs.js} +2 -2
- package/dist/cli/{chunk-c5bjxq8v.js → chunk-zj6bcx0n.js} +2 -2
- package/dist/cli/{chunk-1ch3ct3r.js → chunk-zjfwzrvd.js} +14 -14
- package/dist/cli/{chunk-j4nfgn2j.js → chunk-znwhf37a.js} +1 -1
- 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 +35 -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 +16 -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/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 +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/PageLayout.astro +25 -12
- package/src/components/layout/RootLayout.astro +7 -0
- package/src/components/layout/locale-redirect.ts +38 -19
- package/src/components/layout/search/types.ts +12 -4
- package/src/core/config-input.ts +34 -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 +47 -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/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-080mfch6.js.map → chunk-0wt5amrk.js.map} +0 -0
- /package/dist/cli/{chunk-f260jdrn.js.map → chunk-0ynr0fr7.js.map} +0 -0
- /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-1ay3hzn0.js.map} +0 -0
- /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-426rytg6.js.map} +0 -0
- /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-5f96yq2c.js.map} +0 -0
- /package/dist/cli/{chunk-x60e15st.js.map → chunk-5xq12jz7.js.map} +0 -0
- /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6xag1c83.js.map} +0 -0
- /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-88zkpg89.js.map} +0 -0
- /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-8fm3b4vm.js.map} +0 -0
- /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-8h2t2vyj.js.map} +0 -0
- /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-982qfeny.js.map} +0 -0
- /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-9emfspcp.js.map} +0 -0
- /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-akj2p7k1.js.map} +0 -0
- /package/dist/cli/{chunk-beb0z983.js.map → chunk-b664x65a.js.map} +0 -0
- /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-b7n58hgj.js.map} +0 -0
- /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-c0qddrxr.js.map} +0 -0
- /package/dist/cli/{chunk-p834rkcn.js.map → chunk-g62vnvaw.js.map} +0 -0
- /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-p4fe7tax.js.map} +0 -0
- /package/dist/cli/{chunk-34wap3ed.js.map → chunk-psg2za5z.js.map} +0 -0
- /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-r5ajrw1g.js.map} +0 -0
- /package/dist/cli/{chunk-69rjf786.js.map → chunk-ry2ymbxf.js.map} +0 -0
- /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-t43g7v22.js.map} +0 -0
- /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-x0jqyq1g.js.map} +0 -0
- /package/dist/cli/{chunk-aafhwx74.js.map → chunk-x25s4gph.js.map} +0 -0
- /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-xkx216rd.js.map} +0 -0
- /package/dist/cli/{chunk-etckvbrh.js.map → chunk-xv8nkt56.js.map} +0 -0
- /package/dist/cli/{chunk-hn3g2b89.js.map → chunk-yrmw5n2b.js.map} +0 -0
- /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-zgca0vxs.js.map} +0 -0
- /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-zj6bcx0n.js.map} +0 -0
- /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-zjfwzrvd.js.map} +0 -0
- /package/dist/cli/{chunk-j4nfgn2j.js.map → chunk-znwhf37a.js.map} +0 -0
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. */
|
|
@@ -1609,8 +1608,9 @@ const ogConfigSchema = z.strictObject({
|
|
|
1609
1608
|
/**
|
|
1610
1609
|
* Generate a per-page Open Graph image. Defaults to on once a deployment
|
|
1611
1610
|
* site URL is known (set or auto-detected) and off otherwise, since
|
|
1612
|
-
* `og:image` must be absolute to be useful to crawlers
|
|
1613
|
-
*
|
|
1611
|
+
* `og:image` must be absolute to be useful to crawlers, and off when `image`
|
|
1612
|
+
* sets a default every page would show instead — resolved in `loadConfig`.
|
|
1613
|
+
* An explicit value here always wins.
|
|
1614
1614
|
*/
|
|
1615
1615
|
enabled: z.boolean().optional(),
|
|
1616
1616
|
/**
|
|
@@ -1619,6 +1619,13 @@ const ogConfigSchema = z.strictObject({
|
|
|
1619
1619
|
* families are fetched from Google Fonts at build.
|
|
1620
1620
|
*/
|
|
1621
1621
|
fonts: z.array(ogFontSchema).optional(),
|
|
1622
|
+
/**
|
|
1623
|
+
* Default social image for pages without their own `seo.image`, in place of
|
|
1624
|
+
* the generated card: one path or URL, or one per locale (resolved like any
|
|
1625
|
+
* localizable label). A root-relative path is served under the deployment
|
|
1626
|
+
* base.
|
|
1627
|
+
*/
|
|
1628
|
+
image: localizableLabelSchema.optional(),
|
|
1622
1629
|
/**
|
|
1623
1630
|
* Local SVG used in the generated card instead of the site logo; `false`
|
|
1624
1631
|
* renders the card without any brand mark.
|
|
@@ -1728,6 +1735,29 @@ type SoftwareResolved = z.output<typeof softwareConfigSchema>;
|
|
|
1728
1735
|
|
|
1729
1736
|
/** Discoverability features: OG images, feeds, sitemap, structured data. */
|
|
1730
1737
|
const seoConfigFields = {
|
|
1738
|
+
/**
|
|
1739
|
+
* Date pages without a front matter `date` by the git commit that added
|
|
1740
|
+
* their source file, following exact renames. Off by default.
|
|
1741
|
+
*/
|
|
1742
|
+
datePublished: z.union([z.literal(false), z.literal("git")]).default(false),
|
|
1743
|
+
/**
|
|
1744
|
+
* Structured-data entities shared with a parent site (`example.com/docs`),
|
|
1745
|
+
* referenced by absolute `@id` instead of defined again (see `seo/jsonld.ts`).
|
|
1746
|
+
*/
|
|
1747
|
+
jsonLd: z
|
|
1748
|
+
.strictObject({
|
|
1749
|
+
/** Credited on pages without their own `authors`. */
|
|
1750
|
+
author: z.strictObject({ "@id": z.url() }).optional(),
|
|
1751
|
+
/** The Organization's `@id`; `seo.organization` still adds its details. */
|
|
1752
|
+
organizationId: z.url().optional(),
|
|
1753
|
+
/** Every page's publisher, over the Organization. */
|
|
1754
|
+
publisher: z.strictObject({ "@id": z.url() }).optional(),
|
|
1755
|
+
/** Emit the WebSite node. Defaults to true unless `websiteId` is set. */
|
|
1756
|
+
website: z.boolean().optional(),
|
|
1757
|
+
/** The WebSite's `@id`, which every page names as `isPartOf`. */
|
|
1758
|
+
websiteId: z.url().optional(),
|
|
1759
|
+
})
|
|
1760
|
+
.prefault({}),
|
|
1731
1761
|
/**
|
|
1732
1762
|
* Meta tags written into every page's head, name to content: site
|
|
1733
1763
|
* verification, `theme-color`, and anything else Blume has no setting for.
|
|
@@ -1754,8 +1784,16 @@ const seoConfigFields = {
|
|
|
1754
1784
|
/** Generate robots.txt (with a Sitemap reference when available). */
|
|
1755
1785
|
robots: z.boolean().default(true),
|
|
1756
1786
|
rss: rssConfigSchema.prefault({}),
|
|
1757
|
-
/**
|
|
1758
|
-
|
|
1787
|
+
/**
|
|
1788
|
+
* Generate sitemap.xml (requires deployment.site). `{ alternates: true }`
|
|
1789
|
+
* also lists each page's translations as hreflang links.
|
|
1790
|
+
*/
|
|
1791
|
+
sitemap: z
|
|
1792
|
+
.union([
|
|
1793
|
+
z.boolean(),
|
|
1794
|
+
z.strictObject({ alternates: z.boolean().default(false) }),
|
|
1795
|
+
])
|
|
1796
|
+
.default(true),
|
|
1759
1797
|
/** The documented product, as a homepage `SoftwareApplication` node. */
|
|
1760
1798
|
software: z
|
|
1761
1799
|
.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;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latin folding, shared by the search index and everything that finds a query
|
|
3
|
+
* in a page's text afterwards: the search dialog's highlights and excerpts,
|
|
4
|
+
* and the assistant's excerpts. The index matches Prüfung for `prufung`, so
|
|
5
|
+
* the rest must too, or a page found by folding shows its opening with
|
|
6
|
+
* nothing marked.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A Latin letter and the combining marks on it, which NFD splits off. */
|
|
10
|
+
const LATIN_ACCENTS = /(?<letter>\p{Script=Latin})\p{M}+/gu;
|
|
11
|
+
|
|
12
|
+
/** A Latin character outside ASCII, once NFD has split off its accents. */
|
|
13
|
+
const NON_ASCII_LATIN = /(?=\P{ASCII})\p{Script=Latin}/gu;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Latin letters NFD leaves whole, spelled the way an unaccented query types
|
|
17
|
+
* them. Orama's own diacritics table folds the same letters, though its
|
|
18
|
+
* English splitter cuts words at them first.
|
|
19
|
+
*/
|
|
20
|
+
const LATIN_FOLDS = new Map([
|
|
21
|
+
["ß", "ss"],
|
|
22
|
+
["æ", "ae"],
|
|
23
|
+
["œ", "oe"],
|
|
24
|
+
["ø", "o"],
|
|
25
|
+
["ł", "l"],
|
|
26
|
+
["đ", "d"],
|
|
27
|
+
["ð", "d"],
|
|
28
|
+
["ħ", "h"],
|
|
29
|
+
["ı", "i"],
|
|
30
|
+
["þ", "th"],
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* U+00AD, the invisible break hint `­` writes inside a long word. It
|
|
35
|
+
* belongs to no word: split there, Prüfungs­ordnung would index as two.
|
|
36
|
+
*/
|
|
37
|
+
const SOFT_HYPHEN = /\u00AD/gu;
|
|
38
|
+
|
|
39
|
+
/** `text` without its soft hyphens. */
|
|
40
|
+
export const stripSoftHyphens = (text: string): string =>
|
|
41
|
+
text.replace(SOFT_HYPHEN, "");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fold lowercased text's Latin letters to the plain letters an unaccented
|
|
45
|
+
* query types: accents drop (café → cafe), letters NFD leaves whole are
|
|
46
|
+
* spelled out (Łódź → lodz, ışık and IŞIK → isik, Straße → strasse), and
|
|
47
|
+
* compatibility forms unpack (the fi ligature → fi, fullwidth API → api).
|
|
48
|
+
* Soft hyphens go too. Marks on any other script, and marks on no letter
|
|
49
|
+
* (the keycap in 1️⃣), stay.
|
|
50
|
+
*/
|
|
51
|
+
export const foldLatin = (lowered: string): string =>
|
|
52
|
+
stripSoftHyphens(lowered)
|
|
53
|
+
.normalize("NFD")
|
|
54
|
+
.replace(
|
|
55
|
+
NON_ASCII_LATIN,
|
|
56
|
+
(letter) =>
|
|
57
|
+
LATIN_FOLDS.get(letter) ?? letter.normalize("NFKD").toLowerCase()
|
|
58
|
+
)
|
|
59
|
+
.replace(LATIN_ACCENTS, "$<letter>");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A run of Latin letters and the marks on them (lowercase İ is i plus a
|
|
63
|
+
* combining dot), inside a term of any script.
|
|
64
|
+
*/
|
|
65
|
+
const LATIN_RUN = /\p{Script=Latin}[\p{Script=Latin}\p{M}]*/gu;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* {@link foldLatin} for the Latin letters of a lowercased term, whatever
|
|
69
|
+
* script the rest of it is in: a segmenter keeps caféनमस्ते together as one
|
|
70
|
+
* term. Only the Latin runs fold. Marks are spelling elsewhere (Thai vowels
|
|
71
|
+
* and tones; the breve that separates Cyrillic й from и), and NFD would split
|
|
72
|
+
* a Hangul syllable into its jamo.
|
|
73
|
+
*/
|
|
74
|
+
export const foldLatinTerm = (term: string): string =>
|
|
75
|
+
term.replace(LATIN_RUN, (run) => foldLatin(run));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The Unicode blocks holding Latin letters outside ASCII: Latin-1 through
|
|
79
|
+
* Latin Extended-B, the phonetic extensions and Latin Extended Additional,
|
|
80
|
+
* superscripts through number forms, Latin Extended-C, -D and -E, the
|
|
81
|
+
* ligatures, and the fullwidth forms.
|
|
82
|
+
*/
|
|
83
|
+
const LATIN_BLOCKS = [
|
|
84
|
+
[0x00_80, 0x02_4f],
|
|
85
|
+
[0x1d_00, 0x1e_ff],
|
|
86
|
+
[0x20_70, 0x21_8f],
|
|
87
|
+
[0x2c_60, 0x2c_7f],
|
|
88
|
+
[0xa7_20, 0xa7_ff],
|
|
89
|
+
[0xab_30, 0xab_6f],
|
|
90
|
+
[0xfb_00, 0xfb_06],
|
|
91
|
+
[0xff_21, 0xff_5a],
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
const LATIN = /^\p{Script=Latin}$/u;
|
|
95
|
+
const PLAIN = /^[a-z]+$/u;
|
|
96
|
+
|
|
97
|
+
let spellings: Map<string, string> | undefined;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Every Latin character outside ASCII that folds to plain letters, keyed by
|
|
101
|
+
* those letters: `a` → àáâ…, `ss` → ßẞ. Built on first use, not at import.
|
|
102
|
+
*/
|
|
103
|
+
const latinSpellings = (): Map<string, string> => {
|
|
104
|
+
if (spellings) {
|
|
105
|
+
return spellings;
|
|
106
|
+
}
|
|
107
|
+
spellings = new Map();
|
|
108
|
+
for (const [first, last] of LATIN_BLOCKS) {
|
|
109
|
+
for (let code = first; code <= last; code += 1) {
|
|
110
|
+
const char = String.fromCodePoint(code);
|
|
111
|
+
const plain = foldLatin(char.toLowerCase());
|
|
112
|
+
if (LATIN.test(char) && PLAIN.test(plain)) {
|
|
113
|
+
spellings.set(plain, `${spellings.get(plain) ?? ""}${char}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return spellings;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** A Latin letter in a query, with the marks a decomposed query puts on it. */
|
|
121
|
+
const QUERY_LETTER = /\p{Script=Latin}\p{M}*/gu;
|
|
122
|
+
|
|
123
|
+
const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
|
|
124
|
+
|
|
125
|
+
/** What may follow a letter inside a word: its accents, or a soft hyphen. */
|
|
126
|
+
const LETTER_TAIL = String.raw`[\p{M}\u00AD]*`;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The longest plain spelling a single character folds to (ⅷ → viii), so the
|
|
130
|
+
* pattern builder knows how far ahead to look.
|
|
131
|
+
*/
|
|
132
|
+
const longestSpelling = (folds: Map<string, string>): number =>
|
|
133
|
+
Math.max(...[...folds.keys()].map((plain) => plain.length));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A regex source that finds `term` in unfolded text whatever accents, soft
|
|
137
|
+
* hyphens or spelled-out letters its Latin words carry there: `prufung`
|
|
138
|
+
* matches Prüfung, `strasse` matches Straße. Compile it with the `iu` flags.
|
|
139
|
+
* Characters outside Latin match as themselves.
|
|
140
|
+
*/
|
|
141
|
+
export const accentInsensitive = (term: string): string => {
|
|
142
|
+
const folds = latinSpellings();
|
|
143
|
+
const longest = longestSpelling(folds);
|
|
144
|
+
const letter = (plain: string): string =>
|
|
145
|
+
`[${plain}${folds.get(plain) ?? ""}]${LETTER_TAIL}`;
|
|
146
|
+
const chars = [
|
|
147
|
+
...stripSoftHyphens(term.toLowerCase()).replace(QUERY_LETTER, (latin) =>
|
|
148
|
+
foldLatin(latin)
|
|
149
|
+
),
|
|
150
|
+
];
|
|
151
|
+
let source = "";
|
|
152
|
+
let index = 0;
|
|
153
|
+
while (index < chars.length) {
|
|
154
|
+
let length = Math.min(longest, chars.length - index);
|
|
155
|
+
// The longest run here that a single character spells out — `ss` for ß.
|
|
156
|
+
while (
|
|
157
|
+
length > 1 &&
|
|
158
|
+
!folds.has(chars.slice(index, index + length).join(""))
|
|
159
|
+
) {
|
|
160
|
+
length -= 1;
|
|
161
|
+
}
|
|
162
|
+
const run = chars.slice(index, index + length);
|
|
163
|
+
const plain = run.join("");
|
|
164
|
+
if (length > 1) {
|
|
165
|
+
const letters = run.map((char) => letter(char)).join("");
|
|
166
|
+
source += `(?:${letters}|[${folds.get(plain) ?? ""}]${LETTER_TAIL})`;
|
|
167
|
+
} else if (PLAIN.test(plain)) {
|
|
168
|
+
source += letter(plain);
|
|
169
|
+
} else {
|
|
170
|
+
source += plain.replaceAll(REGEXP_SPECIAL, String.raw`\$&`);
|
|
171
|
+
}
|
|
172
|
+
index += length;
|
|
173
|
+
}
|
|
174
|
+
return source;
|
|
175
|
+
};
|