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(/]+rel=["']canonical["'][^>]*>/gi, "")
+ .replace(/]+(?:name|property)=["'](?:description|og:[^"']+|twitter:[^"']+|article:[^"']+)["'][^>]*>/gi, "");
+
+ return stripped.replace("", ` ${metaBlock}\n `);
+};
+
+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 (
@@ -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
;
if (state === "error") return
Error: {error}
;
if (posts.length === 0) return
No posts yet.
;
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 (
- {featuredPost && (
+ {hasFeatured && featuredPost && (
{renderPost(featuredPost)}
@@ -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("");
const [postFrontmatter, setPostFrontmatter] = useState({});
@@ -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 ;
if (state === "error") return Error: {manifestError}
;
if (!post) return Post not found.
;
if (postState === "loading") return showPostLoader ? : null;
if (postState === "error") return Error: {postError}
;
- 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 (
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";