diff --git a/src/blog-data.test.ts b/src/blog-data.test.ts index b22f55d..eaa64c3 100644 --- a/src/blog-data.test.ts +++ b/src/blog-data.test.ts @@ -68,6 +68,49 @@ test("renders rich Leaflet blocks through the read-only adapter", () => { assert.match(post.body, / { + const sentence = "The Letta Agent SDK will feel familiar to people who have used the Claude Agent SDK. "; + const post = parseBlogDocument( + "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/adjacent-facets", + { + title: "Adjacent facets", + path: "/adjacent-facets", + publishedAt: "2026-08-18T19:59:27.602Z", + content: { + $type: "pub.leaflet.content", + pages: [{ + $type: "pub.leaflet.pages.linearDocument", + id: "7e289be1-eacb-463e-bace-159fca42a1c6", + blocks: [{ block: { + $type: "pub.leaflet.blocks.text", + plaintext: sentence, + facets: [ + { + index: { byteStart: 0, byteEnd: 15 }, + features: [{ $type: "pub.leaflet.richtext.facet#bold" }], + }, + { + index: { byteStart: 15, byteEnd: 16 }, + features: [ + { $type: "pub.leaflet.richtext.facet#bold" }, + { $type: "pub.leaflet.richtext.facet#italic" }, + ], + }, + { + index: { byteStart: 16, byteEnd: 84 }, + features: [{ $type: "pub.leaflet.richtext.facet#bold" }], + }, + ], + } }], + }], + }, + }, + ); + + assert.equal(post.body, `**${sentence.slice(0, 84)}**`); + assert.doesNotMatch(post.body, /\*{3} \*{3}/); +}); + test("drops dangling legacy footnote references without disturbing defined notes", () => { const post = parseBlogDocument( "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/footnotes", diff --git a/src/leaflet-reader.ts b/src/leaflet-reader.ts index 7ad96b8..c558fba 100644 --- a/src/leaflet-reader.ts +++ b/src/leaflet-reader.ts @@ -19,20 +19,74 @@ function escapeAttribute(value: string): string { .replace(/>/g, ">"); } -function applyFacets(text: string, facets?: JsonObject[]): string { - if (!facets || facets.length === 0) return text; +function featureKey(feature: JsonObject): string { + return JSON.stringify( + Object.fromEntries(Object.entries(feature).sort(([left], [right]) => left.localeCompare(right))), + ); +} - const unicode = new UnicodeString(text); - const sorted = [...facets] +function featureSetKey(features: JsonObject[]): string { + return features.map(featureKey).sort().join("\n"); +} + +function normalizeFacets(unicode: UnicodeString, facets: JsonObject[]): JsonObject[] { + const sorted = facets .filter((facet) => Number.isInteger(facet.index?.byteStart) && Number.isInteger(facet.index?.byteEnd) && facet.index.byteStart >= 0 && - facet.index.byteEnd >= facet.index.byteStart + facet.index.byteEnd >= facet.index.byteStart && + facet.index.byteEnd <= unicode.length ) - .sort((a, b) => - a.index.byteStart - b.index.byteStart || b.index.byteEnd - a.index.byteEnd + .map((facet) => ({ + ...facet, + index: { ...facet.index }, + features: Array.isArray(facet.features) ? [...facet.features] : [], + })) + .sort((left, right) => + left.index.byteStart - right.index.byteStart || + right.index.byteEnd - left.index.byteEnd + ); + + for (let index = 0; index < sorted.length; index++) { + const facet = sorted[index]; + const selected = unicode.slice(facet.index.byteStart, facet.index.byteEnd); + if (selected.trim() !== "") continue; + + const previous = sorted[index - 1]; + const next = sorted[index + 1]; + if (previous?.index.byteEnd !== facet.index.byteStart || + next?.index.byteStart !== facet.index.byteEnd) { + facet.features = []; + continue; + } + + const nextFeatures = new Set(next.features.map(featureKey)); + facet.features = previous.features.filter((feature: JsonObject) => + nextFeatures.has(featureKey(feature)) ); + } + + const merged: JsonObject[] = []; + for (const facet of sorted) { + if (facet.features.length === 0 || facet.index.byteStart === facet.index.byteEnd) continue; + const previous = merged.at(-1); + if (previous && + previous.index.byteEnd === facet.index.byteStart && + featureSetKey(previous.features) === featureSetKey(facet.features)) { + previous.index.byteEnd = facet.index.byteEnd; + continue; + } + merged.push(facet); + } + return merged; +} + +function applyFacets(text: string, facets?: JsonObject[]): string { + if (!facets || facets.length === 0) return text; + + const unicode = new UnicodeString(text); + const sorted = normalizeFacets(unicode, facets); const starts = new Map(); const ends = new Map();