Vibecode WordPress
track this build4 phases, 12 steps, beginner friendly0%The thing people actually use WordPress for on a personal site, write posts, upload images, publish, have an RSS feed, is a weekend build and always has been. An agent can hand you a SQLite-backed CMS with a markdown editor, media uploads, drafts, tags, and a sitemap in one sitting, and it will be faster and less hackable than the real thing. What you cannot one-shot is the other 90 percent of WordPress: 60,000 plugins, WooCommerce, Yoast, Elementor, the block editor, and the fact that any freelancer on earth can pick up your site. Also worth noting that core WordPress is already free and self-hostable, so the honest question is whether you want to maintain your own CMS instead of someone else's. If your site has a contact form, a store, or a client who needs to log in, stop reading and just install WordPress.
You are building a lean indie version of WordPress. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # WordPress · indie build A single-author CMS whose hard problems are extracting a live WordPress site completely rather than partially, keeping every published URL working after the switch, and rendering imported content that was written for a renderer you no longer have. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small server-rendered application, no client framework | a blog that needs a build step to fix a typo is a blog you stop updating | | Storage | SQLite in one file, with media as files on disk beside it | one file to back up is the difference between backing up and meaning to | | Extraction | The site's own REST API with page-by-page fetching and a checksummed manifest | the export file references media it does not contain, and paged fetches truncate quietly | | Rendering | Markdown for new posts, sanitised stored HTML for imported ones | imported content was written for a renderer you are not going to reimplement | | URLs | A permalink map with explicit redirects, checked against the old sitemap | links you break are links other people wrote | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the site and the import scripts. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Read access to the WordPress site you are leaving** · free while the site is still up - Why: Extraction runs against the live site, and doing it after the hosting lapses is not possible. - Get it: An application password or read-only credentials on the existing site, plus its API base URL. - Verify: A request for one post returns JSON and the totals headers - [ ] **A saved copy of the old site's sitemap** · free - Why: It is the complete list of URLs you are about to become responsible for, and it disappears with the site. - Get it: Download the sitemap and any index files it points at, before anything else. - Verify: The saved file lists every URL you expect, including archives and the feed - [ ] **A host with TLS and automatic certificate renewal** · a few pounds a month - Why: You are the hosting relationship now, and a lapsed certificate is the most common way a personal site goes dark. - Get it: Any small instance with an automatic certificate, or a platform that manages one. - Verify: The site loads with a valid certificate and renewal is automatic - [ ] **Somewhere off the host for backups** · pennies per month - Why: The thing you removed was doing this for you, and you will not notice the absence until you need it. - Get it: Object storage or a second machine, with the backup command scheduled. - Verify: A restore from the copy reproduces counts and checksums ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Plugins and themes. - Commerce of any kind. - Multiple authors with roles. - A block editor. - The plugin ecosystem: SEO, forms, caching, memberships, analytics, backups, all one click away in WordPress and all your problem in the DIY build - WooCommerce and anything resembling commerce - Gutenberg block editing and the visual page builders non-technical people actually like - A theme marketplace, so your site looks like whatever the agent felt like that day - Any hope of handing the site to a developer who already knows the stack - Security updates and managed hosting done by someone who is not you If one of those is essential to you, that is the reason to keep paying for WordPress, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · WordPress The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-author site that first extracts everything from an existing WordPress installation and then serves it without one to replace WordPress. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Be honest about what you are replacing. WordPress the software is free and you can self-host it today for nothing. What costs money is the hosting relationship: somebody patching it, backing it up and answering the phone when a plugin update takes the site down. Swapping that for code you maintain is a real trade, and the only version of it that makes sense is one where your content leaves whole and your URLs keep working. So the first phase is extraction, not features, and it runs against the live site before you have written a single page template. Two things about that extraction bite people. The export file the admin screen produces is XML listing posts, pages, custom post types, comments, custom fields, terms and users. It is not an archive: the images are references to URLs on the old host, so if that host goes away before you fetched them, they are gone. The REST API is the better route and it has its own rule, that a collection request returns at most one hundred records per page, with the totals reported in the X-WP-Total and X-WP-TotalPages response headers, so a naive fetch silently gives you the first hundred posts and looks like it worked. The rule: extraction produces a manifest with counts and a checksum per file, the counts are compared against the headers the site itself reported, and the build does not proceed until they match. And do not switch the domain over until every URL in the old sitemap resolves, either to the same content or to a redirect you wrote deliberately. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small server-rendered application, no client framework · a blog that needs a build step to fix a typo is a blog you stop updating - Storage: SQLite in one file, with media as files on disk beside it · one file to back up is the difference between backing up and meaning to - Extraction: The site's own REST API with page-by-page fetching and a checksummed manifest · the export file references media it does not contain, and paged fetches truncate quietly - Rendering: Markdown for new posts, sanitised stored HTML for imported ones · imported content was written for a renderer you are not going to reimplement - URLs: A permalink map with explicit redirects, checked against the old sitemap · links you break are links other people wrote ### Data model (create this before Phase 1) - posts(id, kind, slug, title, body_format, body, excerpt, status, published_at, updated_at) · body_format is markdown or imported_html - legacy(post_id, source_url, source_id, source_modified, import_batch, unhandled_markers_json) · what the old site said, kept for the arguments later - redirects(from_path, to_path, code, reason, created_at) · every old URL that is not the new URL - media(id, filename, original_url, bytes, sha256, width, height, alt, fetched_at) - post_media(post_id, media_id, rewritten) · which references in a body were rewritten to local files - terms(id, kind, slug, name) and post_terms(post_id, term_id) · categories and tags, flattened deliberately - imports(id, started_at, finished_at, source_base, expected_counts_json, actual_counts_json, ok) · the manifest, and the gate ### Phase 1 · Get everything out, and prove it Build: Fetch posts, pages, media, categories and tags page by page from the site's REST API, reading the reported totals from the response headers and continuing until the count matches. Download every media file by its URL, store it with a checksum, and record the original URL. Write a manifest of expected and actual counts per collection and fail the import if any pair differs. Trap: Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. Done when: The manifest records expected and actual counts per collection and the import fails when they differ, tested by truncating a fetch. Every media file referenced by an imported post is on local disk with a checksum, or is listed as unfetchable with its URL and the reason. Re-running the import against an unchanged site fetches nothing new and reports zero differences. The extraction runs against the live site without any write to it, verified by using read-only credentials. Do not build yet: No templates, editor, publishing or redirects yet. ### Phase 2 · Content written for a renderer you no longer have Build: Store imported bodies as HTML, sanitised on output rather than on import, so the original is preserved. Detect the markers the old renderer understood, including block delimiter comments and short codes in square brackets, and record every one you do not handle against the post. Rewrite media references to local paths and record which were rewritten. Trap: Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. Done when: Every imported post lists the markers found in it that this build does not render, and the total is visible in the admin, checked against a post containing a short code and a block comment. The original imported body is retained unmodified and sanitisation happens on output, verified in the database. A media reference pointing at the old host is rewritten to a local path, and any that could not be is listed. No import step deletes content from a body. Do not build yet: No redirects, editor or publishing workflow yet. ### Phase 3 · Every old link still goes somewhere Build: Build a map from every old path to a new one, including the permalink structure, the category and tag archives, the feed and any attachment pages. Serve permanent redirects for paths whose address changed. Fetch the old sitemap, request every URL against the new site, and produce a report of anything unresolved. Treat that report as a release gate. Trap: Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. Done when: Every URL in the old sitemap resolves to content or to a redirect, with the report showing zero unresolved, tested against a saved copy of the sitemap. Redirects are permanent and each carries a stored reason. The feed path from the old site continues to serve a valid feed. A request for a path that never existed returns a not found page with a search box rather than an empty response. Do not build yet: No editor, scheduling or backups yet. ### Phase 4 · Writing the next post Build: An admin behind one password with a Markdown editor, a preview, drafts, scheduled publishing resolved on request rather than by a scheduler, and image upload that resizes and stores alongside the originals. Full text search over posts. A feed, a sitemap and per-post metadata for link previews. Trap: Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. Done when: Writing and publishing a post takes fewer steps than the old site, timed and written in the README. Uploaded originals are retained and derived sizes can be deleted and regenerated, tested. A scheduled post appears at its time without any background process running, tested by moving the clock. Search returns posts by body text and the feed validates. Do not build yet: No backup, restore or export work yet. ### Phase 5 · You are the host now Build: A backup command producing one archive of the database, the media originals and the redirect table, with a checksum. A restore command that rebuilds into an empty directory and verifies counts. Export every post as Markdown with front matter and media as files. Serve behind TLS with a documented certificate renewal, and log enough to tell an outage from a mistake. Trap: Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed. Done when: A restore into an empty directory reproduces the post, media and redirect counts and matching checksums, exercised in the test suite. The backup archive includes media originals, verified by size and count rather than by inspection. Export produces one Markdown file per post with front matter plus media files, and a fresh install imports them. The README states who is on call, which is you, and what to do when the certificate expires. Do not build yet: Nothing after this. ### Out of scope (and why) - Plugins and themes, because the plugin economy is the actual product you are walking away from and half-reimplementing it is the worst of both. - Anything that takes money, because a checkout on a CMS you maintain alone is a liability rather than a feature. - Multiple authors with roles and approvals, which is where a small CMS turns into an application. - A block editor, which is a large project on its own and is the reason non-technical people like the original. ### README must contain - That core WordPress is free and self-hostable, and that what this build replaces is the hosting relationship rather than the software - That the admin export file lists media by URL rather than containing it, so extraction fetches the files separately - That collection requests to the site API are paged and the totals come from response headers, and that the import fails on a count mismatch - That every old URL is redirected and the sitemap check is a release gate - Who is on call, how backups run, and the restore command with the last date it was exercised ===== AGENTS.md ===== # Agent instructions · WordPress indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and a small server-rendered application, no client framework, SQLite in one file, with media as files on disk beside it, The site's own REST API with page-by-page fetching and a checksummed manifest, Markdown for new posts, sanitised stored HTML for imported ones, A permalink map with explicit redirects, checked against the old sitemap. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. ===== BUILD_PLAN.md ===== # Build plan · WordPress A single-author CMS whose hard problems are extracting a live WordPress site completely rather than partially, keeping every published URL working after the switch, and rendering imported content that was written for a renderer you no longer have. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Get everything out, and prove it A complete copy of the live site sits on your disk with counts that match what the site reported. ### Steps 1. Page until the reported totals are satisfied The header tells you how many there are, so there is no excuse. Files: `scripts/import.ts`, `tests/import-paging.test.ts` 2. Fetch media as files, keep the original URL and a checksum The export file is a list of links, not an archive. Files: `scripts/media.ts` 3. Make a count mismatch a failure, not a warning This is the only moment you can still go back and look. Files: `scripts/import.ts` ### Done when - [ ] Counts are compared against the site's reported totals - [ ] Every referenced media file is local or listed as unfetchable - [ ] A second run fetches nothing and reports no differences - [ ] The import never writes to the source site ### Watch out - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. ## Phase 2 · Content written for a renderer you no longer have Imported bodies render correctly or are visibly flagged, and nothing is silently dropped. ### Steps 1. Sanitise on output, store the original You cannot get back what you removed at import time. Files: `src/render.ts`, `src/import/body.ts` 2. Record every marker you do not handle Invisible loss is the failure mode of every migration. Files: `src/import/markers.ts`, `tests/markers.test.ts` 3. Rewrite media references and record the rewrites So you can tell a broken image from one that was always broken. Files: `src/import/media.ts` ### Done when - [ ] Unhandled markers are recorded and counted per post - [ ] Original bodies are retained and sanitised on output - [ ] Media references are rewritten to local paths - [ ] No import step removes content from a body ### Watch out - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. ## Phase 3 · Every old link still goes somewhere No URL that worked before the switch returns a bare not found after it. ### Steps 1. Build the redirect table from the old permalink structure Including archives, feeds and attachment pages, which everyone forgets. Files: `src/redirects.ts` 2. Check every URL in the old sitemap It is the only complete list of what you are responsible for. Files: `scripts/check-urls.ts`, `tests/redirects.test.ts` 3. Keep the feed path alive Feed readers are the subscribers who never tell you they left. Files: `src/routes/feed.ts` ### Done when - [ ] Zero unresolved URLs from the old sitemap - [ ] Redirects are permanent and carry a reason - [ ] The old feed path still serves a valid feed - [ ] Unknown paths return a useful not found page ### Watch out - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. ## Phase 4 · Writing the next post Publishing something new is faster than it was on the old site. ### Steps 1. Keep the original upload, derive the rest Derived files are cheap; originals are not recoverable. Files: `src/media.ts`, `tests/media.test.ts` 2. Resolve scheduled posts on request One less thing that can be down at midnight. Files: `src/posts.ts` 3. Make the editor faster than the thing it replaced If it is slower you will write less, which defeats the project. Files: `src/admin/editor.ts` ### Done when - [ ] Originals are retained and derived sizes regenerable - [ ] Scheduled posts appear without a background job - [ ] Search covers post bodies - [ ] The feed validates ### Watch out - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. ## Not in this build - Plugins and themes. - Commerce of any kind. - Multiple authors with roles. - A block editor. ## After v1, if you want it - A static export of the whole site, so the content survives even if the application stops running - Comments by email rather than a form, which removes the spam problem instead of fighting it - A second reader for the old site kept running for a month after the switch, so you can compare pages you have not looked at yet ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Posts, terms, redirects and the import manifests. DATABASE_PATH=./data/site.db # Required. Originals and derived sizes. Originals are never deleted by the application. MEDIA_DIR=./data/media # Optional. Only needed while importing. Remove it once the manifest matches. SOURCE_API_BASE=https://old.example.com/wp-json/wp/v2 # Optional · secret. Read-only credentials for the old site. Delete after the final import. SOURCE_AUTH=user:application-password-redacted # Required · secret. The single author login, generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in the feed, the sitemap and every redirect target. PUBLIC_BASE_URL=https://example.com # Required. Where the archive lands before it is copied off the host. BACKUP_DIR=./backups
You are building a lean indie version of WordPress. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # WordPress · indie build A single-author CMS whose hard problems are extracting a live WordPress site completely rather than partially, keeping every published URL working after the switch, and rendering imported content that was written for a renderer you no longer have. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small server-rendered application, no client framework | a blog that needs a build step to fix a typo is a blog you stop updating | | Storage | SQLite in one file, with media as files on disk beside it | one file to back up is the difference between backing up and meaning to | | Extraction | The site's own REST API with page-by-page fetching and a checksummed manifest | the export file references media it does not contain, and paged fetches truncate quietly | | Rendering | Markdown for new posts, sanitised stored HTML for imported ones | imported content was written for a renderer you are not going to reimplement | | URLs | A permalink map with explicit redirects, checked against the old sitemap | links you break are links other people wrote | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the site and the import scripts. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Read access to the WordPress site you are leaving** · free while the site is still up - Why: Extraction runs against the live site, and doing it after the hosting lapses is not possible. - Get it: An application password or read-only credentials on the existing site, plus its API base URL. - Verify: A request for one post returns JSON and the totals headers - [ ] **A saved copy of the old site's sitemap** · free - Why: It is the complete list of URLs you are about to become responsible for, and it disappears with the site. - Get it: Download the sitemap and any index files it points at, before anything else. - Verify: The saved file lists every URL you expect, including archives and the feed - [ ] **A host with TLS and automatic certificate renewal** · a few pounds a month - Why: You are the hosting relationship now, and a lapsed certificate is the most common way a personal site goes dark. - Get it: Any small instance with an automatic certificate, or a platform that manages one. - Verify: The site loads with a valid certificate and renewal is automatic - [ ] **Somewhere off the host for backups** · pennies per month - Why: The thing you removed was doing this for you, and you will not notice the absence until you need it. - Get it: Object storage or a second machine, with the backup command scheduled. - Verify: A restore from the copy reproduces counts and checksums ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Plugins and themes. - Commerce of any kind. - Multiple authors with roles. - A block editor. - The plugin ecosystem: SEO, forms, caching, memberships, analytics, backups, all one click away in WordPress and all your problem in the DIY build - WooCommerce and anything resembling commerce - Gutenberg block editing and the visual page builders non-technical people actually like - A theme marketplace, so your site looks like whatever the agent felt like that day - Any hope of handing the site to a developer who already knows the stack - Security updates and managed hosting done by someone who is not you If one of those is essential to you, that is the reason to keep paying for WordPress, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · WordPress The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-author site that first extracts everything from an existing WordPress installation and then serves it without one to replace WordPress. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Be honest about what you are replacing. WordPress the software is free and you can self-host it today for nothing. What costs money is the hosting relationship: somebody patching it, backing it up and answering the phone when a plugin update takes the site down. Swapping that for code you maintain is a real trade, and the only version of it that makes sense is one where your content leaves whole and your URLs keep working. So the first phase is extraction, not features, and it runs against the live site before you have written a single page template. Two things about that extraction bite people. The export file the admin screen produces is XML listing posts, pages, custom post types, comments, custom fields, terms and users. It is not an archive: the images are references to URLs on the old host, so if that host goes away before you fetched them, they are gone. The REST API is the better route and it has its own rule, that a collection request returns at most one hundred records per page, with the totals reported in the X-WP-Total and X-WP-TotalPages response headers, so a naive fetch silently gives you the first hundred posts and looks like it worked. The rule: extraction produces a manifest with counts and a checksum per file, the counts are compared against the headers the site itself reported, and the build does not proceed until they match. And do not switch the domain over until every URL in the old sitemap resolves, either to the same content or to a redirect you wrote deliberately. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small server-rendered application, no client framework · a blog that needs a build step to fix a typo is a blog you stop updating - Storage: SQLite in one file, with media as files on disk beside it · one file to back up is the difference between backing up and meaning to - Extraction: The site's own REST API with page-by-page fetching and a checksummed manifest · the export file references media it does not contain, and paged fetches truncate quietly - Rendering: Markdown for new posts, sanitised stored HTML for imported ones · imported content was written for a renderer you are not going to reimplement - URLs: A permalink map with explicit redirects, checked against the old sitemap · links you break are links other people wrote ### Data model (create this before Phase 1) - posts(id, kind, slug, title, body_format, body, excerpt, status, published_at, updated_at) · body_format is markdown or imported_html - legacy(post_id, source_url, source_id, source_modified, import_batch, unhandled_markers_json) · what the old site said, kept for the arguments later - redirects(from_path, to_path, code, reason, created_at) · every old URL that is not the new URL - media(id, filename, original_url, bytes, sha256, width, height, alt, fetched_at) - post_media(post_id, media_id, rewritten) · which references in a body were rewritten to local files - terms(id, kind, slug, name) and post_terms(post_id, term_id) · categories and tags, flattened deliberately - imports(id, started_at, finished_at, source_base, expected_counts_json, actual_counts_json, ok) · the manifest, and the gate ### Phase 1 · Get everything out, and prove it Build: Fetch posts, pages, media, categories and tags page by page from the site's REST API, reading the reported totals from the response headers and continuing until the count matches. Download every media file by its URL, store it with a checksum, and record the original URL. Write a manifest of expected and actual counts per collection and fail the import if any pair differs. Trap: Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. Done when: The manifest records expected and actual counts per collection and the import fails when they differ, tested by truncating a fetch. Every media file referenced by an imported post is on local disk with a checksum, or is listed as unfetchable with its URL and the reason. Re-running the import against an unchanged site fetches nothing new and reports zero differences. The extraction runs against the live site without any write to it, verified by using read-only credentials. Do not build yet: No templates, editor, publishing or redirects yet. ### Phase 2 · Content written for a renderer you no longer have Build: Store imported bodies as HTML, sanitised on output rather than on import, so the original is preserved. Detect the markers the old renderer understood, including block delimiter comments and short codes in square brackets, and record every one you do not handle against the post. Rewrite media references to local paths and record which were rewritten. Trap: Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. Done when: Every imported post lists the markers found in it that this build does not render, and the total is visible in the admin, checked against a post containing a short code and a block comment. The original imported body is retained unmodified and sanitisation happens on output, verified in the database. A media reference pointing at the old host is rewritten to a local path, and any that could not be is listed. No import step deletes content from a body. Do not build yet: No redirects, editor or publishing workflow yet. ### Phase 3 · Every old link still goes somewhere Build: Build a map from every old path to a new one, including the permalink structure, the category and tag archives, the feed and any attachment pages. Serve permanent redirects for paths whose address changed. Fetch the old sitemap, request every URL against the new site, and produce a report of anything unresolved. Treat that report as a release gate. Trap: Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. Done when: Every URL in the old sitemap resolves to content or to a redirect, with the report showing zero unresolved, tested against a saved copy of the sitemap. Redirects are permanent and each carries a stored reason. The feed path from the old site continues to serve a valid feed. A request for a path that never existed returns a not found page with a search box rather than an empty response. Do not build yet: No editor, scheduling or backups yet. ### Phase 4 · Writing the next post Build: An admin behind one password with a Markdown editor, a preview, drafts, scheduled publishing resolved on request rather than by a scheduler, and image upload that resizes and stores alongside the originals. Full text search over posts. A feed, a sitemap and per-post metadata for link previews. Trap: Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. Done when: Writing and publishing a post takes fewer steps than the old site, timed and written in the README. Uploaded originals are retained and derived sizes can be deleted and regenerated, tested. A scheduled post appears at its time without any background process running, tested by moving the clock. Search returns posts by body text and the feed validates. Do not build yet: No backup, restore or export work yet. ### Phase 5 · You are the host now Build: A backup command producing one archive of the database, the media originals and the redirect table, with a checksum. A restore command that rebuilds into an empty directory and verifies counts. Export every post as Markdown with front matter and media as files. Serve behind TLS with a documented certificate renewal, and log enough to tell an outage from a mistake. Trap: Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed. Done when: A restore into an empty directory reproduces the post, media and redirect counts and matching checksums, exercised in the test suite. The backup archive includes media originals, verified by size and count rather than by inspection. Export produces one Markdown file per post with front matter plus media files, and a fresh install imports them. The README states who is on call, which is you, and what to do when the certificate expires. Do not build yet: Nothing after this. ### Out of scope (and why) - Plugins and themes, because the plugin economy is the actual product you are walking away from and half-reimplementing it is the worst of both. - Anything that takes money, because a checkout on a CMS you maintain alone is a liability rather than a feature. - Multiple authors with roles and approvals, which is where a small CMS turns into an application. - A block editor, which is a large project on its own and is the reason non-technical people like the original. ### README must contain - That core WordPress is free and self-hostable, and that what this build replaces is the hosting relationship rather than the software - That the admin export file lists media by URL rather than containing it, so extraction fetches the files separately - That collection requests to the site API are paged and the totals come from response headers, and that the import fails on a count mismatch - That every old URL is redirected and the sitemap check is a release gate - Who is on call, how backups run, and the restore command with the last date it was exercised ===== AGENTS.md ===== # Agent instructions · WordPress indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and a small server-rendered application, no client framework, SQLite in one file, with media as files on disk beside it, The site's own REST API with page-by-page fetching and a checksummed manifest, Markdown for new posts, sanitised stored HTML for imported ones, A permalink map with explicit redirects, checked against the old sitemap. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. ===== BUILD_PLAN.md ===== # Build plan · WordPress A single-author CMS whose hard problems are extracting a live WordPress site completely rather than partially, keeping every published URL working after the switch, and rendering imported content that was written for a renderer you no longer have. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Get everything out, and prove it A complete copy of the live site sits on your disk with counts that match what the site reported. ### Steps 1. Page until the reported totals are satisfied The header tells you how many there are, so there is no excuse. Files: `scripts/import.ts`, `tests/import-paging.test.ts` 2. Fetch media as files, keep the original URL and a checksum The export file is a list of links, not an archive. Files: `scripts/media.ts` 3. Make a count mismatch a failure, not a warning This is the only moment you can still go back and look. Files: `scripts/import.ts` ### Done when - [ ] Counts are compared against the site's reported totals - [ ] Every referenced media file is local or listed as unfetchable - [ ] A second run fetches nothing and reports no differences - [ ] The import never writes to the source site ### Watch out - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. ## Phase 2 · Content written for a renderer you no longer have Imported bodies render correctly or are visibly flagged, and nothing is silently dropped. ### Steps 1. Sanitise on output, store the original You cannot get back what you removed at import time. Files: `src/render.ts`, `src/import/body.ts` 2. Record every marker you do not handle Invisible loss is the failure mode of every migration. Files: `src/import/markers.ts`, `tests/markers.test.ts` 3. Rewrite media references and record the rewrites So you can tell a broken image from one that was always broken. Files: `src/import/media.ts` ### Done when - [ ] Unhandled markers are recorded and counted per post - [ ] Original bodies are retained and sanitised on output - [ ] Media references are rewritten to local paths - [ ] No import step removes content from a body ### Watch out - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. ## Phase 3 · Every old link still goes somewhere No URL that worked before the switch returns a bare not found after it. ### Steps 1. Build the redirect table from the old permalink structure Including archives, feeds and attachment pages, which everyone forgets. Files: `src/redirects.ts` 2. Check every URL in the old sitemap It is the only complete list of what you are responsible for. Files: `scripts/check-urls.ts`, `tests/redirects.test.ts` 3. Keep the feed path alive Feed readers are the subscribers who never tell you they left. Files: `src/routes/feed.ts` ### Done when - [ ] Zero unresolved URLs from the old sitemap - [ ] Redirects are permanent and carry a reason - [ ] The old feed path still serves a valid feed - [ ] Unknown paths return a useful not found page ### Watch out - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. ## Phase 4 · Writing the next post Publishing something new is faster than it was on the old site. ### Steps 1. Keep the original upload, derive the rest Derived files are cheap; originals are not recoverable. Files: `src/media.ts`, `tests/media.test.ts` 2. Resolve scheduled posts on request One less thing that can be down at midnight. Files: `src/posts.ts` 3. Make the editor faster than the thing it replaced If it is slower you will write less, which defeats the project. Files: `src/admin/editor.ts` ### Done when - [ ] Originals are retained and derived sizes regenerable - [ ] Scheduled posts appear without a background job - [ ] Search covers post bodies - [ ] The feed validates ### Watch out - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. ## Not in this build - Plugins and themes. - Commerce of any kind. - Multiple authors with roles. - A block editor. ## After v1, if you want it - A static export of the whole site, so the content survives even if the application stops running - Comments by email rather than a form, which removes the spam problem instead of fighting it - A second reader for the old site kept running for a month after the switch, so you can compare pages you have not looked at yet ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Posts, terms, redirects and the import manifests. DATABASE_PATH=./data/site.db # Required. Originals and derived sizes. Originals are never deleted by the application. MEDIA_DIR=./data/media # Optional. Only needed while importing. Remove it once the manifest matches. SOURCE_API_BASE=https://old.example.com/wp-json/wp/v2 # Optional · secret. Read-only credentials for the old site. Delete after the final import. SOURCE_AUTH=user:application-password-redacted # Required · secret. The single author login, generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in the feed, the sitemap and every redirect target. PUBLIC_BASE_URL=https://example.com # Required. Where the archive lands before it is copied off the host. BACKUP_DIR=./backups
You are building a production product version of WordPress. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # WordPress · product brief ## Problem The thing people actually use WordPress for on a personal site, write posts, upload images, publish, have an RSS feed, is a weekend build and always has been. An agent can hand you a SQLite-backed CMS with a markdown editor, media uploads, drafts, tags, and a sitemap in one sitting, and it will be faster and less hackable than the real thing. What you cannot one-shot is the other 90 percent of WordPress: 60,000 plugins, WooCommerce, Yoast, Elementor, the block editor, and the fact that any freelancer on earth can pick up your site. Also worth noting that core WordPress is already free and self-hostable, so the honest question is whether you want to maintain your own CMS instead of someone else's. If your site has a contact form, a store, or a client who needs to log in, stop reading and just install WordPress. ## Product outcome A site whose content came across whole and provably, whose old URLs all still work, whose imported posts declare what this renderer cannot render, and whose backups have actually been restored. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node 22 and a machine or VPS to run it on - A domain and TLS if you want it public - Local disk or an S3 bucket for media - Your own backup habit ## Explicit non-goals for v1 - Plugins and themes. - Commerce of any kind. - Multiple authors with roles. - A block editor. - The plugin ecosystem: SEO, forms, caching, memberships, analytics, backups, all one click away in WordPress and all your problem in the DIY build - WooCommerce and anything resembling commerce - Gutenberg block editing and the visual page builders non-technical people actually like - A theme marketplace, so your site looks like whatever the agent felt like that day - Any hope of handing the site to a developer who already knows the stack - Security updates and managed hosting done by someone who is not you ## Success criteria - Import counts are confirmed to match the source's reported totals - Every referenced media file is confirmed local or listed as unfetchable - Zero URLs from the old sitemap are confirmed unresolved - Unhandled markers are confirmed recorded rather than stripped - A restore into an empty directory is confirmed to reproduce counts and checksums ===== BRIEF.md ===== # Build brief · WordPress The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-author site that first extracts everything from an existing WordPress installation and then serves it without one to replace WordPress. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Be honest about what you are replacing. WordPress the software is free and you can self-host it today for nothing. What costs money is the hosting relationship: somebody patching it, backing it up and answering the phone when a plugin update takes the site down. Swapping that for code you maintain is a real trade, and the only version of it that makes sense is one where your content leaves whole and your URLs keep working. So the first phase is extraction, not features, and it runs against the live site before you have written a single page template. Two things about that extraction bite people. The export file the admin screen produces is XML listing posts, pages, custom post types, comments, custom fields, terms and users. It is not an archive: the images are references to URLs on the old host, so if that host goes away before you fetched them, they are gone. The REST API is the better route and it has its own rule, that a collection request returns at most one hundred records per page, with the totals reported in the X-WP-Total and X-WP-TotalPages response headers, so a naive fetch silently gives you the first hundred posts and looks like it worked. The rule: extraction produces a manifest with counts and a checksum per file, the counts are compared against the headers the site itself reported, and the build does not proceed until they match. And do not switch the domain over until every URL in the old sitemap resolves, either to the same content or to a redirect you wrote deliberately. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small server-rendered application, no client framework · a blog that needs a build step to fix a typo is a blog you stop updating - Storage: SQLite in one file, with media as files on disk beside it · one file to back up is the difference between backing up and meaning to - Extraction: The site's own REST API with page-by-page fetching and a checksummed manifest · the export file references media it does not contain, and paged fetches truncate quietly - Rendering: Markdown for new posts, sanitised stored HTML for imported ones · imported content was written for a renderer you are not going to reimplement - URLs: A permalink map with explicit redirects, checked against the old sitemap · links you break are links other people wrote ### Data model (create this before Phase 1) - posts(id, kind, slug, title, body_format, body, excerpt, status, published_at, updated_at) · body_format is markdown or imported_html - legacy(post_id, source_url, source_id, source_modified, import_batch, unhandled_markers_json) · what the old site said, kept for the arguments later - redirects(from_path, to_path, code, reason, created_at) · every old URL that is not the new URL - media(id, filename, original_url, bytes, sha256, width, height, alt, fetched_at) - post_media(post_id, media_id, rewritten) · which references in a body were rewritten to local files - terms(id, kind, slug, name) and post_terms(post_id, term_id) · categories and tags, flattened deliberately - imports(id, started_at, finished_at, source_base, expected_counts_json, actual_counts_json, ok) · the manifest, and the gate ### Phase 1 · Get everything out, and prove it Build: Fetch posts, pages, media, categories and tags page by page from the site's REST API, reading the reported totals from the response headers and continuing until the count matches. Download every media file by its URL, store it with a checksum, and record the original URL. Write a manifest of expected and actual counts per collection and fail the import if any pair differs. Trap: Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. Done when: The manifest records expected and actual counts per collection and the import fails when they differ, tested by truncating a fetch. Every media file referenced by an imported post is on local disk with a checksum, or is listed as unfetchable with its URL and the reason. Re-running the import against an unchanged site fetches nothing new and reports zero differences. The extraction runs against the live site without any write to it, verified by using read-only credentials. Do not build yet: No templates, editor, publishing or redirects yet. ### Phase 2 · Content written for a renderer you no longer have Build: Store imported bodies as HTML, sanitised on output rather than on import, so the original is preserved. Detect the markers the old renderer understood, including block delimiter comments and short codes in square brackets, and record every one you do not handle against the post. Rewrite media references to local paths and record which were rewritten. Trap: Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. Done when: Every imported post lists the markers found in it that this build does not render, and the total is visible in the admin, checked against a post containing a short code and a block comment. The original imported body is retained unmodified and sanitisation happens on output, verified in the database. A media reference pointing at the old host is rewritten to a local path, and any that could not be is listed. No import step deletes content from a body. Do not build yet: No redirects, editor or publishing workflow yet. ### Phase 3 · Every old link still goes somewhere Build: Build a map from every old path to a new one, including the permalink structure, the category and tag archives, the feed and any attachment pages. Serve permanent redirects for paths whose address changed. Fetch the old sitemap, request every URL against the new site, and produce a report of anything unresolved. Treat that report as a release gate. Trap: Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. Done when: Every URL in the old sitemap resolves to content or to a redirect, with the report showing zero unresolved, tested against a saved copy of the sitemap. Redirects are permanent and each carries a stored reason. The feed path from the old site continues to serve a valid feed. A request for a path that never existed returns a not found page with a search box rather than an empty response. Do not build yet: No editor, scheduling or backups yet. ### Phase 4 · Writing the next post Build: An admin behind one password with a Markdown editor, a preview, drafts, scheduled publishing resolved on request rather than by a scheduler, and image upload that resizes and stores alongside the originals. Full text search over posts. A feed, a sitemap and per-post metadata for link previews. Trap: Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. Done when: Writing and publishing a post takes fewer steps than the old site, timed and written in the README. Uploaded originals are retained and derived sizes can be deleted and regenerated, tested. A scheduled post appears at its time without any background process running, tested by moving the clock. Search returns posts by body text and the feed validates. Do not build yet: No backup, restore or export work yet. ### Phase 5 · You are the host now Build: A backup command producing one archive of the database, the media originals and the redirect table, with a checksum. A restore command that rebuilds into an empty directory and verifies counts. Export every post as Markdown with front matter and media as files. Serve behind TLS with a documented certificate renewal, and log enough to tell an outage from a mistake. Trap: Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed. Done when: A restore into an empty directory reproduces the post, media and redirect counts and matching checksums, exercised in the test suite. The backup archive includes media originals, verified by size and count rather than by inspection. Export produces one Markdown file per post with front matter plus media files, and a fresh install imports them. The README states who is on call, which is you, and what to do when the certificate expires. Do not build yet: Nothing after this. ### Out of scope (and why) - Plugins and themes, because the plugin economy is the actual product you are walking away from and half-reimplementing it is the worst of both. - Anything that takes money, because a checkout on a CMS you maintain alone is a liability rather than a feature. - Multiple authors with roles and approvals, which is where a small CMS turns into an application. - A block editor, which is a large project on its own and is the reason non-technical people like the original. ### README must contain - That core WordPress is free and self-hostable, and that what this build replaces is the hosting relationship rather than the software - That the admin export file lists media by URL rather than containing it, so extraction fetches the files separately - That collection requests to the site API are paged and the totals come from response headers, and that the import fails on a count mismatch - That every old URL is redirected and the sitemap check is a release gate - Who is on call, how backups run, and the restore command with the last date it was exercised ===== ARCHITECTURE.md ===== # Architecture · WordPress ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small server-rendered application, no client framework | a blog that needs a build step to fix a typo is a blog you stop updating | | Storage | SQLite in one file, with media as files on disk beside it | one file to back up is the difference between backing up and meaning to | | Extraction | The site's own REST API with page-by-page fetching and a checksummed manifest | the export file references media it does not contain, and paged fetches truncate quietly | | Rendering | Markdown for new posts, sanitised stored HTML for imported ones | imported content was written for a renderer you are not going to reimplement | | URLs | A permalink map with explicit redirects, checked against the old sitemap | links you break are links other people wrote | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Extraction | Paged fetching, media download, manifest and gate. | Any source, provided counts are compared against what the source reported. | | Content | Posts, terms, bodies and unhandled markers. | Any store, provided imported bodies are kept unmodified. | | URLs | Redirect table and the sitemap check. | Any router, provided the sitemap check stays a release gate. | | Media | Originals and derived sizes. | Any pipeline, provided originals are never the thing that gets deleted. | | Operations | Backup, restore and export. | Any target, provided the restore runs in the suite. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Posts, terms, redirects and the import manifests. - `MEDIA_DIR` · required · Originals and derived sizes. Originals are never deleted by the application. - `SOURCE_API_BASE` · optional · Only needed while importing. Remove it once the manifest matches. - `SOURCE_AUTH` · optional, secret · Read-only credentials for the old site. Delete after the final import. - `ADMIN_PASSWORD_HASH` · required, secret · The single author login, generated by the included command. - `PUBLIC_BASE_URL` · required · Used in the feed, the sitemap and every redirect target. - `BACKUP_DIR` · required · Where the archive lands before it is copied off the host. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · WordPress product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript and a small server-rendered application, no client framework, SQLite in one file, with media as files on disk beside it, The site's own REST API with page-by-page fetching and a checksummed manifest, Markdown for new posts, sanitised stored HTML for imported ones, A permalink map with explicit redirects, checked against the old sitemap. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. - Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed. ===== MILESTONES.md ===== # Delivery milestones · WordPress Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Get everything out, and prove it A complete copy of the live site sits on your disk with counts that match what the site reported. ### Steps 1. Page until the reported totals are satisfied The header tells you how many there are, so there is no excuse. Files: `scripts/import.ts`, `tests/import-paging.test.ts` 2. Fetch media as files, keep the original URL and a checksum The export file is a list of links, not an archive. Files: `scripts/media.ts` 3. Make a count mismatch a failure, not a warning This is the only moment you can still go back and look. Files: `scripts/import.ts` ### Done when - [ ] Counts are compared against the site's reported totals - [ ] Every referenced media file is local or listed as unfetchable - [ ] A second run fetches nothing and reports no differences - [ ] The import never writes to the source site ### Watch out - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. ## M2 · Content written for a renderer you no longer have Imported bodies render correctly or are visibly flagged, and nothing is silently dropped. ### Steps 1. Sanitise on output, store the original You cannot get back what you removed at import time. Files: `src/render.ts`, `src/import/body.ts` 2. Record every marker you do not handle Invisible loss is the failure mode of every migration. Files: `src/import/markers.ts`, `tests/markers.test.ts` 3. Rewrite media references and record the rewrites So you can tell a broken image from one that was always broken. Files: `src/import/media.ts` ### Done when - [ ] Unhandled markers are recorded and counted per post - [ ] Original bodies are retained and sanitised on output - [ ] Media references are rewritten to local paths - [ ] No import step removes content from a body ### Watch out - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. ## M3 · Every old link still goes somewhere No URL that worked before the switch returns a bare not found after it. ### Steps 1. Build the redirect table from the old permalink structure Including archives, feeds and attachment pages, which everyone forgets. Files: `src/redirects.ts` 2. Check every URL in the old sitemap It is the only complete list of what you are responsible for. Files: `scripts/check-urls.ts`, `tests/redirects.test.ts` 3. Keep the feed path alive Feed readers are the subscribers who never tell you they left. Files: `src/routes/feed.ts` ### Done when - [ ] Zero unresolved URLs from the old sitemap - [ ] Redirects are permanent and carry a reason - [ ] The old feed path still serves a valid feed - [ ] Unknown paths return a useful not found page ### Watch out - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. ## M4 · Writing the next post Publishing something new is faster than it was on the old site. ### Steps 1. Keep the original upload, derive the rest Derived files are cheap; originals are not recoverable. Files: `src/media.ts`, `tests/media.test.ts` 2. Resolve scheduled posts on request One less thing that can be down at midnight. Files: `src/posts.ts` 3. Make the editor faster than the thing it replaced If it is slower you will write less, which defeats the project. Files: `src/admin/editor.ts` ### Done when - [ ] Originals are retained and derived sizes regenerable - [ ] Scheduled posts appear without a background job - [ ] Search covers post bodies - [ ] The feed validates ### Watch out - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. ## M5 · You are the host now (production only) Backups run, restores are rehearsed, and the content can leave again. ### Steps 1. Prove the restore in the suite A backup nobody has restored is a directory of hope. Files: `scripts/restore.ts`, `tests/restore.test.ts` 2. Export to Markdown with front matter The format that will still open in ten years. Files: `scripts/export.ts` 3. Write down the certificate renewal and the on-call The hosting relationship you removed was doing this for you. Files: `README.md`, `docs/operations.md` ### Done when - [ ] Restore reproduces counts and checksums - [ ] Backups include media originals - [ ] Export produces Markdown plus media and reimports - [ ] Certificate renewal and on-call are documented ### Watch out - Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed. ===== OPERATIONS.md ===== # Operations · WordPress ## Backup One archive holding the database, the media originals and the redirect table, taken on a schedule and copied off the host. The redirects matter as much as the posts: losing them is losing every link anyone else ever made to you. ## Restore Restore into an empty directory, compare post, media and redirect counts and checksums against the manifest, then run the sitemap check before pointing the domain back. A restore that passes counts but fails the URL check means the redirect table did not come back. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Certificate expiry, backup age and size, not found rate by path, and feed fetch errors. A rising not found rate on old paths is the earliest sign a redirect was lost in a deploy. ## Incident checklist You are now the hosting relationship you were paying for, so write down what you do when the disk fills, when the certificate lapses and when a deploy breaks the redirects. The last one is the most likely and the least dramatic. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Import counts are confirmed to match the source's reported totals - [ ] Every referenced media file is confirmed local or listed as unfetchable - [ ] Zero URLs from the old sitemap are confirmed unresolved - [ ] Unhandled markers are confirmed recorded rather than stripped - [ ] A restore into an empty directory is confirmed to reproduce counts and checksums ## Launch constraint Do not market omitted WordPress capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Posts, terms, redirects and the import manifests. DATABASE_PATH=./data/site.db # Required. Originals and derived sizes. Originals are never deleted by the application. MEDIA_DIR=./data/media # Optional. Only needed while importing. Remove it once the manifest matches. SOURCE_API_BASE=https://old.example.com/wp-json/wp/v2 # Optional · secret. Read-only credentials for the old site. Delete after the final import. SOURCE_AUTH=user:application-password-redacted # Required · secret. The single author login, generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in the feed, the sitemap and every redirect target. PUBLIC_BASE_URL=https://example.com # Required. Where the archive lands before it is copied off the host. BACKUP_DIR=./backups
# WordPress · indie build A single-author CMS whose hard problems are extracting a live WordPress site completely rather than partially, keeping every published URL working after the switch, and rendering imported content that was written for a renderer you no longer have. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small server-rendered application, no client framework | a blog that needs a build step to fix a typo is a blog you stop updating | | Storage | SQLite in one file, with media as files on disk beside it | one file to back up is the difference between backing up and meaning to | | Extraction | The site's own REST API with page-by-page fetching and a checksummed manifest | the export file references media it does not contain, and paged fetches truncate quietly | | Rendering | Markdown for new posts, sanitised stored HTML for imported ones | imported content was written for a renderer you are not going to reimplement | | URLs | A permalink map with explicit redirects, checked against the old sitemap | links you break are links other people wrote | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the site and the import scripts. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Read access to the WordPress site you are leaving** · free while the site is still up - Why: Extraction runs against the live site, and doing it after the hosting lapses is not possible. - Get it: An application password or read-only credentials on the existing site, plus its API base URL. - Verify: A request for one post returns JSON and the totals headers - [ ] **A saved copy of the old site's sitemap** · free - Why: It is the complete list of URLs you are about to become responsible for, and it disappears with the site. - Get it: Download the sitemap and any index files it points at, before anything else. - Verify: The saved file lists every URL you expect, including archives and the feed - [ ] **A host with TLS and automatic certificate renewal** · a few pounds a month - Why: You are the hosting relationship now, and a lapsed certificate is the most common way a personal site goes dark. - Get it: Any small instance with an automatic certificate, or a platform that manages one. - Verify: The site loads with a valid certificate and renewal is automatic - [ ] **Somewhere off the host for backups** · pennies per month - Why: The thing you removed was doing this for you, and you will not notice the absence until you need it. - Get it: Object storage or a second machine, with the backup command scheduled. - Verify: A restore from the copy reproduces counts and checksums ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Plugins and themes. - Commerce of any kind. - Multiple authors with roles. - A block editor. - The plugin ecosystem: SEO, forms, caching, memberships, analytics, backups, all one click away in WordPress and all your problem in the DIY build - WooCommerce and anything resembling commerce - Gutenberg block editing and the visual page builders non-technical people actually like - A theme marketplace, so your site looks like whatever the agent felt like that day - Any hope of handing the site to a developer who already knows the stack - Security updates and managed hosting done by someone who is not you If one of those is essential to you, that is the reason to keep paying for WordPress, and the README should say so rather than pretend.
# Build brief · WordPress The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-author site that first extracts everything from an existing WordPress installation and then serves it without one to replace WordPress. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Be honest about what you are replacing. WordPress the software is free and you can self-host it today for nothing. What costs money is the hosting relationship: somebody patching it, backing it up and answering the phone when a plugin update takes the site down. Swapping that for code you maintain is a real trade, and the only version of it that makes sense is one where your content leaves whole and your URLs keep working. So the first phase is extraction, not features, and it runs against the live site before you have written a single page template. Two things about that extraction bite people. The export file the admin screen produces is XML listing posts, pages, custom post types, comments, custom fields, terms and users. It is not an archive: the images are references to URLs on the old host, so if that host goes away before you fetched them, they are gone. The REST API is the better route and it has its own rule, that a collection request returns at most one hundred records per page, with the totals reported in the X-WP-Total and X-WP-TotalPages response headers, so a naive fetch silently gives you the first hundred posts and looks like it worked. The rule: extraction produces a manifest with counts and a checksum per file, the counts are compared against the headers the site itself reported, and the build does not proceed until they match. And do not switch the domain over until every URL in the old sitemap resolves, either to the same content or to a redirect you wrote deliberately. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small server-rendered application, no client framework · a blog that needs a build step to fix a typo is a blog you stop updating - Storage: SQLite in one file, with media as files on disk beside it · one file to back up is the difference between backing up and meaning to - Extraction: The site's own REST API with page-by-page fetching and a checksummed manifest · the export file references media it does not contain, and paged fetches truncate quietly - Rendering: Markdown for new posts, sanitised stored HTML for imported ones · imported content was written for a renderer you are not going to reimplement - URLs: A permalink map with explicit redirects, checked against the old sitemap · links you break are links other people wrote ### Data model (create this before Phase 1) - posts(id, kind, slug, title, body_format, body, excerpt, status, published_at, updated_at) · body_format is markdown or imported_html - legacy(post_id, source_url, source_id, source_modified, import_batch, unhandled_markers_json) · what the old site said, kept for the arguments later - redirects(from_path, to_path, code, reason, created_at) · every old URL that is not the new URL - media(id, filename, original_url, bytes, sha256, width, height, alt, fetched_at) - post_media(post_id, media_id, rewritten) · which references in a body were rewritten to local files - terms(id, kind, slug, name) and post_terms(post_id, term_id) · categories and tags, flattened deliberately - imports(id, started_at, finished_at, source_base, expected_counts_json, actual_counts_json, ok) · the manifest, and the gate ### Phase 1 · Get everything out, and prove it Build: Fetch posts, pages, media, categories and tags page by page from the site's REST API, reading the reported totals from the response headers and continuing until the count matches. Download every media file by its URL, store it with a checksum, and record the original URL. Write a manifest of expected and actual counts per collection and fail the import if any pair differs. Trap: Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. Done when: The manifest records expected and actual counts per collection and the import fails when they differ, tested by truncating a fetch. Every media file referenced by an imported post is on local disk with a checksum, or is listed as unfetchable with its URL and the reason. Re-running the import against an unchanged site fetches nothing new and reports zero differences. The extraction runs against the live site without any write to it, verified by using read-only credentials. Do not build yet: No templates, editor, publishing or redirects yet. ### Phase 2 · Content written for a renderer you no longer have Build: Store imported bodies as HTML, sanitised on output rather than on import, so the original is preserved. Detect the markers the old renderer understood, including block delimiter comments and short codes in square brackets, and record every one you do not handle against the post. Rewrite media references to local paths and record which were rewritten. Trap: Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. Done when: Every imported post lists the markers found in it that this build does not render, and the total is visible in the admin, checked against a post containing a short code and a block comment. The original imported body is retained unmodified and sanitisation happens on output, verified in the database. A media reference pointing at the old host is rewritten to a local path, and any that could not be is listed. No import step deletes content from a body. Do not build yet: No redirects, editor or publishing workflow yet. ### Phase 3 · Every old link still goes somewhere Build: Build a map from every old path to a new one, including the permalink structure, the category and tag archives, the feed and any attachment pages. Serve permanent redirects for paths whose address changed. Fetch the old sitemap, request every URL against the new site, and produce a report of anything unresolved. Treat that report as a release gate. Trap: Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. Done when: Every URL in the old sitemap resolves to content or to a redirect, with the report showing zero unresolved, tested against a saved copy of the sitemap. Redirects are permanent and each carries a stored reason. The feed path from the old site continues to serve a valid feed. A request for a path that never existed returns a not found page with a search box rather than an empty response. Do not build yet: No editor, scheduling or backups yet. ### Phase 4 · Writing the next post Build: An admin behind one password with a Markdown editor, a preview, drafts, scheduled publishing resolved on request rather than by a scheduler, and image upload that resizes and stores alongside the originals. Full text search over posts. A feed, a sitemap and per-post metadata for link previews. Trap: Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. Done when: Writing and publishing a post takes fewer steps than the old site, timed and written in the README. Uploaded originals are retained and derived sizes can be deleted and regenerated, tested. A scheduled post appears at its time without any background process running, tested by moving the clock. Search returns posts by body text and the feed validates. Do not build yet: No backup, restore or export work yet. ### Phase 5 · You are the host now Build: A backup command producing one archive of the database, the media originals and the redirect table, with a checksum. A restore command that rebuilds into an empty directory and verifies counts. Export every post as Markdown with front matter and media as files. Serve behind TLS with a documented certificate renewal, and log enough to tell an outage from a mistake. Trap: Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed. Done when: A restore into an empty directory reproduces the post, media and redirect counts and matching checksums, exercised in the test suite. The backup archive includes media originals, verified by size and count rather than by inspection. Export produces one Markdown file per post with front matter plus media files, and a fresh install imports them. The README states who is on call, which is you, and what to do when the certificate expires. Do not build yet: Nothing after this. ### Out of scope (and why) - Plugins and themes, because the plugin economy is the actual product you are walking away from and half-reimplementing it is the worst of both. - Anything that takes money, because a checkout on a CMS you maintain alone is a liability rather than a feature. - Multiple authors with roles and approvals, which is where a small CMS turns into an application. - A block editor, which is a large project on its own and is the reason non-technical people like the original. ### README must contain - That core WordPress is free and self-hostable, and that what this build replaces is the hosting relationship rather than the software - That the admin export file lists media by URL rather than containing it, so extraction fetches the files separately - That collection requests to the site API are paged and the totals come from response headers, and that the import fails on a count mismatch - That every old URL is redirected and the sitemap check is a release gate - Who is on call, how backups run, and the restore command with the last date it was exercised
# Agent instructions · WordPress indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and a small server-rendered application, no client framework, SQLite in one file, with media as files on disk beside it, The site's own REST API with page-by-page fetching and a checksummed manifest, Markdown for new posts, sanitised stored HTML for imported ones, A permalink map with explicit redirects, checked against the old sitemap. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable.
# Build plan · WordPress A single-author CMS whose hard problems are extracting a live WordPress site completely rather than partially, keeping every published URL working after the switch, and rendering imported content that was written for a renderer you no longer have. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Get everything out, and prove it A complete copy of the live site sits on your disk with counts that match what the site reported. ### Steps 1. Page until the reported totals are satisfied The header tells you how many there are, so there is no excuse. Files: `scripts/import.ts`, `tests/import-paging.test.ts` 2. Fetch media as files, keep the original URL and a checksum The export file is a list of links, not an archive. Files: `scripts/media.ts` 3. Make a count mismatch a failure, not a warning This is the only moment you can still go back and look. Files: `scripts/import.ts` ### Done when - [ ] Counts are compared against the site's reported totals - [ ] Every referenced media file is local or listed as unfetchable - [ ] A second run fetches nothing and reports no differences - [ ] The import never writes to the source site ### Watch out - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. ## Phase 2 · Content written for a renderer you no longer have Imported bodies render correctly or are visibly flagged, and nothing is silently dropped. ### Steps 1. Sanitise on output, store the original You cannot get back what you removed at import time. Files: `src/render.ts`, `src/import/body.ts` 2. Record every marker you do not handle Invisible loss is the failure mode of every migration. Files: `src/import/markers.ts`, `tests/markers.test.ts` 3. Rewrite media references and record the rewrites So you can tell a broken image from one that was always broken. Files: `src/import/media.ts` ### Done when - [ ] Unhandled markers are recorded and counted per post - [ ] Original bodies are retained and sanitised on output - [ ] Media references are rewritten to local paths - [ ] No import step removes content from a body ### Watch out - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. ## Phase 3 · Every old link still goes somewhere No URL that worked before the switch returns a bare not found after it. ### Steps 1. Build the redirect table from the old permalink structure Including archives, feeds and attachment pages, which everyone forgets. Files: `src/redirects.ts` 2. Check every URL in the old sitemap It is the only complete list of what you are responsible for. Files: `scripts/check-urls.ts`, `tests/redirects.test.ts` 3. Keep the feed path alive Feed readers are the subscribers who never tell you they left. Files: `src/routes/feed.ts` ### Done when - [ ] Zero unresolved URLs from the old sitemap - [ ] Redirects are permanent and carry a reason - [ ] The old feed path still serves a valid feed - [ ] Unknown paths return a useful not found page ### Watch out - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. ## Phase 4 · Writing the next post Publishing something new is faster than it was on the old site. ### Steps 1. Keep the original upload, derive the rest Derived files are cheap; originals are not recoverable. Files: `src/media.ts`, `tests/media.test.ts` 2. Resolve scheduled posts on request One less thing that can be down at midnight. Files: `src/posts.ts` 3. Make the editor faster than the thing it replaced If it is slower you will write less, which defeats the project. Files: `src/admin/editor.ts` ### Done when - [ ] Originals are retained and derived sizes regenerable - [ ] Scheduled posts appear without a background job - [ ] Search covers post bodies - [ ] The feed validates ### Watch out - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. ## Not in this build - Plugins and themes. - Commerce of any kind. - Multiple authors with roles. - A block editor. ## After v1, if you want it - A static export of the whole site, so the content survives even if the application stops running - Comments by email rather than a form, which removes the spam problem instead of fighting it - A second reader for the old site kept running for a month after the switch, so you can compare pages you have not looked at yet
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Posts, terms, redirects and the import manifests. DATABASE_PATH=./data/site.db # Required. Originals and derived sizes. Originals are never deleted by the application. MEDIA_DIR=./data/media # Optional. Only needed while importing. Remove it once the manifest matches. SOURCE_API_BASE=https://old.example.com/wp-json/wp/v2 # Optional · secret. Read-only credentials for the old site. Delete after the final import. SOURCE_AUTH=user:application-password-redacted # Required · secret. The single author login, generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in the feed, the sitemap and every redirect target. PUBLIC_BASE_URL=https://example.com # Required. Where the archive lands before it is copied off the host. BACKUP_DIR=./backups
# WordPress · product brief ## Problem The thing people actually use WordPress for on a personal site, write posts, upload images, publish, have an RSS feed, is a weekend build and always has been. An agent can hand you a SQLite-backed CMS with a markdown editor, media uploads, drafts, tags, and a sitemap in one sitting, and it will be faster and less hackable than the real thing. What you cannot one-shot is the other 90 percent of WordPress: 60,000 plugins, WooCommerce, Yoast, Elementor, the block editor, and the fact that any freelancer on earth can pick up your site. Also worth noting that core WordPress is already free and self-hostable, so the honest question is whether you want to maintain your own CMS instead of someone else's. If your site has a contact form, a store, or a client who needs to log in, stop reading and just install WordPress. ## Product outcome A site whose content came across whole and provably, whose old URLs all still work, whose imported posts declare what this renderer cannot render, and whose backups have actually been restored. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node 22 and a machine or VPS to run it on - A domain and TLS if you want it public - Local disk or an S3 bucket for media - Your own backup habit ## Explicit non-goals for v1 - Plugins and themes. - Commerce of any kind. - Multiple authors with roles. - A block editor. - The plugin ecosystem: SEO, forms, caching, memberships, analytics, backups, all one click away in WordPress and all your problem in the DIY build - WooCommerce and anything resembling commerce - Gutenberg block editing and the visual page builders non-technical people actually like - A theme marketplace, so your site looks like whatever the agent felt like that day - Any hope of handing the site to a developer who already knows the stack - Security updates and managed hosting done by someone who is not you ## Success criteria - Import counts are confirmed to match the source's reported totals - Every referenced media file is confirmed local or listed as unfetchable - Zero URLs from the old sitemap are confirmed unresolved - Unhandled markers are confirmed recorded rather than stripped - A restore into an empty directory is confirmed to reproduce counts and checksums
# Build brief · WordPress The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-author site that first extracts everything from an existing WordPress installation and then serves it without one to replace WordPress. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Be honest about what you are replacing. WordPress the software is free and you can self-host it today for nothing. What costs money is the hosting relationship: somebody patching it, backing it up and answering the phone when a plugin update takes the site down. Swapping that for code you maintain is a real trade, and the only version of it that makes sense is one where your content leaves whole and your URLs keep working. So the first phase is extraction, not features, and it runs against the live site before you have written a single page template. Two things about that extraction bite people. The export file the admin screen produces is XML listing posts, pages, custom post types, comments, custom fields, terms and users. It is not an archive: the images are references to URLs on the old host, so if that host goes away before you fetched them, they are gone. The REST API is the better route and it has its own rule, that a collection request returns at most one hundred records per page, with the totals reported in the X-WP-Total and X-WP-TotalPages response headers, so a naive fetch silently gives you the first hundred posts and looks like it worked. The rule: extraction produces a manifest with counts and a checksum per file, the counts are compared against the headers the site itself reported, and the build does not proceed until they match. And do not switch the domain over until every URL in the old sitemap resolves, either to the same content or to a redirect you wrote deliberately. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small server-rendered application, no client framework · a blog that needs a build step to fix a typo is a blog you stop updating - Storage: SQLite in one file, with media as files on disk beside it · one file to back up is the difference between backing up and meaning to - Extraction: The site's own REST API with page-by-page fetching and a checksummed manifest · the export file references media it does not contain, and paged fetches truncate quietly - Rendering: Markdown for new posts, sanitised stored HTML for imported ones · imported content was written for a renderer you are not going to reimplement - URLs: A permalink map with explicit redirects, checked against the old sitemap · links you break are links other people wrote ### Data model (create this before Phase 1) - posts(id, kind, slug, title, body_format, body, excerpt, status, published_at, updated_at) · body_format is markdown or imported_html - legacy(post_id, source_url, source_id, source_modified, import_batch, unhandled_markers_json) · what the old site said, kept for the arguments later - redirects(from_path, to_path, code, reason, created_at) · every old URL that is not the new URL - media(id, filename, original_url, bytes, sha256, width, height, alt, fetched_at) - post_media(post_id, media_id, rewritten) · which references in a body were rewritten to local files - terms(id, kind, slug, name) and post_terms(post_id, term_id) · categories and tags, flattened deliberately - imports(id, started_at, finished_at, source_base, expected_counts_json, actual_counts_json, ok) · the manifest, and the gate ### Phase 1 · Get everything out, and prove it Build: Fetch posts, pages, media, categories and tags page by page from the site's REST API, reading the reported totals from the response headers and continuing until the count matches. Download every media file by its URL, store it with a checksum, and record the original URL. Write a manifest of expected and actual counts per collection and fail the import if any pair differs. Trap: Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. Done when: The manifest records expected and actual counts per collection and the import fails when they differ, tested by truncating a fetch. Every media file referenced by an imported post is on local disk with a checksum, or is listed as unfetchable with its URL and the reason. Re-running the import against an unchanged site fetches nothing new and reports zero differences. The extraction runs against the live site without any write to it, verified by using read-only credentials. Do not build yet: No templates, editor, publishing or redirects yet. ### Phase 2 · Content written for a renderer you no longer have Build: Store imported bodies as HTML, sanitised on output rather than on import, so the original is preserved. Detect the markers the old renderer understood, including block delimiter comments and short codes in square brackets, and record every one you do not handle against the post. Rewrite media references to local paths and record which were rewritten. Trap: Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. Done when: Every imported post lists the markers found in it that this build does not render, and the total is visible in the admin, checked against a post containing a short code and a block comment. The original imported body is retained unmodified and sanitisation happens on output, verified in the database. A media reference pointing at the old host is rewritten to a local path, and any that could not be is listed. No import step deletes content from a body. Do not build yet: No redirects, editor or publishing workflow yet. ### Phase 3 · Every old link still goes somewhere Build: Build a map from every old path to a new one, including the permalink structure, the category and tag archives, the feed and any attachment pages. Serve permanent redirects for paths whose address changed. Fetch the old sitemap, request every URL against the new site, and produce a report of anything unresolved. Treat that report as a release gate. Trap: Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. Done when: Every URL in the old sitemap resolves to content or to a redirect, with the report showing zero unresolved, tested against a saved copy of the sitemap. Redirects are permanent and each carries a stored reason. The feed path from the old site continues to serve a valid feed. A request for a path that never existed returns a not found page with a search box rather than an empty response. Do not build yet: No editor, scheduling or backups yet. ### Phase 4 · Writing the next post Build: An admin behind one password with a Markdown editor, a preview, drafts, scheduled publishing resolved on request rather than by a scheduler, and image upload that resizes and stores alongside the originals. Full text search over posts. A feed, a sitemap and per-post metadata for link previews. Trap: Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. Done when: Writing and publishing a post takes fewer steps than the old site, timed and written in the README. Uploaded originals are retained and derived sizes can be deleted and regenerated, tested. A scheduled post appears at its time without any background process running, tested by moving the clock. Search returns posts by body text and the feed validates. Do not build yet: No backup, restore or export work yet. ### Phase 5 · You are the host now Build: A backup command producing one archive of the database, the media originals and the redirect table, with a checksum. A restore command that rebuilds into an empty directory and verifies counts. Export every post as Markdown with front matter and media as files. Serve behind TLS with a documented certificate renewal, and log enough to tell an outage from a mistake. Trap: Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed. Done when: A restore into an empty directory reproduces the post, media and redirect counts and matching checksums, exercised in the test suite. The backup archive includes media originals, verified by size and count rather than by inspection. Export produces one Markdown file per post with front matter plus media files, and a fresh install imports them. The README states who is on call, which is you, and what to do when the certificate expires. Do not build yet: Nothing after this. ### Out of scope (and why) - Plugins and themes, because the plugin economy is the actual product you are walking away from and half-reimplementing it is the worst of both. - Anything that takes money, because a checkout on a CMS you maintain alone is a liability rather than a feature. - Multiple authors with roles and approvals, which is where a small CMS turns into an application. - A block editor, which is a large project on its own and is the reason non-technical people like the original. ### README must contain - That core WordPress is free and self-hostable, and that what this build replaces is the hosting relationship rather than the software - That the admin export file lists media by URL rather than containing it, so extraction fetches the files separately - That collection requests to the site API are paged and the totals come from response headers, and that the import fails on a count mismatch - That every old URL is redirected and the sitemap check is a release gate - Who is on call, how backups run, and the restore command with the last date it was exercised
# Architecture · WordPress ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small server-rendered application, no client framework | a blog that needs a build step to fix a typo is a blog you stop updating | | Storage | SQLite in one file, with media as files on disk beside it | one file to back up is the difference between backing up and meaning to | | Extraction | The site's own REST API with page-by-page fetching and a checksummed manifest | the export file references media it does not contain, and paged fetches truncate quietly | | Rendering | Markdown for new posts, sanitised stored HTML for imported ones | imported content was written for a renderer you are not going to reimplement | | URLs | A permalink map with explicit redirects, checked against the old sitemap | links you break are links other people wrote | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Extraction | Paged fetching, media download, manifest and gate. | Any source, provided counts are compared against what the source reported. | | Content | Posts, terms, bodies and unhandled markers. | Any store, provided imported bodies are kept unmodified. | | URLs | Redirect table and the sitemap check. | Any router, provided the sitemap check stays a release gate. | | Media | Originals and derived sizes. | Any pipeline, provided originals are never the thing that gets deleted. | | Operations | Backup, restore and export. | Any target, provided the restore runs in the suite. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Posts, terms, redirects and the import manifests. - `MEDIA_DIR` · required · Originals and derived sizes. Originals are never deleted by the application. - `SOURCE_API_BASE` · optional · Only needed while importing. Remove it once the manifest matches. - `SOURCE_AUTH` · optional, secret · Read-only credentials for the old site. Delete after the final import. - `ADMIN_PASSWORD_HASH` · required, secret · The single author login, generated by the included command. - `PUBLIC_BASE_URL` · required · Used in the feed, the sitemap and every redirect target. - `BACKUP_DIR` · required · Where the archive lands before it is copied off the host. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · WordPress product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript and a small server-rendered application, no client framework, SQLite in one file, with media as files on disk beside it, The site's own REST API with page-by-page fetching and a checksummed manifest, Markdown for new posts, sanitised stored HTML for imported ones, A permalink map with explicit redirects, checked against the old sitemap. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. - Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed.
# Delivery milestones · WordPress Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Get everything out, and prove it A complete copy of the live site sits on your disk with counts that match what the site reported. ### Steps 1. Page until the reported totals are satisfied The header tells you how many there are, so there is no excuse. Files: `scripts/import.ts`, `tests/import-paging.test.ts` 2. Fetch media as files, keep the original URL and a checksum The export file is a list of links, not an archive. Files: `scripts/media.ts` 3. Make a count mismatch a failure, not a warning This is the only moment you can still go back and look. Files: `scripts/import.ts` ### Done when - [ ] Counts are compared against the site's reported totals - [ ] Every referenced media file is local or listed as unfetchable - [ ] A second run fetches nothing and reports no differences - [ ] The import never writes to the source site ### Watch out - Requesting a collection without paging returns the first page only, and since a hundred posts looks like a lot, the import is declared a success while half the archive is still on a host you are about to cancel. Read the totals from the response headers, page until you have them all, and make a count mismatch fail the run rather than print a warning. ## M2 · Content written for a renderer you no longer have Imported bodies render correctly or are visibly flagged, and nothing is silently dropped. ### Steps 1. Sanitise on output, store the original You cannot get back what you removed at import time. Files: `src/render.ts`, `src/import/body.ts` 2. Record every marker you do not handle Invisible loss is the failure mode of every migration. Files: `src/import/markers.ts`, `tests/markers.test.ts` 3. Rewrite media references and record the rewrites So you can tell a broken image from one that was always broken. Files: `src/import/media.ts` ### Done when - [ ] Unhandled markers are recorded and counted per post - [ ] Original bodies are retained and sanitised on output - [ ] Media references are rewritten to local paths - [ ] No import step removes content from a body ### Watch out - Stripping anything that is not plain HTML on import produces pages that look fine and are missing a gallery, a form and three embeds, and since the original is gone you will not find out until a reader tells you. Keep the original body, list every unhandled marker per post, and show the count in the admin so the work is visible instead of invisible. ## M3 · Every old link still goes somewhere No URL that worked before the switch returns a bare not found after it. ### Steps 1. Build the redirect table from the old permalink structure Including archives, feeds and attachment pages, which everyone forgets. Files: `src/redirects.ts` 2. Check every URL in the old sitemap It is the only complete list of what you are responsible for. Files: `scripts/check-urls.ts`, `tests/redirects.test.ts` 3. Keep the feed path alive Feed readers are the subscribers who never tell you they left. Files: `src/routes/feed.ts` ### Done when - [ ] Zero unresolved URLs from the old sitemap - [ ] Redirects are permanent and carry a reason - [ ] The old feed path still serves a valid feed - [ ] Unknown paths return a useful not found page ### Watch out - Changing the permalink structure because the new one is tidier breaks every link anyone ever shared, and search engines and other people's posts do not come back once they have been redirected to a not found page. Keep the old paths working with permanent redirects, and only change the structure once the redirect table exists and the sitemap check passes. ## M4 · Writing the next post Publishing something new is faster than it was on the old site. ### Steps 1. Keep the original upload, derive the rest Derived files are cheap; originals are not recoverable. Files: `src/media.ts`, `tests/media.test.ts` 2. Resolve scheduled posts on request One less thing that can be down at midnight. Files: `src/posts.ts` 3. Make the editor faster than the thing it replaced If it is slower you will write less, which defeats the project. Files: `src/admin/editor.ts` ### Done when - [ ] Originals are retained and derived sizes regenerable - [ ] Scheduled posts appear without a background job - [ ] Search covers post bodies - [ ] The feed validates ### Watch out - Resizing an upload and throwing the original away means the day you change the layout or want a larger image you are re-uploading from a phone backup, which is the same class of loss as the import problem one phase earlier. Keep the original file, derive sizes from it, and let the derived files be deletable and regenerable. ## M5 · You are the host now (production only) Backups run, restores are rehearsed, and the content can leave again. ### Steps 1. Prove the restore in the suite A backup nobody has restored is a directory of hope. Files: `scripts/restore.ts`, `tests/restore.test.ts` 2. Export to Markdown with front matter The format that will still open in ten years. Files: `scripts/export.ts` 3. Write down the certificate renewal and the on-call The hosting relationship you removed was doing this for you. Files: `README.md`, `docs/operations.md` ### Done when - [ ] Restore reproduces counts and checksums - [ ] Backups include media originals - [ ] Export produces Markdown plus media and reimports - [ ] Certificate renewal and on-call are documented ### Watch out - Treating backups as a command you will run later means the first restore you attempt is the one after the disk failed, and a restore that has never been rehearsed usually reveals that the media directory was excluded. Run the restore in the test suite against a fresh directory and compare counts and checksums, so the backup is proved rather than assumed.
# Operations · WordPress ## Backup One archive holding the database, the media originals and the redirect table, taken on a schedule and copied off the host. The redirects matter as much as the posts: losing them is losing every link anyone else ever made to you. ## Restore Restore into an empty directory, compare post, media and redirect counts and checksums against the manifest, then run the sitemap check before pointing the domain back. A restore that passes counts but fails the URL check means the redirect table did not come back. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Certificate expiry, backup age and size, not found rate by path, and feed fetch errors. A rising not found rate on old paths is the earliest sign a redirect was lost in a deploy. ## Incident checklist You are now the hosting relationship you were paying for, so write down what you do when the disk fills, when the certificate lapses and when a deploy breaks the redirects. The last one is the most likely and the least dramatic. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Import counts are confirmed to match the source's reported totals - [ ] Every referenced media file is confirmed local or listed as unfetchable - [ ] Zero URLs from the old sitemap are confirmed unresolved - [ ] Unhandled markers are confirmed recorded rather than stripped - [ ] A restore into an empty directory is confirmed to reproduce counts and checksums ## Launch constraint Do not market omitted WordPress capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Posts, terms, redirects and the import manifests. DATABASE_PATH=./data/site.db # Required. Originals and derived sizes. Originals are never deleted by the application. MEDIA_DIR=./data/media # Optional. Only needed while importing. Remove it once the manifest matches. SOURCE_API_BASE=https://old.example.com/wp-json/wp/v2 # Optional · secret. Read-only credentials for the old site. Delete after the final import. SOURCE_AUTH=user:application-password-redacted # Required · secret. The single author login, generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in the feed, the sitemap and every redirect target. PUBLIC_BASE_URL=https://example.com # Required. Where the archive lands before it is copied off the host. BACKUP_DIR=./backups
$ choose a build depth, inspect the files, then open the complete pack in your agent
Almost nobody pays for WordPress the software. They pay for managed hosting so the site stays up and patched, for the three or four plugins that quietly run their business, and for the person who knows how to fix it when the theme update explodes. A personal CMS you built yourself removes the hosting bill and adds an on-call rotation of one. That trade is fine for a blog and terrible for anything that takes money.
xThe plugin ecosystem: SEO, forms, caching, memberships, analytics, backups, all one click away in WordPress and all your problem in the DIY build
xWooCommerce and anything resembling commerce
xGutenberg block editing and the visual page builders non-technical people actually like
xA theme marketplace, so your site looks like whatever the agent felt like that day
xAny hope of handing the site to a developer who already knows the stack
xSecurity updates and managed hosting done by someone who is not you
Nothing worth pointing at. That's why the prompt exists.
Vibecode WordPress
Kinda. The core of WordPress is buildable in a weekend with the prompt on this page, but there are real gaps: The plugin ecosystem: SEO, forms, caching, memberships, analytics, backups, all one click away in WordPress and all your problem in the DIY build, WooCommerce and anything resembling commerce. Read the honest list above before committing.
How much does WordPress cost?
WordPress costs about $4/month (Personal, checked 2026-08-18), which is $48 per year.
What do I lose by replacing WordPress?
Honestly: The plugin ecosystem: SEO, forms, caching, memberships, analytics, backups, all one click away in WordPress and all your problem in the DIY build; WooCommerce and anything resembling commerce; Gutenberg block editing and the visual page builders non-technical people actually like; A theme marketplace, so your site looks like whatever the agent felt like that day; Any hope of handing the site to a developer who already knows the stack; Security updates and managed hosting done by someone who is not you. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to WordPress?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.