From 87e51844405005ad748b525d2f97d511e43add90 Mon Sep 17 00:00:00 2001 From: VAIBHAVSING Date: Thu, 12 Mar 2026 23:33:54 +0530 Subject: [PATCH 1/8] Add XCard component for automatic Twitter/X tweet embeds in markdown Standalone X/Twitter URLs on their own line in markdown content are automatically rendered as rich tweet cards at build time via a remark plugin. The component is also exported for direct use in Svelte pages. - src/lib/utils/x-api.js: tweet fetch utility using Twitter syndication API - src/lib/components/XCard.svelte: Svelte 4 component with loading/error states - src/lib/cms/remark-x-card.js: remark plugin for auto-detecting standalone X URLs - src/lib/cms/content-processor.js: integrate remark-x-card into mdsvex pipeline - src/lib/index.ts: export XCard component - templates/default/content/docs/x-card.md: documentation --- src/lib/cms/content-processor.js | 3 +- src/lib/cms/remark-x-card.js | 123 ++++ src/lib/components/XCard.svelte | 371 ++++++++++++ src/lib/utils/x-api.js | 716 +++++++++++++++++++++++ templates/default/content/docs/x-card.md | 123 ++++ 5 files changed, 1335 insertions(+), 1 deletion(-) create mode 100644 src/lib/cms/remark-x-card.js create mode 100644 src/lib/components/XCard.svelte create mode 100644 src/lib/utils/x-api.js create mode 100644 templates/default/content/docs/x-card.md diff --git a/src/lib/cms/content-processor.js b/src/lib/cms/content-processor.js index 4dfcfa60..f18e423a 100644 --- a/src/lib/cms/content-processor.js +++ b/src/lib/cms/content-processor.js @@ -8,6 +8,7 @@ import { compile } from 'mdsvex'; import matter from 'gray-matter'; import rehypeSlug from 'rehype-slug'; import remarkGfm from 'remark-gfm'; +import remarkXCard from './remark-x-card.js'; // This error check is to provide an early warning when this module is attempted to be used in the browser const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; @@ -30,7 +31,7 @@ try { // Configure mdsvex options const mdsvexOptions = { extensions: ['.md'], - remarkPlugins: [remarkGfm], + remarkPlugins: [remarkGfm, remarkXCard], rehypePlugins: [rehypeSlug], layout: null // We'll handle layout in Svelte components }; diff --git a/src/lib/cms/remark-x-card.js b/src/lib/cms/remark-x-card.js new file mode 100644 index 00000000..966844e7 --- /dev/null +++ b/src/lib/cms/remark-x-card.js @@ -0,0 +1,123 @@ +/** + * Remark plugin that detects standalone X/Twitter URLs in markdown + * and replaces them with rendered tweet cards at build time. + * + * Detection rule: Only bare X/Twitter URLs on their own line (a paragraph + * containing ONLY the URL as a single link child) become cards. URLs mixed + * with text, inside markdown links, or inline remain regular clickable links. + * + * The plugin fetches tweet data via the public syndication API and injects + * the card HTML + styles directly into the AST. No client-side JS required. + */ + +import { visit } from 'unist-util-visit'; +import { isXUrl, renderTweetCardFromUrl, getXCardStyles } from '../utils/x-api.js'; + +// Track whether styles have already been injected for this build pass +let stylesInjected = false; + +/** + * Check whether a paragraph node contains exactly one child that is an + * auto-linked X/Twitter status URL. + * + * Remark/GFM auto-links bare URLs, producing an AST like: + * paragraph -> link (url, children: [text]) + * + * We match when: + * 1. The paragraph has exactly one child + * 2. That child is a `link` node + * 3. The link URL matches an X/Twitter status pattern + * 4. The link has a single text child whose value matches the URL + * (i.e. it was auto-linked, not a user-written `[text](url)`) + * + * @param {object} node - MDAST paragraph node + * @returns {string|null} The URL if it matches, otherwise null + */ +function getStandaloneXUrl(node) { + if (!node || node.type !== 'paragraph') return null; + if (!node.children || node.children.length !== 1) return null; + + const child = node.children[0]; + if (child.type !== 'link') return null; + if (!isXUrl(child.url)) return null; + + // Ensure the link was auto-linked (text content == URL) rather than + // a user markdown link like [click here](https://x.com/...) + if ( + child.children && + child.children.length === 1 && + child.children[0].type === 'text' && + child.children[0].value === child.url + ) { + return child.url; + } + + return null; +} + +/** + * Remark plugin factory. + * @returns {(tree: object) => Promise} Transformer + */ +export default function remarkXCard() { + // Reset styles injection flag for each plugin instantiation + // (covers fresh builds) + stylesInjected = false; + + return async (tree) => { + // Collect nodes that need replacement (we can't mutate during visit) + /** @type {{ node: object, index: number, parent: object, url: string }[]} */ + const targets = []; + + visit(tree, 'paragraph', (node, index, parent) => { + const url = getStandaloneXUrl(node); + if (url) { + targets.push({ node, index, parent, url }); + } + }); + + if (targets.length === 0) return; + + // Fetch tweet data and build replacement nodes in parallel + const results = await Promise.allSettled( + targets.map(async (t) => { + const html = await renderTweetCardFromUrl(t.url); + return { ...t, html }; + }) + ); + + // Inject styles once at the top of the document if we have any cards + const hasCards = results.some((r) => r.status === 'fulfilled'); + if (hasCards && !stylesInjected) { + stylesInjected = true; + tree.children.unshift({ + type: 'html', + value: `` + }); + } + + // Replace nodes in reverse order to preserve indices. + // Because we unshifted a diff --git a/src/lib/utils/x-api.js b/src/lib/utils/x-api.js new file mode 100644 index 00000000..c8ebb969 --- /dev/null +++ b/src/lib/utils/x-api.js @@ -0,0 +1,716 @@ +/** + * X/Twitter API utilities for fetching tweet data via the syndication API. + * No API key required - uses the same public endpoint as Twitter's embed widgets. + */ + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +/** Regex matching X / Twitter status URLs */ +const X_URL_REGEX = /^https?:\/\/(?:www\.)?(?:twitter\.com|x\.com)\/([^/]+)\/status\/(\d+)/; + +/** + * Test whether a URL is an X/Twitter status link. + * @param {string} url + * @returns {boolean} + */ +export function isXUrl(url) { + return X_URL_REGEX.test(url); +} + +/** + * Extract the tweet ID from an X/Twitter status URL. + * @param {string} url + * @returns {string|null} + */ +export function extractTweetId(url) { + const match = url.match(X_URL_REGEX); + return match ? match[2] : null; +} + +/** + * Extract the username from an X/Twitter status URL. + * @param {string} url + * @returns {string|null} + */ +export function extractUsername(url) { + const match = url.match(X_URL_REGEX); + return match ? match[1] : null; +} + +// --------------------------------------------------------------------------- +// Syndication API +// --------------------------------------------------------------------------- + +/** + * Generate the token required by the syndication endpoint. + * Mirrors the algorithm used by Twitter's own embed JS. + * @param {string} id - Tweet ID + * @returns {string} + */ +function generateToken(id) { + return ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, ''); +} + +/** Feature flags expected by the syndication endpoint */ +const SYNDICATION_FEATURES = [ + 'tfw_timeline_list:', + 'tfw_follower_count_sunset:true', + 'tfw_tweet_edit_backend:on', + 'tfw_refsrc_session:on', + 'tfw_fosnr_soft_interventions_enabled:on', + 'tfw_mixed_media_15897:treatment', + 'tfw_experiments_cookie_expiration:1209600', + 'tfw_show_birdwatch_pivots_enabled:on', + 'tfw_duplicate_scribes_to_settings:on', + 'tfw_video_hls_dynamic_manifests_15082:true_bitrate', + 'tfw_legacy_timeline_sunset:true', + 'tfw_tweet_edit_frontend:on' +].join(';'); + +/** + * Fetch tweet data from Twitter's public syndication API. + * @param {string} id - Tweet ID + * @returns {Promise} Raw tweet payload + */ +export async function fetchTweet(id) { + const token = generateToken(id); + const url = `https://cdn.syndication.twimg.com/tweet-result?id=${id}&token=${token}&features=${encodeURIComponent(SYNDICATION_FEATURES)}`; + + const res = await fetch(url, { + headers: { + Accept: 'application/json' + } + }); + + if (!res.ok) { + throw new Error(`Failed to fetch tweet ${id}: ${res.status} ${res.statusText}`); + } + + return res.json(); +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +/** + * Format a large number into a human-readable compact string. + * e.g. 1234 -> "1.2K", 1234567 -> "1.2M" + * @param {number} num + * @returns {string} + */ +export function formatNumber(num) { + if (num == null) return '0'; + if (num >= 1_000_000) return (num / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'; + if (num >= 1_000) return (num / 1_000).toFixed(1).replace(/\.0$/, '') + 'K'; + return String(num); +} + +/** + * Format a date string into a human-readable format. + * e.g. "Mon Jan 01 12:00:00 +0000 2024" -> "Jan 1, 2024" + * @param {string} dateStr + * @returns {string} + */ +export function formatDate(dateStr) { + try { + const d = new Date(dateStr); + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + } catch { + return dateStr; + } +} + +/** + * Format a date string into an ISO 8601 datetime for