CI / Typecheck, test, build (pull_request) Successful in 9s
Two changes. They share the `Segment` model, which is why they arrive
together.
## Reddit
A new adapter under /reddit, plus the thread beneath the post -- on Reddit the
conversation is usually the reason the link was shared, so a viewer that showed
only the post would be showing the wrong half.
The `.json` twin of a post URL is the post and the whole first page of comments
in one response, far better than anything the page gives up, so it is the only
layer that normally runs. Reddit refuses it to a browser it has never seen and
answers with a JavaScript challenge, which any ordinary navigation solves by
itself; the adapter navigates once and retries, and the cookie left behind
serves every later post. Below that, `shreddit-comment` elements are read from
the rendered page -- flat, each carrying its own `depth`, so `treeFromDepths`
rebuilds the nesting.
`Post.comments` is a tree rather than a flat list with depths, because folding
a comment has to take everything under it along and nesting is what makes that
free. Each comment renders as a `<details open>`, so collapsing works with the
stylesheet off and from the keyboard, and a collapsed one says how many replies
it is hiding. "Collapse all" is the only part that needs the script, so it
ships hidden and appears once the script has run. What sits behind a "load
more" is not fetched -- that is a second page and often a third -- but it is
counted and said out loud rather than quietly dropped.
Four things the payloads got wrong on the first try, each now with a fixture:
`fallback_url` is the video track alone whenever `has_audio` is true, so a post
with sound has to use `hls_url` and only a silent one gets the proxied MP4;
`scrubber_media_url` looks like a poster and is a second MP4 for the timeline
thumbnails, while the still is in `preview.images`; a gallery's pictures live
in `media_metadata` keyed and unordered, with their order only in
`gallery_data`; and `replies` is the string "" rather than an object when there
are none. Comment bodies are Markdown, rendered by a new render/markdown.ts
that escapes first and then puts back only the constructs we chose to support
-- never Reddit's own `body_html`, which would mean trusting markup a stranger
caused to be generated.
An app share link (/r/<sub>/s/<code>) is a plain 301, so one request told not
to follow it is enough. The permalink it resolves to is what the copy button
hands back, since an opaque share code is a tracking parameter by another name.
## Quoted posts
Both X and Bluesky lifted the quoted post's media out and showed it as the
quoter's own, dropping the quoted words and the quoted author entirely. A quote
of a photo post therefore rendered as somebody else's picture under the wrong
name with nothing to say so, and a quote that had a picture of its own dropped
the quoted one instead -- the two could never both appear. Half the quote posts
people share are someone answering a stranger and the other half are someone
continuing a thought from an earlier post; neither reads with only one side of
it on the page.
`Segment.quoted` now carries the whole thing -- author, words, pictures, time
and a link to it -- and renders as a post inside the post. Neither payload
carries a usable address for it: X has no permalink and it is rebuilt from the
handle and `id_str`, Bluesky has an `at://` URI nobody can open and it is
rebuilt from the handle and the record key. On Bluesky the record sits at
`embed.record` for a plain quote and at `embed.record.record` when the quoting
post has media of its own, and a quote can also point at a feed, a list or a
post since deleted, which arrive in the same slot under a different `$type` --
only `app.bsky.embed.record#viewRecord` is taken.
Two things about X's text, both visible on any post and not only a quote. It
arrives pre-escaped, so an ampersand someone typed was reaching the page as the
literal `&`; it is decoded in the adapter, where the encoding comes from,
leaving the escape-on-the-way-out rule alone. And every link is a `t.co`, which
tells the reader nothing and routes them through X's click tracker to find out
-- `entities.urls` carries the real address alongside, so it is put back. The
shortlink X staples onto the end of a quote post is dropped rather than
expanded, since the post it points at is already on the page;
`display_text_range` is where that boundary is and it keeps a link the author
put there deliberately. Its indices are UTF-16 units into the escaped text, so
the slice happens before decoding and before expanding, and splitting to
codepoints first overshoots past an emoji -- checked against a post carrying
one.
A quote is context, and context that fills the screen has stopped being
context, so a segment carrying one gives up the window-filling cap on its own
media.
## Along the way
setupMedia only ever wired `document.querySelector('.media')`, the first rail
on the page. That was already wrong for a Bluesky or Threads chain with media
in more than one post, and a quoted carousel would have hit it too. It now runs
per rail.
## Verified
108 tests, typecheck and build clean. Resolved end to end against the live
platforms: Reddit self, gallery, video, link and share-link posts; X quotes
with no media, with media on the quoted side, and with media on both; Bluesky
quotes in both embed shapes, including a ten-post chain where every post quotes
a different account and all nine quotes come back under the right name.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
240 lines
8.9 KiB
TypeScript
240 lines
8.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { escapeHtml, html, raw } from '../src/render/html.ts';
|
|
import { linkify } from '../src/render/text.ts';
|
|
import { renderPost } from '../src/render/post.ts';
|
|
import { renderError } from '../src/render/error.ts';
|
|
import { oneSegment, type Media, type Post } from '../src/types.ts';
|
|
|
|
test('interpolations are escaped, Raw values are not', () => {
|
|
assert.equal(html`<p>${'<script>alert(1)</script>'}</p>`.value,
|
|
'<p><script>alert(1)</script></p>');
|
|
assert.equal(html`<p>${raw('<b>ok</b>')}</p>`.value, '<p><b>ok</b></p>');
|
|
assert.equal(html`${['a', '<b>']}`.value, 'a<b>');
|
|
assert.equal(html`${undefined}${null}${false}`.value, '');
|
|
});
|
|
|
|
test('attribute-breaking characters are escaped', () => {
|
|
assert.equal(escapeHtml(`" onload='x'`), '" onload='x'');
|
|
});
|
|
|
|
test('post text is escaped before links are made', () => {
|
|
const out = linkify('<img src=x onerror=alert(1)> https://example.com/a', 'x').value;
|
|
assert.ok(!out.includes('<img'), 'markup in post text must not survive');
|
|
assert.ok(out.includes('<a href="https://example.com/a"'));
|
|
});
|
|
|
|
test('mentions and hashtags point back at the right platform', () => {
|
|
assert.ok(linkify('hi @nasa', 'tiktok').value.includes('https://www.tiktok.com/@nasa'));
|
|
assert.ok(linkify('hi @nasa', 'bluesky').value.includes('https://bsky.app/profile/nasa'));
|
|
// Threads has no hashtag pages, so a tag stays plain text there.
|
|
assert.ok(!linkify('#moon', 'threads').value.includes('<a '));
|
|
assert.ok(linkify('#moon', 'instagram').value.includes('explore/tags/moon'));
|
|
});
|
|
|
|
test('newlines become line breaks', () => {
|
|
assert.equal(linkify('a\nb', 'x').value, 'a<br>b');
|
|
});
|
|
|
|
function post(overrides: Partial<Post> = {}): Post {
|
|
return {
|
|
platform: 'instagram',
|
|
platformLabel: 'Instagram',
|
|
originalUrl: 'https://www.instagram.com/p/ABC/',
|
|
author: { handle: '@nasa' },
|
|
textPosition: 'below',
|
|
segments: oneSegment({
|
|
text: 'caption',
|
|
media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }],
|
|
}),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function withMedia(media: Media[], overrides: Partial<Post> = {}): Post {
|
|
return post({ segments: oneSegment({ text: 'caption', media }), ...overrides });
|
|
}
|
|
|
|
test('media is proxied, never linked straight at the CDN', () => {
|
|
const page = renderPost(post());
|
|
assert.ok(!page.includes('https://cdn/1.jpg'), 'upstream URLs must not reach the page');
|
|
assert.equal((page.match(/src="\/m\//g) ?? []).length, 2);
|
|
});
|
|
|
|
test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => {
|
|
const page = renderPost(withMedia([
|
|
{ kind: 'video', url: 'https://video.bsky.app/x/playlist.m3u8', hls: true, direct: true },
|
|
]));
|
|
assert.ok(page.includes('https://video.bsky.app/x/playlist.m3u8'));
|
|
});
|
|
|
|
test('text sits below the media for Instagram and above it for X', () => {
|
|
const below = renderPost(post());
|
|
assert.ok(below.indexOf('class="media"') < below.indexOf('class="text"'));
|
|
|
|
const above = renderPost(post({ platform: 'x', platformLabel: 'X', textPosition: 'above' }));
|
|
assert.ok(above.indexOf('class="text"') < above.indexOf('class="media"'));
|
|
});
|
|
|
|
test('the layout toggle only appears when there is more than one item', () => {
|
|
assert.ok(renderPost(post()).includes('data-view="grid"'));
|
|
assert.ok(!renderPost(withMedia([{ kind: 'image', url: 'https://cdn/1.jpg' }]))
|
|
.includes('data-view="grid"'));
|
|
});
|
|
|
|
test('a video gets native controls and a source, not an iframe', () => {
|
|
const page = renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }]));
|
|
assert.ok(page.includes('<video'));
|
|
assert.ok(page.includes('controls'));
|
|
assert.ok(page.includes('playsinline'));
|
|
assert.ok(!page.includes('<iframe'));
|
|
});
|
|
|
|
test('the copy button carries the clean original URL, and so does the page text', () => {
|
|
const page = renderPost(post());
|
|
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
|
|
assert.ok(page.includes('<code>https://www.instagram.com/p/ABC/</code>'));
|
|
});
|
|
|
|
test('a failure still hands the link back', () => {
|
|
const page = renderError({
|
|
platform: 'tiktok',
|
|
platformLabel: 'TikTok',
|
|
originalUrl: 'https://www.tiktok.com/@a/video/1',
|
|
heading: 'Could not read that TikTok post',
|
|
detail: 'TikTok showed a verification puzzle instead of the post.',
|
|
});
|
|
assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"'));
|
|
assert.ok(page.includes('Open on TikTok'));
|
|
assert.ok(page.includes('verification puzzle'));
|
|
});
|
|
|
|
function redditPost(overrides: Partial<Post> = {}): Post {
|
|
return post({
|
|
platform: 'reddit',
|
|
platformLabel: 'Reddit',
|
|
originalUrl: 'https://www.reddit.com/r/aww/comments/abc/a/',
|
|
author: { handle: 'r/aww' },
|
|
textPosition: 'above',
|
|
segments: oneSegment({ title: 'A headline', media: [] }),
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
test('a post with no comments renders no comment section at all', () => {
|
|
assert.ok(!renderPost(redditPost()).includes('class="thread"'));
|
|
assert.ok(!renderPost(post()).includes('class="thread"'));
|
|
});
|
|
|
|
test('comments nest, so collapsing one takes its replies with it', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [
|
|
{
|
|
author: 'u/a',
|
|
text: 'top',
|
|
replies: [{ author: 'u/b', text: 'nested', replies: [] }],
|
|
},
|
|
],
|
|
}));
|
|
|
|
// The reply is inside the parent's <details>, not a sibling of it.
|
|
const parent = page.slice(page.indexOf('<details class="c"'));
|
|
const closing = parent.indexOf('</details>');
|
|
assert.ok(parent.slice(0, closing).includes('nested'), 'a reply must live inside its parent');
|
|
assert.ok(page.includes('<details class="c" open'), 'threads start expanded');
|
|
});
|
|
|
|
test('a comment says how much a fold would hide', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [
|
|
{
|
|
author: 'u/a',
|
|
text: 'top',
|
|
replies: [
|
|
{ author: 'u/b', text: 'one', replies: [{ author: 'u/c', text: 'two', replies: [] }] },
|
|
],
|
|
},
|
|
],
|
|
}));
|
|
assert.ok(page.includes('+2 replies'), 'the whole subtree is counted, not just direct replies');
|
|
});
|
|
|
|
test('comment text is escaped, and its markdown is rendered', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [{ author: 'u/a', text: '**bold** <img src=x onerror=alert(1)>', replies: [] }],
|
|
}));
|
|
assert.ok(!page.includes('<img src=x'), 'markup in a comment must not survive');
|
|
assert.ok(page.includes('<strong>bold</strong>'));
|
|
});
|
|
|
|
test('what the first page did not carry is counted rather than pretended away', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [{ author: 'u/a', text: 'x', replies: [], moreReplies: 3 }],
|
|
moreComments: 40,
|
|
commentCount: 443,
|
|
}));
|
|
assert.ok(page.includes('3 more replies, on Reddit'));
|
|
assert.ok(page.includes('40 more, behind'));
|
|
assert.ok(page.includes('1 of 443'));
|
|
});
|
|
|
|
function quoting(quoted: Post['segments'][number]['quoted']): Post {
|
|
return post({
|
|
platform: 'x',
|
|
platformLabel: 'X',
|
|
textPosition: 'above',
|
|
segments: oneSegment({ text: 'look at this', media: [], quoted }),
|
|
});
|
|
}
|
|
|
|
test('a quoted post is drawn with its own author, not folded under the quoter', () => {
|
|
const page = quoting({
|
|
author: { handle: '@other', displayName: 'Someone Else' },
|
|
text: 'the original words',
|
|
media: [],
|
|
url: 'https://x.com/other/status/9',
|
|
});
|
|
const html_ = renderPost(page);
|
|
|
|
assert.ok(html_.includes('class="quote"'));
|
|
assert.ok(html_.includes('@other'), 'the quoted handle must be on the page');
|
|
assert.ok(html_.includes('Someone Else'));
|
|
assert.ok(html_.includes('the original words'));
|
|
assert.ok(html_.includes('href="https://x.com/other/status/9"'));
|
|
});
|
|
|
|
test('a quoted post with no quote renders no block', () => {
|
|
assert.ok(!renderPost(quoting(undefined)).includes('class="quote"'));
|
|
});
|
|
|
|
test("the quoted post's media is proxied like any other", () => {
|
|
const html_ = renderPost(quoting({
|
|
author: { handle: '@other' },
|
|
media: [{ kind: 'image', url: 'https://pbs.twimg.com/q.jpg' }],
|
|
}));
|
|
assert.ok(!html_.includes('https://pbs.twimg.com/q.jpg'), 'upstream URLs must not reach the page');
|
|
assert.ok(html_.includes('src="/m/'));
|
|
});
|
|
|
|
test('a quoted post is separated from the words that quote it', () => {
|
|
const html_ = renderPost(quoting({
|
|
author: { handle: '@other' },
|
|
text: 'quoted words',
|
|
media: [],
|
|
}));
|
|
// The quoter's text closes before the block opens, so the two can never
|
|
// read as one paragraph by the same person.
|
|
assert.ok(html_.indexOf('look at this') < html_.indexOf('class="quote"'));
|
|
assert.ok(html_.indexOf('class="quote"') < html_.indexOf('quoted words'));
|
|
});
|
|
|
|
test('markup in a quoted post is escaped like any other stranger\'s text', () => {
|
|
const html_ = renderPost(quoting({
|
|
author: { handle: '<img src=x onerror=alert(1)>' },
|
|
text: '<script>alert(1)</script>',
|
|
media: [],
|
|
}));
|
|
assert.ok(!html_.includes('<script>alert(1)</script>'));
|
|
assert.ok(!html_.includes('<img src=x'));
|
|
});
|