import { Tweet } from './twitter.ts'; class SafeString { constructor(readonly raw: string) {} get length(): number { return this.raw.length; } toString(): string { return this.raw; } toJSON(): string { return this.raw; } } type StringLike = string | SafeString; function escapeHTML(unsafe: string): StringLike { return new SafeString( unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") ); } function unescapeHTML(safe: string): string { return safe .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function joinChildren(children: StringLike[]): SafeString { return new SafeString(children .map(child => typeof child === 'string' ? escapeHTML(child) : child) .join('') ); } function tag(tag: string, attributes: Record = {}, children: StringLike | StringLike[] = []): SafeString { const attrs = Object.entries(attributes).map(([ key, value ]) => { return ` ${key}="${escapeHTML(value)}"`; }).join(''); if (children.length === 0) { return new SafeString(`<${tag}${attrs} />`); } else { const childrenArray = Array.isArray(children) ? children : [children]; const normalizedChildren = joinChildren(childrenArray); return new SafeString(`<${tag}${attrs}>${normalizedChildren}`); } } function buildTwitterUrl(url: string): string { return new URL(url, 'https://twitter.com').toString(); } function buildTweetUrl(tweet: Tweet): string { return buildTwitterUrl(`/${tweet.user.screen_name}/status/${tweet.id_str}`); } function buildProxyUrl(url: string): string { const search = new URLSearchParams({ target: url }).toString(); return `/__proxy?${search}`; } function formatPlainText(text: string): SafeString { // apparently twitter already escapes the text for you return new SafeString(text.replace(/\n/g, "
")); } class TextFormatter { private splices: { text: StringLike, indices: [number, number] }[]; private media: { type: 'video' | 'img', url: string, loop: boolean, link?: string }[]; private characters: string[]; constructor(readonly tweet: Tweet, readonly useProxy: boolean) { this.characters = [...tweet.full_text]; this.splices = []; this.media = []; for (const { indices, text } of tweet.entities.hashtags) { const url = buildTwitterUrl(`/hashtag/${text}`); this.splices.push({ indices, text: tag('a', { href: url }, `#${text}`), }); } const quoteLink = tweet.quoted_status ? buildTweetUrl(tweet.quoted_status) : undefined; for (const link of tweet.entities.urls) { if (quoteLink && link.expanded_url === quoteLink) { // skip links for quoted status that are displayed inline continue; } const url = new URL(link.expanded_url).toString(); this.splices.push({ indices: link.indices, text: tag('a', { href: url }, link.display_url), }); } for (const { indices, name, screen_name } of tweet.entities.user_mentions) { const url = buildTwitterUrl(`/${screen_name}`); this.splices.push({ indices: indices, text: tag('a', { href: url, title: name }, `@${screen_name}`), }); } const media = tweet.extended_entities?.media ?? []; for (const item of media) { if (item.type === 'photo') { const url = new URL(item.media_url_https).toString(); this.media.push({ type: 'img', url, loop: false }); } else if (item.video_info !== undefined) { let max = -1; let maxUrl: string | undefined = undefined; for (const variant of item.video_info.variants) { if (variant.bitrate === undefined) { continue; } if (variant.bitrate > max) { max = variant.bitrate; maxUrl = variant.url; } } const loop = item.type === 'animated_gif'; if (maxUrl !== undefined) { const url = new URL(maxUrl).toString(); this.media.push({ type: 'video', url, loop }); } else { const url = new URL(item.media_url_https).toString(); this.media.push({ type: 'img', url, link: item.expanded_url, loop }); } } } } getRange(start: number, end?: number): string { const max = this.tweet.display_text_range[1]; return this.characters.slice(start, end ?? max).join(''); } headerHTML(): SafeString { const date = new Date(this.tweet.created_at); const dateOptions = { weekday: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short', } as const; const imageUrl = new URL(this.tweet.user.profile_image_url_https).toString(); const imageSrc = this.useProxy ? buildProxyUrl(imageUrl) : imageUrl; const profileUrl = buildTwitterUrl(`/${this.tweet.user.screen_name}`); const tweetUrl = buildTweetUrl(this.tweet); const html = [ tag('img', { loading: 'lazy', src: imageSrc, height: '24px', width: '24px' }), ' ', tag('strong', {}, this.tweet.user.name), ' ', tag('a', { href: profileUrl }, `@${this.tweet.user.screen_name}`), tag('br'), 'Posted ', tag('a', { href: tweetUrl }, date.toLocaleString(this.tweet.lang, dateOptions)), ]; return joinChildren(html); } bodyHTML(): SafeString { const splices = this.splices .filter(({ indices }) => indices[0] !== undefined && indices[1] !== undefined) .sort((a, b) => a.indices[0] - b.indices[0]); let index = 0; const html: StringLike[] = []; for (const { text, indices } of splices) { const start = index; const end = indices[0]; html.push(formatPlainText(this.getRange(start, end))); html.push(text); index = indices[1]; } html.push(formatPlainText(this.getRange(index))); for (const { type, url, link, loop } of this.media) { html.push(tag('br')); html.push(tag('br')); const src = this.useProxy ? buildProxyUrl(url) : url; if (type === 'img') { html.push(tag('a', { href: link ?? url }, [ tag('img', { loading: 'lazy', src }), ])); } else if (type === 'video') { html.push(tag('video', { controls: '', src, loop: `${loop}` })); } } return joinChildren(html); } toHTML(): SafeString { return joinChildren([ this.headerHTML(), tag('br'), this.bodyHTML() ]); } } const STYLES = ` body > div { margin: 10px; padding: 10px; border: solid 1px gray; border-radius: 10px; } blockquote { padding: 10px; border: solid 1px lightgray; border-radius: 10px; } div { max-width: 600px; } img, video { max-width: 100%; } `; export function timelineAsHTML(tweets: Tweet[]): string { const body = tweets.map(tweet => { const displayTweet = tweet.retweeted_status ?? tweet; const children: StringLike[] = []; children.push(new TextFormatter(displayTweet, true).toHTML()); const quoteTweet = displayTweet.quoted_status; if (quoteTweet !== undefined) { children.push(tag('blockquote', {}, new TextFormatter(quoteTweet, true).toHTML())); } return tag('div', {}, children); }).join('\n'); return ` ${body} `; } export function timelineAsJSON(username: string, tweets: Tweet[]): string { const items = tweets.map(tweet => { const displayTweet = tweet.retweeted_status ?? tweet; const children: StringLike[] = []; children.push(new TextFormatter(displayTweet, false).bodyHTML()); const quoteTweet = displayTweet.quoted_status; if (quoteTweet !== undefined) { children.push(tag('blockquote', {}, [ new TextFormatter(quoteTweet, false).toHTML(), ])); } const html = joinChildren(children); return { id: tweet.id_str, title: unescapeHTML(tweet.full_text.split('\n')[0]), url: buildTweetUrl(tweet), content_html: html, date_published: new Date(tweet.created_at).toISOString(), authors: [{ name: `${displayTweet.user.name} - @${displayTweet.user.screen_name}`, url: buildTwitterUrl(`/${displayTweet.user.screen_name}`), avatar: displayTweet.user.profile_image_url_https, }], }; }); return JSON.stringify({ version: '1.1', title: `Twitter @${username}`, home_page_url: buildTwitterUrl(`/${username}`), items, }); }