@hraness/wordcell 0.22.3 → 0.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,14 +42,14 @@ Git evidence, and selective publishing. [Compare the tradeoffs](https://github.c
42
42
  The CLI and TypeScript SDK run with Bun. Install the versioned GitHub archive:
43
43
 
44
44
  ```sh
45
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz
45
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.4/hraness-wordcell-0.22.4.tgz
46
46
  wordcell --help
47
47
  ```
48
48
 
49
49
  Prefer npm? The same release is [mirrored there](https://www.npmjs.com/package/@hraness/wordcell):
50
50
 
51
51
  ```sh
52
- npm install --global --ignore-scripts @hraness/wordcell@0.22.3
52
+ npm install --global --ignore-scripts @hraness/wordcell@0.22.4
53
53
  wordcell --help
54
54
  ```
55
55
 
@@ -91,7 +91,7 @@ After trying the CLI, install the public Agent Skill into a compatible agent,
91
91
  such as Claude Code, Codex, Cursor, or GitHub Copilot:
92
92
 
93
93
  ```sh
94
- bunx skills add hraness/wordcell#v0.22.3 --skill wordcell
94
+ bunx skills add hraness/wordcell#v0.22.4 --skill wordcell
95
95
  ```
96
96
 
97
97
  Then ask:
@@ -283,7 +283,7 @@ exist. [Graph queries and proof limits](https://github.com/hraness/wordcell/blob
283
283
  Add the same immutable release to a Bun project:
284
284
 
285
285
  ```sh
286
- bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz
286
+ bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.4/hraness-wordcell-0.22.4.tgz
287
287
  ```
288
288
 
289
289
  The SDK provides read-only vault sessions, metadata queries, search, graph
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  parseArguments,
6
6
  runExecutable,
7
7
  usage
8
- } from "./index-yx6p1vzc.js";
8
+ } from "./index-9h7zh0wt.js";
9
9
  import"./index-bcknqxrq.js";
10
10
  import"./index-054mb7d3.js";
11
11
  import"./index-bqtqeak3.js";
@@ -41,14 +41,14 @@ import"./index-tf4wzjew.js";
41
41
  import"./index-1gwbassd.js";
42
42
  import"./index-vnybgywh.js";
43
43
  import"./index-1xxnjn0d.js";
44
- import"./index-a3j61wz9.js";
44
+ import"./index-pnt9wr59.js";
45
45
  import"./index-233z9wmn.js";
46
46
  import"./index-d13v9ckt.js";
47
47
  import"./index-48pz4jpc.js";
48
48
  import"./index-06c9ctr6.js";
49
49
  import"./index-4knsp9qj.js";
50
50
  import"./index-66pshdtx.js";
51
- import"./index-3agn8scn.js";
51
+ import"./index-23m6bbjt.js";
52
52
  import"./index-hya40gb2.js";
53
53
  import"./index-5vwpzb5a.js";
54
54
  import"./index-x3fthpsc.js";
@@ -61,7 +61,7 @@ import {
61
61
 
62
62
  // src/cli.ts
63
63
  if (import.meta.main) {
64
- const { standaloneSupportEnvironment, isUsefulSupportResult, runProductSupportCommand, showProductSupportInvitation } = await import("./support-vm8fw4yc.js");
64
+ const { standaloneSupportEnvironment, isUsefulSupportResult, runProductSupportCommand, showProductSupportInvitation } = await import("./support-m3t917ev.js");
65
65
  const env = standaloneSupportEnvironment();
66
66
  const args = process.argv.slice(2);
67
67
  if (args[0] === "support") {
@@ -285,22 +285,58 @@ function comparePublishScores(left, right) {
285
285
  return Number(right.identity) - Number(left.identity) || right.score - left.score || left.i - right.i;
286
286
  }
287
287
  function publishSnippet(text, query, fallback, windowBytes = 160) {
288
- const normalized = publishNormalize(text);
289
- let offset = query.normalized === "" ? -1 : normalized.indexOf(query.normalized);
290
- if (offset < 0) {
288
+ if (!Number.isSafeInteger(windowBytes) || windowBytes < 0)
289
+ throw new RangeError("Snippet window must be a nonnegative safe byte count.");
290
+ const display = text.normalize("NFC");
291
+ const normalized = publishNormalize(display);
292
+ let match = query.normalized === "" ? -1 : normalized.indexOf(query.normalized);
293
+ if (match < 0) {
291
294
  for (const term of query.terms) {
292
- offset = normalized.indexOf(term);
293
- if (offset >= 0)
295
+ match = normalized.indexOf(term);
296
+ if (match >= 0)
294
297
  break;
295
298
  }
296
299
  }
297
- if (offset < 0)
300
+ if (match < 0)
298
301
  return fallback;
299
- const half = Math.floor(windowBytes / 2);
300
- const start = Math.max(0, offset - half);
301
- const end = Math.min(text.length, offset + half);
302
- const snippet = text.slice(start, end).replace(/\s+/gu, " ").trim();
303
- return `${start > 0 ? "\u2026" : ""}${snippet}${end < text.length ? "\u2026" : ""}`;
302
+ let offset = 0, normalizedOffset = 0;
303
+ for (const character of display) {
304
+ const width = character.toLocaleLowerCase("en-US").length;
305
+ if (normalizedOffset + width > match)
306
+ break;
307
+ normalizedOffset += width;
308
+ offset += character.length;
309
+ }
310
+ const edge = windowBytes >= 6 ? "\u2026" : "";
311
+ const budget = windowBytes - 2 * publishUtf8Bytes(edge);
312
+ const previous = (end2) => {
313
+ const last = display.charCodeAt(end2 - 1);
314
+ return end2 > 1 && last >= 56320 && last <= 57343 && display.charCodeAt(end2 - 2) >= 55296 && display.charCodeAt(end2 - 2) <= 56319 ? end2 - 2 : end2 - 1;
315
+ };
316
+ let start = offset, end = offset, used = 0;
317
+ while (start > 0) {
318
+ const before = previous(start), bytes = publishUtf8Bytes(display.slice(before, start));
319
+ if (used + bytes > Math.floor(budget / 2))
320
+ break;
321
+ start = before;
322
+ used += bytes;
323
+ }
324
+ while (end < display.length) {
325
+ const character = String.fromCodePoint(display.codePointAt(end) ?? 0), bytes = publishUtf8Bytes(character);
326
+ if (used + bytes > budget)
327
+ break;
328
+ end += character.length;
329
+ used += bytes;
330
+ }
331
+ while (start > 0) {
332
+ const before = previous(start), bytes = publishUtf8Bytes(display.slice(before, start));
333
+ if (used + bytes > budget)
334
+ break;
335
+ start = before;
336
+ used += bytes;
337
+ }
338
+ const snippet = display.slice(start, end).replace(/\s+/gu, " ").trim();
339
+ return `${start > 0 ? edge : ""}${snippet}${end < display.length ? edge : ""}`;
304
340
  }
305
341
 
306
342
  export { PUBLISH_FIELD_TITLE, PUBLISH_FIELD_ALIAS, PUBLISH_FIELD_PATH, PUBLISH_FIELD_TAG, PUBLISH_FIELD_METADATA, PUBLISH_FIELD_CONTENT, PUBLISH_SEARCH_WEIGHTS_V1, MAX_PUBLISH_QUERY_BYTES, MAX_PUBLISH_QUERY_TERMS, MAX_PUBLISH_PREFIX_EXPANSIONS, publishUtf8Bytes, publishNormalize, MAX_PUBLISH_QUERY_FILTERS, publishQueryParts, publishDocMatchesFilters, MAX_PUBLISH_MARK_RANGES, publishMarkRanges, publishQuery, publishShardName, publishPrefixTerms, scorePublishDocument, comparePublishScores, publishSnippet };
@@ -100,7 +100,7 @@ import {
100
100
  MAX_PUBLISH_LIST_LIMIT,
101
101
  publishVault,
102
102
  renderPublishReportText
103
- } from "./index-a3j61wz9.js";
103
+ } from "./index-pnt9wr59.js";
104
104
  import {
105
105
  refreshVault,
106
106
  scanVault
@@ -31,7 +31,7 @@ import {
31
31
  import {
32
32
  publishNormalize,
33
33
  publishShardName
34
- } from "./index-3agn8scn.js";
34
+ } from "./index-23m6bbjt.js";
35
35
  import {
36
36
  parseLocalAttachmentReferences,
37
37
  validateMarkdownAttachments
@@ -49,133 +49,6 @@ import { canonicalJson as canonicalJson2, canonicalSha256 } from "@hraness/oh";
49
49
 
50
50
  // src/publish-index.ts
51
51
  import { canonicalJson } from "@hraness/oh";
52
- var TERM_PATTERN = /[\p{L}\p{N}][\p{L}\p{N}._/-]*/gu;
53
- function bounded(value, maximumBytes) {
54
- if (Buffer.byteLength(value, "utf8") <= maximumBytes)
55
- return { text: value, truncated: false };
56
- const bytes = Buffer.from(value, "utf8");
57
- let end = maximumBytes;
58
- while (end > 0 && ((bytes[end] ?? 0) & 192) === 128)
59
- end -= 1;
60
- return { text: bytes.subarray(0, end).toString("utf8"), truncated: true };
61
- }
62
- function previewText(note) {
63
- const basis = note.summary !== "" ? note.summary : note.searchableText;
64
- const collapsed = basis.replace(/\s+/gu, " ").trim();
65
- return bounded(collapsed, WORDCELL_SITE_LIMITS_V1.docPreviewBytes).text;
66
- }
67
- function indexableTerms(text) {
68
- const terms = new Set;
69
- for (const match of text.matchAll(TERM_PATTERN))
70
- terms.add(match[0]);
71
- return [...terms];
72
- }
73
- function buildSiteIndex(notes, slugById, options = {}) {
74
- const indexContent = options.indexContent !== false;
75
- const fields = [];
76
- const inlineTexts = [];
77
- let textTruncated = false;
78
- let inlineTotal = 0;
79
- for (const note of notes) {
80
- const title = bounded(publishNormalize(note.title), WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
81
- const aliases = bounded(note.aliases.map(publishNormalize).join(`
82
- `), WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
83
- const path = bounded(`${publishNormalize(note.path)}
84
- ${publishNormalize(note.id)}`, WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
85
- const tags = bounded(note.tags.map(publishNormalize).join(`
86
- `), WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
87
- const metadata = bounded(publishNormalize(canonicalJson(note.metadata)), WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
88
- textTruncated ||= title.truncated || aliases.truncated || path.truncated || tags.truncated || metadata.truncated;
89
- fields.push({ t: title.text, a: aliases.text, p: path.text, g: tags.text, m: metadata.text });
90
- if (indexContent) {
91
- const inline = bounded(publishNormalize(note.searchableText), WORDCELL_SITE_LIMITS_V1.inlineTextBytes);
92
- textTruncated ||= inline.truncated;
93
- inlineTexts.push(inline.text);
94
- inlineTotal += Buffer.byteLength(inline.text, "utf8");
95
- }
96
- }
97
- const mode = !indexContent ? "none" : inlineTotal <= WORDCELL_SITE_LIMITS_V1.inlineTotalBytes ? "inline" : "shards";
98
- const allTerms = new Set;
99
- const contentPostings = new Map;
100
- const docs = [];
101
- for (const [index, note] of notes.entries()) {
102
- const slug = slugById.get(note.id);
103
- if (slug === undefined)
104
- throw new Error(`Missing published slug for ${note.id}.`);
105
- const field = fields[index];
106
- if (field === undefined)
107
- throw new Error(`Missing indexed fields for ${note.id}.`);
108
- const inline = mode === "inline" ? inlineTexts[index] : undefined;
109
- const fieldTerms = indexableTerms(`${field.t}
110
- ${field.a}
111
- ${field.p}
112
- ${field.g}
113
- ${field.m}`);
114
- for (const term of fieldTerms)
115
- allTerms.add(term);
116
- if (mode !== "none") {
117
- const content = inlineTexts[index] ?? "";
118
- for (const term of indexableTerms(content)) {
119
- allTerms.add(term);
120
- if (mode === "shards") {
121
- const list = contentPostings.get(term) ?? [];
122
- list.push(index);
123
- contentPostings.set(term, list);
124
- }
125
- }
126
- }
127
- docs.push({
128
- i: index,
129
- s: slug,
130
- t: note.title,
131
- p: previewText(note),
132
- f: field,
133
- ...inline === undefined ? {} : { x: inline }
134
- });
135
- }
136
- let termsTruncated = false;
137
- let sortedTerms = [...allTerms].toSorted((left, right) => left.localeCompare(right));
138
- if (sortedTerms.length > WORDCELL_SITE_LIMITS_V1.indexTerms) {
139
- sortedTerms = sortedTerms.slice(0, WORDCELL_SITE_LIMITS_V1.indexTerms);
140
- termsTruncated = true;
141
- }
142
- const admitted = new Set(sortedTerms);
143
- const postings = new Map;
144
- if (mode === "shards") {
145
- const grouped = new Map;
146
- for (const [term, docIds] of [...contentPostings.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
147
- if (!admitted.has(term))
148
- continue;
149
- const shard = publishShardName(term);
150
- const bucket = grouped.get(shard) ?? Object.create(null);
151
- bucket[term] = docIds;
152
- grouped.set(shard, bucket);
153
- }
154
- for (const [shard, bucket] of [...grouped.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
155
- postings.set(shard, {
156
- format: WORDCELL_SITE_POSTINGS_FORMAT_V1,
157
- shard,
158
- postings: Object.freeze(bucket)
159
- });
160
- }
161
- }
162
- return {
163
- docs: {
164
- format: WORDCELL_SITE_DOCS_FORMAT_V1,
165
- content: mode,
166
- docs
167
- },
168
- terms: {
169
- format: WORDCELL_SITE_TERMS_FORMAT_V1,
170
- terms: sortedTerms
171
- },
172
- postings,
173
- termsCount: sortedTerms.length,
174
- termsTruncated,
175
- textTruncated,
176
- mode
177
- };
178
- }
179
52
 
180
53
  // src/publish-markdown.ts
181
54
  var VOID_SLUG = /[^\p{L}\p{N}._~-]+/gu;
@@ -257,6 +130,8 @@ function renderFootnoteReference(raw, label, footnotes) {
257
130
  const id = `wordcell:footnote-ref:${number}:${note.references.length + 1}`;
258
131
  note.references.push(id);
259
132
  footnotes.references += 1;
133
+ if (footnotes.plain)
134
+ return "";
260
135
  return `<sup><a id="${id}" href="#wordcell:footnote:${number}" role="doc-noteref" aria-label="Footnote ${number}">${number}</a></sup>`;
261
136
  }
262
137
  function renderInline(text, ctx, footnotes) {
@@ -904,6 +779,72 @@ function renderMarkdownToHtml(content, ctx) {
904
779
  ${notes}
905
780
  </ol></section>`;
906
781
  }
782
+ function projectMarkdownText(content, description) {
783
+ const footnotes = { byLabel: new Map, numbered: [], definitions: 0, references: 0, disabled: false, plain: true };
784
+ const blocks = parseMarkdownBlocks(content, footnotes, true);
785
+ const context = {
786
+ source: "",
787
+ resolveNote: () => {
788
+ return;
789
+ },
790
+ resolveAsset: () => {
791
+ return;
792
+ },
793
+ noteHref: () => ""
794
+ };
795
+ const inline = (text2, state) => stripMarkup(renderInline(text2, context, state));
796
+ const parts = (items, state) => items.map((block) => {
797
+ switch (block.type) {
798
+ case "paragraph":
799
+ case "heading":
800
+ return inline(block.text, state);
801
+ case "literal":
802
+ case "code":
803
+ return block.text;
804
+ case "break":
805
+ return "";
806
+ case "quote":
807
+ return parts(block.blocks, state).join(`
808
+
809
+ `);
810
+ case "table":
811
+ return [block.header, ...block.rows].map((row) => row.map((cell) => inline(cell, state)).join(" ")).join(`
812
+ `);
813
+ case "list":
814
+ return block.parts.map((part) => part.type === "item" || part.type === "continuation" ? inline(part.text, state) : "").filter(Boolean).join(`
815
+ `);
816
+ case "footnote":
817
+ return state.disabled || block.note.invalid ? block.note.authored : "";
818
+ }
819
+ });
820
+ const rendered = parts(blocks, footnotes);
821
+ if (!footnotes.disabled)
822
+ for (const note of footnotes.byLabel.values()) {
823
+ if (note !== null && !note.invalid)
824
+ footnoteNumber(note, footnotes);
825
+ }
826
+ const text = [...rendered, ...footnotes.numbered.map((note) => inline(note.body.replaceAll(`
827
+ `, " ")))].filter(Boolean).join(`
828
+
829
+ `);
830
+ let preview = rendered.find((value, index) => blocks[index]?.type === "paragraph" && value.trim() !== "") ?? text;
831
+ if (description !== undefined && description.trim() !== "") {
832
+ const state = {
833
+ byLabel: new Map([...footnotes.byLabel].map(([label, note]) => [label, note === null ? null : { body: note.body, authored: note.authored, invalid: note.invalid, references: [] }])),
834
+ numbered: [],
835
+ definitions: footnotes.definitions,
836
+ references: 0,
837
+ disabled: footnotes.disabled,
838
+ plain: true
839
+ };
840
+ const authored = parts(parseMarkdownBlocks(description, state, false), state).filter(Boolean).join(`
841
+
842
+ `);
843
+ if (authored.trim() !== "")
844
+ preview = authored;
845
+ }
846
+ return { text, preview };
847
+ }
907
848
  function stripMarkup(html) {
908
849
  let stripped = "";
909
850
  let inTag = false;
@@ -918,6 +859,135 @@ function stripMarkup(html) {
918
859
  return stripped.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&amp;", "&");
919
860
  }
920
861
 
862
+ // src/publish-index.ts
863
+ var TERM_PATTERN = /[\p{L}\p{N}][\p{L}\p{N}._/-]*/gu;
864
+ function bounded(value, maximumBytes) {
865
+ if (Buffer.byteLength(value, "utf8") <= maximumBytes)
866
+ return { text: value, truncated: false };
867
+ const bytes = Buffer.from(value, "utf8");
868
+ let end = maximumBytes;
869
+ while (end > 0 && ((bytes[end] ?? 0) & 192) === 128)
870
+ end -= 1;
871
+ return { text: bytes.subarray(0, end).toString("utf8"), truncated: true };
872
+ }
873
+ function previewText(note) {
874
+ const display = projectMarkdownText(note.content, note.properties["description"]);
875
+ const collapsed = display.preview.replace(/\s+/gu, " ").trim();
876
+ return bounded(collapsed, WORDCELL_SITE_LIMITS_V1.docPreviewBytes).text;
877
+ }
878
+ function indexableTerms(text) {
879
+ const terms = new Set;
880
+ for (const match of text.matchAll(TERM_PATTERN))
881
+ terms.add(match[0]);
882
+ return [...terms];
883
+ }
884
+ function buildSiteIndex(notes, slugById, options = {}) {
885
+ const indexContent = options.indexContent !== false;
886
+ const fields = [];
887
+ const inlineTexts = [];
888
+ let textTruncated = false;
889
+ let inlineTotal = 0;
890
+ for (const note of notes) {
891
+ const title = bounded(publishNormalize(note.title), WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
892
+ const aliases = bounded(note.aliases.map(publishNormalize).join(`
893
+ `), WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
894
+ const path = bounded(`${publishNormalize(note.path)}
895
+ ${publishNormalize(note.id)}`, WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
896
+ const tags = bounded(note.tags.map(publishNormalize).join(`
897
+ `), WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
898
+ const metadata = bounded(publishNormalize(canonicalJson(note.metadata)), WORDCELL_SITE_LIMITS_V1.fieldTextBytes);
899
+ textTruncated ||= title.truncated || aliases.truncated || path.truncated || tags.truncated || metadata.truncated;
900
+ fields.push({ t: title.text, a: aliases.text, p: path.text, g: tags.text, m: metadata.text });
901
+ if (indexContent) {
902
+ const inline = bounded(publishNormalize(note.searchableText), WORDCELL_SITE_LIMITS_V1.inlineTextBytes);
903
+ textTruncated ||= inline.truncated;
904
+ inlineTexts.push(inline.text);
905
+ inlineTotal += Buffer.byteLength(inline.text, "utf8");
906
+ }
907
+ }
908
+ const mode = !indexContent ? "none" : inlineTotal <= WORDCELL_SITE_LIMITS_V1.inlineTotalBytes ? "inline" : "shards";
909
+ const allTerms = new Set;
910
+ const contentPostings = new Map;
911
+ const docs = [];
912
+ for (const [index, note] of notes.entries()) {
913
+ const slug = slugById.get(note.id);
914
+ if (slug === undefined)
915
+ throw new Error(`Missing published slug for ${note.id}.`);
916
+ const field = fields[index];
917
+ if (field === undefined)
918
+ throw new Error(`Missing indexed fields for ${note.id}.`);
919
+ const inline = mode === "inline" ? inlineTexts[index] : undefined;
920
+ const fieldTerms = indexableTerms(`${field.t}
921
+ ${field.a}
922
+ ${field.p}
923
+ ${field.g}
924
+ ${field.m}`);
925
+ for (const term of fieldTerms)
926
+ allTerms.add(term);
927
+ if (mode !== "none") {
928
+ const content = inlineTexts[index] ?? "";
929
+ for (const term of indexableTerms(content)) {
930
+ allTerms.add(term);
931
+ if (mode === "shards") {
932
+ const list = contentPostings.get(term) ?? [];
933
+ list.push(index);
934
+ contentPostings.set(term, list);
935
+ }
936
+ }
937
+ }
938
+ docs.push({
939
+ i: index,
940
+ s: slug,
941
+ t: note.title,
942
+ p: previewText(note),
943
+ f: field,
944
+ ...inline === undefined ? {} : { x: inline }
945
+ });
946
+ }
947
+ let termsTruncated = false;
948
+ let sortedTerms = [...allTerms].toSorted((left, right) => left.localeCompare(right));
949
+ if (sortedTerms.length > WORDCELL_SITE_LIMITS_V1.indexTerms) {
950
+ sortedTerms = sortedTerms.slice(0, WORDCELL_SITE_LIMITS_V1.indexTerms);
951
+ termsTruncated = true;
952
+ }
953
+ const admitted = new Set(sortedTerms);
954
+ const postings = new Map;
955
+ if (mode === "shards") {
956
+ const grouped = new Map;
957
+ for (const [term, docIds] of [...contentPostings.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
958
+ if (!admitted.has(term))
959
+ continue;
960
+ const shard = publishShardName(term);
961
+ const bucket = grouped.get(shard) ?? Object.create(null);
962
+ bucket[term] = docIds;
963
+ grouped.set(shard, bucket);
964
+ }
965
+ for (const [shard, bucket] of [...grouped.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
966
+ postings.set(shard, {
967
+ format: WORDCELL_SITE_POSTINGS_FORMAT_V1,
968
+ shard,
969
+ postings: Object.freeze(bucket)
970
+ });
971
+ }
972
+ }
973
+ return {
974
+ docs: {
975
+ format: WORDCELL_SITE_DOCS_FORMAT_V1,
976
+ content: mode,
977
+ docs
978
+ },
979
+ terms: {
980
+ format: WORDCELL_SITE_TERMS_FORMAT_V1,
981
+ terms: sortedTerms
982
+ },
983
+ postings,
984
+ termsCount: sortedTerms.length,
985
+ termsTruncated,
986
+ textTruncated,
987
+ mode
988
+ };
989
+ }
990
+
921
991
  // src/publish-nav.ts
922
992
  var SITE_NAV_GROUP_LIMIT = 96;
923
993
  var SITE_NAV_NODE_BUDGET = 768;
@@ -1709,7 +1779,7 @@ async function projectVault(snapshot, options, io) {
1709
1779
  let anyTextTruncated = false;
1710
1780
  for (const note of selection.notes) {
1711
1781
  const slug = slugFor(note.id);
1712
- const text = boundedNormalized(note.searchableText, WORDCELL_SITE_LIMITS_V1.noteTextBytes);
1782
+ const text = boundedNormalized(projectMarkdownText(note.content).text, WORDCELL_SITE_LIMITS_V1.noteTextBytes);
1713
1783
  anyTextTruncated ||= text.truncated;
1714
1784
  const payload = {
1715
1785
  format: WORDCELL_SITE_NOTE_FORMAT_V1,
package/dist/index.js CHANGED
@@ -167,7 +167,7 @@ import {
167
167
  publishVault,
168
168
  renderPublishReportText,
169
169
  serializeSiteFile
170
- } from "./index-a3j61wz9.js";
170
+ } from "./index-pnt9wr59.js";
171
171
  import {
172
172
  MAX_NOTE_UTF8_BYTES,
173
173
  MAX_SCANNED_NOTES,
@@ -245,7 +245,7 @@ import {
245
245
  parseSitePostingsV1,
246
246
  parseSiteTermsV1
247
247
  } from "./index-66pshdtx.js";
248
- import"./index-3agn8scn.js";
248
+ import"./index-23m6bbjt.js";
249
249
  import {
250
250
  auditAgentGuideRepository,
251
251
  auditAgentGuideSource,
@@ -757,22 +757,58 @@ function comparePublishScores(left, right) {
757
757
  return Number(right.identity) - Number(left.identity) || right.score - left.score || left.i - right.i;
758
758
  }
759
759
  function publishSnippet(text, query, fallback, windowBytes = 160) {
760
- const normalized = publishNormalize(text);
761
- let offset = query.normalized === "" ? -1 : normalized.indexOf(query.normalized);
762
- if (offset < 0) {
760
+ if (!Number.isSafeInteger(windowBytes) || windowBytes < 0)
761
+ throw new RangeError("Snippet window must be a nonnegative safe byte count.");
762
+ const display = text.normalize("NFC");
763
+ const normalized = publishNormalize(display);
764
+ let match = query.normalized === "" ? -1 : normalized.indexOf(query.normalized);
765
+ if (match < 0) {
763
766
  for (const term of query.terms) {
764
- offset = normalized.indexOf(term);
765
- if (offset >= 0)
767
+ match = normalized.indexOf(term);
768
+ if (match >= 0)
766
769
  break;
767
770
  }
768
771
  }
769
- if (offset < 0)
772
+ if (match < 0)
770
773
  return fallback;
771
- const half = Math.floor(windowBytes / 2);
772
- const start = Math.max(0, offset - half);
773
- const end = Math.min(text.length, offset + half);
774
- const snippet = text.slice(start, end).replace(/\s+/gu, " ").trim();
775
- return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
774
+ let offset = 0, normalizedOffset = 0;
775
+ for (const character of display) {
776
+ const width = character.toLocaleLowerCase("en-US").length;
777
+ if (normalizedOffset + width > match)
778
+ break;
779
+ normalizedOffset += width;
780
+ offset += character.length;
781
+ }
782
+ const edge = windowBytes >= 6 ? "…" : "";
783
+ const budget = windowBytes - 2 * publishUtf8Bytes(edge);
784
+ const previous = (end2) => {
785
+ const last = display.charCodeAt(end2 - 1);
786
+ return end2 > 1 && last >= 56320 && last <= 57343 && display.charCodeAt(end2 - 2) >= 55296 && display.charCodeAt(end2 - 2) <= 56319 ? end2 - 2 : end2 - 1;
787
+ };
788
+ let start = offset, end = offset, used = 0;
789
+ while (start > 0) {
790
+ const before = previous(start), bytes = publishUtf8Bytes(display.slice(before, start));
791
+ if (used + bytes > Math.floor(budget / 2))
792
+ break;
793
+ start = before;
794
+ used += bytes;
795
+ }
796
+ while (end < display.length) {
797
+ const character = String.fromCodePoint(display.codePointAt(end) ?? 0), bytes = publishUtf8Bytes(character);
798
+ if (used + bytes > budget)
799
+ break;
800
+ end += character.length;
801
+ used += bytes;
802
+ }
803
+ while (start > 0) {
804
+ const before = previous(start), bytes = publishUtf8Bytes(display.slice(before, start));
805
+ if (used + bytes > budget)
806
+ break;
807
+ start = before;
808
+ used += bytes;
809
+ }
810
+ const snippet = display.slice(start, end).replace(/\s+/gu, " ").trim();
811
+ return `${start > 0 ? edge : ""}${snippet}${end < display.length ? edge : ""}`;
776
812
  }
777
813
 
778
814
  // src/publish-theme.ts
@@ -924,7 +960,7 @@ function resultHref(base, slug) {
924
960
  }
925
961
  function snippetFor(index, doc, query) {
926
962
  const hydrated = index.notes.get(doc.s);
927
- const text = hydrated?.text ?? doc.x;
963
+ const text = hydrated?.text;
928
964
  return text === undefined ? doc.p : publishSnippet(text, query, doc.p);
929
965
  }
930
966
  function appendMarked(parent, text, terms) {
@@ -23,7 +23,7 @@ import {
23
23
  publishSnippet,
24
24
  publishUtf8Bytes,
25
25
  scorePublishDocument
26
- } from "./index-3agn8scn.js";
26
+ } from "./index-23m6bbjt.js";
27
27
  import"./index-z1w83f81.js";
28
28
  export {
29
29
  scorePublishDocument,
package/dist/publish.js CHANGED
@@ -10,14 +10,14 @@ import {
10
10
  publishVault,
11
11
  renderPublishReportText,
12
12
  serializeSiteFile
13
- } from "./index-a3j61wz9.js";
13
+ } from "./index-pnt9wr59.js";
14
14
  import"./index-233z9wmn.js";
15
15
  import"./index-d13v9ckt.js";
16
16
  import"./index-48pz4jpc.js";
17
17
  import"./index-06c9ctr6.js";
18
18
  import"./index-4knsp9qj.js";
19
19
  import"./index-66pshdtx.js";
20
- import"./index-3agn8scn.js";
20
+ import"./index-23m6bbjt.js";
21
21
  import"./index-5vwpzb5a.js";
22
22
  import"./index-x3fthpsc.js";
23
23
  import"./index-qbssx940.js";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  parseArguments as parseArguments2,
4
4
  parseUrlMetadataArguments
5
- } from "./index-yx6p1vzc.js";
5
+ } from "./index-9h7zh0wt.js";
6
6
  import"./index-bcknqxrq.js";
7
7
  import {
8
8
  parsePdfArguments
@@ -42,14 +42,14 @@ import"./index-tf4wzjew.js";
42
42
  import"./index-1gwbassd.js";
43
43
  import"./index-vnybgywh.js";
44
44
  import"./index-1xxnjn0d.js";
45
- import"./index-a3j61wz9.js";
45
+ import"./index-pnt9wr59.js";
46
46
  import"./index-233z9wmn.js";
47
47
  import"./index-d13v9ckt.js";
48
48
  import"./index-48pz4jpc.js";
49
49
  import"./index-06c9ctr6.js";
50
50
  import"./index-4knsp9qj.js";
51
51
  import"./index-66pshdtx.js";
52
- import"./index-3agn8scn.js";
52
+ import"./index-23m6bbjt.js";
53
53
  import"./index-hya40gb2.js";
54
54
  import"./index-5vwpzb5a.js";
55
55
  import"./index-x3fthpsc.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/wordcell",
3
- "version": "0.22.3",
3
+ "version": "0.22.4",
4
4
  "description": "A local Markdown knowledge base with superpowers. Save decisions, sources, and plans as files you own, then recover them through exact and semantic search, typed relationships, backlinks, and Git history.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -479,8 +479,8 @@
479
479
  "check": "bun run check:worker && bun run check:effect && bun run check:workflows && bun run check:portfolio-inventory && bun run check:installed-command-docs && bun run typecheck && bun run build && bun run test:package && bun run test && bun run url-metadata:tool:check",
480
480
  "prepack": "bun run check",
481
481
  "check:effect": "bun run scripts/check-effect-policy.ts",
482
- "kb:search": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz wordcell search --root kb --mode exact --rerank typesafe --rerank-limit 25",
483
- "kb:search:local": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz wordcell search --root kb --mode exact",
482
+ "kb:search": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz wordcell search --root kb --mode exact --rerank typesafe --rerank-limit 25",
483
+ "kb:search:local": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz wordcell search --root kb --mode exact",
484
484
  "check:worker": "tsc -p worker/tsconfig.json --noEmit"
485
485
  },
486
486
  "dependencies": {
@@ -56,7 +56,7 @@ missing:
56
56
  ```sh
57
57
  command -v wordcell >/dev/null 2>&1 || {
58
58
  command -v bun >/dev/null 2>&1 || exit 1
59
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz
59
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.4/hraness-wordcell-0.22.4.tgz
60
60
  }
61
61
  wordcell --help
62
62
  ```
@@ -1,6 +1,7 @@
1
1
  import { canonicalJson } from "@hraness/oh";
2
2
 
3
3
  import type { Note } from "./graph.js";
4
+ import { projectMarkdownText } from "./publish-markdown.js";
4
5
  import {
5
6
  WORDCELL_SITE_DOCS_FORMAT_V1,
6
7
  WORDCELL_SITE_LIMITS_V1,
@@ -38,8 +39,8 @@ function bounded(value: string, maximumBytes: number): { text: string; truncated
38
39
  }
39
40
 
40
41
  function previewText(note: Note): string {
41
- const basis = note.summary !== "" ? note.summary : note.searchableText;
42
- const collapsed = basis.replace(/\s+/gu, " ").trim();
42
+ const display = projectMarkdownText(note.content, note.properties["description"]);
43
+ const collapsed = display.preview.replace(/\s+/gu, " ").trim();
43
44
  return bounded(collapsed, WORDCELL_SITE_LIMITS_V1.docPreviewBytes).text;
44
45
  }
45
46
 
@@ -108,6 +108,8 @@ type Footnotes = {
108
108
  definitions: number;
109
109
  references: number;
110
110
  disabled: boolean;
111
+ /** Plain display projections omit admitted citation navigation only. */
112
+ readonly plain?: true;
111
113
  };
112
114
 
113
115
  function footnoteLabel(raw: string): string | undefined {
@@ -136,6 +138,7 @@ function renderFootnoteReference(raw: string, label: string, footnotes: Footnote
136
138
  const id = `wordcell:footnote-ref:${number}:${note.references.length + 1}`;
137
139
  note.references.push(id);
138
140
  footnotes.references += 1;
141
+ if (footnotes.plain) return "";
139
142
  return `<sup><a id="${id}" href="#wordcell:footnote:${number}" role="doc-noteref" aria-label="Footnote ${number}">${number}</a></sup>`;
140
143
  }
141
144
 
@@ -820,6 +823,58 @@ export function renderMarkdownToHtml(content: string, ctx: PublishRenderContext)
820
823
  return `${body}${body === "" ? "" : "\n"}<section class="footnotes" aria-label="Footnotes"><ol>\n${notes}\n</ol></section>`;
821
824
  }
822
825
 
826
+ export type PublishMarkdownText = {
827
+ readonly text: string;
828
+ readonly preview: string;
829
+ };
830
+
831
+ /**
832
+ * Readable display text, derived before truncation or case normalization.
833
+ * Shares the article grammar and footnote admission rules; it is not an index
834
+ * or a replacement for authored Markdown. Valid citation navigation disappears,
835
+ * while unresolved definitions/references, escaped text, and code stay literal.
836
+ */
837
+ export function projectMarkdownText(content: string, description?: string): PublishMarkdownText {
838
+ const footnotes: Footnotes = { byLabel: new Map(), numbered: [], definitions: 0, references: 0, disabled: false, plain: true };
839
+ const blocks = parseMarkdownBlocks(content, footnotes, true);
840
+ // Display labels need no filesystem, URL fetch, or selected-note resolution.
841
+ // Unresolved local assets retain their authored alt text through renderInline.
842
+ const context: PublishRenderContext = {
843
+ source: "", resolveNote: () => undefined, resolveAsset: () => undefined, noteHref: () => "",
844
+ };
845
+ const inline = (text: string, state?: Footnotes): string => stripMarkup(renderInline(text, context, state));
846
+ const parts = (items: readonly MarkdownBlock[], state: Footnotes): string[] => items.map((block): string => {
847
+ switch (block.type) {
848
+ case "paragraph": case "heading": return inline(block.text, state);
849
+ case "literal": case "code": return block.text;
850
+ case "break": return "";
851
+ case "quote": return parts(block.blocks, state).join("\n\n");
852
+ case "table": return [block.header, ...block.rows].map((row) => row.map((cell) => inline(cell, state)).join(" ")).join("\n");
853
+ case "list": return block.parts.map((part) => part.type === "item" || part.type === "continuation" ? inline(part.text, state) : "").filter(Boolean).join("\n");
854
+ case "footnote": return state.disabled || block.note.invalid ? block.note.authored : "";
855
+ }
856
+ });
857
+ const rendered = parts(blocks, footnotes);
858
+ // The article orders valid definitions by first reference, then unreferenced
859
+ // definitions. Nested references in their bodies deliberately remain literal.
860
+ if (!footnotes.disabled) for (const note of footnotes.byLabel.values()) {
861
+ if (note !== null && !note.invalid) footnoteNumber(note, footnotes);
862
+ }
863
+ const text = [...rendered, ...footnotes.numbered.map((note) => inline(note.body.replaceAll("\n", " ")))].filter(Boolean).join("\n\n");
864
+ let preview = rendered.find((value, index) => blocks[index]?.type === "paragraph" && value.trim() !== "") ?? text;
865
+ if (description !== undefined && description.trim() !== "") {
866
+ // Resolve description citations against the same authored definitions, but
867
+ // never let preview rendering consume the article's citation allowance.
868
+ const state: Footnotes = {
869
+ byLabel: new Map([...footnotes.byLabel].map(([label, note]) => [label, note === null ? null : { body: note.body, authored: note.authored, invalid: note.invalid, references: [] }])),
870
+ numbered: [], definitions: footnotes.definitions, references: 0, disabled: footnotes.disabled, plain: true,
871
+ };
872
+ const authored = parts(parseMarkdownBlocks(description, state, false), state).filter(Boolean).join("\n\n");
873
+ if (authored.trim() !== "") preview = authored;
874
+ }
875
+ return { text, preview };
876
+ }
877
+
823
878
  export function stripMarkup(html: string): string {
824
879
  // Scan literal tags first — escaped angle brackets (&lt;) decode to text,
825
880
  // never tag boundaries. The character scanner (not a regex) keeps CodeQL's
@@ -47,7 +47,7 @@ export const WORDCELL_SITE_LIMITS_V1 = Object.freeze({
47
47
  * exact lane's substring semantics; larger corpora use postings shards.
48
48
  */
49
49
  inlineTotalBytes: 2 * 1_024 * 1_024,
50
- /** Normalized content text retained per note payload for hydration. */
50
+ /** Normalized readable content retained per note payload for snippet hydration. */
51
51
  noteTextBytes: 32 * 1_024,
52
52
  /** Site title and description. */
53
53
  titleBytes: 512,
@@ -185,7 +185,7 @@ export type WordcellSiteNoteV1 = {
185
185
  readonly type?: string;
186
186
  readonly tags: readonly string[];
187
187
  readonly summary: string;
188
- /** Bounded normalized content text for search hydration. */
188
+ /** Bounded normalized readable content for snippet hydration, not ranking. */
189
189
  readonly text: string;
190
190
  readonly textTruncated: boolean;
191
191
  readonly links: readonly WordcellSiteNoteLinkV1[];
@@ -321,7 +321,9 @@ function resultHref(base: string, slug: string): string {
321
321
 
322
322
  function snippetFor(index: SiteIndex, doc: WordcellSiteDocV1, query: PublishQuery): string {
323
323
  const hydrated = index.notes.get(doc.s);
324
- const text = hydrated?.text ?? doc.x;
324
+ // doc.x is a normalized ranking basis, not display text. The eager preview
325
+ // remains readable while the existing bounded hydration fetch is pending.
326
+ const text = hydrated?.text;
325
327
  return text === undefined ? doc.p : publishSnippet(text, query, doc.p);
326
328
  }
327
329
 
@@ -392,8 +392,10 @@ export function comparePublishScores(
392
392
  }
393
393
 
394
394
  /**
395
- * Bounded context window around the first query match in normalized text.
396
- * Mirrors `exactSnippet`: collapse whitespace, ellipsize edges.
395
+ * Bounded display context around the first normalized query match. Match
396
+ * offsets are mapped back to NFC display text before taking a UTF-8 byte
397
+ * window, so case expansion and supplementary characters cannot split it.
398
+ * The fallback retains the separate document-preview byte contract.
397
399
  */
398
400
  export function publishSnippet(
399
401
  text: string,
@@ -401,18 +403,47 @@ export function publishSnippet(
401
403
  fallback: string,
402
404
  windowBytes = 160,
403
405
  ): string {
404
- const normalized = publishNormalize(text);
405
- let offset = query.normalized === "" ? -1 : normalized.indexOf(query.normalized);
406
- if (offset < 0) {
406
+ if (!Number.isSafeInteger(windowBytes) || windowBytes < 0) throw new RangeError("Snippet window must be a nonnegative safe byte count.");
407
+ const display = text.normalize("NFC");
408
+ const normalized = publishNormalize(display);
409
+ let match = query.normalized === "" ? -1 : normalized.indexOf(query.normalized);
410
+ if (match < 0) {
407
411
  for (const term of query.terms) {
408
- offset = normalized.indexOf(term);
409
- if (offset >= 0) break;
412
+ match = normalized.indexOf(term);
413
+ if (match >= 0) break;
410
414
  }
411
415
  }
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 ? "…" : ""}`;
416
+ if (match < 0) return fallback;
417
+ let offset = 0, normalizedOffset = 0;
418
+ for (const character of display) {
419
+ const width = character.toLocaleLowerCase("en-US").length;
420
+ if (normalizedOffset + width > match) break;
421
+ normalizedOffset += width;
422
+ offset += character.length;
423
+ }
424
+ const edge = windowBytes >= 6 ? "…" : "";
425
+ const budget = windowBytes - 2 * publishUtf8Bytes(edge);
426
+ const previous = (end: number): number => {
427
+ const last = display.charCodeAt(end - 1);
428
+ return end > 1 && last >= 0xdc00 && last <= 0xdfff && display.charCodeAt(end - 2) >= 0xd800 && display.charCodeAt(end - 2) <= 0xdbff ? end - 2 : end - 1;
429
+ };
430
+ let start = offset, end = offset, used = 0;
431
+ while (start > 0) {
432
+ const before = previous(start), bytes = publishUtf8Bytes(display.slice(before, start));
433
+ if (used + bytes > Math.floor(budget / 2)) break;
434
+ start = before; used += bytes;
435
+ }
436
+ while (end < display.length) {
437
+ const character = String.fromCodePoint(display.codePointAt(end) ?? 0), bytes = publishUtf8Bytes(character);
438
+ if (used + bytes > budget) break;
439
+ end += character.length; used += bytes;
440
+ }
441
+ // Near the end, spend the remaining budget on earlier whole characters.
442
+ while (start > 0) {
443
+ const before = previous(start), bytes = publishUtf8Bytes(display.slice(before, start));
444
+ if (used + bytes > budget) break;
445
+ start = before; used += bytes;
446
+ }
447
+ const snippet = display.slice(start, end).replace(/\s+/gu, " ").trim();
448
+ return `${start > 0 ? edge : ""}${snippet}${end < display.length ? edge : ""}`;
418
449
  }
package/src/publish.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./graph.js";
17
17
  import { buildSiteIndex } from "./publish-index.js";
18
18
  import {
19
+ projectMarkdownText,
19
20
  renderMarkdownToHtml,
20
21
  type PublishRenderContext,
21
22
  } from "./publish-markdown.js";
@@ -307,7 +308,9 @@ export async function projectVault(
307
308
  let anyTextTruncated = false;
308
309
  for (const note of selection.notes) {
309
310
  const slug = slugFor(note.id);
310
- const text = boundedNormalized(note.searchableText, WORDCELL_SITE_LIMITS_V1.noteTextBytes);
311
+ // Display grammar owns citations before normalization can fold distinct
312
+ // labels together. Ranking continues to use untouched searchableText.
313
+ const text = boundedNormalized(projectMarkdownText(note.content).text, WORDCELL_SITE_LIMITS_V1.noteTextBytes);
311
314
  anyTextTruncated ||= text.truncated;
312
315
  const payload: WordcellSiteNoteV1 = {
313
316
  format: WORDCELL_SITE_NOTE_FORMAT_V1,