feat: metadata
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build && node scripts/prerender-meta.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"wallet:new": "node scripts/generate-wallet.mjs",
|
"wallet:new": "node scripts/generate-wallet.mjs",
|
||||||
"deploy": "node scripts/deploy.mjs --app-name=hyperzine --app-version=1.0.0",
|
"deploy": "node scripts/deploy.mjs --app-name=hyperzine --app-version=1.0.0",
|
||||||
|
|||||||
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();
|
||||||
144
src/App.tsx
144
src/App.tsx
@@ -1,11 +1,95 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, Navigate, Route, Routes, useParams } from "react-router-dom";
|
import { Link, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom";
|
||||||
import { BLOG_NAME } from "./config";
|
import {
|
||||||
|
BLOG_DEFAULT_DESCRIPTION,
|
||||||
|
BLOG_NAME,
|
||||||
|
BLOG_SITE_URL,
|
||||||
|
BLOG_TWITTER_HANDLE
|
||||||
|
} from "./config";
|
||||||
import { arweaveUrl, getReadableDate, loadManifest, loadPostContent } from "./lib";
|
import { arweaveUrl, getReadableDate, loadManifest, loadPostContent } from "./lib";
|
||||||
import type { Frontmatter, ManifestPost } from "./types";
|
import type { Frontmatter, ManifestPost } from "./types";
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "error";
|
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() {
|
function BlisterLoader() {
|
||||||
return (
|
return (
|
||||||
<div className="loader-screen" aria-live="polite" aria-label="Loading article">
|
<div className="loader-screen" aria-live="polite" aria-label="Loading article">
|
||||||
@@ -90,12 +174,27 @@ function IndexPage({
|
|||||||
state: LoadState;
|
state: LoadState;
|
||||||
error: string;
|
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 === "loading") return <BlisterLoader />;
|
||||||
if (state === "error") return <p className="status">Error: {error}</p>;
|
if (state === "error") return <p className="status">Error: {error}</p>;
|
||||||
if (posts.length === 0) return <p className="status">No posts yet.</p>;
|
if (posts.length === 0) return <p className="status">No posts yet.</p>;
|
||||||
|
|
||||||
const hasFeatured = posts.length >= 3;
|
const hasFeatured = posts.length >= 3;
|
||||||
const featuredPost = hasFeatured ? posts[0] : null;
|
|
||||||
const gridPosts = hasFeatured ? posts.slice(1) : posts;
|
const gridPosts = hasFeatured ? posts.slice(1) : posts;
|
||||||
|
|
||||||
const renderPost = (post: ManifestPost) => {
|
const renderPost = (post: ManifestPost) => {
|
||||||
@@ -131,7 +230,7 @@ function IndexPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="index">
|
<section className="index">
|
||||||
{featuredPost && (
|
{hasFeatured && featuredPost && (
|
||||||
<article className="post-card post-card-featured" key={featuredPost.postTxId}>
|
<article className="post-card post-card-featured" key={featuredPost.postTxId}>
|
||||||
{renderPost(featuredPost)}
|
{renderPost(featuredPost)}
|
||||||
</article>
|
</article>
|
||||||
@@ -157,6 +256,7 @@ function PostPage({
|
|||||||
manifestError: string;
|
manifestError: string;
|
||||||
}) {
|
}) {
|
||||||
const { slug = "" } = useParams();
|
const { slug = "" } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
const post = useMemo(() => posts.find((entry) => entry.slug === slug), [posts, slug]);
|
const post = useMemo(() => posts.find((entry) => entry.slug === slug), [posts, slug]);
|
||||||
const [html, setHtml] = useState<string>("");
|
const [html, setHtml] = useState<string>("");
|
||||||
const [postFrontmatter, setPostFrontmatter] = useState<Frontmatter>({});
|
const [postFrontmatter, setPostFrontmatter] = useState<Frontmatter>({});
|
||||||
@@ -202,25 +302,35 @@ function PostPage({
|
|||||||
};
|
};
|
||||||
}, [post]);
|
}, [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 === "loading") return <BlisterLoader />;
|
||||||
if (state === "error") return <p className="status">Error: {manifestError}</p>;
|
if (state === "error") return <p className="status">Error: {manifestError}</p>;
|
||||||
if (!post) return <p className="status">Post not found.</p>;
|
if (!post) return <p className="status">Post not found.</p>;
|
||||||
if (postState === "loading") return showPostLoader ? <BlisterLoader /> : null;
|
if (postState === "loading") return showPostLoader ? <BlisterLoader /> : null;
|
||||||
if (postState === "error") return <p className="status">Error: {postError}</p>;
|
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 (
|
return (
|
||||||
<article className="post">
|
<article className="post">
|
||||||
<header className="post-header">
|
<header className="post-header">
|
||||||
|
|||||||
@@ -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 MANIFEST_TX_ID = "N2zjKSSh5PtGKzCekTJF50yTv3mTIpQnaIXieyPxie8";
|
||||||
export const ARWEAVE_GATEWAY = "https://arweave.net";
|
export const ARWEAVE_GATEWAY = "https://arweave.net";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user