init
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
.vite/
|
||||||
|
.cache/
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
wallet.json
|
||||||
18
AGENTS.md
Normal file
18
AGENTS.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
Look at bundle-uploader/arghost.js for a guide on how the manifest to power this blog is generated.
|
||||||
|
|
||||||
|
We are starting with arweave.net/VKzmyjIucGm2t9XBYNXpP0aolCD10CfEkivJvRnTpp4 as the manifest.
|
||||||
|
|
||||||
|
Inside you have a JSON of arweave.net txids for the blog, which need rendering as an index and as /slug -> load single post.
|
||||||
|
|
||||||
|
We want:
|
||||||
|
|
||||||
|
* Markdown -> html engine, use something that exists and is the robust standard
|
||||||
|
* A minimal blog template. Light theme, only black and white.
|
||||||
|
* Pretty individual post view, with metadata from the manifest
|
||||||
|
* Nice index view
|
||||||
|
|
||||||
|
The look and feel is a modern, elegant, developer-focused light-theme blog. No rounded corners.
|
||||||
|
|
||||||
|
Ideal flow going forward: I update some const with the manifest view whenever I make an update to the blog.
|
||||||
|
|
||||||
|
The blog is called Hyperzine.
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>hyperzine</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2167
package-lock.json
generated
Normal file
2167
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "hyperzine",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"wallet:new": "node scripts/generate-wallet.mjs",
|
||||||
|
"deploy": "node scripts/deploy.mjs --app-name=hyperzine --app-version=1.0.0",
|
||||||
|
"deploy:up": "npm run build && node scripts/deploy-up.mjs",
|
||||||
|
"ship": "node scripts/ship.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"arweave": "^1.15.7",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
|
"marked": "^15.0.12",
|
||||||
|
"mime-types": "^3.0.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.5.3",
|
||||||
|
"warp-arbundles": "^1.0.4",
|
||||||
|
"yaml": "^2.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.15.0",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
scripts/deploy-up.mjs
Normal file
47
scripts/deploy-up.mjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
|
||||||
|
function parseArg(name) {
|
||||||
|
const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`));
|
||||||
|
if (!raw) return "";
|
||||||
|
return raw.slice(name.length + 3).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNodeScript(scriptPath, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit"
|
||||||
|
});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Deploy failed with exit code ${code}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const walletArg = parseArg("wallet");
|
||||||
|
const gatewayArg = parseArg("gateway");
|
||||||
|
const appName = parseArg("app-name");
|
||||||
|
const appVersion = parseArg("app-version");
|
||||||
|
|
||||||
|
await runNodeScript(path.join(root, "scripts", "deploy.mjs"), [
|
||||||
|
"--upload-mode=up",
|
||||||
|
...(walletArg ? [`--wallet=${walletArg}`] : []),
|
||||||
|
...(gatewayArg ? [`--gateway=${gatewayArg}`] : []),
|
||||||
|
...(appName ? [`--app-name=${appName}`] : []),
|
||||||
|
...(appVersion ? [`--app-version=${appVersion}`] : [])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error?.message || String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
356
scripts/deploy.mjs
Normal file
356
scripts/deploy.mjs
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import Arweave from "arweave";
|
||||||
|
import mime from "mime-types";
|
||||||
|
import arbundles from "warp-arbundles";
|
||||||
|
|
||||||
|
const { createData, ArweaveSigner } = arbundles;
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const distDir = path.join(root, "dist");
|
||||||
|
const sourceConfigPath = path.join(root, "src", "config.ts");
|
||||||
|
const DEFAULT_GATEWAY = "https://arweave.net";
|
||||||
|
const DEFAULT_UPLOAD_MODE = "arweave";
|
||||||
|
const UP_UPLOAD_MODE = "up";
|
||||||
|
const SUPPORTED_UPLOAD_MODES = new Set([DEFAULT_UPLOAD_MODE, UP_UPLOAD_MODE]);
|
||||||
|
const DEFAULT_UP_UPLOAD_SERVICE = "https://up.arweave.net";
|
||||||
|
|
||||||
|
function parseArg(name) {
|
||||||
|
const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`));
|
||||||
|
if (!raw) return "";
|
||||||
|
return raw.slice(name.length + 3).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGatewayConfig(gateway) {
|
||||||
|
const parsed = new URL(gateway);
|
||||||
|
return {
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80,
|
||||||
|
protocol: parsed.protocol.replace(":", "")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(targetPath) {
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFiles(dir) {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
const nested = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const absolutePath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return listFiles(absolutePath);
|
||||||
|
return [absolutePath];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return nested.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBinary(command, args, cwd) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdoutChunks = [];
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ stdout: Buffer.concat(stdoutChunks), stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCodeArchiveBuffer() {
|
||||||
|
const { stdout } = await runBinary(
|
||||||
|
"tar",
|
||||||
|
["--exclude=./node_modules", "--exclude=./wallet.json", "-cf", "-", "."],
|
||||||
|
root
|
||||||
|
);
|
||||||
|
return gzipSync(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWallet(arweave, walletArg) {
|
||||||
|
const envWallet = process.env.ARWEAVE_WALLET?.trim();
|
||||||
|
|
||||||
|
if (walletArg) {
|
||||||
|
const absoluteWalletPath = path.resolve(root, walletArg);
|
||||||
|
if (!(await pathExists(absoluteWalletPath))) {
|
||||||
|
throw new Error(`Wallet file not found: ${absoluteWalletPath}`);
|
||||||
|
}
|
||||||
|
const jwkRaw = await fs.readFile(absoluteWalletPath, "utf8");
|
||||||
|
return { walletPath: absoluteWalletPath, generated: false, jwk: JSON.parse(jwkRaw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envWallet) {
|
||||||
|
const absoluteWalletPath = path.resolve(root, envWallet);
|
||||||
|
if (!(await pathExists(absoluteWalletPath))) {
|
||||||
|
throw new Error(`ARWEAVE_WALLET points to a missing file: ${absoluteWalletPath}`);
|
||||||
|
}
|
||||||
|
const jwkRaw = await fs.readFile(absoluteWalletPath, "utf8");
|
||||||
|
return { walletPath: absoluteWalletPath, generated: false, jwk: JSON.parse(jwkRaw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultWalletPath = path.join(root, "wallet.json");
|
||||||
|
if (!(await pathExists(defaultWalletPath))) {
|
||||||
|
const jwk = await arweave.wallets.generate();
|
||||||
|
await fs.writeFile(defaultWalletPath, `${JSON.stringify(jwk, null, 2)}\n`, "utf8");
|
||||||
|
return { walletPath: defaultWalletPath, generated: true, jwk };
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwkRaw = await fs.readFile(defaultWalletPath, "utf8");
|
||||||
|
return { walletPath: defaultWalletPath, generated: false, jwk: JSON.parse(jwkRaw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadTransaction(arweave, jwk, data, tags) {
|
||||||
|
const tx = await arweave.createTransaction({ data }, jwk);
|
||||||
|
for (const [key, value] of tags) tx.addTag(key, value);
|
||||||
|
|
||||||
|
await arweave.transactions.sign(tx, jwk);
|
||||||
|
const uploader = await arweave.transactions.getUploader(tx);
|
||||||
|
while (!uploader.isComplete) await uploader.uploadChunk();
|
||||||
|
return tx.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetriableUploadError(message) {
|
||||||
|
return (
|
||||||
|
message.includes("timeout") ||
|
||||||
|
message.includes("504") ||
|
||||||
|
message.includes("502") ||
|
||||||
|
message.includes("503") ||
|
||||||
|
message.includes("429") ||
|
||||||
|
message.includes("EAI_AGAIN") ||
|
||||||
|
message.includes("ECONNRESET") ||
|
||||||
|
message.includes("failed to fetch")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWithUpService(serviceUrl, signer, data, tags) {
|
||||||
|
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
const dataItem = createData(payload, signer, {
|
||||||
|
tags: tags.map(([name, value]) => ({ name, value }))
|
||||||
|
});
|
||||||
|
await dataItem.sign(signer);
|
||||||
|
const txId = await dataItem.id;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serviceUrl.replace(/\/+$/, "")}/tx`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
body: dataItem.getRaw()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodyText = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`up upload failed (${response.status}${bodyText ? `): ${bodyText.slice(0, 220)}` : ")"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseTxId = "";
|
||||||
|
try {
|
||||||
|
const bodyJson = JSON.parse(bodyText);
|
||||||
|
if (typeof bodyJson?.id === "string") responseTxId = bodyJson.id.trim();
|
||||||
|
} catch {
|
||||||
|
responseTxId = bodyText.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseTxId || txId;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || String(error);
|
||||||
|
if (!isRetriableUploadError(message) || attempt === 3) throw error;
|
||||||
|
await sleep(1500 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("up upload failed unexpectedly.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUploadMode(uploadModeArg) {
|
||||||
|
const mode = (uploadModeArg || process.env.ARWEAVE_UPLOAD_MODE || DEFAULT_UPLOAD_MODE).trim();
|
||||||
|
if (!SUPPORTED_UPLOAD_MODES.has(mode)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported upload mode "${mode}". Supported values: ${[...SUPPORTED_UPLOAD_MODES].join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUploader(uploadMode, arweave, jwk) {
|
||||||
|
if (uploadMode === DEFAULT_UPLOAD_MODE) {
|
||||||
|
return {
|
||||||
|
mode: DEFAULT_UPLOAD_MODE,
|
||||||
|
serviceUrl: null,
|
||||||
|
async beforeUpload() {},
|
||||||
|
upload(data, tags) {
|
||||||
|
return uploadTransaction(arweave, jwk, data, tags);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const signer = new ArweaveSigner(jwk);
|
||||||
|
return {
|
||||||
|
mode: UP_UPLOAD_MODE,
|
||||||
|
serviceUrl: DEFAULT_UP_UPLOAD_SERVICE,
|
||||||
|
async beforeUpload() {},
|
||||||
|
upload(data, tags) {
|
||||||
|
return uploadWithUpService(DEFAULT_UP_UPLOAD_SERVICE, signer, data, tags);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBlogManifestTxId() {
|
||||||
|
const source = await fs.readFile(sourceConfigPath, "utf8");
|
||||||
|
const match = source.match(/MANIFEST_TX_ID\s*=\s*"([^"]+)"/);
|
||||||
|
return match?.[1] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePostSlugs(gateway) {
|
||||||
|
const blogManifestTxId = await resolveBlogManifestTxId();
|
||||||
|
if (!blogManifestTxId) return [];
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const walletArg = parseArg("wallet");
|
||||||
|
const gatewayArg = parseArg("gateway");
|
||||||
|
const uploadModeArg = parseArg("upload-mode");
|
||||||
|
const appName = parseArg("app-name") || "hyperzine";
|
||||||
|
const appVersion = parseArg("app-version") || "1.0.0";
|
||||||
|
const uploadMode = resolveUploadMode(uploadModeArg);
|
||||||
|
const gateway = gatewayArg || process.env.ARWEAVE_GATEWAY || DEFAULT_GATEWAY;
|
||||||
|
|
||||||
|
if (!(await pathExists(distDir))) {
|
||||||
|
throw new Error("dist/ is missing. Run npm run build first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const arweave = Arweave.init({
|
||||||
|
...buildGatewayConfig(gateway),
|
||||||
|
timeout: 30_000,
|
||||||
|
logging: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { walletPath, generated, jwk } = await loadWallet(arweave, walletArg);
|
||||||
|
console.log(`${generated ? "Generated" : "Using"} wallet: ${walletPath}`);
|
||||||
|
console.log(`Gateway: ${gateway}`);
|
||||||
|
console.log(`Upload mode: ${uploadMode}`);
|
||||||
|
|
||||||
|
const uploader = await createUploader(uploadMode, arweave, jwk);
|
||||||
|
if (uploader.serviceUrl) console.log(`Upload service: ${uploader.serviceUrl}`);
|
||||||
|
await uploader.beforeUpload();
|
||||||
|
|
||||||
|
const filePaths = await listFiles(distDir);
|
||||||
|
if (!filePaths.length) {
|
||||||
|
throw new Error("dist/ is empty. Run npm run build first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeArchiveData = await createCodeArchiveBuffer();
|
||||||
|
const codeArchiveId = await uploader.upload(codeArchiveData, [
|
||||||
|
["Content-Type", "application/gzip"],
|
||||||
|
["Content-Encoding", "gzip"],
|
||||||
|
["App-Name", appName],
|
||||||
|
["App-Version", appVersion],
|
||||||
|
["Type", "code-archive"]
|
||||||
|
]);
|
||||||
|
console.log(`Uploaded code archive: ${codeArchiveId}`);
|
||||||
|
|
||||||
|
const pathMap = {};
|
||||||
|
let indexTxId = "";
|
||||||
|
|
||||||
|
for (const absolutePath of filePaths) {
|
||||||
|
const relativePath = path.relative(distDir, absolutePath).replace(/\\/g, "/");
|
||||||
|
const contentType = mime.lookup(relativePath) || "application/octet-stream";
|
||||||
|
const data = await fs.readFile(absolutePath);
|
||||||
|
|
||||||
|
const txId = await uploader.upload(data, [
|
||||||
|
["Content-Type", String(contentType)],
|
||||||
|
["App-Name", appName],
|
||||||
|
["App-Version", appVersion],
|
||||||
|
["Type", "app-asset"],
|
||||||
|
["File-Path", relativePath],
|
||||||
|
["code", codeArchiveId]
|
||||||
|
]);
|
||||||
|
|
||||||
|
pathMap[relativePath] = { id: txId };
|
||||||
|
if (relativePath === "index.html") indexTxId = txId;
|
||||||
|
console.log(`Uploaded ${relativePath}: ${txId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!indexTxId) {
|
||||||
|
throw new Error("Could not find uploaded index.html. Dist output is invalid for SPA deploy.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugs = await resolvePostSlugs(gateway);
|
||||||
|
for (const slug of slugs) {
|
||||||
|
pathMap[slug] = { id: indexTxId };
|
||||||
|
pathMap[`${slug}/`] = { id: indexTxId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
manifest: "arweave/paths",
|
||||||
|
version: "0.2.0",
|
||||||
|
index: { path: "index.html" },
|
||||||
|
paths: pathMap
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifestId = await uploader.upload(JSON.stringify(manifest), [
|
||||||
|
["Content-Type", "application/x.arweave-manifest+json"],
|
||||||
|
["App-Name", appName],
|
||||||
|
["App-Version", appVersion],
|
||||||
|
["Type", "manifest"],
|
||||||
|
["code", codeArchiveId]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appUrl = `${gateway.replace(/\/+$/, "")}/${manifestId}/`;
|
||||||
|
const codeArchiveUrl = `${gateway.replace(/\/+$/, "")}/${codeArchiveId}`;
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log(`Code Archive ID: ${codeArchiveId}`);
|
||||||
|
console.log(`Code Archive URL: ${codeArchiveUrl}`);
|
||||||
|
console.log(`Manifest ID (AppArweaveID): ${manifestId}`);
|
||||||
|
console.log(`App URL: ${appUrl}`);
|
||||||
|
if (slugs.length) {
|
||||||
|
console.log(`Slug route aliases: ${slugs.join(", ")}`);
|
||||||
|
} else {
|
||||||
|
console.log("Slug route aliases: none generated.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error?.message || String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
25
scripts/generate-wallet.mjs
Normal file
25
scripts/generate-wallet.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import Arweave from "arweave";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const walletPath = path.join(root, "wallet.json");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const arweave = Arweave.init({
|
||||||
|
host: "arweave.net",
|
||||||
|
port: 443,
|
||||||
|
protocol: "https",
|
||||||
|
timeout: 30_000,
|
||||||
|
logging: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const jwk = await arweave.wallets.generate();
|
||||||
|
await fs.writeFile(walletPath, `${JSON.stringify(jwk, null, 2)}\n`, "utf8");
|
||||||
|
console.log(`Generated wallet: ${walletPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error?.message || String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
100
scripts/ship.mjs
Normal file
100
scripts/ship.mjs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const nodeModulesPath = path.join(root, "node_modules");
|
||||||
|
const walletPath = path.join(root, "wallet.json");
|
||||||
|
|
||||||
|
async function pathExists(targetPath) {
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: root,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
stdout += text;
|
||||||
|
process.stdout.write(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
stderr += text;
|
||||||
|
process.stderr.write(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLink(output, label) {
|
||||||
|
const regex = new RegExp(`${label}:\\s*(https?:\\/\\/\\S+)`);
|
||||||
|
const match = output.match(regex);
|
||||||
|
return match?.[1] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDependencies() {
|
||||||
|
if (await pathExists(nodeModulesPath)) {
|
||||||
|
console.log("Dependencies present: skipping npm install.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Dependencies missing: running npm install...");
|
||||||
|
await runCommand("npm", ["install"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWallet() {
|
||||||
|
if (await pathExists(walletPath)) {
|
||||||
|
console.log("wallet.json present: skipping wallet:new.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("wallet.json missing: running npm run wallet:new...");
|
||||||
|
await runCommand("npm", ["run", "wallet:new"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureDependencies();
|
||||||
|
await ensureWallet();
|
||||||
|
|
||||||
|
console.log("Running deploy pipeline: npm run deploy:up");
|
||||||
|
const deployResult = await runCommand("npm", ["run", "deploy:up"]);
|
||||||
|
|
||||||
|
const appUrl = extractLink(deployResult.stdout, "App URL");
|
||||||
|
const codeArchiveUrl = extractLink(deployResult.stdout, "Code Archive URL");
|
||||||
|
|
||||||
|
if (!appUrl || !codeArchiveUrl) {
|
||||||
|
throw new Error(
|
||||||
|
"Deploy step did not emit required links (App URL and Code Archive URL)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log("Ship completed.");
|
||||||
|
console.log(`App URL: ${appUrl}`);
|
||||||
|
console.log(`Code Archive URL: ${codeArchiveUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error?.message || String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
248
src/App.tsx
Normal file
248
src/App.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||||
|
import { BLOG_NAME } from "./config";
|
||||||
|
import { arweaveUrl, getReadableDate, loadManifest, loadPostContent } from "./lib";
|
||||||
|
import type { Frontmatter, ManifestPost } from "./types";
|
||||||
|
|
||||||
|
type LoadState = "idle" | "loading" | "error";
|
||||||
|
|
||||||
|
function BlisterLoader() {
|
||||||
|
return (
|
||||||
|
<div className="loader-screen" aria-live="polite" aria-label="Loading article">
|
||||||
|
<span className="loader-square" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [posts, setPosts] = useState<ManifestPost[]>([]);
|
||||||
|
const [state, setState] = useState<LoadState>("idle");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
setState("loading");
|
||||||
|
const data = await loadManifest();
|
||||||
|
if (!ignore) {
|
||||||
|
setPosts(
|
||||||
|
[...data].sort((a, b) => {
|
||||||
|
const aDate = a.publishedAt ?? "";
|
||||||
|
const bDate = b.publishedAt ?? "";
|
||||||
|
return aDate < bDate ? 1 : -1;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setState("idle");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!ignore) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load manifest");
|
||||||
|
setState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="header">
|
||||||
|
<Link to="/" className="brand">
|
||||||
|
<span className="brand-mark" aria-hidden="true">
|
||||||
|
<span className="brand-square brand-square-red" />
|
||||||
|
<span className="brand-square brand-square-purple" />
|
||||||
|
<span className="brand-square brand-square-blue" />
|
||||||
|
<span className="brand-square brand-square-yellow" />
|
||||||
|
<span className="brand-square brand-square-green" />
|
||||||
|
</span>
|
||||||
|
<span className="brand-text">{BLOG_NAME}</span>
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
<main className="content">
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<IndexPage posts={posts} state={state} error={error} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/:slug"
|
||||||
|
element={<PostPage posts={posts} state={state} manifestError={error} />}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndexPage({
|
||||||
|
posts,
|
||||||
|
state,
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
posts: ManifestPost[];
|
||||||
|
state: LoadState;
|
||||||
|
error: string;
|
||||||
|
}) {
|
||||||
|
if (state === "loading") return <BlisterLoader />;
|
||||||
|
if (state === "error") return <p className="status">Error: {error}</p>;
|
||||||
|
if (posts.length === 0) return <p className="status">No posts yet.</p>;
|
||||||
|
|
||||||
|
const hasFeatured = posts.length >= 3;
|
||||||
|
const featuredPost = hasFeatured ? posts[0] : null;
|
||||||
|
const gridPosts = hasFeatured ? posts.slice(1) : posts;
|
||||||
|
|
||||||
|
const renderPost = (post: ManifestPost) => {
|
||||||
|
const publishedDate = getReadableDate(post.publishedAt);
|
||||||
|
const bannerTxId = post.frontmatter?.banner ?? post.bannerTxId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{bannerTxId && (
|
||||||
|
<Link to={`/${post.slug}`} className="banner-link">
|
||||||
|
<img
|
||||||
|
className="post-banner"
|
||||||
|
src={arweaveUrl(bannerTxId)}
|
||||||
|
alt={`${post.title} banner`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link to={`/${post.slug}`} className="post-title-link">
|
||||||
|
<h2 className="post-title">{post.title}</h2>
|
||||||
|
</Link>
|
||||||
|
<p className="post-description">
|
||||||
|
{post.description || post.excerpt || "No description provided."}
|
||||||
|
</p>
|
||||||
|
<div className="meta-row">
|
||||||
|
{publishedDate && <span>{publishedDate}</span>}
|
||||||
|
{post.readingTime && <span>{post.readingTime} min read</span>}
|
||||||
|
{post.wordCount && <span>{post.wordCount} words</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="index">
|
||||||
|
{featuredPost && (
|
||||||
|
<article className="post-card post-card-featured" key={featuredPost.postTxId}>
|
||||||
|
{renderPost(featuredPost)}
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
<div className="index-grid">
|
||||||
|
{gridPosts.map((post) => (
|
||||||
|
<article key={post.postTxId} className="post-card">
|
||||||
|
{renderPost(post)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostPage({
|
||||||
|
posts,
|
||||||
|
state,
|
||||||
|
manifestError
|
||||||
|
}: {
|
||||||
|
posts: ManifestPost[];
|
||||||
|
state: LoadState;
|
||||||
|
manifestError: string;
|
||||||
|
}) {
|
||||||
|
const { slug = "" } = useParams();
|
||||||
|
const post = useMemo(() => posts.find((entry) => entry.slug === slug), [posts, slug]);
|
||||||
|
const [html, setHtml] = useState<string>("");
|
||||||
|
const [postFrontmatter, setPostFrontmatter] = useState<Frontmatter>({});
|
||||||
|
const [postState, setPostState] = useState<LoadState>("idle");
|
||||||
|
const [postError, setPostError] = useState<string>("");
|
||||||
|
const [showPostLoader, setShowPostLoader] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (postState !== "loading") {
|
||||||
|
setShowPostLoader(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => setShowPostLoader(true), 300);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [postState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
if (!post) return;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
setPostError("");
|
||||||
|
setPostState("loading");
|
||||||
|
const { html: nextHtml, frontmatter } = await loadPostContent(post.postTxId);
|
||||||
|
if (!ignore) {
|
||||||
|
setHtml(nextHtml);
|
||||||
|
setPostFrontmatter(frontmatter);
|
||||||
|
setPostState("idle");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!ignore) {
|
||||||
|
setPostError(err instanceof Error ? err.message : "Failed to load post");
|
||||||
|
setPostState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [post]);
|
||||||
|
|
||||||
|
if (state === "loading") return <BlisterLoader />;
|
||||||
|
if (state === "error") return <p className="status">Error: {manifestError}</p>;
|
||||||
|
if (!post) return <p className="status">Post not found.</p>;
|
||||||
|
if (postState === "loading") return showPostLoader ? <BlisterLoader /> : null;
|
||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="post">
|
||||||
|
<header className="post-header">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{description}</p>
|
||||||
|
<div className="meta-row">
|
||||||
|
{publishedDate && <span>Published {publishedDate}</span>}
|
||||||
|
{updatedDate && <span>Updated {updatedDate}</span>}
|
||||||
|
{post.readingTime && <span>{post.readingTime} min read</span>}
|
||||||
|
{post.wordCount && <span>{post.wordCount} words</span>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{bannerTxId && (
|
||||||
|
<div className="post-hero">
|
||||||
|
<img className="post-banner" src={arweaveUrl(bannerTxId)} alt={`${title} banner`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section
|
||||||
|
className="article"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
6
src/config.ts
Normal file
6
src/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const BLOG_NAME = "hyperzine";
|
||||||
|
export const MANIFEST_TX_ID = "Zjc1hce_CgyNrq7qqufrJ20ra7IvNU7t8EnK2QA1EHE";
|
||||||
|
export const ARWEAVE_GATEWAY = "https://arweave.net";
|
||||||
|
|
||||||
|
export const AO_URL = "https://push-1.forward.computer";
|
||||||
|
export const AO_PROCESS_ID = "fiZ72JP4b6vOuy9PGzqTJo3JLG_LJRmjfZoI6z8E3n4";
|
||||||
185
src/lib.ts
Normal file
185
src/lib.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
|
import {
|
||||||
|
AO_PROCESS_ID,
|
||||||
|
AO_URL,
|
||||||
|
ARWEAVE_GATEWAY,
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 getLatestManifestFromAo = async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const pushResponse = await fetch(`${AO_URL}/${AO_PROCESS_ID}~process@1.0/push`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"signing-format": "ans104",
|
||||||
|
"accept-bundle": "true",
|
||||||
|
"require-codec": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
Type: "Message",
|
||||||
|
"Data-Protocol": "ao",
|
||||||
|
Variant: "ao.N.1",
|
||||||
|
Action: "Get",
|
||||||
|
target: AO_PROCESS_ID,
|
||||||
|
data: "1984"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!pushResponse.ok) return null;
|
||||||
|
|
||||||
|
const pushPayload = (await pushResponse.json()) as unknown;
|
||||||
|
const slot = asString(asObject(pushPayload)?.slot);
|
||||||
|
if (!slot) return null;
|
||||||
|
|
||||||
|
const computeResponse = await fetch(`${AO_URL}/${AO_PROCESS_ID}~process@1.0/compute=${slot}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"signing-format": "ans104",
|
||||||
|
"accept-bundle": "true",
|
||||||
|
"require-codec": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
target: AO_PROCESS_ID,
|
||||||
|
data: "1984"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!computeResponse.ok) return null;
|
||||||
|
|
||||||
|
const computePayload = (await computeResponse.json()) as unknown;
|
||||||
|
const body = asObject(asObject(asObject(computePayload)?.results)?.json)?.body;
|
||||||
|
const parsedBody = typeof body === "string" ? asObject(JSON.parse(body)) : asObject(body);
|
||||||
|
const messages = Array.isArray(parsedBody?.Messages) ? parsedBody.Messages : [];
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const tags = Array.isArray(asObject(message)?.Tags) ? (asObject(message)?.Tags as unknown[]) : [];
|
||||||
|
for (const tag of tags) {
|
||||||
|
const tagObject = asObject(tag);
|
||||||
|
const name = asString(tagObject?.name) ?? asString(tagObject?.Name);
|
||||||
|
const value = asString(tagObject?.value) ?? asString(tagObject?.Value);
|
||||||
|
if (name === "LatestManifestId" && value) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadManifest = async (): Promise<ManifestPost[]> => {
|
||||||
|
const manifestTxId = (await getLatestManifestFromAo()) ?? MANIFEST_TX_ID;
|
||||||
|
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 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
|
||||||
|
};
|
||||||
|
};
|
||||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
303
src/styles.css
Normal file
303
src/styles.css
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
:root {
|
||||||
|
color: #0a0a0a;
|
||||||
|
background: #ffffff;
|
||||||
|
font-family: "IBM Plex Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
--brand-red: #f60000;
|
||||||
|
--brand-purple: #9611ff;
|
||||||
|
--brand-blue: #86dafe;
|
||||||
|
--brand-yellow: #fee55f;
|
||||||
|
--brand-green: #33f22f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #111111;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
border-bottom: 2px solid #111111;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1px;
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
display: inline-block;
|
||||||
|
transform: rotate(-3deg);
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-square {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-square-red {
|
||||||
|
background: var(--brand-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-square-purple {
|
||||||
|
background: var(--brand-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-square-blue {
|
||||||
|
background: var(--brand-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-square-yellow {
|
||||||
|
background: var(--brand-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-square-green {
|
||||||
|
background: var(--brand-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-screen {
|
||||||
|
min-height: calc(100vh - 160px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-square {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: var(--brand-red);
|
||||||
|
animation: blister-flicker 180ms steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blister-flicker {
|
||||||
|
0% {
|
||||||
|
background: var(--brand-red);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
background: var(--brand-purple);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
background: var(--brand-blue);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
background: var(--brand-yellow);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
background: var(--brand-green);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: var(--brand-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.index {
|
||||||
|
display: grid;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
column-gap: 28px;
|
||||||
|
row-gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid .post-card:nth-child(n + 3) {
|
||||||
|
border-top: 1px solid #111111;
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-featured {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-link {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-banner {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-description {
|
||||||
|
margin: 10px 0 12px;
|
||||||
|
font-size: 1.04rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row > span + span {
|
||||||
|
position: relative;
|
||||||
|
margin-left: 14px;
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row > span + span::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
width: 6px;
|
||||||
|
height: 1px;
|
||||||
|
background: #111111;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-topline {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-link {
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-size: clamp(2rem, 6vw, 3.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header p {
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 1.06rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-hero {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article {
|
||||||
|
margin-top: 32px;
|
||||||
|
border-top: 1px solid #111111;
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article h2,
|
||||||
|
.article h3,
|
||||||
|
.article h4 {
|
||||||
|
margin-top: 1.9em;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article pre {
|
||||||
|
border: 1px solid #111111;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article code {
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 2px solid #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 740px) {
|
||||||
|
.page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid .post-card:nth-child(n + 3) {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid .post-card + .post-card {
|
||||||
|
border-top: 1px solid #111111;
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/types.ts
Normal file
110
src/types.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
export interface Frontmatter {
|
||||||
|
title?: string;
|
||||||
|
desc?: string;
|
||||||
|
description?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
slug?: string;
|
||||||
|
banner?: string;
|
||||||
|
date?: string;
|
||||||
|
updated?: string;
|
||||||
|
tags?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManifestPost {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
excerpt?: string;
|
||||||
|
postTxId: string;
|
||||||
|
bannerTxId?: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
updated?: string | null;
|
||||||
|
readingTime?: number;
|
||||||
|
wordCount?: number;
|
||||||
|
tags?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
frontmatter?: Frontmatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManifestResponse {
|
||||||
|
posts?: ManifestPost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null;
|
||||||
|
|
||||||
|
const asString = (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 (
|
||||||
|
asString(value.txId) ??
|
||||||
|
asString(value.id) ??
|
||||||
|
asString(value.src) ??
|
||||||
|
asString(value.url)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const asStringArray = (value: unknown): string[] =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value.filter((entry): entry is string => typeof entry === "string")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const toFrontmatter = (value: unknown): Frontmatter | undefined => {
|
||||||
|
if (!isObject(value)) return undefined;
|
||||||
|
return {
|
||||||
|
title: asString(value.title),
|
||||||
|
desc: asString(value.desc),
|
||||||
|
description: asString(value.description),
|
||||||
|
excerpt: asString(value.excerpt),
|
||||||
|
slug: asString(value.slug),
|
||||||
|
banner: asBannerTxId(value.banner),
|
||||||
|
date: asString(value.date),
|
||||||
|
updated: asString(value.updated),
|
||||||
|
tags: asStringArray(value.tags),
|
||||||
|
categories: asStringArray(value.categories)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPost = (input: unknown): ManifestPost | null => {
|
||||||
|
if (!isObject(input)) return null;
|
||||||
|
|
||||||
|
const frontmatter = toFrontmatter(input.frontmatter);
|
||||||
|
const slug = asString(input.slug) ?? frontmatter?.slug;
|
||||||
|
const title = asString(input.title) ?? frontmatter?.title ?? "Untitled";
|
||||||
|
const postTxId = asString(input.postTxId);
|
||||||
|
if (!slug || !postTxId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
asString(input.description) ??
|
||||||
|
frontmatter?.desc ??
|
||||||
|
frontmatter?.description ??
|
||||||
|
"",
|
||||||
|
excerpt: asString(input.excerpt) ?? frontmatter?.excerpt,
|
||||||
|
postTxId,
|
||||||
|
bannerTxId: asBannerTxId(input.bannerTxId) ?? frontmatter?.banner,
|
||||||
|
publishedAt: asString(input.publishedAt) ?? frontmatter?.date,
|
||||||
|
updated: asString(input.updated) ?? frontmatter?.updated,
|
||||||
|
readingTime:
|
||||||
|
typeof input.readingTime === "number" ? input.readingTime : undefined,
|
||||||
|
wordCount: typeof input.wordCount === "number" ? input.wordCount : undefined,
|
||||||
|
tags: asStringArray(input.tags).length ? asStringArray(input.tags) : frontmatter?.tags ?? [],
|
||||||
|
categories: asStringArray(input.categories).length
|
||||||
|
? asStringArray(input.categories)
|
||||||
|
: frontmatter?.categories ?? [],
|
||||||
|
frontmatter
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseManifest = (input: unknown): ManifestPost[] => {
|
||||||
|
if (!isObject(input)) return [];
|
||||||
|
const { posts } = input as ManifestResponse;
|
||||||
|
if (!Array.isArray(posts)) return [];
|
||||||
|
return posts.map(toPost).filter((post): post is ManifestPost => post !== null);
|
||||||
|
};
|
||||||
20
tsconfig.app.json
Normal file
20
tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
2
vite.config.d.ts
vendored
Normal file
2
vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
export default defineConfig({
|
||||||
|
base: "./",
|
||||||
|
plugins: [react()]
|
||||||
|
});
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "./",
|
||||||
|
plugins: [react()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user