SPEC_BLOG.md
blog.siao.ai — personal technical blog spec
What this covers: a new, standalone personal technical blog at
blog.siao.ai — its scope, stack, deployment, and its two-way linkage
with SiaoHub (git.siao.ai). Lives in a new repo, not yet created
(proposed name: blog-siao-ai, matching the apps-siao-ai /
git-siao-ai naming convention — not finalized with the owner). Also
covers a small addition to the existing siaohub repo (the backlink
display on file pages).
No issue tracker exists for this project family — /setup-matt-pocock-skills
has not been run, this working directory is not itself a git repository,
and the target repo for the blog does not exist yet. Following the
precedent set by SPEC_APPS.md ("No issue tracker exists yet for this
project — this file is the spec until one does"), this file is the
spec of record. Status field below uses the same convention as the
.scratch/*/issues/*.md tickets elsewhere in this project.
Status: ready-for-agent
For what is built or deployed, see ClaudeDocs/HANDOFF.md once work
starts — status deliberately does not live here.
Problem Statement
The owner wants to publish long-form, narrative technical writing —
documented experience, not Q&A content — somewhere of its own. AnswerHub
(apps-siao-ai, at apps.siao.ai/answerhub) already has a working
BlogPost feature (Prisma model, markdown rendering, a web form to
publish), but it was already cut from AnswerHub's navigation
(SPEC_ANSWERHUB_FRONTEND.md: "Blog and knowledge base cut from nav")
and AnswerHub itself is expected to be deprecated later. Building the
new blog as another AnswerHub feature would mean redoing this same move
a second time when AnswerHub is retired. Separately, the owner wants
technical posts to be able to reference real code living in SiaoHub
(git.siao.ai) — showing an actual file inline rather than pasting a
copy into the post — and wants that connection to run in both
directions: a reader on the blog can see the referenced file, and a
reader on SiaoHub can discover that a file has been written about.
Solution
Build blog.siao.ai as a new, independent, single-author, Markdown-only
static site — no database, no login, no multi-user system — deployed to
Cloudflare Pages from its own GitHub repository, the same deployment
model already proven by the siao.ai homepage. Posts are plain
Markdown files with frontmatter, written in the owner's own editor and
published by git push. A post may embed a reference to a specific
file in a SiaoHub-hosted repo; the referenced content is fetched at
build time (never at reader-visit time) and rendered inline with a link
back to the canonical file on git.siao.ai — this is the blog→SiaoHub
half of the connection, automatic and requiring no separate authoring
step. The reverse direction — SiaoHub showing that a file is referenced
by a blog post — is driven by a small public manifest (backlinks.json)
the blog's build emits, which SiaoHub fetches and checks when rendering
a file page. To keep embedded previews from going stale, the owner
manually registers a Forgejo webhook on any repo referenced by a post,
pointed at the blog's Cloudflare Pages Deploy Hook, so a push to that
repo triggers a blog rebuild.
This deliberately does not reuse AnswerHub's BlogPost model, its
database, or its auth. AnswerHub's blog feature is left as-is and
expected to be retired later, independently of this work.
User Stories
- As the blog owner, I want to write posts as plain Markdown files in my own editor, so that I can write long-form narrative content without a web form or a database.
- As the blog owner, I want
git pushto be the entire publish step, so that posting has no extra ceremony beyond writing. - As the blog owner, I want the blog's source hosted on GitHub, so that Cloudflare Pages can build and redeploy automatically on every push without me wiring a custom webhook for my own commits.
- As the blog owner, I want blog.siao.ai added as a Cloudflare Pages custom domain, so that shipping it never touches the self-hosted Docker Compose / Cloudflare Tunnel / Caddy stack that runs SiaoHub, AnswerHub, and Logto.
- As the blog owner, I want to reference a specific file from one of my SiaoHub repos directly inside a post, so that I can show real code as part of a story instead of duplicating it into the post text.
- As a reader, I want an embedded file reference to show the file's actual content inline, so that I don't have to leave the post to see the code being discussed.
- As the blog owner, I want an embedded file's content to be pinned to the ref (branch/commit) I specified when I wrote the post, so that my narrative and the shown code stay in agreement even if the source file later changes.
- As the blog owner, I want to be able to register a webhook on a referenced repo pointing at the blog's Cloudflare Pages Deploy Hook, so that pushing to that repo can refresh the embedded preview without me manually rebuilding the blog.
- As the blog owner, I want this webhook registration to be a manual, one-time, per-repo action, so that neither system needs to hold a credential capable of modifying the other's configuration.
- As the blog owner, I do not want the blog to depend on SiaoHub being reachable at the moment a reader visits, so that a SiaoHub outage never breaks the blog for readers.
- As a reader browsing a file on SiaoHub, I want to see when that file is referenced by a blog post, so that I can discover the narrative context behind a piece of code.
- As the blog owner, I do not want to manually maintain the SiaoHub side of this backlink, so that adding a reference in a post is the only action required for the backlink to appear.
- As the blog owner, if the blog's backlinks manifest is briefly unreachable or malformed, I want SiaoHub's file pages to render normally with no backlink shown, so that a blog-side problem never breaks SiaoHub for its own visitors.
- As the blog owner, I want the blog to be single-author with no login, registration, or session system, so that there is no user-management surface to build or secure.
- As the blog owner, I want the blog to default to Traditional Chinese, so that I can write in the language I think in without translation overhead on every post.
- As the blog owner, I want to optionally provide a translated version of a specific post, so that select posts can reach other audiences without committing to translating everything I write.
- As a reader, I want a language switcher to appear on a post only when a translation actually exists for it, so that I never land on an empty or machine-stub page.
- As the blog owner, I want the blog to have no database and no server-rendered runtime, so that there is nothing to patch, scale, or keep alive beyond a static file host.
- As the blog owner, I want each post's frontmatter to carry at least a title, date, and excerpt, so that the build script can generate an index/listing page and page metadata without extra authoring.
- As a search engine or a social-media crawler, I want each post to expose correct title/description/canonical-URL metadata, so that shared links render meaningful previews.
- As the blog owner, I want the Markdown-to-HTML pipeline (GFM, math, syntax-highlighted code, sanitized output) to match AnswerHub's existing rendering choices, so that code blocks and math look consistent across my properties.
- As the blog owner, I want the renderer to still run content through an HTML sanitizer even though I am the only author, so that accidentally pasted untrusted HTML (e.g. copied from a browser) can't introduce a stray executable tag.
- As the blog owner, I want the build to fail loudly if a post references a SiaoHub file/repo/path that doesn't exist or isn't reachable, so that a broken reference is caught before it reaches readers, not shipped as a blank embed.
- As the blog owner, I want to render a post locally before pushing, so that I can catch a broken embed reference or a math/syntax mistake before it goes live.
- As the blog owner, I want post slugs to be stable and independent of where the source file happens to live, so that I can reorganize my source tree without breaking published links.
- As the blog owner, I want AnswerHub's existing
/answerhub/blogfeature to be treated as legacy, receiving no further investment, so that effort concentrates on this new blog instead of maintaining two parallel blog systems. - As the blog owner, I want the repo name and exact Cloudflare Pages project/domain settings confirmed before implementation starts, so that they're created correctly the first time.
Implementation Decisions
- New repo, not yet created. Proposed name
blog-siao-ai(matchesapps-siao-ai/git-siao-ai), hosted on GitHub (notgit.siao.ai/Forgejo — SiaoHub's stated role is to showcase projects, not to be the primary remote for every repo the owner writes). Cloudflare Pages is connected directly to this GitHub repo using its native GitHub integration, so pushes tomaintrigger a build with no custom webhook needed for the blog's own deploys. - Deployment target: Cloudflare Pages, custom domain
blog.siao.ai. No changes to thegit-siao-airepo'sdocker-compose.yml,cloudflared/config.yml, orcaddy/Caddyfile— this blog is not part of that self-hosted stack. - Build command runs on every Cloudflare Pages build (unlike the
siao-aihomepage, which commits its generated HTML and configures Pages with no build step at all). This is a deliberate divergence: the SiaoHub file-embed feature needs a live fetch against Forgejo at build time, so the generated output cannot be pre-committed and replayed — regenerating on every build is what makes the webhook-triggered-rebuild mechanism (story 8) actually refresh content. - Content model: Markdown files with frontmatter (
title,date,excerpt, slug derived from filename or an explicit frontmatter field). Optional per-post translations as sibling files named<slug>.<lang>.md; the build only emits a language switcher entry for languages that actually have a file — no forced full-language matrix, no_redirects-based locale redirect chain like the homepage's. - Build script: a hand-rolled Node script, structurally modeled on
siao-ai/scripts/build.js(template-literal HTML generation, anescape()/url()helper style) — not a general-purpose static site generator (Astro/Eleventy) — to stay consistent with the family's existing minimal-tooling approach and to keep the embed-resolution and backlink-manifest logic in one place the owner already understands the shape of. - Markdown pipeline:
unified+remark-parse+remark-gfm+remark-math/rehype-katex+rehype-pretty-code(shiki) +rehype-sanitize+rehype-stringify— the same stack AnswerHub already uses inapps-siao-ai/lib/answerhub/markdown.ts, kept for visual consistency.remark-rehypestays at its defaultallowDangerousHtml: false.rehype-sanitizeis retained as defense-in-depth (story 22) even though the original motivating threat — arbitrary signed-in visitors submitting content — doesn't apply to a single-author blog. - New dependency:
remark-directive, to implement the SiaoHub file-embed syntax as a leaf directive, e.g.::siaohub-file{repo="owner/name" path="dir/file.py" ref="main"}. This package is not used elsewhere in thesiao.aifamily; everything else in the pipeline is a direct carry-over from AnswerHub. - Embed resolution: at build time, for each
siaohub-filedirective encountered, fetch the file's raw content from Forgejo (viaFORGEJO_PUBLIC_URL, the samehttps://git.siao.aiconventionsiaohubalready uses) at the givenref, and render it as a syntax-highlighted block (through the samerehype-pretty-codepipeline used for regular code fences) plus a visible link back to the canonical file view ongit.siao.ai. A missing repo/path/ref fails the build (story 23). - Backlinks manifest: the same build pass that resolves embeds
collects every
{repo, path, ref, postSlug, postTitle, postUrl}tuple site-wide intobacklinks.json, emitted at the site root and served as a plain static asset alongside the rest of the site. - Freshness mechanism: manual, per-repo. The owner adds a Forgejo repository webhook (native Forgejo feature) pointed at the blog's Cloudflare Pages Deploy Hook (native Pages feature) for any repo referenced by a post. No new code, no credential granted in either direction (story 9).
- SiaoHub-side change (in the existing
siaohubrepo): a small module fetcheshttps://blog.siao.ai/backlinks.json(cached with a revalidation window, e.g. Next.jsfetch(..., { next: { revalidate: 3600 } }), since SiaoHub is a live server unlike the blog) and a UI addition on the file-view page that looks up the currently rendered{repo, path}against the manifest, rendering a small "referenced in a blog post" link when found. On fetch failure, timeout, or no match, render nothing — fail-open (story 13). - No auth, no OIDC, no database anywhere in the blog. AnswerHub's
BlogPostPrisma model and/answerhub/blog/*routes are untouched by this work.
Testing Decisions
Good tests here assert on observable output — generated HTML content, or the presence/absence of a rendered UI element — not on internal function calls, consistent with the testing style already used across this family.
- Build script / embed-and-manifest logic — unit-tested with
vitest. Prior art:apps-siao-ai/lib/answerhub/markdown.test.ts(asserts onrenderMarkdown()'s output strings directly). The Forgejo fetch is mocked with fixture content — tests never make a real network call, matching how every other external-call test in this family is written. - Full build pipeline (source
.mdfixtures → generated static site) — tested with Playwright against the built output directory. Prior art:siao-ai/tests/generated.spec.jsandpage.spec.js. Confirms frontmatter renders correctly, the language switcher appears only for slugs that actually have a translation file, and a post with asiaohub-fileembed renders the fixture content plus a working link back togit.siao.ai. - SiaoHub's new backlink UI — Playwright e2e against a running
SiaoHub instance, with the
backlinks.jsonfetch stubbed. Prior art:siaohub/e2e/sensitive-preview.spec.tsandprivate-repo-boundary.spec.ts(the existing "condition A shows, condition B doesn't" pattern in this suite). Cases: chip renders when the manifest has a matching entry; chip absent when it doesn't; file page still renders normally when the manifest fetch itself fails. - No auth/session test suite is needed anywhere in this work — none of it has a login surface.
Out of Scope
- Migrating or retiring AnswerHub's existing
BlogPostdata and pages. Left as-is; see Further Notes. - Multi-author support, comments, reactions, or any reader interactivity beyond reading.
- RSS/Atom feed, tag or category pages, full-text search — not requested; can be added later inside the same build script without new tooling.
- Client-side/runtime-fetched embed previews — explicitly rejected in favor of build-time fetch plus webhook-triggered rebuild, to keep the blog's reader-facing runtime dependency count at zero.
- Automating webhook registration on referenced SiaoHub repos — explicitly rejected in favor of a manual, one-time action per repo.
- Shared identity /
accounts.siao.aiOIDC login for the blog — explicitly rejected; single-author, no auth surface. - A full multi-language build like the
siao.aihomepage's ten-language pipeline (font subsetting, hreflang matrix, locale-redirect chain) — explicitly rejected in favor of per-post optional translation files. - Any change to the self-hosted Docker Compose / Cloudflare Tunnel /
Caddy infrastructure in
git-siao-ai.
Further Notes
- The repo name (
blog-siao-ai) and the exact Cloudflare Pages project/domain settings are proposed, not confirmed with the owner — resolve before scaffolding (story 27). - Whether to migrate existing AnswerHub blog posts before that feature
is retired is still open. AnswerHub's blog was already removed from
its own navigation (
SPEC_ANSWERHUB_FRONTEND.md), so there is no urgency from a UX standpoint, but the data itself hasn't been addressed — resolve before AnswerHub's blog is actually deleted. OVERVIEW.mddescribes SiaoHub as having "visibility tiers via repo topics" and a preview tier (SPEC_SIAOHUB.md). Before implementing the embed fetch, check what happens if a post references a file in a private or preview-tier repo — the embed resolution as specified above uses the same public read pathsiaohubitself uses, and should not become a way to expose content from a repo whose visibility tier says otherwise. This wasn't resolved during scoping and needs a policy decision (e.g., refuse to embed anything outside the "public" tier) before implementation.- This spec's three testing seams (build-script unit tests, full-build Playwright tests, SiaoHub backlink e2e tests) were confirmed with the owner during scoping; no additional seam was requested.