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.
Files changed (145) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/THIRD-PARTY-NOTICES.md +51 -0
  3. package/dist/cli/{chunk-kmswjxj9.js → chunk-0b1a8xqz.js} +3 -3
  4. package/dist/cli/{chunk-rtvgmg2j.js → chunk-0qx6vwwf.js} +2 -2
  5. package/dist/cli/{chunk-qcyxkj44.js → chunk-3ps63r91.js} +43 -17
  6. package/dist/cli/chunk-3ps63r91.js.map +15 -0
  7. package/dist/cli/{chunk-b680sv30.js → chunk-402xaaxn.js} +10 -8
  8. package/dist/cli/chunk-402xaaxn.js.map +13 -0
  9. package/dist/cli/{chunk-k45sq16q.js → chunk-49xa4yf2.js} +39 -23
  10. package/dist/cli/{chunk-k45sq16q.js.map → chunk-49xa4yf2.js.map} +3 -3
  11. package/dist/cli/{chunk-j4nfgn2j.js → chunk-51wr1wre.js} +1 -1
  12. package/dist/cli/{chunk-9jqmq5hv.js → chunk-531qbwn1.js} +6 -6
  13. package/dist/cli/{chunk-r5nm9w0c.js → chunk-5bp65pv4.js} +107 -37
  14. package/dist/cli/{chunk-r5nm9w0c.js.map → chunk-5bp65pv4.js.map} +11 -10
  15. package/dist/cli/{chunk-yg8ckc3g.js → chunk-6nvcjzts.js} +2 -2
  16. package/dist/cli/{chunk-x7bh6d9j.js → chunk-6v5w8vzd.js} +4 -4
  17. package/dist/cli/{chunk-f260jdrn.js → chunk-8cshtem6.js} +5 -5
  18. package/dist/cli/{chunk-ydqzkhjn.js → chunk-8h5ceyk0.js} +6 -6
  19. package/dist/cli/{chunk-e1n41w9m.js → chunk-8v90n67z.js} +3 -3
  20. package/dist/cli/{chunk-etckvbrh.js → chunk-98me5yas.js} +14 -14
  21. package/dist/cli/{chunk-hktq4j5r.js → chunk-9k7gg4ga.js} +16 -10
  22. package/dist/cli/{chunk-hktq4j5r.js.map → chunk-9k7gg4ga.js.map} +5 -5
  23. package/dist/cli/{chunk-34wap3ed.js → chunk-9qmc57zw.js} +3 -3
  24. package/dist/cli/{chunk-beb0z983.js → chunk-a1xmzxk2.js} +16 -16
  25. package/dist/cli/{chunk-gwy55cx3.js → chunk-ajw85dgf.js} +2 -2
  26. package/dist/cli/{chunk-69rjf786.js → chunk-ansqj08e.js} +5 -5
  27. package/dist/cli/{chunk-q1wwrxzw.js → chunk-b0vwhmdf.js} +3 -3
  28. package/dist/cli/chunk-b0vwhmdf.js.map +10 -0
  29. package/dist/cli/{chunk-nqna8dzp.js → chunk-b5gztt4j.js} +17 -17
  30. package/dist/cli/{chunk-fnxgtjeq.js → chunk-bc4jz2m4.js} +1 -1
  31. package/dist/cli/{chunk-aafhwx74.js → chunk-d4h1y312.js} +1 -1
  32. package/dist/cli/{chunk-080mfch6.js → chunk-et7zwqkg.js} +15 -15
  33. package/dist/cli/{chunk-487b95qy.js → chunk-f29q3j13.js} +41 -25
  34. package/dist/cli/{chunk-487b95qy.js.map → chunk-f29q3j13.js.map} +3 -3
  35. package/dist/cli/{chunk-c5bjxq8v.js → chunk-f72388jn.js} +2 -2
  36. package/dist/cli/{chunk-t2h8pwy1.js → chunk-g0hpwbyx.js} +4 -4
  37. package/dist/cli/{chunk-40vk8kgf.js → chunk-j102xmnk.js} +7 -7
  38. package/dist/cli/{chunk-ppa6mg4d.js → chunk-m54bz9ng.js} +6 -6
  39. package/dist/cli/{chunk-h0qwdbem.js → chunk-mt6njy68.js} +2 -2
  40. package/dist/cli/{chunk-2ssh60qh.js → chunk-n091sgam.js} +6 -6
  41. package/dist/cli/{chunk-1ch3ct3r.js → chunk-p33bf2nb.js} +14 -14
  42. package/dist/cli/{chunk-4zxh25hc.js → chunk-saxq5jjr.js} +2 -2
  43. package/dist/cli/{chunk-x60e15st.js → chunk-sbt1s5j2.js} +17 -17
  44. package/dist/cli/{chunk-gxhcf2hs.js → chunk-tckd6qwv.js} +2 -2
  45. package/dist/cli/{chunk-p834rkcn.js → chunk-tf4134kk.js} +5 -5
  46. package/dist/cli/{chunk-zxd5jrgx.js → chunk-vxdjss7f.js} +76 -34
  47. package/dist/cli/{chunk-zxd5jrgx.js.map → chunk-vxdjss7f.js.map} +4 -4
  48. package/dist/cli/{chunk-9h8mh2tk.js → chunk-x2pgg22t.js} +1 -1
  49. package/dist/cli/{chunk-hn3g2b89.js → chunk-xhrwrzrh.js} +9 -9
  50. package/dist/cli/index.js +18 -18
  51. package/dist/types/ai/ask-context.d.ts +8 -0
  52. package/dist/types/ai/link-text.d.ts +5 -0
  53. package/dist/types/ai/relative-links.d.ts +26 -6
  54. package/dist/types/core/config-input.d.ts +41 -1
  55. package/dist/types/core/data.d.ts +14 -0
  56. package/dist/types/core/last-modified.d.ts +33 -18
  57. package/dist/types/core/links.d.ts +16 -0
  58. package/dist/types/core/schema.d.ts +17 -1
  59. package/dist/types/core/types.d.ts +10 -0
  60. package/dist/types/deploy/sitemap.d.ts +9 -0
  61. package/dist/types/search/documents.d.ts +3 -1
  62. package/dist/types/search/fold.d.ts +33 -0
  63. package/dist/types/search/orama-index.d.ts +4 -4
  64. package/dist/types/seo/jsonld.d.ts +33 -0
  65. package/docs/configuration/search.mdx +2 -2
  66. package/docs/configuration/theming.mdx +13 -1
  67. package/docs/content/frontmatter.mdx +1 -1
  68. package/docs/content/i18n.mdx +1 -1
  69. package/docs/content/sources/remote-mdx.mdx +1 -1
  70. package/docs/discoverability/open-graph.mdx +16 -1
  71. package/docs/discoverability/sitemap-and-robots.mdx +12 -0
  72. package/docs/discoverability/structured-data.mdx +44 -0
  73. package/package.json +4 -3
  74. package/skills/blume-migrate/references/docus.md +1 -2
  75. package/skills/blume-migrate/references/docusaurus.md +1 -1
  76. package/skills/blume-migrate/references/vitepress.md +2 -2
  77. package/skills/blume-migrate/references/vuepress.md +1 -1
  78. package/src/ai/ask-context.ts +52 -14
  79. package/src/ai/ask-data.ts +4 -1
  80. package/src/ai/ask-tools.ts +17 -5
  81. package/src/ai/link-text.ts +6 -0
  82. package/src/ai/llms.ts +1 -7
  83. package/src/ai/markdown.ts +8 -4
  84. package/src/ai/relative-links.ts +155 -48
  85. package/src/ai/site-skill.ts +1 -4
  86. package/src/astro/generate.ts +7 -0
  87. package/src/astro/templates.ts +36 -20
  88. package/src/components/layout/Header.astro +18 -10
  89. package/src/components/layout/PageActions.astro +1 -0
  90. package/src/components/layout/PageLayout.astro +31 -13
  91. package/src/components/layout/ReferenceLayout.astro +6 -1
  92. package/src/components/layout/RootLayout.astro +13 -1
  93. package/src/components/layout/head-scripts.ts +5 -1
  94. package/src/components/layout/locale-redirect.ts +38 -19
  95. package/src/components/layout/search/types.ts +12 -4
  96. package/src/core/config-input.ts +40 -1
  97. package/src/core/config.ts +5 -2
  98. package/src/core/data.ts +14 -0
  99. package/src/core/last-modified.ts +80 -34
  100. package/src/core/links.ts +43 -2
  101. package/src/core/manifest.ts +2 -0
  102. package/src/core/project-graph.ts +83 -43
  103. package/src/core/schema.ts +48 -9
  104. package/src/core/types.ts +10 -0
  105. package/src/deploy/sitemap.ts +127 -49
  106. package/src/search/documents.ts +34 -11
  107. package/src/search/fold.ts +175 -0
  108. package/src/search/orama-index.ts +40 -49
  109. package/src/seo/jsonld.ts +100 -23
  110. package/src/theme/chrome-icons.ts +2 -1
  111. package/src/theme/entry.ts +4 -1
  112. package/dist/cli/chunk-b680sv30.js.map +0 -13
  113. package/dist/cli/chunk-q1wwrxzw.js.map +0 -10
  114. package/dist/cli/chunk-qcyxkj44.js.map +0 -14
  115. /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-0b1a8xqz.js.map} +0 -0
  116. /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-0qx6vwwf.js.map} +0 -0
  117. /package/dist/cli/{chunk-j4nfgn2j.js.map → chunk-51wr1wre.js.map} +0 -0
  118. /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-531qbwn1.js.map} +0 -0
  119. /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-6nvcjzts.js.map} +0 -0
  120. /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6v5w8vzd.js.map} +0 -0
  121. /package/dist/cli/{chunk-f260jdrn.js.map → chunk-8cshtem6.js.map} +0 -0
  122. /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-8h5ceyk0.js.map} +0 -0
  123. /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-8v90n67z.js.map} +0 -0
  124. /package/dist/cli/{chunk-etckvbrh.js.map → chunk-98me5yas.js.map} +0 -0
  125. /package/dist/cli/{chunk-34wap3ed.js.map → chunk-9qmc57zw.js.map} +0 -0
  126. /package/dist/cli/{chunk-beb0z983.js.map → chunk-a1xmzxk2.js.map} +0 -0
  127. /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-ajw85dgf.js.map} +0 -0
  128. /package/dist/cli/{chunk-69rjf786.js.map → chunk-ansqj08e.js.map} +0 -0
  129. /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-b5gztt4j.js.map} +0 -0
  130. /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-bc4jz2m4.js.map} +0 -0
  131. /package/dist/cli/{chunk-aafhwx74.js.map → chunk-d4h1y312.js.map} +0 -0
  132. /package/dist/cli/{chunk-080mfch6.js.map → chunk-et7zwqkg.js.map} +0 -0
  133. /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-f72388jn.js.map} +0 -0
  134. /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-g0hpwbyx.js.map} +0 -0
  135. /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-j102xmnk.js.map} +0 -0
  136. /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-m54bz9ng.js.map} +0 -0
  137. /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-mt6njy68.js.map} +0 -0
  138. /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-n091sgam.js.map} +0 -0
  139. /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-p33bf2nb.js.map} +0 -0
  140. /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-saxq5jjr.js.map} +0 -0
  141. /package/dist/cli/{chunk-x60e15st.js.map → chunk-sbt1s5j2.js.map} +0 -0
  142. /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-tckd6qwv.js.map} +0 -0
  143. /package/dist/cli/{chunk-p834rkcn.js.map → chunk-tf4134kk.js.map} +0 -0
  144. /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-x2pgg22t.js.map} +0 -0
  145. /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 `&shy;` writes inside a long word. It
