<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:media="http://search.yahoo.com/mrss/">
    <channel>
        <title># notACMS</title>
        <link>https://notacms.holas.pl/</link>
        <description><![CDATA[AI-friendly static site generator. Zero database. Pure Markdown.]]></description>
        <language>en</language>
        <atom:link href="https://notacms.holas.pl/feed/" rel="self" type="application/rss+xml"/>
                        <lastBuildDate>Sat, 13 Jun 2026 00:00:00 +0000</lastBuildDate>
                        <item>
            <title><![CDATA[notACMS 1.2.1 — charset fix, smarter build warnings, new docs pages]]></title>
            <link>https://notacms.holas.pl/blog/release-1-2-1/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/release-1-2-1/</guid>
                        <pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[What's in 1.2.1 A small follow-up to 1.2.0 with two bug fixes and expanded documentation. nginx charset Plain-text responses — /llms.txt, /robots.txt — were served without a charset declaration. Browsers would render non-ASCII characters as garbage. charset utf-8; is now set at the server-block level in docker/nginx.conf.template, so every response type gets the correct declaration automatically. …]]></description>
            <content:encoded><![CDATA[<h2>What's in 1.2.1<a id="whats-in-121" href="#whats-in-121" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A small follow-up to 1.2.0 with two bug fixes and expanded documentation.</p>
<h2>nginx charset<a id="nginx-charset" href="#nginx-charset" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Plain-text responses — <code>/llms.txt</code>, <code>/robots.txt</code> — were served without a charset declaration. Browsers would render non-ASCII characters as garbage. <code>charset utf-8;</code> is now set at the server-block level in <code>docker/nginx.conf.template</code>, so every response type gets the correct declaration automatically.</p>
<h2>Ambiguous directory key warning<a id="ambiguous-directory-key-warning" href="#ambiguous-directory-key-warning" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The &quot;Ambiguous directory key&quot; warning previously fired at tree construction time — the moment a second content item registered a conflicting basename — regardless of whether that bare key was ever actually used in a template.</p>
<p>Now the warning is deferred to the actual lookup: it fires once, and only when <code>content_item()</code> or <code>content_url()</code> is called with an ambiguous bare key. Sites with naming collisions in different content sections no longer see spurious warnings from keys they never reference.</p>
<h2>Two new documentation pages<a id="two-new-documentation-pages" href="#two-new-documentation-pages" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The demo theme now has pages for <strong>Theme Building</strong> and <strong>Security</strong>, available in all four locales.</p>
<p><strong><a href="/theme-building/">Theme Building</a></strong> covers the full template layer model — how <code>local/templates/</code>, registered theme paths, and the bare core resolve in priority order — plus a reference for template context contracts, Twig globals, functions and filters, the ContentItem API, required translation keys, and the six portability rules every theme should follow.</p>
<p><strong><a href="/security/">Security</a></strong> explains the threat model that shapes notACMS's security decisions: no database, no dynamic rendering, a build pipeline that never executes content. It covers the built-in protections (Turnstile hostname validation, path traversal guarding, strict types), the deliberate CSRF design on the contact form, and the deployment best practices for a production instance.</p>
<p>Both pages appear in the Documentation nav dropdown and sidebar.</p>
<h2>Full changelog<a id="full-changelog" href="#full-changelog" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md#121---2026-06-13">CHANGELOG.md</a></p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[release]]></category>
                        <category><![CDATA[announcement]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.2.0 — the audit release]]></title>
            <link>https://notacms.holas.pl/blog/release-1-2-0/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/release-1-2-0/</guid>
                        <pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[The audit release 1.2.0 is the result of a complete audit of notACMS — architecture, security, code quality, and both template trees. Around 170 findings were fixed. The highlights: Robustness A markdown file with broken frontmatter no longer takes down a locale or the build — it's skipped and reported with its path and error. A missing slug: can no longer silently hijack the homepage; posts_per_p…]]></description>
            <content:encoded><![CDATA[<h2>The audit release<a id="the-audit-release" href="#the-audit-release" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>1.2.0 is the result of a complete audit of notACMS — architecture, security, code quality, and both template trees. Around 170 findings were fixed. The highlights:</p>
<h3>Robustness<a id="robustness" href="#robustness" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li>A markdown file with broken frontmatter no longer takes down a locale or the build — it's skipped and reported with its path and error.</li>
<li>A missing <code>slug:</code> can no longer silently hijack the homepage; <code>posts_per_page: 0</code> can no longer crash every blog page; duplicate URLs, ambiguous directory keys, and invalid tag values now produce clear build warnings.</li>
<li>Draft and scheduled <strong>pages</strong> are now genuinely excluded from builds, menus, and <code>/llms.txt</code> — previously only posts were filtered.</li>
<li><code>app:build -o &lt;dir&gt;</code> refuses to wipe a directory other than the configured static dir unless you pass <code>--force</code>.</li>
<li>Visiting a <code>/pl/</code> link for the first time no longer bounces you to EN — the URL is treated as your language choice and a cookie is set. The locale cookie is now also <code>Secure</code>-conditional so the mechanism works in HTTP dev environments.</li>
<li>Search result excerpts now render highlighted terms as actual <code>&lt;mark&gt;</code> highlights instead of literal <code>&amp;lt;mark&amp;gt;</code> text.</li>
<li>The bare starter theme's <code>_site.yaml</code> now includes placeholder <code>contact_form</code> values — no more build error on a fresh install.</li>
</ul>
<h3>Security<a id="security" href="#security" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li>ImageMagick runs through <code>Symfony\Process</code> with argv arrays — <code>exec()</code> is gone.</li>
<li>Turnstile now validates the response hostname against your <code>base_url</code> and logs an error when the committed always-pass test keys are active in production. A boot check warns when <code>APP_SECRET</code> is still the placeholder.</li>
<li>JSON-LD output is hex-escaped (<code>&lt;/script&gt;</code> in a title can't break out), translation catalogs no longer carry HTML, and the nginx security headers now also apply to <code>/assets/</code> and <code>/media/</code> responses.</li>
<li>The locale-prefixed contact API was unreachable in the Docker runtime deployment due to an nginx regex bug — fixed.</li>
</ul>
<h3>For theme builders<a id="for-theme-builders" href="#for-theme-builders" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li>New <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/docs/THEME_BUILDING.md">THEME_BUILDING.md</a>: the full theme API contract — per-route template contexts, Twig functions and globals, the ContentItem API, and the translation keys every theme must define.</li>
<li><code>#[LocalizedRoute]</code> now works in <code>local/src/Controller/</code>, language switching works correctly on 3+-locale sites, and the static build pipeline is decomposed into reusable services.</li>
</ul>
<h3>New<a id="new" href="#new" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li>The static build now produces a <code>/llms.txt</code> feed per locale — the most recent posts in a machine-readable format for LLM context windows. Configurable via <code>llms_limit</code> in <code>_site.yaml</code> (default: 5). The template is overridable per-theme via the <code>@base</code> Twig namespace.</li>
</ul>
<h3>Breaking changes<a id="breaking-changes" href="#breaking-changes" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>A few — each with a one-line migration. <code>directoryKey()</code> returns full content paths (basename lookups still work), <code>getTree()</code> moved to <code>ContentTreeProviderInterface</code>, <code>blogPosting()</code> takes a named map, the <code>lang_switch_url</code> context key is gone, four dead translation keys were removed, and the 1.0 <code>old-template</code> package was dropped. See <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/UPGRADE-1.2.md">UPGRADE-1.2.md</a> for the complete guide.</p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[release]]></category>
                        <category><![CDATA[security]]></category>
                        <category><![CDATA[refactoring]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.1.4 — Symfony 7.4.13 and Twig 3.27.0 security update]]></title>
            <link>https://notacms.holas.pl/blog/release-1-1-4/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/release-1-1-4/</guid>
                        <pubDate>Thu, 28 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[Security update: Symfony 7.4.13 and Twig 3.27.0 Upgrade immediately. Run composer update in your project, then rebuild. Symfony 7.4.13 (6 CVEs) CVE-2026-48489 — Security firewall bypass: the failure_forward handler honored an attacker-supplied _failure_path parameter on the internal subrequest, allowing the firewall to be bypassed. CVE-2026-48736 — SSRF bypass in NoPrivateNetworkHttpClient and IpU…]]></description>
            <content:encoded><![CDATA[<h2>Security update: Symfony 7.4.13 and Twig 3.27.0<a id="security-update-symfony-7413-and-twig-3270" href="#security-update-symfony-7413-and-twig-3270" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Upgrade immediately.</strong> Run <code>composer update</code> in your project, then rebuild.</p>
<h3>Symfony 7.4.13 (6 CVEs)<a id="symfony-7413-6-cves" href="#symfony-7413-6-cves" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li><strong>CVE-2026-48489</strong> — Security firewall bypass: the <code>failure_forward</code> handler honored an attacker-supplied <code>_failure_path</code> parameter on the internal subrequest, allowing the firewall to be bypassed.</li>
<li><strong>CVE-2026-48736</strong> — SSRF bypass in <code>NoPrivateNetworkHttpClient</code> and <code>IpUtils::PRIVATE_SUBNETS</code> via IPv6 transition address forms (IPv4-mapped, IPv4-compatible, 6to4, Teredo).</li>
<li><strong>CVE-2026-48761</strong> — <code>HtmlSanitizer</code> failed to sanitize URL attributes on <code>&lt;object&gt;</code>, <code>&lt;applet&gt;</code>, <code>&lt;iframe&gt;</code>, <code>&lt;img&gt;</code>, and the URL inside <code>&lt;meta http-equiv=&quot;refresh&quot;&gt;</code> content.</li>
<li><strong>CVE-2026-48760</strong> — <code>HtmlSanitizer</code> accepted percent-encoded BiDi marks and Unicode whitespace in URLs, allowing sanitizer bypass via visual spoofing.</li>
<li><strong>CVE-2026-48784</strong> — <code>UrlGenerator</code> misencoded chained <code>../</code> and <code>./</code> segments, producing URLs that could traverse out of the intended path.</li>
<li><strong>CVE-2026-48747</strong> — Mailer: the Mailomat webhook signature algorithm was not pinned to SHA-256, allowing algorithm-substitution attacks.</li>
</ul>
<p>Full list: <a rel="nofollow noopener noreferrer" target="_blank" href="https://symfony.com/blog/symfony-7-4-13-released">symfony.com/blog/symfony-7-4-13-released</a>.</p>
<h3>Twig 3.27.0 (5 CVEs)<a id="twig-3270-5-cves" href="#twig-3270-5-cves" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>All five are sandbox bypasses. If your theme renders user-controlled templates in a sandbox, these are critical:</p>
<ul>
<li><strong>CVE-2026-46636</strong> — Filter, tag, and function allow-list bypass when sandbox state changes between renders in long-lived workers (e.g. FrankenPHP, RoadRunner).</li>
<li><strong>CVE-2026-48808</strong> — <code>column</code> filter bypassed the property allowlist under <code>SourcePolicyInterface</code>.</li>
<li><strong>CVE-2026-48806</strong> — <code>__toString()</code> policy bypass via dynamic mapping keys: <code>{% set arr = {(obj): &quot;value&quot;} %}</code>.</li>
<li><strong>CVE-2026-48807</strong> — <code>__toString()</code> policy bypass via <code>Traversable</code> objects in the <code>join</code>/<code>replace</code> filters and the <code>in</code>/<code>not in</code> operators.</li>
<li><strong>CVE-2026-48805</strong> — Sandbox state regression in deprecated internal wrappers (<code>twig_check_arrow_in_sandbox()</code>, <code>twig_array_some()</code>, <code>twig_array_every()</code>).</li>
</ul>
<h2>Also in 1.1.4<a id="also-in-114" href="#also-in-114" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li><code>symfony/polyfill-*</code> updated from v1.37.0 to v1.38.1.</li>
<li>Dev dependencies bumped: <code>phpstan/phpstan</code> 2.1.55 → 2.2.1, <code>phpunit/phpunit</code> 13.1.10 → 13.1.13, <code>rector/rector</code> 2.4.4 → 2.4.5.</li>
</ul>
<h2>How to upgrade<a id="how-to-upgrade" href="#how-to-upgrade" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-bash">composer update
ddev build   # or your equivalent build command
</code></pre>
<p>No configuration changes or migration steps required.</p>
<h2>Full changelog<a id="full-changelog" href="#full-changelog" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md#114---2026-05-28">CHANGELOG.md</a></p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[release]]></category>
                        <category><![CDATA[security]]></category>
                        <category><![CDATA[symfony]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.1.3 — JSON Schema files and Symfony 7.4.12 security update]]></title>
            <link>https://notacms.holas.pl/blog/release-1-1-3/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/release-1-1-3/</guid>
                        <pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[JSON Schema files for config and frontmatter Six JSON Schema draft-07 files now live in config/schema/: Schema file Describes site.schema.json _site.yaml — site-wide settings, locales, social links, contact form routes.schema.json _routes.yaml — locale URL path overrides tags.schema.json _tags.yaml — tag translation map post.frontmatter.schema.json Blog post Markdown frontmatter page.frontmatter.s…]]></description>
            <content:encoded><![CDATA[<h2>JSON Schema files for config and frontmatter<a id="json-schema-files-for-config-and-frontmatter" href="#json-schema-files-for-config-and-frontmatter" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Six JSON Schema draft-07 files now live in <code>config/schema/</code>:</p>
<table>
<thead>
<tr>
<th>Schema file</th>
<th>Describes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>site.schema.json</code></td>
<td><code>_site.yaml</code> — site-wide settings, locales, social links, contact form</td>
</tr>
<tr>
<td><code>routes.schema.json</code></td>
<td><code>_routes.yaml</code> — locale URL path overrides</td>
</tr>
<tr>
<td><code>tags.schema.json</code></td>
<td><code>_tags.yaml</code> — tag translation map</td>
</tr>
<tr>
<td><code>post.frontmatter.schema.json</code></td>
<td>Blog post Markdown frontmatter</td>
</tr>
<tr>
<td><code>page.frontmatter.schema.json</code></td>
<td>Static page Markdown frontmatter</td>
</tr>
<tr>
<td><code>category.frontmatter.schema.json</code></td>
<td>Category index Markdown frontmatter</td>
</tr>
</tbody>
</table>
<p>All template YAML files (<code>_site.yaml</code>, <code>_routes.yaml</code>, <code>_tags.yaml</code>) in <code>docs/bare/</code>, <code>docs/demo/</code>, and <code>docs/customization/old-template/</code> now carry a <code># yaml-language-server: $schema=</code> comment pointing to the raw GitHub URL. VS Code (and any editor with YAML Language Server) picks these up automatically — you get validation and autocomplete in the content files without any project configuration.</p>
<p>Schemas are optimised for AI-assisted authoring: descriptions include defaults, constraints, and behaviour explanation so an AI agent working in another project can fetch a schema and know exactly what each field does.</p>
<p>Fetch any schema directly from the main branch:</p>
<pre><code>https://raw.githubusercontent.com/holas1337/notACMS/main/config/schema/&lt;name&gt;.schema.json
</code></pre>
<h2>Security update: Symfony 7.4.12 and Twig 3.26.0<a id="security-update-symfony-7412-and-twig-3260" href="#security-update-symfony-7412-and-twig-3260" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Upgrade immediately.</strong> Run <code>composer update</code> in your project, then rebuild.</p>
<h3>Symfony 7.4.12 (21 CVEs)<a id="symfony-7412-21-cves" href="#symfony-7412-21-cves" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The most impactful for a CMS deployment:</p>
<ul>
<li><strong>CVE-2026-45073</strong> — SQL injection in <code>Cache</code> via unsanitized <code>$prefix</code> in <code>PdoAdapter::doClear()</code>.</li>
<li><strong>CVE-2026-45071</strong> — XXE / local file disclosure in <code>DomCrawler::addXmlContent()</code> when <code>validateOnParse</code> is enabled.</li>
<li><strong>CVE-2026-45075</strong> — HEAD requests bypass the <code>methods</code> filter on <code>#[IsGranted]</code>, <code>#[IsCsrfTokenValid]</code>, and <code>#[IsSignatureValid]</code> attributes.</li>
<li><strong>CVE-2026-45072</strong> — XSS in <code>TwigBridge</code>'s <code>CodeExtension::fileExcerpt()</code>.</li>
<li><strong>CVE-2026-45068</strong> — Header injection in <code>SendmailTransport</code>; addresses starting with a dash are now rejected.</li>
<li><strong>CVE-2026-45067</strong> — Email addresses containing line breaks accepted in <code>Mime\Address</code> — now rejected.</li>
<li><strong>CVE-2026-45305 / 45304 / 45133</strong> — Catastrophic backtracking and unbounded recursion in the YAML parser.</li>
<li><strong>CVE-2026-45066 / 45064 / 45753</strong> — Three <code>HtmlSanitizer</code> bypasses: BiDi override characters, URL parser differentials, and unsanitized <code>action</code>/<code>formaction</code>/<code>poster</code>/<code>cite</code> attributes.</li>
</ul>
<p>Full list: <a rel="nofollow noopener noreferrer" target="_blank" href="https://symfony.com/blog/symfony-7-4-12-released">symfony.com/blog/symfony-7-4-12-released</a>.</p>
<h3>Twig 3.26.0 (4 CVEs)<a id="twig-3260-4-cves" href="#twig-3260-4-cves" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>All four are sandbox bypasses. If your theme uses Twig's sandbox for user-supplied templates, these are critical:</p>
<ul>
<li><strong>CVE-2026-46635</strong> — Property allowlist bypass via the <code>column</code> filter (<code>array_column</code> on objects).</li>
<li><strong>CVE-2026-46638</strong> — <code>{% sandbox %}{% include %}</code> skips <code>checkSecurity()</code> on cached templates; incomplete fix for CVE-2024-45411.</li>
<li><strong>CVE-2026-24425</strong> — Sandbox bypass when using a source policy.</li>
<li><strong>CVE-2026-47732</strong> — Multiple <code>__toString()</code> policy bypasses via unguarded string coercion.</li>
</ul>
<h2>Also in 1.1.3<a id="also-in-113" href="#also-in-113" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li><strong>Stale Pagefind fragments fixed</strong> — <code>scripts/rebuild-content.sh</code> now wipes <code>public/pagefind/</code> before reindexing. Previously, removing or renaming content left orphaned fragment files that Pagefind served alongside fresh results.</li>
<li><strong>DESIGN.md improvements</strong> — hardcoded hex values replaced with token references in both bare and demo <code>DESIGN.md</code> files; <code>primary</code> color alias added; <code>card-hover</code> component token added to the demo theme.</li>
</ul>
<h2>How to upgrade<a id="how-to-upgrade" href="#how-to-upgrade" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-bash">composer update
ddev build   # or your equivalent build command
</code></pre>
<p>No configuration changes or migration steps required.</p>
<h2>Full changelog<a id="full-changelog" href="#full-changelog" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md#113---2026-05-20">CHANGELOG.md</a></p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[release]]></category>
                        <category><![CDATA[security]]></category>
                        <category><![CDATA[symfony]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.1.2 — Canonical URLs and a structured-data builder]]></title>
            <link>https://notacms.holas.pl/blog/release-1-1-2/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/release-1-1-2/</guid>
                        <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[One canonical URL per page If your site's default locale is en, /en/blog/ and /blog/ were both reachable and rendered the same content. That's two indexable URLs for one page — and search engines pick whichever they like, which is rarely the one you want. 1.1.2 adds DefaultLocaleRedirectListener. Every request to /&lt;default-locale&gt;/... returns a 301 to the unprefixed URL: GET /en/blog/ → 301 …]]></description>
            <content:encoded><![CDATA[<h2>One canonical URL per page<a id="one-canonical-url-per-page" href="#one-canonical-url-per-page" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>If your site's default locale is <code>en</code>, <code>/en/blog/</code> and <code>/blog/</code> were both reachable and rendered the same content. That's two indexable URLs for one page — and search engines pick whichever they like, which is rarely the one you want.</p>
<p>1.1.2 adds <code>DefaultLocaleRedirectListener</code>. Every request to <code>/&lt;default-locale&gt;/...</code> returns a <code>301</code> to the unprefixed URL:</p>
<pre><code>GET /en/blog/         →  301 Location: /blog/
GET /en/about/?ref=x  →  301 Location: /about/?ref=x
GET /pl/blog/         →  200 (non-default locale, untouched)
</code></pre>
<p>The listener runs before Symfony's router, so the redirect happens before the framework even tries to match a route — no wasted work, no 404 noise in logs. Non-default locales (<code>pl</code>, <code>de</code>, <code>fr</code> on this site) are left alone.</p>
<p>This also closes a small SEO leak: backlinks that accidentally include the locale prefix now consolidate into the canonical URL instead of splitting authority.</p>
<h2>Schema.org JSON-LD as a fluent builder<a id="schemaorg-json-ld-as-a-fluent-builder" href="#schemaorg-json-ld-as-a-fluent-builder" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Until 1.1.1, every template that needed structured data inlined a PHP array, piped it through <code>|json_encode|raw</code>, and wrapped the result in a <code>&lt;script&gt;</code> tag. Six templates, six near-identical blocks, and each one a fresh chance to forget <code>JSON_UNESCAPED_SLASHES</code> or to ship <code>&lt;script&gt;false&lt;/script&gt;</code> if any frontmatter contained an invalid byte.</p>
<p>1.1.2 replaces all of that with a service and two Twig functions:</p>
<pre><code class="language-twig">{% block structured_data %}{{ json_ld(structured_data().blogPosting(
    content.title(),
    site_base_url ~ content.url(),
    content.date(),
    content.htmlContent,
    site_author.name,
    content.image() ? site_base_url ~ content.image() : null
)) }}{% endblock %}
</code></pre>
<p><code>structured_data()</code> returns the builder; chain a typed method (<code>webSite</code>, <code>webPage</code>, <code>blogPosting</code>, <code>collectionPage</code>, <code>contactPage</code>, <code>person</code>, <code>breadcrumbList</code>, <code>organization</code>, <code>imageObject</code>) and the result is a plain array. <code>json_ld(array)</code> encodes it and wraps it in a <code>&lt;script type=&quot;application/ld+json&quot;&gt;</code> tag. Empty/null fields are stripped recursively, so optional inputs (an unset author email, a missing image) simply don't appear in the output.</p>
<p>Encoding now uses <code>JSON_THROW_ON_ERROR</code> — bad UTF-8 in frontmatter surfaces as a real exception during render instead of silently shipping a broken <code>&lt;script&gt;</code> tag.</p>
<p>The bare core templates for <code>contact</code>, <code>default</code>, and <code>projects</code> pages now emit Schema.org markup too. Previously bare deploys had weaker SEO than every customisation example we ship; that gap is gone.</p>
<p>See the <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/docs/CUSTOMIZATION.md#structured-data-json-ld">Customisation guide → Structured data</a> for the full builder API and how to override the <code>structured_data</code> block in your own theme.</p>
<h2>Security fixes<a id="security-fixes" href="#security-fixes" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Two bugs surfaced during the 1.1.2 review and were fixed before release:</p>
<ul>
<li><strong>Open-redirect via path normalisation.</strong> Symfony's <code>Request::getPathInfo()</code> does not collapse repeated slashes, so a request to <code>/&lt;default-locale&gt;//evil.com</code> would otherwise produce <code>Location: //evil.com</code> — a protocol-relative redirect that browsers follow cross-origin. The new redirect listener now refuses to issue redirects to anything that isn't a single-slash-rooted local path.</li>
<li><strong>XSS in search results.</strong> <code>assets/search.js</code> interpolated the Pagefind <code>excerpt</code> field into innerHTML without escaping. The demo build was already correct; core had drifted. The two are now in sync. Pagefind's <code>&lt;mark&gt;</code> highlight tags are no longer rendered in core search results — a deliberate trade for the safer baseline.</li>
</ul>
<h2>Also in 1.1.2<a id="also-in-112" href="#also-in-112" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li><strong>Internal Twig refactor.</strong> Sidebar, breadcrumb, post-badge, og-image, and blog-filter logic moved out of templates into dedicated Twig extensions. Templates stay focused on layout; tests can target each helper directly. New customisation surface: see <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/docs/CUSTOMIZATION.md#layout-helpers">Layout helpers</a> in the customisation guide.</li>
<li><strong><code>O(1)</code> directory-key lookups</strong> on <code>ContentTree</code> — a small performance fix on sites with hundreds of pages.</li>
<li><strong><code>docs/customization/old-template/</code> is now deprecated</strong> and will be removed in 1.2.0. It was a one-shot 1.0→1.1 transition aid; new customisation work should branch from <code>custom-footer/</code> or <code>self-hosted-fonts/</code> instead.</li>
<li><strong>Dependency bumps:</strong> Symfony 7.4.8 → 7.4.9 across the bundle, PHPStan 2.1.51 → 2.1.54, PHPUnit 13.1.7 → 13.1.8.</li>
</ul>
<h2>Full changelog<a id="full-changelog" href="#full-changelog" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Every change with its category: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md#112---2026-05-04">CHANGELOG.md</a>.</p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[release]]></category>
                        <category><![CDATA[announcement]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.1.1 — Deploy respects existing content]]></title>
            <link>https://notacms.holas.pl/blog/release-1-1-1/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/release-1-1-1/</guid>
                        <pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[Frontmatter-driven navigation labels Navigation labels for content pages now come from frontmatter — not translation keys. Set menu.label in any page's frontmatter and it becomes the label in the navigation; omit it and the page title is used as fallback. --- title: &quot;Architecture guide&quot; slug: &quot;architecture-guide&quot; menu: label: &quot;Architecture&quot; weight: 30 --- New content_…]]></description>
            <content:encoded><![CDATA[<h2>Frontmatter-driven navigation labels<a id="frontmatter-driven-navigation-labels" href="#frontmatter-driven-navigation-labels" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Navigation labels for content pages now come from frontmatter — not translation keys. Set <code>menu.label</code> in any page's frontmatter and it becomes the label in the navigation; omit it and the page <code>title</code> is used as fallback.</p>
<pre><code class="language-yaml">---
title: &quot;Architecture guide&quot;
slug: &quot;architecture-guide&quot;
menu:
  label: &quot;Architecture&quot;
  weight: 30
---
</code></pre>
<p>New <code>content_item()</code> Twig function resolves any content page by directory key:</p>
<pre><code class="language-twig">{{ content_item('architecture-guide', 'en').menuLabel() }}
{# → &quot;Architecture&quot; (or the page title if menu.label is absent) #}
</code></pre>
<p><strong>Why this matters:</strong> Previously every locale's <code>messages.*.yaml</code> had to duplicate translations for <code>nav.home</code>, <code>nav.blog</code>, <code>nav.about</code>, <code>nav.contact</code>, <code>nav.privacy_policy</code> (core) and <code>site.releases</code>, <code>site.about</code>, <code>site.manual</code>, <code>site.architecture</code>, <code>site.customization</code>, <code>site.locales</code>, <code>site.design_reference</code>, <code>site.contact</code> (demo). Adding a page meant updating N translation files. Now it's one frontmatter field. Custom templates referencing the removed keys should switch to <code>content_item('key', locale).menuLabel()</code>.</p>
<h2>Deploy no longer overwrites existing <code>local/</code><a id="deploy-no-longer-overwrites-existing-local" href="#deploy-no-longer-overwrites-existing-local" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>The bug<a id="the-bug" href="#the-bug" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Running <code>./notACMS deploy --prod</code> would <strong>back up and replace</strong> your entire <code>local/</code> directory with <code>docs/demo/</code> on every deploy — even when it already contained your content, templates, and customisations. The seed logic didn't distinguish between &quot;user explicitly requested a theme re-seed&quot; and &quot;user just wants to redeploy with existing content.&quot;</p>
<h3>The fix<a id="the-fix" href="#the-fix" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Deploy now works exactly like <code>ddev build</code>:</p>
<ul>
<li><strong>No <code>--bare</code> / <code>--demo</code> flag</strong> → seeds <code>local/</code> only if it's missing or empty; skips if it has content.</li>
<li><strong><code>--bare</code> or <code>--demo</code> passed explicitly</strong> → backs up existing <code>local/</code> and seeds the chosen theme.</li>
</ul>
<pre><code class="language-bash">./notACMS deploy --prod          # preserves existing local/
./notACMS deploy --prod --demo   # forces re-seed from docs/demo/
./notACMS deploy --prod --bare   # forces re-seed from docs/bare/
</code></pre>
<p><code>--prod</code> now only controls Composer flags (<code>--no-dev</code>) and the runtime environment (<code>APP_ENV=prod</code>). It never touches <code>local/</code>.</p>
<h2>Also in 1.1.1<a id="also-in-111" href="#also-in-111" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li><strong>Dependency bump:</strong> <code>symfony/polyfill-*</code> packages updated from v1.36.0 to v1.37.0.</li>
<li><strong>Polish, German, and French demo content</strong> reviewed and improved across all pages and blog posts.</li>
<li><strong>Translation style guides</strong> added to the AI-agent skill system for Polish, German, and French to ensure consistent quality in future translations.</li>
</ul>
<h2>Full changelog<a id="full-changelog" href="#full-changelog" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Every change with its category: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md#111---2026-04-26">CHANGELOG.md</a>.</p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[release]]></category>
                        <category><![CDATA[announcement]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.1.0 — Bare core, demo theme, pick your own]]></title>
            <link>https://notacms.holas.pl/blog/release-1-1-0/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/release-1-1-0/</guid>
                        <pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[Two themes, one pick on first build The headline change in 1.1.0 is that notACMS now ships two themes out of the box, and you pick one on your very first build: ./notACMS deploy --demo # default — the amber-phosphor design you see here ./notACMS deploy --bare # a minimal wireframe: system fonts, light mode, ~200 lines of CSS ddev build supports the same flags. Whichever you choose, every feature w…]]></description>
            <content:encoded><![CDATA[<h2>Two themes, one pick on first build<a id="two-themes-one-pick-on-first-build" href="#two-themes-one-pick-on-first-build" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The headline change in 1.1.0 is that notACMS now ships two themes out of the box, and you pick one on your very first build:</p>
<pre><code class="language-bash">./notACMS deploy --demo    # default — the amber-phosphor design you see here
./notACMS deploy --bare    # a minimal wireframe: system fonts, light mode, ~200 lines of CSS
</code></pre>
<p><code>ddev build</code> supports the same flags. Whichever you choose, every feature works: multi-language content, blog, RSS, sitemap, search, contact form, images with responsive variants, reading time, reading progress. The difference is only in how it looks — and, critically, in how much you inherit before you start customising.</p>
<h3>Bare core<a id="bare-core" href="#bare-core" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The core at <code>templates/</code>, <code>assets/</code>, <code>translations/</code> is now a wireframe. It's intentionally minimal so that overriding a single block doesn't drag in a visual language you have to fight. If you're building a bespoke design, start from bare and you'll own the full look from day one.</p>
<h3>Demo theme<a id="demo-theme" href="#demo-theme" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The demo lives at <code>docs/demo/</code> and is copied into <code>local/</code> when you choose <code>--demo</code>. It's the full amber-phosphor design you're reading now — dark mode, search overlay, docs sidebar, theme toggle, the lot. Start here if you want a polished design today and plan to tweak, not rebuild.</p>
<h2>The <code>local/</code> override system<a id="the-local-override-system" href="#the-local-override-system" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Both themes consume the same mechanism: every file in <code>local/</code> takes precedence over the matching core path.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Override location</th>
<th>Resolver</th>
</tr>
</thead>
<tbody>
<tr>
<td>Twig templates</td>
<td><code>local/templates/*.html.twig</code></td>
<td>Symfony kernel</td>
</tr>
<tr>
<td>SCSS entrypoint</td>
<td><code>local/assets/styles/app_local.scss</code></td>
<td>Sass bundle</td>
</tr>
<tr>
<td>JS entrypoint</td>
<td><code>local/assets/app.js</code></td>
<td>AssetMapper importmap</td>
</tr>
<tr>
<td>Translations</td>
<td><code>local/translations/messages.*.yaml</code></td>
<td>Symfony translator</td>
</tr>
<tr>
<td>Content</td>
<td><code>local/content/**</code></td>
<td><code>notacms_content</code> parameter</td>
</tr>
<tr>
<td>Nginx snippets</td>
<td><code>local/docker/nginx/*.conf</code></td>
<td>Container entrypoint</td>
</tr>
</tbody>
</table>
<p>Core is never edited. <code>git pull</code> stays clean. Customisations live in your site's repo, not in a fork of this one.</p>
<h2>Upgrading from 1.0.0<a id="upgrading-from-100" href="#upgrading-from-100" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>If you were running 1.0.0 and want your site to look exactly as it did, the compatibility package is a one-liner:</p>
<pre><code class="language-bash">cp -r docs/customization/old-template/. local/
ddev build
</code></pre>
<p>That drops the complete pre-1.1.0 theme — templates, SCSS, fonts, images, translations — into <code>local/</code>. Your site renders exactly as before. Then you can remove files from <code>local/</code> selectively as you adopt new design elements.</p>
<p>The breaking changes you'll actually hit if you customised the old core:</p>
<ul>
<li><strong>SCSS entrypoint renamed</strong>: <code>local/assets/styles/local.scss</code> → <code>local/assets/styles/app_local.scss</code>. Rename the file and update the import in <code>local/assets/app.js</code>.</li>
<li><strong>Core colour SCSS variables replaced with CSS custom properties</strong>: <code>$color-body</code> → <code>var(--text)</code>, <code>$color-link</code> → <code>var(--accent)</code>, and so on. Full mapping in <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/UPGRADE-1.1.md">UPGRADE-1.1.md</a>.</li>
<li><strong>Translation keys removed</strong> from core (still present in the demo theme): <code>header.tagline</code>, <code>nav.projects</code>, <code>nav.search</code>, <code>blog.published_on</code>, <code>blog.comments_disabled</code>. If your templates call <code>'...'|trans</code> on those, update them.</li>
<li><strong><code>docs/examples/</code> directory removed</strong>: the useful bits moved to <code>docs/customization/old-template/</code>; the scratch override examples were superseded by the bare/demo model.</li>
</ul>
<p>See <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/UPGRADE-1.1.md">UPGRADE-1.1.md</a> for the full list with before/after snippets.</p>
<h2>Also new in 1.1.0<a id="also-new-in-110" href="#also-new-in-110" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li><strong>Reading time</strong> and <strong>reading progress</strong> on posts, docs, and releases.</li>
<li><strong>Language switcher</strong> as a Twig extension (<code>lang_switch_urls</code>) with proper fallback chains for archives, paginated lists, and home pages.</li>
<li><strong>Post excerpts strip heading anchors</strong> — no more stray <code>#</code> characters in listing cards.</li>
<li><strong>Test suite scaffolding</strong> at <code>tests/Unit/</code>, <code>tests/Integration/</code>, <code>tests/Fixtures/</code> with initial coverage and a <code>test</code> command runnable from the host.</li>
<li><strong>AI-agent skills</strong> under <code>.claude/skills/</code> for working with the repo: adding locales, doc alignment checks, site sweeps, translations, and upgrade-guide generation.</li>
</ul>
<h2>Full changelog<a id="full-changelog" href="#full-changelog" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Every change with its category: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md#110---2026-04-24">CHANGELOG.md</a>.</p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[release]]></category>
                        <category><![CDATA[announcement]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS v1.0.0]]></title>
            <link>https://notacms.holas.pl/blog/release-1-0-0/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/release-1-0-0/</guid>
                        <pubDate>Thu, 09 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[What's in v1.0.0 After several months of internal use on personal projects, I'm tagging the first stable release. The core feature set is solid enough to build real sites with. Content pipeline The content pipeline is the heart of notACMS. It reads your local/content/ directory, parses frontmatter, and generates a complete static site in one command. Markdown content with YAML frontmatter CommonMa…]]></description>
            <content:encoded><![CDATA[<h2>What's in v1.0.0<a id="whats-in-v100" href="#whats-in-v100" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>After several months of internal use on personal projects, I'm tagging the first stable release. The core feature set is solid enough to build real sites with.</p>
<h3>Content pipeline<a id="content-pipeline" href="#content-pipeline" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The content pipeline is the heart of notACMS. It reads your <code>local/content/</code> directory, parses frontmatter, and generates a complete static site in one command.</p>
<ul>
<li>Markdown content with YAML frontmatter</li>
<li>CommonMark rendering with heading permalinks</li>
<li>Excerpt generation from prose content</li>
<li>Reading time calculation</li>
</ul>
<h3>Multilingual routing<a id="multilingual-routing" href="#multilingual-routing" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Locales are defined in <code>_site.yaml</code>. Each locale gets its own URL space, with optional path overrides in <code>_routes.yaml</code>. hreflang tags are generated automatically.</p>
<pre><code class="language-yaml">site:
  locales:
    en:
      label: &quot;English&quot;
      date_format: &quot;M d, Y&quot;
    pl:
      label: &quot;Polski&quot;
      date_format: &quot;d.m.Y&quot;
</code></pre>
<h3>Pagefind search<a id="pagefind-search" href="#pagefind-search" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Full-text search is built into the static output via <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagefind.app/">Pagefind</a>. The build command generates the search index automatically. No external API, no server-side search — just a static index that works offline.</p>
<h3>Image processing<a id="image-processing" href="#image-processing" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Images co-located with content are processed during build. notACMS generates WebP variants at multiple widths, automatically updates <code>src</code> attributes with responsive <code>srcset</code>, and handles the path mapping between content and output directories.</p>
<h3>DDEV local development<a id="ddev-local-development" href="#ddev-local-development" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The development environment is fully containerized with DDEV. <code>ddev start</code> gives you PHP 8.5, Nginx, and all build tools. <code>ddev build</code> produces the static output. <code>ddev code-check</code> runs PHPStan and PHP CS Fixer.</p>
<h2>Breaking changes<a id="breaking-changes" href="#breaking-changes" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>This is the first stable release. If you've been using a pre-1.0 version, review the <code>_site.yaml</code> schema — the <code>social</code> key changed from a list to a map.</p>
<h2>Upgrade<a id="upgrade" href="#upgrade" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-bash">git pull
ddev composer install
ddev build
</code></pre>
<h2>What's next<a id="whats-next" href="#whats-next" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>v1.1.0 will focus on the design system and documentation. This site — built with notACMS — will become the official documentation and design reference.</p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[release]]></category>
                        <category><![CDATA[announcement]]></category>
                    </item>
                <item>
            <title><![CDATA[Why I built notACMS]]></title>
            <link>https://notacms.holas.pl/blog/the-idea/</link>
            <guid isPermaLink="true">https://notacms.holas.pl/blog/the-idea/</guid>
                        <pubDate>Sun, 01 Feb 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[Longer backstory on my personal blog: I left WordPress. The problem I kept running into Every PHP project I worked on eventually needed a website. Documentation, a landing page, a blog — the usual. And every time I reached for a tool, I ended up in the same place: a tool that wanted me to think like it, not like a PHP developer. Hugo is fast but its template language is alien. Jekyll requires Ruby…]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>Longer backstory on my personal blog: <a rel="nofollow noopener noreferrer" target="_blank" href="https://holas.pl/blog/why-i-left-wordpress/">I left WordPress</a>.</p>
</blockquote>
<h2>The problem I kept running into<a id="the-problem-i-kept-running-into" href="#the-problem-i-kept-running-into" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Every PHP project I worked on eventually needed a website. Documentation, a landing page, a blog — the usual. And every time I reached for a tool, I ended up in the same place: a tool that wanted me to think like it, not like a PHP developer.</p>
<p>Hugo is fast but its template language is alien. Jekyll requires Ruby. WordPress is the wrong shape for static content. Next.js is powerful but it turns a Markdown publishing problem into a JavaScript bundler problem.</p>
<p>I kept thinking: I already know Symfony. I already know Twig. I already have Composer. Why do I need to learn a new ecosystem just to publish text?</p>
<h2>The zero-database decision<a id="the-zero-database-decision" href="#the-zero-database-decision" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The first thing I decided: no database. Not &quot;database optional&quot; — no database at all. Content lives in files. The build process reads files. The output is files.</p>
<p>This forces a certain discipline. Content structure has to be explicit and predictable. There's no database query to reach for when you want to find related posts or generate a sitemap. Everything has to be derivable from the file tree.</p>
<p>That constraint turned out to be the right one. It makes the system easy to reason about, easy to back up, and trivially easy for AI tools to work with.</p>
<h2>AI-friendly by design<a id="ai-friendly-by-design" href="#ai-friendly-by-design" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>I started thinking about this seriously when I began using LLMs in my daily work. Asking an AI to help generate content, translate pages, or validate YAML schemas is straightforward when the format is plain text.</p>
<p>With a database-backed CMS, you'd need to explain the schema, write SQL, manage migrations. With notACMS, you hand the AI a directory of Markdown files and it can read, generate, and edit content directly — because the format is just text.</p>
<p>This isn't a feature I added after the fact. It's the reason the system is designed the way it is.</p>
<h2>The Symfony foundation<a id="the-symfony-foundation" href="#the-symfony-foundation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>I built notACMS on top of Symfony 7 for the same reason I use Symfony for everything else: it's explicit, typed, and well-documented. The DI container, console commands, Twig — all of this is standard Symfony. There's nothing novel about the infrastructure.</p>
<p>The content model is the interesting part. The routing system reads <code>_routes.yaml</code> and generates Symfony routes from your content tree. The build command renders each route and writes static HTML. The service layer is thin and replaceable.</p>
<h2>What's next<a id="whats-next" href="#whats-next" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>This first release is a working foundation. The things I want to build:</p>
<ul>
<li>Pagefind search integration (coming in v1.0.0)</li>
<li>Better image pipeline with WebP generation</li>
<li>More complete multilingual support</li>
<li>A proper Design Reference page (that's what this site is becoming)</li>
</ul>
<p>The repository is public. If you want to follow along or contribute, the link is in the footer.</p>
]]></content:encoded>
                                    <category><![CDATA[releases]]></category>
                                    <category><![CDATA[announcement]]></category>
                        <category><![CDATA[open-source]]></category>
                    </item>
            </channel>
</rss>
