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 @@