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",
|
"name": "hyperzine",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@permaweb/references": "^0.1.1",
|
||||||
"arweave": "^1.15.7",
|
"arweave": "^1.15.7",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
@@ -811,6 +812,24 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"ship": "node scripts/ship.mjs"
|
"ship": "node scripts/ship.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@permaweb/references": "^0.1.1",
|
||||||
"arweave": "^1.15.7",
|
"arweave": "^1.15.7",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
|
|||||||
import Arweave from "arweave";
|
import Arweave from "arweave";
|
||||||
import mime from "mime-types";
|
import mime from "mime-types";
|
||||||
import arbundles from "warp-arbundles";
|
import arbundles from "warp-arbundles";
|
||||||
|
import { ReferenceClient } from "@permaweb/references";
|
||||||
|
|
||||||
const { createData, ArweaveSigner } = arbundles;
|
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 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] || "";
|
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() {
|
async function resolveAoConfig() {
|
||||||
const source = await fs.readFile(sourceConfigPath, "utf8");
|
const source = await fs.readFile(sourceConfigPath, "utf8");
|
||||||
const aoUrlMatch = source.match(/AO_URL\s*=\s*"([^"]+)"/);
|
const aoUrlMatch = source.match(/AO_URL\s*=\s*"([^"]+)"/);
|
||||||
@@ -308,21 +322,35 @@ async function getLatestManifestFromAo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePostSlugs(gateway) {
|
async function fetchPostSlugs(gateway, blogManifestTxId) {
|
||||||
const blogManifestTxId = (await getLatestManifestFromAo()) || (await resolveBlogManifestTxId());
|
if (!blogManifestTxId) return null;
|
||||||
if (!blogManifestTxId) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${gateway.replace(/\/+$/, "")}/${blogManifestTxId}`);
|
const response = await fetch(`${gateway.replace(/\/+$/, "")}/${blogManifestTxId}`);
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return null;
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (!Array.isArray(payload?.posts)) return [];
|
if (!Array.isArray(payload?.posts)) return null;
|
||||||
|
|
||||||
return payload.posts
|
return payload.posts
|
||||||
.map((post) => (typeof post?.slug === "string" ? post.slug.trim() : ""))
|
.map((post) => (typeof post?.slug === "string" ? post.slug.trim() : ""))
|
||||||
.filter((slug) => slug.length > 0);
|
.filter((slug) => slug.length > 0);
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function main() {
|
||||||
@@ -396,8 +424,9 @@ async function main() {
|
|||||||
|
|
||||||
const slugs = await resolvePostSlugs(gateway);
|
const slugs = await resolvePostSlugs(gateway);
|
||||||
for (const slug of slugs) {
|
for (const slug of slugs) {
|
||||||
pathMap[slug] = { id: indexTxId };
|
const routeIndexId = pathMap[`${slug}/index.html`]?.id || indexTxId;
|
||||||
pathMap[`${slug}/`] = { id: indexTxId };
|
pathMap[slug] = { id: routeIndexId };
|
||||||
|
pathMap[`${slug}/`] = { id: routeIndexId };
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { ReferenceClient } from "@permaweb/references";
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const DIST_DIR = path.join(ROOT, "dist");
|
const DIST_DIR = path.join(ROOT, "dist");
|
||||||
@@ -10,7 +11,8 @@ const DEFAULTS = {
|
|||||||
BLOG_NAME: "hyperzine",
|
BLOG_NAME: "hyperzine",
|
||||||
BLOG_SITE_URL: "https://hyperzine.xyz",
|
BLOG_SITE_URL: "https://hyperzine.xyz",
|
||||||
BLOG_DEFAULT_DESCRIPTION: "hyperzine",
|
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);
|
const asObject = (value) => (typeof value === "object" && value !== null ? value : null);
|
||||||
@@ -50,7 +52,8 @@ const parseConfig = async () => {
|
|||||||
BLOG_NAME: pick("BLOG_NAME"),
|
BLOG_NAME: pick("BLOG_NAME"),
|
||||||
BLOG_SITE_URL: pick("BLOG_SITE_URL"),
|
BLOG_SITE_URL: pick("BLOG_SITE_URL"),
|
||||||
BLOG_DEFAULT_DESCRIPTION: pick("BLOG_DEFAULT_DESCRIPTION"),
|
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 [];
|
if (!manifestTxId) return [];
|
||||||
const url = `https://arweave.net/${manifestTxId}`;
|
const gateway = (config.ARWEAVE_GATEWAY || DEFAULTS.ARWEAVE_GATEWAY).replace(/\/+$/, "");
|
||||||
const response = await fetch(url, { cache: "no-store" });
|
const response = await fetch(`${gateway}/${manifestTxId}`, { cache: "no-store" });
|
||||||
if (!response.ok) throw new Error(`Manifest fetch failed (${response.status})`);
|
if (!response.ok) throw new Error(`Manifest fetch failed (${response.status})`);
|
||||||
const payload = await response.json();
|
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 = ({
|
const buildMetaTags = ({
|
||||||
@@ -197,7 +215,7 @@ const main = async () => {
|
|||||||
|
|
||||||
let posts = [];
|
let posts = [];
|
||||||
try {
|
try {
|
||||||
posts = await fetchManifestPosts(config.MANIFEST_TX_ID);
|
posts = await fetchCurrentManifestPosts(config);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[prerender-meta] Could not fetch manifest; generating root metadata only: ${error.message}`
|
`[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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom";
|
import {
|
||||||
|
Link,
|
||||||
|
NavLink,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
useParams
|
||||||
|
} from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
BLOG_DEFAULT_DESCRIPTION,
|
BLOG_DEFAULT_DESCRIPTION,
|
||||||
BLOG_NAME,
|
BLOG_NAME,
|
||||||
BLOG_PERMALINK_PREFIX,
|
|
||||||
BLOG_SITE_URL,
|
BLOG_SITE_URL,
|
||||||
BLOG_TWITTER_HANDLE
|
BLOG_TWITTER_HANDLE
|
||||||
} from "./config";
|
} from "./config";
|
||||||
@@ -18,6 +24,16 @@ import load6 from "./load6.png";
|
|||||||
|
|
||||||
type LoadState = "idle" | "loading" | "error";
|
type LoadState = "idle" | "loading" | "error";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
twttr?: {
|
||||||
|
widgets?: {
|
||||||
|
load: (element?: HTMLElement | null) => void | Promise<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type MetaInput = {
|
type MetaInput = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -30,9 +46,65 @@ type MetaInput = {
|
|||||||
|
|
||||||
const LOADER_FRAMES = [load1, load2, load3, load4, load5, load6];
|
const LOADER_FRAMES = [load1, load2, load3, load4, load5, load6];
|
||||||
const FORCE_LOADER_PREVIEW = false;
|
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 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 => {
|
const setMetaTag = (attribute: "name" | "property", key: string, content?: string): void => {
|
||||||
if (!content) return;
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
@@ -169,6 +244,24 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="brand-text">{BLOG_NAME}</span>
|
<span className="brand-text">{BLOG_NAME}</span>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
@@ -178,13 +271,20 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
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
|
<Route
|
||||||
path="/:slug"
|
path="/:slug"
|
||||||
element={<PostPage posts={posts} state={state} manifestError={error} />}
|
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>
|
</Routes>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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({
|
function PostPage({
|
||||||
posts,
|
posts,
|
||||||
state,
|
state,
|
||||||
@@ -282,7 +431,8 @@ function PostPage({
|
|||||||
state: LoadState;
|
state: LoadState;
|
||||||
manifestError: string;
|
manifestError: string;
|
||||||
}) {
|
}) {
|
||||||
const { slug = "" } = useParams();
|
const params = useParams();
|
||||||
|
const slug = params.slug ?? params["*"] ?? "";
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const post = useMemo(() => posts.find((entry) => entry.slug === slug), [posts, slug]);
|
const post = useMemo(() => posts.find((entry) => entry.slug === slug), [posts, slug]);
|
||||||
const [html, setHtml] = useState<string>("");
|
const [html, setHtml] = useState<string>("");
|
||||||
@@ -290,6 +440,7 @@ function PostPage({
|
|||||||
const [postState, setPostState] = useState<LoadState>("idle");
|
const [postState, setPostState] = useState<LoadState>("idle");
|
||||||
const [postError, setPostError] = useState<string>("");
|
const [postError, setPostError] = useState<string>("");
|
||||||
const [showPostLoader, setShowPostLoader] = useState<boolean>(false);
|
const [showPostLoader, setShowPostLoader] = useState<boolean>(false);
|
||||||
|
const articleRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (postState !== "loading") {
|
if (postState !== "loading") {
|
||||||
@@ -329,6 +480,10 @@ function PostPage({
|
|||||||
};
|
};
|
||||||
}, [post]);
|
}, [post]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
renderTweetEmbeds(articleRef.current);
|
||||||
|
}, [html]);
|
||||||
|
|
||||||
const title = postFrontmatter.title || post?.title || "Post";
|
const title = postFrontmatter.title || post?.title || "Post";
|
||||||
const description =
|
const description =
|
||||||
postFrontmatter.desc ||
|
postFrontmatter.desc ||
|
||||||
@@ -337,12 +492,13 @@ function PostPage({
|
|||||||
postFrontmatter.excerpt ||
|
postFrontmatter.excerpt ||
|
||||||
post?.excerpt ||
|
post?.excerpt ||
|
||||||
BLOG_DEFAULT_DESCRIPTION;
|
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 bannerTxId = postFrontmatter.banner || post?.frontmatter?.banner || post?.bannerTxId;
|
||||||
const publishedDate = getReadableDate(postFrontmatter.date || post?.publishedAt);
|
const publishedDate = getReadableDate(postFrontmatter.date || post?.publishedAt);
|
||||||
const updatedDate = getReadableDate(postFrontmatter.updated || post?.updated || undefined);
|
const updatedDate = getReadableDate(postFrontmatter.updated || post?.updated || undefined);
|
||||||
const permalink = post
|
|
||||||
? toPermalinkUrl(`/${post.slug}`)
|
|
||||||
: toPermalinkUrl(location.pathname);
|
|
||||||
const metadataTitle =
|
const metadataTitle =
|
||||||
state === "loading"
|
state === "loading"
|
||||||
? `${BLOG_NAME} | Loading`
|
? `${BLOG_NAME} | Loading`
|
||||||
@@ -364,7 +520,7 @@ function PostPage({
|
|||||||
title: metadataTitle,
|
title: metadataTitle,
|
||||||
description: metadataDescription,
|
description: metadataDescription,
|
||||||
path: location.pathname,
|
path: location.pathname,
|
||||||
image: bannerTxId ? arweaveUrl(bannerTxId) : undefined,
|
image: bannerTxId && !isArtifact ? arweaveUrl(bannerTxId) : undefined,
|
||||||
type: post ? "article" : "website",
|
type: post ? "article" : "website",
|
||||||
publishedTime: post ? postFrontmatter.date || post.publishedAt : undefined,
|
publishedTime: post ? postFrontmatter.date || post.publishedAt : undefined,
|
||||||
modifiedTime: post ? postFrontmatter.updated || post.updated || undefined : 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>;
|
if (postState === "error") return <p className="status">Error: {postError}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="post">
|
<article className={`post${isArtifact ? " post-artifact" : ""}`}>
|
||||||
<header className="post-header">
|
<header className="post-header">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
<div className="meta-row post-meta-row">
|
<div className="meta-row post-meta-row">
|
||||||
{publishedDate && <span>Published {publishedDate}</span>}
|
{publishedDate && <span>Published {publishedDate}</span>}
|
||||||
{updatedDate && <span>Updated {updatedDate}</span>}
|
{updatedDate && <span>Updated {updatedDate}</span>}
|
||||||
|
{isArtifact && <span>codex</span>}
|
||||||
{post.readingTime && <span>{post.readingTime} min read</span>}
|
{post.readingTime && <span>{post.readingTime} min read</span>}
|
||||||
{post.wordCount && <span>{post.wordCount} words</span>}
|
{post.wordCount && <span>{post.wordCount} words</span>}
|
||||||
<span>{post.postTxId.slice(0, 8)}...</span>
|
<span>{post.postTxId.slice(0, 8)}...</span>
|
||||||
<span>
|
|
||||||
<a href={permalink}>[permalink]</a>
|
|
||||||
</span>
|
|
||||||
<span>
|
<span>
|
||||||
<a href={arweaveUrl(post.postTxId)} target="_blank" rel="noreferrer">
|
<a href={arweaveUrl(post.postTxId)} target="_blank" rel="noreferrer">
|
||||||
[arweave]
|
[arweave]
|
||||||
@@ -397,12 +551,13 @@ function PostPage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{bannerTxId && (
|
{bannerTxId && !isArtifact && (
|
||||||
<div className="post-hero">
|
<div className="post-hero">
|
||||||
<img className="post-banner" src={arweaveUrl(bannerTxId)} alt={`${title} banner`} />
|
<img className="post-banner" src={arweaveUrl(bannerTxId)} alt={`${title} banner`} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<section
|
<section
|
||||||
|
ref={articleRef}
|
||||||
className="article"
|
className="article"
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
export const BLOG_NAME = "hyperzine";
|
export const BLOG_NAME = "hyperzine";
|
||||||
export const BLOG_SITE_URL = "https://zpz3tbjvlwkkrkn2talxgib6plcj62d3r3gpjebyinbcu7oa7bjq.arweave.net";
|
export const BLOG_SITE_URL = "https://hyperzine.xyz";
|
||||||
export const BLOG_PERMALINK_PREFIX = "https://386464538491k.arweave.net";
|
export const BLOG_PERMALINK_PREFIX = "https://hyperzine.xyz";
|
||||||
export const BLOG_DEFAULT_DESCRIPTION =
|
export const BLOG_DEFAULT_DESCRIPTION =
|
||||||
"A blog about cyberspace decentralization";
|
"A blog about cyberspace decentralization";
|
||||||
export const BLOG_TWITTER_HANDLE = "";
|
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 ARWEAVE_GATEWAY = "https://arweave.net";
|
||||||
|
|
||||||
export const AO_URL = "https://push-1.forward.computer";
|
export const AO_URL = "https://push-1.forward.computer";
|
||||||
|
|||||||
183
src/lib.ts
183
src/lib.ts
@@ -1,7 +1,8 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { marked } from "marked";
|
import { marked, type Tokens } from "marked";
|
||||||
import { parse as parseYaml } from "yaml";
|
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";
|
import { parseManifest, type Frontmatter, type ManifestPost } from "./types";
|
||||||
|
|
||||||
const formatDate = (date: string): string =>
|
const formatDate = (date: string): string =>
|
||||||
@@ -16,6 +17,52 @@ marked.setOptions({
|
|||||||
breaks: false
|
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 arweaveUrl = (txId: string): string => `${ARWEAVE_GATEWAY}/${txId}`;
|
||||||
|
|
||||||
export const getReadableDate = (date?: string | null): string | null => {
|
export const getReadableDate = (date?: string | null): string | null => {
|
||||||
@@ -25,112 +72,50 @@ export const getReadableDate = (date?: string | null): string | null => {
|
|||||||
return formatDate(date);
|
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[]> => {
|
export const loadManifest = async (): Promise<ManifestPost[]> => {
|
||||||
const loadByTxId = async (manifestTxId: string): Promise<ManifestPost[]> => {
|
return (await loadCurrentManifest()).posts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchManifest = async (manifestTxId: string): Promise<{
|
||||||
|
txId: string;
|
||||||
|
posts: ManifestPost[];
|
||||||
|
}> => {
|
||||||
const response = await fetch(arweaveUrl(manifestTxId));
|
const response = await fetch(arweaveUrl(manifestTxId));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load manifest (${response.status})`);
|
throw new Error(`Failed to load manifest (${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: unknown = await response.json();
|
const payload: unknown = await response.json();
|
||||||
return parseManifest(payload);
|
const posts = parseManifest(payload);
|
||||||
|
return { txId: manifestTxId, posts };
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const resolveReferenceManifestTxId = async (): Promise<string> => {
|
||||||
return await loadByTxId(MANIFEST_TX_ID);
|
if (!MANIFEST_REFERENCE_NAME) return "";
|
||||||
} catch (manifestError) {
|
|
||||||
const latestManifestTxId = await getLatestManifestFromAo();
|
const names = new ReferenceClient({
|
||||||
if (!latestManifestTxId || latestManifestTxId === MANIFEST_TX_ID) {
|
gateway: ARWEAVE_GATEWAY,
|
||||||
throw manifestError;
|
fetch: (...args) => fetch(...args)
|
||||||
}
|
});
|
||||||
return loadByTxId(latestManifestTxId);
|
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> =>
|
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 {
|
.header-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
min-height: 72px;
|
min-height: 72px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,30 @@ a:hover {
|
|||||||
background: var(--brand-green);
|
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 {
|
.content {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -224,6 +250,68 @@ a:hover {
|
|||||||
transform: translateY(-50%);
|
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 {
|
.post {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
@@ -333,12 +421,49 @@ a:hover {
|
|||||||
color: #292929;
|
color: #292929;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article .twitter-tweet {
|
||||||
|
margin: 1.6em 0;
|
||||||
|
padding: 16px 0 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article iframe {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.article img {
|
.article img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border: 0;
|
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) {
|
@media (max-width: 860px) {
|
||||||
.page {
|
.page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -353,6 +478,7 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-card,
|
.post-card,
|
||||||
|
.artifact-index,
|
||||||
.post-header,
|
.post-header,
|
||||||
.post-hero,
|
.post-hero,
|
||||||
.article,
|
.article,
|
||||||
@@ -373,3 +499,17 @@ a:hover {
|
|||||||
font-size: 1.38rem;
|
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