Compare commits
13 Commits
3f059905d8
...
3618d0aa2d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3618d0aa2d | |||
| e57f80bad5 | |||
| 7db8aff97f | |||
| 4f0ba35da0 | |||
| 79a7355668 | |||
| 71a8a23054 | |||
| d02bca8646 | |||
| ac3b7d7ce9 | |||
| e7361cd907 | |||
| 1f9f4a08a0 | |||
| e99f7ffd20 | |||
| 5a952a8809 | |||
| 7c9dc57c29 |
19
package-lock.json
generated
19
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "hyperzine",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@permaweb/references": "^0.1.1",
|
||||
"arweave": "^1.15.7",
|
||||
"dompurify": "^3.2.6",
|
||||
"marked": "^15.0.12",
|
||||
@@ -811,6 +812,24 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@permaweb/references": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@permaweb/references/-/references-0.1.1.tgz",
|
||||
"integrity": "sha512-N3raK74AS339mJB8ho00oLUYpTRlZw4gL3qjxA1YyKyueKWQzh3099ovW7AgFftev94HkDgSZA2BpXzb1hpWSA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"arbundles": "*",
|
||||
"arweave": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"arbundles": {
|
||||
"optional": true
|
||||
},
|
||||
"arweave": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"ship": "node scripts/ship.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@permaweb/references": "^0.1.1",
|
||||
"arweave": "^1.15.7",
|
||||
"dompurify": "^3.2.6",
|
||||
"marked": "^15.0.12",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
|
||||
import Arweave from "arweave";
|
||||
import mime from "mime-types";
|
||||
import arbundles from "warp-arbundles";
|
||||
import { ReferenceClient } from "@permaweb/references";
|
||||
|
||||
const { createData, ArweaveSigner } = arbundles;
|
||||
|
||||
@@ -222,12 +223,25 @@ async function createUploader(uploadMode, arweave, jwk) {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBlogManifestTxId() {
|
||||
async function resolveBlogManifestReferenceName() {
|
||||
const source = await fs.readFile(sourceConfigPath, "utf8");
|
||||
const match = source.match(/MANIFEST_TX_ID\s*=\s*"([^"]+)"/);
|
||||
const match = source.match(/MANIFEST_REFERENCE_NAME\s*=\s*"([^"]+)"/);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
async function getLatestManifestFromReference(gateway) {
|
||||
const referenceName = await resolveBlogManifestReferenceName();
|
||||
if (!referenceName) return "";
|
||||
|
||||
try {
|
||||
const names = new ReferenceClient({ gateway });
|
||||
const value = await names.resolveName(referenceName);
|
||||
return typeof value === "string" ? value : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAoConfig() {
|
||||
const source = await fs.readFile(sourceConfigPath, "utf8");
|
||||
const aoUrlMatch = source.match(/AO_URL\s*=\s*"([^"]+)"/);
|
||||
@@ -308,21 +322,35 @@ async function getLatestManifestFromAo() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePostSlugs(gateway) {
|
||||
const blogManifestTxId = (await getLatestManifestFromAo()) || (await resolveBlogManifestTxId());
|
||||
if (!blogManifestTxId) return [];
|
||||
async function fetchPostSlugs(gateway, blogManifestTxId) {
|
||||
if (!blogManifestTxId) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${gateway.replace(/\/+$/, "")}/${blogManifestTxId}`);
|
||||
if (!response.ok) return [];
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.posts)) return [];
|
||||
return payload.posts
|
||||
.map((post) => (typeof post?.slug === "string" ? post.slug.trim() : ""))
|
||||
.filter((slug) => slug.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
const response = await fetch(`${gateway.replace(/\/+$/, "")}/${blogManifestTxId}`);
|
||||
if (!response.ok) return null;
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.posts)) return null;
|
||||
|
||||
return payload.posts
|
||||
.map((post) => (typeof post?.slug === "string" ? post.slug.trim() : ""))
|
||||
.filter((slug) => slug.length > 0);
|
||||
}
|
||||
|
||||
async function resolvePostSlugs(gateway) {
|
||||
const candidates = [
|
||||
await getLatestManifestFromReference(gateway),
|
||||
await getLatestManifestFromAo()
|
||||
].filter(Boolean);
|
||||
|
||||
for (const blogManifestTxId of candidates) {
|
||||
try {
|
||||
const slugs = await fetchPostSlugs(gateway, blogManifestTxId);
|
||||
if (slugs) return slugs;
|
||||
} catch {
|
||||
// Try the next manifest source.
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -396,8 +424,9 @@ async function main() {
|
||||
|
||||
const slugs = await resolvePostSlugs(gateway);
|
||||
for (const slug of slugs) {
|
||||
pathMap[slug] = { id: indexTxId };
|
||||
pathMap[`${slug}/`] = { id: indexTxId };
|
||||
const routeIndexId = pathMap[`${slug}/index.html`]?.id || indexTxId;
|
||||
pathMap[slug] = { id: routeIndexId };
|
||||
pathMap[`${slug}/`] = { id: routeIndexId };
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ReferenceClient } from "@permaweb/references";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DIST_DIR = path.join(ROOT, "dist");
|
||||
@@ -10,7 +11,8 @@ const DEFAULTS = {
|
||||
BLOG_NAME: "hyperzine",
|
||||
BLOG_SITE_URL: "https://hyperzine.xyz",
|
||||
BLOG_DEFAULT_DESCRIPTION: "hyperzine",
|
||||
MANIFEST_TX_ID: ""
|
||||
ARWEAVE_GATEWAY: "https://arweave.net",
|
||||
MANIFEST_REFERENCE_NAME: ""
|
||||
};
|
||||
|
||||
const asObject = (value) => (typeof value === "object" && value !== null ? value : null);
|
||||
@@ -50,7 +52,8 @@ const parseConfig = async () => {
|
||||
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")
|
||||
ARWEAVE_GATEWAY: pick("ARWEAVE_GATEWAY"),
|
||||
MANIFEST_REFERENCE_NAME: pick("MANIFEST_REFERENCE_NAME")
|
||||
};
|
||||
};
|
||||
|
||||
@@ -116,13 +119,28 @@ const parseManifest = (input) => {
|
||||
});
|
||||
};
|
||||
|
||||
const fetchManifestPosts = async (manifestTxId) => {
|
||||
const fetchManifestPosts = async (config, manifestTxId) => {
|
||||
if (!manifestTxId) return [];
|
||||
const url = `https://arweave.net/${manifestTxId}`;
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
const gateway = (config.ARWEAVE_GATEWAY || DEFAULTS.ARWEAVE_GATEWAY).replace(/\/+$/, "");
|
||||
const response = await fetch(`${gateway}/${manifestTxId}`, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Manifest fetch failed (${response.status})`);
|
||||
const payload = await response.json();
|
||||
return parseManifest(payload);
|
||||
const posts = parseManifest(payload);
|
||||
return { txId: manifestTxId, posts };
|
||||
};
|
||||
|
||||
const resolveManifestTxId = async (config) => {
|
||||
if (!config.MANIFEST_REFERENCE_NAME) return "";
|
||||
const names = new ReferenceClient({ gateway: config.ARWEAVE_GATEWAY || DEFAULTS.ARWEAVE_GATEWAY });
|
||||
const value = await names.resolveName(config.MANIFEST_REFERENCE_NAME);
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
throw new Error(`Reference ${config.MANIFEST_REFERENCE_NAME} did not resolve to a manifest id`);
|
||||
};
|
||||
|
||||
const fetchCurrentManifestPosts = async (config) => {
|
||||
const manifestTxId = await resolveManifestTxId(config);
|
||||
if (!manifestTxId) return [];
|
||||
return (await fetchManifestPosts(config, manifestTxId)).posts;
|
||||
};
|
||||
|
||||
const buildMetaTags = ({
|
||||
@@ -197,7 +215,7 @@ const main = async () => {
|
||||
|
||||
let posts = [];
|
||||
try {
|
||||
posts = await fetchManifestPosts(config.MANIFEST_TX_ID);
|
||||
posts = await fetchCurrentManifestPosts(config);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[prerender-meta] Could not fetch manifest; generating root metadata only: ${error.message}`
|
||||
|
||||
187
src/App.tsx
187
src/App.tsx
@@ -1,9 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
NavLink,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useParams
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
BLOG_DEFAULT_DESCRIPTION,
|
||||
BLOG_NAME,
|
||||
BLOG_PERMALINK_PREFIX,
|
||||
BLOG_SITE_URL,
|
||||
BLOG_TWITTER_HANDLE
|
||||
} from "./config";
|
||||
@@ -18,6 +24,16 @@ import load6 from "./load6.png";
|
||||
|
||||
type LoadState = "idle" | "loading" | "error";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
twttr?: {
|
||||
widgets?: {
|
||||
load: (element?: HTMLElement | null) => void | Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type MetaInput = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -30,9 +46,65 @@ type MetaInput = {
|
||||
|
||||
const LOADER_FRAMES = [load1, load2, load3, load4, load5, load6];
|
||||
const FORCE_LOADER_PREVIEW = false;
|
||||
const TWITTER_WIDGETS_SCRIPT_ID = "twitter-widgets-script";
|
||||
const ARTIFACT_CATEGORY = "artifact";
|
||||
const ARTIFACTS_PATH = "/artifacts";
|
||||
|
||||
let twitterWidgetsPromise: Promise<void> | 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 hasCategory = (categories: string[] | undefined, category: string): boolean =>
|
||||
(categories ?? []).some((entry) => entry.trim().toLowerCase() === category);
|
||||
|
||||
const isArtifactPost = (post: ManifestPost): boolean =>
|
||||
hasCategory(post.categories, ARTIFACT_CATEGORY);
|
||||
|
||||
const ensureTwitterWidgets = (): Promise<void> => {
|
||||
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;
|
||||
@@ -155,6 +227,9 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const articlePosts = useMemo(() => posts.filter((post) => !isArtifactPost(post)), [posts]);
|
||||
const artifactPosts = useMemo(() => posts.filter(isArtifactPost), [posts]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="header">
|
||||
@@ -169,6 +244,24 @@ function App() {
|
||||
</span>
|
||||
<span className="brand-text">{BLOG_NAME}</span>
|
||||
</Link>
|
||||
<nav className="site-nav" aria-label="Primary">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) => `nav-link${isActive ? " nav-link-active" : ""}`}
|
||||
>
|
||||
articles
|
||||
</NavLink>
|
||||
<span className="nav-separator" aria-hidden="true">
|
||||
//
|
||||
</span>
|
||||
<NavLink
|
||||
to={ARTIFACTS_PATH}
|
||||
className={({ isActive }) => `nav-link${isActive ? " nav-link-active" : ""}`}
|
||||
>
|
||||
artifacts
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content">
|
||||
@@ -178,13 +271,20 @@ function App() {
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<IndexPage posts={posts} state={state} error={error} />}
|
||||
element={<IndexPage posts={articlePosts} state={state} error={error} />}
|
||||
/>
|
||||
<Route
|
||||
path={ARTIFACTS_PATH}
|
||||
element={<ArtifactsPage posts={artifactPosts} state={state} error={error} />}
|
||||
/>
|
||||
<Route
|
||||
path="/:slug"
|
||||
element={<PostPage posts={posts} state={state} manifestError={error} />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={<PostPage posts={posts} state={state} manifestError={error} />}
|
||||
/>
|
||||
</Routes>
|
||||
)}
|
||||
</main>
|
||||
@@ -273,6 +373,55 @@ function IndexPage({
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactsPage({
|
||||
posts,
|
||||
state,
|
||||
error
|
||||
}: {
|
||||
posts: ManifestPost[];
|
||||
state: LoadState;
|
||||
error: string;
|
||||
}) {
|
||||
usePageMetadata({
|
||||
title: `Artifacts | ${BLOG_NAME}`,
|
||||
description: "Raw, unpolished LLM outputs.",
|
||||
path: ARTIFACTS_PATH,
|
||||
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 artifacts yet.</p>;
|
||||
|
||||
return (
|
||||
<section className="artifact-index">
|
||||
<header className="artifact-index-header">
|
||||
<h1>Artifacts</h1>
|
||||
<p>Raw, unpolished LLM outputs.</p>
|
||||
</header>
|
||||
<ol className="artifact-list">
|
||||
{posts.map((post) => {
|
||||
const publishedDate = getReadableDate(post.publishedAt);
|
||||
const description = post.description || post.excerpt || "No description provided.";
|
||||
|
||||
return (
|
||||
<li key={post.postTxId} className="artifact-list-item">
|
||||
<Link to={`/${post.slug}`} className="artifact-link">
|
||||
{post.title}
|
||||
</Link>
|
||||
<div className="artifact-item-meta">
|
||||
{publishedDate && <span>{publishedDate}</span>}
|
||||
<span>codex</span>
|
||||
</div>
|
||||
<p>{description}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PostPage({
|
||||
posts,
|
||||
state,
|
||||
@@ -282,7 +431,8 @@ function PostPage({
|
||||
state: LoadState;
|
||||
manifestError: string;
|
||||
}) {
|
||||
const { slug = "" } = useParams();
|
||||
const params = useParams();
|
||||
const slug = params.slug ?? params["*"] ?? "";
|
||||
const location = useLocation();
|
||||
const post = useMemo(() => posts.find((entry) => entry.slug === slug), [posts, slug]);
|
||||
const [html, setHtml] = useState<string>("");
|
||||
@@ -290,6 +440,7 @@ function PostPage({
|
||||
const [postState, setPostState] = useState<LoadState>("idle");
|
||||
const [postError, setPostError] = useState<string>("");
|
||||
const [showPostLoader, setShowPostLoader] = useState<boolean>(false);
|
||||
const articleRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (postState !== "loading") {
|
||||
@@ -329,6 +480,10 @@ function PostPage({
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
useEffect(() => {
|
||||
renderTweetEmbeds(articleRef.current);
|
||||
}, [html]);
|
||||
|
||||
const title = postFrontmatter.title || post?.title || "Post";
|
||||
const description =
|
||||
postFrontmatter.desc ||
|
||||
@@ -337,12 +492,13 @@ function PostPage({
|
||||
postFrontmatter.excerpt ||
|
||||
post?.excerpt ||
|
||||
BLOG_DEFAULT_DESCRIPTION;
|
||||
const categories = postFrontmatter.categories?.length
|
||||
? postFrontmatter.categories
|
||||
: post?.categories ?? [];
|
||||
const isArtifact = hasCategory(categories, ARTIFACT_CATEGORY);
|
||||
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
|
||||
? toPermalinkUrl(`/${post.slug}`)
|
||||
: toPermalinkUrl(location.pathname);
|
||||
const metadataTitle =
|
||||
state === "loading"
|
||||
? `${BLOG_NAME} | Loading`
|
||||
@@ -364,7 +520,7 @@ function PostPage({
|
||||
title: metadataTitle,
|
||||
description: metadataDescription,
|
||||
path: location.pathname,
|
||||
image: bannerTxId ? arweaveUrl(bannerTxId) : undefined,
|
||||
image: bannerTxId && !isArtifact ? arweaveUrl(bannerTxId) : undefined,
|
||||
type: post ? "article" : "website",
|
||||
publishedTime: post ? postFrontmatter.date || post.publishedAt : undefined,
|
||||
modifiedTime: post ? postFrontmatter.updated || post.updated || undefined : undefined
|
||||
@@ -377,19 +533,17 @@ function PostPage({
|
||||
if (postState === "error") return <p className="status">Error: {postError}</p>;
|
||||
|
||||
return (
|
||||
<article className="post">
|
||||
<article className={`post${isArtifact ? " post-artifact" : ""}`}>
|
||||
<header className="post-header">
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
<div className="meta-row post-meta-row">
|
||||
{publishedDate && <span>Published {publishedDate}</span>}
|
||||
{updatedDate && <span>Updated {updatedDate}</span>}
|
||||
{isArtifact && <span>codex</span>}
|
||||
{post.readingTime && <span>{post.readingTime} min read</span>}
|
||||
{post.wordCount && <span>{post.wordCount} words</span>}
|
||||
<span>{post.postTxId.slice(0, 8)}...</span>
|
||||
<span>
|
||||
<a href={permalink}>[permalink]</a>
|
||||
</span>
|
||||
<span>
|
||||
<a href={arweaveUrl(post.postTxId)} target="_blank" rel="noreferrer">
|
||||
[arweave]
|
||||
@@ -397,12 +551,13 @@ function PostPage({
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
{bannerTxId && (
|
||||
{bannerTxId && !isArtifact && (
|
||||
<div className="post-hero">
|
||||
<img className="post-banner" src={arweaveUrl(bannerTxId)} alt={`${title} banner`} />
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
ref={articleRef}
|
||||
className="article"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
@@ -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://hyperzine.xyz";
|
||||
export const BLOG_PERMALINK_PREFIX = "https://hyperzine.xyz";
|
||||
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_REFERENCE_NAME = "mystic";
|
||||
export const ARWEAVE_GATEWAY = "https://arweave.net";
|
||||
|
||||
export const AO_URL = "https://push-1.forward.computer";
|
||||
|
||||
195
src/lib.ts
195
src/lib.ts
@@ -1,7 +1,8 @@
|
||||
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 { ReferenceClient } from "@permaweb/references";
|
||||
import { ARWEAVE_GATEWAY, MANIFEST_REFERENCE_NAME } from "./config";
|
||||
import { parseManifest, type Frontmatter, type ManifestPost } from "./types";
|
||||
|
||||
const formatDate = (date: string): string =>
|
||||
@@ -16,6 +17,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 `<p>${this.parser.parseInline(token.tokens)}</p>\n`;
|
||||
}
|
||||
|
||||
const href = escapeHtml(tweetUrl);
|
||||
const link = `<a href="${href}">${href}</a>`;
|
||||
return `<blockquote class="twitter-tweet" data-theme="light" data-dnt="true">${link}</blockquote>\n`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const arweaveUrl = (txId: string): string => `${ARWEAVE_GATEWAY}/${txId}`;
|
||||
|
||||
export const getReadableDate = (date?: string | null): string | null => {
|
||||
@@ -25,112 +72,50 @@ export const getReadableDate = (date?: string | null): string | null => {
|
||||
return formatDate(date);
|
||||
};
|
||||
|
||||
const MANIFEST_ID_CACHE_TTL_MS = 60_000;
|
||||
let manifestIdCache: { expiresAt: number; manifestId: string } | null = null;
|
||||
|
||||
const asObject = (value: unknown): Record<string, unknown> | null =>
|
||||
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||
|
||||
const asString = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.length > 0 ? value : null;
|
||||
|
||||
const findManifestIdInTags = (tagsValue: unknown): string | null => {
|
||||
const tags = Array.isArray(tagsValue) ? tagsValue : [];
|
||||
for (const tag of tags) {
|
||||
const tagObject = asObject(tag);
|
||||
if (!tagObject) continue;
|
||||
const name = asString(tagObject.name) ?? asString(tagObject.Name);
|
||||
const value = asString(tagObject.value) ?? asString(tagObject.Value);
|
||||
if ((name === "LatestManifestId" || name === "ManifestId") && value) return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractManifestId = (payload: unknown): string | null => {
|
||||
const root = asObject(payload);
|
||||
const results = asObject(root?.results);
|
||||
if (!results) return null;
|
||||
|
||||
const outbox = asObject(results.outbox);
|
||||
if (outbox) {
|
||||
for (const message of Object.values(outbox)) {
|
||||
const messageObject = asObject(message);
|
||||
if (!messageObject) continue;
|
||||
const direct = asString(messageObject.LatestManifestId) ?? asString(messageObject.ManifestId);
|
||||
if (direct) return direct;
|
||||
const tagged = findManifestIdInTags(messageObject.Tags);
|
||||
if (tagged) return tagged;
|
||||
}
|
||||
}
|
||||
|
||||
const raw = asObject(results.raw);
|
||||
const rawMessages = Array.isArray(raw?.Messages) ? raw.Messages : [];
|
||||
for (const message of rawMessages) {
|
||||
const messageObject = asObject(message);
|
||||
if (!messageObject) continue;
|
||||
const tagged = findManifestIdInTags(messageObject.Tags);
|
||||
if (tagged) return tagged;
|
||||
}
|
||||
|
||||
const json = asObject(results.json);
|
||||
const body = json?.body;
|
||||
const parsedBody =
|
||||
typeof body === "string" ? asObject(JSON.parse(body)) : asObject(body);
|
||||
const jsonMessages = Array.isArray(parsedBody?.Messages) ? parsedBody.Messages : [];
|
||||
for (const message of jsonMessages) {
|
||||
const messageObject = asObject(message);
|
||||
if (!messageObject) continue;
|
||||
const tagged = findManifestIdInTags(messageObject.Tags);
|
||||
if (tagged) return tagged;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getLatestManifestFromAo = async (): Promise<string | null> => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (manifestIdCache && manifestIdCache.expiresAt > now) {
|
||||
return manifestIdCache.manifestId;
|
||||
}
|
||||
|
||||
const url = `${AO_URL}/${AO_PROCESS_ID}~process@1.0/compute?Action=Get&require-codec=application/json&accept-bundle=true`;
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
const manifestId = extractManifestId(payload);
|
||||
if (!manifestId) return null;
|
||||
|
||||
manifestIdCache = {
|
||||
expiresAt: now + MANIFEST_ID_CACHE_TTL_MS,
|
||||
manifestId
|
||||
};
|
||||
return manifestId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadManifest = async (): Promise<ManifestPost[]> => {
|
||||
const loadByTxId = async (manifestTxId: string): Promise<ManifestPost[]> => {
|
||||
const response = await fetch(arweaveUrl(manifestTxId));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load manifest (${response.status})`);
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
return parseManifest(payload);
|
||||
};
|
||||
return (await loadCurrentManifest()).posts;
|
||||
};
|
||||
|
||||
try {
|
||||
return await loadByTxId(MANIFEST_TX_ID);
|
||||
} catch (manifestError) {
|
||||
const latestManifestTxId = await getLatestManifestFromAo();
|
||||
if (!latestManifestTxId || latestManifestTxId === MANIFEST_TX_ID) {
|
||||
throw manifestError;
|
||||
}
|
||||
return loadByTxId(latestManifestTxId);
|
||||
const fetchManifest = async (manifestTxId: string): Promise<{
|
||||
txId: string;
|
||||
posts: ManifestPost[];
|
||||
}> => {
|
||||
const response = await fetch(arweaveUrl(manifestTxId));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load manifest (${response.status})`);
|
||||
}
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
const posts = parseManifest(payload);
|
||||
return { txId: manifestTxId, posts };
|
||||
};
|
||||
|
||||
const resolveReferenceManifestTxId = async (): Promise<string> => {
|
||||
if (!MANIFEST_REFERENCE_NAME) return "";
|
||||
|
||||
const names = new ReferenceClient({
|
||||
gateway: ARWEAVE_GATEWAY,
|
||||
fetch: (...args) => fetch(...args)
|
||||
});
|
||||
const value = await names.resolveName(MANIFEST_REFERENCE_NAME);
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Reference ${MANIFEST_REFERENCE_NAME} did not resolve to a manifest id`);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const loadReferenceManifest = async () => {
|
||||
if (!MANIFEST_REFERENCE_NAME) throw new Error("No manifest reference configured");
|
||||
return fetchManifest(await resolveReferenceManifestTxId());
|
||||
};
|
||||
|
||||
const loadCurrentManifest = async () => {
|
||||
return loadReferenceManifest();
|
||||
};
|
||||
|
||||
export const resolveManifestTxId = async (): Promise<string> => {
|
||||
return (await loadCurrentManifest()).txId;
|
||||
};
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
|
||||
140
src/styles.css
140
src/styles.css
@@ -60,6 +60,8 @@ a:hover {
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 72px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
@@ -113,6 +115,30 @@ a:hover {
|
||||
background: var(--brand-green);
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #444444;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link-active {
|
||||
color: #111111;
|
||||
border-bottom-color: #111111;
|
||||
}
|
||||
|
||||
.nav-separator {
|
||||
color: #444444;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -224,6 +250,68 @@ a:hover {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.artifact-index {
|
||||
padding: 0 24px 52px;
|
||||
}
|
||||
|
||||
.artifact-index-header {
|
||||
padding: 28px 0 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.artifact-index-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.artifact-index-header p {
|
||||
margin: 12px 0 0;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.artifact-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.artifact-list-item {
|
||||
padding: 18px 0 20px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.artifact-link {
|
||||
display: inline-block;
|
||||
font-size: 1.12rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.artifact-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.artifact-item-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 7px;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
font-size: 0.75rem;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.artifact-list-item p {
|
||||
margin: 9px 0 0;
|
||||
color: #222222;
|
||||
}
|
||||
|
||||
.post {
|
||||
border-top: 0;
|
||||
}
|
||||
@@ -333,12 +421,49 @@ 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;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.post-artifact .post-header {
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.post-artifact .post-header h1 {
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.post-artifact .article {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.post-artifact .article p,
|
||||
.post-artifact .article li,
|
||||
.post-artifact .article blockquote {
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.post-artifact .article h2,
|
||||
.post-artifact .article h3,
|
||||
.post-artifact .article h4 {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.page {
|
||||
width: 100%;
|
||||
@@ -353,6 +478,7 @@ a:hover {
|
||||
}
|
||||
|
||||
.post-card,
|
||||
.artifact-index,
|
||||
.post-header,
|
||||
.post-hero,
|
||||
.article,
|
||||
@@ -373,3 +499,17 @@ a:hover {
|
||||
font-size: 1.38rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.header-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user