Thread viewer for Bluesky

updated rich_text_lite to typescript

+213 -164
-161
lib/rich_text_lite.js
··· 1 - /* 2 - Extracted from https://github.com/bluesky-social/atproto/ 3 - 4 - Copyright (c) 2022-2024 Bluesky PBC, and Contributors 5 - MIT License 6 - 7 - Permission is hereby granted, free of charge, to any person obtaining a copy 8 - of this software and associated documentation files (the "Software"), to deal 9 - in the Software without restriction, including without limitation the rights 10 - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 - copies of the Software, and to permit persons to whom the Software is 12 - furnished to do so, subject to the following conditions: 13 - 14 - The above copyright notice and this permission notice shall be included in all 15 - copies or substantial portions of the Software. 16 - 17 - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 - SOFTWARE. 24 - */ 25 - 26 - // packages/api/src/rich-text/rich-text.ts 27 - 28 - class RichTextSegment { 29 - /** @param {string} text, @param {json} [facet] */ 30 - constructor(text, facet) { 31 - this.text = text; 32 - this.facet = facet; 33 - } 34 - 35 - /** @returns {object | undefined} */ 36 - get link() { 37 - return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#link'); 38 - } 39 - 40 - /** @returns {object | undefined} */ 41 - get mention() { 42 - return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#mention'); 43 - } 44 - 45 - /** @returns {object | undefined} */ 46 - get tag() { 47 - return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#tag'); 48 - } 49 - } 50 - 51 - export class RichText { 52 - /** @params {json} props */ 53 - constructor(props) { 54 - this.unicodeText = new UnicodeString(props.text); 55 - this.facets = props.facets; 56 - 57 - if (this.facets) { 58 - this.facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 59 - } 60 - } 61 - 62 - /** @returns {string} */ 63 - get text() { 64 - return this.unicodeText.toString(); 65 - } 66 - 67 - /** @returns {number} */ 68 - get length() { 69 - return this.unicodeText.length; 70 - } 71 - 72 - /** @returns {number} */ 73 - get graphemeLength() { 74 - return this.unicodeText.graphemeLength; 75 - } 76 - 77 - *segments() { 78 - const facets = this.facets || []; 79 - 80 - if (facets.length == 0) { 81 - yield new RichTextSegment(this.unicodeText.utf16); 82 - return; 83 - } 84 - 85 - let textCursor = 0; 86 - let facetCursor = 0; 87 - 88 - do { 89 - const currFacet = facets[facetCursor]; 90 - 91 - if (textCursor < currFacet.index.byteStart) { 92 - yield new RichTextSegment(this.unicodeText.slice(textCursor, currFacet.index.byteStart)); 93 - } else if (textCursor > currFacet.index.byteStart) { 94 - facetCursor++; 95 - continue; 96 - } 97 - 98 - if (currFacet.index.byteStart < currFacet.index.byteEnd) { 99 - const subtext = this.unicodeText.slice(currFacet.index.byteStart, currFacet.index.byteEnd); 100 - 101 - if (subtext.trim().length == 0) { 102 - yield new RichTextSegment(subtext); 103 - } else { 104 - yield new RichTextSegment(subtext, currFacet); 105 - } 106 - } 107 - 108 - textCursor = currFacet.index.byteEnd; 109 - facetCursor++; 110 - 111 - } while (facetCursor < facets.length); 112 - 113 - if (textCursor < this.unicodeText.length) { 114 - yield new RichTextSegment(this.unicodeText.slice(textCursor, this.unicodeText.length)); 115 - } 116 - } 117 - } 118 - 119 - 120 - // packages/api/src/rich-text/unicode.ts 121 - 122 - /** 123 - * Javascript uses utf16-encoded strings while most environments and specs 124 - * have standardized around utf8 (including JSON). 125 - * 126 - * After some lengthy debated we decided that richtext facets need to use 127 - * utf8 indices. This means we need tools to convert indices between utf8 128 - * and utf16, and that's precisely what this library handles. 129 - */ 130 - 131 - class UnicodeString { 132 - static encoder = new TextEncoder(); 133 - static decoder = new TextDecoder(); 134 - static segmenter = window.Intl && Intl.Segmenter && new Intl.Segmenter(); 135 - 136 - /** @param {string} utf16 */ 137 - constructor(utf16) { 138 - this.utf16 = utf16; 139 - this.utf8 = UnicodeString.encoder.encode(utf16); 140 - } 141 - 142 - /** @returns {number} */ 143 - get length() { 144 - return this.utf8.byteLength; 145 - } 146 - 147 - /** @returns {number} */ 148 - get graphemeLength() { 149 - return Array.from(UnicodeString.segmenter.segment(this.utf16)).length; 150 - } 151 - 152 - /** @param {number} start, @param {number} end, @returns {string} */ 153 - slice(start, end) { 154 - return UnicodeString.decoder.decode(this.utf8.slice(start, end)); 155 - } 156 - 157 - /** @returns {string} */ 158 - toString() { 159 - return this.utf16; 160 - } 161 - }
+209
lib/rich_text_lite.ts
··· 1 + /* 2 + Extracted from https://github.com/bluesky-social/atproto/ 3 + 4 + Copyright (c) 2022-2024 Bluesky PBC, and Contributors 5 + MIT License 6 + 7 + Permission is hereby granted, free of charge, to any person obtaining a copy 8 + of this software and associated documentation files (the "Software"), to deal 9 + in the Software without restriction, including without limitation the rights 10 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 + copies of the Software, and to permit persons to whom the Software is 12 + furnished to do so, subject to the following conditions: 13 + 14 + The above copyright notice and this permission notice shall be included in all 15 + copies or substantial portions of the Software. 16 + 17 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 + SOFTWARE. 24 + */ 25 + 26 + // packages/api/src/rich-text/rich-text.ts 27 + 28 + export interface ByteSlice { 29 + $type?: 'app.bsky.richtext.facet#byteSlice' 30 + byteStart: number 31 + byteEnd: number 32 + } 33 + 34 + export interface Facet { 35 + $type?: 'app.bsky.richtext.facet' 36 + index: ByteSlice 37 + features: (FacetMention | FacetLink | FacetTag | { $type: string })[] 38 + } 39 + 40 + export interface FacetTag { 41 + $type?: 'app.bsky.richtext.facet#tag' 42 + tag: string 43 + } 44 + 45 + export interface FacetLink { 46 + $type?: 'app.bsky.richtext.facet#link' 47 + uri: string 48 + } 49 + 50 + export interface FacetMention { 51 + $type?: 'app.bsky.richtext.facet#mention' 52 + did: string 53 + } 54 + 55 + export interface RichTextProps { 56 + text: string 57 + facets?: Facet[] | undefined 58 + } 59 + 60 + export class RichTextSegment { 61 + constructor(public text: string, public facet?: Facet) {} 62 + 63 + get link(): FacetLink | undefined { 64 + return this.facet?.features.find(v => v.$type === 'app.bsky.richtext.facet#link') as FacetLink 65 + } 66 + 67 + isLink() { 68 + return !!this.link 69 + } 70 + 71 + get mention(): FacetMention | undefined { 72 + return this.facet?.features.find(v => v.$type === 'app.bsky.richtext.facet#mention') as FacetMention 73 + } 74 + 75 + isMention() { 76 + return !!this.mention 77 + } 78 + 79 + get tag(): FacetTag | undefined { 80 + return this.facet?.features.find(v => v.$type === 'app.bsky.richtext.facet#tag') as FacetTag 81 + } 82 + 83 + isTag() { 84 + return !!this.tag 85 + } 86 + } 87 + 88 + export class RichText { 89 + unicodeText: UnicodeString 90 + facets?: Facet[] | undefined 91 + 92 + constructor(props: RichTextProps) { 93 + this.unicodeText = new UnicodeString(props.text); 94 + this.facets = props.facets; 95 + 96 + if (this.facets) { 97 + this.facets = this.facets.filter(facetFilter).sort(facetSort) 98 + } 99 + } 100 + 101 + get text() { 102 + return this.unicodeText.toString(); 103 + } 104 + 105 + get length() { 106 + return this.unicodeText.length; 107 + } 108 + 109 + get graphemeLength() { 110 + return this.unicodeText.graphemeLength; 111 + } 112 + 113 + *segments(): Generator<RichTextSegment, void, void> { 114 + const facets = this.facets || []; 115 + 116 + if (!facets.length) { 117 + yield new RichTextSegment(this.unicodeText.utf16); 118 + return; 119 + } 120 + 121 + let textCursor = 0; 122 + let facetCursor = 0; 123 + 124 + do { 125 + const currFacet = facets[facetCursor]; 126 + 127 + if (textCursor < currFacet.index.byteStart) { 128 + yield new RichTextSegment(this.unicodeText.slice(textCursor, currFacet.index.byteStart)); 129 + } else if (textCursor > currFacet.index.byteStart) { 130 + facetCursor++; 131 + continue; 132 + } 133 + 134 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 135 + const subtext = this.unicodeText.slice(currFacet.index.byteStart, currFacet.index.byteEnd); 136 + 137 + if (!subtext.trim()) { 138 + // dont empty string entities 139 + yield new RichTextSegment(subtext); 140 + } else { 141 + yield new RichTextSegment(subtext, currFacet); 142 + } 143 + } 144 + 145 + textCursor = currFacet.index.byteEnd; 146 + facetCursor++; 147 + } while (facetCursor < facets.length); 148 + 149 + if (textCursor < this.unicodeText.length) { 150 + yield new RichTextSegment(this.unicodeText.slice(textCursor, this.unicodeText.length)); 151 + } 152 + } 153 + } 154 + 155 + const facetSort = (a: Facet, b: Facet) => a.index.byteStart - b.index.byteStart 156 + 157 + const facetFilter = (facet: Facet) => 158 + // discard negative-length facets. zero-length facets are valid 159 + facet.index.byteStart <= facet.index.byteEnd 160 + 161 + 162 + // packages/api/src/rich-text/unicode.ts 163 + 164 + /** 165 + * Javascript uses utf16-encoded strings while most environments and specs 166 + * have standardized around utf8 (including JSON). 167 + * 168 + * After some lengthy debated we decided that richtext facets need to use 169 + * utf8 indices. This means we need tools to convert indices between utf8 170 + * and utf16, and that's precisely what this library handles. 171 + */ 172 + 173 + const encoder = new TextEncoder() 174 + const decoder = new TextDecoder() 175 + const segmenter = new Intl.Segmenter(); 176 + 177 + export const graphemeLen = (str: string): number => { 178 + return Array.from(segmenter.segment(str)).length; 179 + } 180 + 181 + export class UnicodeString { 182 + utf16: string 183 + utf8: Uint8Array 184 + private _graphemeLen?: number | undefined 185 + 186 + constructor(utf16: string) { 187 + this.utf16 = utf16; 188 + this.utf8 = encoder.encode(utf16); 189 + } 190 + 191 + get length() { 192 + return this.utf8.byteLength; 193 + } 194 + 195 + get graphemeLength() { 196 + if (!this._graphemeLen) { 197 + this._graphemeLen = graphemeLen(this.utf16) 198 + } 199 + return this._graphemeLen; 200 + } 201 + 202 + slice(start?: number, end?: number): string { 203 + return decoder.decode(this.utf8.slice(start, end)); 204 + } 205 + 206 + toString() { 207 + return this.utf16; 208 + } 209 + }
+2 -2
src/components/RichTextFromFacets.svelte
··· 1 1 <script lang="ts"> 2 - import { RichText } from '../../lib/rich_text_lite.js'; 2 + import { RichText, type Facet } from '../../lib/rich_text_lite.js'; 3 3 import { linkToHashtagPage } from '../router.js'; 4 4 5 - let { text, facets }: { text: string, facets: json[] } = $props(); 5 + let { text, facets }: { text: string, facets: Facet[] } = $props(); 6 6 7 7 let richText = $derived(new RichText({ text, facets })); 8 8 let segments = $derived(richText.segments());
+2 -1
src/components/posts/PostBody.svelte
··· 1 1 <script lang="ts"> 2 2 import { getPostContext } from './PostComponent.svelte'; 3 3 import { sanitizeHTML } from '../../utils.js'; 4 + import { type Facet } from '../../../lib/rich_text_lite.js'; 4 5 import RichTextFromFacets from '../RichTextFromFacets.svelte'; 5 6 6 7 const highlightID = 'search-results'; ··· 54 55 </div> 55 56 {:else} 56 57 <p class="body" bind:this={bodyElement}> 57 - <RichTextFromFacets text={post.text} facets={post.facets} /> 58 + <RichTextFromFacets text={post.text} facets={post.facets as Facet[]} /> 58 59 </p> 59 60 {/if} 60 61