@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,599 @@
1
+ /**
2
+ * `hraness.wordcell.site.*` — the versioned static-publication contract emitted
3
+ * by `wordcell publish`. Every JSON file in a published site carries its own
4
+ * format identifier so consumers can reject foreign or mixed-version content
5
+ * before reading fields. All emitted document order and object key order is
6
+ * canonical: builders sort entries deterministically and writers serialize
7
+ * through `@hraness/oh` canonical JSON.
8
+ */
9
+ export const WORDCELL_SITE_FORMAT_V1 = "hraness.wordcell.site.v1" as const;
10
+ export const WORDCELL_SITE_CATALOG_FORMAT_V1 =
11
+ "hraness.wordcell.site-catalog.v1" as const;
12
+ export const WORDCELL_SITE_DOCS_FORMAT_V1 =
13
+ "hraness.wordcell.site-docs.v1" as const;
14
+ export const WORDCELL_SITE_TERMS_FORMAT_V1 =
15
+ "hraness.wordcell.site-terms.v1" as const;
16
+ export const WORDCELL_SITE_POSTINGS_FORMAT_V1 =
17
+ "hraness.wordcell.site-postings.v1" as const;
18
+ export const WORDCELL_SITE_NOTE_FORMAT_V1 =
19
+ "hraness.wordcell.site-note.v1" as const;
20
+ export const WORDCELL_SITE_GRAPH_FORMAT_V1 =
21
+ "hraness.wordcell.site-graph.v1" as const;
22
+
23
+ export const WORDCELL_SITE_LIMITS_V1 = Object.freeze({
24
+ /** Published notes, matching the vault analysis ceiling. */
25
+ notes: 10_000,
26
+ /** Copied attachment files. */
27
+ assets: 10_000,
28
+ /** Bytes accepted from one attachment source file. */
29
+ assetBytes: 16 * 1_024 * 1_024,
30
+ /** Total attachment bytes copied into one artifact. */
31
+ totalAssetBytes: 512 * 1_024 * 1_024,
32
+ /** Unique normalized terms admitted to the content index. */
33
+ indexTerms: 250_000,
34
+ /** Content posting shards; one hex byte names each shard file. */
35
+ contentShards: 256,
36
+ /** Postings pairs stored in one shard before lower-frequency terms drop. */
37
+ shardPairs: 250_000,
38
+ /** Plain-text preview stored per document for result snippets. */
39
+ docPreviewBytes: 240,
40
+ /** Normalized per-document field text retained for eager search. */
41
+ fieldTextBytes: 4 * 1_024,
42
+ /** Per-note normalized content text retained when inline search applies. */
43
+ inlineTextBytes: 16 * 1_024,
44
+ /**
45
+ * Aggregate inline text budget. When the selected corpus fits, the document
46
+ * table carries normalized content text and browser search reproduces the
47
+ * exact lane's substring semantics; larger corpora use postings shards.
48
+ */
49
+ inlineTotalBytes: 2 * 1_024 * 1_024,
50
+ /** Normalized content text retained per note payload for hydration. */
51
+ noteTextBytes: 32 * 1_024,
52
+ /** Site title and description. */
53
+ titleBytes: 512,
54
+ descriptionBytes: 4 * 1_024,
55
+ /** Reader-side result hydration and rendering bounds. */
56
+ searchHydration: 50,
57
+ searchResults: 100,
58
+ });
59
+
60
+ export type WordcellSiteContentSearchV1 = "inline" | "shards" | "none";
61
+
62
+ export type WordcellSiteManifestV1 = {
63
+ readonly format: typeof WORDCELL_SITE_FORMAT_V1;
64
+ readonly site: Readonly<{
65
+ title: string;
66
+ basePath: string;
67
+ description?: string;
68
+ }>;
69
+ readonly generated: Readonly<{
70
+ by: "@hraness/wordcell";
71
+ version: string;
72
+ /** RFC 3339 instant; absent in deterministic output. */
73
+ at?: string;
74
+ }>;
75
+ readonly source: Readonly<{
76
+ selection: Readonly<Record<string, unknown>>;
77
+ notes: number;
78
+ digest: `sha256:${string}`;
79
+ }>;
80
+ readonly paths: Readonly<{
81
+ catalog: string;
82
+ graph: string;
83
+ docs: string;
84
+ terms: string;
85
+ postingsPrefix: string;
86
+ notePrefix: string;
87
+ assetPrefix: string;
88
+ readerPrefix: string;
89
+ }>;
90
+ readonly search: Readonly<{
91
+ mode: "exact";
92
+ content: WordcellSiteContentSearchV1;
93
+ shards: number;
94
+ hash: "fnv1a32-8bit";
95
+ }>;
96
+ readonly counts: Readonly<{
97
+ notes: number;
98
+ assets: number;
99
+ bytes: number;
100
+ }>;
101
+ /** Truncated aspects; an empty object means complete data. Always present. */
102
+ readonly truncated: Readonly<{
103
+ terms?: true;
104
+ text?: true;
105
+ assets?: true;
106
+ }>;
107
+ };
108
+
109
+ export type WordcellSiteCatalogEntryV1 = {
110
+ /** Numeric document id; indexes docs and graph edges. */
111
+ readonly i: number;
112
+ /** Site-relative note path below the note prefix, no extension. */
113
+ readonly s: string;
114
+ readonly t: string;
115
+ readonly type?: string;
116
+ readonly g?: readonly string[];
117
+ };
118
+
119
+ export type WordcellSiteCatalogV1 = {
120
+ readonly format: typeof WORDCELL_SITE_CATALOG_FORMAT_V1;
121
+ readonly entries: readonly WordcellSiteCatalogEntryV1[];
122
+ };
123
+
124
+ export type WordcellSiteDocFieldsV1 = {
125
+ /** Normalized title. */
126
+ readonly t: string;
127
+ /** Normalized aliases joined by newline. */
128
+ readonly a: string;
129
+ /** Normalized path and id joined by newline. */
130
+ readonly p: string;
131
+ /** Normalized tags joined by newline. */
132
+ readonly g: string;
133
+ /** Normalized canonical metadata JSON. */
134
+ readonly m: string;
135
+ };
136
+
137
+ export type WordcellSiteDocV1 = {
138
+ readonly i: number;
139
+ readonly s: string;
140
+ readonly t: string;
141
+ /** Bounded plain-text preview for result snippets. */
142
+ readonly p: string;
143
+ readonly f: WordcellSiteDocFieldsV1;
144
+ /** Normalized content text, present only in inline search mode. */
145
+ readonly x?: string;
146
+ };
147
+
148
+ export type WordcellSiteDocsV1 = {
149
+ readonly format: typeof WORDCELL_SITE_DOCS_FORMAT_V1;
150
+ readonly content: WordcellSiteContentSearchV1;
151
+ readonly docs: readonly WordcellSiteDocV1[];
152
+ };
153
+
154
+ export type WordcellSiteTermsV1 = {
155
+ readonly format: typeof WORDCELL_SITE_TERMS_FORMAT_V1;
156
+ /** Sorted unique normalized content terms for prefix expansion. */
157
+ readonly terms: readonly string[];
158
+ };
159
+
160
+ export type WordcellSitePostingsV1 = {
161
+ readonly format: typeof WORDCELL_SITE_POSTINGS_FORMAT_V1;
162
+ /** Two-digit lowercase hex shard name. */
163
+ readonly shard: string;
164
+ /** Normalized content term to document ids, both sorted. */
165
+ readonly postings: Readonly<Record<string, readonly number[]>>;
166
+ };
167
+
168
+ export type WordcellSiteNoteLinkV1 = {
169
+ readonly s: string;
170
+ readonly t: string;
171
+ };
172
+
173
+ export type WordcellSiteNoteRelationV1 = {
174
+ readonly p: string;
175
+ readonly s: string;
176
+ readonly t: string;
177
+ };
178
+
179
+ export type WordcellSiteNoteV1 = {
180
+ readonly format: typeof WORDCELL_SITE_NOTE_FORMAT_V1;
181
+ readonly id: string;
182
+ readonly slug: string;
183
+ readonly title: string;
184
+ readonly aliases: readonly string[];
185
+ readonly type?: string;
186
+ readonly tags: readonly string[];
187
+ readonly summary: string;
188
+ /** Bounded normalized content text for search hydration. */
189
+ readonly text: string;
190
+ readonly textTruncated: boolean;
191
+ readonly links: readonly WordcellSiteNoteLinkV1[];
192
+ readonly backlinks: readonly WordcellSiteNoteLinkV1[];
193
+ readonly relations: readonly WordcellSiteNoteRelationV1[];
194
+ readonly relationBacklinks: readonly WordcellSiteNoteRelationV1[];
195
+ };
196
+
197
+ export type WordcellSiteGraphEdgeV1 = {
198
+ readonly s: number;
199
+ readonly t: number;
200
+ readonly k: "link" | "relation";
201
+ readonly p?: string;
202
+ };
203
+
204
+ export type WordcellSiteGraphV1 = {
205
+ readonly format: typeof WORDCELL_SITE_GRAPH_FORMAT_V1;
206
+ readonly edges: readonly WordcellSiteGraphEdgeV1[];
207
+ };
208
+
209
+ export type WordcellSiteSelectionV1 = {
210
+ /** New publishers record counts only; legacy manifests may carry selectors. */
211
+ readonly includeCount?: number;
212
+ readonly excludeCount?: number;
213
+ readonly includeGlobCount?: number;
214
+ readonly excludeGlobCount?: number;
215
+ readonly fromCount?: number;
216
+ readonly includes: readonly string[];
217
+ readonly excludes: readonly string[];
218
+ readonly from?: Readonly<{
219
+ note: string;
220
+ depth: number;
221
+ direction: "in" | "out" | "both";
222
+ }>;
223
+ readonly filterCount: number;
224
+ readonly tagCount: number;
225
+ readonly scopeCount: number;
226
+ };
227
+
228
+ export type WordcellPublishReport = {
229
+ readonly format: typeof WORDCELL_SITE_FORMAT_V1;
230
+ readonly out: string;
231
+ readonly deterministic: boolean;
232
+ /** Bounded selected ids for local review; never includes note bodies or excluded ids. */
233
+ readonly selection: Readonly<{
234
+ ids: readonly string[];
235
+ total: number;
236
+ truncated: boolean;
237
+ digest: string;
238
+ }>;
239
+ readonly files: number;
240
+ readonly bytes: number;
241
+ readonly notes: Readonly<{
242
+ published: number;
243
+ excludedPrivate: number;
244
+ excludedBySelection: number;
245
+ }>;
246
+ readonly links: Readonly<{
247
+ kept: number;
248
+ droppedExternal: number;
249
+ }>;
250
+ readonly assets: Readonly<{
251
+ count: number;
252
+ bytes: number;
253
+ skipped: number;
254
+ }>;
255
+ readonly search: Readonly<{
256
+ content: WordcellSiteContentSearchV1;
257
+ terms: number;
258
+ truncated: boolean;
259
+ }>;
260
+ };
261
+
262
+ const textEncoder = new TextEncoder();
263
+
264
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
265
+ return typeof value === "object" && value !== null && !Array.isArray(value);
266
+ }
267
+
268
+ function boundedText(
269
+ value: unknown,
270
+ maximumBytes: number,
271
+ field: string,
272
+ ): string {
273
+ if (typeof value !== "string" || textEncoder.encode(value).byteLength > maximumBytes) {
274
+ throw new TypeError(`${field} must be a string of at most ${maximumBytes} bytes`);
275
+ }
276
+ return value;
277
+ }
278
+
279
+ function boundedCount(value: unknown, maximum: number, field: string): number {
280
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) {
281
+ throw new TypeError(`${field} must be a safe integer from 0 through ${maximum}`);
282
+ }
283
+ return value;
284
+ }
285
+
286
+ function exactKeys(
287
+ value: Readonly<Record<string, unknown>>,
288
+ required: readonly string[],
289
+ optional: readonly string[],
290
+ field: string,
291
+ ): void {
292
+ const allowed = new Set([...required, ...optional]);
293
+ for (const key of Object.keys(value)) {
294
+ if (!allowed.has(key)) throw new TypeError(`${field} has unexpected key ${JSON.stringify(key)}`);
295
+ }
296
+ for (const key of required) {
297
+ if (!(key in value)) throw new TypeError(`${field} is missing ${JSON.stringify(key)}`);
298
+ }
299
+ }
300
+
301
+ function stringArray(value: unknown, maximum: number, field: string): readonly string[] {
302
+ if (!Array.isArray(value) || value.length > maximum) {
303
+ throw new TypeError(`${field} must be an array of at most ${maximum} strings`);
304
+ }
305
+ for (const item of value) {
306
+ if (typeof item !== "string") throw new TypeError(`${field} entries must be strings`);
307
+ }
308
+ return value as readonly string[];
309
+ }
310
+
311
+ function contentSearch(value: unknown): WordcellSiteContentSearchV1 {
312
+ if (value === "inline" || value === "shards" || value === "none") return value;
313
+ throw new TypeError("search.content must be inline, shards, or none");
314
+ }
315
+
316
+ export function parseSiteManifestV1(value: unknown): WordcellSiteManifestV1 {
317
+ if (!isRecord(value)) throw new TypeError("manifest must be an object");
318
+ exactKeys(value, ["format", "site", "generated", "source", "paths", "search", "counts", "truncated"], [], "manifest");
319
+ if (value.format !== WORDCELL_SITE_FORMAT_V1) {
320
+ throw new TypeError(`manifest format must be ${WORDCELL_SITE_FORMAT_V1}`);
321
+ }
322
+ const { site, generated, source, paths, search, counts, truncated } = value;
323
+ if (!isRecord(site)) throw new TypeError("manifest.site must be an object");
324
+ exactKeys(site, ["title", "basePath"], ["description"], "manifest.site");
325
+ if (!isRecord(generated)) throw new TypeError("manifest.generated must be an object");
326
+ exactKeys(generated, ["by", "version"], ["at"], "manifest.generated");
327
+ if (generated.by !== "@hraness/wordcell") {
328
+ throw new TypeError("manifest.generated.by must be @hraness/wordcell");
329
+ }
330
+ if (!isRecord(source)) throw new TypeError("manifest.source must be an object");
331
+ exactKeys(source, ["selection", "notes", "digest"], [], "manifest.source");
332
+ if (!isRecord(paths)) throw new TypeError("manifest.paths must be an object");
333
+ exactKeys(
334
+ paths,
335
+ ["catalog", "graph", "docs", "terms", "postingsPrefix", "notePrefix", "assetPrefix", "readerPrefix"],
336
+ [],
337
+ "manifest.paths",
338
+ );
339
+ if (!isRecord(search)) throw new TypeError("manifest.search must be an object");
340
+ exactKeys(search, ["mode", "content", "shards", "hash"], [], "manifest.search");
341
+ if (search.mode !== "exact") throw new TypeError("manifest.search.mode must be exact");
342
+ if (search.hash !== "fnv1a32-8bit") {
343
+ throw new TypeError("manifest.search.hash must be fnv1a32-8bit");
344
+ }
345
+ if (!isRecord(counts)) throw new TypeError("manifest.counts must be an object");
346
+ exactKeys(counts, ["notes", "assets", "bytes"], [], "manifest.counts");
347
+ if (!isRecord(truncated)) throw new TypeError("manifest.truncated must be an object");
348
+ exactKeys(truncated, [], ["terms", "text", "assets"], "manifest.truncated");
349
+ for (const flag of Object.values(truncated)) {
350
+ if (flag !== true) throw new TypeError("manifest.truncated flags must be true");
351
+ }
352
+ const digest = source.digest;
353
+ if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(digest)) {
354
+ throw new TypeError("manifest.source.digest must be a sha256 hex digest");
355
+ }
356
+ return {
357
+ format: WORDCELL_SITE_FORMAT_V1,
358
+ site: Object.freeze({
359
+ title: boundedText(site.title, WORDCELL_SITE_LIMITS_V1.titleBytes, "site.title"),
360
+ basePath: boundedText(site.basePath, 1_024, "site.basePath"),
361
+ ...(site.description === undefined
362
+ ? {}
363
+ : { description: boundedText(site.description, WORDCELL_SITE_LIMITS_V1.descriptionBytes, "site.description") }),
364
+ }),
365
+ generated: Object.freeze({
366
+ by: "@hraness/wordcell",
367
+ version: boundedText(generated.version, 64, "generated.version"),
368
+ ...(generated.at === undefined
369
+ ? {}
370
+ : { at: boundedText(generated.at, 64, "generated.at") }),
371
+ }),
372
+ source: Object.freeze({
373
+ selection: source.selection as Readonly<Record<string, unknown>>,
374
+ notes: boundedCount(source.notes, WORDCELL_SITE_LIMITS_V1.notes, "source.notes"),
375
+ digest: digest as `sha256:${string}`,
376
+ }),
377
+ paths: Object.freeze({
378
+ catalog: boundedText(paths.catalog, 256, "paths.catalog"),
379
+ graph: boundedText(paths.graph, 256, "paths.graph"),
380
+ docs: boundedText(paths.docs, 256, "paths.docs"),
381
+ terms: boundedText(paths.terms, 256, "paths.terms"),
382
+ postingsPrefix: boundedText(paths.postingsPrefix, 256, "paths.postingsPrefix"),
383
+ notePrefix: boundedText(paths.notePrefix, 256, "paths.notePrefix"),
384
+ assetPrefix: boundedText(paths.assetPrefix, 256, "paths.assetPrefix"),
385
+ readerPrefix: boundedText(paths.readerPrefix, 256, "paths.readerPrefix"),
386
+ }),
387
+ search: Object.freeze({
388
+ mode: "exact",
389
+ content: contentSearch(search.content),
390
+ shards: boundedCount(search.shards, WORDCELL_SITE_LIMITS_V1.contentShards, "search.shards"),
391
+ hash: "fnv1a32-8bit",
392
+ }),
393
+ counts: Object.freeze({
394
+ notes: boundedCount(counts.notes, WORDCELL_SITE_LIMITS_V1.notes, "counts.notes"),
395
+ assets: boundedCount(counts.assets, WORDCELL_SITE_LIMITS_V1.assets, "counts.assets"),
396
+ bytes: boundedCount(counts.bytes, Number.MAX_SAFE_INTEGER, "counts.bytes"),
397
+ }),
398
+ truncated: Object.freeze({ ...(truncated as Record<string, true>) }),
399
+ };
400
+ }
401
+
402
+ export function parseSiteCatalogV1(value: unknown): WordcellSiteCatalogV1 {
403
+ if (!isRecord(value)) throw new TypeError("catalog must be an object");
404
+ exactKeys(value, ["format", "entries"], [], "catalog");
405
+ if (value.format !== WORDCELL_SITE_CATALOG_FORMAT_V1) {
406
+ throw new TypeError(`catalog format must be ${WORDCELL_SITE_CATALOG_FORMAT_V1}`);
407
+ }
408
+ if (!Array.isArray(value.entries) || value.entries.length > WORDCELL_SITE_LIMITS_V1.notes) {
409
+ throw new TypeError("catalog.entries must be a bounded array");
410
+ }
411
+ const entries = (value.entries as unknown[]).map((entry, index): WordcellSiteCatalogEntryV1 => {
412
+ if (!isRecord(entry)) throw new TypeError(`catalog entry ${index} must be an object`);
413
+ exactKeys(entry, ["i", "s", "t"], ["type", "g"], `catalog entry ${index}`);
414
+ return {
415
+ i: boundedCount(entry.i, WORDCELL_SITE_LIMITS_V1.notes, `catalog entry ${index}.i`),
416
+ s: boundedText(entry.s, 1_024, `catalog entry ${index}.s`),
417
+ t: boundedText(entry.t, WORDCELL_SITE_LIMITS_V1.titleBytes, `catalog entry ${index}.t`),
418
+ ...(entry.type === undefined
419
+ ? {}
420
+ : { type: boundedText(entry.type, 256, `catalog entry ${index}.type`) }),
421
+ ...(entry.g === undefined
422
+ ? {}
423
+ : { g: stringArray(entry.g, 128, `catalog entry ${index}.g`) }),
424
+ };
425
+ });
426
+ return { format: WORDCELL_SITE_CATALOG_FORMAT_V1, entries };
427
+ }
428
+
429
+ function docFields(value: unknown, field: string): WordcellSiteDocFieldsV1 {
430
+ if (!isRecord(value)) throw new TypeError(`${field} must be an object`);
431
+ exactKeys(value, ["t", "a", "p", "g", "m"], [], field);
432
+ return {
433
+ t: boundedText(value.t, WORDCELL_SITE_LIMITS_V1.fieldTextBytes, `${field}.t`),
434
+ a: boundedText(value.a, WORDCELL_SITE_LIMITS_V1.fieldTextBytes, `${field}.a`),
435
+ p: boundedText(value.p, WORDCELL_SITE_LIMITS_V1.fieldTextBytes, `${field}.p`),
436
+ g: boundedText(value.g, WORDCELL_SITE_LIMITS_V1.fieldTextBytes, `${field}.g`),
437
+ m: boundedText(value.m, WORDCELL_SITE_LIMITS_V1.fieldTextBytes, `${field}.m`),
438
+ };
439
+ }
440
+
441
+ export function parseSiteDocsV1(value: unknown): WordcellSiteDocsV1 {
442
+ if (!isRecord(value)) throw new TypeError("docs must be an object");
443
+ exactKeys(value, ["format", "content", "docs"], [], "docs");
444
+ if (value.format !== WORDCELL_SITE_DOCS_FORMAT_V1) {
445
+ throw new TypeError(`docs format must be ${WORDCELL_SITE_DOCS_FORMAT_V1}`);
446
+ }
447
+ const content = contentSearch(value.content);
448
+ if (!Array.isArray(value.docs) || value.docs.length > WORDCELL_SITE_LIMITS_V1.notes) {
449
+ throw new TypeError("docs.docs must be a bounded array");
450
+ }
451
+ const docs = (value.docs as unknown[]).map((entry, index): WordcellSiteDocV1 => {
452
+ const field = `docs entry ${index}`;
453
+ if (!isRecord(entry)) throw new TypeError(`${field} must be an object`);
454
+ exactKeys(entry, ["i", "s", "t", "p", "f"], ["x"], field);
455
+ const inline = entry.x === undefined
456
+ ? {}
457
+ : { x: boundedText(entry.x, WORDCELL_SITE_LIMITS_V1.inlineTextBytes, `${field}.x`) };
458
+ return {
459
+ i: boundedCount(entry.i, WORDCELL_SITE_LIMITS_V1.notes, `${field}.i`),
460
+ s: boundedText(entry.s, 1_024, `${field}.s`),
461
+ t: boundedText(entry.t, WORDCELL_SITE_LIMITS_V1.titleBytes, `${field}.t`),
462
+ p: boundedText(entry.p, WORDCELL_SITE_LIMITS_V1.docPreviewBytes + 16, `${field}.p`),
463
+ f: docFields(entry.f, `${field}.f`),
464
+ ...inline,
465
+ };
466
+ });
467
+ return { format: WORDCELL_SITE_DOCS_FORMAT_V1, content, docs };
468
+ }
469
+
470
+ export function parseSiteTermsV1(value: unknown): WordcellSiteTermsV1 {
471
+ if (!isRecord(value)) throw new TypeError("terms must be an object");
472
+ exactKeys(value, ["format", "terms"], [], "terms");
473
+ if (value.format !== WORDCELL_SITE_TERMS_FORMAT_V1) {
474
+ throw new TypeError(`terms format must be ${WORDCELL_SITE_TERMS_FORMAT_V1}`);
475
+ }
476
+ return {
477
+ format: WORDCELL_SITE_TERMS_FORMAT_V1,
478
+ terms: stringArray(value.terms, WORDCELL_SITE_LIMITS_V1.indexTerms, "terms.terms"),
479
+ };
480
+ }
481
+
482
+ export function parseSitePostingsV1(value: unknown): WordcellSitePostingsV1 {
483
+ if (!isRecord(value)) throw new TypeError("postings must be an object");
484
+ exactKeys(value, ["format", "shard", "postings"], [], "postings");
485
+ if (value.format !== WORDCELL_SITE_POSTINGS_FORMAT_V1) {
486
+ throw new TypeError(`postings format must be ${WORDCELL_SITE_POSTINGS_FORMAT_V1}`);
487
+ }
488
+ const shard = value.shard;
489
+ if (typeof shard !== "string" || !/^[0-9a-f]{2}$/u.test(shard)) {
490
+ throw new TypeError("postings.shard must be a two-digit lowercase hex string");
491
+ }
492
+ if (!isRecord(value.postings)) throw new TypeError("postings.postings must be an object");
493
+ const entries = Object.entries(value.postings);
494
+ if (entries.length > WORDCELL_SITE_LIMITS_V1.shardPairs) {
495
+ throw new TypeError("postings.postings exceeds the shard pair limit");
496
+ }
497
+ const postings: Record<string, readonly number[]> = Object.create(null);
498
+ for (const [term, docs] of entries) {
499
+ if (!Array.isArray(docs) || docs.length > WORDCELL_SITE_LIMITS_V1.notes) {
500
+ throw new TypeError(`postings for ${JSON.stringify(term)} must be a bounded array`);
501
+ }
502
+ postings[term] = (docs as unknown[]).map((doc) =>
503
+ boundedCount(doc, WORDCELL_SITE_LIMITS_V1.notes, "posting document id"));
504
+ }
505
+ return { format: WORDCELL_SITE_POSTINGS_FORMAT_V1, shard, postings: Object.freeze(postings) };
506
+ }
507
+
508
+ function noteLinks(value: unknown, field: string): readonly WordcellSiteNoteLinkV1[] {
509
+ if (!Array.isArray(value) || value.length > WORDCELL_SITE_LIMITS_V1.notes) {
510
+ throw new TypeError(`${field} must be a bounded array`);
511
+ }
512
+ return (value as unknown[]).map((entry, index): WordcellSiteNoteLinkV1 => {
513
+ if (!isRecord(entry)) throw new TypeError(`${field} ${index} must be an object`);
514
+ exactKeys(entry, ["s", "t"], [], `${field} ${index}`);
515
+ return {
516
+ s: boundedText(entry.s, 1_024, `${field} ${index}.s`),
517
+ t: boundedText(entry.t, WORDCELL_SITE_LIMITS_V1.titleBytes, `${field} ${index}.t`),
518
+ };
519
+ });
520
+ }
521
+
522
+ export function parseSiteNoteV1(value: unknown): WordcellSiteNoteV1 {
523
+ if (!isRecord(value)) throw new TypeError("note payload must be an object");
524
+ exactKeys(
525
+ value,
526
+ ["format", "id", "slug", "title", "aliases", "tags", "summary", "text", "textTruncated", "links", "backlinks", "relations", "relationBacklinks"],
527
+ ["type"],
528
+ "note payload",
529
+ );
530
+ if (value.format !== WORDCELL_SITE_NOTE_FORMAT_V1) {
531
+ throw new TypeError(`note payload format must be ${WORDCELL_SITE_NOTE_FORMAT_V1}`);
532
+ }
533
+ if (value.textTruncated !== true && value.textTruncated !== false) {
534
+ throw new TypeError("note payload textTruncated must be boolean");
535
+ }
536
+ return {
537
+ format: WORDCELL_SITE_NOTE_FORMAT_V1,
538
+ id: boundedText(value.id, 1_024, "note.id"),
539
+ slug: boundedText(value.slug, 1_024, "note.slug"),
540
+ title: boundedText(value.title, WORDCELL_SITE_LIMITS_V1.titleBytes, "note.title"),
541
+ aliases: stringArray(value.aliases, 128, "note.aliases"),
542
+ ...(value.type === undefined
543
+ ? {}
544
+ : { type: boundedText(value.type, 256, "note.type") }),
545
+ tags: stringArray(value.tags, 128, "note.tags"),
546
+ summary: boundedText(value.summary, WORDCELL_SITE_LIMITS_V1.descriptionBytes, "note.summary"),
547
+ text: boundedText(value.text, WORDCELL_SITE_LIMITS_V1.noteTextBytes, "note.text"),
548
+ textTruncated: value.textTruncated,
549
+ links: noteLinks(value.links, "note.links"),
550
+ backlinks: noteLinks(value.backlinks, "note.backlinks"),
551
+ relations: noteRelations(value.relations, "note.relations"),
552
+ relationBacklinks: noteRelations(value.relationBacklinks, "note.relationBacklinks"),
553
+ };
554
+ }
555
+
556
+ function noteRelations(
557
+ value: unknown,
558
+ field: string,
559
+ ): readonly WordcellSiteNoteRelationV1[] {
560
+ if (!Array.isArray(value) || value.length > WORDCELL_SITE_LIMITS_V1.notes) {
561
+ throw new TypeError(`${field} must be a bounded array`);
562
+ }
563
+ return (value as unknown[]).map((entry, index): WordcellSiteNoteRelationV1 => {
564
+ if (!isRecord(entry)) throw new TypeError(`${field} ${index} must be an object`);
565
+ exactKeys(entry, ["p", "s", "t"], [], `${field} ${index}`);
566
+ return {
567
+ p: boundedText(entry.p, 256, `${field} ${index}.p`),
568
+ s: boundedText(entry.s, 1_024, `${field} ${index}.s`),
569
+ t: boundedText(entry.t, WORDCELL_SITE_LIMITS_V1.titleBytes, `${field} ${index}.t`),
570
+ };
571
+ });
572
+ }
573
+
574
+ export function parseSiteGraphV1(value: unknown): WordcellSiteGraphV1 {
575
+ if (!isRecord(value)) throw new TypeError("graph must be an object");
576
+ exactKeys(value, ["format", "edges"], [], "graph");
577
+ if (value.format !== WORDCELL_SITE_GRAPH_FORMAT_V1) {
578
+ throw new TypeError(`graph format must be ${WORDCELL_SITE_GRAPH_FORMAT_V1}`);
579
+ }
580
+ if (!Array.isArray(value.edges) || value.edges.length > 1_000_000) {
581
+ throw new TypeError("graph.edges must be a bounded array");
582
+ }
583
+ const edges = (value.edges as unknown[]).map((entry, index): WordcellSiteGraphEdgeV1 => {
584
+ if (!isRecord(entry)) throw new TypeError(`graph edge ${index} must be an object`);
585
+ exactKeys(entry, ["s", "t", "k"], ["p"], `graph edge ${index}`);
586
+ if (entry.k !== "link" && entry.k !== "relation") {
587
+ throw new TypeError(`graph edge ${index}.k must be link or relation`);
588
+ }
589
+ return {
590
+ s: boundedCount(entry.s, WORDCELL_SITE_LIMITS_V1.notes, `graph edge ${index}.s`),
591
+ t: boundedCount(entry.t, WORDCELL_SITE_LIMITS_V1.notes, `graph edge ${index}.t`),
592
+ k: entry.k,
593
+ ...(entry.p === undefined
594
+ ? {}
595
+ : { p: boundedText(entry.p, 256, `graph edge ${index}.p`) }),
596
+ };
597
+ });
598
+ return { format: WORDCELL_SITE_GRAPH_FORMAT_V1, edges };
599
+ }