@hraness/wordcell 0.21.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +196 -66
  2. package/dist/{authority-4sp9d552.js → authority-1pgftmwq.js} +55 -10
  3. package/dist/benchmark.js +3 -3
  4. package/dist/capture.js +6 -5
  5. package/dist/cli.js +61 -3800
  6. package/dist/clip/acquire.js +3 -2
  7. package/dist/clip/cli.js +6 -5
  8. package/dist/clip/doctor.js +4 -3
  9. package/dist/clip/jobs.js +3 -3
  10. package/dist/clip/refresh.js +1 -1
  11. package/dist/evaluation-builder.js +18 -17
  12. package/dist/evaluation-kb.js +14 -13
  13. package/dist/graph-authority.js +3 -3
  14. package/dist/graph-percolation.js +5 -5
  15. package/dist/index-0dqfnrz9.js +1985 -0
  16. package/dist/{index-0k2x4nn9.js → index-1tm7bgx7.js} +16 -4
  17. package/dist/{index-vdw5yjfa.js → index-2hf27mws.js} +108 -18
  18. package/dist/index-3agn8scn.js +306 -0
  19. package/dist/index-4ag64rc1.js +4299 -0
  20. package/dist/index-4knsp9qj.js +34 -0
  21. package/dist/index-66pshdtx.js +341 -0
  22. package/dist/index-9rf81m0p.js +368 -0
  23. package/dist/{index-59ssmqqm.js → index-a6kbv718.js} +1 -1
  24. package/dist/{index-51bap0mq.js → index-avbce90h.js} +9 -9
  25. package/dist/{index-180zbkkt.js → index-de2w8crk.js} +14 -13
  26. package/dist/{index-pbr6rv47.js → index-e6p9e926.js} +6 -6
  27. package/dist/{index-bcfn9xah.js → index-famy7fhs.js} +5 -5
  28. package/dist/{index-by0pbant.js → index-fc4dr114.js} +5 -2
  29. package/dist/{index-x6m57nfc.js → index-g5vsqmdy.js} +28 -57
  30. package/dist/{index-115b07ap.js → index-hcw140eb.js} +5 -5
  31. package/dist/index-j70m75wd.js +217 -0
  32. package/dist/{index-3ehtt59c.js → index-qwgsmtsz.js} +1 -1
  33. package/dist/{index-bgfzwt4h.js → index-t2bs9xdr.js} +4 -4
  34. package/dist/index.js +285 -93
  35. package/dist/portfolio.js +14 -13
  36. package/dist/publish-model.js +36 -0
  37. package/dist/publish-reader/reader.css +1042 -0
  38. package/dist/publish-reader/reader.js +1534 -0
  39. package/dist/publish-reader/theme.js +92 -0
  40. package/dist/publish-search.js +52 -0
  41. package/dist/publish.js +36 -0
  42. package/dist/rerank-typesafe.js +13 -0
  43. package/dist/rerank.js +14 -0
  44. package/dist/sdk.js +14 -11
  45. package/dist/search.js +2 -2
  46. package/dist/semantic.js +4 -4
  47. package/dist/support-mywv1em0.js +140 -0
  48. package/dist/workflows/decision-context.js +13 -12
  49. package/dist/workflows/index.js +13 -12
  50. package/package.json +68 -7
  51. package/skills/wordcell/AGENTS.md +1 -0
  52. package/skills/wordcell/SKILL.md +21 -1
  53. package/skills/wordcell/agents/openai.yaml +1 -1
  54. package/skills/wordcell/references/publish.md +93 -0
  55. package/skills/wordcell/references/query.md +22 -0
  56. package/src/cli-program.ts +353 -9
  57. package/src/cli.ts +12 -1
  58. package/src/clip/acquire.ts +2 -0
  59. package/src/clip/doctor.ts +2 -0
  60. package/src/clip/media.ts +2 -0
  61. package/src/index.ts +4 -0
  62. package/src/oh/authority.ts +14 -5
  63. package/src/oh/canonical-rust.ts +127 -0
  64. package/src/oh/projection-rust.ts +63 -0
  65. package/src/oh-adoption.ts +7 -1
  66. package/src/publish-graph.ts +0 -0
  67. package/src/publish-index.ts +195 -0
  68. package/src/publish-markdown.ts +617 -0
  69. package/src/publish-model.ts +599 -0
  70. package/src/publish-nav.ts +204 -0
  71. package/src/publish-pages.ts +382 -0
  72. package/src/publish-reader/reader.css +1042 -0
  73. package/src/publish-reader/reader.ts +1017 -0
  74. package/src/publish-reader/theme.ts +97 -0
  75. package/src/publish-search.ts +418 -0
  76. package/src/publish-select.ts +419 -0
  77. package/src/publish-theme.ts +106 -0
  78. package/src/publish.ts +904 -0
  79. package/src/rerank-credentials.ts +56 -0
  80. package/src/rerank-typesafe.ts +472 -0
  81. package/src/rerank.ts +389 -0
  82. package/src/sdk.ts +157 -6
  83. package/src/serve.ts +180 -0
  84. package/src/support-profile.ts +7 -0
  85. package/src/support.ts +73 -0
  86. package/src/vault.ts +23 -9
  87. package/dist/{index-why54q5p.js → index-gm9t95d9.js} +3 -3
