@hraness/wordcell 0.21.3 → 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 (81) hide show
  1. package/README.md +170 -64
  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 +37 -31
  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-0rfcqab6.js → index-4ag64rc1.js} +555 -59
  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-9zcpdbjg.js → index-de2w8crk.js} +13 -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-van48n3g.js → index-fc4dr114.js} +4 -2
  29. package/dist/{index-jzcws0zn.js → index-g5vsqmdy.js} +27 -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-vm4mkw0k.js → support-mywv1em0.js} +38 -32
  48. package/dist/workflows/decision-context.js +13 -12
  49. package/dist/workflows/index.js +13 -12
  50. package/package.json +63 -5
  51. package/skills/wordcell/AGENTS.md +1 -0
  52. package/skills/wordcell/SKILL.md +2 -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 +347 -9
  57. package/src/index.ts +4 -0
  58. package/src/oh/authority.ts +14 -5
  59. package/src/oh/canonical-rust.ts +127 -0
  60. package/src/oh/projection-rust.ts +63 -0
  61. package/src/oh-adoption.ts +7 -1
  62. package/src/publish-graph.ts +0 -0
  63. package/src/publish-index.ts +195 -0
  64. package/src/publish-markdown.ts +617 -0
  65. package/src/publish-model.ts +599 -0
  66. package/src/publish-nav.ts +204 -0
  67. package/src/publish-pages.ts +382 -0
  68. package/src/publish-reader/reader.css +1042 -0
  69. package/src/publish-reader/reader.ts +1017 -0
  70. package/src/publish-reader/theme.ts +97 -0
  71. package/src/publish-search.ts +418 -0
  72. package/src/publish-select.ts +419 -0
  73. package/src/publish-theme.ts +106 -0
  74. package/src/publish.ts +904 -0
  75. package/src/rerank-credentials.ts +56 -0
  76. package/src/rerank-typesafe.ts +472 -0
  77. package/src/rerank.ts +389 -0
  78. package/src/sdk.ts +157 -6
  79. package/src/serve.ts +180 -0
  80. package/src/vault.ts +23 -9
  81. package/dist/{index-why54q5p.js → index-gm9t95d9.js} +3 -3
