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
|
@@ -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
|
+
};
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
components,
|
|
3
|
-
create,
|
|
4
|
-
insert,
|
|
5
|
-
insertMultiple,
|
|
6
|
-
search,
|
|
7
|
-
} from "@orama/orama";
|
|
1
|
+
import { create, insert, insertMultiple, search } from "@orama/orama";
|
|
8
2
|
import type {
|
|
9
3
|
AnyOrama,
|
|
10
4
|
EnumArrComparisonOperator,
|
|
@@ -14,6 +8,8 @@ import type {
|
|
|
14
8
|
Tokenizer,
|
|
15
9
|
} from "@orama/orama";
|
|
16
10
|
|
|
11
|
+
import { foldLatin, foldLatinTerm, stripSoftHyphens } from "./fold.ts";
|
|
12
|
+
|
|
17
13
|
/**
|
|
18
14
|
* The minimal document shape both the client-side search dialog and the
|
|
19
15
|
* server-side MCP `search_docs` tool index. Mirrors the `blume-search.json`
|
|
@@ -82,10 +78,9 @@ const byBoostedScore = (a: RankedMatch, b: RankedMatch): number =>
|
|
|
82
78
|
b[1] * (b[2].boost ?? 1) - a[1] * (a[2].boost ?? 1);
|
|
83
79
|
|
|
84
80
|
/**
|
|
85
|
-
* The script
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* tokens and every query silently returns no hits. Unspaced scripts are the
|
|
81
|
+
* The script {@link latinTokenizer} indexes. {@link LATIN_SEPARATOR} splits on
|
|
82
|
+
* every character of any other script, so such text collapses to zero tokens
|
|
83
|
+
* and every query silently returns no hits. Unspaced scripts are the
|
|
89
84
|
* best-known casualty, but the failure is not about spacing — Russian, Greek,
|
|
90
85
|
* Hebrew and Hindi lose their tokens the same way Japanese does.
|
|
91
86
|
*/
|
|
@@ -189,22 +184,23 @@ const BIGRAM_SCRIPTS =
|
|
|
189
184
|
const TERM =
|
|
190
185
|
/[\p{L}\p{M}\p{N}]+(?:(?:['’](?=\p{L})|(?<=\p{N})[.,](?=\p{N}))[\p{L}\p{M}\p{N}]+)*/gu;
|
|
191
186
|
|
|
187
|
+
const LATIN_SEPARATOR = /[^\p{Script=Latin}0-9_'-]+/u;
|
|
188
|
+
|
|
192
189
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* the segmenting tokenizer would silently drop the unaccented-query matches
|
|
198
|
-
* the default tokenizer provided. Only all-Latin terms fold: marks are
|
|
199
|
-
* spelling elsewhere (Thai vowels and tones; the breve that separates
|
|
200
|
-
* Cyrillic й from и), so a term carrying any other script keeps its marks.
|
|
190
|
+
* Modeled on Orama's default tokenizer, with the splitter widened from the
|
|
191
|
+
* accented vowels it keeps (`àèéìòóù`) to every Latin letter: Prüfung, når and
|
|
192
|
+
* İletişim index as whole words rather than splitting at ü, å and İ. Words
|
|
193
|
+
* fold with {@link foldLatin}, and ASCII text tokenizes exactly as in Orama's.
|
|
201
194
|
*/
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
195
|
+
const latinTokenizer = (): Tokenizer => ({
|
|
196
|
+
language: "english",
|
|
197
|
+
normalizationCache: new Map(),
|
|
198
|
+
tokenize: (raw: string): string[] => [
|
|
199
|
+
...new Set(
|
|
200
|
+
foldLatin(raw.toLowerCase()).split(LATIN_SEPARATOR).filter(Boolean)
|
|
201
|
+
),
|
|
202
|
+
],
|
|
203
|
+
});
|
|
208
204
|
|
|
209
205
|
/**
|
|
210
206
|
* Emit every overlapping 2-character window of `run`, or the lone character.
|
|
@@ -249,10 +245,11 @@ const hasSegmenter = (
|
|
|
249
245
|
* pipelines) indexes the same terms a composed query produces, and Latin
|
|
250
246
|
* terms ("GDPR", English pages on a mixed-locale site) still match
|
|
251
247
|
* case-insensitively, with their diacritics folded by
|
|
252
|
-
* {@link
|
|
248
|
+
* {@link foldLatinTerm}. Soft hyphens go first, so a word they break still
|
|
249
|
+
* segments whole. Returns `undefined` for scripts the default
|
|
253
250
|
* tokenizer already serves ({@link resolveLocale} decides, so `sr-Latn` keeps
|
|
254
251
|
* the default while `az-Cyrl` is segmented), and on runtimes without
|
|
255
|
-
* `Intl.Segmenter`, where the caller falls back to
|
|
252
|
+
* `Intl.Segmenter`, where the caller falls back to {@link latinTokenizer}.
|
|
256
253
|
*
|
|
257
254
|
* On a {@link BIGRAM_INDEX_SCRIPTS} index, runs of adjacent
|
|
258
255
|
* {@link BIGRAM_SCRIPTS} segments are joined and re-cut into character
|
|
@@ -292,10 +289,10 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
|
|
|
292
289
|
return;
|
|
293
290
|
}
|
|
294
291
|
flush();
|
|
295
|
-
tokens.add(
|
|
292
|
+
tokens.add(foldLatinTerm(term));
|
|
296
293
|
};
|
|
297
294
|
for (const segment of segmenter.segment(
|
|
298
|
-
raw.normalize("NFC").toLowerCase()
|
|
295
|
+
stripSoftHyphens(raw).normalize("NFC").toLowerCase()
|
|
299
296
|
)) {
|
|
300
297
|
if (!segment.isWordLike) {
|
|
301
298
|
flush();
|
|
@@ -329,7 +326,7 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
|
|
|
329
326
|
*/
|
|
330
327
|
const translationTokenizers = new WeakMap<AnyOrama, Map<string, Tokenizer>>();
|
|
331
328
|
|
|
332
|
-
/** A segmenting tokenizer for each document locale
|
|
329
|
+
/** A segmenting tokenizer for each document locale in a non-Latin script. */
|
|
333
330
|
const localeTokenizers = (documents: OramaDoc[]): Map<string, Tokenizer> => {
|
|
334
331
|
const tokenizers = new Map<string, Tokenizer>();
|
|
335
332
|
for (const code of new Set(documents.map((doc) => doc.locale ?? ""))) {
|
|
@@ -345,25 +342,18 @@ const localeTokenizers = (documents: OramaDoc[]): Map<string, Tokenizer> => {
|
|
|
345
342
|
* One tokenizer that hands each text to its locale's: Orama passes the
|
|
346
343
|
* language given to `insert` and `search` through to `tokenize`, so a page
|
|
347
344
|
* inserted under `ja` and a query searched under `ja` both reach the Japanese
|
|
348
|
-
* tokenizer, and everything else reaches
|
|
349
|
-
* with no translations.
|
|
345
|
+
* tokenizer, and everything else reaches {@link latinTokenizer}, exactly as on
|
|
346
|
+
* an index with no translations.
|
|
350
347
|
*/
|
|
351
348
|
const dispatchingTokenizer = (
|
|
352
349
|
tokenizers: Map<string, Tokenizer>
|
|
353
350
|
): Tokenizer => {
|
|
354
|
-
const standard =
|
|
351
|
+
const standard = latinTokenizer();
|
|
355
352
|
return {
|
|
356
353
|
language: standard.language,
|
|
357
354
|
normalizationCache: standard.normalizationCache,
|
|
358
|
-
tokenize: (raw, language
|
|
359
|
-
|
|
360
|
-
// locale that picked it is not passed on.
|
|
361
|
-
(tokenizers.get(language ?? "") ?? standard).tokenize(
|
|
362
|
-
raw,
|
|
363
|
-
undefined,
|
|
364
|
-
prop,
|
|
365
|
-
withCache
|
|
366
|
-
),
|
|
355
|
+
tokenize: (raw, language) =>
|
|
356
|
+
(tokenizers.get(language ?? "") ?? standard).tokenize(raw),
|
|
367
357
|
};
|
|
368
358
|
};
|
|
369
359
|
|
|
@@ -372,10 +362,10 @@ const dispatchingTokenizer = (
|
|
|
372
362
|
* Orama client loader (browser), the MCP server, and assistant grounding (Node),
|
|
373
363
|
* so ranking is identical wherever docs are queried. `locale` — the site's
|
|
374
364
|
* `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for every
|
|
375
|
-
* non-Latin script, all of which
|
|
365
|
+
* non-Latin script, all of which {@link latinTokenizer} reduces to zero
|
|
376
366
|
* tokens. A non-Latin default's tokenizer serves every page: Latin words
|
|
377
367
|
* survive segmentation intact, so English pages on a segmented index stay
|
|
378
|
-
* searchable. A Latin default keeps
|
|
368
|
+
* searchable. A Latin default keeps {@link latinTokenizer} for its Latin-script
|
|
379
369
|
* pages, and each page in a non-Latin locale (the Japanese and Hindi
|
|
380
370
|
* translations of an English site) is indexed with its own locale's tokenizer.
|
|
381
371
|
*/
|
|
@@ -400,9 +390,10 @@ export const buildOramaIndex = async (
|
|
|
400
390
|
}
|
|
401
391
|
translationTokenizers.set(db, translations);
|
|
402
392
|
} else {
|
|
403
|
-
db =
|
|
404
|
-
|
|
405
|
-
|
|
393
|
+
db = create({
|
|
394
|
+
components: { tokenizer: tokenizer ?? latinTokenizer() },
|
|
395
|
+
schema: SCHEMA,
|
|
396
|
+
});
|
|
406
397
|
await insertMultiple(db, rows);
|
|
407
398
|
}
|
|
408
399
|
if (documents.some((doc) => (doc.boost ?? 1) !== 1)) {
|
|
@@ -492,8 +483,8 @@ const boostedScore = (hit: Hit): number =>
|
|
|
492
483
|
* On an index whose translations have tokenizers of their own, a query scoped
|
|
493
484
|
* to a locale is tokenized with that locale's tokenizer. An unscoped query
|
|
494
485
|
* searches each locale's pages with its own tokenizer, and the Latin-script
|
|
495
|
-
* pages with
|
|
496
|
-
* the one index, so their scores share its term statistics.
|
|
486
|
+
* pages with {@link latinTokenizer}, then merges the matches by score: every
|
|
487
|
+
* search runs over the one index, so their scores share its term statistics.
|
|
497
488
|
*/
|
|
498
489
|
export const queryOramaIndex = async (
|
|
499
490
|
db: AnyOrama,
|
package/src/seo/jsonld.ts
CHANGED
|
@@ -64,8 +64,36 @@ export interface StructuredDataIdentity {
|
|
|
64
64
|
software?: SoftwareIdentity;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* `seo.jsonLd`: entities the docs share with a site they live inside
|
|
69
|
+
* (`example.com/docs`), referenced by `@id` instead of defined again.
|
|
70
|
+
*/
|
|
71
|
+
export interface JsonLdEntities {
|
|
72
|
+
/** Credited on pages without their own `authors`. */
|
|
73
|
+
author?: { "@id": string };
|
|
74
|
+
/** Cited as every page's publisher in place of the Organization's id. */
|
|
75
|
+
publisher?: { "@id": string };
|
|
76
|
+
/** The Organization's `@id`, defined here or elsewhere. */
|
|
77
|
+
organizationId?: string;
|
|
78
|
+
/** Emit the WebSite node. Defaults to true unless `websiteId` is set. */
|
|
79
|
+
website?: boolean;
|
|
80
|
+
/** The WebSite's `@id`, which every page names as `isPartOf`. */
|
|
81
|
+
websiteId?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A front matter `authors` entry: a name, or a name with a profile URL. */
|
|
85
|
+
export type AuthorInput = string | { name: string; url?: string };
|
|
86
|
+
|
|
67
87
|
/** Inputs for a page's JSON-LD, all known at render time in RootLayout. */
|
|
68
88
|
export interface StructuredDataInput {
|
|
89
|
+
jsonLd?: JsonLdEntities;
|
|
90
|
+
/**
|
|
91
|
+
* The page's `og:image`, emitted as the page node's `image`. Absolute when
|
|
92
|
+
* `siteUrl` is set, otherwise root-relative like every other URL here.
|
|
93
|
+
*/
|
|
94
|
+
image?: string | null;
|
|
95
|
+
/** The page's own byline, which wins over `jsonLd.author`. */
|
|
96
|
+
authors?: AuthorInput | AuthorInput[];
|
|
69
97
|
siteName: string;
|
|
70
98
|
/** Absolute site origin, or null when `deployment.site` is unset. */
|
|
71
99
|
siteUrl: string | null;
|
|
@@ -162,15 +190,17 @@ const organizationNode = (
|
|
|
162
190
|
context: {
|
|
163
191
|
absolutize: (path: string) => string;
|
|
164
192
|
id: string;
|
|
165
|
-
|
|
166
|
-
|
|
193
|
+
/** The site's name and root, or null when the id names an outside entity. */
|
|
194
|
+
defaults: { name: string; url: string } | null;
|
|
167
195
|
}
|
|
168
196
|
): JsonLdNode => {
|
|
169
197
|
const node: JsonLdNode = {
|
|
170
198
|
"@id": context.id,
|
|
171
199
|
"@type": "Organization",
|
|
172
|
-
|
|
173
|
-
|
|
200
|
+
...definedStrings({
|
|
201
|
+
name: organization.name ?? context.defaults?.name,
|
|
202
|
+
url: organization.url ?? context.defaults?.url,
|
|
203
|
+
}),
|
|
174
204
|
};
|
|
175
205
|
if (organization.logo) {
|
|
176
206
|
node.logo = context.absolutize(organization.logo);
|
|
@@ -261,6 +291,19 @@ const pageSchemaType = (input: StructuredDataInput, home: boolean): string => {
|
|
|
261
291
|
return isArticleType(pageType) ? ARTICLE_TYPES[pageType] : "TechArticle";
|
|
262
292
|
};
|
|
263
293
|
|
|
294
|
+
/** An `authors` entry is a bare name or a name with a profile URL. */
|
|
295
|
+
const isBareName = (author: AuthorInput): author is string =>
|
|
296
|
+
typeof author === "string";
|
|
297
|
+
|
|
298
|
+
/** `Person` nodes for a page's front matter `authors`. */
|
|
299
|
+
const authorNodes = (authors: AuthorInput | AuthorInput[]): JsonLdNode[] =>
|
|
300
|
+
[authors].flat().map((author): JsonLdNode => ({
|
|
301
|
+
"@type": "Person",
|
|
302
|
+
...(isBareName(author)
|
|
303
|
+
? { name: author }
|
|
304
|
+
: definedStrings({ name: author.name, url: author.url })),
|
|
305
|
+
}));
|
|
306
|
+
|
|
264
307
|
/**
|
|
265
308
|
* The page's own node: a homepage is a `WebPage` (it isn't an article, but it
|
|
266
309
|
* still needs a machine-readable date, or search engines take whatever other
|
|
@@ -298,11 +341,25 @@ const pageNode = (
|
|
|
298
341
|
if (modified) {
|
|
299
342
|
node.dateModified = modified;
|
|
300
343
|
}
|
|
301
|
-
|
|
302
|
-
|
|
344
|
+
// A shared id is already absolute, so it holds without a site URL too.
|
|
345
|
+
const websiteId =
|
|
346
|
+
input.jsonLd?.websiteId ??
|
|
347
|
+
(context.base ? `${context.base}#website` : null);
|
|
348
|
+
if (websiteId) {
|
|
349
|
+
node.isPartOf = { "@id": websiteId };
|
|
303
350
|
}
|
|
304
|
-
|
|
305
|
-
|
|
351
|
+
const publisher = input.jsonLd?.publisher?.["@id"] ?? context.organizationId;
|
|
352
|
+
if (publisher) {
|
|
353
|
+
node.publisher = { "@id": publisher };
|
|
354
|
+
}
|
|
355
|
+
const authors = input.authors ? authorNodes(input.authors) : [];
|
|
356
|
+
if (authors.length > 0) {
|
|
357
|
+
node.author = authors;
|
|
358
|
+
} else if (input.jsonLd?.author) {
|
|
359
|
+
node.author = input.jsonLd.author;
|
|
360
|
+
}
|
|
361
|
+
if (input.image) {
|
|
362
|
+
node.image = input.image;
|
|
306
363
|
}
|
|
307
364
|
return node;
|
|
308
365
|
};
|
|
@@ -335,6 +392,32 @@ const breadcrumbNode = (
|
|
|
335
392
|
};
|
|
336
393
|
};
|
|
337
394
|
|
|
395
|
+
/**
|
|
396
|
+
* The site's `WebSite` node, which needs the site URL. A shared `websiteId`
|
|
397
|
+
* names one the parent site defines, so the docs leave it out unless
|
|
398
|
+
* `jsonLd.website` asks for it — their own name and root would contradict it.
|
|
399
|
+
*/
|
|
400
|
+
const addWebsiteNode = (
|
|
401
|
+
graph: JsonLdNode[],
|
|
402
|
+
input: StructuredDataInput,
|
|
403
|
+
base: string | null,
|
|
404
|
+
rootUrl: string,
|
|
405
|
+
organizationId: string | null
|
|
406
|
+
): void => {
|
|
407
|
+
if (base && (input.jsonLd?.website ?? !input.jsonLd?.websiteId)) {
|
|
408
|
+
const website: JsonLdNode = {
|
|
409
|
+
"@id": input.jsonLd?.websiteId ?? `${base}#website`,
|
|
410
|
+
"@type": "WebSite",
|
|
411
|
+
name: input.siteName,
|
|
412
|
+
url: rootUrl,
|
|
413
|
+
};
|
|
414
|
+
if (organizationId) {
|
|
415
|
+
website.publisher = { "@id": organizationId };
|
|
416
|
+
}
|
|
417
|
+
graph.push(website);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
338
421
|
/**
|
|
339
422
|
* Build a schema.org JSON-LD `@graph` for a page: site identity (the WebSite,
|
|
340
423
|
* plus the configured Organization everywhere and the SoftwareApplication on
|
|
@@ -355,20 +438,11 @@ export const buildStructuredData = (input: StructuredDataInput): JsonLdNode => {
|
|
|
355
438
|
// the same rule as the WebSite node they attach to.
|
|
356
439
|
const organization = base ? input.identity?.organization : undefined;
|
|
357
440
|
const software = base ? input.identity?.software : undefined;
|
|
358
|
-
const organizationId =
|
|
441
|
+
const organizationId =
|
|
442
|
+
input.jsonLd?.organizationId ??
|
|
443
|
+
(organization ? `${base}#organization` : null);
|
|
359
444
|
|
|
360
|
-
|
|
361
|
-
const website: JsonLdNode = {
|
|
362
|
-
"@id": `${base}#website`,
|
|
363
|
-
"@type": "WebSite",
|
|
364
|
-
name: input.siteName,
|
|
365
|
-
url: rootUrl,
|
|
366
|
-
};
|
|
367
|
-
if (organizationId) {
|
|
368
|
-
website.publisher = { "@id": organizationId };
|
|
369
|
-
}
|
|
370
|
-
graph.push(website);
|
|
371
|
-
}
|
|
445
|
+
addWebsiteNode(graph, input, base, rootUrl, organizationId);
|
|
372
446
|
if (organization && organizationId) {
|
|
373
447
|
graph.push(
|
|
374
448
|
organizationNode(organization, {
|
|
@@ -378,9 +452,12 @@ export const buildStructuredData = (input: StructuredDataInput): JsonLdNode => {
|
|
|
378
452
|
path.startsWith("/")
|
|
379
453
|
? absolute(base, withBasePath(deployBase, path))
|
|
380
454
|
: path,
|
|
455
|
+
// A shared id names an organization defined elsewhere, which the
|
|
456
|
+
// docs' title and root would contradict; only configured fields go in.
|
|
457
|
+
defaults: input.jsonLd?.organizationId
|
|
458
|
+
? null
|
|
459
|
+
: { name: input.siteName, url: rootUrl },
|
|
381
460
|
id: organizationId,
|
|
382
|
-
rootUrl,
|
|
383
|
-
siteName: input.siteName,
|
|
384
461
|
})
|
|
385
462
|
);
|
|
386
463
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* time via `resolveIcon` and inline as zero-JS SVG.
|
|
11
11
|
*
|
|
12
12
|
* Values are Lucide inner-SVG markup; the client `svg()` helpers wrap them in an
|
|
13
|
-
* `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" …>`.
|
|
13
|
+
* `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" …>`. Lucide's
|
|
14
|
+
* license is in THIRD-PARTY-NOTICES.md.
|
|
14
15
|
*/
|
|
15
16
|
/** Inner-SVG markup keyed by the icon name a client script requests. */
|
|
16
17
|
interface ChromeIconSet {
|
package/src/theme/entry.ts
CHANGED
|
@@ -9,7 +9,10 @@ import { CODE_PADDING_BLOCK_REM } from "./code-block-padding.ts";
|
|
|
9
9
|
*/
|
|
10
10
|
const CODE_SCROLL_INSET_REM = 0.375;
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Lucide's arrow-up-right (the featured-link icon) as a `mask` data URI.
|
|
14
|
+
* Lucide's license is in THIRD-PARTY-NOTICES.md.
|
|
15
|
+
*/
|
|
13
16
|
const EXTERNAL_LINK_MASK = `url("data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M7 7h10v10M7 17L17 7"/></svg>')}")`;
|
|
14
17
|
|
|
15
18
|
interface TailwindEntryOptions {
|