From db8c82a9a7328ddeff74c4edee9bf2f1aa07a846 Mon Sep 17 00:00:00 2001 From: xylophonez Date: Thu, 2 Apr 2026 13:14:20 +0100 Subject: [PATCH] feat: metadata --- package.json | 2 +- scripts/prerender-meta.mjs | 255 +++++++++++++++++++++++++++++++++++++ src/App.tsx | 144 ++++++++++++++++++--- src/config.ts | 6 +- 4 files changed, 388 insertions(+), 19 deletions(-) create mode 100644 scripts/prerender-meta.mjs diff --git a/package.json b/package.json index e2b5e99..9c2b4bc 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && node scripts/prerender-meta.mjs", "preview": "vite preview", "wallet:new": "node scripts/generate-wallet.mjs", "deploy": "node scripts/deploy.mjs --app-name=hyperzine --app-version=1.0.0", diff --git a/scripts/prerender-meta.mjs b/scripts/prerender-meta.mjs new file mode 100644 index 0000000..90c5561 --- /dev/null +++ b/scripts/prerender-meta.mjs @@ -0,0 +1,255 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +const ROOT = process.cwd(); +const DIST_DIR = path.join(ROOT, "dist"); +const DIST_INDEX_HTML = path.join(DIST_DIR, "index.html"); +const SRC_CONFIG = path.join(ROOT, "src", "config.ts"); + +const DEFAULTS = { + BLOG_NAME: "Hyperzine", + BLOG_SITE_URL: "https://example.com", + BLOG_DEFAULT_DESCRIPTION: "Hyperzine", + MANIFEST_TX_ID: "" +}; + +const asObject = (value) => (typeof value === "object" && value !== null ? value : null); +const asString = (value) => (typeof value === "string" && value.trim().length > 0 ? value : ""); + +const htmlEscape = (value) => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + +const normalizeSlashPath = (input) => { + const cleaned = input.trim().replace(/^\/+|\/+$/g, ""); + return cleaned ? `/${cleaned}` : "/"; +}; + +const toAbsoluteUrl = (siteUrl, inputPath) => new URL(normalizeSlashPath(inputPath), siteUrl).toString(); + +const parseConfig = async () => { + let source = ""; + try { + source = await fs.readFile(SRC_CONFIG, "utf8"); + } catch { + return DEFAULTS; + } + + const pick = (key) => { + const match = source.match( + new RegExp(`export\\s+const\\s+${key}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "m") + ); + const value = match?.[1] || match?.[2]; + return value ?? DEFAULTS[key]; + }; + + return { + BLOG_NAME: pick("BLOG_NAME"), + BLOG_SITE_URL: pick("BLOG_SITE_URL"), + BLOG_DEFAULT_DESCRIPTION: pick("BLOG_DEFAULT_DESCRIPTION"), + MANIFEST_TX_ID: pick("MANIFEST_TX_ID") + }; +}; + +const parseFrontmatter = (input) => { + const obj = asObject(input); + if (!obj) return {}; + const bannerValue = obj.banner; + const bannerObj = asObject(bannerValue); + const banner = + asString(bannerValue) || + asString(bannerObj?.txId) || + asString(bannerObj?.id) || + asString(bannerObj?.src) || + asString(bannerObj?.url); + + return { + title: asString(obj.title), + desc: asString(obj.desc), + description: asString(obj.description), + excerpt: asString(obj.excerpt), + slug: asString(obj.slug), + banner, + date: asString(obj.date), + updated: asString(obj.updated) + }; +}; + +const parseManifest = (input) => { + const root = asObject(input); + const posts = Array.isArray(root?.posts) ? root.posts : []; + return posts + .map((entry) => { + const post = asObject(entry); + if (!post) return null; + const frontmatter = parseFrontmatter(post.frontmatter); + const bannerTxIdValue = post.bannerTxId; + const bannerTxIdObject = asObject(bannerTxIdValue); + const bannerTxId = + asString(bannerTxIdValue) || + asString(bannerTxIdObject?.txId) || + asString(bannerTxIdObject?.id) || + asString(bannerTxIdObject?.src) || + asString(bannerTxIdObject?.url); + const slug = asString(post.slug) || frontmatter.slug; + const postTxId = asString(post.postTxId); + if (!slug || !postTxId) return null; + return { + slug, + title: asString(post.title) || frontmatter.title || "Untitled", + description: + asString(post.description) || frontmatter.desc || frontmatter.description || "", + excerpt: asString(post.excerpt) || frontmatter.excerpt || "", + bannerTxId: bannerTxId || frontmatter.banner || "", + publishedAt: asString(post.publishedAt) || frontmatter.date || "", + updated: asString(post.updated) || frontmatter.updated || "" + }; + }) + .filter(Boolean) + .sort((a, b) => { + const aDate = a.publishedAt || ""; + const bDate = b.publishedAt || ""; + return aDate < bDate ? 1 : -1; + }); +}; + +const fetchManifestPosts = async (manifestTxId) => { + if (!manifestTxId) return []; + const url = `https://arweave.net/${manifestTxId}`; + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Manifest fetch failed (${response.status})`); + const payload = await response.json(); + return parseManifest(payload); +}; + +const buildMetaTags = ({ + title, + description, + canonicalUrl, + siteName, + imageUrl, + type = "website", + publishedTime, + modifiedTime +}) => { + const tags = [ + `${htmlEscape(title)}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `` + ]; + + if (imageUrl) { + tags.push(``); + tags.push(``); + } + + if (type === "article" && publishedTime) { + tags.push(``); + } + + if (type === "article" && modifiedTime) { + tags.push(``); + } + + return tags.join("\n "); +}; + +const injectMeta = (html, metaBlock) => { + const marker = ""; + if (html.includes(marker)) { + return html.replace(marker, `${marker}\n ${metaBlock}`); + } + + const stripped = html + .replace(/[\s\S]*?<\/title>/gi, "") + .replace(/<link[^>]+rel=["']canonical["'][^>]*>/gi, "") + .replace(/<meta[^>]+(?:name|property)=["'](?:description|og:[^"']+|twitter:[^"']+|article:[^"']+)["'][^>]*>/gi, ""); + + return stripped.replace("</head>", ` ${metaBlock}\n </head>`); +}; + +const normalizeAssetPaths = (html) => html.replaceAll('src="./assets/', 'src="/assets/').replaceAll('href="./assets/', 'href="/assets/'); + +const writeRouteHtml = async (baseHtml, routePath, metaBlock) => { + const routeDir = routePath === "/" ? DIST_DIR : path.join(DIST_DIR, routePath.slice(1)); + const routeHtmlPath = path.join(routeDir, "index.html"); + await fs.mkdir(routeDir, { recursive: true }); + + let html = injectMeta(baseHtml, metaBlock); + if (routePath !== "/") html = normalizeAssetPaths(html); + await fs.writeFile(routeHtmlPath, html, "utf8"); +}; + +const main = async () => { + const config = await parseConfig(); + const baseHtml = await fs.readFile(DIST_INDEX_HTML, "utf8"); + + let posts = []; + try { + posts = await fetchManifestPosts(config.MANIFEST_TX_ID); + } catch (error) { + console.warn( + `[prerender-meta] Could not fetch manifest; generating root metadata only: ${error.message}` + ); + } + + const featured = posts[0]; + const rootTitle = config.BLOG_NAME; + const rootDescription = + config.BLOG_DEFAULT_DESCRIPTION || + featured?.description || + featured?.excerpt || + `Posts from ${config.BLOG_NAME}.`; + const rootImage = featured?.bannerTxId + ? `https://arweave.net/${featured.bannerTxId}` + : ""; + const rootCanonical = toAbsoluteUrl(config.BLOG_SITE_URL, "/"); + + const rootMeta = buildMetaTags({ + title: rootTitle, + description: rootDescription, + canonicalUrl: rootCanonical, + siteName: config.BLOG_NAME, + imageUrl: rootImage, + type: "website" + }); + + await writeRouteHtml(baseHtml, "/", rootMeta); + + for (const post of posts) { + const postPath = normalizeSlashPath(post.slug); + const postTitle = `${post.title} | ${config.BLOG_NAME}`; + const postDescription = + post.description || post.excerpt || config.BLOG_DEFAULT_DESCRIPTION || `Post on ${config.BLOG_NAME}`; + const postImage = post.bannerTxId ? `https://arweave.net/${post.bannerTxId}` : ""; + const canonicalUrl = toAbsoluteUrl(config.BLOG_SITE_URL, postPath); + + const postMeta = buildMetaTags({ + title: postTitle, + description: postDescription, + canonicalUrl, + siteName: config.BLOG_NAME, + imageUrl: postImage, + type: "article", + publishedTime: post.publishedAt, + modifiedTime: post.updated + }); + + await writeRouteHtml(baseHtml, postPath, postMeta); + } + + console.log(`[prerender-meta] Generated metadata pages: ${posts.length + 1}`); +}; + +void main(); diff --git a/src/App.tsx b/src/App.tsx index 99d5973..0767519 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,95 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, Navigate, Route, Routes, useParams } from "react-router-dom"; -import { BLOG_NAME } from "./config"; +import { Link, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom"; +import { + BLOG_DEFAULT_DESCRIPTION, + BLOG_NAME, + BLOG_SITE_URL, + BLOG_TWITTER_HANDLE +} from "./config"; import { arweaveUrl, getReadableDate, loadManifest, loadPostContent } from "./lib"; import type { Frontmatter, ManifestPost } from "./types"; type LoadState = "idle" | "loading" | "error"; +type MetaInput = { + title: string; + description: string; + path?: string; + image?: string; + type?: "website" | "article"; + publishedTime?: string; + modifiedTime?: string; +}; + +const toAbsoluteUrl = (path = "/"): string => new URL(path, BLOG_SITE_URL).toString(); + +const setMetaTag = (attribute: "name" | "property", key: string, content?: string): void => { + if (!content) return; + let tag = document.head.querySelector(`meta[${attribute}="${key}"]`); + if (!tag) { + tag = document.createElement("meta"); + tag.setAttribute(attribute, key); + document.head.appendChild(tag); + } + tag.setAttribute("content", content); +}; + +const setCanonical = (href: string): void => { + let link = document.head.querySelector('link[rel="canonical"]'); + if (!link) { + link = document.createElement("link"); + link.setAttribute("rel", "canonical"); + document.head.appendChild(link); + } + link.setAttribute("href", href); +}; + +const usePageMetadata = ({ + title, + description, + path = "/", + image, + type = "website", + publishedTime, + modifiedTime +}: MetaInput): void => { + useEffect(() => { + const canonicalUrl = toAbsoluteUrl(path); + const imageUrl = image ? toAbsoluteUrl(image) : undefined; + + document.title = title; + setCanonical(canonicalUrl); + + setMetaTag("name", "description", description); + setMetaTag("property", "og:title", title); + setMetaTag("property", "og:description", description); + setMetaTag("property", "og:type", type); + setMetaTag("property", "og:url", canonicalUrl); + setMetaTag("property", "og:site_name", BLOG_NAME); + + if (imageUrl) { + setMetaTag("property", "og:image", imageUrl); + setMetaTag("name", "twitter:image", imageUrl); + setMetaTag("name", "twitter:card", "summary_large_image"); + } else { + setMetaTag("name", "twitter:card", "summary"); + } + + setMetaTag("name", "twitter:title", title); + setMetaTag("name", "twitter:description", description); + + if (BLOG_TWITTER_HANDLE) { + setMetaTag("name", "twitter:site", BLOG_TWITTER_HANDLE); + setMetaTag("name", "twitter:creator", BLOG_TWITTER_HANDLE); + } + + if (type === "article") { + setMetaTag("property", "article:published_time", publishedTime); + setMetaTag("property", "article:modified_time", modifiedTime); + } + }, [description, image, modifiedTime, path, publishedTime, title, type]); +}; + function BlisterLoader() { return ( <div className="loader-screen" aria-live="polite" aria-label="Loading article"> @@ -90,12 +174,27 @@ function IndexPage({ state: LoadState; error: string; }) { + const featuredPost = posts[0]; + const indexDescription = + BLOG_DEFAULT_DESCRIPTION || + featuredPost?.description || + featuredPost?.excerpt || + `Posts from ${BLOG_NAME}.`; + const indexImage = featuredPost?.frontmatter?.banner ?? featuredPost?.bannerTxId; + + usePageMetadata({ + title: BLOG_NAME, + description: indexDescription, + path: "/", + image: indexImage ? arweaveUrl(indexImage) : undefined, + type: "website" + }); + if (state === "loading") return <BlisterLoader />; if (state === "error") return <p className="status">Error: {error}</p>; if (posts.length === 0) return <p className="status">No posts yet.</p>; const hasFeatured = posts.length >= 3; - const featuredPost = hasFeatured ? posts[0] : null; const gridPosts = hasFeatured ? posts.slice(1) : posts; const renderPost = (post: ManifestPost) => { @@ -131,7 +230,7 @@ function IndexPage({ return ( <section className="index"> - {featuredPost && ( + {hasFeatured && featuredPost && ( <article className="post-card post-card-featured" key={featuredPost.postTxId}> {renderPost(featuredPost)} </article> @@ -157,6 +256,7 @@ function PostPage({ manifestError: string; }) { const { slug = "" } = useParams(); + const location = useLocation(); const post = useMemo(() => posts.find((entry) => entry.slug === slug), [posts, slug]); const [html, setHtml] = useState<string>(""); const [postFrontmatter, setPostFrontmatter] = useState<Frontmatter>({}); @@ -202,25 +302,35 @@ function PostPage({ }; }, [post]); + const title = postFrontmatter.title || post?.title || "Post"; + const description = + postFrontmatter.desc || + postFrontmatter.description || + post?.description || + postFrontmatter.excerpt || + post?.excerpt || + BLOG_DEFAULT_DESCRIPTION; + const bannerTxId = postFrontmatter.banner || post?.frontmatter?.banner || post?.bannerTxId; + const publishedDate = getReadableDate(postFrontmatter.date || post?.publishedAt); + const updatedDate = getReadableDate(postFrontmatter.updated || post?.updated || undefined); + const permalink = post ? toAbsoluteUrl(`/${post.slug}`) : toAbsoluteUrl(location.pathname); + + usePageMetadata({ + title: post ? `${title} | ${BLOG_NAME}` : `${BLOG_NAME} | Post Not Found`, + description, + path: location.pathname, + image: bannerTxId ? arweaveUrl(bannerTxId) : undefined, + type: "article", + publishedTime: postFrontmatter.date || post?.publishedAt, + modifiedTime: postFrontmatter.updated || post?.updated || undefined + }); + if (state === "loading") return <BlisterLoader />; if (state === "error") return <p className="status">Error: {manifestError}</p>; if (!post) return <p className="status">Post not found.</p>; if (postState === "loading") return showPostLoader ? <BlisterLoader /> : null; if (postState === "error") return <p className="status">Error: {postError}</p>; - const title = postFrontmatter.title || post.title; - const description = - postFrontmatter.desc || - postFrontmatter.description || - post.description || - postFrontmatter.excerpt || - post.excerpt || - ""; - const bannerTxId = postFrontmatter.banner || post.frontmatter?.banner || post.bannerTxId; - const publishedDate = getReadableDate(postFrontmatter.date || post.publishedAt); - const updatedDate = getReadableDate(postFrontmatter.updated || post.updated || undefined); - const permalink = `https://zpz3tbjvlwkkrkn2talxgib6plcj62d3r3gpjebyinbcu7oa7bjq.arweave.net/${post.slug}`; - return ( <article className="post"> <header className="post-header"> diff --git a/src/config.ts b/src/config.ts index beabb50..e774ff2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,8 @@ -export const BLOG_NAME = "hyperzine"; +export const BLOG_NAME = "Hyperzine"; +export const BLOG_SITE_URL = "https://zpz3tbjvlwkkrkn2talxgib6plcj62d3r3gpjebyinbcu7oa7bjq.arweave.net"; +export const BLOG_DEFAULT_DESCRIPTION = + "Hyperzine is a modern developer-focused blog on the permaweb."; +export const BLOG_TWITTER_HANDLE = ""; export const MANIFEST_TX_ID = "N2zjKSSh5PtGKzCekTJF50yTv3mTIpQnaIXieyPxie8"; export const ARWEAVE_GATEWAY = "https://arweave.net";