@@ -0,0 +1,97 @@
1
+ import {
2
+ DEFAULT_WORDCELL_APPEARANCE,
3
+ parseAppearancePreference,
4
+ resolveAppearanceMode,
5
+ serializeAppearancePreference,
6
+ WORDCELL_APPEARANCE_STORAGE_KEY,
7
+ type WordcellAppearanceBridge,
8
+ type WordcellAppearancePreference,
9
+ } from "../publish-theme.js";
10
+
11
+ /**
12
+ * Synchronous appearance bootstrap emitted as `reader/theme.js` — a classic
13
+ * (non-module) script in every published page head. It applies the saved
14
+ * palette to `data-palette`/`data-theme` on the document element before the
15
+ * first paint so a stored preference never flashes the default theme, then
16
+ * keeps the attributes current across operating-system appearance changes
17
+ * under `system` and cross-tab storage writes. It exposes the small
18
+ * `wordcellAppearance` bridge for the deferred reader bundle's menu and
19
+ * graph repaint. No inline script or style is required, so the strict
20
+ * `default-src 'self'` policy still applies.
21
+ */
22
+
23
+ type ThemeMediaQuery = {
24
+ readonly matches: boolean;
25
+ addEventListener(name: "change", listener: () => void): void;
26
+ };
27
+
28
+ const host = globalThis as {
29
+ document?: { documentElement?: { setAttribute(name: string, value: string): void } };
30
+ localStorage?: { getItem(key: string): string | null; setItem(key: string, value: string): void };
31
+ matchMedia?(query: string): ThemeMediaQuery;
32
+ addEventListener?(name: "storage", listener: (event: { key?: string }) => void): void;
33
+ wordcellAppearance?: WordcellAppearanceBridge;
34
+ };
35
+
36
+ const root = host.document?.documentElement;
37
+ const media = host.matchMedia?.("(prefers-color-scheme: dark)");
38
+
39
+ function readPreference(): WordcellAppearancePreference {
40
+ try {
41
+ const raw = host.localStorage?.getItem(WORDCELL_APPEARANCE_STORAGE_KEY);
42
+ if (raw === null || raw === undefined) return DEFAULT_WORDCELL_APPEARANCE;
43
+ return parseAppearancePreference(JSON.parse(raw)) ?? DEFAULT_WORDCELL_APPEARANCE;
44
+ } catch {
45
+ return DEFAULT_WORDCELL_APPEARANCE;
46
+ }
47
+ }
48
+
49
+ let preference = readPreference();
50
+ const listeners = new Set<
51
+ (preference: WordcellAppearancePreference, resolved: "light" | "dark") => void
52
+ >();
53
+
54
+ function resolved(): "light" | "dark" {
55
+ return resolveAppearanceMode(preference.mode, media?.matches === true);
56
+ }
57
+
58
+ function apply(): void {
59
+ const theme = resolved();
60
+ root?.setAttribute("data-palette", preference.palette);
61
+ root?.setAttribute("data-theme", theme);
62
+ for (const listener of listeners) listener(preference, theme);
63
+ }
64
+
65
+ host.addEventListener?.("storage", (event) => {
66
+ if (event.key !== WORDCELL_APPEARANCE_STORAGE_KEY) return;
67
+ preference = readPreference();
68
+ apply();
69
+ });
70
+
71
+ media?.addEventListener("change", () => {
72
+ if (preference.mode === "system") apply();
73
+ });
74
+
75
+ host.wordcellAppearance = {
76
+ get: () => preference,
77
+ resolvedTheme: resolved,
78
+ set(next: WordcellAppearancePreference) {
79
+ preference = parseAppearancePreference(next) ?? DEFAULT_WORDCELL_APPEARANCE;
80
+ try {
81
+ host.localStorage?.setItem(
82
+ WORDCELL_APPEARANCE_STORAGE_KEY,
83
+ serializeAppearancePreference(preference),
84
+ );
85
+ } catch {
86
+ // Storage may be unavailable (private mode, disabled cookies); the
87
+ // preference still applies for this page view.
88
+ }
89
+ apply();
90
+ },
91
+ subscribe(listener) {
92
+ listeners.add(listener);
93
+ return () => listeners.delete(listener);
94
+ },
95
+ };
96
+
97
+ apply();
@@ -0,0 +1,418 @@
1
+ import type {
2
+ WordcellSiteDocFieldsV1,
3
+ WordcellSiteDocV1,
4
+ } from "./publish-model.js";
5
+
6
+ /**
7
+ * Shared pure search core for published sites. The index builder, the bundled
8
+ * browser reader, and tests all import these functions, so this module must
9
+ * stay free of Node and DOM dependencies.
10
+ *
11
+ * Field weights and admission rules mirror the exact lane in `search.ts`:
12
+ * field matching is normalized substring matching, identity and phrase matches
13
+ * outrank term coverage, and multi-term queries require bounded coverage unless
14
+ * an identity or phrase match already admits the document.
15
+ */
16
+
17
+ export const PUBLISH_FIELD_TITLE = 1;
18
+ export const PUBLISH_FIELD_ALIAS = 2;
19
+ export const PUBLISH_FIELD_PATH = 4;
20
+ export const PUBLISH_FIELD_TAG = 8;
21
+ export const PUBLISH_FIELD_METADATA = 16;
22
+ export const PUBLISH_FIELD_CONTENT = 32;
23
+
24
+ export const PUBLISH_SEARCH_WEIGHTS_V1 = Object.freeze({
25
+ identity: Object.freeze({ title: 1_000, alias: 950, path: 900 }),
26
+ phrase: Object.freeze({
27
+ title: 400,
28
+ alias: 350,
29
+ path: 300,
30
+ tag: 250,
31
+ content: 150,
32
+ metadata: 100,
33
+ }),
34
+ term: Object.freeze({
35
+ title: 40,
36
+ alias: 35,
37
+ path: 30,
38
+ tag: 30,
39
+ metadata: 10,
40
+ content: 5,
41
+ }),
42
+ /** Scaled by the fraction of unique query terms that matched anywhere. */
43
+ coverage: 100,
44
+ });
45
+
46
+ export const MAX_PUBLISH_QUERY_BYTES = 16 * 1_024;
47
+ export const MAX_PUBLISH_QUERY_TERMS = 64;
48
+ export const MAX_PUBLISH_PREFIX_EXPANSIONS = 32;
49
+
50
+ const TERM_PATTERN = /[\p{L}\p{N}][\p{L}\p{N}._/-]*/gu;
51
+
52
+ const textEncoder = new TextEncoder();
53
+
54
+ /** UTF-8 byte length without Node's Buffer — this module ships to browsers. */
55
+ export function publishUtf8Bytes(value: string): number {
56
+ return textEncoder.encode(value).byteLength;
57
+ }
58
+
59
+ export function publishNormalize(value: string): string {
60
+ return value.normalize("NFC").toLocaleLowerCase("en-US");
61
+ }
62
+
63
+ export type PublishQuery = {
64
+ readonly raw: string;
65
+ readonly normalized: string;
66
+ readonly terms: readonly string[];
67
+ };
68
+
69
+ export const MAX_PUBLISH_QUERY_FILTERS = 8;
70
+
71
+ export type PublishQueryFilters = {
72
+ /** Normalized tag values; a document must carry every one. */
73
+ readonly tags: readonly string[];
74
+ /** Normalized note types; absent catalog types count as "note". */
75
+ readonly types: readonly string[];
76
+ /** Normalized slug/path/id prefixes, matched on segment boundaries. */
77
+ readonly paths: readonly string[];
78
+ };
79
+
80
+ export type PublishQueryParts = {
81
+ readonly filters: PublishQueryFilters;
82
+ /** Query text with filter tokens removed, ready for `publishQuery`. */
83
+ readonly text: string;
84
+ };
85
+
86
+ const FILTER_PATTERN = /^(tag|type|path):(\S+)$/u;
87
+
88
+ /** Trim `/` runs at both ends without a regex (CodeQL-safe, linear). */
89
+ function trimSlashes(value: string): string {
90
+ let start = 0;
91
+ let end = value.length;
92
+ while (start < end && value.charCodeAt(start) === 0x2f) start += 1;
93
+ while (end > start && value.charCodeAt(end - 1) === 0x2f) end -= 1;
94
+ return value.slice(start, end);
95
+ }
96
+
97
+ /**
98
+ * Split a raw query into field filters and free text. `tag:`, `type:`, and
99
+ * `path:` tokens become filters (values normalized like query text); anything
100
+ * else — including a malformed `name:` token with an empty value — stays in
101
+ * the free-text query. Each filter family keeps at most
102
+ * MAX_PUBLISH_QUERY_FILTERS values; overflow falls back to free text.
103
+ */
104
+ export function publishQueryParts(raw: string): PublishQueryParts {
105
+ const tags: string[] = [];
106
+ const types: string[] = [];
107
+ const paths: string[] = [];
108
+ const rest: string[] = [];
109
+ for (const token of raw.split(/\s+/u)) {
110
+ if (token === "") continue;
111
+ const match = FILTER_PATTERN.exec(token);
112
+ const value = match === null
113
+ ? ""
114
+ : publishNormalize(trimSlashes(match[2] ?? ""));
115
+ if (match === null || value === "") {
116
+ rest.push(token);
117
+ continue;
118
+ }
119
+ const bucket = match[1] === "tag" ? tags : match[1] === "type" ? types : paths;
120
+ if (bucket.length < MAX_PUBLISH_QUERY_FILTERS) bucket.push(value);
121
+ else rest.push(token);
122
+ }
123
+ return { filters: { tags, types, paths }, text: rest.join(" ") };
124
+ }
125
+
126
+ /**
127
+ * Whether a document satisfies parsed filters. `type` is the catalog type for
128
+ * the document (raw); both sides normalize before comparing so filter casing
129
+ * matches the indexed value.
130
+ */
131
+ export function publishDocMatchesFilters(
132
+ doc: Pick<WordcellSiteDocV1, "s" | "f">,
133
+ filters: PublishQueryFilters,
134
+ type: string | undefined,
135
+ ): boolean {
136
+ if (filters.tags.length > 0) {
137
+ const tags = doc.f.g === "" ? [] : doc.f.g.split("\n");
138
+ for (const tag of filters.tags) {
139
+ if (!tags.includes(publishNormalize(tag))) return false;
140
+ }
141
+ }
142
+ if (filters.types.length > 0) {
143
+ const docType = publishNormalize(type === undefined || type === "" ? "note" : type);
144
+ for (const wanted of filters.types) {
145
+ if (docType !== publishNormalize(wanted)) return false;
146
+ }
147
+ }
148
+ if (filters.paths.length > 0) {
149
+ const slug = publishNormalize(doc.s);
150
+ const fields = doc.f.p === "" ? [] : doc.f.p.split("\n");
151
+ for (const value of filters.paths) {
152
+ const prefix = publishNormalize(trimSlashes(value));
153
+ const boundary = `${prefix}/`;
154
+ const inSlug = slug === prefix || slug.startsWith(boundary);
155
+ const inFields = fields.some((line) => line === prefix || line.startsWith(boundary));
156
+ if (!inSlug && !inFields) return false;
157
+ }
158
+ }
159
+ return true;
160
+ }
161
+
162
+ export type PublishMarkRange = {
163
+ /** Start and end offsets into the NFC-normalized input text (UTF-16). */
164
+ readonly start: number;
165
+ readonly end: number;
166
+ };
167
+
168
+ export const MAX_PUBLISH_MARK_RANGES = 32;
169
+
170
+ /**
171
+ * Locate query terms in display text for `<mark>` highlighting. The search
172
+ * index normalizes with NFC + en-US lowercase, so this builds the same
173
+ * normalized form code point by code point and maps matches back to ranges in
174
+ * the NFC-normalized input. Ranges are sorted, non-overlapping, and bounded.
175
+ */
176
+ export function publishMarkRanges(
177
+ text: string,
178
+ terms: readonly string[],
179
+ maximum = MAX_PUBLISH_MARK_RANGES,
180
+ ): readonly PublishMarkRange[] {
181
+ const display = text.normalize("NFC");
182
+ let normalized = "";
183
+ const map: number[] = [];
184
+ for (let offset = 0; offset < display.length;) {
185
+ const point = display.codePointAt(offset) ?? 0;
186
+ const character = String.fromCodePoint(point);
187
+ const lowered = character.toLocaleLowerCase("en-US");
188
+ for (let index = 0; index < lowered.length; index += 1) map.push(offset);
189
+ normalized += lowered;
190
+ offset += character.length;
191
+ }
192
+ const ranges: PublishMarkRange[] = [];
193
+ for (const term of terms) {
194
+ const needle = publishNormalize(term);
195
+ if (needle === "") continue;
196
+ let from = 0;
197
+ while (ranges.length < maximum) {
198
+ const hit = normalized.indexOf(needle, from);
199
+ if (hit === -1) break;
200
+ const start = map[hit] ?? 0;
201
+ const tail = hit + needle.length;
202
+ const end = tail >= map.length ? display.length : (map[tail] ?? display.length);
203
+ if (end > start) ranges.push({ start, end });
204
+ from = tail === 0 ? 1 : tail;
205
+ }
206
+ if (ranges.length >= maximum) break;
207
+ }
208
+ ranges.sort((left, right) => left.start - right.start || left.end - right.end);
209
+ const merged: PublishMarkRange[] = [];
210
+ for (const range of ranges) {
211
+ const last = merged[merged.length - 1];
212
+ if (last !== undefined && range.start <= last.end) {
213
+ merged[merged.length - 1] = { start: last.start, end: Math.max(last.end, range.end) };
214
+ } else {
215
+ merged.push(range);
216
+ }
217
+ }
218
+ return merged;
219
+ }
220
+
221
+ /** Normalize and bound a published-site query like `validateSearchQuery`. */
222
+ export function publishQuery(value: unknown): PublishQuery {
223
+ if (typeof value !== "string") {
224
+ throw new TypeError("Search query must be a string.");
225
+ }
226
+ if (publishUtf8Bytes(value) > MAX_PUBLISH_QUERY_BYTES) {
227
+ throw new RangeError(
228
+ `Search query exceeds the ${MAX_PUBLISH_QUERY_BYTES}-byte limit.`,
229
+ );
230
+ }
231
+ const raw = value.trim();
232
+ const normalized = publishNormalize(raw);
233
+ const terms: string[] = [];
234
+ const seen = new Set<string>();
235
+ for (const match of normalized.matchAll(TERM_PATTERN)) {
236
+ if (seen.has(match[0])) continue;
237
+ seen.add(match[0]);
238
+ terms.push(match[0]);
239
+ if (terms.length > MAX_PUBLISH_QUERY_TERMS) {
240
+ throw new RangeError(
241
+ `Search query may contain at most ${MAX_PUBLISH_QUERY_TERMS} unique normalized terms.`,
242
+ );
243
+ }
244
+ }
245
+ return { raw, normalized, terms };
246
+ }
247
+
248
+ /** FNV-1a 32-bit; the top byte names the content postings shard. */
249
+ export function publishShardName(term: string): string {
250
+ let hash = 0x811c9dc5;
251
+ for (let index = 0; index < term.length; index += 1) {
252
+ hash ^= term.charCodeAt(index);
253
+ hash = Math.imul(hash, 0x01000193) >>> 0;
254
+ }
255
+ return (hash >>> 24).toString(16).padStart(2, "0");
256
+ }
257
+
258
+ /** Sorted dictionary terms that start with the normalized prefix, bounded. */
259
+ export function publishPrefixTerms(
260
+ dictionary: readonly string[],
261
+ prefix: string,
262
+ maximum = MAX_PUBLISH_PREFIX_EXPANSIONS,
263
+ ): readonly string[] {
264
+ if (prefix === "") return [];
265
+ let low = 0;
266
+ let high = dictionary.length;
267
+ while (low < high) {
268
+ const middle = (low + high) >> 1;
269
+ if ((dictionary[middle] ?? "") < prefix) low = middle + 1;
270
+ else high = middle;
271
+ }
272
+ const found: string[] = [];
273
+ for (let index = low; index < dictionary.length && found.length < maximum; index += 1) {
274
+ const term = dictionary[index] ?? "";
275
+ if (!term.startsWith(prefix)) break;
276
+ found.push(term);
277
+ }
278
+ return found;
279
+ }
280
+
281
+ function fieldMasks(term: string, fields: WordcellSiteDocFieldsV1): number {
282
+ let mask = 0;
283
+ if (fields.t.includes(term)) mask |= PUBLISH_FIELD_TITLE;
284
+ if (fields.a.includes(term)) mask |= PUBLISH_FIELD_ALIAS;
285
+ if (fields.p.includes(term)) mask |= PUBLISH_FIELD_PATH;
286
+ if (fields.g.includes(term)) mask |= PUBLISH_FIELD_TAG;
287
+ if (fields.m.includes(term)) mask |= PUBLISH_FIELD_METADATA;
288
+ return mask;
289
+ }
290
+
291
+ export type PublishScoreInput = {
292
+ readonly doc: WordcellSiteDocV1;
293
+ /**
294
+ * True when the content field contains the term: inline text scan, a
295
+ * postings hit, or a hydrated note payload match.
296
+ */
297
+ readonly contentTerms: ReadonlySet<string>;
298
+ /**
299
+ * Full normalized content text when available (inline mode or a hydrated
300
+ * note payload); enables the content phrase bonus.
301
+ */
302
+ readonly contentText?: string;
303
+ };
304
+
305
+ export type PublishScore = {
306
+ readonly score: number;
307
+ readonly identity: boolean;
308
+ readonly phraseMatched: boolean;
309
+ readonly matchedTerms: number;
310
+ };
311
+
312
+ /**
313
+ * Score one document against a validated query, reproducing the exact lane's
314
+ * rank inputs. Returns null when the document is not admissible: a multi-term
315
+ * query needs bounded coverage unless identity or phrase already matched.
316
+ */
317
+ export function scorePublishDocument(
318
+ input: PublishScoreInput,
319
+ query: PublishQuery,
320
+ ): PublishScore | null {
321
+ const fields = input.doc.f;
322
+ const phrase = query.normalized;
323
+ const weights = PUBLISH_SEARCH_WEIGHTS_V1;
324
+ let score = 0;
325
+ let identity = false;
326
+ let phraseMatched = false;
327
+
328
+ if (phrase !== "") {
329
+ if (fields.t === phrase) {
330
+ identity = true;
331
+ score += weights.identity.title;
332
+ }
333
+ if (fields.a.split("\n").includes(phrase)) {
334
+ identity = true;
335
+ score += weights.identity.alias;
336
+ }
337
+ if (fields.p.split("\n").includes(phrase)) {
338
+ identity = true;
339
+ score += weights.identity.path;
340
+ }
341
+ const phraseFields: readonly [number, string, number][] = [
342
+ [PUBLISH_FIELD_TITLE, fields.t, weights.phrase.title],
343
+ [PUBLISH_FIELD_ALIAS, fields.a, weights.phrase.alias],
344
+ [PUBLISH_FIELD_PATH, fields.p, weights.phrase.path],
345
+ [PUBLISH_FIELD_TAG, fields.g, weights.phrase.tag],
346
+ [PUBLISH_FIELD_METADATA, fields.m, weights.phrase.metadata],
347
+ ];
348
+ for (const [, value, weight] of phraseFields) {
349
+ if (value.includes(phrase)) {
350
+ phraseMatched = true;
351
+ score += weight;
352
+ }
353
+ }
354
+ if (input.contentText !== undefined && input.contentText.includes(phrase)) {
355
+ phraseMatched = true;
356
+ score += weights.phrase.content;
357
+ }
358
+ }
359
+
360
+ let matchedTerms = 0;
361
+ for (const term of query.terms) {
362
+ const mask = fieldMasks(term, fields)
363
+ | (input.contentTerms.has(term) ? PUBLISH_FIELD_CONTENT : 0);
364
+ if (mask === 0) continue;
365
+ matchedTerms += 1;
366
+ if (mask & PUBLISH_FIELD_TITLE) score += weights.term.title;
367
+ if (mask & PUBLISH_FIELD_ALIAS) score += weights.term.alias;
368
+ if (mask & PUBLISH_FIELD_PATH) score += weights.term.path;
369
+ if (mask & PUBLISH_FIELD_TAG) score += weights.term.tag;
370
+ if (mask & PUBLISH_FIELD_METADATA) score += weights.term.metadata;
371
+ if (mask & PUBLISH_FIELD_CONTENT) score += weights.term.content;
372
+ }
373
+ const requiredTerms = query.terms.length <= 1
374
+ ? query.terms.length
375
+ : Math.min(3, Math.ceil(query.terms.length / 2));
376
+ if (!identity && !phraseMatched && matchedTerms < requiredTerms) return null;
377
+ if (query.terms.length > 0) {
378
+ score += Math.round((matchedTerms / query.terms.length) * weights.coverage);
379
+ }
380
+ if (score === 0) return null;
381
+ return { score, identity, phraseMatched, matchedTerms };
382
+ }
383
+
384
+ /** Deterministic rank: identity first, then score, then document id. */
385
+ export function comparePublishScores(
386
+ left: PublishScore & { i: number },
387
+ right: PublishScore & { i: number },
388
+ ): number {
389
+ return Number(right.identity) - Number(left.identity)
390
+ || right.score - left.score
391
+ || left.i - right.i;
392
+ }
393
+
394
+ /**
395
+ * Bounded context window around the first query match in normalized text.
396
+ * Mirrors `exactSnippet`: collapse whitespace, ellipsize edges.
397
+ */
398
+ export function publishSnippet(
399
+ text: string,
400
+ query: PublishQuery,
401
+ fallback: string,
402
+ windowBytes = 160,
403
+ ): string {
404
+ const normalized = publishNormalize(text);
405
+ let offset = query.normalized === "" ? -1 : normalized.indexOf(query.normalized);
406
+ if (offset < 0) {
407
+ for (const term of query.terms) {
408
+ offset = normalized.indexOf(term);
409
+ if (offset >= 0) break;
410
+ }
411
+ }
412
+ if (offset < 0) return fallback;
413
+ const half = Math.floor(windowBytes / 2);
414
+ const start = Math.max(0, offset - half);
415
+ const end = Math.min(text.length, offset + half);
416
+ const snippet = text.slice(start, end).replace(/\s+/gu, " ").trim();
417
+ return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
418
+ }