37b6764431
Ships Blog tags/categories, Internal search, and XML sitemap together as
one shared mechanism, plus a new meta keywords request, rather than three
separate ones - the "worth deciding together" call ISSUES.md made when
XML sitemap was first written.
Content stays in files. Per-page metadata is four Twig blocks in the
root layout, the same override mechanism already used for
title/description/og_* - keywords (rendered), tags/changefreq/priority
(not rendered, harvested only). App\ContentIndexer crawls every routable
page (Overlay::listPageDirs(), new) and pulls each block via
Renderer::renderForIndex() (new) calling Twig's own renderBlock() API,
not regex-parsing .twig source, so overrides and layout inheritance
resolve exactly like a real render. Rendered HTML is stripped and
indexed into a SQLite FTS5 table for search.
Off by default (content_index_enabled) - same posture as Matomo/admin
auth, since this is a real SQLite dependency plenty of sites won't want.
Verified true zero footprint when disabled: no data/novaconium.sqlite
gets created, all three consumer routes 404 like they don't exist.
content_index_auto (default true) reindexes lazily on first stale touch
of a consumer route, never on a normal page view; both that path and the
explicit `php novaconium/bin/index-content.php` share one reindex().
Two real bugs caught by testing, not review: a reentrancy bug where
/search's own sidecar calling ensureFresh() during the crawl triggered a
nested reindex() mid-transaction (fixed with a static re-entrancy guard),
and a wrong PDO constant in the search sidecar. Also fixed two unrelated
pre-existing bugs found while building this: an unescaped {{ }} in
admin/docs/sidecars that fataled Twig on any real render of that page,
and a stale "no database layer yet" claim there and in Lib\Input's
docblock, left over from before Lib\Db shipped.
Lib\Db's migrations_dir now accepts an ordered list of roots, not just
one path, so the content index's schema could ship as a framework
migration (novaconium/migrations/) without colliding with project
migrations in App/migrations/ - the two-root extension point flagged in
AGENTS.md when SQLite groundwork shipped. Migrations are tracked by path
relative to the repo root rather than bare filename so two roots with a
same-named file can't shadow each other.
New consumer routes: novaconium/pages/sitemap.xml/ and
novaconium/pages/search/ (framework defaults), App/pages/blog/tag/[tag]/
(project-owned, since blog/ is project content - the existing hand
-written post array in App/pages/blog/index.php is untouched). Added
tags to the 4 existing blog posts as a real demonstration.
Closes the Blog tags/categories, Internal search, and XML sitemap
backlog items in novaconium/ISSUES.md.
74 lines
3.5 KiB
PHP
74 lines
3.5 KiB
PHP
<?php
|
|
|
|
// /sitemap.xml — a directory literally named "sitemap.xml" under
|
|
// novaconium/pages/, a framework default (like /admin/*), since sitemap
|
|
// generation is generic machinery, not project content. Router only ever
|
|
// splits the request path on "/", so a directory named "sitemap.xml"
|
|
// resolves the literal /sitemap.xml URL correctly — there's no special
|
|
// extension-routing mechanism involved. Sidecar-only: no index.twig next
|
|
// to this file, since Response::xml() bypasses Twig entirely (see
|
|
// /admin/docs/sidecars' JSON-only example for the same pattern).
|
|
|
|
use App\ContentIndexer;
|
|
use App\Response;
|
|
use Lib\Db;
|
|
|
|
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
|
// isn't handed $config, so it loads its own copy to read
|
|
// content_index_enabled before touching Lib\Db at all.
|
|
$config = require __DIR__ . '/../../config.php';
|
|
$appConfigFile = __DIR__ . '/../../../App/config.php';
|
|
if (is_file($appConfigFile)) {
|
|
$config = array_merge($config, require $appConfigFile);
|
|
}
|
|
|
|
// Content index is off by default (depends on SQLite) — see
|
|
// /admin/docs/content-index. When it's off, this route must 404 exactly
|
|
// like a page that doesn't exist, and never construct a Lib\Db connection
|
|
// (which would otherwise create data/novaconium.sqlite just because this
|
|
// file exists, even on a site that never opted in).
|
|
if (!$config['content_index_enabled']) {
|
|
return Response::html('404 Not Found', 404);
|
|
}
|
|
|
|
// Lazy reindex-if-stale — a no-op on most requests (only actually
|
|
// reindexes when a page's source file changed since the last index).
|
|
// ContentIndexer's crawl itself never renders this route: a sidecar
|
|
// returning a Response (this one always does) has nothing meaningful to
|
|
// index, so Renderer::renderForIndex() returns null for it and
|
|
// ContentIndexer skips it — no reentrancy concern here, unlike /search
|
|
// (see that file's comments), which does get crawled since it renders
|
|
// normally on an empty query.
|
|
ContentIndexer::ensureFresh();
|
|
|
|
// One row per indexed page — populated entirely by ContentIndexer::
|
|
// reindex(), never written to directly here. changefreq/priority come
|
|
// from each page's own {% block changefreq %}/{% block priority %}
|
|
// (defaults: monthly / 0.5 — see novaconium/pages/_layout/layout.twig),
|
|
// source_mtime is that page's source file's own mtime, used below as
|
|
// <lastmod> so it reflects when the content actually last changed, not
|
|
// when the index was last rebuilt.
|
|
$pages = Db::query('SELECT route, changefreq, priority, source_mtime FROM content_pages ORDER BY route')
|
|
->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
// Plain string concatenation rather than DOMDocument — this site's scale
|
|
// doesn't need a real XML builder, but every value is still
|
|
// htmlspecialchars()-escaped (ENT_XML1, not the HTML default) since
|
|
// route/changefreq/priority all ultimately come from page-author-controlled
|
|
// Twig blocks, not hardcoded constants.
|
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
|
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
|
|
|
foreach ($pages as $page) {
|
|
$xml .= ' <url>' . "\n";
|
|
$xml .= ' <loc>' . htmlspecialchars($page['route'], ENT_XML1) . '</loc>' . "\n";
|
|
$xml .= ' <lastmod>' . gmdate('Y-m-d', (int) $page['source_mtime']) . '</lastmod>' . "\n";
|
|
$xml .= ' <changefreq>' . htmlspecialchars($page['changefreq'], ENT_XML1) . '</changefreq>' . "\n";
|
|
$xml .= ' <priority>' . htmlspecialchars($page['priority'], ENT_XML1) . '</priority>' . "\n";
|
|
$xml .= ' </url>' . "\n";
|
|
}
|
|
|
|
$xml .= '</urlset>' . "\n";
|
|
|
|
return Response::xml($xml);
|