Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Add a new entry to the category's `articles` array in `src/App/src/Fixture/artic
| `"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.
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 4 generators in step 4 so `feed.xml`/`sitemap.xml`/`llms.txt`/`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

Expand All @@ -68,24 +68,18 @@ php bin/create-uploads-dir
```shell
php bin/generate-feed
php bin/sitemap
php bin/generate-llms
php bin/generate-llms-full
```

- `bin/generate-feed` rewrites `public/feed.xml` from the published posts in the database.
- `bin/sitemap` rewrites `public/sitemap.xml` from the published posts in the database.
- `bin/generate-llms` rewrites `public/llms.txt`, the short curated index (one line per article, grouped by category) — built from the published posts in the database, grouped by category (known categories first, in a fixed order, then any remaining categories by post count) and sorted alphabetically by title within each category. Requires the `llms.sourceDir` / `llms.indexFile` keys in `config/autoload/local.php` (see `local.php.dist`); the `llms.pagesDir` key is optional, same as for `bin/generate-llms-full` below.
- `bin/generate-llms-full` rewrites `public/llms-full.txt` by concatenating `public/md-articles/index.md` and every other `public/md-articles/*/*.md` file, sorted by path, then appending each `public/md-pages/*.md` file — the markdown versions of the static pages — labelled with a `md-pages/` prefix in the section header. Requires the `llms.sourceDir` / `llms.outputFile` keys in `config/autoload/local.php` (see `local.php.dist`); the `llms.pagesDir` key 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.
These four 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:

