Files
hyperzine/src/lib.ts

227 lines
7.0 KiB
TypeScript

import DOMPurify from "dompurify";
import { marked, type Tokens } from "marked";
import { parse as parseYaml } from "yaml";
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 =>
new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric"
});
marked.setOptions({
gfm: true,
breaks: false
});
const escapeHtml = (value: string): string =>
value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
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 => {
if (!date) return null;
const parsed = Date.parse(date);
if (Number.isNaN(parsed)) return null;
return formatDate(date);
};
export const loadManifest = async (): Promise<ManifestPost[]> => {
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();
const posts = parseManifest(payload);
return { txId: manifestTxId, posts, freshness: manifestFreshness(payload, posts) };
};
const resolveManifestCandidates = async (): Promise<string[]> => {
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<string> => {
return (await loadBestManifest()).txId;
};
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const asStringOrUndefined = (value: unknown): string | undefined =>
typeof value === "string" ? value : undefined;
const asBannerTxId = (value: unknown): string | undefined => {
if (typeof value === "string") return value;
if (!isObject(value)) return undefined;
return (
asStringOrUndefined(value.txId) ??
asStringOrUndefined(value.id) ??
asStringOrUndefined(value.src) ??
asStringOrUndefined(value.url)
);
};
const asStringArray = (value: unknown): string[] =>
Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
const parseFrontmatter = (input: unknown): Frontmatter => {
if (!isObject(input)) return {};
return {
title: asStringOrUndefined(input.title),
desc: asStringOrUndefined(input.desc),
description: asStringOrUndefined(input.description),
excerpt: asStringOrUndefined(input.excerpt),
slug: asStringOrUndefined(input.slug),
banner: asBannerTxId(input.banner),
date: asStringOrUndefined(input.date),
updated: asStringOrUndefined(input.updated),
tags: asStringArray(input.tags),
categories: asStringArray(input.categories)
};
};
export interface PostContent {
html: string;
frontmatter: Frontmatter;
}
const FRONTMATTER_PATTERN = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/;
const splitFrontmatter = (
markdown: string
): { frontmatter: Frontmatter; content: string } => {
const match = markdown.match(FRONTMATTER_PATTERN);
if (!match) {
return { frontmatter: {}, content: markdown };
}
const parsed = parseYaml(match[1]);
const frontmatter = parseFrontmatter(parsed);
return {
frontmatter,
content: markdown.slice(match[0].length)
};
};
export const loadPostContent = async (txId: string): Promise<PostContent> => {
const response = await fetch(arweaveUrl(txId));
if (!response.ok) {
throw new Error(`Failed to load post (${response.status})`);
}
const markdown = await response.text();
const { frontmatter, content } = splitFrontmatter(markdown);
const html = await marked.parse(content);
return {
html: DOMPurify.sanitize(html),
frontmatter
};
};