diff --git a/src/lib/app/i18n/en.json b/src/lib/app/i18n/en.json --- a/src/lib/app/i18n/en.json +++ b/src/lib/app/i18n/en.json @@ -556,6 +556,10 @@ "warning": "This will delete all posts. Are you sure? (The button will enable in 3 seconds.)" } }, "content": { + "spoiler": { + "show": "Show spoiler" + }, + "copyCode": "Copy code", "all": "All", "users": "Users", "posts": "Posts", @@ -777,6 +781,7 @@ "invalidURL": "Invalid URL", "generatedTitle": "Generated title and body from the URL. Do you want to undo this?", "failGenerateTitle": "There was no usable title or description of that website.", "restoredFromDraft": "Restored your post from the draft.", + "copyFailed": "Couldn't copy to clipboard.", "copied": "Copied to clipboard.", "approvedApplication": "Approved that application.", "deniedApplication": "Denied that application.", diff --git a/src/lib/app/richtext/RichText.svelte b/src/lib/app/richtext/RichText.svelte new file mode 100644 --- /dev/null +++ b/src/lib/app/richtext/RichText.svelte @@ -0,0 +1,122 @@ + + +{#snippet textSegment(segment: TextSegment)} + {#if segment.code} + {segment.text} + {:else if segment.bold || segment.italic || segment.strikethrough} + {segment.text} + {:else}{segment.text}{/if} +{/snippet} + +{#snippet linkKids(node: LinkSpan)} + {#each node.children as child, i (i)}{@render textSegment(child)}{/each} +{/snippet} + +{#snippet linkOrText(node: TextSegment | LinkSpan)} + {#if node.type === 'link'} + {@render linkKids(node)} + {:else}{@render textSegment(node)}{/if} +{/snippet} + +{#snippet inlineList(nodes: readonly Inline[])} + {#each nodes as node, i (i)} + {#if node.type === 'spoiler'} + + {#each node.children as child, j (j)}{@render linkOrText(child)}{/each} + + {:else}{@render linkOrText(node)}{/if} + {/each} +{/snippet} + +{#snippet blockNode(block: Block)} + {#if block.type === 'paragraph'} +

{@render inlineList(block.children)}

+ {:else if block.type === 'heading'} + + {@render inlineList(block.children)} + + {:else if block.type === 'codeBlock'} + + {:else if block.type === 'blockquote'} +
1 + ? `${(block.level - 1) * 0.75}rem` + : undefined} + > + {#each block.children as child, i (i)}{@render blockNode(child)}{/each} +
+ {/if} +{/snippet} + +
+ {#each blocks as block, i (i)}{@render blockNode(block)}{/each} +
diff --git a/src/lib/app/richtext/RichTextCodeBlock.svelte b/src/lib/app/richtext/RichTextCodeBlock.svelte new file mode 100644 --- /dev/null +++ b/src/lib/app/richtext/RichTextCodeBlock.svelte @@ -0,0 +1,48 @@ + + + +
+
 {language ?? ''}
+ +
+
{code}
+
diff --git a/src/lib/app/richtext/RichTextSpoiler.svelte b/src/lib/app/richtext/RichTextSpoiler.svelte new file mode 100644 --- /dev/null +++ b/src/lib/app/richtext/RichTextSpoiler.svelte @@ -0,0 +1,37 @@ + + +{#if revealed} + + {@render children()} +{:else} + + +{/if} diff --git a/src/lib/app/richtext/facets.test.ts b/src/lib/app/richtext/facets.test.ts new file mode 100644 --- /dev/null +++ b/src/lib/app/richtext/facets.test.ts @@ -0,0 +1,543 @@ +import { describe, expect, it } from 'vitest' +import { + buildRichText, + hasFacets, + MAX_FACETS, + MAX_FEATURES_PER_FACET, + type Block, + type HeadingBlock, + type BlockquoteBlock, + type CodeBlock, + type LinkSpan, + type ParagraphBlock, + type SpoilerSpan, + type TextSegment, +} from './facets' + +const NS = 'social.coves.richtext.facet' + +function facet( + byteStart: number, + byteEnd: number, + ...features: Record[] +) { + return { index: { byteStart, byteEnd }, features } +} + +const bold = { $type: `${NS}#bold` } +const italic = { $type: `${NS}#italic` } +const strikethrough = { $type: `${NS}#strikethrough` } +const code = { $type: `${NS}#code` } +const link = (uri: string) => ({ $type: `${NS}#link`, uri }) +const mention = (did: string) => ({ $type: `${NS}#mention`, did }) +const spoiler = (reason?: string) => ({ $type: `${NS}#spoiler`, reason }) +const blockquote = (level?: number) => ({ $type: `${NS}#blockquote`, level }) +const heading = (level?: number) => ({ $type: `${NS}#heading`, level }) +const codeBlock = (language?: string) => ({ + $type: `${NS}#codeBlock`, + language, +}) + +function paragraph(block: Block): ParagraphBlock { + expect(block.type).toBe('paragraph') + return block as ParagraphBlock +} + +function text(inline: unknown): TextSegment { + expect((inline as TextSegment).type).toBe('text') + return inline as TextSegment +} + +/** Concatenate all visible text in the tree, blocks joined by \n. */ +function plainText(blocks: readonly Block[]): string { + const inlineText = (nodes: readonly unknown[]): string => + nodes + .map((n) => { + const node = n as TextSegment | LinkSpan | SpoilerSpan + return node.type === 'text' ? node.text : inlineText(node.children) + }) + .join('') + return blocks + .map((b) => + b.type === 'codeBlock' + ? b.code + : b.type === 'blockquote' + ? plainText(b.children) + : inlineText(b.children), + ) + .join('\n') +} + +describe('hasFacets', () => { + it('is true for a non-empty array', () => { + expect(hasFacets([{}])).toBe(true) + }) + + it('is false for empty, missing, or non-array values', () => { + expect(hasFacets([])).toBe(false) + expect(hasFacets(undefined)).toBe(false) + expect(hasFacets(null)).toBe(false) + expect(hasFacets('nope')).toBe(false) + }) +}) + +describe('buildRichText — plain content', () => { + it('renders unfaceted content as a single paragraph', () => { + const blocks = buildRichText('hello world', []) + expect(blocks).toEqual([ + { type: 'paragraph', children: [{ type: 'text', text: 'hello world' }] }, + ]) + }) + + it('returns no blocks for empty content', () => { + expect(buildRichText('', [])).toEqual([]) + }) + + it('preserves interior newlines within a paragraph', () => { + const blocks = buildRichText('line one\n\nline two', []) + expect(text(paragraph(blocks[0]).children[0]).text).toBe( + 'line one\n\nline two', + ) + }) +}) + +describe('buildRichText — malformed facets degrade to plain text', () => { + const content = 'hello world' + + it.each([ + ['non-object facet', ['junk']], + ['missing index', [{ features: [bold] }]], + ['non-integer offsets', [facet(0.5, 5, bold)]], + [ + 'negative start', + [{ index: { byteStart: -1, byteEnd: 5 }, features: [bold] }], + ], + ['inverted range', [facet(5, 2, bold)]], + ['empty range', [facet(3, 3, bold)]], + ['start beyond content', [facet(50, 60, bold)]], + [ + 'features not an array', + [{ index: { byteStart: 0, byteEnd: 5 }, features: 'x' }], + ], + ['feature without $type', [facet(0, 5, { uri: 'https://x.test' })]], + ['unknown feature $type', [facet(0, 5, { $type: `${NS}#sparkle` })]], + ])('%s', (_name, facets) => { + expect(buildRichText(content, facets as unknown[])).toEqual([ + { type: 'paragraph', children: [{ type: 'text', text: content }] }, + ]) + }) + + it('clamps byteEnd beyond the content length', () => { + const blocks = buildRichText('hello', [facet(0, 999, bold)]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'hello', bold: true }, + ]) + }) + + it('never loses text regardless of facet garbage', () => { + const content = 'a\nb\nc — テスト 🎉' + const garbage = [ + facet(0, 4, blockquote(3), heading(2)), + facet(2, 9, codeBlock('go')), + facet(1, 3, bold, { $type: `${NS}#wat` }), + 'junk', + null, + facet(7, 100, spoiler('x')), + ] + // Full equality: block splits happen at line boundaries, so joining + // blocks with \n must reproduce the exact content. + expect(plainText(buildRichText(content, garbage as unknown[]))).toBe( + content, + ) + }) +}) + +describe('buildRichText — inline marks', () => { + it('applies bold to a sub-range', () => { + const blocks = buildRichText('hello world', [facet(6, 11, bold)]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'hello ' }, + { type: 'text', text: 'world', bold: true }, + ]) + }) + + it('combines multiple features on one facet', () => { + const blocks = buildRichText('hi', [facet(0, 2, bold, italic)]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'hi', bold: true, italic: true }, + ]) + }) + + it('splits partially overlapping marks into segments', () => { + // bold over [0,6), italic over [4,10) + const blocks = buildRichText('aaaabbcccc', [ + facet(0, 6, bold), + facet(4, 10, italic), + ]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'aaaa', bold: true }, + { type: 'text', text: 'bb', bold: true, italic: true }, + { type: 'text', text: 'cccc', italic: true }, + ]) + }) + + it('renders strikethrough and inline code marks', () => { + const blocks = buildRichText('ab cd', [ + facet(0, 2, strikethrough), + facet(3, 5, code), + ]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'ab', strikethrough: true }, + { type: 'text', text: ' ' }, + { type: 'text', text: 'cd', code: true }, + ]) + }) + + it('uses UTF-8 byte offsets, not UTF-16 indices', () => { + const content = '🎉 party' + // '🎉' is 4 bytes; bold covers 'party' at bytes 5..10 + const blocks = buildRichText(content, [facet(5, 10, bold)]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: '🎉 ' }, + { type: 'text', text: 'party', bold: true }, + ]) + }) + + it('snaps mid-codepoint boundaries inward to whole codepoints', () => { + // The facet starts inside the 4-byte emoji; it must not split the + // codepoint (which would decode to U+FFFD replacement characters). + const blocks = buildRichText('🎉 party', [facet(2, 10, bold)]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: '🎉' }, + { type: 'text', text: ' party', bold: true }, + ]) + }) + + it('drops a facet that covers no whole codepoint', () => { + // bytes 2..4 lie strictly inside the emoji at bytes 1..5 of 'a🎉b' + const blocks = buildRichText('a🎉b', [facet(2, 4, bold)]) + expect(blocks).toEqual([ + { type: 'paragraph', children: [{ type: 'text', text: 'a🎉b' }] }, + ]) + }) + + it('clips an inline mark across a heading boundary', () => { + const content = 'abc\nHead\ntail' + const blocks = buildRichText(content, [ + facet(4, 8, heading(2)), + facet(0, 8, bold), + ]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'abc', bold: true }, + ]) + const h = blocks[1] as HeadingBlock + expect(h.children).toEqual([{ type: 'text', text: 'Head', bold: true }]) + expect(text(paragraph(blocks[2]).children[0]).text).toBe('tail') + }) +}) + +describe('buildRichText — hostile-input caps', () => { + it('ignores facets beyond MAX_FACETS', () => { + const facets: unknown[] = Array.from({ length: MAX_FACETS }, () => + facet(0, 1, bold), + ) + facets.push(facet(1, 2, italic)) + const blocks = buildRichText('ab', facets) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'a', bold: true }, + { type: 'text', text: 'b' }, + ]) + }) + + it('ignores features beyond MAX_FEATURES_PER_FACET on one facet', () => { + const junk = Array.from({ length: MAX_FEATURES_PER_FACET }, (_, i) => ({ + $type: `${NS}#junk${i}`, + })) + const blocks = buildRichText('hi', [facet(0, 2, ...junk, bold)]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'hi' }, + ]) + }) +}) + +describe('buildRichText — links and mentions', () => { + it('renders an http(s) link facet as an external anchor', () => { + const blocks = buildRichText('see example.com now', [ + facet(4, 15, link('https://example.com')), + ]) + const span = paragraph(blocks[0]).children[1] as LinkSpan + expect(span).toEqual({ + type: 'link', + href: 'https://example.com', + external: true, + children: [{ type: 'text', text: 'example.com' }], + }) + }) + + it.each([['javascript:alert(1)'], ['data:text/html,x'], ['not a url']])( + 'degrades unsafe link uri %s to plain text', + (uri) => { + const blocks = buildRichText('click here', [facet(0, 10, link(uri))]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'click here' }, + ]) + }, + ) + + it('routes a user mention to /u/', () => { + const blocks = buildRichText('cc @mari.coves.dev', [ + facet(3, 18, mention('did:plc:abc123')), + ]) + const span = paragraph(blocks[0]).children[1] as LinkSpan + expect(span.type).toBe('link') + expect(span.href).toBe(`/u/${encodeURIComponent('did:plc:abc123')}`) + expect(span.external).toBe(false) + }) + + it('routes a community mention (! prefix) to /c/', () => { + const blocks = buildRichText('join !gaming.coves.dev', [ + facet(5, 22, mention('did:plc:xyz')), + ]) + const span = paragraph(blocks[0]).children[1] as LinkSpan + expect(span.href).toBe(`/c/${encodeURIComponent('did:plc:xyz')}`) + }) + + it('drops a mention with a malformed did', () => { + const blocks = buildRichText('cc @who', [facet(3, 7, mention('not-a-did'))]) + expect(paragraph(blocks[0]).children).toEqual([ + { type: 'text', text: 'cc @who' }, + ]) + }) + + it('snaps link boundaries so no replacement characters render', () => { + const blocks = buildRichText('🎉x', [facet(1, 5, link('https://x.test'))]) + const children = paragraph(blocks[0]).children + expect(text(children[0]).text).toBe('🎉') + expect((children[1] as LinkSpan).children).toEqual([ + { type: 'text', text: 'x' }, + ]) + }) + + it('keeps the first of overlapping links', () => { + const blocks = buildRichText('abcdef', [ + facet(0, 4, link('https://first.test')), + facet(2, 6, link('https://second.test')), + ]) + const children = paragraph(blocks[0]).children + expect((children[0] as LinkSpan).href).toBe('https://first.test') + expect(text(children[1]).text).toBe('ef') + }) + + it('applies marks inside link text', () => { + const blocks = buildRichText('read this', [ + facet(5, 9, link('https://x.test')), + facet(5, 9, bold), + ]) + const span = paragraph(blocks[0]).children[1] as LinkSpan + expect(span.children).toEqual([{ type: 'text', text: 'this', bold: true }]) + }) +}) + +describe('buildRichText — spoilers', () => { + it('wraps the range in a spoiler span with reason', () => { + const blocks = buildRichText('the killer is Bob', [ + facet(14, 17, spoiler('ending')), + ]) + const span = paragraph(blocks[0]).children[1] as SpoilerSpan + expect(span).toEqual({ + type: 'spoiler', + reason: 'ending', + children: [{ type: 'text', text: 'Bob' }], + }) + }) + + it('merges overlapping spoiler ranges', () => { + const blocks = buildRichText('abcdef', [ + facet(0, 3, spoiler()), + facet(2, 6, spoiler('later')), + ]) + const children = paragraph(blocks[0]).children + expect(children).toHaveLength(1) + const span = children[0] as SpoilerSpan + expect(span.type).toBe('spoiler') + expect(text(span.children[0]).text).toBe('abcdef') + }) + + it('nests links inside spoilers', () => { + const blocks = buildRichText('go here', [ + facet(0, 7, spoiler()), + facet(3, 7, link('https://x.test')), + ]) + const span = paragraph(blocks[0]).children[0] as SpoilerSpan + expect(span.children).toHaveLength(2) + expect((span.children[1] as LinkSpan).href).toBe('https://x.test') + }) +}) + +describe('buildRichText — headings', () => { + it('renders a whole-line heading with surrounding paragraphs', () => { + const content = 'intro\nBig Title\nbody text' + const blocks = buildRichText(content, [facet(6, 15, heading(2))]) + expect(blocks).toHaveLength(3) + expect(text(paragraph(blocks[0]).children[0]).text).toBe('intro') + const h = blocks[1] as HeadingBlock + expect(h.type).toBe('heading') + expect(h.level).toBe(2) + expect(text(h.children[0]).text).toBe('Big Title') + expect(text(paragraph(blocks[2]).children[0]).text).toBe('body text') + }) + + it('extends a mid-line heading range to line boundaries', () => { + const content = 'intro\nBig Title\nbody' + const blocks = buildRichText(content, [facet(10, 12, heading(1))]) + const h = blocks[1] as HeadingBlock + expect(h.type).toBe('heading') + expect(text(h.children[0]).text).toBe('Big Title') + }) + + it('degrades a heading without a level to plain text', () => { + const blocks = buildRichText('Title', [facet(0, 5, heading())]) + expect(blocks[0].type).toBe('paragraph') + }) + + it('clamps out-of-range heading levels', () => { + const blocks = buildRichText('Title', [facet(0, 5, heading(9))]) + expect((blocks[0] as HeadingBlock).level).toBe(6) + }) + + it('applies inline marks inside a heading', () => { + const blocks = buildRichText('Big Title', [ + facet(0, 9, heading(1)), + facet(0, 3, italic), + ]) + const h = blocks[0] as HeadingBlock + expect(h.children).toEqual([ + { type: 'text', text: 'Big', italic: true }, + { type: 'text', text: ' Title' }, + ]) + }) +}) + +describe('buildRichText — blockquotes', () => { + it('renders a quote block, defaulting level to 1', () => { + const content = 'said:\nquoted line\nreply' + const blocks = buildRichText(content, [facet(6, 17, blockquote())]) + const q = blocks[1] as BlockquoteBlock + expect(q.type).toBe('blockquote') + expect(q.level).toBe(1) + expect(text(paragraph(q.children[0]).children[0]).text).toBe('quoted line') + }) + + it('keeps adjacent same-level quotes as separate blocks', () => { + const content = 'first quote\nsecond quote' + const blocks = buildRichText(content, [ + facet(0, 11, blockquote(1)), + facet(12, 24, blockquote(1)), + ]) + expect(blocks.map((b) => b.type)).toEqual(['blockquote', 'blockquote']) + expect(plainText(blocks)).toBe(content) + }) + + it('renders adjacent quotes with increasing level as separate blocks', () => { + const content = 'outer quote\ninner quote\nreply' + const blocks = buildRichText(content, [ + facet(0, 11, blockquote(1)), + facet(12, 23, blockquote(2)), + ]) + expect(blocks.map((b) => b.type)).toEqual([ + 'blockquote', + 'blockquote', + 'paragraph', + ]) + expect((blocks[0] as BlockquoteBlock).level).toBe(1) + expect((blocks[1] as BlockquoteBlock).level).toBe(2) + }) + + it('drops a quote contained inside another quote', () => { + const content = 'line one\nline two' + const blocks = buildRichText(content, [ + facet(0, 17, blockquote(1)), + facet(9, 17, blockquote(2)), + ]) + expect(blocks).toHaveLength(1) + const q = blocks[0] as BlockquoteBlock + expect(plainText(q.children)).toBe('line one\nline two') + }) + + it('nests a codeBlock inside a blockquote by containment', () => { + const content = 'they said:\nfmt.Println("hi")\nend quote' + const blocks = buildRichText(content, [ + facet(0, 38, blockquote(1)), + facet(11, 28, codeBlock('go')), + ]) + expect(blocks).toHaveLength(1) + const q = blocks[0] as BlockquoteBlock + expect(q.children.map((b) => b.type)).toEqual([ + 'paragraph', + 'codeBlock', + 'paragraph', + ]) + expect((q.children[1] as CodeBlock).code).toBe('fmt.Println("hi")') + expect((q.children[1] as CodeBlock).language).toBe('go') + }) + + it('clamps quote levels above 6', () => { + const blocks = buildRichText('deep', [facet(0, 4, blockquote(9))]) + expect((blocks[0] as BlockquoteBlock).level).toBe(6) + }) +}) + +describe('buildRichText — code blocks', () => { + it('renders literal code with language and no inline processing', () => { + const content = 'before\nconst x = 1 // **not bold**\nafter' + const blocks = buildRichText(content, [ + facet(7, 34, codeBlock('ts')), + facet(19, 27, bold), + ]) + const cb = blocks[1] as CodeBlock + expect(cb.type).toBe('codeBlock') + expect(cb.language).toBe('ts') + expect(cb.code).toBe('const x = 1 // **not bold**') + }) + + it('omits a non-string language', () => { + const blocks = buildRichText('code', [ + facet(0, 4, { $type: `${NS}#codeBlock`, language: 42 }), + ]) + expect((blocks[0] as CodeBlock).language).toBeUndefined() + }) + + it('drops overlapping (non-contained) block facets', () => { + const content = 'aaa\nbbb\nccc' + const blocks = buildRichText(content, [ + facet(0, 7, codeBlock()), + facet(4, 11, heading(1)), + ]) + expect(blocks.map((b) => b.type)).toEqual(['codeBlock', 'paragraph']) + expect(plainText(blocks)).toBe('aaa\nbbb\nccc') + }) +}) + +describe('buildRichText — bridged-content shape', () => { + it('renders the canonical bridged-Lemmy structure', () => { + // heading + nested quote (disjoint increasing levels) + code block, + // mirroring the backend's TestBlockFacetConventions. + const content = + 'Release notes\nsomeone wrote this\nsomeone quoted that\nmy reply\nx = 1\ndone' + const blocks = buildRichText(content, [ + facet(0, 13, heading(2)), + facet(14, 32, blockquote(1)), + facet(33, 52, blockquote(2)), + facet(62, 67, codeBlock('python')), + ]) + expect(blocks.map((b) => b.type)).toEqual([ + 'heading', + 'blockquote', + 'blockquote', + 'paragraph', + 'codeBlock', + 'paragraph', + ]) + expect(plainText(blocks)).toBe(content) + }) +}) diff --git a/src/lib/app/richtext/facets.ts b/src/lib/app/richtext/facets.ts new file mode 100644 --- /dev/null +++ b/src/lib/app/richtext/facets.ts @@ -0,0 +1,580 @@ +/** + * Rich text facet processing for `social.coves.richtext.facet`. + * + * Facets are advisory annotations over canonical plaintext: byte ranges + * (UTF-8) carrying an open union of features. This module converts a + * content string plus its raw (untrusted) facets array into a block/inline + * render tree, applying the reader-side conventions from the lexicon: + * + * - Unknown feature `$type`s degrade to plain text (open union). + * - Malformed or out-of-range byte slices are dropped or clamped; a bad + * facet must never make content unreadable. + * - Block ranges (blockquote / heading / codeBlock) are extended to + * enclosing line boundaries when they don't span whole lines. + * - Cross-type block nesting by containment is honored (e.g. a codeBlock + * inside a blockquote); blockquote-in-blockquote containment is invalid + * and the contained quote is dropped (nested quotes are expressed as + * disjoint ranges with increasing level). + * - Byte offsets landing inside a multi-byte UTF-8 sequence are snapped + * inward to codepoint boundaries so decoding never produces U+FFFD. + * - The backend's schema caps (200 facets, 20 features each) are enforced + * here too, because old or federated records predate them and a hostile + * record must not be able to stall rendering. + */ + +import { isValidDID } from '$lib/types/atproto' + +const FACET_NS = 'social.coves.richtext.facet' + +/** Mirror of the lexicon's schema caps (facets per record, features per + * facet). Entries beyond a cap are ignored. */ +export const MAX_FACETS = 200 +export const MAX_FEATURES_PER_FACET = 20 + +export const FEATURE_TYPE = { + mention: `${FACET_NS}#mention`, + link: `${FACET_NS}#link`, + bold: `${FACET_NS}#bold`, + italic: `${FACET_NS}#italic`, + strikethrough: `${FACET_NS}#strikethrough`, + spoiler: `${FACET_NS}#spoiler`, + blockquote: `${FACET_NS}#blockquote`, + heading: `${FACET_NS}#heading`, + code: `${FACET_NS}#code`, + codeBlock: `${FACET_NS}#codeBlock`, +} as const + +// --------------------------------------------------------------------------- +// Render tree types +// --------------------------------------------------------------------------- + +/** Quote nesting depth / heading level, as constrained by the lexicon. */ +export type FacetLevel = 1 | 2 | 3 | 4 | 5 | 6 + +type Mark = 'bold' | 'italic' | 'strikethrough' | 'code' + +export interface TextSegment { + readonly type: 'text' + readonly text: string + readonly bold?: true + readonly italic?: true + readonly strikethrough?: true + readonly code?: true +} + +export interface LinkSpan { + readonly type: 'link' + /** Resolved href: an external URL, or an internal route for mentions. */ + readonly href: string + /** True for link facets (external URL), false for mention routes. */ + readonly external: boolean + readonly children: readonly TextSegment[] +} + +export interface SpoilerSpan { + readonly type: 'spoiler' + readonly reason?: string + readonly children: readonly (TextSegment | LinkSpan)[] +} + +export type Inline = TextSegment | LinkSpan | SpoilerSpan + +export interface ParagraphBlock { + readonly type: 'paragraph' + readonly children: readonly Inline[] +} + +export interface HeadingBlock { + readonly type: 'heading' + /** 1 (largest) through 6. */ + readonly level: FacetLevel + readonly children: readonly Inline[] +} + +export interface CodeBlock { + readonly type: 'codeBlock' + readonly language?: string + readonly code: string +} + +export interface BlockquoteBlock { + readonly type: 'blockquote' + /** Quote nesting depth, 1 through 6. */ + readonly level: FacetLevel + readonly children: readonly (ParagraphBlock | HeadingBlock | CodeBlock)[] +} + +export type Block = ParagraphBlock | HeadingBlock | CodeBlock | BlockquoteBlock + +/** True when a record's facets field holds at least one entry. */ +export function hasFacets(facets: unknown): facets is unknown[] { + return Array.isArray(facets) && facets.length > 0 +} + +// --------------------------------------------------------------------------- +// Facet parsing (untrusted input → normalized annotations) +// --------------------------------------------------------------------------- + +interface Range { + start: number + end: number +} + +interface MarkRange extends Range { + mark: Mark +} + +interface LinkRange extends Range { + href: string + external: boolean +} + +interface SpoilerRange extends Range { + reason?: string +} + +type BlockKind = 'blockquote' | 'heading' | 'codeBlock' + +interface BlockRange extends Range { + kind: BlockKind + level: FacetLevel + language?: string + children: BlockRange[] +} + +interface Annotations { + marks: MarkRange[] + links: LinkRange[] + spoilers: SpoilerRange[] + blocks: BlockRange[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asByteOffset(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 + ? value + : null +} + +function clampLevel( + value: unknown, + fallback: FacetLevel | null, +): FacetLevel | null { + if (value === undefined) return fallback + if (typeof value !== 'number' || !Number.isInteger(value)) return fallback + // The clamp adjacent to the cast is what makes the assertion sound. + return Math.min(6, Math.max(1, value)) as FacetLevel +} + +/** Only http(s) link targets are rendered as anchors; anything else (e.g. + * `javascript:`) degrades to plain text. */ +function safeExternalHref(uri: unknown): string | null { + if (typeof uri !== 'string') return null + try { + const url = new URL(uri) + return url.protocol === 'http:' || url.protocol === 'https:' ? uri : null + } catch { + return null + } +} + +function mentionHref(did: unknown, mentionText: string): string | null { + if (typeof did !== 'string' || !isValidDID(did)) return null + const encoded = encodeURIComponent(did) + // Community mentions conventionally use a '!' prefix in the text; user + // mentions use '@'. Route accordingly, defaulting to a user profile. + return mentionText.startsWith('!') ? `/c/${encoded}` : `/u/${encoded}` +} + +/** Snap a byte range inward to UTF-8 codepoint boundaries so no decode can + * split a multi-byte sequence (which would render U+FFFD replacement + * characters). Returns null when nothing whole remains. */ +function snapToCodepoints( + bytes: Uint8Array, + start: number, + end: number, +): Range | null { + const isContinuation = (i: number) => (bytes[i] & 0xc0) === 0x80 + while (start < end && isContinuation(start)) start++ + while (end > start && end < bytes.length && isContinuation(end)) end-- + return start < end ? { start, end } : null +} + +/** Trim newline bytes off both ends of a range, then extend it outward to + * the enclosing line boundaries, per the lexicon's reader guidance. + * Assumes LF line endings (canonical content); a range containing only + * newline bytes yields null and the block feature is dropped. */ +function extendToLineBounds(bytes: Uint8Array, range: Range): Range | null { + let { start, end } = range + while (end > start && bytes[end - 1] === 0x0a) end-- + while (start < end && bytes[start] === 0x0a) start++ + if (start >= end) return null + while (start > 0 && bytes[start - 1] !== 0x0a) start-- + while (end < bytes.length && bytes[end] !== 0x0a) end++ + return { start, end } +} + +function parseAnnotations( + bytes: Uint8Array, + decoder: TextDecoder, + facets: unknown[], +): Annotations { + const out: Annotations = { marks: [], links: [], spoilers: [], blocks: [] } + + for (const rawFacet of facets.slice(0, MAX_FACETS)) { + if (!isRecord(rawFacet)) continue + const index = rawFacet.index + if (!isRecord(index)) continue + const rawStart = asByteOffset(index.byteStart) + const rawEnd = asByteOffset(index.byteEnd) + if (rawStart === null || rawEnd === null) continue + const snapped = snapToCodepoints( + bytes, + rawStart, + Math.min(rawEnd, bytes.length), + ) + if (!snapped) continue + const { start, end } = snapped + const features = rawFacet.features + if (!Array.isArray(features)) continue + + for (const feature of features.slice(0, MAX_FEATURES_PER_FACET)) { + if (!isRecord(feature) || typeof feature.$type !== 'string') continue + switch (feature.$type) { + case FEATURE_TYPE.bold: + out.marks.push({ start, end, mark: 'bold' }) + break + case FEATURE_TYPE.italic: + out.marks.push({ start, end, mark: 'italic' }) + break + case FEATURE_TYPE.strikethrough: + out.marks.push({ start, end, mark: 'strikethrough' }) + break + case FEATURE_TYPE.code: + out.marks.push({ start, end, mark: 'code' }) + break + case FEATURE_TYPE.link: { + const href = safeExternalHref(feature.uri) + if (href) out.links.push({ start, end, href, external: true }) + break + } + case FEATURE_TYPE.mention: { + const text = decoder.decode(bytes.subarray(start, end)) + const href = mentionHref(feature.did, text) + if (href) out.links.push({ start, end, href, external: false }) + break + } + case FEATURE_TYPE.spoiler: + out.spoilers.push({ + start, + end, + reason: + typeof feature.reason === 'string' ? feature.reason : undefined, + }) + break + case FEATURE_TYPE.blockquote: { + const level = clampLevel(feature.level, 1) + const range = extendToLineBounds(bytes, { start, end }) + if (level !== null && range) { + out.blocks.push({ + ...range, + kind: 'blockquote', + level, + children: [], + }) + } + break + } + case FEATURE_TYPE.heading: { + // level is required by the lexicon; a heading without one is + // invalid and degrades to plain text. + const level = clampLevel(feature.level, null) + const range = extendToLineBounds(bytes, { start, end }) + if (level !== null && range) { + out.blocks.push({ ...range, kind: 'heading', level, children: [] }) + } + break + } + case FEATURE_TYPE.codeBlock: { + const range = extendToLineBounds(bytes, { start, end }) + if (range) { + out.blocks.push({ + ...range, + kind: 'codeBlock', + level: 1, // unused for codeBlock + language: + typeof feature.language === 'string' + ? feature.language + : undefined, + children: [], + }) + } + break + } + default: + // Open union: unknown feature types render as plain text. + break + } + } + } + + return out +} + +// --------------------------------------------------------------------------- +// Block structure +// --------------------------------------------------------------------------- + +/** + * Arrange block ranges into a top-level sequence, nesting contained + * cross-type blocks under blockquotes. Invalid structures are dropped: + * partial overlaps, quote-in-quote containment, and anything contained in a + * heading or codeBlock (their content is a single line / literal code). + * Same-range block features resolve in array order — the first wins; a + * same-range non-quote feature after a blockquote nests inside it. + */ +function arrangeBlocks(blocks: BlockRange[]): BlockRange[] { + const sorted = [...blocks].sort( + (a, b) => a.start - b.start || b.end - b.start - (a.end - a.start), + ) + const top: BlockRange[] = [] + + for (const block of sorted) { + const last = top[top.length - 1] + if (!last || block.start >= last.end) { + top.push(block) + continue + } + // Overlaps the previous block: only cross-type containment inside a + // blockquote is valid. + if ( + last.kind === 'blockquote' && + block.kind !== 'blockquote' && + block.end <= last.end + ) { + const prevChild = last.children[last.children.length - 1] + if (!prevChild || block.start >= prevChild.end) { + last.children.push(block) + } + } + } + + return top +} + +// --------------------------------------------------------------------------- +// Inline structure +// --------------------------------------------------------------------------- + +function clipRanges( + ranges: T[], + start: number, + end: number, +): T[] { + return ranges + .filter((r) => r.start < end && r.end > start) + .map((r) => ({ + ...r, + start: Math.max(r.start, start), + end: Math.min(r.end, end), + })) + .sort((a, b) => a.start - b.start) +} + +/** Merge overlapping or abutting spoiler ranges into a disjoint union; the + * earliest-starting defined reason wins. */ +function mergeSpoilers(spoilers: SpoilerRange[]): SpoilerRange[] { + const sorted = [...spoilers].sort((a, b) => a.start - b.start) + const merged: SpoilerRange[] = [] + for (const s of sorted) { + const last = merged[merged.length - 1] + if (last && s.start <= last.end) { + last.end = Math.max(last.end, s.end) + last.reason = last.reason ?? s.reason + } else { + merged.push({ ...s }) + } + } + return merged +} + +/** Input must already be sorted by start. Keeps the first of any + * overlapping ranges; a later overlapping range is dropped entirely, + * including its non-overlapping tail. */ +function dropOverlaps(sorted: T[]): T[] { + const kept: T[] = [] + for (const r of sorted) { + const last = kept[kept.length - 1] + if (!last || r.start >= last.end) kept.push(r) + } + return kept +} + +interface InlineContext { + bytes: Uint8Array + decoder: TextDecoder + marks: MarkRange[] + links: LinkRange[] + spoilers: SpoilerRange[] +} + +function textSegments( + ctx: InlineContext, + start: number, + end: number, +): TextSegment[] { + const marks = clipRanges(ctx.marks, start, end) + const boundaries = new Set([start, end]) + for (const m of marks) { + boundaries.add(m.start) + boundaries.add(m.end) + } + const points = [...boundaries].sort((a, b) => a - b) + + const segments: TextSegment[] = [] + for (let i = 0; i < points.length - 1; i++) { + const [s, e] = [points[i], points[i + 1]] + const text = ctx.decoder.decode(ctx.bytes.subarray(s, e)) + if (!text) continue + const active = marks.filter((m) => m.start <= s && m.end >= e) + const segment: { type: 'text'; text: string } & Partial< + Record + > = { type: 'text', text } + for (const m of active) segment[m.mark] = true + segments.push(segment) + } + return segments +} + +function linkSpans( + ctx: InlineContext, + start: number, + end: number, +): (TextSegment | LinkSpan)[] { + const links = dropOverlaps(clipRanges(ctx.links, start, end)) + const out: (TextSegment | LinkSpan)[] = [] + let cursor = start + for (const link of links) { + out.push(...textSegments(ctx, cursor, link.start)) + const children = textSegments(ctx, link.start, link.end) + if (children.length > 0) { + out.push({ + type: 'link', + href: link.href, + external: link.external, + children, + }) + } + cursor = link.end + } + out.push(...textSegments(ctx, cursor, end)) + return out +} + +function inlines(ctx: InlineContext, start: number, end: number): Inline[] { + const spoilers = clipRanges(ctx.spoilers, start, end) + const out: Inline[] = [] + let cursor = start + for (const spoiler of spoilers) { + out.push(...linkSpans(ctx, cursor, spoiler.start)) + const children = linkSpans(ctx, spoiler.start, spoiler.end) + if (children.length > 0) { + out.push({ type: 'spoiler', reason: spoiler.reason, children }) + } + cursor = spoiler.end + } + out.push(...linkSpans(ctx, cursor, end)) + return out +} + +// --------------------------------------------------------------------------- +// Tree assembly +// --------------------------------------------------------------------------- + +function paragraphsForGap( + ctx: InlineContext, + start: number, + end: number, +): ParagraphBlock[] { + // Trim the newline separators left over at block boundaries; interior + // newlines are preserved (rendered with pre-wrap). + let s = start + let e = end + while (s < e && ctx.bytes[s] === 0x0a) s++ + while (e > s && ctx.bytes[e - 1] === 0x0a) e-- + if (s >= e) return [] + const children = inlines(ctx, s, e) + return children.length > 0 ? [{ type: 'paragraph', children }] : [] +} + +function blocksForRange( + ctx: InlineContext, + start: number, + end: number, + blockRanges: BlockRange[], +): Block[] { + const out: Block[] = [] + let cursor = start + for (const block of blockRanges) { + out.push(...paragraphsForGap(ctx, cursor, block.start)) + switch (block.kind) { + case 'heading': + out.push({ + type: 'heading', + level: block.level, + children: inlines(ctx, block.start, block.end), + }) + break + case 'codeBlock': + out.push({ + type: 'codeBlock', + language: block.language, + code: ctx.decoder.decode(ctx.bytes.subarray(block.start, block.end)), + }) + break + case 'blockquote': { + const children = blocksForRange( + ctx, + block.start, + block.end, + block.children, + ).filter( + (b): b is ParagraphBlock | HeadingBlock | CodeBlock => + b.type !== 'blockquote', + ) + out.push({ type: 'blockquote', level: block.level, children }) + break + } + } + cursor = block.end + } + out.push(...paragraphsForGap(ctx, cursor, end)) + return out +} + +/** + * Build the render tree for `content` annotated by `facets`. + * + * `facets` is accepted as `unknown[]` because records arrive from the + * network unvalidated; malformed entries are silently ignored (the text + * must stay readable no matter what the facets say). + */ +export function buildRichText( + content: string, + facets: unknown[], +): readonly Block[] { + const bytes = new TextEncoder().encode(content) + const decoder = new TextDecoder() + const annotations = parseAnnotations(bytes, decoder, facets) + const ctx: InlineContext = { + bytes, + decoder, + marks: annotations.marks, + links: annotations.links, + spoilers: mergeSpoilers(annotations.spoilers), + } + return blocksForRange(ctx, 0, bytes.length, arrangeBlocks(annotations.blocks)) +} diff --git a/src/lib/feature/comment/Comment.svelte b/src/lib/feature/comment/Comment.svelte --- a/src/lib/feature/comment/Comment.svelte +++ b/src/lib/feature/comment/Comment.svelte @@ -8,6 +8,8 @@ import { profile } from '$lib/app/auth.svelte' import { errorMessage } from '$lib/app/error' import { t } from '$lib/app/i18n' import Markdown from '$lib/app/markdown/Markdown.svelte' + import RichText from '$lib/app/richtext/RichText.svelte' + import { hasFacets } from '$lib/app/richtext/facets' import { settings } from '$lib/app/settings.svelte' import type { PostLinkRef } from '$lib/feature/post' import { publishedToDate } from '$lib/ui/util/date' @@ -64,6 +66,13 @@ // Stable anchor id (`comment-`) so permalinks can deep-link to this // comment via a `#comment-` URL fragment. const domId = $derived(`comment-${parseAtUri(node.comment.uri).rkey}`) + // Shared by the RichText and Markdown body branches. + const bodyClass = $derived([ + 'text-[15px] sm:text-base text-slate-700 dark:text-zinc-300 *:leading-[1.6] break-words space-y-3', + page.url.hash.slice(1) === domId && + 'material-info px-3 py-1.5 rounded-xl max-w-max', + ]) + async function save() { if (node.comment.isDeleted) return if (!profile.current?.jwt) { @@ -79,18 +88,27 @@ editingLoad = true try { // The backend performs a full record replace on update, so pass the - // existing rich-text fields through to avoid erasing them. Caveat: - // facet byte offsets may be stale relative to the edited content. + // existing rich-text fields through to avoid erasing them. Facets are + // byte-offset annotations over the exact content, so they only survive + // an edit that leaves the content unchanged — otherwise stale offsets + // would silently corrupt the annotations. This guard is the real + // protection: the backend only rejects offsets that land outside the + // new content, not stale ones that still fit within it. const record = node.comment.record + const keptFacets = + newComment === record.content ? record.facets : undefined const response = await coves().updateComment({ uri: node.comment.uri, content: newComment, - facets: record.facets, + facets: keptFacets, embed: record.embed, langs: record.langs, labels: record.labels, }) + // Mirror exactly what the server now stores, so the re-render can't + // apply old byte offsets to the new content. node.comment.record.content = newComment + node.comment.record.facets = keptFacets node.comment.cid = response.cid editing = false } catch (err) { @@ -244,15 +262,20 @@ class={[ 'flex flex-col whitespace-pre-wrap max-w-full gap-1 mt-1 relative w-full', ]} > - + {#if hasFacets(node.comment.record.facets)} + + {:else} + + {/if} {#if actions} diff --git a/src/lib/feature/post/PostBody.svelte b/src/lib/feature/post/PostBody.svelte --- a/src/lib/feature/post/PostBody.svelte +++ b/src/lib/feature/post/PostBody.svelte @@ -1,5 +1,7 @@