```markdown
- [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.
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. **This must be run manually as part of every deploy** whenever `articles_cleaned.json` changed since the last deploy. `public/feed.xml`, `public/sitemap.xml`, `public/llms.txt`, 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

Expand Down
5 changes: 5 additions & 0 deletions public/.htaccess
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]

RewriteCond %{REQUEST_METHOD} GET|HEAD
RewriteCond %{REQUEST_URI} !/$
RewriteCond %{REQUEST_URI} !\.[^/]+$
RewriteRule ^(.*)$ /$1/ [R=301,L]

# The following rewrites all other queries to index.php. The
# condition ensures that if you are using Apache aliases to do
# mass virtual hosting, the base path will be prepended to
Expand Down
2 changes: 1 addition & 1 deletion public/css/app.css

Large diffs are not rendered by default.

Empty file added public/llms.txt
Empty file.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/opengraph/dotkernel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 47 additions & 1 deletion src/App/assets/scss/components/_custom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -519,8 +519,8 @@ html[data-theme="light"] .theme-toggle {

p.sub {
font-size: 18px;
max-width: 547px;
margin-top: 20px;
max-width: 520px;
color: var(--text-muted);

strong {
Expand All @@ -537,6 +537,32 @@ html[data-theme="light"] .theme-toggle {
align-items: center;
}

/* ============================================================
Error pages
============================================================ */

h1.error-code {
text-align: center;
}

.hero h1.error-code {
font-size: 88px;
}

.page-intro h1.error-code {
font-size: 80px;
}

.error-sub,
.error-message {
text-align: center;
margin: auto;
}

.error-buttons {
justify-content: center;
}

.hero-ctas {
display: flex;
gap: 14px;
Expand Down Expand Up @@ -1298,6 +1324,22 @@ footer {
padding: 50px 0 40px;
}

.footer-announcement {
padding-bottom: 34px;
margin-bottom: 34px;
border-bottom: 1px solid var(--border-soft);

p {
margin-top: 10px;
font-size: 14px;
color: var(--text-muted);
}
}

.footer-announcement-cta {
margin-top: 20px;
}

// Link columns mirroring the top menu, above the copyright/social row.
.footer-nav {
display: grid;
Expand Down Expand Up @@ -1460,6 +1502,10 @@ footer {
font-size: 32px;
}

.hero h1.error-code {
font-size: 64px;
}

.products-grid {
grid-template-columns: 1fr;
}
Expand Down
2 changes: 1 addition & 1 deletion src/App/src/Fixture/articles_cleaned.json
Original file line number Diff line number Diff line change
Expand Up @@ -998,7 +998,7 @@
"github": "OStefan2001"
},
"isObsolete": false,
"opengraph_img": null,
"opengraph_img": "/opengraph/article/twitter-card-build-dotkernel.png",
"excerpt": "How this blog itself came together: starting from the Dotkernel Light starter project, then following the Tutorial 101 to add Doctrine ORM and load real data into the database.",
"tl_dr": "This project started from the Dotkernel Light starter (Mezzio, Twig, FastRoute, PSR-7 via Laminas Diactoros) as a bare-bones website skeleton.\nFollowing the official Tutorial 101, Doctrine ORM was added on top: entities for posts, categories, authors and tags, migrations to create the schema, and fixtures to load real content into the database.\nThe result is this: a Dotkernel Light project turned into a fully data-driven site.",
"tags": [
Expand Down
11 changes: 0 additions & 11 deletions src/App/src/RoutesDelegator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace Light\App;

use Laminas\Diactoros\Response\RedirectResponse;
use Light\App\Handler\GetFeedViewHandler;
use Light\App\Handler\GetIndexViewHandler;
use Light\App\Handler\GetMarkdownArticleHandler;
Expand Down Expand Up @@ -34,16 +33,6 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal
GetPackagesViewHandler::TEMPLATE
);

$app->get('/{first}', function ($request) {
$uri = $request->getUri();
return new RedirectResponse((string) $uri . '/', 301);
});

$app->get('/{first}/{second}', function ($request) {
$uri = $request->getUri();
return new RedirectResponse((string) $uri . '/', 301);
});

return $app;
}
}
10 changes: 5 additions & 5 deletions src/App/templates/error/404.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
<section class="hero">
<div class="wrap">
<span class="eyebrow">Oops! This is awkward.</span>
<h1>404</h1>
<p class="sub">
You are looking for something that doesn't exist or may have moved.
Check out one of the categories below, or head back home.
<h1 class="error-code">404</h1>
<p class="sub error-sub">
You are looking for something that doesn't exist or may have moved.<br>
Return to the homepage below, or head to the Blog.
</p>

<div class="hero-ctas">
<div class="hero-ctas error-buttons">
<a class="btn btn-primary" href="{{ path('app::index') }}">Back to Home</a>
<a class="btn btn-ghost" href="{{ url('page::blog') }}">Browse new posts</a>
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/App/templates/error/410.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
<section class="hero">
<div class="wrap">
<span class="eyebrow">Gone. This article was removed.</span>
<h1>410</h1>
<p class="sub">
The content was misleading, old and obsolete, so we removed it.
<h1 class="error-code">410</h1>
<p class="sub error-sub">
The content was misleading, old and obsolete, so we removed it.<br>
It is gone for good and won't be coming back.
</p>

<div class="hero-ctas">
<div class="hero-ctas error-buttons">
<a class="btn btn-primary" href="{{ path('app::index') }}">Back to Home</a>
<a class="btn btn-ghost" href="{{ url('page::blog') }}">Browse new posts</a>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/App/templates/error/error.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<div class="page-intro home-intro error-messages">
<div class="container">
<span class="eyebrow">Oops! This is awkward.</span>
<h1>{{ status }}</h1>
<h2 class="message">{{ reason }}</h2>
<h1 class="error-code">{{ status }}</h1>
<h2 class="message error-message">{{ reason }}</h2>
</div>
</div>
{% endblock %}
5 changes: 5 additions & 0 deletions src/App/templates/layout/default.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@
</main>
<footer>
<div class="wrap">
<div class="footer-announcement">
<span class="eyebrow">We do not just build durable architecture; we stand by it.</span>
<p>While we build our digital platforms to stand the test of time, our commercial support ensures they adapt efficiently to your business needs. From 24/7 monitoring to rapid-response troubleshooting, we handle the infrastructure so you can focus on growth.</p>
<a class="btn btn-primary footer-announcement-cta" href="https://www.apidemia.com/#contact" target="_blank">Partner With Our Experts</a>
</div>
{# Mirrors the top menu: the Products dropdown, then the remaining menu items. #}
<nav class="footer-nav" aria-label="Footer">
<div class="footer-col">
Expand Down
70 changes: 70 additions & 0 deletions test/Unit/App/RoutesDelegatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace LightTest\Unit\App;

use Light\App\Handler\GetFeedViewHandler;
use Light\App\Handler\GetIndexViewHandler;
use Light\App\Handler\GetMarkdownArticleHandler;
use Light\App\Handler\GetPackagesViewHandler;
use Light\App\Handler\GetSitemapViewHandler;
use Light\App\RoutesDelegator;
use LightTest\Unit\UnitTest;
use Mezzio\Application;
use Mezzio\Router\Route;
use PHPUnit\Framework\MockObject\Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;

class RoutesDelegatorTest extends UnitTest
{
/** @var array<string, array{handler: mixed, name: string|null}> */
private array $registeredRoutes = [];

/**
* @throws ContainerExceptionInterface
* @throws Exception
* @throws NotFoundExceptionInterface
*/
public function testWillRegisterAllRoutes(): void
{
$container = $this->createStub(ContainerInterface::class);
$app = $this->createStub(Application::class);

$app->method('get')->willReturnCallback(function (string $uri, mixed $handler, ?string $name = null) {
$this->registeredRoutes[$uri] = ['handler' => $handler, 'name' => $name];

return $this->createStub(Route::class);
});

$application = (new RoutesDelegator())($container, '', fn () => $app);

$this->assertSame($app, $application);

$this->assertSame([GetIndexViewHandler::class], $this->registeredRoutes['/']['handler']);
$this->assertSame('app::index', $this->registeredRoutes['/']['name']);

$this->assertSame([GetFeedViewHandler::class], $this->registeredRoutes['/feed/']['handler']);
$this->assertSame('app::feed', $this->registeredRoutes['/feed/']['name']);

$this->assertSame([GetSitemapViewHandler::class], $this->registeredRoutes['/sitemap/']['handler']);
$this->assertSame('app::sitemap', $this->registeredRoutes['/sitemap/']['name']);

$this->assertSame(
[GetMarkdownArticleHandler::class],
$this->registeredRoutes['/{categorySlug}/{slug}.md']['handler']
);
$this->assertSame('app::markdown-article', $this->registeredRoutes['/{categorySlug}/{slug}.md']['name']);

$this->assertSame(
[GetPackagesViewHandler::class],
$this->registeredRoutes['/dotkernel-packages-oss-lifecycle/']['handler']
);
$this->assertSame(
GetPackagesViewHandler::TEMPLATE,
$this->registeredRoutes['/dotkernel-packages-oss-lifecycle/']['name']
);
}
}