import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"; import { Link, useParams } from "react-router-dom"; import SkyCanvas from "../components/SkyCanvas"; import { PublicFooter, PublicNav } from "../components/PublicChrome"; import { blogPosts, getPost } from "../content/blogPosts"; import type { BlogPost, Mission } from "../content/seoTypes"; import "../styles/blog.css"; export function useMeta(title: string, description: string, canonical?: string) { useEffect(() => { document.title = title; const set = (selector: string, attr: string, value: string, create: () => HTMLElement) => { let el = document.head.querySelector(selector) as HTMLElement | null; if (!el) { el = create(); document.head.appendChild(el); } el.setAttribute(attr, value); }; set('meta[name="description"]', "content", description, () => { const m = document.createElement("meta"); m.setAttribute("name", "description"); return m; }); set('link[rel="canonical"]', "href", `${window.location.origin}${canonical ?? window.location.pathname}`, () => { const l = document.createElement("link"); l.setAttribute("rel", "canonical"); return l; }); }, [title, description, canonical]); } const fmtDate = (d: string) => new Date(`${d}T00:00:00Z`).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" }); /** Posts are numbered as missions in publishing order: the oldest post is Mission 01. */ const missionNumber = (post: BlogPost) => { const ordered = [...blogPosts].sort((a, b) => a.publishedAt.localeCompare(b.publishedAt)); return String(ordered.findIndex((p) => p.slug === post.slug) + 1).padStart(2, "0"); }; const missionOf = (post: BlogPost): Mission => post.mission ?? { question: post.title.endsWith("?") ? post.title : `${post.heroTitle}?`, answer: post.description }; const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g; /** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */ export function Inline({ text }: { text: string }) { const out: ReactNode[] = []; let last = 0; let i = 0; for (const m of text.matchAll(INLINE)) { const at = m.index ?? 0; if (at > last) out.push(text.slice(last, at)); const [whole, link, bold] = m; if (link) { const label = link.slice(1, link.indexOf("](")); const href = link.slice(link.indexOf("](") + 2, -1); out.push( href.startsWith("/") ? ( {label} ) : ( {label} ), ); } else if (bold) { out.push({bold.slice(2, -2)}); } last = at + whole.length; } if (last < text.length) out.push(text.slice(last)); return <>{out}; } const plain = (text: string) => text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1"); export function Blog() { useMeta("Blog | Notestack", "Field notes on research, deep writing and turning your archive into audio, video and launches."); const posts = [...blogPosts].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); return (

Mission log

Every post is a search for an answer

Pick a question. Each mission follows it through a few waypoints until the answer comes into view: research, deep writing, audio, video and distribution.

    {posts.map((p) => { const m = missionOf(p); return (
  1. Mission {missionNumber(p)} {p.category} {m.question}

    {p.title}

    {p.sections.length} waypoints · {p.readTime}
  2. ); })}
); } /** Tracks which waypoint the reader is at and how far along the flight path they are. */ function useFlight(count: number) { const refs = useRef<(HTMLElement | null)[]>([]); const pathRef = useRef(null); const [active, setActive] = useState(0); const [reached, setReached] = useState>(new Set()); const [progress, setProgress] = useState(0); useEffect(() => { const io = new IntersectionObserver( (entries) => { for (const e of entries) { const idx = Number((e.target as HTMLElement).dataset.idx); if (e.isIntersecting) { setActive(idx); setReached((r) => (r.has(idx) ? r : new Set(r).add(idx))); } } }, { rootMargin: "-35% 0px -55% 0px" }, ); refs.current.forEach((el) => el && io.observe(el)); const onScroll = () => { const el = pathRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const total = rect.height - window.innerHeight * 0.4; setProgress(Math.max(0, Math.min(1, (window.innerHeight * 0.45 - rect.top) / Math.max(total, 1)))); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => { io.disconnect(); window.removeEventListener("scroll", onScroll); }; }, [count]); return { refs, pathRef, active, reached, progress }; } export function BlogPostPage() { const { slug = "" } = useParams(); const post = getPost(slug); useMeta(post ? `${post.title} | Notestack` : "Not found | Notestack", post?.description ?? "", post?.canonicalPath); const flight = useFlight(post?.sections.length ?? 0); if (!post) { return (

Lost in space

That post drifted out of range.

Back to the mission log
); } const mission = missionOf(post); const number = missionNumber(post); const related = post.relatedPaths .map((path) => blogPosts.find((p) => `/blogs/${p.slug}` === path)) .filter((p): p is NonNullable => Boolean(p)); const jsonLd = { "@context": "https://schema.org", "@type": "BlogPosting", headline: post.title, description: post.description, datePublished: post.publishedAt, keywords: [post.primaryKeyword, post.keywordVariant].join(", "), author: { "@type": "Organization", name: "Notestack" }, }; const faqLd = post.faq.length ? { "@context": "https://schema.org", "@type": "FAQPage", mainEntity: post.faq.map((f) => ({ "@type": "Question", name: f.question, acceptedAnswer: { "@type": "Answer", text: plain(f.answer) }, })), } : null; const goTo = (i: number) => flight.refs.current[i]?.scrollIntoView({ behavior: "smooth", block: "start" }); return (
{faqLd && } Mission log / {post.category}

Mission {number} · {post.heroEyebrow}

Incoming query

{mission.question}

{post.title}

{post.heroDescription}

{fmtDate(post.publishedAt)} · {post.readTime} · {post.sections.length} waypoints

Short answer

{post.heroImage && {post.heroImageAlt}
{related.length > 0 && (

Next missions

{related.map((p) => ( Mission {missionNumber(p)}

{p.title}

{missionOf(p).question}

))}
)}
); }