diff --git a/assets/css/app.css b/assets/css/app.css index 472c916..7fc0914 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1454,6 +1454,92 @@ img { opacity: 0.3; } +/* Category badges on post cards and single post headers */ +.post-card__badge, +.post-header__badge { + display: inline-flex; + align-items: center; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 550; + letter-spacing: 0.06em; + text-transform: uppercase; + line-height: 1.15; + padding: 0.1875rem 0.5rem; + border-radius: 0.25rem; +} + +.post-card__badge--announcement, +.post-header__badge--announcement { + background: var(--violet-glass); + color: var(--violet); +} + +.post-card__badge--update, +.post-header__badge--update { + background: rgba(125, 211, 252, 0.1); + color: #7dd3fc; +} + +.post-card__badge--guide, +.post-header__badge--guide { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; +} + +.post-header__badge { + text-decoration: none; + transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease); + border: 1px solid transparent; +} + +.post-header__badge:hover { + border-color: var(--violet-border); + box-shadow: 0 0 20px rgba(167, 139, 250, 0.08); +} + +/* Publishing listing category filter */ +.publishing-filter { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.75rem; +} + +.publishing-filter__link { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--ink-soft); + padding: 0.375rem 0.875rem; + border-radius: 999px; + border: 1px solid var(--rule); + background: var(--paper-raised); + transition: all 0.25s var(--ease); +} + +.publishing-filter__link:hover { + color: var(--violet); + border-color: var(--violet-border); +} + +.publishing-filter__link--active { + background: var(--violet-glass); + border-color: var(--violet-border); + color: var(--violet); +} + +.publishing-filter__count { + font-size: 0.6875rem; + color: var(--ink-muted); +} + +.publishing-filter__link--active .publishing-filter__count { + color: var(--violet-lt); +} + .post-card__title { font-family: var(--font-display); font-size: 1.375rem; diff --git a/composer.json b/composer.json index 8ebecd4..1c159da 100644 --- a/composer.json +++ b/composer.json @@ -1,77 +1,77 @@ { - "name": "webifycms/site", - "description": "WebifyCMS site", - "license": "MIT", - "keywords": [ - "webify", - "webifycms", - "webifycms.com" - ], - "homepage": "https://www.webifycms.com/", - "type": "project", - "support": { - "issues": "https://github.com/webifycms/app/issues?state=open", - "source": "https://github.com/webifycms/app" - }, - "minimum-stability": "dev", - "prefer-stable": true, - "require": { - "php": ">=8.4", - "webifycms/ext-base": "^0.1.0-alpha", - "league/commonmark": "^2.8", - "symfony/yaml": "^8.1", - "symfony/validator": "^7.0", - "symfony/property-access": "^7.0", - "symfony/property-info": "^7.0", - "psr/http-client": "^1.0", - "guzzlehttp/guzzle": "^7.14", - "sentry/sentry": "^4.0" - }, - "require-dev": { - "webifycms/dev-tools": "^1.0", - "phpunit/phpunit": "^13.1", - "fakerphp/faker": "^1.24", - "phpat/phpat": "^0.12", - "filp/whoops": "^2.18", - "roave/security-advisories": "dev-latest" - }, - "repositories": [ - { - "type": "composer", - "url": "https://asset-packagist.org" - } - ], - "optimize-autoloader": true, - "autoload": { - "psr-4": { - "App\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "App\\Test\\": "test/", - "App\\Dev\\": "dev/" - } - }, - "scripts": { - "analyse": [ - "phpstan" - ], - "sniff": [ - "php-cs-fixer fix --config=.php-cs-fixer.dist.php --show-progress=dots --diff --dry-run" - ], - "test": [ - "phpunit test" - ], - "code-check": [ - "@analyse", - "@sniff" - ], - "code-format": [ - "php-cs-fixer fix --config=.php-cs-fixer.dist.php --show-progress=dots" - ] - }, - "config": { - "platform-check": false - } + "name": "webifycms/site", + "description": "WebifyCMS site", + "license": "MIT", + "keywords": [ + "webify", + "webifycms", + "webifycms.com" + ], + "homepage": "https://www.webifycms.com/", + "type": "project", + "support": { + "issues": "https://github.com/webifycms/app/issues?state=open", + "source": "https://github.com/webifycms/app" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.4", + "webifycms/ext-base": "^0.1.0-alpha", + "league/commonmark": "^2.8", + "symfony/yaml": "^8.1", + "symfony/validator": "^7.0", + "symfony/property-access": "^7.0", + "symfony/property-info": "^7.0", + "psr/http-client": "^1.0", + "guzzlehttp/guzzle": "^7.14", + "sentry/sentry": "^4.0" + }, + "require-dev": { + "webifycms/dev-tools": "^1.0", + "phpunit/phpunit": "^13.1", + "fakerphp/faker": "^1.24", + "phpat/phpat": "^0.12", + "filp/whoops": "^2.18", + "roave/security-advisories": "dev-latest" + }, + "repositories": [ + { + "type": "composer", + "url": "https://asset-packagist.org" + } + ], + "optimize-autoloader": true, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Test\\": "test/", + "App\\Dev\\": "dev/" + } + }, + "scripts": { + "analyse": [ + "phpstan" + ], + "sniff": [ + "php-cs-fixer fix --config=.php-cs-fixer.dist.php --show-progress=dots --diff --dry-run" + ], + "test": [ + "phpunit test" + ], + "code-check": [ + "@analyse", + "@sniff" + ], + "code-format": [ + "php-cs-fixer fix --config=.php-cs-fixer.dist.php --show-progress=dots" + ] + }, + "config": { + "platform-check": false + } } diff --git a/resources/posts/2026-06-introduction.md b/resources/posts/2026-06-introduction.md index 8a21684..bcbfe2f 100644 --- a/resources/posts/2026-06-introduction.md +++ b/resources/posts/2026-06-introduction.md @@ -2,6 +2,7 @@ title: 'Introduction to WebifyCMS' slug: introduction-webifycms date: '2026-06-17' +category: 'Guide' --- # Introduction to WebifyCMS diff --git a/resources/posts/2026-06-logo-concept.md b/resources/posts/2026-06-logo-concept.md index 32e7403..4533585 100644 --- a/resources/posts/2026-06-logo-concept.md +++ b/resources/posts/2026-06-logo-concept.md @@ -2,6 +2,7 @@ title: 'WebifyCMS Logo Concept' slug: webifycms-logo-concept date: '2026-06-25' +category: 'Update' --- # WebifyCMS Logo — Concept & Design diff --git a/resources/posts/2026-07-alpha-release.md b/resources/posts/2026-07-alpha-release.md index 2879d9d..e4735da 100644 --- a/resources/posts/2026-07-alpha-release.md +++ b/resources/posts/2026-07-alpha-release.md @@ -3,6 +3,7 @@ title: 'WebifyCMS First Alpha Release - The Bedrock' slug: webifycms-first-alpha-release-the-bedrock date: '2026-07-10' updated: '2026-07-31' +category: 'Announcement' --- # WebifyCMS First Alpha Release diff --git a/resources/posts/2026-08-user-extension-update.md b/resources/posts/2026-08-user-extension-update.md index 62ed2b7..dae2de7 100644 --- a/resources/posts/2026-08-user-extension-update.md +++ b/resources/posts/2026-08-user-extension-update.md @@ -2,6 +2,7 @@ title: 'WebifyCMS User Extension Update - Domain Complete, Infrastructure Next' slug: webifycms-user-extension-update-domain-complete-infrastructure-next date: '2026-08-05' +category: 'Update' --- # WebifyCMS User Extension Update — The Domain Is Ready diff --git a/resources/posts/2026-09-what-you-can-do-right-now.md b/resources/posts/2026-09-what-you-can-do-right-now.md new file mode 100644 index 0000000..2fc20df --- /dev/null +++ b/resources/posts/2026-09-what-you-can-do-right-now.md @@ -0,0 +1,64 @@ +--- +title: "WebifyCMS Alpha — What You Can Do Right Now" +slug: webifycms-alpha-what-you-can-do-right-now +date: "2026-09-20" +category: "Guide" +--- + +# WebifyCMS Alpha — What You Can Do Right Now + +We have been quiet since the [user extension update](/publishing/webifycms-user-extension-update-domain-complete-infrastructure-next), but we have not been idle. The biggest piece of work since then is the website you are reading right now. **webifycms.com is a real WebifyCMS application, running on the alpha and fully open source** at [github.com/webifycms/site](https://github.com/webifycms/site). + +## This Site Is the Proof + +WebifyCMS runs on WebifyCMS. The marketing site is not a static page or a theme demo — it is an actual WebifyCMS application built on the `ext-base` foundation and deployed in production. It is our way of eating our own dog food: before we ask anyone else to trust the architecture, we run our own site on it. + +Every feature of this site is a small demonstration of what a WebifyCMS application already provides: + +- **Publishing** — blog posts as Markdown with YAML front matter, a generated post index, pagination, and categories. +- **Docs** — rendered straight from the repository's Markdown, with a sidebar and an on-page table of contents. +- **Ecosystem pages** — showcases for extensions and themes. +- **Newsletter** — a sign-up flow with server-side validation and rate limiting. +- **SEO** — canonical URLs, meta tags, JSON-LD structured data, and automatic sitemap generation. +- **Discipline** — Clean Architecture with automated boundary tests, PHPStan at level 8, and a production-grade dependency injection container. + +And because it is open source, none of this is a black box. The complete source — every template, controller, reader, and test — is public on [GitHub](https://github.com/webifycms/site). If you want to see how a real WebifyCMS application is put together, this site is the example. + +## What You Can Do With the Alpha Right Now + +Let us be honest: the alpha is a foundation, not a finished CMS. There is no admin panel, no default theme, and no content-management experience yet. What you _can_ do today is genuinely useful: + +- **Install it and run it.** The [official installer](/install) gets you a working WebifyCMS application in minutes, or follow the Docker and manual setup in the site repository's [README](https://github.com/webifycms/site). +- **Ship a simple site in minutes by cloning this one.** The marketing site is a complete, working WebifyCMS application. Clone it, swap in your own Markdown posts and content, and you have a clean, content-driven site running locally in minutes — publishing, docs, and SEO already work out of the box, so you skip the setup and go straight to your content. +- **Explore the architecture.** `ext-base` ships a fully wired Clean Architecture skeleton — dependency injection via PHP-DI with a compiled container, routing through League Route, console commands with Symfony Console, logging with Monolog, and architectural tests that enforce the boundaries. +- **Get a head start on the roadmap.** The `ext-user` domain is complete — identity, authentication, and authorization with full unit-test coverage — and its infrastructure is under active development. + +## Run It Yourself + +The fast path: + +```bash +git clone https://github.com/webifycms/site.git +cd site +composer install +npm install +npm run build +``` + +Serve the `public/` directory with any web server and you have this site running locally — including the publishing pipeline, the docs section, and the newsletter form. From there, making it your own is just editing posts and content — a simple site, in a few minutes. Or start from a blank application with the [installer](https://github.com/webifycms/installer) and build yours on the same foundation. + +## What Comes Next + +The next milestone is the first beta. The pieces that unlock it are moving forward together: the `ext-user` infrastructure layer, the slot-based admin panel `ext-admin`, and the official default theme `theme-canvas`. When those land, WebifyCMS will stop being just a foundation and start being a CMS you can actually manage content with — followed by content management (`ext-cms`) and the marketplace for themes and extensions (`ext-marketplace`). + +## How to Get Involved + +WebifyCMS is fully open source under the MIT license, and so is [this website](https://github.com/webifycms/site). + +- **WebifyCMS on GitHub:** +- **Application repository:** +- **Site repository:** +- **Documentation:** +- **Contributing:** + +Run it locally, read the source, open issues, and tell us what you build. Thanks for following the WebifyCMS journey. diff --git a/resources/posts/posts.json b/resources/posts/posts.json index e1d73e5..b9d9e9b 100644 --- a/resources/posts/posts.json +++ b/resources/posts/posts.json @@ -1,9 +1,19 @@ [ + { + "title": "WebifyCMS Alpha — What You Can Do Right Now", + "slug": "webifycms-alpha-what-you-can-do-right-now", + "date": "2026-09-20", + "updated": "2026-09-20", + "category": "Guide", + "excerpt": "We have been quiet since the user extension update, but we have not been idle. The biggest piece of work since then is the website you are reading...", + "file": "2026-09-what-you-can-do-right-now.md" + }, { "title": "WebifyCMS User Extension Update - Domain Complete, Infrastructure Next", "slug": "webifycms-user-extension-update-domain-complete-infrastructure-next", "date": "2026-08-05", "updated": "2026-08-05", + "category": "Update", "excerpt": "Since the first alpha release, we have been hard at work on the three pieces that unlock the first beta: the ext-user extension, the slot-based admin...", "file": "2026-08-user-extension-update.md" }, @@ -12,6 +22,7 @@ "slug": "webifycms-first-alpha-release-the-bedrock", "date": "2026-07-10", "updated": "2026-07-31", + "category": "Announcement", "excerpt": "We are excited to announce the very first alpha release of WebifyCMS — version 0.1.0-alpha \"Bedrock\" is now available. This marks a major milestone for...", "file": "2026-07-alpha-release.md" }, @@ -20,6 +31,7 @@ "slug": "webifycms-logo-concept", "date": "2026-06-25", "updated": "2026-06-25", + "category": "Update", "excerpt": "The WebifyCMS logo combines a geometric hexagon mark with bold monospaced typography to communicate structure, clarity, and developer focus. The design...", "file": "2026-06-logo-concept.md" }, @@ -28,6 +40,7 @@ "slug": "introduction-webifycms", "date": "2026-06-17", "updated": "2026-06-17", + "category": "Guide", "excerpt": "Summary: WebifyCMS is an open-source PHP application framework built from the ground up on Clean Architecture and Domain-Driven Design (DDD). It was...", "file": "2026-06-introduction.md" } diff --git a/src/Infrastructure/Persistence/Filesystem/PostIndexer.php b/src/Infrastructure/Persistence/Filesystem/PostIndexer.php index 5c5083a..44ea01e 100644 --- a/src/Infrastructure/Persistence/Filesystem/PostIndexer.php +++ b/src/Infrastructure/Persistence/Filesystem/PostIndexer.php @@ -27,12 +27,23 @@ * slug: string, * date: string, * updated: string, + * category: string, * excerpt: string, * file: string, * } */ final readonly class PostIndexer { + /** + * The allowed post categories. + * + * Keep this list small and descriptive. Posts without a matching + * category in their front matter fall back to "Update". + * + * @var list + */ + public const array CATEGORIES = ['Announcement', 'Update', 'Guide']; + /** * The maximum length of the excerpt. */ @@ -129,15 +140,35 @@ private function parseFrontMatter(string $contents, string $basename): ?array } return [ - 'title' => $metadata['title'], - 'slug' => $metadata['slug'], - 'date' => $metadata['date'], - 'updated' => $metadata['updated'] ?? $metadata['date'], - 'excerpt' => $this->generateExcerpt($body), - 'file' => $basename, + 'title' => $metadata['title'], + 'slug' => $metadata['slug'], + 'date' => $metadata['date'], + 'updated' => $metadata['updated'] ?? $metadata['date'], + 'category' => $this->resolveCategory($metadata['category'] ?? null), + 'excerpt' => $this->generateExcerpt($body), + 'file' => $basename, ]; } + /** + * Resolves the post category against the allowed list. + * + * Unknown or missing categories fall back to "Update" so the index + * never carries a category the templates do not know how to render. + */ + private function resolveCategory(mixed $category): string + { + if (is_string($category)) { + foreach (self::CATEGORIES as $allowed) { + if (0 === strcasecmp($category, $allowed)) { + return $allowed; + } + } + } + + return 'Update'; + } + /** * Derives an SEO-length excerpt from the first paragraph of the body. */ diff --git a/src/Infrastructure/Persistence/Filesystem/PostPagination.php b/src/Infrastructure/Persistence/Filesystem/PostPagination.php index 07a23a2..3c52e88 100644 --- a/src/Infrastructure/Persistence/Filesystem/PostPagination.php +++ b/src/Infrastructure/Persistence/Filesystem/PostPagination.php @@ -52,11 +52,21 @@ public function forSlug(string $slug): array /** * Returns a slice of posts for the given page along with pagination metadata. * + * When a category is provided, only posts of that category are paginated. + * * @return ListPagination */ - public function forList(int $page = 1, int $perPage = 10): array + public function forList(int $page = 1, int $perPage = 10, ?string $category = null): array { - $all = $this->reader->findAll(); + $all = $this->reader->findAll(); + + if (null !== $category) { + $all = array_values(array_filter( + $all, + static fn (array $post): bool => ($post['category'] ?? '') === $category, + )); + } + $total = count($all); $totalPages = max(1, (int) ceil($total / $perPage)); $page = max(1, min($page, $totalPages)); diff --git a/src/Infrastructure/Persistence/Filesystem/PostReader.php b/src/Infrastructure/Persistence/Filesystem/PostReader.php index b28c452..3364710 100644 --- a/src/Infrastructure/Persistence/Filesystem/PostReader.php +++ b/src/Infrastructure/Persistence/Filesystem/PostReader.php @@ -35,6 +35,7 @@ * slug: string, * date: string, * updated: string, + * category: string, * excerpt: string, * file: string, * } @@ -43,6 +44,7 @@ * slug: string, * date: string, * updated: string, + * category: string, * excerpt: string, * body: string, * html: string, @@ -111,13 +113,14 @@ public function findBySlug(string $slug): ?array } return [ - 'title' => $entry['title'], - 'slug' => $entry['slug'], - 'date' => $entry['date'], - 'updated' => $entry['updated'] ?? $entry['date'], - 'excerpt' => $entry['excerpt'], - 'body' => $body, - 'html' => $html, + 'title' => $entry['title'], + 'slug' => $entry['slug'], + 'date' => $entry['date'], + 'updated' => $entry['updated'] ?? $entry['date'], + 'category' => $entry['category'], + 'excerpt' => $entry['excerpt'], + 'body' => $body, + 'html' => $html, ]; }, $this->config->get('cache.lifetime', 3600)); } diff --git a/src/Infrastructure/Presentation/Http/Controller/Publishing/Posts.php b/src/Infrastructure/Presentation/Http/Controller/Publishing/Posts.php index 5a68437..1250c6c 100644 --- a/src/Infrastructure/Presentation/Http/Controller/Publishing/Posts.php +++ b/src/Infrastructure/Presentation/Http/Controller/Publishing/Posts.php @@ -13,14 +13,15 @@ namespace App\Infrastructure\Presentation\Http\Controller\Publishing; -use App\Infrastructure\Persistence\Filesystem\PostPagination; +use App\Infrastructure\Persistence\Filesystem\{PostIndexer, PostPagination, PostReader}; use App\Infrastructure\Presentation\Http\Controller\Base; use App\Infrastructure\Service\View; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; /** - * The controller renders the posts listing with page-based pagination. + * The controller renders the posts listing with page-based pagination + * and optional category filtering. */ final readonly class Posts extends Base { @@ -31,6 +32,7 @@ public function __construct( View $view, Psr17Factory $psr17Factory, private PostPagination $pagination, + private PostReader $reader, ) { parent::__construct($view, $psr17Factory); } @@ -40,18 +42,55 @@ public function __construct( */ public function __invoke(ServerRequestInterface $request, array $args): ResponseInterface { - $page = isset($args['page']) ? (int) $args['page'] : 1; - $pagination = $this->pagination->forList($page); + $page = isset($args['page']) ? (int) $args['page'] : 1; + $category = $this->resolveCategory($request); - $this->view->title = 'Publishing'; + $pagination = $this->pagination->forList($page, 10, $category); + + $this->view->title = null === $category ? 'Publishing' : $category . ' — Publishing'; $this->view->description = 'Thoughts on Clean Architecture, DDD, and building the PHP CMS built the right way.'; $this->view->canonical = $this->view->url('/publishing'); $this->view->currentPath = '/publishing'; - if (1 < $page) { + if (1 < $page || null !== $category) { $this->view->addMeta('robots', 'noindex, follow'); } - return $this->render('publishing', ['pagination' => $pagination]); + return $this->render('publishing', [ + 'pagination' => $pagination, + 'activeCategory' => $category, + 'categories' => $this->categoryCounts(), + ]); + } + + /** + * Reads the category query parameter and validates it against the + * allowed list. Invalid values are ignored. + */ + private function resolveCategory(ServerRequestInterface $request): ?string + { + $category = trim((string) ($request->getQueryParams()['category'] ?? '')); + + return in_array($category, PostIndexer::CATEGORIES, true) ? $category : null; + } + + /** + * Returns the number of published posts per category. + * + * @return array + */ + private function categoryCounts(): array + { + $counts = array_fill_keys(PostIndexer::CATEGORIES, 0); + + foreach ($this->reader->findAll() as $post) { + $category = $post['category'] ?? 'Update'; + + if (array_key_exists($category, $counts)) { + ++$counts[$category]; + } + } + + return $counts; } } diff --git a/src/Infrastructure/Presentation/Http/Controller/Publishing/Single.php b/src/Infrastructure/Presentation/Http/Controller/Publishing/Single.php index d44e8dc..2f2a261 100644 --- a/src/Infrastructure/Presentation/Http/Controller/Publishing/Single.php +++ b/src/Infrastructure/Presentation/Http/Controller/Publishing/Single.php @@ -68,6 +68,7 @@ public function __invoke(ServerRequestInterface $request, array $args): Response 'slug' => $post['slug'], 'date' => $post['date'], 'updated' => $post['updated'], + 'category' => $post['category'], 'excerpt' => $post['excerpt'], 'content' => $post['html'], 'prev' => $pagination['prev'], diff --git a/templates/home.php b/templates/home.php index 16e1701..6290cce 100644 --- a/templates/home.php +++ b/templates/home.php @@ -259,16 +259,16 @@

Building in public — notes on architecture, progress, and the journey.

- data['latestPosts']): ?> + data['latestPosts']) { ?>
- data['latestPosts'] as $post): ?> + data['latestPosts'] as $post) { ?> - Article +

@@ -280,13 +280,13 @@
- +
- + diff --git a/templates/post.php b/templates/post.php index 0df8489..4faccb2 100644 --- a/templates/post.php +++ b/templates/post.php @@ -19,6 +19,7 @@ "description": "data['excerpt'], JSON_UNESCAPED_UNICODE); ?>", "datePublished": "data['date'], JSON_UNESCAPED_UNICODE); ?>", "dateModified": "data['updated'], JSON_UNESCAPED_UNICODE); ?>", + "articleSection": data['category'], JSON_UNESCAPED_UNICODE); ?>, "url": "url('/publishing/' . $view->data['slug']); ?>", "author": { "@type": "Organization", @@ -57,7 +58,7 @@ Updated data['updated'], ENT_QUOTES); ?> - Article + data['category'], ENT_QUOTES); ?>

data['title'], ENT_QUOTES); ?>

data['excerpt'], ENT_QUOTES); ?>

diff --git a/templates/publishing.php b/templates/publishing.php index 515f7c0..f3d1b8f 100644 --- a/templates/publishing.php +++ b/templates/publishing.php @@ -19,6 +19,8 @@ ?> renderPartial('header'); ?> +data['activeCategory']) ? '' : '?category=' . urlencode($view->data['activeCategory']); ?> +
@@ -34,6 +36,22 @@
+ +
data['pagination']['items']): ?>
@@ -47,7 +65,7 @@
- Article +

@@ -65,7 +83,7 @@ data['pagination']['totalPages']): ?>
data['pagination']['next']): ?> - + Next