init
This commit is contained in:
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);
|
||||
};
|
||||
Reference in New Issue
Block a user