35
+ * belongs to no word: split there, Prüfungs&shy;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 whose text Orama's default tokenizer keeps mostly intact. Its
86
- * delimiter class is `/[^A-Za-zàèéìòóù0-9_'-]+/`, so every character outside
87
- * that set counts as a separator: text in any other script collapses to zero
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
- * A term written entirely in Latin script (plus digits and the separators
194
- * {@link TERM} keeps within a word). Orama's default tokenizer folds the
195
- * accented vowels it recognizes (café → cafe), so a segmented index folds
196
- * Latin terms too — otherwise switching a Cyrillic- or Greek-default site to
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 LATIN_TERM = /^[\p{Script=Latin}\p{N}'’.,]+$/u;
203
-
204
- const MARKS = /\p{M}+/gu;
205
-
206
- const foldDiacritics = (term: string): string =>
207
- LATIN_TERM.test(term) ? term.normalize("NFD").replace(MARKS, "") : term;
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 foldDiacritics}. Returns `undefined` for scripts the default
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 Orama's default.
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(foldDiacritics(term));
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 Orama's can't serve. */
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 Orama's own, exactly as on an index
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 = components.tokenizer.createTokenizer();
351
+ const standard = latinTokenizer();
355
352
  return {
356
353
  language: standard.language,
357
354
  normalizationCache: standard.normalizationCache,
358
- tokenize: (raw, language, prop, withCache) =>
359
- // Orama's tokenizer rejects a language other than its own, so the
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 Orama's default tokenizer reduces to zero
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 Orama's tokenizer for its Latin-script
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 = tokenizer
404
- ? create({ components: { tokenizer }, schema: SCHEMA })
405
- : create({ schema: SCHEMA });
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 Orama's, then merges the matches by score: every search runs over
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
- rootUrl: string;
166
- siteName: string;
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
- name: organization.name ?? context.siteName,
173
- url: organization.url ?? context.rootUrl,
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
- if (context.base) {
302
- node.isPartOf = { "@id": `${context.base}#website` };
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
- if (context.organizationId) {
305
- node.publisher = { "@id": context.organizationId };
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 = organization ? `${base}#organization` : null;
441
+ const organizationId =
442
+ input.jsonLd?.organizationId ??
443
+ (organization ? `${base}#organization` : null);
359
444
 
360
- if (base) {
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 {
@@ -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
- /** Lucide's arrow-up-right (the featured-link icon) as a `mask` data URI. */
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 {