Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

Select the types of activity you want to include in your feed.

Add rich text rendering pipeline

Grace Kind (Jul 12, 2026, 1:54 AM -0500) ea98ac13 bf3a40c6

+1498 -57
+87
impro-plugin/main.js
··· 310 310 }); 311 311 } 312 312 313 + // callback(tokens, context) receives the rich-text token stream for one 314 + // post and returns a new token array (or the input unchanged). The host 315 + // batches all posts of a render into one call per plugin. 316 + registerRichTextTransform(callback = (tokens) => tokens) { 317 + const handlerId = uuid.create(); 318 + callHandlers.set(handlerId, async (batch) => { 319 + const results = []; 320 + for (const { tokens, context } of batch) { 321 + try { 322 + const value = await callback(tokens, context); 323 + results.push({ value: serializeTransformTokens(value) }); 324 + } catch (error) { 325 + results.push({ error: error?.message ?? String(error) }); 326 + } 327 + } 328 + return results; 329 + }); 330 + self.postMessage({ 331 + type: "register", 332 + target: "richTextTransform", 333 + handlerId, 334 + }); 335 + } 336 + 313 337 registerSlot(name, callback = () => null) { 314 338 const handlerId = uuid.create(); 315 339 callHandlers.set(handlerId, async (context) => { ··· 352 376 }), 353 377 ); 354 378 } 379 + } 380 + 381 + function serializeTransformTokens(tokens) { 382 + if (!Array.isArray(tokens)) return tokens; 383 + return tokens.map((token) => { 384 + if ( 385 + (token?.type === "inline" || token?.type === "block") && 386 + token.node instanceof VirtualEl 387 + ) { 388 + return { ...token, node: token.node._serialize() }; 389 + } 390 + return token; 391 + }); 392 + } 393 + 394 + // Concatenates the renderable text of a token stream into a string with a position map, 395 + // so a transform can pattern-match across token boundaries and map matches back to tokens. 396 + export class FlattenedTokens { 397 + constructor(tokens) { 398 + this._segments = []; 399 + let text = ""; 400 + for (const token of tokens) { 401 + let value = null; 402 + if (token.type === "text") value = token.value; 403 + else if (token.type === "facet") value = token.text; 404 + const start = text.length; 405 + if (value != null) text += value; 406 + this._segments.push({ token, start, end: text.length }); 407 + } 408 + this.text = text; 409 + } 410 + 411 + // emit an inert range (no facets) 412 + textFor(start, end) { 413 + return this.text.slice(start, end); 414 + } 415 + 416 + // emit a live range, demoting partial facet tokens to text tokens 417 + tokensFor(start, end) { 418 + const out = []; 419 + for (const segment of this._segments) { 420 + if (segment.start === segment.end) { 421 + if (segment.start >= start && segment.start < end) { 422 + out.push(segment.token); 423 + } 424 + continue; 425 + } 426 + if (segment.end <= start || segment.start >= end) continue; 427 + const from = Math.max(start, segment.start); 428 + const to = Math.min(end, segment.end); 429 + const isWholeToken = from === segment.start && to === segment.end; 430 + if (isWholeToken) { 431 + out.push(segment.token); 432 + } else { 433 + out.push({ type: "text", value: this.text.slice(from, to) }); 434 + } 435 + } 436 + return out; 437 + } 438 + } 439 + 440 + export function flattenForScan(tokens) { 441 + return new FlattenedTokens(tokens); 355 442 } 356 443 357 444 const openModals = new Map();
+1 -1
impro-plugin/package.json
··· 1 1 { 2 2 "name": "@impro.social/impro-plugin", 3 - "version": "0.0.8", 3 + "version": "0.0.9", 4 4 "type": "module", 5 5 "main": "main.js", 6 6 "license": "0BSD",
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.141", 3 + "version": "0.17.142", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+1
plugins.md
··· 36 36 - Override component rendering with custom HTML (e.g. posts, profiles, buttons etc) 37 37 - Add a full page with custom HTML content 38 38 - Add custom feed filters 39 + - Transform rich text in posts 39 40 - Make whitelisted network requests (requires permissions) 40 41 41 42 ### Plugins CANNOT:
+8
src/css/style.css
··· 372 372 word-wrap: break-word; 373 373 } 374 374 375 + plugin-rich-text { 376 + display: contents; 377 + } 378 + 375 379 .rich-text a { 376 380 color: var(--text-link-color); 381 + } 382 + 383 + .rich-text-block { 384 + white-space: normal; 377 385 } 378 386 379 387 @media (hover: hover) {
+113
src/js/components/plugin-rich-text.js
··· 1 + import { render } from "/js/lib/lit-html.js"; 2 + import { Component } from "/js/components/component.js"; 3 + import { Signal, effect } from "/js/signals.js"; 4 + import { shallowEquals } from "/js/utils.js"; 5 + import { tokenizeRichText } from "/js/richTextHelpers.js"; 6 + import { richTextTokensTemplate } from "/js/templates/richText.template.js"; 7 + 8 + class PluginRichText extends Component { 9 + static get observedAttributes() { 10 + return ["truncate-urls"]; 11 + } 12 + 13 + set text(value) { 14 + this._text = value; 15 + this.$text?.set(value); 16 + } 17 + 18 + set facets(value) { 19 + this._facets = value; 20 + this.$facets?.set(value); 21 + } 22 + 23 + set transformContext(value) { 24 + this._transformContext = value; 25 + this.$transformContext?.set(value); 26 + } 27 + 28 + connectedCallback() { 29 + if (this.initialized) return; 30 + this.initialized = true; 31 + if (!this.pluginService) { 32 + throw new Error("pluginService is required"); 33 + } 34 + this.$text = new Signal.State(this._text ?? ""); 35 + this.$facets = new Signal.State(this._facets ?? null); 36 + this.$transformContext = new Signal.State(this._transformContext ?? null, { 37 + equals: shallowEquals, 38 + }); 39 + this.$truncateUrls = new Signal.State(this.hasAttribute("truncate-urls")); 40 + this.disposeRender = effect(() => this.renderContent()); 41 + } 42 + 43 + attributeChangedCallback(name, oldValue, newValue) { 44 + if (!this.initialized || oldValue === newValue) return; 45 + if (name === "truncate-urls") { 46 + this.$truncateUrls.set(newValue !== null); 47 + } 48 + } 49 + 50 + disconnectedCallback() { 51 + if (!this.initialized) return; 52 + this.disposeRender?.(); 53 + this.disposeRender = null; 54 + this._currentRequest = null; 55 + this.initialized = false; 56 + } 57 + 58 + renderContent() { 59 + const text = this.$text.get() ?? ""; 60 + const facets = this.$facets.get() ?? []; 61 + const transformContext = this.$transformContext.get(); 62 + const truncateUrls = this.$truncateUrls.get(); 63 + const pluginService = this.pluginService; 64 + const baseTokens = tokenizeRichText({ text, facets }); 65 + 66 + // Render base token immediately 67 + this._renderTokens(baseTokens, truncateUrls); 68 + 69 + // Subscribe to the version signal so registering/unregistering a transform will re-render 70 + pluginService.$richTextTransformsVersion.get(); 71 + 72 + // Request transformed tokens from pluginService 73 + if (!transformContext) { 74 + this._currentRequest = null; 75 + return; 76 + } 77 + const request = pluginService.transformRichTextTokens(baseTokens, { 78 + numberOfLines: null, 79 + ...transformContext, 80 + source: { text, facets }, 81 + }); 82 + this._currentRequest = request; 83 + request.then( 84 + (transformed) => { 85 + if ( 86 + // Ignore results that resolved after a newer render pass 87 + this._currentRequest !== request || 88 + !this.initialized || 89 + !transformed 90 + ) 91 + return; 92 + this._renderTokens(transformed, truncateUrls); 93 + }, 94 + (error) => { 95 + console.error("Rich text transform request failed", error); 96 + }, 97 + ); 98 + } 99 + 100 + _renderTokens(tokens, truncateUrls) { 101 + render( 102 + richTextTokensTemplate({ 103 + tokens, 104 + truncateUrls, 105 + renderNodeToken: (token) => 106 + this.pluginService.renderRichTextNodeToken(token, this), 107 + }), 108 + this, 109 + ); 110 + } 111 + } 112 + 113 + PluginRichText.register();
+1
src/js/components/post-composer.js
··· 355 355 ${recordEmbedTemplate({ 356 356 record: quotedRecord, 357 357 isAuthenticated: true, 358 + pluginService: this.pluginService, 358 359 })} 359 360 </div> 360 361 </div>`
+172
src/js/plugins/pluginService.js
··· 22 22 isEmptyPermissions, 23 23 } from "/js/plugins/pluginPermissions.js"; 24 24 import { compareVersions, isDev, sortBy } from "/js/utils.js"; 25 + import { 26 + validateRichTextTokens, 27 + hydrateRichTextFacets, 28 + } from "/js/richTextHelpers.js"; 25 29 import { Signal, SignalMap, ReactiveStore } from "/js/signals.js"; 26 30 import { EventEmitter } from "/js/eventEmitter.js"; 27 31 import { PLUGIN_REGISTRY_URL } from "/js/config.js"; ··· 55 59 const repo = segments[1].replace(/\.git$/, ""); 56 60 if (!owner || !repo) return null; 57 61 return `${owner}/${repo}`; 62 + } 63 + 64 + // Stamps node tokens (inline/block) with the pluginId that created them, so 65 + // renderRichTextNodeToken can route each to the correct plugin's renderer. 66 + function stampRichTextNodeTokens(tokens, previousTokens, pluginId) { 67 + const stampedIds = new Set(); 68 + // Only pass through previously stamped tokens to prevent cross-plugin forgery 69 + for (const token of previousTokens) { 70 + if (token.type === "inline" || token.type === "block") { 71 + stampedIds.add(token.pluginId); 72 + } 73 + } 74 + return tokens.map((token) => { 75 + if (token.type !== "inline" && token.type !== "block") return token; 76 + return { 77 + ...token, 78 + pluginId: stampedIds.has(token.pluginId) ? token.pluginId : pluginId, 79 + }; 80 + }); 58 81 } 59 82 60 83 export class PermissionsDeclinedError extends Error { ··· 71 94 sidebarItems: new Set(), 72 95 eventListeners: new Map(), 73 96 feedFilters: new Set(), 97 + richTextTransforms: new Set(), 74 98 }; 75 99 this.$availableUpdates = new Signal.State(null); 76 100 this.$rawRegistryListings = new Signal.State(null); ··· 103 127 })); 104 128 }); 105 129 this.$pluginFilteredFeedItems = new SignalMap(); 130 + // Bumped whenever a transform registers/unregisters 131 + this.$richTextTransformsVersion = new Signal.State(0); 132 + this._richTextTokensCache = new Map(); 133 + this._pendingRichTextRuns = new Map(); 134 + this._richTextQueue = []; 135 + this._richTextFlushScheduled = false; 136 + this._richTextElements = new WeakMap(); 106 137 this.$settingTabs = new SignalMap(); 107 138 this.$slots = new SignalMap(); 108 139 this.localPluginsEnabled = isDev(); ··· 193 224 this.registries.feedFilters.add(entry); 194 225 return () => this.registries.feedFilters.delete(entry); 195 226 }); 227 + this.pluginBridge.addRegistrationTarget( 228 + "richTextTransform", 229 + (plugin, message) => { 230 + const entry = { 231 + pluginId: plugin.pluginId, 232 + invoke: (batch) => plugin.call(message.handlerId, batch), 233 + }; 234 + this.registries.richTextTransforms.add(entry); 235 + this._invalidateRichTextTransforms(); 236 + return () => { 237 + this.registries.richTextTransforms.delete(entry); 238 + this._invalidateRichTextTransforms(); 239 + }; 240 + }, 241 + ); 196 242 this.pluginBridge.addRegistrationTarget("slot", (plugin, message) => { 197 243 const entry = { 198 244 pluginId: plugin.pluginId, ··· 767 813 ? {} 768 814 : (this.$pluginFilteredFeedItems.get(feedURI) ?? {}); 769 815 this.$pluginFilteredFeedItems.set(feedURI, { ...existing, ...filtered }); 816 + } 817 + 818 + // Rich-text transform pipeline 819 + 820 + _invalidateRichTextTransforms() { 821 + this._pendingRichTextRuns.clear(); 822 + this._richTextTokensCache.clear(); 823 + this.$richTextTransformsVersion.set( 824 + this.$richTextTransformsVersion.get() + 1, 825 + ); 826 + } 827 + 828 + // Results are cached by (uri, surface); requests are batched per render flush 829 + async transformRichTextTokens(tokens, context) { 830 + if (this.registries.richTextTransforms.size === 0) return null; 831 + const key = `${context.uri}|${context.surface}`; 832 + const cached = this._richTextTokensCache.get(key); 833 + if (cached && cached.text === context.source.text) { 834 + return cached.tokens; 835 + } 836 + const pending = this._pendingRichTextRuns.get(key); 837 + if (pending) return pending; 838 + const item = { key, baseTokens: tokens, tokens, context }; 839 + const promise = new Promise((resolve) => { 840 + item.resolve = resolve; 841 + }); 842 + this._pendingRichTextRuns.set(key, promise); 843 + this._richTextQueue.push(item); 844 + if (!this._richTextFlushScheduled) { 845 + this._richTextFlushScheduled = true; 846 + queueMicrotask(() => { 847 + this._richTextFlushScheduled = false; 848 + const items = this._richTextQueue.splice(0); 849 + this._runRichTextTransforms( 850 + items, 851 + this.$richTextTransformsVersion.get(), 852 + ); 853 + }); 854 + } 855 + return promise; 856 + } 857 + 858 + async _runRichTextTransforms(items, version) { 859 + for (const transform of this.registries.richTextTransforms) { 860 + const batch = items.map((item) => ({ 861 + tokens: item.tokens, 862 + context: item.context, 863 + })); 864 + let results = null; 865 + try { 866 + results = await transform.invoke(batch); 867 + } catch (e) { 868 + console.error( 869 + `Plugin ${transform.pluginId} rich text transform raised an exception`, 870 + e, 871 + ); 872 + } 873 + if (!Array.isArray(results)) continue; 874 + items.forEach((item, index) => { 875 + const result = results[index]; 876 + if (!result || result.error != null) { 877 + if (result?.error != null) { 878 + console.error( 879 + `Plugin ${transform.pluginId} rich text transform failed: ${result.error}`, 880 + ); 881 + } 882 + return; 883 + } 884 + if (!validateRichTextTokens(result.value)) { 885 + console.error( 886 + `Plugin ${transform.pluginId} rich text transform returned malformed tokens`, 887 + ); 888 + return; 889 + } 890 + try { 891 + const hydrated = hydrateRichTextFacets(result.value, item.baseTokens); 892 + item.tokens = stampRichTextNodeTokens( 893 + hydrated, 894 + item.tokens, 895 + transform.pluginId, 896 + ); 897 + } catch (error) { 898 + console.error( 899 + `Plugin ${transform.pluginId} rich text transform returned an unrecognized facet`, 900 + error, 901 + ); 902 + } 903 + }); 904 + } 905 + // Discard if transforms changed mid-run 906 + const isStale = version !== this.$richTextTransformsVersion.get(); 907 + for (const item of items) { 908 + if (isStale) { 909 + item.resolve(null); 910 + continue; 911 + } 912 + this._pendingRichTextRuns.delete(item.key); 913 + this._richTextTokensCache.set(item.key, { 914 + text: item.context.source.text, 915 + tokens: item.tokens, 916 + }); 917 + item.resolve(item.tokens); 918 + } 919 + } 920 + 921 + // Mounts a node token's VirtualEl via the owning plugin's renderer. 922 + // Elements are cached by host / token 923 + renderRichTextNodeToken(token, host) { 924 + if (!token.pluginId || !token.node || !host) return null; 925 + let byToken = this._richTextElements.get(host); 926 + if (!byToken) { 927 + byToken = new WeakMap(); 928 + this._richTextElements.set(host, byToken); 929 + } 930 + let cached = byToken.get(token); 931 + if (!cached) { 932 + let renderer = null; 933 + try { 934 + renderer = this.getRenderer(token.pluginId); 935 + } catch { 936 + return null; 937 + } 938 + cached = { root: renderer.createRoot() }; 939 + byToken.set(token, cached); 940 + } 941 + return cached.root.render(token.node); 770 942 } 771 943 }
+1
src/js/postComposerService.js
··· 38 38 this.currentPostComposer = document.createElement("post-composer"); 39 39 this.currentPostComposer.dataLayer = this.dataLayer; 40 40 this.currentPostComposer.identityResolver = this.identityResolver; 41 + this.currentPostComposer.pluginService = this.pluginService; 41 42 this.currentPostComposer.replyTo = replyTo; 42 43 this.currentPostComposer.replyRoot = replyRoot; 43 44 this.currentPostComposer.quotedRecord = quotedPost
+97
src/js/richTextHelpers.js
··· 1 + import { sliceByByte, sortBy, getByteLength } from "/js/utils.js"; 2 + import { clampFacetIndex } from "/js/facetHelpers.js"; 3 + 4 + function facetOverlaps(facet1, facet2) { 5 + return ( 6 + facet1.index.byteStart < facet2.index.byteEnd && 7 + facet1.index.byteEnd > facet2.index.byteStart 8 + ); 9 + } 10 + 11 + // Produces the flat token stream ({ type: "text" } / { type: "facet" }) that 12 + // feeds the plugin rich-text pipeline. 13 + export function tokenizeRichText({ text, facets = [] }) { 14 + const textByteLength = getByteLength(text); 15 + const clampedFacets = facets.map((facet) => 16 + clampFacetIndex(facet, { 17 + byteStart: 0, 18 + byteEnd: textByteLength, 19 + }), 20 + ); 21 + const sortedFacets = sortBy(clampedFacets, (facet) => facet.index.byteStart); 22 + const distinctFacets = []; 23 + for (const facet of sortedFacets) { 24 + if (!distinctFacets.some((f) => facetOverlaps(f, facet))) { 25 + distinctFacets.push(facet); 26 + } 27 + } 28 + const tokens = []; 29 + let currentIndex = 0; 30 + for (const facet of distinctFacets) { 31 + const beforeText = sliceByByte(text, currentIndex, facet.index.byteStart); 32 + if (beforeText) { 33 + tokens.push({ type: "text", value: beforeText }); 34 + } 35 + tokens.push({ 36 + type: "facet", 37 + facet, 38 + text: sliceByByte(text, facet.index.byteStart, facet.index.byteEnd), 39 + }); 40 + currentIndex = facet.index.byteEnd; 41 + } 42 + const remainingText = sliceByByte(text, currentIndex); 43 + if (remainingText) { 44 + tokens.push({ type: "text", value: remainingText }); 45 + } 46 + return tokens; 47 + } 48 + 49 + export function validateRichTextTokens(tokens) { 50 + if (!Array.isArray(tokens)) return false; 51 + return tokens.every((token) => { 52 + if (!token || typeof token !== "object") return false; 53 + switch (token.type) { 54 + case "text": 55 + return typeof token.value === "string"; 56 + case "facet": 57 + return !!token.facet?.index; 58 + case "inline": 59 + case "block": { 60 + const node = token.node; 61 + return ( 62 + !!node && typeof node === "object" && typeof node.tag === "string" 63 + ); 64 + } 65 + default: 66 + return false; 67 + } 68 + }); 69 + } 70 + 71 + // Replaces facet tokens with the originals from the input stream they were 72 + // derived from, matched by byte range 73 + export function hydrateRichTextFacets(tokens, baseTokens) { 74 + const facetTokensByRange = new Map(); 75 + for (const token of baseTokens) { 76 + if (token.type === "facet") { 77 + const { byteStart, byteEnd } = token.facet.index; 78 + facetTokensByRange.set(`${byteStart}-${byteEnd}`, token); 79 + } 80 + } 81 + const hydrated = []; 82 + for (const token of tokens) { 83 + if (token.type !== "facet") { 84 + hydrated.push(token); 85 + continue; 86 + } 87 + const { byteStart, byteEnd } = token.facet.index; 88 + const original = facetTokensByRange.get(`${byteStart}-${byteEnd}`); 89 + if (!original) { 90 + throw new Error( 91 + `facet ${byteStart}-${byteEnd} does not match any input facet`, 92 + ); 93 + } 94 + hydrated.push(original); 95 + } 96 + return hydrated; 97 + }
+13 -6
src/js/templates/largePost.template.js
··· 7 7 doHideAuthorOnUnauthenticated, 8 8 } from "/js/dataHelpers.js"; 9 9 import { avatarTemplate } from "/js/templates/avatar.template.js"; 10 - import { richTextTemplate } from "/js/templates/richText.template.js"; 10 + import "/js/components/plugin-rich-text.js"; 11 11 import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; 12 12 import { postActionBarTemplate } from "/js/templates/postActionBar.template.js"; 13 13 import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; ··· 152 152 children: html`<div class="post-body"> 153 153 ${postText.length > 0 154 154 ? html`<div class="post-text"> 155 - ${richTextTemplate({ 156 - text: postText, 157 - facets: post.record.facets, 158 - truncateUrls: true, 159 - })} 155 + <plugin-rich-text 156 + .pluginService=${pluginService} 157 + .text=${postText} 158 + .facets=${post.record.facets} 159 + .transformContext=${{ 160 + surface: "largePost", 161 + uri: post.uri, 162 + did: post.author?.did ?? null, 163 + }} 164 + truncate-urls 165 + ></plugin-rich-text> 160 166 </div>` 161 167 : ""} 162 168 ${post.embed ··· 165 171 embed: post.embed, 166 172 mediaLabel: post.mediaLabel, 167 173 isAuthenticated, 174 + pluginService, 168 175 })} 169 176 </div>` 170 177 : null}
+19 -6
src/js/templates/postEmbed.template.js
··· 9 9 import { avatarTemplate } from "/js/templates/avatar.template.js"; 10 10 import { infoIconTemplate } from "/js/templates/icons/infoIcon.template.js"; 11 11 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; 12 - import { richTextTemplate } from "/js/templates/richText.template.js"; 12 + import "/js/components/plugin-rich-text.js"; 13 13 import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; 14 14 import { postLabelsTemplate } from "/js/templates/postLabels.template.js"; 15 15 import { linkToPost, linkToFeed } from "/js/navigation.js"; ··· 149 149 lazyLoadImages, 150 150 isAuthenticated, 151 151 condensed = false, 152 + pluginService, 152 153 }) { 153 154 if (!quotedPost) { 154 155 return html`<div class="quoted-post embed-card">Post not found</div>`; ··· 224 225 <div class="quoted-post-body"> 225 226 ${postText.length > 0 226 227 ? html`<div class="post-text"> 227 - ${richTextTemplate({ 228 - text: postText, 229 - facets: quotedPost.value.facets, 230 - truncateUrls: true, 231 - })} 228 + <plugin-rich-text 229 + .pluginService=${pluginService} 230 + .text=${postText} 231 + .facets=${quotedPost.value.facets} 232 + .transformContext=${{ 233 + surface: "quotedPost", 234 + uri: quotedPost.uri, 235 + did: quotedPost.author?.did ?? null, 236 + }} 237 + truncate-urls 238 + ></plugin-rich-text> 232 239 </div>` 233 240 : ""} 234 241 ${embed && condensed ··· 240 247 mediaLabel: quotedPost.mediaLabel, 241 248 lazyLoadImages, 242 249 isAuthenticated, 250 + pluginService, 243 251 })} 244 252 </div>` 245 253 : ""} ··· 599 607 lazyLoadImages, 600 608 isAuthenticated, 601 609 condensed = false, 610 + pluginService, 602 611 }) { 603 612 switch (record.$type) { 604 613 case "app.bsky.embed.record#viewRecord": ··· 614 623 lazyLoadImages, 615 624 isAuthenticated, 616 625 condensed, 626 + pluginService, 617 627 }); 618 628 case "app.bsky.embed.record#viewBlocked": 619 629 return blockedQuoteTemplate(); ··· 641 651 lazyLoadImages = false, 642 652 isAuthenticated, 643 653 currentConvoId = null, 654 + pluginService, 644 655 }) { 645 656 if (enabledEmbedTypes && !enabledEmbedTypes.includes(embed.$type)) { 646 657 return null; ··· 651 662 record: embed.record, 652 663 lazyLoadImages, 653 664 isAuthenticated, 665 + pluginService, 654 666 }); 655 667 case "app.bsky.embed.recordWithMedia#view": 656 668 return html` ··· 664 676 record: embed.record.record, 665 677 lazyLoadImages, 666 678 isAuthenticated, 679 + pluginService, 667 680 })} 668 681 `; 669 682 case "app.bsky.embed.video#view":
+53 -36
src/js/templates/richText.template.js
··· 1 1 import { html } from "/js/lib/lit-html.js"; 2 - import { sliceByByte, sortBy, getByteLength, sanitizeUri } from "/js/utils.js"; 3 - import { clampFacetIndex } from "/js/facetHelpers.js"; 2 + import { sanitizeUri } from "/js/utils.js"; 3 + import { tokenizeRichText } from "/js/richTextHelpers.js"; 4 4 import { linkToHashtag, linkToProfileByDid } from "/js/navigation.js"; 5 5 6 6 const KNOWN_UNSUPPORTED_FACET_TYPES = ["blue.poll.post.facet#option"]; ··· 53 53 } 54 54 } 55 55 56 - function facetOverlaps(facet1, facet2) { 57 - return ( 58 - facet1.index.byteStart < facet2.index.byteEnd && 59 - facet1.index.byteEnd > facet2.index.byteStart 60 - ); 61 - } 62 - 63 - export function richTextTemplate({ text, facets = [], truncateUrls = false }) { 64 - const textByteLength = getByteLength(text); 65 - const clampedFacets = facets.map((facet) => 66 - clampFacetIndex(facet, { 67 - byteStart: 0, 68 - byteEnd: textByteLength, 69 - }), 70 - ); 71 - const sortedFacets = sortBy(clampedFacets, (facet) => facet.index.byteStart); 72 - const distinctFacets = []; 73 - for (const facet of sortedFacets) { 74 - if (!distinctFacets.some((f) => facetOverlaps(f, facet))) { 75 - distinctFacets.push(facet); 56 + // tokens: ({ type: "text" } / { type: "facet" } / { type: "inline" } / { type: "block" }) 57 + export function richTextTokensTemplate({ 58 + tokens, 59 + truncateUrls = false, 60 + renderNodeToken = () => null, 61 + }) { 62 + const parts = []; 63 + tokens.forEach((token, index) => { 64 + switch (token.type) { 65 + case "text": { 66 + let value = token.value; 67 + // Trim the newlines adjoining a block token so blocks don't render 68 + // with double gaps (the text is displayed white-space: pre-wrap). 69 + if (tokens[index - 1]?.type === "block" && value.startsWith("\n")) { 70 + value = value.slice(1); 71 + } 72 + if (tokens[index + 1]?.type === "block" && value.endsWith("\n")) { 73 + value = value.slice(0, -1); 74 + } 75 + parts.push(value); 76 + break; 77 + } 78 + case "facet": 79 + parts.push( 80 + facetTemplate({ 81 + facet: token.facet, 82 + wrappedText: token.text, 83 + truncateUrls, 84 + }), 85 + ); 86 + break; 87 + case "inline": 88 + case "block": { 89 + const element = renderNodeToken(token) ?? null; 90 + if (!element) break; 91 + parts.push( 92 + token.type === "block" 93 + ? html`<div class="rich-text-block">${element}</div>` 94 + : element, 95 + ); 96 + break; 97 + } 76 98 } 77 - } 78 - const parts = []; 79 - let currentIndex = 0; 80 - for (const facet of distinctFacets) { 81 - parts.push(sliceByByte(text, currentIndex, facet.index.byteStart)); 82 - const wrappedText = sliceByByte( 83 - text, 84 - facet.index.byteStart, 85 - facet.index.byteEnd, 86 - ); 87 - parts.push(facetTemplate({ facet, wrappedText, truncateUrls })); 88 - currentIndex = facet.index.byteEnd; 89 - } 90 - parts.push(sliceByByte(text, currentIndex)); 99 + }); 91 100 // prettier-ignore 92 101 return html`<div class="rich-text" data-testid="rich-text">${parts}</div>`; 93 102 } 103 + 104 + export function richTextTemplate({ text, facets = [], truncateUrls = false }) { 105 + const tokens = tokenizeRichText({ text, facets }); 106 + return richTextTokensTemplate({ 107 + tokens, 108 + truncateUrls, 109 + }); 110 + }
+13 -6
src/js/templates/smallPost.template.js
··· 9 9 import { noop } from "/js/utils.js"; 10 10 import { linkToPost } from "/js/navigation.js"; 11 11 import { avatarTemplate } from "/js/templates/avatar.template.js"; 12 - import { richTextTemplate } from "/js/templates/richText.template.js"; 12 + import "/js/components/plugin-rich-text.js"; 13 13 import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; 14 14 import { postActionBarTemplate } from "/js/templates/postActionBar.template.js"; 15 15 import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; ··· 160 160 </div>` 161 161 : html`${postText.length > 0 162 162 ? html`<div class="post-text"> 163 - ${richTextTemplate({ 164 - text: postText, 165 - facets: post.record.facets, 166 - truncateUrls: true, 167 - })} 163 + <plugin-rich-text 164 + .pluginService=${pluginService} 165 + .text=${postText} 166 + .facets=${post.record.facets} 167 + .transformContext=${{ 168 + surface: "smallPost", 169 + uri: post.uri, 170 + did: post.author?.did ?? null, 171 + }} 172 + truncate-urls 173 + ></plugin-rich-text> 168 174 </div>` 169 175 : ""} 170 176 ${post.embed ··· 174 180 mediaLabel: post.mediaLabel, 175 181 lazyLoadImages, 176 182 isAuthenticated, 183 + pluginService, 177 184 })} 178 185 </div>` 179 186 : null}`}
+9
src/js/utils.js
··· 209 209 return classname.trim(); 210 210 } 211 211 212 + export function shallowEquals(obj1, obj2) { 213 + if (obj1 === obj2) return true; 214 + if (!obj1 || !obj2) return false; 215 + const keys1 = Object.keys(obj1); 216 + const keys2 = Object.keys(obj2); 217 + if (keys1.length !== keys2.length) return false; 218 + return keys1.every((key) => obj1[key] === obj2[key]); 219 + } 220 + 212 221 export function deepClone(value) { 213 222 if (Array.isArray(value)) { 214 223 return value.map((item) => deepClone(item));
+3
src/js/views/chatDetail.view.js
··· 52 52 chatNotificationService, 53 53 identityResolver, 54 54 mainLayout, 55 + pluginService, 55 56 }, 56 57 }) { 57 58 await auth.requireAuth(); ··· 141 142 record, 142 143 isAuthenticated: true, 143 144 condensed: true, 145 + pluginService, 144 146 })} 145 147 </div>`; 146 148 } ··· 951 953 embed: message.embed, 952 954 isAuthenticated: true, 953 955 currentConvoId: convoId, 956 + pluginService, 954 957 })} 955 958 </div>` 956 959 : null}
+222
tests/unit/specs/components/plugin-rich-text.test.js
··· 1 + import { describe, it, beforeEach } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import { Signal } from "/js/signals.js"; 4 + import "/js/components/plugin-rich-text.js"; 5 + 6 + describe("plugin-rich-text", () => { 7 + async function flushEffects() { 8 + // Two ticks: signal changes re-run effects via rAF, which the test env 9 + // pins to setTimeout. 10 + await new Promise((resolve) => setTimeout(resolve, 0)); 11 + await new Promise((resolve) => setTimeout(resolve, 0)); 12 + } 13 + 14 + // Stand-in for the pipeline's async API. The element reads 15 + // $richTextTransformsVersion inside its render effect, so bumping it 16 + // re-fires the effect. 17 + function makePluginService({ result = null } = {}) { 18 + return { 19 + $richTextTransformsVersion: new Signal.State(0), 20 + calls: [], 21 + result, 22 + async transformRichTextTokens(tokens, context) { 23 + this.calls.push({ tokens, context }); 24 + return this.result; 25 + }, 26 + renderRichTextNodeToken(token) { 27 + const element = document.createElement(token.node.tag); 28 + element.textContent = token.node.text ?? ""; 29 + return element; 30 + }, 31 + }; 32 + } 33 + 34 + function makeTransformContext(overrides = {}) { 35 + return { 36 + surface: "largePost", 37 + uri: "at://did:test/app.bsky.feed.post/1", 38 + did: "did:test", 39 + ...overrides, 40 + }; 41 + } 42 + 43 + beforeEach(() => { 44 + document.body.innerHTML = ""; 45 + }); 46 + 47 + function mount({ 48 + text = "hello", 49 + facets = [], 50 + transformContext = makeTransformContext(), 51 + truncateUrls = false, 52 + pluginService = makePluginService(), 53 + } = {}) { 54 + const element = document.createElement("plugin-rich-text"); 55 + element.pluginService = pluginService; 56 + element.text = text; 57 + element.facets = facets; 58 + element.transformContext = transformContext; 59 + if (truncateUrls) element.setAttribute("truncate-urls", ""); 60 + document.body.appendChild(element); 61 + return element; 62 + } 63 + 64 + it("requires a pluginService to initialize", () => { 65 + const element = document.createElement("plugin-rich-text"); 66 + element.text = "hello"; 67 + assert.throws( 68 + () => element.connectedCallback(), 69 + /pluginService is required/, 70 + ); 71 + }); 72 + 73 + it("renders base rich text", () => { 74 + const element = mount(); 75 + const richText = element.querySelector("[data-testid='rich-text']"); 76 + assert(richText !== null); 77 + assert.deepEqual(richText.textContent, "hello"); 78 + }); 79 + 80 + it("truncates facet URLs when the truncate-urls attribute is set", () => { 81 + const url = "https://example.com/very/long/path/to/page"; 82 + const text = "See " + url; 83 + const facets = [ 84 + { 85 + index: { byteStart: 4, byteEnd: 4 + url.length }, 86 + features: [{ $type: "app.bsky.richtext.facet#link", uri: url }], 87 + }, 88 + ]; 89 + const element = mount({ text, facets, truncateUrls: true }); 90 + const link = element.querySelector("a"); 91 + assert(link !== null); 92 + assert(link.textContent.endsWith("...")); 93 + assert(link.textContent.length < url.length); 94 + }); 95 + 96 + it("passes base tokens and a context carrying the original source", () => { 97 + const pluginService = makePluginService(); 98 + const facets = []; 99 + mount({ pluginService, facets }); 100 + assert.deepEqual(pluginService.calls.length, 1); 101 + const call = pluginService.calls[0]; 102 + assert.deepEqual(call.tokens, [{ type: "text", value: "hello" }]); 103 + assert.deepEqual(call.context.surface, "largePost"); 104 + assert.deepEqual(call.context.numberOfLines, null); 105 + assert(call.context.source.text === "hello"); 106 + assert(call.context.source.facets === facets); 107 + }); 108 + 109 + it("skips the pipeline without a transformContext", async () => { 110 + const pluginService = makePluginService({ 111 + result: [{ type: "text", value: "SHOULD NOT RENDER" }], 112 + }); 113 + const element = mount({ pluginService, transformContext: null }); 114 + await flushEffects(); 115 + assert.deepEqual(pluginService.calls.length, 0); 116 + const richText = element.querySelector("[data-testid='rich-text']"); 117 + assert.deepEqual(richText.textContent, "hello"); 118 + }); 119 + 120 + it("patches in the transformed tokens when the request resolves", async () => { 121 + const pluginService = makePluginService({ 122 + result: [ 123 + { type: "text", value: "use " }, 124 + { 125 + type: "inline", 126 + pluginId: "p1", 127 + node: { tag: "code", text: "npm i" }, 128 + }, 129 + ], 130 + }); 131 + const element = mount({ pluginService }); 132 + // Base tokens render synchronously; the transform patches in async. 133 + assert.deepEqual(element.querySelector("code"), null); 134 + await flushEffects(); 135 + const code = element.querySelector("code"); 136 + assert(code !== null); 137 + assert.deepEqual(code.textContent, "npm i"); 138 + assert.deepEqual( 139 + element.querySelector("[data-testid='rich-text']").textContent, 140 + "use npm i", 141 + ); 142 + }); 143 + 144 + it("keeps the base render when the request resolves null", async () => { 145 + const pluginService = makePluginService({ result: null }); 146 + const element = mount({ pluginService }); 147 + await flushEffects(); 148 + const richText = element.querySelector("[data-testid='rich-text']"); 149 + assert.deepEqual(richText.textContent, "hello"); 150 + }); 151 + 152 + it("ignores a superseded request's result", async () => { 153 + const pluginService = makePluginService(); 154 + const resolvers = []; 155 + pluginService.transformRichTextTokens = () => 156 + new Promise((resolve) => { 157 + resolvers.push(resolve); 158 + }); 159 + const element = mount({ pluginService }); 160 + element.text = "changed"; 161 + await flushEffects(); 162 + assert.deepEqual(resolvers.length, 2); 163 + 164 + // The first (stale) request resolving must not clobber the newer render. 165 + resolvers[0]([{ type: "text", value: "STALE" }]); 166 + await flushEffects(); 167 + const richText = element.querySelector("[data-testid='rich-text']"); 168 + assert.deepEqual(richText.textContent, "changed"); 169 + 170 + resolvers[1]([{ type: "text", value: "fresh" }]); 171 + await flushEffects(); 172 + assert.deepEqual(richText.textContent, "fresh"); 173 + }); 174 + 175 + it("re-requests when the transform set changes", async () => { 176 + const pluginService = makePluginService({ result: null }); 177 + mount({ pluginService }); 178 + assert.deepEqual(pluginService.calls.length, 1); 179 + 180 + pluginService.result = [{ type: "text", value: "now transformed" }]; 181 + pluginService.$richTextTransformsVersion.set(1); 182 + await flushEffects(); 183 + 184 + assert.deepEqual(pluginService.calls.length, 2); 185 + }); 186 + 187 + it("re-renders when text changes", async () => { 188 + const element = mount(); 189 + element.text = "changed"; 190 + await flushEffects(); 191 + const richText = element.querySelector("[data-testid='rich-text']"); 192 + assert.deepEqual(richText.textContent, "changed"); 193 + }); 194 + 195 + it("does not re-run the pipeline for an equal transformContext object", async () => { 196 + const pluginService = makePluginService(); 197 + const transformContext = makeTransformContext(); 198 + const element = mount({ pluginService, transformContext }); 199 + assert.deepEqual(pluginService.calls.length, 1); 200 + 201 + // Parent templates rebuild the context object every render with the same 202 + // field values. 203 + element.transformContext = { ...transformContext }; 204 + await flushEffects(); 205 + 206 + assert.deepEqual(pluginService.calls.length, 1); 207 + }); 208 + 209 + it("stops rendering after disconnect and resumes with the latest text on reconnect", async () => { 210 + const pluginService = makePluginService({ result: null }); 211 + const element = mount({ pluginService }); 212 + element.remove(); 213 + 214 + element.text = "while detached"; 215 + await flushEffects(); 216 + assert.deepEqual(pluginService.calls.length, 1); 217 + 218 + document.body.appendChild(element); 219 + const richText = element.querySelector("[data-testid='rich-text']"); 220 + assert.deepEqual(richText.textContent, "while detached"); 221 + }); 222 + });
+5
tests/unit/specs/components/post-composer.test.js
··· 28 28 displayName: "Test User", 29 29 avatar: null, 30 30 }; 31 + element.pluginService = { 32 + $richTextTransformsVersion: { get: () => 0 }, 33 + transformRichTextTokens: async () => null, 34 + renderRichTextNodeToken: () => null, 35 + }; 31 36 return element; 32 37 } 33 38
+361
tests/unit/specs/plugins/pluginService.test.js
··· 1383 1383 }); 1384 1384 }); 1385 1385 }); 1386 + 1387 + describe("rich text transform pipeline", () => { 1388 + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); 1389 + 1390 + function makeContext({ 1391 + uri = "at://did:test/app.bsky.feed.post/1", 1392 + surface = "largePost", 1393 + text = "hello", 1394 + facets = [], 1395 + } = {}) { 1396 + return { 1397 + surface, 1398 + uri, 1399 + did: "did:test", 1400 + numberOfLines: null, 1401 + source: { text, facets }, 1402 + }; 1403 + } 1404 + 1405 + function addTransform(service, pluginId, invoke) { 1406 + const entry = { pluginId, invoke }; 1407 + service.registries.richTextTransforms.add(entry); 1408 + return entry; 1409 + } 1410 + 1411 + function silencingErrors(run) { 1412 + const originalError = console.error; 1413 + console.error = () => {}; 1414 + return Promise.resolve() 1415 + .then(run) 1416 + .finally(() => { 1417 + console.error = originalError; 1418 + }); 1419 + } 1420 + 1421 + it("resolves null with no transforms registered", async () => { 1422 + const { service } = makeService(); 1423 + const tokens = [{ type: "text", value: "hello" }]; 1424 + assert.deepEqual( 1425 + await service.transformRichTextTokens(tokens, makeContext()), 1426 + null, 1427 + ); 1428 + }); 1429 + 1430 + it("resolves the transformed tokens and caches them per post and surface", async () => { 1431 + const { service } = makeService(); 1432 + const batches = []; 1433 + addTransform(service, "alpha", async (batch) => { 1434 + batches.push(batch); 1435 + return batch.map(({ tokens }) => ({ 1436 + value: [...tokens, { type: "text", value: "!" }], 1437 + })); 1438 + }); 1439 + const tokens = [{ type: "text", value: "hello" }]; 1440 + const context = makeContext(); 1441 + 1442 + const transformed = await service.transformRichTextTokens(tokens, context); 1443 + assert.deepEqual(transformed, [ 1444 + { type: "text", value: "hello" }, 1445 + { type: "text", value: "!" }, 1446 + ]); 1447 + 1448 + // Second request hits the cache: same result, no extra plugin call. 1449 + assert.deepEqual( 1450 + await service.transformRichTextTokens(tokens, context), 1451 + transformed, 1452 + ); 1453 + assert.deepEqual(batches.length, 1); 1454 + }); 1455 + 1456 + it("batches all posts of a render burst into one call per plugin", async () => { 1457 + const { service } = makeService(); 1458 + const batches = []; 1459 + addTransform(service, "alpha", async (batch) => { 1460 + batches.push(batch); 1461 + return batch.map(({ tokens }) => ({ value: tokens })); 1462 + }); 1463 + 1464 + await Promise.all([ 1465 + service.transformRichTextTokens( 1466 + [{ type: "text", value: "one" }], 1467 + makeContext({ uri: "at://post/1", text: "one" }), 1468 + ), 1469 + service.transformRichTextTokens( 1470 + [{ type: "text", value: "two" }], 1471 + makeContext({ uri: "at://post/2", text: "two" }), 1472 + ), 1473 + ]); 1474 + 1475 + assert.deepEqual(batches.length, 1); 1476 + assert.deepEqual(batches[0].length, 2); 1477 + assert.deepEqual(batches[0][0].tokens, [{ type: "text", value: "one" }]); 1478 + assert.deepEqual(batches[0][1].tokens, [{ type: "text", value: "two" }]); 1479 + }); 1480 + 1481 + it("shares one run between concurrent requests for the same post and surface", async () => { 1482 + const { service } = makeService(); 1483 + const batches = []; 1484 + addTransform(service, "alpha", async (batch) => { 1485 + batches.push(batch); 1486 + return batch.map(({ tokens }) => ({ value: tokens })); 1487 + }); 1488 + const tokens = [{ type: "text", value: "hello" }]; 1489 + const context = makeContext(); 1490 + 1491 + const [first, second] = await Promise.all([ 1492 + service.transformRichTextTokens(tokens, context), 1493 + service.transformRichTextTokens(tokens, context), 1494 + ]); 1495 + 1496 + assert.deepEqual(first, second); 1497 + assert.deepEqual(batches.length, 1); 1498 + assert.deepEqual(batches[0].length, 1); 1499 + }); 1500 + 1501 + it("chains transforms in registration order", async () => { 1502 + const { service } = makeService(); 1503 + addTransform(service, "alpha", async (batch) => 1504 + batch.map(({ tokens }) => ({ 1505 + value: [...tokens, { type: "text", value: "A" }], 1506 + })), 1507 + ); 1508 + addTransform(service, "beta", async (batch) => 1509 + batch.map(({ tokens }) => ({ 1510 + value: [...tokens, { type: "text", value: "B" }], 1511 + })), 1512 + ); 1513 + 1514 + const transformed = await service.transformRichTextTokens( 1515 + [{ type: "text", value: "hello" }], 1516 + makeContext(), 1517 + ); 1518 + 1519 + assert.deepEqual( 1520 + transformed.map((token) => token.value), 1521 + ["hello", "A", "B"], 1522 + ); 1523 + }); 1524 + 1525 + it("fails open when a transform throws", async () => { 1526 + const { service } = makeService(); 1527 + addTransform(service, "alpha", async () => { 1528 + throw new Error("boom"); 1529 + }); 1530 + addTransform(service, "beta", async (batch) => 1531 + batch.map(({ tokens }) => ({ 1532 + value: [...tokens, { type: "text", value: "B" }], 1533 + })), 1534 + ); 1535 + 1536 + const transformed = await silencingErrors(() => 1537 + service.transformRichTextTokens( 1538 + [{ type: "text", value: "hello" }], 1539 + makeContext(), 1540 + ), 1541 + ); 1542 + 1543 + assert.deepEqual( 1544 + transformed.map((token) => token.value), 1545 + ["hello", "B"], 1546 + ); 1547 + }); 1548 + 1549 + it("fails open per item on error entries and malformed tokens", async () => { 1550 + const { service } = makeService(); 1551 + addTransform(service, "alpha", async (batch) => 1552 + batch.map(({ context }) => 1553 + context.uri.endsWith("/1") 1554 + ? { error: "no thanks" } 1555 + : { value: [{ type: "bogus" }] }, 1556 + ), 1557 + ); 1558 + 1559 + const [first, second] = await silencingErrors(() => 1560 + Promise.all([ 1561 + service.transformRichTextTokens( 1562 + [{ type: "text", value: "one" }], 1563 + makeContext({ uri: "at://post/1", text: "one" }), 1564 + ), 1565 + service.transformRichTextTokens( 1566 + [{ type: "text", value: "two" }], 1567 + makeContext({ uri: "at://post/2", text: "two" }), 1568 + ), 1569 + ]), 1570 + ); 1571 + 1572 + assert.deepEqual(first, [{ type: "text", value: "one" }]); 1573 + assert.deepEqual(second, [{ type: "text", value: "two" }]); 1574 + }); 1575 + 1576 + it("re-hydrates returned facet tokens to the host originals", async () => { 1577 + const { service } = makeService(); 1578 + const facet = { 1579 + index: { byteStart: 0, byteEnd: 4 }, 1580 + features: [{ $type: "app.bsky.richtext.facet#tag", tag: "tag" }], 1581 + }; 1582 + const facetToken = { type: "facet", facet, text: "#tag" }; 1583 + // Simulate the structured-clone boundary: the plugin returns a copy. 1584 + addTransform(service, "alpha", async (batch) => 1585 + batch.map(({ tokens }) => ({ 1586 + value: JSON.parse(JSON.stringify(tokens)), 1587 + })), 1588 + ); 1589 + 1590 + const transformed = await service.transformRichTextTokens( 1591 + [facetToken, { type: "text", value: " in front" }], 1592 + makeContext({ text: "#tag in front", facets: [facet] }), 1593 + ); 1594 + 1595 + assert( 1596 + transformed[0] === facetToken, 1597 + "facet token should be the host object", 1598 + ); 1599 + }); 1600 + 1601 + it("rejects a result containing an unrecognized facet", async () => { 1602 + const { service } = makeService(); 1603 + addTransform(service, "alpha", async (batch) => 1604 + batch.map(() => ({ 1605 + value: [ 1606 + { 1607 + type: "facet", 1608 + facet: { index: { byteStart: 0, byteEnd: 99 }, features: [] }, 1609 + text: "forged", 1610 + }, 1611 + ], 1612 + })), 1613 + ); 1614 + const tokens = [{ type: "text", value: "hello" }]; 1615 + 1616 + const transformed = await silencingErrors(() => 1617 + service.transformRichTextTokens(tokens, makeContext()), 1618 + ); 1619 + 1620 + assert.deepEqual(transformed, tokens); 1621 + }); 1622 + 1623 + it("stamps inline/block tokens with the emitting transform's pluginId and preserves earlier ids", async () => { 1624 + const { service } = makeService(); 1625 + const node = { tag: "code", text: "x" }; 1626 + addTransform(service, "alpha", async (batch) => 1627 + batch.map(() => ({ value: [{ type: "inline", node }] })), 1628 + ); 1629 + addTransform(service, "beta", async (batch) => 1630 + batch.map(({ tokens }) => ({ 1631 + value: [...tokens, { type: "block", node }], 1632 + })), 1633 + ); 1634 + 1635 + const transformed = await service.transformRichTextTokens( 1636 + [{ type: "text", value: "hello" }], 1637 + makeContext(), 1638 + ); 1639 + 1640 + assert.deepEqual( 1641 + transformed.map((token) => token.pluginId), 1642 + ["alpha", "beta"], 1643 + ); 1644 + }); 1645 + 1646 + it("re-stamps a forged pluginId naming another plugin", async () => { 1647 + const { service } = makeService(); 1648 + const node = { tag: "code", text: "x" }; 1649 + addTransform(service, "alpha", async (batch) => 1650 + batch.map(() => ({ 1651 + value: [{ type: "inline", pluginId: "victim", node }], 1652 + })), 1653 + ); 1654 + 1655 + const transformed = await service.transformRichTextTokens( 1656 + [{ type: "text", value: "hello" }], 1657 + makeContext(), 1658 + ); 1659 + 1660 + assert.deepEqual( 1661 + transformed.map((token) => token.pluginId), 1662 + ["alpha"], 1663 + ); 1664 + }); 1665 + 1666 + it("clears cached results when the transform set changes", async () => { 1667 + const { service } = makeService(); 1668 + const batches = []; 1669 + addTransform(service, "alpha", async (batch) => { 1670 + batches.push(batch); 1671 + return batch.map(({ tokens }) => ({ value: tokens })); 1672 + }); 1673 + const tokens = [{ type: "text", value: "hello" }]; 1674 + const context = makeContext(); 1675 + 1676 + await service.transformRichTextTokens(tokens, context); 1677 + service._invalidateRichTextTransforms(); 1678 + await service.transformRichTextTokens(tokens, context); 1679 + 1680 + assert.deepEqual(batches.length, 2); 1681 + }); 1682 + 1683 + it("resolves in-flight requests with null when transforms change mid-run", async () => { 1684 + const { service } = makeService(); 1685 + let releaseTransform; 1686 + const gate = new Promise((resolve) => { 1687 + releaseTransform = resolve; 1688 + }); 1689 + addTransform(service, "alpha", async (batch) => { 1690 + await gate; 1691 + return batch.map(({ tokens }) => ({ value: tokens })); 1692 + }); 1693 + const request = service.transformRichTextTokens( 1694 + [{ type: "text", value: "hello" }], 1695 + makeContext(), 1696 + ); 1697 + await flush(); 1698 + service._invalidateRichTextTransforms(); 1699 + releaseTransform(); 1700 + 1701 + assert.deepEqual(await request, null); 1702 + assert.deepEqual(service._richTextTokensCache.size, 0); 1703 + }); 1704 + 1705 + it("re-runs when the cached entry no longer matches the source text", async () => { 1706 + const { service } = makeService(); 1707 + const batches = []; 1708 + addTransform(service, "alpha", async (batch) => { 1709 + batches.push(batch); 1710 + return batch.map(({ tokens }) => ({ value: tokens })); 1711 + }); 1712 + const context = makeContext({ text: "before" }); 1713 + 1714 + await service.transformRichTextTokens( 1715 + [{ type: "text", value: "before" }], 1716 + context, 1717 + ); 1718 + const transformed = await service.transformRichTextTokens( 1719 + [{ type: "text", value: "after" }], 1720 + makeContext({ text: "after" }), 1721 + ); 1722 + 1723 + assert.deepEqual(batches.length, 2); 1724 + assert.deepEqual(transformed, [{ type: "text", value: "after" }]); 1725 + }); 1726 + 1727 + it("renderRichTextNodeToken mounts a sanitized element and reuses it per token and host", () => { 1728 + const { service } = makeService(); 1729 + service.setRenderContext({}); 1730 + const token = { 1731 + type: "inline", 1732 + pluginId: "alpha", 1733 + node: { tag: "code", attrs: {}, text: "x", children: [], events: {} }, 1734 + }; 1735 + const host = document.createElement("div"); 1736 + 1737 + const element = service.renderRichTextNodeToken(token, host); 1738 + assert.deepEqual(element.localName, "code"); 1739 + assert.deepEqual(element.textContent, "x"); 1740 + assert(service.renderRichTextNodeToken(token, host) === element); 1741 + const otherHost = document.createElement("div"); 1742 + const otherElement = service.renderRichTextNodeToken(token, otherHost); 1743 + assert.deepEqual(otherElement.localName, "code"); 1744 + assert(otherElement !== element); 1745 + }); 1746 + });
+146
tests/unit/specs/plugins/pluginWorker.test.js
··· 32 32 Setting, 33 33 VirtualEl, 34 34 fetch: pluginFetch, 35 + flattenForScan, 36 + FlattenedTokens, 35 37 } = worker; 36 38 37 39 function lastMessage() { ··· 819 821 ); 820 822 }); 821 823 }); 824 + 825 + describe("registerRichTextTransform", () => { 826 + it("posts a register message", () => { 827 + clearMessages(); 828 + const plugin = new Plugin(); 829 + plugin.registerRichTextTransform((tokens) => tokens); 830 + const msg = lastMessage(); 831 + assert.deepEqual(msg.type, "register"); 832 + assert.deepEqual(msg.target, "richTextTransform"); 833 + assert(typeof msg.handlerId === "number"); 834 + }); 835 + 836 + it("maps a batch through the callback and serializes VirtualEl nodes", async () => { 837 + clearMessages(); 838 + const plugin = new Plugin(); 839 + plugin.registerRichTextTransform((tokens, context) => { 840 + if (context.surface === "smallPost") return tokens; 841 + const node = new VirtualEl("code"); 842 + node.setText("hi"); 843 + return [...tokens, { type: "inline", node }]; 844 + }); 845 + const register = lastMessage(); 846 + clearMessages(); 847 + const batch = [ 848 + { 849 + tokens: [{ type: "text", value: "one" }], 850 + context: { surface: "largePost" }, 851 + }, 852 + { 853 + tokens: [{ type: "text", value: "two" }], 854 + context: { surface: "smallPost" }, 855 + }, 856 + ]; 857 + await dispatch({ 858 + type: "call", 859 + handlerId: register.handlerId, 860 + callId: 7, 861 + args: [batch], 862 + }); 863 + const result = postedMessages.find((message) => message.type === "result"); 864 + assert.deepEqual(result.callId, 7); 865 + assert.deepEqual(result.value.length, 2); 866 + const [first, second] = result.value; 867 + assert.deepEqual(first.value[0], { type: "text", value: "one" }); 868 + assert.deepEqual(first.value[1].type, "inline"); 869 + assert.deepEqual(first.value[1].node.tag, "code"); 870 + assert.deepEqual(first.value[1].node.text, "hi"); 871 + assert.deepEqual(second.value, [{ type: "text", value: "two" }]); 872 + }); 873 + 874 + it("returns an error entry for items whose callback throws", async () => { 875 + clearMessages(); 876 + const plugin = new Plugin(); 877 + plugin.registerRichTextTransform((tokens, context) => { 878 + if (context.uri === "at://bad") throw new Error("nope"); 879 + return tokens; 880 + }); 881 + const register = lastMessage(); 882 + clearMessages(); 883 + await dispatch({ 884 + type: "call", 885 + handlerId: register.handlerId, 886 + callId: 8, 887 + args: [ 888 + [ 889 + { tokens: [], context: { uri: "at://bad" } }, 890 + { 891 + tokens: [{ type: "text", value: "ok" }], 892 + context: { uri: "at://good" }, 893 + }, 894 + ], 895 + ], 896 + }); 897 + const result = postedMessages.find((message) => message.type === "result"); 898 + assert.deepEqual(result.value[0].error, "nope"); 899 + assert.deepEqual(result.value[1].value, [{ type: "text", value: "ok" }]); 900 + }); 901 + }); 902 + 903 + describe("flattenForScan", () => { 904 + const facetToken = { 905 + type: "facet", 906 + facet: { 907 + index: { byteStart: 4, byteEnd: 23 }, 908 + features: [ 909 + { 910 + $type: "app.bsky.richtext.facet#link", 911 + uri: "https://example.com", 912 + }, 913 + ], 914 + }, 915 + text: "https://example.com", 916 + }; 917 + const tokens = [ 918 + { type: "text", value: "see " }, 919 + facetToken, 920 + { type: "text", value: " now" }, 921 + ]; 922 + 923 + it("returns a FlattenedTokens instance concatenating text and facet token text", () => { 924 + const flat = flattenForScan(tokens); 925 + assert(flat instanceof FlattenedTokens); 926 + assert.deepEqual(flat.text, "see https://example.com now"); 927 + }); 928 + 929 + it("textFor returns the flattened source text", () => { 930 + const flat = flattenForScan(tokens); 931 + assert.deepEqual(flat.textFor(4, 23), "https://example.com"); 932 + }); 933 + 934 + it("tokensFor preserves whole tokens by identity", () => { 935 + const flat = flattenForScan(tokens); 936 + const covered = flat.tokensFor(0, flat.text.length); 937 + assert.deepEqual(covered.length, 3); 938 + assert(covered[1] === facetToken); 939 + }); 940 + 941 + it("tokensFor slices partially covered text tokens", () => { 942 + const flat = flattenForScan(tokens); 943 + assert.deepEqual(flat.tokensFor(0, 3), [{ type: "text", value: "see" }]); 944 + }); 945 + 946 + it("tokensFor demotes a partially covered facet to text", () => { 947 + const flat = flattenForScan(tokens); 948 + const covered = flat.tokensFor(0, 9); 949 + assert.deepEqual(covered, [ 950 + { type: "text", value: "see " }, 951 + { type: "text", value: "https" }, 952 + ]); 953 + }); 954 + 955 + it("passes zero-width inline/block tokens through in range", () => { 956 + const inlineToken = { type: "inline", node: { tag: "code" } }; 957 + const flat = flattenForScan([ 958 + { type: "text", value: "ab" }, 959 + inlineToken, 960 + { type: "text", value: "cd" }, 961 + ]); 962 + assert.deepEqual(flat.text, "abcd"); 963 + const covered = flat.tokensFor(0, 4); 964 + assert.deepEqual(covered.length, 3); 965 + assert(covered[1] === inlineToken); 966 + }); 967 + });
+45
tests/unit/specs/richTextHelpers.test.js
··· 1 + import { describe, it } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import { tokenizeRichText } from "/js/richTextHelpers.js"; 4 + 5 + describe("tokenizeRichText", () => { 6 + it("returns a single text token for plain text", () => { 7 + assert.deepEqual(tokenizeRichText({ text: "hello", facets: [] }), [ 8 + { type: "text", value: "hello" }, 9 + ]); 10 + }); 11 + 12 + it("interleaves text and facet tokens without empty text tokens", () => { 13 + const text = "#tag in front"; 14 + const facets = [ 15 + { 16 + index: { byteStart: 0, byteEnd: 4 }, 17 + features: [{ $type: "app.bsky.richtext.facet#tag", tag: "tag" }], 18 + }, 19 + ]; 20 + const tokens = tokenizeRichText({ text, facets }); 21 + assert.deepEqual(tokens.length, 2); 22 + assert.deepEqual(tokens[0].type, "facet"); 23 + assert.deepEqual(tokens[0].text, "#tag"); 24 + assert.deepEqual(tokens[1], { type: "text", value: " in front" }); 25 + }); 26 + 27 + it("drops overlapping facets like the renderer does", () => { 28 + const text = "overlap here"; 29 + const facets = [ 30 + { 31 + index: { byteStart: 0, byteEnd: 7 }, 32 + features: [{ $type: "app.bsky.richtext.facet#tag", tag: "a" }], 33 + }, 34 + { 35 + index: { byteStart: 3, byteEnd: 12 }, 36 + features: [{ $type: "app.bsky.richtext.facet#tag", tag: "b" }], 37 + }, 38 + ]; 39 + const tokens = tokenizeRichText({ text, facets }); 40 + assert.deepEqual( 41 + tokens.filter((token) => token.type === "facet").length, 42 + 1, 43 + ); 44 + }); 45 + });
+9
tests/unit/specs/templates/largePost.template.test.js
··· 20 20 21 21 const pluginService = { 22 22 getPostContextMenuItems: async () => [], 23 + $richTextTransformsVersion: { get: () => 0 }, 24 + transformRichTextTokens: async () => null, 25 + renderRichTextNodeToken: () => null, 23 26 }; 24 27 25 28 const baseProps = { ··· 63 66 ...baseProps, 64 67 }); 65 68 const container = document.createElement("div"); 69 + document.body.appendChild(container); 66 70 render(result, container); 67 71 assert(container.textContent.includes("Hello world!")); 72 + container.remove(); 68 73 }); 69 74 70 75 it("should render post action bar", () => { ··· 130 135 ...baseProps, 131 136 }); 132 137 const container = document.createElement("div"); 138 + document.body.appendChild(container); 133 139 render(result, container); 134 140 const link = container.querySelector("a[href='" + url + "']"); 135 141 assert(link !== null); 136 142 assert(link.textContent.endsWith("...")); 137 143 assert(link.textContent.length < url.length); 144 + container.remove(); 138 145 }); 139 146 }); 140 147 ··· 250 257 251 258 it("should render plugin-provided context menu items in the action bar", async () => { 252 259 const customPluginService = { 260 + ...pluginService, 253 261 getPostContextMenuItems: async () => [ 254 262 { title: "Custom plugin item", invoke: () => {} }, 255 263 { title: "Save to Notion", invoke: () => {} }, ··· 282 290 it("should invoke the plugin item callback when clicked", async () => { 283 291 let invoked = false; 284 292 const customPluginService = { 293 + ...pluginService, 285 294 getPostContextMenuItems: async () => [ 286 295 { 287 296 title: "Custom plugin item",
+9
tests/unit/specs/templates/postEmbed.template.test.js
··· 7 7 import { post } from "../../testData.js"; 8 8 import { render } from "/js/lib/lit-html.js"; 9 9 10 + const pluginService = { 11 + $richTextTransformsVersion: { get: () => 0 }, 12 + transformRichTextTokens: async () => null, 13 + renderRichTextNodeToken: () => null, 14 + }; 15 + 10 16 describe("postEmbedTemplate - images", () => { 11 17 it("should render image embed", () => { 12 18 const embed = { ··· 833 839 embed, 834 840 labels: [], 835 841 isAuthenticated: true, 842 + pluginService, 836 843 }); 837 844 const container = document.createElement("div"); 845 + document.body.appendChild(container); 838 846 render(result, container); 839 847 const link = container.querySelector(".quoted-post a[href='" + url + "']"); 840 848 assert(link !== null); 841 849 assert(link.textContent.endsWith("...")); 842 850 assert(link.textContent.length < url.length); 851 + container.remove(); 843 852 }); 844 853 }); 845 854
+72 -1
tests/unit/specs/templates/richText.template.test.js
··· 1 1 import { describe, it } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 - import { richTextTemplate } from "/js/templates/richText.template.js"; 3 + import { 4 + richTextTemplate, 5 + richTextTokensTemplate, 6 + } from "/js/templates/richText.template.js"; 4 7 import { render } from "/js/lib/lit-html.js"; 5 8 6 9 describe("richTextTemplate", () => { ··· 211 214 assert.deepEqual(richText.textContent, text); 212 215 }); 213 216 }); 217 + 218 + describe("richTextTokensTemplate", () => { 219 + function renderTokenAsElement(token) { 220 + const element = document.createElement(token.node.tag); 221 + element.textContent = token.node.text ?? ""; 222 + return element; 223 + } 224 + 225 + it("renders inline tokens inside the rich text flow", () => { 226 + const result = richTextTokensTemplate({ 227 + tokens: [ 228 + { type: "text", value: "use " }, 229 + { 230 + type: "inline", 231 + pluginId: "p1", 232 + node: { tag: "code", text: "npm i" }, 233 + }, 234 + { type: "text", value: " now" }, 235 + ], 236 + renderNodeToken: renderTokenAsElement, 237 + }); 238 + const container = document.createElement("div"); 239 + render(result, container); 240 + const richText = container.querySelector("[data-testid='rich-text']"); 241 + const code = richText.querySelector("code"); 242 + assert(code !== null); 243 + assert.deepEqual(code.textContent, "npm i"); 244 + assert.deepEqual(richText.textContent, "use npm i now"); 245 + }); 246 + 247 + it("wraps block tokens and trims the adjoining newlines", () => { 248 + const result = richTextTokensTemplate({ 249 + tokens: [ 250 + { type: "text", value: "before\n" }, 251 + { 252 + type: "block", 253 + pluginId: "p1", 254 + node: { tag: "pre", text: "const a = 1;" }, 255 + }, 256 + { type: "text", value: "\nafter" }, 257 + ], 258 + renderNodeToken: renderTokenAsElement, 259 + }); 260 + const container = document.createElement("div"); 261 + render(result, container); 262 + const richText = container.querySelector("[data-testid='rich-text']"); 263 + const block = richText.querySelector(".rich-text-block"); 264 + assert(block !== null); 265 + assert.deepEqual(block.querySelector("pre").textContent, "const a = 1;"); 266 + // The newlines flanking the block are trimmed so the pre-wrap text 267 + // doesn't add gaps around it. 268 + assert.deepEqual(richText.textContent, "before" + "const a = 1;" + "after"); 269 + }); 270 + 271 + it("skips inline/block tokens the renderer returns null for", () => { 272 + const result = richTextTokensTemplate({ 273 + tokens: [ 274 + { type: "text", value: "hello" }, 275 + { type: "inline", node: { tag: "code", text: "x" } }, 276 + ], 277 + }); 278 + const container = document.createElement("div"); 279 + render(result, container); 280 + const richText = container.querySelector("[data-testid='rich-text']"); 281 + assert.deepEqual(richText.textContent, "hello"); 282 + assert.deepEqual(richText.querySelector("code"), null); 283 + }); 284 + });
+9
tests/unit/specs/templates/smallPost.template.test.js
··· 20 20 21 21 const pluginService = { 22 22 getPostContextMenuItems: async () => [], 23 + $richTextTransformsVersion: { get: () => 0 }, 24 + transformRichTextTokens: async () => null, 25 + renderRichTextNodeToken: () => null, 23 26 }; 24 27 25 28 const baseProps = { ··· 72 75 ...baseProps, 73 76 }); 74 77 const container = document.createElement("div"); 78 + document.body.appendChild(container); 75 79 render(result, container); 76 80 assert(container.textContent.includes("Hello small world!")); 81 + container.remove(); 77 82 }); 78 83 79 84 it("should render post action bar", () => { ··· 113 118 ...baseProps, 114 119 }); 115 120 const container = document.createElement("div"); 121 + document.body.appendChild(container); 116 122 render(result, container); 117 123 const link = container.querySelector("a[href='" + url + "']"); 118 124 assert(link !== null); 119 125 assert(link.textContent.endsWith("...")); 120 126 assert(link.textContent.length < url.length); 127 + container.remove(); 121 128 }); 122 129 }); 123 130 ··· 521 528 522 529 it("should render plugin-provided context menu items in the action bar", async () => { 523 530 const customPluginService = { 531 + ...pluginService, 524 532 getPostContextMenuItems: async () => [ 525 533 { title: "Custom plugin item", invoke: () => {} }, 526 534 { title: "Save to Notion", invoke: () => {} }, ··· 553 561 it("should invoke the plugin item callback when clicked", async () => { 554 562 let invoked = false; 555 563 const customPluginService = { 564 + ...pluginService, 556 565 getPostContextMenuItems: async () => [ 557 566 { 558 567 title: "Custom plugin item",
+28
tests/unit/specs/utils.test.js
··· 8 8 formatLargeNumber, 9 9 formatFullTimestamp, 10 10 classnames, 11 + shallowEquals, 11 12 deepClone, 12 13 differenceInMinutes, 13 14 differenceInHours, ··· 259 260 assert.deepEqual(e.message, "Invalid classname definition"); 260 261 } 261 262 assert(errorThrown); 263 + }); 264 + }); 265 + 266 + describe("shallowEquals", () => { 267 + it("returns true for the same reference and for equal null/undefined", () => { 268 + const obj = { a: 1 }; 269 + assert.deepEqual(shallowEquals(obj, obj), true); 270 + assert.deepEqual(shallowEquals(null, null), true); 271 + assert.deepEqual(shallowEquals(undefined, undefined), true); 272 + }); 273 + 274 + it("returns false when either side is nullish and the other is not", () => { 275 + assert.deepEqual(shallowEquals(null, {}), false); 276 + assert.deepEqual(shallowEquals({}, null), false); 277 + }); 278 + 279 + it("compares own keys by value identity", () => { 280 + assert.deepEqual(shallowEquals({ a: 1, b: "x" }, { a: 1, b: "x" }), true); 281 + assert.deepEqual(shallowEquals({ a: 1 }, { a: 2 }), false); 282 + const nested = { c: 3 }; 283 + assert.deepEqual(shallowEquals({ a: nested }, { a: nested }), true); 284 + assert.deepEqual(shallowEquals({ a: { c: 3 } }, { a: { c: 3 } }), false); 285 + }); 286 + 287 + it("returns false when key sets differ", () => { 288 + assert.deepEqual(shallowEquals({ a: 1 }, { a: 1, b: 2 }), false); 289 + assert.deepEqual(shallowEquals({ a: 1, b: 2 }, { a: 1 }), false); 262 290 }); 263 291 }); 264 292