diff --git a/README.md b/README.md
index 78c4a3ec..17b0a132 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,16 @@ Add a new entry to the category's `articles` array in `src/App/src/Fixture/artic
`opengraph_img` is the image shown as the social-media (Twitter/OG) preview card. Leave it `null` to fall back to the site-wide default image (`config/autoload/local.php` → `application.meta.image`). To set one, put the image file at `public/opengraph/article/your-image.png` and reference it here as a root-relative path: `"opengraph_img": "/opengraph/article/your-image.png"`. This is unrelated to the in-article images described in step 3 — it is placed by hand, not by `bin/create-uploads-dir`.
-**Important:** you can set `"post_status": "draft"` instead of `"publish"` to keep an article out of sight — anything other than `publish`/`private` is treated as a draft by `PostLoader`, and `getPublishedPosts()` (used by both `bin/generate-feed` and `bin/sitemap`) only returns posts with `publish` status. After changing it, follow the same steps: re-run `bin/doctrine-fixtures`, then `bin/generate-feed` and `bin/sitemap`. This applies generally, not just to status changes — **any** edit to `articles_cleaned.json` (title, excerpt, status, date, etc.) needs `bin/doctrine-fixtures` re-run to update the database, followed by re-running the 3 generators in step 4 so `feed.xml`/`sitemap.xml`/`llms-full.txt` reflect it. One exception: `bin/generate-llms-full` reads straight from the `.md` files on disk and does **not** check `post_status` at all — a `draft` article's `.md` file will still be included in `llms-full.txt` unless you also remove or rename that file.
+**`post_status` values.** `PostLoader` (`src/App/src/Fixture/PostLoader.php`) only recognizes 3 JSON strings — anything else (including the literal `"draft"`) falls through to `Draft`:
+
+| JSON value | Maps to | Behavior |
+| --- | --- | --- |
+| `"publish"` | `PostStatusEnum::Published` | The only status shown in listings, the RSS feed, and the sitemap (`getPublishedPosts()` and every category/tag/author query filter on `Published` only). |
+| `"private"` | `PostStatusEnum::Private` | Not published: excluded from listings/feed/sitemap same as a draft, and its own page returns `404` — there is currently no route or view that treats `Private` differently from `Draft`. |
+| `"archived"` | `PostStatusEnum::Archived` | Not published: excluded from listings/feed/sitemap, but its own page returns `410 Gone` instead of `404` — use this for content that existed and was intentionally removed (outdated articles, leftover test content, etc.), as opposed to content that was never public. |
+| anything else (including `"draft"`) | `PostStatusEnum::Draft` | Not published: excluded from listings/feed/sitemap, its own page returns `404`. This is also the fallback for typos in `post_status`. |
+
+After changing `post_status`, follow the same steps: re-run `bin/doctrine-fixtures`, then `bin/generate-feed` and `bin/sitemap`. This applies generally, not just to status changes — **any** edit to `articles_cleaned.json` (title, excerpt, status, date, etc.) needs `bin/doctrine-fixtures` re-run to update the database, followed by re-running the 3 generators in step 4 so `feed.xml`/`sitemap.xml`/`llms-full.txt` reflect it. One exception: `bin/generate-llms-full` reads straight from the `.md` files on disk and does **not** check `post_status` at all — a non-published article's `.md` file will still be included in `llms-full.txt` unless you also remove or rename that file.
## 2. Create the templates
@@ -64,6 +73,32 @@ These three have no ordering dependency on each other, only on step 3 being done
None of this is wired into an automated deploy pipeline in this repository — there is no `deploy` script or CI job that runs these `bin/` scripts. `public/feed.xml`, `public/sitemap.xml`, and `public/llms-full.txt` are committed generated artifacts, so re-running these scripts leaves them modified in git until committed.
+## How to update an article
+
+Steps to edit an existing article (change its status, text, or both) and get the change live.
+
+1. **Edit the article's data.** Find its entry under the category's `articles` array in `src/App/src/Fixture/articles_cleaned.json` and change whatever needs updating: `post_title`, `excerpt`, `tl_dr`, `post_status`, `isObsolete`, etc. `PostLoader` matches the existing article by slug (derived from `post_title`), so as long as you don't change the title, it updates the same `Post` row instead of creating a new one.
+ - See the `post_status` values table in step 1 above for what each status does — e.g. `"archived"` is the right choice for content that existed and was intentionally removed (outdated content, a leftover test article, etc.), as it serves `410 Gone` instead of `404`.
+2. **Edit the content, if the body itself changed.** Update the matching files for that article's category/slug:
+ - `public/md-articles/{category-slug}/{article-slug}.md`
+ - `src/Blog/templates/page/blog-resource/{category-slug}/{article-slug}.html.twig`
+ - `src/Blog/templates/page/JSON-LD/{category-slug}/{article-slug}.jsonld.twig` (only if it has hardcoded text outside of `article.*`/`meta.*` variables — most of its fields pull straight from the database and update automatically)
+3. **Re-run the same commands as step 3 and step 4 above** (`bin/doctrine-fixtures`, then `bin/generate-feed` / `bin/sitemap` / `bin/generate-llms-full`) so the database and the generated artifacts reflect the change. `bin/create-uploads-dir` only needs to run again if you added a new image.
+
+## How to move an article to a different category
+
+An article's category isn't a field on the article itself — it's whichever top-level category object its entry sits under in `articles_cleaned.json`. Moving it is a structural move, not a value change, and the article's page/JSON-LD templates are resolved dynamically off the *current* category at render time (`GetPostResourceHandler` renders `page::blog-resource/{article.category.slug}/{article.slug}`, and the layout includes `@jsonld/{article.category.slug}/{article.slug}.jsonld.twig`) — there's no fallback if a file is missing at that path, so skipping any of the steps below leaves the article **404**ing at both the old and the new URL.
+
+1. **Cut the article's JSON object from its current category's `articles` array and paste it into the target category's `articles` array**, in `src/App/src/Fixture/articles_cleaned.json`. The target category must already exist as a top-level entry. Nothing else in the object needs to change — `PostLoader` matches the existing `Post` by slug and updates its category on the next run.
+2. **Physically move the three per-article files** from the old `{category-slug}` folder to the new one, keeping the same filename:
+ - `public/md-articles/{old-category-slug}/{article-slug}.md` → `public/md-articles/{new-category-slug}/{article-slug}.md`
+ - `src/Blog/templates/page/blog-resource/{old-category-slug}/{article-slug}.html.twig` → `.../{new-category-slug}/{article-slug}.html.twig`
+ - `src/Blog/templates/page/JSON-LD/{old-category-slug}/{article-slug}.jsonld.twig` → `.../{new-category-slug}/{article-slug}.jsonld.twig`
+3. **Check the moved `.html.twig` for a hardcoded `json_ld` block override.** Most articles leave that block untouched, so it resolves dynamically via the layout — but a few hardcode a literal path (e.g. `{% block json_ld %}{{ include('@jsonld/some-category/some-slug.jsonld.twig') }}{% endblock %}`). If yours does, update that literal path to the new category too.
+4. **Re-run `bin/doctrine-fixtures`**, then the 3 generators (`bin/generate-feed`, `bin/sitemap`, `bin/generate-llms-full`), same as any other update.
+
+Note: this changes the article's URL (`/{categorySlug}/{slug}/`), so the old URL will start 404ing — there is no redirect set up for a category move in this app.
+
## 5. Scheduled jobs (cron)
- **`bin/generate-packages`** — the only script here actually wired into a cron job. It rebuilds the Dotkernel packages listing from the GitHub organisation, which changes independently of this repo, so it runs on a schedule instead of at deploy time:
diff --git a/public/md-articles/dotkernel/test-article.md b/public/md-articles/dotkernel/test-article.md
index 038b11a8..610f7998 100644
--- a/public/md-articles/dotkernel/test-article.md
+++ b/public/md-articles/dotkernel/test-article.md
@@ -12,7 +12,7 @@ language: "en"
## TL;DR
-This is a test article added to the dotkernel category for testing purposes.
+This is a test article added to the dotkernel category for testing purposes. This article is now updated.
## Overview
diff --git a/src/App/src/Fixture/PostLoader.php b/src/App/src/Fixture/PostLoader.php
index 02e4c574..4ebd08df 100644
--- a/src/App/src/Fixture/PostLoader.php
+++ b/src/App/src/Fixture/PostLoader.php
@@ -67,9 +67,10 @@ public function load(ObjectManager $manager): void
}
$status = match ($articleData['post_status']) {
- 'publish' => PostStatusEnum::Published,
- 'private' => PostStatusEnum::Private,
- default => PostStatusEnum::Draft,
+ 'publish' => PostStatusEnum::Published,
+ 'private' => PostStatusEnum::Private,
+ 'archived' => PostStatusEnum::Archived,
+ default => PostStatusEnum::Draft,
};
$rawDate = $articleData['post_date'] ?? '';
diff --git a/src/App/src/Fixture/articles_cleaned.json b/src/App/src/Fixture/articles_cleaned.json
index 642f3cb8..b8e69604 100644
--- a/src/App/src/Fixture/articles_cleaned.json
+++ b/src/App/src/Fixture/articles_cleaned.json
@@ -986,7 +986,7 @@
"isObsolete": false,
"opengraph_img": "/opengraph/article/twitter-card-symfony-mailer.png",
"excerpt": "This is a test article used for fixture and testing purposes.",
- "tl_dr": "This is a test article added to the dotkernel category for testing purposes.",
+ "tl_dr": "This is a test article added to the dotkernel category for testing purposes. This article is now updated.",
"tags": []
}
]
@@ -2875,4 +2875,4 @@
}
]
}
-]
\ No newline at end of file
+]
diff --git a/src/Blog/templates/page/JSON-LD/dotkernel/test-article.jsonld.twig b/src/Blog/templates/page/JSON-LD/dotkernel/test-article.jsonld.twig
index 9ab8095f..a6d6888b 100644
--- a/src/Blog/templates/page/JSON-LD/dotkernel/test-article.jsonld.twig
+++ b/src/Blog/templates/page/JSON-LD/dotkernel/test-article.jsonld.twig
@@ -41,7 +41,7 @@
"@type": "FAQPage",
"@id": "{{ app.url ~ path('page::blog-resource', {categorySlug: article.category.slug, slug: article.slug}) }}#faq",
"mainEntity": [
- { "@type": "Question", "name": "What is this article for?", "acceptedAnswer": { "@type": "Answer", "text": "This is a test article added to the Dotkernel category for fixture and testing purposes." } }
+ { "@type": "Question", "name": "What is this article for?", "acceptedAnswer": { "@type": "Answer", "text": "This is a test article added to the Dotkernel category for fixture and testing purposes. This article is now updated." } }
]
}
]
diff --git a/src/Blog/templates/page/blog-resource/dotkernel/test-article.html.twig b/src/Blog/templates/page/blog-resource/dotkernel/test-article.html.twig
index 012bb94e..bac62a68 100644
--- a/src/Blog/templates/page/blog-resource/dotkernel/test-article.html.twig
+++ b/src/Blog/templates/page/blog-resource/dotkernel/test-article.html.twig
@@ -3,7 +3,7 @@
{% block body %}
-
This is a test article used for fixture and testing purposes.
+This is a test article used for fixture and testing purposes. This article is now updated.