Steps to publish a new article and regenerate the public artifacts that depend on it.
Add a new entry to the category's articles array in src/App/src/Fixture/articles_cleaned.json:
{
"post_title": "Your article title",
"post_date": "YYYY-MM-DD HH:MM:SS",
"post_status": "publish",
"author": {
"display_name": "admin",
"github": "arhimede"
},
"isObsolete": false,
"opengraph_img": null,
"excerpt": "Short excerpt shown in listings.",
"tl_dr": "One or two sentence summary.",
"tags": [
{
"name": "your tag",
"slug": "your-tag"
}
]
}author.display_name can either match an existing author or be a new name - bin/doctrine-fixtures creates a new Author automatically for any name not already in the database. The category (top-level slug) must already exist, though. The article's slug is derived automatically from the title (lowercased, non-alphanumeric characters collapsed to -) by PostLoader::slugify().
tags can be left empty, but adding tags that describe the article's subject is useful - they show up on the article page and back its tag-resource pages.
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.
src/Blog/templates/page/blog-resource/{category-slug}/{article-slug}.html.twig- the page body, extending@layout/blog-post.html.twig.src/Blog/templates/page/JSON-LD/{category-slug}/{article-slug}.jsonld.twig- the@graphofTechArticle+BreadcrumbList+FAQPagestructured data.public/md-articles/{category-slug}/{article-slug}.md- the markdown version, with YAML front matter (title,description,author,date_published,canonical_url,category,language) followed by the article body (TL;DR, sections,FAQ). This feedsllms-full.txt.
Copy an existing set of these three files in the same category as a starting point, to match the established structure (FAQ block matching the FAQPage entries, etc.).
If the article body uses images (via asset('uploads/article/' ~ article.id ~ '/filename.png') in the .html.twig), just drop the image file anywhere under public/uploads - bin/create-uploads-dir (step 4) finds it by filename and copies it to the right place. No manual path/folder creation needed.
php bin/doctrine-fixtures
php bin/create-uploads-dirbin/doctrine-fixturesloadsarticles_cleaned.jsoninto the database, creating thePostentity (with its database-generated UUID) for the new article.bin/create-uploads-dirmust run after it - it resolves the post by slug to get that UUID, createspublic/uploads/article/{post-id}/, and copies each image referenced in the.html.twigthere from wherever it already lives underpublic/uploads.
php bin/generate-feed
php bin/sitemap
php bin/generate-llms-fullbin/generate-feedrewritespublic/feed.xmlfrom the published posts in the database.bin/sitemaprewritespublic/sitemap.xmlfrom the published posts in the database.bin/generate-llms-fullrewritespublic/llms-full.txtby concatenatingpublic/md-articles/index.mdand every otherpublic/md-articles/*/*.mdfile, sorted by path, then appending eachpublic/md-pages/*.mdfile — the markdown versions of the static pages — labelled with amd-pages/prefix in the section header. Requires thellms.sourceDir/llms.outputFilekeys inconfig/autoload/local.php(seelocal.php.dist); thellms.pagesDirkey is optional, and omitting it leaves the page sections out.
These three have no ordering dependency on each other, only on step 3 being done first.
public/llms.txt is not part of this - it is edited by hand, not generated. It's a separate, curated index (one line per article, grouped by category) distinct from the full-text llms-full.txt. Whenever an article is added, add a matching entry under its category:
- [Your article title](https://www.dotkernel.com/{category-slug}/{article-slug}/): One-sentence description, similar to the excerpt.Also bump that category's post count in its heading (e.g. ## Dotkernel (65 posts)). Entries are ordered alphabetically by title within each category.
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.
Steps to edit an existing article (change its status, text, or both) and get the change live.
- Edit the article's data. Find its entry under the category's
articlesarray insrc/App/src/Fixture/articles_cleaned.jsonand change whatever needs updating:post_title,excerpt,tl_dr,post_status,isObsolete, etc.PostLoadermatches the existing article by slug (derived frompost_title), so as long as you don't change the title, it updates the samePostrow instead of creating a new one.- See the
post_statusvalues 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 serves410 Goneinstead of404.
- See the
- 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}.mdsrc/Blog/templates/page/blog-resource/{category-slug}/{article-slug}.html.twigsrc/Blog/templates/page/JSON-LD/{category-slug}/{article-slug}.jsonld.twig(only if it has hardcoded text outside ofarticle.*/meta.*variables — most of its fields pull straight from the database and update automatically)
- Re-run the same commands as step 3 and step 4 above (
bin/doctrine-fixtures, thenbin/generate-feed/bin/sitemap/bin/generate-llms-full) so the database and the generated artifacts reflect the change.bin/create-uploads-dironly needs to run again if you added a new image.
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 404ing at both the old and the new URL.
- Cut the article's JSON object from its current category's
articlesarray and paste it into the target category'sarticlesarray, insrc/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 —PostLoadermatches the existingPostby slug and updates its category on the next run. - 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}.mdsrc/Blog/templates/page/blog-resource/{old-category-slug}/{article-slug}.html.twig→.../{new-category-slug}/{article-slug}.html.twigsrc/Blog/templates/page/JSON-LD/{old-category-slug}/{article-slug}.jsonld.twig→.../{new-category-slug}/{article-slug}.jsonld.twig
- Check the moved
.html.twigfor a hardcodedjson_ldblock 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. - 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.
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:0 4 * * * cd /path/to/dotkernel.com && /usr/bin/php bin/generate-packages >> log/generate-packages.log 2>&1- Runs daily at 04:00.
- Exits non-zero without touching the data file if the run can't be trusted, so the previously generated listing keeps serving.
- Logs to
log/generate-packages.log.
bin/generate-feed(RSS,public/feed.xml) — manual only, no cron. Run it as part of the publish flow in step 4, right afterbin/doctrine-fixtures/bin/create-uploads-dir, whenever an article is added, edited, or itspost_statuschanges.bin/sitemap(public/sitemap.xml) — manual only, no cron. Same trigger asbin/generate-feed: re-run after any change toarticles_cleaned.json.bin/generate-llms-full(public/llms-full.txt) — manual only, no cron. Re-run after adding/editing a.mdfile underpublic/md-articles/orpublic/md-pages/, or after removing/renaming a draft's.mdfile (it doesn't checkpost_status, see step 1). Thepublic/md-pages/*.mdfiles are hand-maintained alongside the templates insrc/Page/templates/page/— editing a page template means updating its.mdand re-running this.