227 lines
7.0 KiB
TypeScript
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("<", "<")
|
|
.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 => {
|
|
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
|
|
};
|
|
};
|