diff --git a/package-lock.json b/package-lock.json index 1610dbf..4fbd246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9c2b4bc..da2fdae 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index 7f8768a..8a5bb2d 100644 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -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; @@ -228,6 +229,25 @@ async function resolveBlogManifestTxId() { return match?.[1] || ""; } +async function resolveBlogManifestReferenceName() { + const source = await fs.readFile(sourceConfigPath, "utf8"); + 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 +328,51 @@ async function getLatestManifestFromAo() { } } -async function resolvePostSlugs(gateway) { - const blogManifestTxId = (await getLatestManifestFromAo()) || (await resolveBlogManifestTxId()); - if (!blogManifestTxId) return []; +function timestampMs(value) { + if (typeof value !== "string" || !value) return 0; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} - 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 []; +function manifestFreshness(payload) { + const posts = Array.isArray(payload?.posts) ? payload.posts : []; + return Math.max( + timestampMs(payload?.updated), + timestampMs(payload?.date), + timestampMs(payload?.generatedAt), + ...posts.map((post) => Math.max(timestampMs(post?.updated), timestampMs(post?.publishedAt))) + ); +} + +async function resolvePostSlugs(gateway) { + const candidates = [ + await getLatestManifestFromReference(gateway), + await getLatestManifestFromAo(), + await resolveBlogManifestTxId() + ].filter(Boolean); + + const manifests = []; + for (const blogManifestTxId of candidates) { + try { + const response = await fetch(`${gateway.replace(/\/+$/, "")}/${blogManifestTxId}`); + if (!response.ok) continue; + const payload = await response.json(); + if (!Array.isArray(payload?.posts)) continue; + manifests.push({ + txId: blogManifestTxId, + freshness: manifestFreshness(payload), + posts: payload.posts + }); + } catch { + // Try the next manifest source. + } } + + if (!manifests.length) return []; + manifests.sort((a, b) => b.freshness - a.freshness); + return manifests[0].posts + .map((post) => (typeof post?.slug === "string" ? post.slug.trim() : "")) + .filter((slug) => slug.length > 0); } async function main() { @@ -396,8 +446,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 = { diff --git a/scripts/prerender-meta.mjs b/scripts/prerender-meta.mjs index ba93064..2b4d345 100644 --- a/scripts/prerender-meta.mjs +++ b/scripts/prerender-meta.mjs @@ -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,6 +11,8 @@ const DEFAULTS = { BLOG_NAME: "hyperzine", BLOG_SITE_URL: "https://hyperzine.xyz", BLOG_DEFAULT_DESCRIPTION: "hyperzine", + ARWEAVE_GATEWAY: "https://arweave.net", + MANIFEST_REFERENCE_NAME: "", MANIFEST_TX_ID: "" }; @@ -50,6 +53,8 @@ const parseConfig = async () => { BLOG_NAME: pick("BLOG_NAME"), BLOG_SITE_URL: pick("BLOG_SITE_URL"), BLOG_DEFAULT_DESCRIPTION: pick("BLOG_DEFAULT_DESCRIPTION"), + ARWEAVE_GATEWAY: pick("ARWEAVE_GATEWAY"), + MANIFEST_REFERENCE_NAME: pick("MANIFEST_REFERENCE_NAME"), MANIFEST_TX_ID: pick("MANIFEST_TX_ID") }; }; @@ -116,13 +121,81 @@ const parseManifest = (input) => { }); }; +const timestampMs = (value) => { + if (typeof value !== "string" || !value) return 0; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const manifestFreshness = (payload, posts) => + Math.max( + timestampMs(payload?.updated), + timestampMs(payload?.date), + timestampMs(payload?.generatedAt), + ...posts.map((post) => Math.max(timestampMs(post.updated), timestampMs(post.publishedAt))) + ); + 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 posts = parseManifest(payload); + return { txId: manifestTxId, posts, freshness: manifestFreshness(payload, posts) }; +}; + +const resolveManifestTxIds = async (config) => { + const candidates = []; + if (config.MANIFEST_REFERENCE_NAME) { + try { + 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) candidates.push(value); + else throw new Error(`Reference ${config.MANIFEST_REFERENCE_NAME} did not resolve to a manifest id`); + } catch (error) { + console.warn( + `[prerender-meta] Trying configured manifest; reference ${config.MANIFEST_REFERENCE_NAME} failed: ${error.message}` + ); + } + } + + if (config.MANIFEST_TX_ID) candidates.push(config.MANIFEST_TX_ID); + return [...new Set(candidates)]; +}; + +const fetchBestManifestPosts = async (config) => { + const candidates = await resolveManifestTxIds(config); + const loaded = []; + + for (const manifestTxId of candidates) { + try { + loaded.push(await fetchManifestPosts(manifestTxId)); + } catch (error) { + console.warn(`[prerender-meta] Manifest ${manifestTxId} failed: ${error.message}`); + } + } + + if (!loaded.length) return []; + loaded.sort((a, b) => b.freshness - a.freshness); + return loaded[0].posts; +}; + +const resolveManifestTxId = async (config) => { + if (config.MANIFEST_REFERENCE_NAME) { + try { + 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`); + } catch (error) { + console.warn( + `[prerender-meta] Falling back to configured manifest; reference ${config.MANIFEST_REFERENCE_NAME} failed: ${error.message}` + ); + } + } + + return config.MANIFEST_TX_ID; }; const buildMetaTags = ({ @@ -197,7 +270,7 @@ const main = async () => { let posts = []; try { - posts = await fetchManifestPosts(config.MANIFEST_TX_ID); + posts = await fetchBestManifestPosts(config); } catch (error) { console.warn( `[prerender-meta] Could not fetch manifest; generating root metadata only: ${error.message}` diff --git a/src/config.ts b/src/config.ts index db12b08..3b2d889 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,11 @@ export const BLOG_NAME = "hyperzine"; -export const BLOG_SITE_URL = "https://386900368387k.arweave.net/" -export const BLOG_PERMALINK_PREFIX = "https://386900368387k.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 = "yVgW1MbkWYc76paeQZ92RK4GmzCXYiFLn_Z_1K7RzrE"; +export const MANIFEST_REFERENCE_NAME = "mystic"; +export const MANIFEST_TX_ID = "SShELKFa2EsiB-OJbeoNOjAIxgah9OmRhxYXBF_M9wA"; export const ARWEAVE_GATEWAY = "https://arweave.net"; export const AO_URL = "https://push-1.forward.computer"; diff --git a/src/lib.ts b/src/lib.ts index 89b6753..656d250 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,7 +1,8 @@ import DOMPurify from "dompurify"; import { marked, type Tokens } from "marked"; import { parse as parseYaml } from "yaml"; -import { ARWEAVE_GATEWAY, MANIFEST_TX_ID } from "./config"; +import { ReferenceClient } from "@permaweb/references"; +import { ARWEAVE_GATEWAY, MANIFEST_REFERENCE_NAME, MANIFEST_TX_ID } from "./config"; import { parseManifest, type Frontmatter, type ManifestPost } from "./types"; const formatDate = (date: string): string => @@ -72,13 +73,80 @@ export const getReadableDate = (date?: string | null): string | null => { }; export const loadManifest = async (): Promise => { - const response = await fetch(arweaveUrl(MANIFEST_TX_ID)); + return (await loadBestManifest()).posts; +}; + +const timestampMs = (value: unknown): number => { + if (typeof value !== "string" || !value) return 0; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const manifestFreshness = (payload: unknown, posts: ManifestPost[]): number => { + const root = isObject(payload) ? payload : {}; + return Math.max( + timestampMs(root.updated), + timestampMs(root.date), + timestampMs(root.generatedAt), + ...posts.map((post) => Math.max(timestampMs(post.updated), timestampMs(post.publishedAt))) + ); +}; + +const fetchManifest = async (manifestTxId: string): Promise<{ + txId: string; + posts: ManifestPost[]; + freshness: number; +}> => { + 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); + const posts = parseManifest(payload); + return { txId: manifestTxId, posts, freshness: manifestFreshness(payload, posts) }; +}; + +const resolveManifestCandidates = async (): Promise => { + const candidates: string[] = []; + if (MANIFEST_REFERENCE_NAME) { + try { + const names = new ReferenceClient({ gateway: ARWEAVE_GATEWAY }); + const value = await names.resolveName(MANIFEST_REFERENCE_NAME); + if (typeof value === "string" && value.length > 0) candidates.push(value); + else throw new Error(`Reference ${MANIFEST_REFERENCE_NAME} did not resolve to a manifest id`); + } catch (error) { + console.warn( + `Could not resolve manifest reference ${MANIFEST_REFERENCE_NAME}; trying configured manifest`, + error + ); + } + } + + if (MANIFEST_TX_ID) candidates.push(MANIFEST_TX_ID); + return [...new Set(candidates)]; +}; + +const loadBestManifest = async () => { + const candidates = await resolveManifestCandidates(); + if (!candidates.length) throw new Error("No manifest reference or fallback manifest configured"); + + const loaded = []; + for (const candidate of candidates) { + try { + loaded.push(await fetchManifest(candidate)); + } catch (error) { + console.warn(`Could not load manifest ${candidate}; trying other configured candidates`, error); + } + } + + if (!loaded.length) throw new Error("No configured manifest could be loaded"); + loaded.sort((a, b) => b.freshness - a.freshness); + return loaded[0]; +}; + +export const resolveManifestTxId = async (): Promise => { + return (await loadBestManifest()).txId; }; const isObject = (value: unknown): value is Record =>