@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 +4 -4
- package/dist/cli.js +4 -4
- package/dist/{index-3agn8scn.js → index-23m6bbjt.js} +47 -11
- package/dist/{index-yx6p1vzc.js → index-9h7zh0wt.js} +1 -1
- package/dist/{index-a3j61wz9.js → index-pnt9wr59.js} +199 -129
- package/dist/index.js +2 -2
- package/dist/publish-reader/reader.js +48 -12
- package/dist/publish-search.js +1 -1
- package/dist/publish.js +2 -2
- package/dist/{support-vm8fw4yc.js → support-m3t917ev.js} +3 -3
- package/package.json +3 -3
- package/skills/wordcell/SKILL.md +1 -1
- package/src/publish-index.ts +3 -2
- package/src/publish-markdown.ts +55 -0
- package/src/publish-model.ts +2 -2
- package/src/publish-reader/reader.ts +3 -1
- package/src/publish-search.ts +44 -13
- package/src/publish.ts +4 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
if (
|
|
295
|
+
match = normalized.indexOf(term);
|
|
296
|
+
if (match >= 0)
|
|
294
297
|
break;
|
|
295
298
|
}
|
|
296
299
|
}
|
|
297
|
-
if (
|
|
300
|
+
if (match < 0)
|
|
298
301
|
return fallback;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 };
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
publishNormalize,
|
|
33
33
|
publishShardName
|
|
34
|
-
} from "./index-
|
|
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("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&");
|
|
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.
|
|
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-
|
|
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-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
765
|
-
if (
|
|
767
|
+
match = normalized.indexOf(term);
|
|
768
|
+
if (match >= 0)
|
|
766
769
|
break;
|
|
767
770
|
}
|
|
768
771
|
}
|
|
769
|
-
if (
|
|
772
|
+
if (match < 0)
|
|
770
773
|
return fallback;
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
|
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) {
|
package/dist/publish-search.js
CHANGED
package/dist/publish.js
CHANGED
|
@@ -10,14 +10,14 @@ import {
|
|
|
10
10
|
publishVault,
|
|
11
11
|
renderPublishReportText,
|
|
12
12
|
serializeSiteFile
|
|
13
|
-
} from "./index-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
+
"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.
|
|
483
|
-
"kb:search:local": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.
|
|
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": {
|
package/skills/wordcell/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
```
|
package/src/publish-index.ts
CHANGED
|
@@ -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
|
|
42
|
-
const collapsed =
|
|
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
|
|
package/src/publish-markdown.ts
CHANGED
|
@@ -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 (<) decode to text,
|
|
825
880
|
// never tag boundaries. The character scanner (not a regex) keeps CodeQL's
|
package/src/publish-model.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/publish-search.ts
CHANGED
|
@@ -392,8 +392,10 @@ export function comparePublishScores(
|
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
/**
|
|
395
|
-
* Bounded context
|
|
396
|
-
*
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
if (
|
|
412
|
+
match = normalized.indexOf(term);
|
|
413
|
+
if (match >= 0) break;
|
|
410
414
|
}
|
|
411
415
|
}
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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,
|