@@ -0,0 +1,419 @@
1
+ import {
2
+ posix,
3
+ } from "node:path";
4
+
5
+ import {
6
+ parseLocalAttachmentReferences,
7
+ type LocalAttachmentReference,
8
+ } from "./attachments.js";
9
+ import type {
10
+ AuthoredRelation,
11
+ Backlink,
12
+ Note,
13
+ VaultAnalysis,
14
+ } from "./graph.js";
15
+ import { lookupNote } from "./graph.js";
16
+ import {
17
+ navigateLinks,
18
+ type LinkDirection,
19
+ } from "./navigation.js";
20
+ import { queryVault } from "./query.js";
21
+ import type {
22
+ MetadataFilter,
23
+ } from "./query.js";
24
+ import type {
25
+ WordcellSiteSelectionV1,
26
+ } from "./publish-model.js";
27
+
28
+ export const MAX_PUBLISH_SELECTORS = 256;
29
+ export const MAX_PUBLISH_FROM_DEPTH = 10;
30
+ export const MAX_PUBLISH_FROM_NOTES = 1_000;
31
+ export const MAX_PUBLISH_SELECTOR_BYTES = 1_024;
32
+ /** Aggregate dynamic-programming cells across all include/exclude glob matches. */
33
+ export const MAX_PUBLISH_GLOB_WORK = 100_000_000;
34
+ export const MAX_PUBLISH_SLUG_BYTES = 1_024;
35
+
36
+ /**
37
+ * Include prefixes/globs, the combined metadata query, and a `--from`
38
+ * neighborhood form a union. Excludes and the authored
39
+ * `publish: false` flag then carve notes back out. Selection never reads
40
+ * note bodies; a selected note's prose can still mention excluded ids, so the
41
+ * boundary is graph and metadata presence, not text redaction.
42
+ */
43
+ export type PublishSelectionInput = {
44
+ readonly includes?: readonly string[];
45
+ readonly excludes?: readonly string[];
46
+ /** Case-sensitive globs over note ids or Markdown paths; * and ? stay in a segment. */
47
+ readonly includeGlobs?: readonly string[];
48
+ readonly excludeGlobs?: readonly string[];
49
+ readonly filters?: readonly MetadataFilter[];
50
+ readonly tags?: readonly string[];
51
+ readonly repositoryScopes?: readonly string[];
52
+ readonly from?: Readonly<{
53
+ note: string;
54
+ depth: number;
55
+ direction: LinkDirection;
56
+ }>;
57
+ };
58
+
59
+ export type PublishSelection = {
60
+ /** Selected notes sorted by canonical id. */
61
+ readonly notes: readonly Note[];
62
+ /** Note id to site slug; the vault index note maps to the empty root slug. */
63
+ readonly slugById: ReadonlyMap<string, string>;
64
+ /** Selected-id lookup for closure checks. */
65
+ readonly selected: ReadonlySet<string>;
66
+ /** Resolved contextual links with both endpoints inside the selection. */
67
+ readonly links: readonly { readonly source: string; readonly target: string }[];
68
+ /** Selected-note id to in-selection backlinks. */
69
+ readonly backlinksById: ReadonlyMap<string, readonly Backlink[]>;
70
+ /** Selected-note id to in-selection outbound authored relations. */
71
+ readonly relationsById: ReadonlyMap<string, readonly AuthoredRelation[]>;
72
+ /** Selected-note id to in-selection relation backlinks. */
73
+ readonly relationBacklinksById: ReadonlyMap<string, readonly AuthoredRelation[]>;
74
+ /** Selected-note id to declared local attachment references. */
75
+ readonly attachmentsById: ReadonlyMap<string, readonly LocalAttachmentReference[]>;
76
+ readonly excludedPrivate: number;
77
+ readonly excludedBySelection: number;
78
+ /** Resolved links and relations whose target stayed outside the selection. */
79
+ readonly droppedExternalLinks: number;
80
+ readonly droppedExternalRelations: number;
81
+ /** Manifest counts of the selectors; their raw values stay local. */
82
+ readonly descriptor: WordcellSiteSelectionV1;
83
+ };
84
+
85
+ function normalizeSelectorPrefix(value: string, flag: string): string {
86
+ const trimmed = value.trim().replaceAll("\\", "/");
87
+ if (trimmed === "" || Buffer.byteLength(trimmed, "utf8") > MAX_PUBLISH_SELECTOR_BYTES || /[\u0000-\u001f\u007f]/u.test(trimmed)) {
88
+ throw new TypeError(`${flag} must be bounded text without control characters`);
89
+ }
90
+ const normalized = posix.normalize(trimmed).replace(/^\.\//u, "").replace(/\/+$/u, "");
91
+ if (
92
+ normalized === ""
93
+ || trimmed.split("/").includes("..")
94
+ || /^[a-z]:\//iu.test(normalized)
95
+ || normalized.startsWith("/")
96
+ || normalized === ".."
97
+ || normalized.startsWith("../")
98
+ || normalized.split("/").includes("..")
99
+ ) {
100
+ throw new TypeError(`${flag} must be a vault-relative path prefix or note id`);
101
+ }
102
+ return normalized;
103
+ }
104
+
105
+ function selectorIncludes(prefixes: readonly string[], id: string): boolean {
106
+ return prefixes.some((prefix) =>
107
+ prefix === "."
108
+ || id === prefix
109
+ || id.startsWith(`${prefix}/`)
110
+ || (prefix.endsWith(".md") && id === prefix.slice(0, -3)));
111
+ }
112
+
113
+ /** Small glob grammar with bounded work and no filesystem traversal. */
114
+ function publishGlob(value: string, flag: string): readonly string[] {
115
+ const pattern = value.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
116
+ const segments = pattern.split("/");
117
+ if (
118
+ pattern === "" || /^[a-z]:\//iu.test(pattern)
119
+ || Buffer.byteLength(pattern, "utf8") > MAX_PUBLISH_SELECTOR_BYTES
120
+ || /[\u0000-\u001f\u007f\[\]{}]/u.test(pattern)
121
+ || segments.some((segment) => segment === "" || segment === "." || segment === ".."
122
+ || (segment.includes("**") && segment !== "**"))
123
+ ) {
124
+ throw new TypeError(`${flag} must be a vault-relative glob using *, ?, or whole-segment **`);
125
+ }
126
+ return segments;
127
+ }
128
+
129
+ type PublishGlobBudget = { remaining: number };
130
+
131
+ function spendGlobWork(budget: PublishGlobBudget, cells: number): void {
132
+ if (cells > budget.remaining) {
133
+ throw new RangeError(
134
+ `Publish glob matching exceeds the ${MAX_PUBLISH_GLOB_WORK}-cell work budget; reduce the number or complexity of globs, or use --include/--exclude path prefixes.`,
135
+ );
136
+ }
137
+ budget.remaining -= cells;
138
+ }
139
+
140
+ function wildcardSegment(pattern: string, value: string, budget: PublishGlobBudget): boolean {
141
+ const tokens = Array.from(pattern);
142
+ const characters = Array.from(value);
143
+ spendGlobWork(budget, tokens.length * (characters.length + 1));
144
+ let previous = new Uint8Array(characters.length + 1);
145
+ previous[0] = 1;
146
+ for (const token of tokens) {
147
+ const current = new Uint8Array(characters.length + 1);
148
+ if (token === "*") current[0] = previous[0] ?? 0;
149
+ for (let index = 1; index <= characters.length; index += 1) {
150
+ current[index] = token === "*"
151
+ ? (previous[index] || current[index - 1] ? 1 : 0)
152
+ : (previous[index - 1] && (token === "?" || token === characters[index - 1]) ? 1 : 0);
153
+ }
154
+ previous = current;
155
+ }
156
+ return previous[characters.length] === 1;
157
+ }
158
+
159
+ function globMatches(pattern: readonly string[], path: string, budget: PublishGlobBudget): boolean {
160
+ const segments = path.split("/");
161
+ spendGlobWork(budget, pattern.length * (segments.length + 1));
162
+ let previous = new Uint8Array(segments.length + 1);
163
+ previous[0] = 1;
164
+ for (const token of pattern) {
165
+ const current = new Uint8Array(segments.length + 1);
166
+ if (token === "**") current[0] = previous[0] ?? 0;
167
+ for (let index = 1; index <= segments.length; index += 1) {
168
+ current[index] = token === "**"
169
+ ? (previous[index] || current[index - 1] ? 1 : 0)
170
+ : (previous[index - 1] && wildcardSegment(token, segments[index - 1] ?? "", budget) ? 1 : 0);
171
+ }
172
+ previous = current;
173
+ }
174
+ return previous[segments.length] === 1;
175
+ }
176
+
177
+ function selectorGlobs(patterns: readonly (readonly string[])[], note: Note, budget: PublishGlobBudget): boolean {
178
+ return patterns.some((pattern) => globMatches(pattern, note.id, budget) || globMatches(pattern, note.path, budget));
179
+ }
180
+
181
+ function isPrivate(note: Note): boolean {
182
+ return note.metadata["publish"] === false;
183
+ }
184
+
185
+ const SLUG_UNSAFE = /[^\p{L}\p{N}._~-]+/gu;
186
+ const SLUG_EDGE = /^[-._~]+|[-._~]+$/gu;
187
+
188
+ export function publishSlugSegment(segment: string): string {
189
+ const cleaned = segment
190
+ .normalize("NFC")
191
+ .toLocaleLowerCase("en-US")
192
+ .replace(SLUG_UNSAFE, "-")
193
+ .replace(SLUG_EDGE, "")
194
+ .replace(/-{2,}/gu, "-");
195
+ return cleaned === "" ? "x" : cleaned;
196
+ }
197
+
198
+ /**
199
+ * Deterministically assign collision-free slugs to selected note ids. The
200
+ * vault index note publishes at the site root with the empty slug; every other
201
+ * note nests under the note prefix by its slugified id path.
202
+ */
203
+ export function derivePublishSlugs(ids: readonly string[]): ReadonlyMap<string, string> {
204
+ const sorted = [...ids].toSorted((left, right) => left.localeCompare(right));
205
+ const slugById = new Map<string, string>();
206
+ const used = new Set<string>();
207
+ for (const id of sorted) {
208
+ const segments = id.split("/").map(publishSlugSegment);
209
+ // The index note owns the root slug outright — a second index can't exist
210
+ // (ids are unique), and no other id can slugify to "" (segments bottom out
211
+ // at "x").
212
+ let slug = id === "index" ? "" : segments.join("/");
213
+ if (slug !== "" && used.has(slug)) {
214
+ let suffix = 2;
215
+ while (used.has(`${slug}-${suffix}`)) suffix += 1;
216
+ slug = `${slug}-${suffix}`;
217
+ }
218
+ if (Buffer.byteLength(slug, "utf8") > MAX_PUBLISH_SLUG_BYTES) {
219
+ throw new RangeError(`Published slug for ${JSON.stringify(id)} exceeds the byte limit.`);
220
+ }
221
+ used.add(slug);
222
+ slugById.set(id, slug);
223
+ }
224
+ return slugById;
225
+ }
226
+
227
+ /**
228
+ * Decode an authored attachment target like the vault validator: strip query,
229
+ * heading, block and page fragments, percent-decode, unescape, NFC.
230
+ */
231
+ export function publishAssetTarget(rawTarget: string): string | undefined {
232
+ const withoutDecoration = rawTarget.trim().split(/[?#^]/u, 1)[0] ?? "";
233
+ if (withoutDecoration === "" || withoutDecoration.startsWith("#")) return undefined;
234
+ let decoded: string;
235
+ try {
236
+ decoded = decodeURIComponent(withoutDecoration)
237
+ .replace(/\\([\\()[\] ])/gu, "$1")
238
+ .normalize("NFC");
239
+ } catch {
240
+ return undefined;
241
+ }
242
+ if (decoded === "" || /^(?:[a-z][a-z\d+.-]*:|\/\/)/iu.test(decoded)) return undefined;
243
+ return decoded;
244
+ }
245
+
246
+ /**
247
+ * Resolve a decoded attachment target against its source note's directory.
248
+ * Returns the normalized vault-relative path or undefined when the target
249
+ * escapes the vault. This is the canonical lexical resolver: `publishVault`
250
+ * answers it through real filesystem validation, and custom `PublishIo`
251
+ * implementations should use it (or stricter) for traversal confinement.
252
+ */
253
+ export function publishAssetVaultPath(source: string, target: string): string | undefined {
254
+ const base = posix.dirname(source);
255
+ const resolved = posix.normalize(posix.join(base, target));
256
+ if (
257
+ resolved === ".."
258
+ || resolved.startsWith("../")
259
+ || resolved.startsWith("/")
260
+ || resolved.split("/").includes("..")
261
+ ) {
262
+ return undefined;
263
+ }
264
+ return resolved;
265
+ }
266
+
267
+ export function selectPublishNotes(
268
+ notes: readonly Note[],
269
+ analysis: VaultAnalysis,
270
+ input: PublishSelectionInput = {},
271
+ ): PublishSelection {
272
+ const selectorCount = (input.includes?.length ?? 0) + (input.excludes?.length ?? 0)
273
+ + (input.includeGlobs?.length ?? 0) + (input.excludeGlobs?.length ?? 0);
274
+ if (selectorCount > MAX_PUBLISH_SELECTORS) {
275
+ throw new RangeError(`Publish selectors may contain at most ${MAX_PUBLISH_SELECTORS} entries.`);
276
+ }
277
+ const includes = (input.includes ?? []).map((value) =>
278
+ normalizeSelectorPrefix(value, "--include"));
279
+ const excludes = (input.excludes ?? []).map((value) =>
280
+ normalizeSelectorPrefix(value, "--exclude"));
281
+ const includeGlobs = (input.includeGlobs ?? []).map((value) => publishGlob(value, "--include-glob"));
282
+ const excludeGlobs = (input.excludeGlobs ?? []).map((value) => publishGlob(value, "--exclude-glob"));
283
+ const filters = input.filters ?? [];
284
+ const tags = input.tags ?? [];
285
+ const repositoryScopes = input.repositoryScopes ?? [];
286
+
287
+ const globBudget: PublishGlobBudget = { remaining: MAX_PUBLISH_GLOB_WORK };
288
+ const candidates = new Set<string>();
289
+ const hasPositive = includes.length > 0
290
+ || includeGlobs.length > 0
291
+ || filters.length > 0
292
+ || tags.length > 0
293
+ || repositoryScopes.length > 0
294
+ || input.from !== undefined;
295
+
296
+ for (const note of notes) {
297
+ if (selectorIncludes(includes, note.id) || selectorGlobs(includeGlobs, note, globBudget)) candidates.add(note.id);
298
+ }
299
+ if (filters.length > 0 || tags.length > 0 || repositoryScopes.length > 0) {
300
+ for (const row of queryVault(notes, analysis, { filters, tags, repositoryScopes })) {
301
+ candidates.add(row.id);
302
+ }
303
+ }
304
+ if (input.from !== undefined) {
305
+ const lookup = lookupNote(notes, input.from.note);
306
+ if (lookup.kind === "missing") {
307
+ throw new Error(`Publish seed ${JSON.stringify(input.from.note)} was not found.`);
308
+ }
309
+ if (lookup.kind === "ambiguous") {
310
+ throw new Error(
311
+ `Publish seed ${JSON.stringify(input.from.note)} is ambiguous: `
312
+ + lookup.candidates.map(({ path }) => path).join(", "),
313
+ );
314
+ }
315
+ const neighborhood = navigateLinks(notes, analysis, lookup.note, {
316
+ direction: input.from.direction,
317
+ depth: input.from.depth,
318
+ limit: MAX_PUBLISH_FROM_NOTES,
319
+ });
320
+ if (neighborhood.truncated) {
321
+ throw new RangeError(`Publish neighborhood exceeds the ${MAX_PUBLISH_FROM_NOTES}-note or connection limit; narrow --depth or select explicit paths instead.`);
322
+ }
323
+ for (const node of neighborhood.nodes) candidates.add(node.id);
324
+ }
325
+
326
+ let excludedPrivate = 0;
327
+ let excludedBySelection = 0;
328
+ const selected: Note[] = [];
329
+ for (const note of notes) {
330
+ const chosen = hasPositive ? candidates.has(note.id) : true;
331
+ const excluded = selectorIncludes(excludes, note.id) || selectorGlobs(excludeGlobs, note, globBudget);
332
+ if (!chosen || excluded) {
333
+ excludedBySelection += 1;
334
+ continue;
335
+ }
336
+ if (isPrivate(note)) {
337
+ excludedPrivate += 1;
338
+ continue;
339
+ }
340
+ selected.push(note);
341
+ }
342
+ selected.sort((left, right) => left.id.localeCompare(right.id));
343
+
344
+ const selectedIds = new Set(selected.map(({ id }) => id));
345
+ const slugById = derivePublishSlugs(selected.map(({ id }) => id));
346
+
347
+ // contextualLinks carry vault paths (`a/b.md`); authored relations carry
348
+ // canonical ids (`a/b`). Normalize links to ids so every downstream map is
349
+ // keyed identically.
350
+ const idByPath = new Map(notes.map((note) => [note.path, note.id]));
351
+ let droppedExternalLinks = 0;
352
+ const links: { source: string; target: string }[] = [];
353
+ const backlinksById = new Map<string, Backlink[]>();
354
+ for (const link of analysis.contextualLinks) {
355
+ const sourceId = idByPath.get(link.source);
356
+ const targetId = idByPath.get(link.target);
357
+ if (sourceId === undefined || targetId === undefined) continue;
358
+ const sourceInside = selectedIds.has(sourceId);
359
+ const targetInside = selectedIds.has(targetId);
360
+ if (sourceInside && !targetInside) droppedExternalLinks += 1;
361
+ if (!sourceInside || !targetInside) continue;
362
+ links.push({ source: sourceId, target: targetId });
363
+ const list = backlinksById.get(targetId) ?? [];
364
+ list.push({ source: sourceId, target: targetId, line: link.line });
365
+ backlinksById.set(targetId, list);
366
+ }
367
+
368
+ let droppedExternalRelations = 0;
369
+ const relationsById = new Map<string, AuthoredRelation[]>();
370
+ const relationBacklinksById = new Map<string, AuthoredRelation[]>();
371
+ for (const relation of analysis.authoredRelations) {
372
+ const sourceInside = selectedIds.has(relation.source);
373
+ const targetInside = selectedIds.has(relation.target);
374
+ if (sourceInside && !targetInside) droppedExternalRelations += 1;
375
+ if (!sourceInside || !targetInside) continue;
376
+ const outbound = relationsById.get(relation.source) ?? [];
377
+ outbound.push(relation);
378
+ relationsById.set(relation.source, outbound);
379
+ const inbound = relationBacklinksById.get(relation.target) ?? [];
380
+ inbound.push(relation);
381
+ relationBacklinksById.set(relation.target, inbound);
382
+ }
383
+
384
+ const attachmentsById = new Map<string, readonly LocalAttachmentReference[]>();
385
+ for (const note of selected) {
386
+ const parsed = parseLocalAttachmentReferences(note.path, note.content);
387
+ if (parsed.references.length > 0) attachmentsById.set(note.id, parsed.references);
388
+ }
389
+
390
+ const descriptor: WordcellSiteSelectionV1 = {
391
+ // Raw selectors can identify private paths and metadata; publish counts only.
392
+ includes: [],
393
+ excludes: [],
394
+ includeCount: includes.length,
395
+ excludeCount: excludes.length,
396
+ includeGlobCount: includeGlobs.length,
397
+ excludeGlobCount: excludeGlobs.length,
398
+ fromCount: input.from === undefined ? 0 : 1,
399
+ filterCount: filters.length,
400
+ tagCount: tags.length,
401
+ scopeCount: repositoryScopes.length,
402
+ };
403
+
404
+ return {
405
+ notes: selected,
406
+ slugById,
407
+ selected: selectedIds,
408
+ links,
409
+ backlinksById,
410
+ relationsById,
411
+ relationBacklinksById,
412
+ attachmentsById,
413
+ excludedPrivate,
414
+ excludedBySelection,
415
+ droppedExternalLinks,
416
+ droppedExternalRelations,
417
+ descriptor,
418
+ };
419
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Appearance contract for the published reader. Palette identifiers and the
3
+ * storage key mirror the shared Hraness palette preference
4
+ * (`hraness-design-palette-v1`, `{ palette, mode }`) so a reader hosted on the
5
+ * same origin as a design-kit application honors the visitor's existing
6
+ * choice, and values the reader writes degrade gracefully there. `wordcell`
7
+ * is the reader's own neutral palette and is not a design-kit identifier;
8
+ * other consumers treat it as an unknown value and fall back.
9
+ *
10
+ * The module is pure — the synchronous `theme.js` bootstrap and the deferred
11
+ * reader bundle both consume it, and tests exercise it without a DOM.
12
+ */
13
+
14
+ export const WORDCELL_APPEARANCE_STORAGE_KEY = "hraness-design-palette-v1";
15
+
16
+ export const wordcellPalettes = [
17
+ "wordcell",
18
+ "catppuccin",
19
+ "gruvbox",
20
+ "rose-pine",
21
+ "tokyo-night",
22
+ ] as const;
23
+ export type WordcellPalette = (typeof wordcellPalettes)[number];
24
+
25
+ export const wordcellPaletteLabels: Readonly<Record<WordcellPalette, string>> = {
26
+ wordcell: "Wordcell",
27
+ catppuccin: "Catppuccin",
28
+ gruvbox: "Gruvbox",
29
+ "rose-pine": "Rosé Pine",
30
+ "tokyo-night": "Tokyo Night",
31
+ };
32
+
33
+ export const wordcellAppearanceModes = ["system", "light", "dark"] as const;
34
+ export type WordcellAppearanceMode = (typeof wordcellAppearanceModes)[number];
35
+
36
+ export const wordcellAppearanceModeLabels: Readonly<Record<WordcellAppearanceMode, string>> = {
37
+ system: "System",
38
+ light: "Light",
39
+ dark: "Dark",
40
+ };
41
+
42
+ export type WordcellAppearancePreference = Readonly<{
43
+ palette: WordcellPalette;
44
+ mode: WordcellAppearanceMode;
45
+ }>;
46
+
47
+ export const DEFAULT_WORDCELL_APPEARANCE: WordcellAppearancePreference = {
48
+ palette: "wordcell",
49
+ mode: "system",
50
+ };
51
+
52
+ export function isWordcellPalette(value: unknown): value is WordcellPalette {
53
+ return typeof value === "string"
54
+ && (wordcellPalettes as readonly string[]).includes(value);
55
+ }
56
+
57
+ export function isWordcellAppearanceMode(value: unknown): value is WordcellAppearanceMode {
58
+ return typeof value === "string"
59
+ && (wordcellAppearanceModes as readonly string[]).includes(value);
60
+ }
61
+
62
+ /** Parses a foreign preference value; unknown shapes resolve to `undefined`. */
63
+ export function parseAppearancePreference(
64
+ value: unknown,
65
+ ): WordcellAppearancePreference | undefined {
66
+ if (typeof value !== "object" || value === null) return undefined;
67
+ const palette = (value as { palette?: unknown }).palette;
68
+ const mode = (value as { mode?: unknown }).mode;
69
+ if (!isWordcellPalette(palette) || !isWordcellAppearanceMode(mode)) return undefined;
70
+ return { palette, mode };
71
+ }
72
+
73
+ export function serializeAppearancePreference(
74
+ preference: WordcellAppearancePreference,
75
+ ): string {
76
+ return JSON.stringify({ palette: preference.palette, mode: preference.mode });
77
+ }
78
+
79
+ export function resolveAppearanceMode(
80
+ mode: WordcellAppearanceMode,
81
+ systemPrefersDark: boolean,
82
+ ): "light" | "dark" {
83
+ if (mode === "system") return systemPrefersDark ? "dark" : "light";
84
+ return mode;
85
+ }
86
+
87
+ /**
88
+ * Bridge installed by the synchronous `theme.js` bootstrap on `window` for
89
+ * the deferred reader bundle. `set` persists and applies a preference,
90
+ * `resolvedTheme` reports the concrete theme after `system` resolution, and
91
+ * `subscribe` notifies on every applied change (menu selections, cross-tab
92
+ * storage events, and operating-system appearance changes under `system`).
93
+ */
94
+ export type WordcellAppearanceBridge = {
95
+ get(): WordcellAppearancePreference;
96
+ resolvedTheme(): "light" | "dark";
97
+ set(preference: WordcellAppearancePreference): void;
98
+ subscribe(
99
+ listener: (preference: WordcellAppearancePreference, resolved: "light" | "dark") => void,
100
+ ): () => void;
101
+ };
102
+
103
+ /** The bridge travels on `globalThis` so neither bundle needs DOM lib types. */
104
+ export function wordcellAppearanceBridge(): WordcellAppearanceBridge | undefined {
105
+ return (globalThis as { wordcellAppearance?: WordcellAppearanceBridge }).wordcellAppearance;
106
+ }