Compare commits
1
Commits
1.2.1
..
e64e83c124
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e64e83c124
|
@@ -147,16 +147,7 @@ Things worth knowing before editing:
|
|||||||
timeline thumbnails, and the still is in `preview.images`. A gallery's pictures are in
|
timeline thumbnails, and the still is in `preview.images`. A gallery's pictures are in
|
||||||
`media_metadata`, keyed and unordered; their order is only in `gallery_data`. Comment
|
`media_metadata`, keyed and unordered; their order is only in `gallery_data`. Comment
|
||||||
bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put
|
bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put
|
||||||
back the constructs we chose to support, never `body_html`. An image in a comment is
|
back the constructs we chose to support, never `body_html`.
|
||||||
written as a token rather than an address — ``,
|
|
||||||
``, `` — and in every case the token is
|
|
||||||
a key in that same comment's own `media_metadata`, so `resolveInlineImages` is one
|
|
||||||
lookup rather than three special cases. A bare `preview.redd.it` address pasted into a
|
|
||||||
comment is in there too, keyed by the id inside the URL. Prefer `s.gif` over `s.mp4`
|
|
||||||
for an animated one: a GIF moves in an `<img>` and an MP4 needs a player. An address
|
|
||||||
typed without a scheme counts as well, but only when it ends in an image extension —
|
|
||||||
the rule wants a host, a path *and* that extension, because comments are full of
|
|
||||||
dotted, slashed prose that must not turn into links.
|
|
||||||
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its
|
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its
|
||||||
payloads are full of empty stub nodes, so the finder only accepts a node with actual
|
payloads are full of empty stub nodes, so the finder only accepts a node with actual
|
||||||
candidates in it. The page ships the linked post, the author's follow-ups, other
|
candidates in it. The page ships the linked post, the author's follow-ups, other
|
||||||
|
|||||||
@@ -126,13 +126,6 @@ which is why it only appears once the script has run. What was behind a _load mo
|
|||||||
not fetched — that is a second page and often a third — but it is counted and said out
|
not fetched — that is a second page and often a third — but it is counted and said out
|
||||||
loud rather than quietly dropped.
|
loud rather than quietly dropped.
|
||||||
|
|
||||||
Pictures inside comments are shown as pictures. Reddit writes them as a token rather
|
|
||||||
than an address — a Giphy id, a subreddit emote, or an image uploaded to the comment —
|
|
||||||
and all three are looked up in the comment's own metadata to find the real file. An
|
|
||||||
image address someone simply pasted is shown too, which on Reddit is how most of them
|
|
||||||
arrive. All of it goes through the same `/m/` proxy as everything else, so reading a
|
|
||||||
comment thread never has your browser talking to Reddit.
|
|
||||||
|
|
||||||
Media never gets linked straight at a CDN. Instagram and TikTok reject requests without
|
Media never gets linked straight at a CDN. Instagram and TikTok reject requests without
|
||||||
a matching `Referer` (and sometimes cookies), and proxying keeps your browser from
|
a matching `Referer` (and sometimes cookies), and proxying keeps your browser from
|
||||||
talking to the platform at all. Every asset is registered under an opaque `/m/<id>` and
|
talking to the platform at all. Every asset is registered under an opaque `/m/<id>` and
|
||||||
|
|||||||
@@ -556,22 +556,3 @@ main { max-width: 680px; margin: 0 auto; }
|
|||||||
max-height: 45dvh;
|
max-height: 45dvh;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A picture someone put in a comment. Capped hard: it is a remark inside a
|
|
||||||
conversation, not the thing the page is about. */
|
|
||||||
.c__img {
|
|
||||||
display: block;
|
|
||||||
max-width: min(100%, 420px);
|
|
||||||
max-height: 40vh;
|
|
||||||
max-height: 40dvh;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
margin: 8px 0;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A lone image is the whole comment more often than not, so it should not
|
|
||||||
carry a paragraph's worth of space above it as well as its own. */
|
|
||||||
.c__body > p:first-child > .c__img:first-child { margin-top: 2px; }
|
|
||||||
|
|||||||
+1
-33
@@ -62,7 +62,6 @@ type Link = {
|
|||||||
type CommentData = {
|
type CommentData = {
|
||||||
author?: string;
|
author?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
media_metadata?: Record<string, MediaMeta>;
|
|
||||||
created_utc?: number;
|
created_utc?: number;
|
||||||
score?: number;
|
score?: number;
|
||||||
score_hidden?: boolean;
|
score_hidden?: boolean;
|
||||||
@@ -189,37 +188,6 @@ function bodyOf(link: Link): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The whole of ``, with the target captured. */
|
|
||||||
const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Point a comment's inline images at something fetchable.
|
|
||||||
*
|
|
||||||
* Reddit writes them as ``, ``
|
|
||||||
* or `` — a token rather than an address. In every case
|
|
||||||
* the token is a key in that same comment's `media_metadata`, which is where
|
|
||||||
* the real URL is, so one lookup covers all three and none of them needs
|
|
||||||
* naming here.
|
|
||||||
*
|
|
||||||
* A target that is already an address is not a key, so it falls through
|
|
||||||
* untouched.
|
|
||||||
*/
|
|
||||||
export function resolveInlineImages(
|
|
||||||
body: string,
|
|
||||||
meta: Record<string, MediaMeta> | undefined,
|
|
||||||
): string {
|
|
||||||
if (!meta) return body;
|
|
||||||
|
|
||||||
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
|
|
||||||
const entry = meta[token];
|
|
||||||
if (!entry || entry.status !== 'valid') return whole;
|
|
||||||
// An animated one has both; the GIF plays in an `<img>` on its own, which
|
|
||||||
// an MP4 does not.
|
|
||||||
const url = entry.s?.gif ?? entry.s?.u;
|
|
||||||
return url ? `` : whole;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commentsFrom(listing: Listing<CommentData> | undefined): {
|
export function commentsFrom(listing: Listing<CommentData> | undefined): {
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
more: number;
|
more: number;
|
||||||
@@ -241,7 +209,7 @@ export function commentsFrom(listing: Listing<CommentData> | undefined): {
|
|||||||
|
|
||||||
comments.push({
|
comments.push({
|
||||||
author: authorName(data.author),
|
author: authorName(data.author),
|
||||||
...(data.body ? { text: resolveInlineImages(data.body, data.media_metadata) } : {}),
|
...(data.body ? { text: data.body } : {}),
|
||||||
...(isoFrom(data.created_utc) ? { postedAt: isoFrom(data.created_utc) } : {}),
|
...(isoFrom(data.created_utc) ? { postedAt: isoFrom(data.created_utc) } : {}),
|
||||||
// Reddit hides the score on a new comment so an early downvote cannot
|
// Reddit hides the score on a new comment so an early downvote cannot
|
||||||
// steer the rest. Showing a placeholder 1 would be a lie.
|
// steer the rest. Showing a placeholder 1 would be a lie.
|
||||||
|
|||||||
+22
-62
@@ -13,20 +13,6 @@ import { escapeHtml, raw, type Raw } from './html.ts';
|
|||||||
|
|
||||||
const REDDIT = 'https://www.reddit.com';
|
const REDDIT = 'https://www.reddit.com';
|
||||||
|
|
||||||
/**
|
|
||||||
* How an image in a comment becomes markup.
|
|
||||||
*
|
|
||||||
* Supplied by the caller rather than decided here, because the address has to
|
|
||||||
* go through the media proxy and this file knows nothing about that. Without
|
|
||||||
* one an image degrades to a link, which is what it was before.
|
|
||||||
*/
|
|
||||||
export type ImageRenderer = (url: string, alt: string) => string;
|
|
||||||
|
|
||||||
/** Worth showing as a picture rather than as a link to one. */
|
|
||||||
function looksLikeImage(url: string): boolean {
|
|
||||||
return /\.(jpe?g|png|gif|webp|avif)(\?|$)/i.test(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Absolute http(s) only. `javascript:` and friends never become links. */
|
/** Absolute http(s) only. `javascript:` and friends never become links. */
|
||||||
function safeHref(url: string): string | undefined {
|
function safeHref(url: string): string | undefined {
|
||||||
try {
|
try {
|
||||||
@@ -60,69 +46,44 @@ function trimUrlTail(url: string): string {
|
|||||||
const INLINE = new RegExp(
|
const INLINE = new RegExp(
|
||||||
[
|
[
|
||||||
'`([^`\\n]+)`', // 1 code
|
'`([^`\\n]+)`', // 1 code
|
||||||
// Before the link rule, or the `[` of an image matches as a link and
|
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 2 label, 3 href
|
||||||
// leaves its `!` behind as text.
|
'\\*\\*([^*\\n]+)\\*\\*', // 4 strong
|
||||||
'!\\[([^\\]\\n]*)\\]\\(([^)\\s]+)\\)', // 2 alt, 3 src
|
'~~([^~\\n]+)~~', // 5 strike
|
||||||
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 4 label, 5 href
|
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 6 em with asterisks
|
||||||
'\\*\\*([^*\\n]+)\\*\\*', // 6 strong
|
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 7 em with underscores
|
||||||
'~~([^~\\n]+)~~', // 7 strike
|
'(https?://[^\\s<>]+)', // 8 bare url
|
||||||
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 8 em with asterisks
|
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 9 r/sub and u/name
|
||||||
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 9 em with underscores
|
|
||||||
'(https?://[^\\s<>]+)', // 10 bare url
|
|
||||||
// 11 the same thing with the scheme left off, which is how people type
|
|
||||||
// them. Narrow on purpose: a host, a path, and an image extension. Prose
|
|
||||||
// is full of dotted words, and `src/render/post.ts` must not become a
|
|
||||||
// link to a website in Tonga.
|
|
||||||
'(?<![\\w@/.])((?:[a-z0-9-]+\\.)+[a-z]{2,}/[^\\s<>]*\\.(?:jpe?g|png|gif|webp|avif)(?:\\?[^\\s<>]*)?)',
|
|
||||||
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 12 r/sub and u/name
|
|
||||||
].join('|'),
|
].join('|'),
|
||||||
'g',
|
'g',
|
||||||
);
|
);
|
||||||
|
|
||||||
/** One line of body text: escaped, with the inline constructs put back. */
|
/** One line of body text: escaped, with the inline constructs put back. */
|
||||||
function inline(text: string, image?: ImageRenderer): string {
|
function inline(text: string): string {
|
||||||
let out = '';
|
let out = '';
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
for (const match of text.matchAll(INLINE)) {
|
for (const match of text.matchAll(INLINE)) {
|
||||||
const [whole, code, alt, src, label, href, strong, strike, emStar, emScore, url, schemeless,
|
const [whole, code, label, href, strong, strike, emStar, emScore, url, subOrUser] = match;
|
||||||
subOrUser] = match;
|
|
||||||
out += escapeHtml(text.slice(cursor, match.index));
|
out += escapeHtml(text.slice(cursor, match.index));
|
||||||
cursor = match.index + whole.length;
|
cursor = match.index + whole.length;
|
||||||
|
|
||||||
if (code !== undefined) {
|
if (code !== undefined) {
|
||||||
out += `<code>${escapeHtml(code)}</code>`;
|
out += `<code>${escapeHtml(code)}</code>`;
|
||||||
} else if (src !== undefined) {
|
|
||||||
const safe = safeHref(src);
|
|
||||||
// Without a renderer to place it, an image is still a link to one.
|
|
||||||
out += safe ? (image ? image(safe, alt ?? '') : anchor(safe, alt || safe)) : escapeHtml(whole);
|
|
||||||
} else if (label !== undefined && href !== undefined) {
|
} else if (label !== undefined && href !== undefined) {
|
||||||
const safe = safeHref(href);
|
const safe = safeHref(href);
|
||||||
out += safe ? anchor(safe, label) : escapeHtml(whole);
|
out += safe ? anchor(safe, label) : escapeHtml(whole);
|
||||||
} else if (strong !== undefined) {
|
} else if (strong !== undefined) {
|
||||||
out += `<strong>${inline(strong, image)}</strong>`;
|
out += `<strong>${inline(strong)}</strong>`;
|
||||||
} else if (strike !== undefined) {
|
} else if (strike !== undefined) {
|
||||||
out += `<del>${inline(strike, image)}</del>`;
|
out += `<del>${inline(strike)}</del>`;
|
||||||
} else if (emStar !== undefined || emScore !== undefined) {
|
} else if (emStar !== undefined || emScore !== undefined) {
|
||||||
out += `<em>${inline(emStar ?? emScore ?? '', image)}</em>`;
|
out += `<em>${inline(emStar ?? emScore ?? '')}</em>`;
|
||||||
} else if (url !== undefined) {
|
} else if (url !== undefined) {
|
||||||
const trimmed = trimUrlTail(url);
|
const trimmed = trimUrlTail(url);
|
||||||
const safe = safeHref(trimmed);
|
const safe = safeHref(trimmed);
|
||||||
const tail = escapeHtml(url.slice(trimmed.length));
|
out += safe
|
||||||
if (!safe) {
|
? anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + escapeHtml(url.slice(trimmed.length))
|
||||||
out += escapeHtml(whole);
|
: escapeHtml(whole);
|
||||||
} else if (image && looksLikeImage(trimmed)) {
|
|
||||||
// People paste the address of a picture and mean the picture. On
|
|
||||||
// Reddit that is most of what an image in a comment even is.
|
|
||||||
out += image(safe, '') + tail;
|
|
||||||
} else {
|
|
||||||
out += anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + tail;
|
|
||||||
}
|
|
||||||
} else if (schemeless !== undefined) {
|
|
||||||
// Assumed https: every host that serves these redirects to it anyway,
|
|
||||||
// and a picture is the one thing worth guessing a scheme for.
|
|
||||||
const safe = safeHref(`https://${schemeless}`);
|
|
||||||
out += safe ? (image ? image(safe, '') : anchor(safe, schemeless)) : escapeHtml(whole);
|
|
||||||
} else if (subOrUser !== undefined) {
|
} else if (subOrUser !== undefined) {
|
||||||
const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`;
|
const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`;
|
||||||
out += anchor(`${REDDIT}${path}`, subOrUser);
|
out += anchor(`${REDDIT}${path}`, subOrUser);
|
||||||
@@ -140,7 +101,7 @@ const NUMBERED = /^\s{0,3}\d+[.)]\s+/;
|
|||||||
* from the reply to it, and a comment that loses that separation reads as
|
* from the reply to it, and a comment that loses that separation reads as
|
||||||
* though the commenter said both halves.
|
* though the commenter said both halves.
|
||||||
*/
|
*/
|
||||||
function blocks(lines: string[], image?: ImageRenderer): string {
|
function blocks(lines: string[]): string {
|
||||||
let out = '';
|
let out = '';
|
||||||
let at = 0;
|
let at = 0;
|
||||||
|
|
||||||
@@ -178,7 +139,7 @@ function blocks(lines: string[], image?: ImageRenderer): string {
|
|||||||
if (/^\s*>/.test(line)) {
|
if (/^\s*>/.test(line)) {
|
||||||
const body = takeWhile((l) => /^\s*>/.test(l));
|
const body = takeWhile((l) => /^\s*>/.test(l));
|
||||||
// Nested, so a quote of a quote keeps its shape.
|
// Nested, so a quote of a quote keeps its shape.
|
||||||
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')), image)}</blockquote>`;
|
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')))}</blockquote>`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,21 +148,20 @@ function blocks(lines: string[], image?: ImageRenderer): string {
|
|||||||
const pattern = ordered ? NUMBERED : BULLET;
|
const pattern = ordered ? NUMBERED : BULLET;
|
||||||
const items = takeWhile((l) => pattern.test(l));
|
const items = takeWhile((l) => pattern.test(l));
|
||||||
const tag = ordered ? 'ol' : 'ul';
|
const tag = ordered ? 'ol' : 'ul';
|
||||||
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''), image)}</li>`).join('')}</${tag}>`;
|
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''))}</li>`).join('')}</${tag}>`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paragraph = takeWhile(
|
const paragraph = takeWhile(
|
||||||
(l) => l.trim() !== '' && !/^\s*>/.test(l) && !BULLET.test(l) && !NUMBERED.test(l) && !/^\s*```/.test(l),
|
(l) => l.trim() !== '' && !/^\s*>/.test(l) && !BULLET.test(l) && !NUMBERED.test(l) && !/^\s*```/.test(l),
|
||||||
);
|
);
|
||||||
out += `<p>${paragraph.map((l) => inline(l, image)).join('<br>')}</p>`;
|
out += `<p>${paragraph.map((l) => inline(l)).join('<br>')}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Comment text, as safe markup. `image` places the pictures; without it
|
/** Comment text, as safe markup. */
|
||||||
* they stay links, which is what they were before. */
|
export function renderMarkdown(text: string): Raw {
|
||||||
export function renderMarkdown(text: string, image?: ImageRenderer): Raw {
|
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n')));
|
||||||
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n'), image));
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-19
@@ -145,22 +145,6 @@ function shortWhen(postedAt: string | undefined): Raw {
|
|||||||
})}</time>`;
|
})}</time>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A picture inside a comment.
|
|
||||||
*
|
|
||||||
* Through the proxy like everything else — a comment full of `preview.redd.it`
|
|
||||||
* addresses would otherwise have the viewer's browser fetch every one of them
|
|
||||||
* straight from Reddit, which is the thing this whole app exists to avoid.
|
|
||||||
*
|
|
||||||
* No dimensions to reserve space with: the size is in the payload but not in
|
|
||||||
* the Markdown, so these are capped by the stylesheet and load at whatever
|
|
||||||
* shape they are.
|
|
||||||
*/
|
|
||||||
function renderCommentImage(url: string, alt: string): string {
|
|
||||||
return html`<img class="c__img" src="${proxyUrlFor({ url })}" alt="${alt}" loading="lazy" decoding="async">`
|
|
||||||
.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything hanging off a comment, however deep. Shown only while it is
|
/** Everything hanging off a comment, however deep. Shown only while it is
|
||||||
* collapsed, so what a fold is hiding is never a mystery. */
|
* collapsed, so what a fold is hiding is never a mystery. */
|
||||||
function descendantsOf(comment: Comment): number {
|
function descendantsOf(comment: Comment): number {
|
||||||
@@ -195,9 +179,7 @@ function renderComment(comment: Comment, depth: number): Raw {
|
|||||||
}</span>`
|
}</span>`
|
||||||
: ''}
|
: ''}
|
||||||
</summary>
|
</summary>
|
||||||
${comment.text
|
${comment.text ? html`<div class="c__body">${renderMarkdown(comment.text)}</div>` : ''}
|
||||||
? html`<div class="c__body">${renderMarkdown(comment.text, renderCommentImage)}</div>`
|
|
||||||
: ''}
|
|
||||||
${comment.replies.length || comment.moreReplies
|
${comment.replies.length || comment.moreReplies
|
||||||
? html`<div class="c__replies">
|
? html`<div class="c__replies">
|
||||||
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
|
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { escapeHtml } from '../src/render/html.ts';
|
|
||||||
import { renderMarkdown } from '../src/render/markdown.ts';
|
import { renderMarkdown } from '../src/render/markdown.ts';
|
||||||
|
|
||||||
const md = (text: string): string => String(renderMarkdown(text));
|
const md = (text: string): string => String(renderMarkdown(text));
|
||||||
|
|
||||||
/** Stands in for the real one, which proxies. Escapes the way that one does:
|
|
||||||
* placing the image is the renderer's job, and so is making it safe. */
|
|
||||||
const img = (url: string, alt: string): string =>
|
|
||||||
`<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}">`;
|
|
||||||
const mdi = (text: string): string => String(renderMarkdown(text, img));
|
|
||||||
|
|
||||||
test('markup a commenter typed is text, not markup', () => {
|
test('markup a commenter typed is text, not markup', () => {
|
||||||
const out = md('<script>alert(1)</script> & "quoted"');
|
const out = md('<script>alert(1)</script> & "quoted"');
|
||||||
assert.ok(!out.includes('<script>'));
|
assert.ok(!out.includes('<script>'));
|
||||||
@@ -71,85 +64,3 @@ test('a single newline inside a paragraph is a line break, a blank line is a new
|
|||||||
assert.equal(md('one\ntwo'), '<p>one<br>two</p>');
|
assert.equal(md('one\ntwo'), '<p>one<br>two</p>');
|
||||||
assert.equal(md('one\n\ntwo'), '<p>one</p><p>two</p>');
|
assert.equal(md('one\n\ntwo'), '<p>one</p><p>two</p>');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an image is a picture when there is something to place it with', () => {
|
|
||||||
assert.equal(mdi(''),
|
|
||||||
'<p><img src="https://i.redd.it/x.jpg" alt="a cat"></p>');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an image degrades to a link when there is not', () => {
|
|
||||||
const out = md('');
|
|
||||||
assert.ok(out.includes('<a href="https://i.redd.it/x.jpg"'));
|
|
||||||
assert.ok(!out.includes('<img'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an image's `!` is not left behind as text", () => {
|
|
||||||
// The link rule would otherwise match from the `[` and strand the bang.
|
|
||||||
assert.ok(!mdi('').includes('!'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a pasted image address becomes the picture, not a link to it', () => {
|
|
||||||
// Which is how most images in a Reddit comment arrive.
|
|
||||||
const out = mdi('look\n\nhttps://preview.redd.it/abc.jpeg?width=1274&s=deadbeef');
|
|
||||||
assert.ok(out.includes('<img src="https://preview.redd.it/abc.jpeg?width=1274&s=deadbeef"'));
|
|
||||||
assert.ok(!out.includes('<a href'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a link that is not an image is still a link', () => {
|
|
||||||
const out = mdi('see https://example.com/article');
|
|
||||||
assert.ok(out.includes('<a href="https://example.com/article"'));
|
|
||||||
assert.ok(!out.includes('<img'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a sentence after a pasted image keeps its punctuation out of the address', () => {
|
|
||||||
const out = mdi('here https://i.redd.it/x.jpg.');
|
|
||||||
assert.ok(out.includes('src="https://i.redd.it/x.jpg"'), out);
|
|
||||||
assert.ok(out.endsWith('.</p>'), out);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('only http and https become pictures', () => {
|
|
||||||
const out = mdi(')');
|
|
||||||
assert.ok(!out.includes('<img'));
|
|
||||||
assert.ok(out.includes('![x]'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an image's alt text is escaped like anything else a stranger wrote", () => {
|
|
||||||
const out = mdi('');
|
|
||||||
assert.ok(!out.includes('onerror=alert(1)>'), out);
|
|
||||||
assert.ok(out.includes('"'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('images inside a quote are still placed', () => {
|
|
||||||
assert.match(mdi('> '), /<blockquote><p><img/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an image address typed without a scheme is still the picture', () => {
|
|
||||||
// Which is how people type them: no https, straight from the address bar.
|
|
||||||
const out = mdi('preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e');
|
|
||||||
assert.ok(out.includes('<img src="https://preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e"'), out);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a schemeless image address mid-sentence keeps the sentence', () => {
|
|
||||||
const out = mdi('look at i.redd.it/x.png nice one');
|
|
||||||
assert.ok(out.startsWith('<p>look at <img'), out);
|
|
||||||
assert.ok(out.endsWith(' nice one</p>'), out);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prose full of dots and slashes is not mistaken for an address', () => {
|
|
||||||
// The reason this rule insists on a host, a path and an image extension.
|
|
||||||
for (const text of [
|
|
||||||
'the file is at src/render/post.ts',
|
|
||||||
'see node_modules/foo/bar.js',
|
|
||||||
'a path like ./images/cat.jpg',
|
|
||||||
'C:/Users/x/cat.png',
|
|
||||||
'email [email protected]/nope.jpg',
|
|
||||||
'version 1.2.3/4.png',
|
|
||||||
]) {
|
|
||||||
assert.ok(!mdi(text).includes('<img'), `treated as an image: ${text}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a schemeless address that is not an image is left alone', () => {
|
|
||||||
// Guessing a scheme is worth it for a picture and not for prose.
|
|
||||||
assert.equal(mdi('example.com/article'), '<p>example.com/article</p>');
|
|
||||||
});
|
|
||||||
|
|||||||
+1
-80
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { commentsFrom, mediaFromLink, resolveInlineImages, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
|
import { commentsFrom, mediaFromLink, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
|
||||||
import { reddit } from '../src/platforms/reddit.ts';
|
import { reddit } from '../src/platforms/reddit.ts';
|
||||||
import { originalUrlFor } from '../src/platforms/index.ts';
|
import { originalUrlFor } from '../src/platforms/index.ts';
|
||||||
import { fixture } from './helpers.ts';
|
import { fixture } from './helpers.ts';
|
||||||
@@ -168,82 +168,3 @@ test('the page fallback rebuilds nesting from the depth on each comment', () =>
|
|||||||
test('a comment the page gave no text for is dropped rather than shown empty', () => {
|
test('a comment the page gave no text for is dropped rather than shown empty', () => {
|
||||||
assert.deepEqual(treeFromDepths([{ depth: 0, author: 'a', score: 1, created: '', text: '' }]), []);
|
assert.deepEqual(treeFromDepths([{ depth: 0, author: 'a', score: 1, created: '', text: '' }]), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Real shapes, captured from comments carrying each kind.
|
|
||||||
const GIPHY = {
|
|
||||||
'giphy|Ve7wX45gaOFmw8eeEM': {
|
|
||||||
status: 'valid',
|
|
||||||
e: 'AnimatedImage',
|
|
||||||
m: 'image/gif',
|
|
||||||
s: {
|
|
||||||
y: 200,
|
|
||||||
x: 304,
|
|
||||||
gif: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9',
|
|
||||||
mp4: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&format=mp4&s=a389',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const UPLOAD = {
|
|
||||||
jo8gf0ca92zd1: {
|
|
||||||
status: 'valid',
|
|
||||||
e: 'Image',
|
|
||||||
m: 'image/jpeg',
|
|
||||||
s: { y: 1270, x: 1274, u: 'https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
test('a giphy comment points at the gif rather than at a token', () => {
|
|
||||||
// `` is not an address, and renders as nothing at all until
|
|
||||||
// it is looked up in the comment's own media_metadata.
|
|
||||||
assert.equal(
|
|
||||||
resolveInlineImages('', GIPHY),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the animated form takes the gif, which plays on its own', () => {
|
|
||||||
const out = resolveInlineImages('', GIPHY);
|
|
||||||
assert.ok(out.includes('.gif?'), out);
|
|
||||||
assert.ok(!out.includes('format=mp4'), 'an mp4 would need a player to move');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an uploaded image resolves through the same lookup', () => {
|
|
||||||
// Giphy, emotes and uploads are all a token that is a key in the same map,
|
|
||||||
// so none of them needs naming.
|
|
||||||
assert.equal(
|
|
||||||
resolveInlineImages('', UPLOAD),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a target that is already an address is left alone', () => {
|
|
||||||
const already = '';
|
|
||||||
assert.equal(resolveInlineImages(already, UPLOAD), already);
|
|
||||||
assert.equal(resolveInlineImages(already, undefined), already);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a token with no entry, or a broken one, is not invented', () => {
|
|
||||||
assert.equal(resolveInlineImages('', GIPHY), '');
|
|
||||||
assert.equal(
|
|
||||||
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('inline images survive the walk into the comment tree', () => {
|
|
||||||
const { comments } = commentsFrom({
|
|
||||||
data: {
|
|
||||||
children: [{
|
|
||||||
kind: 't1',
|
|
||||||
data: {
|
|
||||||
author: 'a',
|
|
||||||
body: 'ha ',
|
|
||||||
media_metadata: GIPHY,
|
|
||||||
replies: '',
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -237,21 +237,3 @@ test('markup in a quoted post is escaped like any other stranger\'s text', () =>
|
|||||||
assert.ok(!html_.includes('<script>alert(1)</script>'));
|
assert.ok(!html_.includes('<script>alert(1)</script>'));
|
||||||
assert.ok(!html_.includes('<img src=x'));
|
assert.ok(!html_.includes('<img src=x'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a picture in a comment is proxied, and its alt text cannot break out', () => {
|
|
||||||
const page = renderPost(redditPost({
|
|
||||||
comments: [{
|
|
||||||
author: 'u/a',
|
|
||||||
text: '\n\nhttps://i.redd.it/y.png',
|
|
||||||
replies: [],
|
|
||||||
}],
|
|
||||||
}));
|
|
||||||
|
|
||||||
assert.ok(!page.includes('https://preview.redd.it/x.jpeg'), 'upstream URLs must not reach the page');
|
|
||||||
assert.ok(!page.includes('https://i.redd.it/y.png'), 'a pasted address is proxied too');
|
|
||||||
assert.equal((page.match(/<img class="c__img" src="\/m\//g) ?? []).length, 2);
|
|
||||||
// The payload survives as text inside the attribute, which is the point:
|
|
||||||
// its quotes are neutered, so it cannot close `alt="` and become markup.
|
|
||||||
assert.ok(page.includes('alt="" onerror=alert(1) x=""'), 'alt text must be escaped');
|
|
||||||
assert.ok(!/alt="" onerror/.test(page), 'the attribute must not be closable');
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user