feat: metadata
This commit is contained in:
255
scripts/prerender-meta.mjs
Normal file
255
scripts/prerender-meta.mjs
Normal file
@@ -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 = [
|
||||
`<title>${htmlEscape(title)}</title>`,
|
||||
`<link rel="canonical" href="${htmlEscape(canonicalUrl)}">`,
|
||||
`<meta name="description" content="${htmlEscape(description)}">`,
|
||||
`<meta property="og:title" content="${htmlEscape(title)}">`,
|
||||
`<meta property="og:description" content="${htmlEscape(description)}">`,
|
||||
`<meta property="og:type" content="${htmlEscape(type)}">`,
|
||||
`<meta property="og:url" content="${htmlEscape(canonicalUrl)}">`,
|
||||
`<meta property="og:site_name" content="${htmlEscape(siteName)}">`,
|
||||
`<meta name="twitter:title" content="${htmlEscape(title)}">`,
|
||||
`<meta name="twitter:description" content="${htmlEscape(description)}">`,
|
||||
`<meta name="twitter:card" content="${imageUrl ? "summary_large_image" : "summary"}">`
|
||||
];
|
||||
|
||||
if (imageUrl) {
|
||||
tags.push(`<meta property="og:image" content="${htmlEscape(imageUrl)}">`);
|
||||
tags.push(`<meta name="twitter:image" content="${htmlEscape(imageUrl)}">`);
|
||||
}
|
||||
|
||||
if (type === "article" && publishedTime) {
|
||||
tags.push(`<meta property="article:published_time" content="${htmlEscape(publishedTime)}">`);
|
||||
}
|
||||
|
||||
if (type === "article" && modifiedTime) {
|
||||
tags.push(`<meta property="article:modified_time" content="${htmlEscape(modifiedTime)}">`);
|
||||
}
|
||||
|
||||
return tags.join("\n ");
|
||||
};
|
||||
|
||||
const injectMeta = (html, metaBlock) => {
|
||||
const marker = "<!-- PRERENDER_META -->";
|
||||
if (html.includes(marker)) {
|
||||
return html.replace(marker, `${marker}\n ${metaBlock}`);
|
||||
}
|
||||
|
||||
const stripped = html
|
||||
.replace(/<title>[\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();
|
||||
Reference in New Issue
Block a user