The source for education.civitai.com: guides, tutorials and news, written in Markdown and built into a static site with Astro. Search is Pagefind.
The content was exported from the old WordPress site, and every public WordPress URL is kept:
posts and pages at /<slug>/, /category/<slug>/, /tag/<slug>/, /author/<slug>/, pagination
at …/page/N/, feeds at …/feed/ and the Yoast sitemap file names.
- Node.js 22 or newer
- pnpm (
corepack enablepicks up the version pinned inpackage.json)
| Command | What it does |
|---|---|
pnpm install |
Install dependencies |
pnpm dev |
Local dev server at http://localhost:4321 (search is not built here) |
pnpm build |
Build the site into dist/, then build the search index |
pnpm preview |
Serve dist/ at http://localhost:4321 |
pnpm check |
Type-check the project |
pnpm check:urls |
Check that every WordPress URL answers on a running build (below) |
pnpm export:wp |
Re-export content from WordPress (below) |
New and rewritten articles get an independent content review before they merge: accuracy (every claim
checked against civitai.com), quality against our best guides, images, links and voice. The checklist
is .claude/skills/content-review/SKILL.md; the PR body carries the reviewer's verdict.
-
Create
src/content/posts/<slug>.md. The slug is the URL:my-new-guideis served at/my-new-guide/. It must not clash with another post or page, or withcategory,tag,author,page,feed,searchorcivitai; the build fails if it does. -
Start it with front matter:
--- title: "My New Guide" slug: "my-new-guide" date: "2026-10-21T15:00:00Z" categories: - "guides" tags: - "flux" excerpt: "One or two sentences shown in listings, feeds and search results." image: "https://…/cover.png" # optional: listing thumbnail and social image --- Write the post in Markdown. Start with `##` headings; the page title is the `#`.
Optional fields:
modified(last updated date),author(defaultcivitai),draft: true(left out of the build),unlisted: true(built at its URL for review, but kept out of every listing, feed, search and sitemap, with a noindex tag; remove it to launch),covers,reviewed(below), andseowithtitle,descriptionandogImageoverrides. The schema is insrc/content.config.ts.Guides about a Civitai feature or model should also declare what they cover and when they were last checked:
covers: # short kebab-case keys, one per feature or model the guide explains - "creator-studio" - "early-access" reviewed: "2026-10-26" # the day every claim was last checked against Civitai
The weekly review matches changes on Civitai against
covers, and lists any guide whoserevieweddate is more than 90 days old. Setreviewedwhenever you check or update a guide, and reuse an existing key (grep -rh '^ - ' src/content/postsshows them) before inventing one. -
Categories and tags are slugs. Existing ones, with their display names, are in
src/data/taxonomies.json; a new slug works too and shows under its slug until it is added there. -
Run
pnpm devand open the post. The table of contents is generated from the##and###headings, so there is no need to write one.
Use .mdx instead of .md if a post needs components.
Two ways to take a post off the shelf:
-
Deprecate it when the page is still worth reading but out of date. Add to its front matter:
deprecated: date: "2026-10-26" replacedBy: "newer-guide-slug" # optional: the banner links there; must be a post's slug note: "One line for the banner." # optional: replaces the default text
The post keeps its URL and its sitemap entry and shows an "out of date" banner. It leaves the home page, the category, tag and author archives, the feeds, search and "Up next". Code that lists posts for readers uses
getListedPosts();getPosts()is for routes and sitemaps. -
Retire it when the URL should go away: delete its file, and add it to
RETIREDinscripts/export-wp.mjsand to the "Retired posts" block ofpublic/_redirects(slug and the post its URL now 301s to). Point its id insrc/data/wp-ids.jsonat the same target. If that empties a tag, category or archive page that WordPress served, redirect that too;pnpm check:urlsfinds them.
Videos and other rich blocks use a placeholder div that src/lib/rehype-embeds.mjs turns into
markup, so their look is decided in one place:
<div data-embed="youtube" data-src="https://www.youtube.com/embed/VIDEO_ID" data-title="Video title" data-width="800" data-height="450"></div>Kinds: youtube, videopress, iframe, video, gallery (wraps <img>s), image-compare
(two <img>s, before and after) and tweet. A <figcaption> inside the div becomes the caption.
Headings get ids from their text. To keep a specific id (for an existing #link), add it at the
end: ## Some heading {#old-anchor}.
Astro caches rendered Markdown in node_modules/.astro/. After changing a remark or rehype plugin,
delete that folder so every post is rendered again.
Pages live in src/content/pages/ with a path in their front matter (/about-us/). Two are
special, matching the old WordPress reading settings in src/lib/site.ts: home is the front page
(/, generated; its file supplies the SEO fields) and civitai is the posts page (/civitai/,
the full list of posts).
The theme is deliberately plain for now. Site chrome is in src/styles/base.css; all Markdown
typography (and the embed markup) is in src/styles/prose.css, applied by
src/components/Prose.astro.
All media is in this repository, so a clone builds the whole site.
- Images and downloads are in
public/wp-content/uploads/, at their old WordPress paths. Most were converted to WebP. The old.png/.jpgURLs redirect to the new names (src/data/media-renames.json). PNGs that carry ComfyUI or A1111 generation data (workflow,prompt,parameters) are kept as they are, so readers can still load them into ComfyUI. - Videos (
public/videos/, the former VideoPress embeds) and the one download over GitHub's 100 MB limit are in Git LFS. Rungit lfs installbefore cloning to get them; without it they are small pointer files and the videos won't play locally.
To add an image, put it under public/ and link it by its path, e.g. /wp-content/uploads/2026/10/x.webp
or a new folder. Prefer WebP, and keep it under about 1 MB.
On the hosted site the image build cannot fetch Git LFS files, so .dockerignore leaves them out and
nginx serves them from a B2 copy (nginx/default.conf). A new video therefore also needs uploading to
that bucket; ask an infra owner.
scripts/export-wp.mjs reads the public WordPress REST API (read-only, no login) and writes:
src/content/posts/*.mdandsrc/content/pages/*.mdsrc/data/taxonomies.json: categories, tags and authorssrc/data/wp-ids.json: WordPress id → path, for redirecting old?p=<id>and?page_id=<id>links (those need a function, since_redirectscannot match query strings)public/_redirects: Cloudflare Pages redirects for removed content and empty archives
pnpm export:wp # fetch and convert
pnpm export:wp --cached # re-convert from the last fetch in .cache/wp/It is safe to re-run: unchanged files are left alone, exported files whose post no longer exists are
removed, and files without a wpId (written here, not exported) are never touched.
Password-protected posts are skipped and never written.
scripts/check-urls.mjs reads the live WordPress sitemaps and REST API for every public URL and
requests each one from a build:
pnpm build && pnpm preview # in one terminal
pnpm check:urls # in another; or: pnpm check:urls -- --base https://education-new.civitai.comIt fails on any URL that is not a 200 or a redirect to a 200.