diff --git a/src/App.tsx b/src/App.tsx index 83edc70..1df7671 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom"; import { BLOG_DEFAULT_DESCRIPTION, @@ -18,6 +18,16 @@ import load6 from "./load6.png"; type LoadState = "idle" | "loading" | "error"; +declare global { + interface Window { + twttr?: { + widgets?: { + load: (element?: HTMLElement | null) => void | Promise; + }; + }; + } +} + type MetaInput = { title: string; description: string; @@ -30,10 +40,59 @@ type MetaInput = { const LOADER_FRAMES = [load1, load2, load3, load4, load5, load6]; const FORCE_LOADER_PREVIEW = false; +const TWITTER_WIDGETS_SCRIPT_ID = "twitter-widgets-script"; + +let twitterWidgetsPromise: Promise | null = null; const toAbsoluteUrl = (path = "/"): string => new URL(path, BLOG_SITE_URL).toString(); const toPermalinkUrl = (path = "/"): string => new URL(path, BLOG_PERMALINK_PREFIX).toString(); +const ensureTwitterWidgets = (): Promise => { + if (window.twttr?.widgets?.load) return Promise.resolve(); + if (twitterWidgetsPromise) return twitterWidgetsPromise; + + twitterWidgetsPromise = new Promise((resolve, reject) => { + const existingScript = document.getElementById( + TWITTER_WIDGETS_SCRIPT_ID + ) as HTMLScriptElement | null; + + if (existingScript) { + existingScript.addEventListener("load", () => resolve(), { once: true }); + existingScript.addEventListener( + "error", + () => reject(new Error("Failed to load tweet embeds")), + { once: true } + ); + return; + } + + const script = document.createElement("script"); + script.id = TWITTER_WIDGETS_SCRIPT_ID; + script.src = "https://platform.twitter.com/widgets.js"; + script.async = true; + script.charset = "utf-8"; + script.addEventListener("load", () => resolve(), { once: true }); + script.addEventListener( + "error", + () => reject(new Error("Failed to load tweet embeds")), + { once: true } + ); + document.head.appendChild(script); + }); + + return twitterWidgetsPromise; +}; + +const renderTweetEmbeds = (element: HTMLElement | null): void => { + if (!element?.querySelector(".twitter-tweet")) return; + + void ensureTwitterWidgets() + .then(() => window.twttr?.widgets?.load(element)) + .catch(() => { + twitterWidgetsPromise = null; + }); +}; + const setMetaTag = (attribute: "name" | "property", key: string, content?: string): void => { if (!content) return; let tag = document.head.querySelector(`meta[${attribute}="${key}"]`); @@ -290,6 +349,7 @@ function PostPage({ const [postState, setPostState] = useState("idle"); const [postError, setPostError] = useState(""); const [showPostLoader, setShowPostLoader] = useState(false); + const articleRef = useRef(null); useEffect(() => { if (postState !== "loading") { @@ -329,6 +389,10 @@ function PostPage({ }; }, [post]); + useEffect(() => { + renderTweetEmbeds(articleRef.current); + }, [html]); + const title = postFrontmatter.title || post?.title || "Post"; const description = postFrontmatter.desc || @@ -403,6 +467,7 @@ function PostPage({ )}
diff --git a/src/config.ts b/src/config.ts index bd017a4..3e7ead9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,10 @@ export const BLOG_NAME = "hyperzine"; -export const BLOG_SITE_URL = "https://zpz3tbjvlwkkrkn2talxgib6plcj62d3r3gpjebyinbcu7oa7bjq.arweave.net"; -export const BLOG_PERMALINK_PREFIX = "https://386464538491k.arweave.net"; +export const BLOG_SITE_URL = "https://386900368387k.arweave.net/" +export const BLOG_PERMALINK_PREFIX = "https://386900368387k.arweave.net" export const BLOG_DEFAULT_DESCRIPTION = "A blog about cyberspace decentralization"; export const BLOG_TWITTER_HANDLE = ""; -export const MANIFEST_TX_ID = "BHVszJ1X-i3hnlNRflQccP_ESXZbWsSCJOfBOuNDePQ"; +export const MANIFEST_TX_ID = "EnWM5yrSyOCGV9YSUe1bUYWsq_Yxg4uEaVF9ODdbWWk"; export const ARWEAVE_GATEWAY = "https://arweave.net"; export const AO_URL = "https://push-1.forward.computer"; diff --git a/src/lib.ts b/src/lib.ts index f907c5e..2580588 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,5 +1,5 @@ import DOMPurify from "dompurify"; -import { marked } from "marked"; +import { marked, type Tokens } from "marked"; import { parse as parseYaml } from "yaml"; import { AO_PROCESS_ID, AO_URL, ARWEAVE_GATEWAY, MANIFEST_TX_ID } from "./config"; import { parseManifest, type Frontmatter, type ManifestPost } from "./types"; @@ -16,6 +16,52 @@ marked.setOptions({ breaks: false }); +const escapeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + +const getTweetEmbedUrl = (value: string): string | null => { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + + const hostname = url.hostname.toLowerCase().replace(/^www\./, ""); + const isTweetHost = + hostname === "x.com" || hostname === "twitter.com" || hostname === "mobile.twitter.com"; + if (!isTweetHost) return null; + + const [handle, statusSegment, statusId] = url.pathname.split("/").filter(Boolean); + if (!handle || !statusSegment || !statusId) return null; + if (!/^[a-z0-9_]{1,15}$/i.test(handle)) return null; + if (statusSegment !== "status" && statusSegment !== "statuses") return null; + if (!/^\d+$/.test(statusId)) return null; + + return `https://twitter.com/${handle}/status/${statusId}`; +}; + +marked.use({ + renderer: { + paragraph(this, token: Tokens.Paragraph): string { + const tweetUrl = getTweetEmbedUrl(token.text); + if (!tweetUrl) { + return `

${this.parser.parseInline(token.tokens)}

\n`; + } + + const href = escapeHtml(tweetUrl); + const link = `${href}`; + return `\n`; + } + } +}); + export const arweaveUrl = (txId: string): string => `${ARWEAVE_GATEWAY}/${txId}`; export const getReadableDate = (date?: string | null): string | null => { diff --git a/src/styles.css b/src/styles.css index 3162db0..7037d4c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -333,6 +333,15 @@ a:hover { color: #292929; } +.article .twitter-tweet { + margin: 1.6em 0; + padding: 16px 0 16px 14px; +} + +.article iframe { + max-width: 100%; +} + .article img { max-width: 100%; height: auto;