Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb64836901 | |||
| 74c0d59019 | |||
| a51faa7208 | |||
| 37b6764431 | |||
| 4862526fa1 | |||
| 5deb298b91 | |||
| 3a59269eaa | |||
| a3b996719a | |||
| 672f997d8b | |||
| fe5f5ee131 |
@@ -1,4 +1,8 @@
|
|||||||
/public/cache/*
|
/public/cache/*
|
||||||
!/public/cache/.gitkeep
|
!/public/cache/.gitkeep
|
||||||
/novaconium/contact-log.txt
|
/novaconium/contact-log.txt
|
||||||
|
/data/*.sqlite
|
||||||
|
/data/*.sqlite-journal
|
||||||
|
/data/*.sqlite-wal
|
||||||
|
/data/*.sqlite-shm
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -130,6 +130,228 @@ management"); don't extend this class toward multi-user/session-based
|
|||||||
auth — that's a separate, larger feature that
|
auth — that's a separate, larger feature that
|
||||||
will replace it.
|
will replace it.
|
||||||
|
|
||||||
|
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite/MySQL groundwork tracked
|
||||||
|
in `novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`)
|
||||||
|
so a project can override it via `App/lib/Db.php` like any other `Lib\`
|
||||||
|
class. It supports multiple, independently-configured, **simultaneously
|
||||||
|
open** named connections (`config['db_connections']`, keyed by name) rather
|
||||||
|
than one global connection — because sidecars are plain PHP with full
|
||||||
|
access to any `Lib\` class, a single request can legitimately need more
|
||||||
|
than one database at once (e.g. this site's own SQLite data alongside a
|
||||||
|
MySQL connection to a legacy database). `Db::query(string $sql, array
|
||||||
|
$params = [], string $connection = 'default')` (prepare+execute) is the
|
||||||
|
only query-running helper — never add a string-interpolation shortcut; see
|
||||||
|
`Lib\Input`'s doc-comment, which already commits this project to
|
||||||
|
parameterized queries as the sole SQL-injection defense. Each connection is
|
||||||
|
lazy and independent (opened on first `Db::query()`/`Db::connection()` call
|
||||||
|
naming it, same shape as `Lib\Csrf`'s lazy session start), and migrates
|
||||||
|
automatically at that point: plain `.sql` files under that connection's own
|
||||||
|
`migrations_dir` — a single path, or (since the content index shipped) an
|
||||||
|
**ordered list of roots**, each root's own files filename-ordered and
|
||||||
|
roots processed fully in the order given, not interleaved by filename
|
||||||
|
across roots (so a framework root always finishes before a project root on
|
||||||
|
the same connection). Tracked by path **relative to the repo root** (e.g.
|
||||||
|
`novaconium/migrations/0001_x.sql`), not bare filename — two roots can
|
||||||
|
each contain a same-named file, and tracking by bare filename would make
|
||||||
|
the second one seen look "already applied" and silently skip it; a
|
||||||
|
repo-relative path is also portable across environments, unlike a full
|
||||||
|
absolute path, which would make every migration look new again after a
|
||||||
|
clone/deploy to a different directory. `realpath()` normalizes any `..` a
|
||||||
|
`migrations_dir` like `__DIR__ . '/../App/migrations'` would otherwise
|
||||||
|
leave in the tracked name. Each connection's own auto-created
|
||||||
|
`schema_migrations` table tracks its migrations independently of any other
|
||||||
|
connection's; each is only ever run once. `novaconium/bin/migrate.php`
|
||||||
|
loops every configured connection and runs the same migration step
|
||||||
|
explicitly (e.g. from a deploy script) without serving a request first.
|
||||||
|
Only `'sqlite'` and `'mysql'` drivers are implemented; a connection's
|
||||||
|
`migrations_dir` is optional — omit it to never run migrations against
|
||||||
|
that connection (e.g. a legacy database this project shouldn't manage
|
||||||
|
schema for).
|
||||||
|
|
||||||
|
**`db_connections` is the one config key in the project that isn't plain
|
||||||
|
shallow-merge** — `bootstrap.php`/`bin/*.php`'s usual `array_merge($config,
|
||||||
|
$appConfig)` would let a project's `App/config.php` silently delete the
|
||||||
|
framework's `default` connection just by adding a second named connection
|
||||||
|
(a shallow merge replaces the whole key, it doesn't merge inside it). So
|
||||||
|
`Lib\Db::config()` (and the copy of this logic duplicated in
|
||||||
|
`bin/migrate.php`, same duplication precedent as the two-step config load
|
||||||
|
already duplicated across `bootstrap.php`/`bin/clear-cache.php`) merges
|
||||||
|
`db_connections` one level deeper, by connection name, **after** capturing
|
||||||
|
the framework defaults — capture the defaults *before* the top-level
|
||||||
|
`array_merge()` overwrites `$config['db_connections']`, not after, or the
|
||||||
|
deeper merge silently operates on the already-overwritten value and the
|
||||||
|
`default` connection vanishes anyway. (This exact bug was hit once while
|
||||||
|
building this feature — verified by testing a real `App/config.php`
|
||||||
|
override end-to-end, not just reading the code — so it's worth re-checking
|
||||||
|
by hand if this logic is ever touched again.) See
|
||||||
|
`/admin/docs/database` for the worked example.
|
||||||
|
|
||||||
|
**`config['db_connections']['default']['path']` must stay outside both
|
||||||
|
`public/` (would be web-accessible) and `novaconium/`** — unlike
|
||||||
|
`cache_dir`/`contact-log.txt`, which are disposable, a SQLite file is data a
|
||||||
|
project can't afford to lose, and `novaconium/` gets wholesale-replaced by
|
||||||
|
the "Updating the framework" workflow (`/admin/docs/getting-started`: `rm
|
||||||
|
-rf novaconium && cp -r <new-novaconium>`). The default
|
||||||
|
(`data/novaconium.sqlite`) lives in a new top-level `data/` directory
|
||||||
|
instead — project-owned like `App/`, gitignored per-file
|
||||||
|
(`*.sqlite`/`-journal`/`-wal`/`-shm`, with a tracked `.gitkeep` so the
|
||||||
|
directory exists in a fresh clone) rather than wholesale like
|
||||||
|
`public/cache/`, since a project might reasonably want other non-DB files
|
||||||
|
there later. The default connection's `migrations_dir` scans
|
||||||
|
`novaconium/migrations/` (framework-shipped schema, e.g. the content
|
||||||
|
index below) before `App/migrations/` (project schema) — see the
|
||||||
|
`migrations_dir` array-form paragraph above. Any *other* connection a
|
||||||
|
project adds still defaults to no `migrations_dir` at all unless it sets
|
||||||
|
one; the two-root default is specific to `default`.
|
||||||
|
|
||||||
|
`Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around
|
||||||
|
native PHP sessions (`session_start()`/`$_SESSION`, not a custom store),
|
||||||
|
all-static and lazy-start like `Lib\Csrf` — nothing calls `session_start()`
|
||||||
|
until the first real call to a `Session` method. Its `ensureSession()` is a
|
||||||
|
**deliberate duplicate** of `Csrf::ensureSession()` (same cookie params,
|
||||||
|
same `session_status()` guard) rather than a shared helper — keeps `Csrf`
|
||||||
|
standalone with zero new dependencies on a class that didn't exist when it
|
||||||
|
shipped, same tolerance for small duplication already established by the
|
||||||
|
config-load block duplicated across `bootstrap.php`/`bin/clear-cache.php`/
|
||||||
|
`Lib\Db::config()`. Both classes touching the same native session in the
|
||||||
|
same request is safe either way, since `session_start()` silently no-ops
|
||||||
|
if a session is already active — there's no ordering requirement between
|
||||||
|
`Csrf::token()`/`::verify()` and any `Session` method.
|
||||||
|
|
||||||
|
Flash data (`Session::flash()`/`::getFlash()`) is one swap, not a
|
||||||
|
sweep/expiry pass: the first `Session` method call in a request snapshots
|
||||||
|
whatever was flashed on the *previous* request into an in-memory static
|
||||||
|
(`self::$currentFlash`) for that request's `getFlash()` reads, then
|
||||||
|
immediately empties the stored flash bucket so `flash()` calls made
|
||||||
|
*during* the current request start filling a fresh bucket for the request
|
||||||
|
after this one. This relies on static properties not persisting across
|
||||||
|
requests (true under `php -S`, mod_php, and PHP-FPM alike — each request
|
||||||
|
gets fresh PHP state regardless of worker-process reuse) — don't add any
|
||||||
|
caching/memoization to `Session` that assumes static state survives
|
||||||
|
between requests, since none of it does. See `/admin/docs/session` for a
|
||||||
|
worked flash example.
|
||||||
|
|
||||||
|
**Standing rule: any mechanism that conditionally hides page content from
|
||||||
|
the public must also be threaded into `Renderer::render()`'s
|
||||||
|
`$excludeFromCache` decision, not just a pre-render auth gate.** This
|
||||||
|
bit twice already — once as a designed-around gotcha (draft pages), once
|
||||||
|
as a real pre-existing bug found while testing that feature (`/admin/*`
|
||||||
|
itself). The reason: `Renderer::render()` writes a sidecar-less page's
|
||||||
|
output to the static HTML cache (`novaconium/src/Cache.php`), and
|
||||||
|
`.htaccess` serves a cached file *before PHP, and therefore any auth
|
||||||
|
check, ever runs again* (see `/admin/docs/caching`). A route can be
|
||||||
|
gated by `AdminAuth::requireLogin()`/`::isAuthenticated()` and still leak
|
||||||
|
completely to the public the moment it's viewed once by someone
|
||||||
|
authorized, if the page has no sidecar and nothing tells `Renderer` to
|
||||||
|
skip the cache write for that route. `draft_routes` (see
|
||||||
|
`/admin/docs/drafts`, `novaconium/config.php`) and every `/admin/*` route
|
||||||
|
both pass `true` for `Renderer::render()`'s `$excludeFromCache` param
|
||||||
|
from `novaconium/bootstrap.php` for exactly this reason — most pages
|
||||||
|
under `novaconium/pages/admin/` (e.g. `admin/index.twig`) have no
|
||||||
|
sidecar, so before this was wired up, visiting `/admin` once as an
|
||||||
|
authenticated admin would cache the admin panel and serve it to every
|
||||||
|
subsequent visitor, unauthenticated, straight from `public/cache/admin/`.
|
||||||
|
Any future feature that gates a route by anything other than a sidecar
|
||||||
|
check (paywall content is the next one on the roadmap likely to hit this)
|
||||||
|
needs to make the same check here, not just at the point where the
|
||||||
|
request is first authorized.
|
||||||
|
|
||||||
|
`AdminAuth::isAuthenticated(string $username, string $passwordHash): bool`
|
||||||
|
(`novaconium/src/AdminAuth.php`) is the credential check on its own, with
|
||||||
|
no response side effects, extracted out of `requireLogin()` (which still
|
||||||
|
does the same check, then issues the `401` challenge on failure) so a
|
||||||
|
different caller can react to failure differently. The draft-page gate in
|
||||||
|
`bootstrap.php` is the first such caller: on failure it renders a plain
|
||||||
|
404 via the same path an unmatched route takes, not a login prompt —
|
||||||
|
prompting for credentials at a draft URL would itself reveal that
|
||||||
|
something is gated there, which defeats the point of hiding it. Returns
|
||||||
|
`true` (open access) when `$passwordHash` is empty, mirroring
|
||||||
|
`requireLogin()`'s existing no-op-when-unset posture, so a draft behaves
|
||||||
|
consistently with the rest of `/admin/*`: wide open until a password is
|
||||||
|
configured, gated once one is.
|
||||||
|
|
||||||
|
`App\ContentIndexer` (`novaconium/src/ContentIndexer.php`) is the shared
|
||||||
|
crawler behind `/sitemap.xml`, `/search`, and blog tag browsing (see
|
||||||
|
`/admin/docs/content-index`) — `App\`, not `Lib\`, since it's rendering
|
||||||
|
infrastructure akin to `Renderer`/`Router`, not project-overridable
|
||||||
|
content. Content stays in files; only metadata is indexed. Per-page
|
||||||
|
metadata is four Twig blocks declared in the root layout next to the SEO
|
||||||
|
blocks (`keywords`, `tags`, `changefreq`, `priority` — the last three
|
||||||
|
never rendered into the page, only harvested) and pulled via
|
||||||
|
`Renderer::renderForIndex()`, which calls Twig's own
|
||||||
|
`TemplateWrapper::renderBlock()` per block rather than parsing `.twig`
|
||||||
|
source — this is deliberate: it gets App-over-novaconium override and
|
||||||
|
layout-inheritance resolution for free, the same way a real render does.
|
||||||
|
**`config['content_index_enabled']` defaults to `false`** — same posture
|
||||||
|
as `matomo_url`/`admin_password_hash`, since this is a real SQLite
|
||||||
|
dependency plenty of sites won't want. Every consumer route checks the
|
||||||
|
flag *before* touching `Lib\Db` and 404s if it's off, so the feature has
|
||||||
|
zero filesystem footprint (no `data/novaconium.sqlite`) when disabled —
|
||||||
|
verified end-to-end, not assumed, since "off" silently still creating a
|
||||||
|
database file would defeat the point.
|
||||||
|
|
||||||
|
**Reentrancy hazard, already hit once:** `ContentIndexer::reindex()`
|
||||||
|
renders every routable page as part of the crawl — including `/search`
|
||||||
|
itself, which is also a real page and also calls
|
||||||
|
`ContentIndexer::ensureFresh()` from its own sidecar. Without a guard,
|
||||||
|
crawling `/search` would trigger a nested `reindex()` call mid-transaction
|
||||||
|
and fatal on a second `PDO::beginTransaction()`. `ContentIndexer` guards
|
||||||
|
this with a `private static bool $indexing` flag, checked at the top of
|
||||||
|
both `ensureFresh()` and `reindex()` — either no-ops while a reindex is
|
||||||
|
already running on the call stack. Any future consumer route added under
|
||||||
|
this mechanism inherits the same hazard for free (it'll also get crawled,
|
||||||
|
and if its sidecar also calls `ensureFresh()`, the guard already covers
|
||||||
|
it) — don't remove the flag thinking it's unnecessary.
|
||||||
|
|
||||||
|
`reindex()` also forces `$_SERVER['REQUEST_METHOD']` to `'GET'` for the
|
||||||
|
duration of the crawl (restoring whatever it was before, in a `finally`)
|
||||||
|
— sidecars are expected to be side-effect-free for non-POST requests
|
||||||
|
anyway (ordinary HTTP-safe-method hygiene), but this guarantees a lazy
|
||||||
|
reindex triggered from within a POST request can never leak that POST
|
||||||
|
into an unrelated page's sidecar purely because the crawler happened to
|
||||||
|
render it. A crawl is a full truncate-and-rebuild inside one transaction,
|
||||||
|
not incremental — simple and correct at this site's scale; don't add
|
||||||
|
incremental/diffing logic without a real need for it.
|
||||||
|
|
||||||
|
**Standing rule: a vendored dependency's files go under `novaconium/vendor/`
|
||||||
|
only if they're server-side (PHP, autoloaded, never fetched by a browser)
|
||||||
|
— anything the browser has to fetch (`.js`, `.css`, images) has to live
|
||||||
|
under `public/vendor/` instead, since `public/` is the only web-reachable
|
||||||
|
directory (`novaconium/` isn't reachable at all — see `public/.htaccess`).**
|
||||||
|
Twig lives under `novaconium/vendor/twig/` correctly, since it's pure PHP
|
||||||
|
source. highlight.js (`/admin/docs/upgrading-highlightjs`,
|
||||||
|
`public/vendor/highlightjs/`) is the first vendored dependency that's
|
||||||
|
actually browser-servable, and originally almost got vendored to
|
||||||
|
`novaconium/vendor/` too, following Twig's precedent blindly — that would
|
||||||
|
have silently 404ed on every request, since nothing under `novaconium/`
|
||||||
|
is ever served to a browser. This has a real consequence beyond just
|
||||||
|
placement: `public/` is project-owned and untouched by the "Updating the
|
||||||
|
framework" workflow (`rm -rf novaconium && cp -r <new-novaconium>` — see
|
||||||
|
`/admin/docs/getting-started`), so a future framework release that bumps
|
||||||
|
a `public/vendor/`-placed dependency will **not** carry that upgrade to
|
||||||
|
an existing project automatically the way a `novaconium/vendor/` bump
|
||||||
|
would — re-vendoring it is a separate manual step every time, documented
|
||||||
|
per-dependency (see `/admin/docs/upgrading-highlightjs`).
|
||||||
|
|
||||||
|
**`class="nohighlight"` marks a `<pre><code>` block whose content is
|
||||||
|
literal Twig template syntax** (`{% %}`/`{{ }}`), so highlight.js's
|
||||||
|
auto-detection (`novaconium/pages/_layout/syntax-highlight.twig`,
|
||||||
|
restricted to `configure({ languages: ['php', 'bash', 'xml', 'css',
|
||||||
|
'python', 'javascript', 'yaml', 'json', 'ini'] })` — `yaml`/`json`/`ini`
|
||||||
|
are vendored as separate per-language files under
|
||||||
|
`public/vendor/highlightjs/languages/`, not part of the core bundle like
|
||||||
|
the other six; see `/admin/docs/upgrading-highlightjs`) doesn't
|
||||||
|
force-match it to whichever configured language scores highest — Twig has
|
||||||
|
no highlight.js grammar, and a restricted auto-detect still always
|
||||||
|
returns its best guess among the allowed set, never "give up," so an
|
||||||
|
unmarked Twig block would get colored *wrong*, not just left plain.
|
||||||
|
Currently on:
|
||||||
|
`novaconium/pages/admin/docs/{layouts,content-index,rss,sitemap,forms,seo}/index.twig`
|
||||||
|
and `App/pages/blog/{style-guide,twig-syntax-guide}/index.twig`. A new
|
||||||
|
Twig-syntax code sample added anywhere needs the same class — a PHP or
|
||||||
|
Bash sample doesn't (auto-detection handles those reliably on its own,
|
||||||
|
via strong signals like a leading `<?php`).
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -238,3 +460,16 @@ site behavior for the next person.
|
|||||||
for the full story). Truncate strings in PHP instead, guarded with
|
for the full story). Truncate strings in PHP instead, guarded with
|
||||||
`function_exists('mb_substr')` falling back to `substr()`, and pass the
|
`function_exists('mb_substr')` falling back to `substr()`, and pass the
|
||||||
already-truncated value into the template.
|
already-truncated value into the template.
|
||||||
|
- Same class of bug, different filter: don't use Twig's `|escape('js')` (or
|
||||||
|
the `'js'` arg to `|e`) either — it calls `Twig\Runtime\mb_ord()`
|
||||||
|
(`novaconium/vendor/twig/src/Extension/EscaperExtension.php`), which
|
||||||
|
hard-requires `mbstring` the same way `|slice` does, and fatals
|
||||||
|
identically without it. Hit for real when
|
||||||
|
`novaconium/pages/_layout/code-copy.twig` used it to pass SVG icon
|
||||||
|
markup into an inline `<script>` as a JS string literal. Fixed by not
|
||||||
|
needing string-escaped markup in JS at all: render the markup as plain
|
||||||
|
HTML into a `<template>` element (default autoescaping, no mbstring
|
||||||
|
dependency) and read it in JS via that template element's `.innerHTML`
|
||||||
|
getter instead. Prefer that pattern — or a `data-*` attribute if the
|
||||||
|
value is plain text, not markup — over `|escape('js')` any time a Twig
|
||||||
|
value needs to reach JS.
|
||||||
|
|||||||
@@ -20,4 +20,35 @@ return [
|
|||||||
// or use the built-in /admin/password-hash form.
|
// or use the built-in /admin/password-hash form.
|
||||||
// 'admin_username' => 'admin',
|
// 'admin_username' => 'admin',
|
||||||
// 'admin_password_hash' => '$2y$10$...',
|
// 'admin_password_hash' => '$2y$10$...',
|
||||||
|
|
||||||
|
// Docs: /admin/docs/database — adds (or overrides) named Lib\Db
|
||||||
|
// connections. This merges into db_connections by name rather than
|
||||||
|
// replacing the whole map, so adding 'legacy' here doesn't require
|
||||||
|
// repeating 'default' — see Lib\Db::config().
|
||||||
|
// 'db_connections' => [
|
||||||
|
// 'legacy' => [
|
||||||
|
// 'driver' => 'mysql',
|
||||||
|
// 'host' => 'localhost',
|
||||||
|
// 'port' => 3306,
|
||||||
|
// 'database' => 'legacy_app',
|
||||||
|
// 'username' => 'root',
|
||||||
|
// 'password' => '...',
|
||||||
|
// 'charset' => 'utf8mb4', // optional, defaults to utf8mb4
|
||||||
|
// 'migrations_dir' => __DIR__ . '/migrations/legacy', // optional
|
||||||
|
// ],
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// Docs: /admin/docs/drafts — requires admin_password_hash above to be
|
||||||
|
// set to actually gate anything; open access otherwise, same as the
|
||||||
|
// rest of /admin/*.
|
||||||
|
// 'draft_routes' => ['blog/upcoming-post'],
|
||||||
|
|
||||||
|
// Docs: /admin/docs/content-index — powers /sitemap.xml, /search, and
|
||||||
|
// blog tag browsing. Off by default (depends on SQLite); enable with:
|
||||||
|
// 'content_index_enabled' => true,
|
||||||
|
//
|
||||||
|
// Or enable but skip the automatic lazy reindex, relying only on
|
||||||
|
// `php novaconium/bin/index-content.php` (e.g. from a deploy step):
|
||||||
|
// 'content_index_enabled' => true,
|
||||||
|
// 'content_index_auto' => false,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
{% import '_layout/icons.twig' as icons %}
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{# Feed auto-discovery — only shows up on /blog/* pages, since only this
|
||||||
|
layout overrides the root layout's empty head_extra block. See
|
||||||
|
App/pages/blog/feed/index.php. #}
|
||||||
|
{% block head_extra %}
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="blog-layout">
|
<div class="blog-layout">
|
||||||
<aside>
|
<aside>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Code Highlighting{% endblock %}
|
||||||
|
{% block description %}How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}highlighting, reference{% endblock %}
|
||||||
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block og_type %}article{% endblock %}
|
||||||
|
{% block og_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block og_description %}{{ block('description') }}{% endblock %}
|
||||||
|
{% block og_url %}{{ block('canonical') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block twitter_card %}summary{% endblock %}
|
||||||
|
{% block twitter_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block twitter_description %}{{ block('description') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block blog_content %}
|
||||||
|
<h1>Code Highlighting</h1>
|
||||||
|
|
||||||
|
<p>Every <code><pre><code></code> block on this site gets colored automatically via vendored <a href="https://highlightjs.org/">highlight.js</a> — see <a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a> for the mechanism and <a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> for what's vendored. This post is a plain reference: how to write a code block, and one worked example in each language this site highlights.</p>
|
||||||
|
|
||||||
|
<h2>How to use it</h2>
|
||||||
|
|
||||||
|
<p>Write a normal code block — nothing extra required, the language is auto-detected:</p>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight"><pre><code>your code here</code></pre></code></pre>
|
||||||
|
|
||||||
|
<p>Auto-detection is restricted to the languages this site actually uses (see <code>hljs.configure(...)</code> in <code>novaconium/pages/_layout/syntax-highlight.twig</code>) so it doesn't misfire trying to match dozens of unrelated bundled languages against a short snippet. If a snippet is ambiguous, or ever gets detected as the wrong language, force it with an explicit <code>language-<name></code> class instead of relying on auto-detection:</p>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight"><pre><code class="language-yaml">your code here</code></pre></code></pre>
|
||||||
|
|
||||||
|
<p>A code block written in Twig template syntax (<code>{{ '{%' }} ... {{ '%}' }}</code>, <code>{{ '{{' }} ... {{ '}}' }}</code>) has no highlight.js grammar to match — mark those <code>class="nohighlight"</code> instead, the same way the two snippets above are marked (they're showing literal HTML as plain text, not being highlighted as HTML themselves). See <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a> for Twig's own syntax, written the same way.</p>
|
||||||
|
|
||||||
|
<h2>Bash</h2>
|
||||||
|
<pre><code>#!/bin/bash
|
||||||
|
for f in App/pages/blog/*/index.twig; do
|
||||||
|
echo "Post: $f"
|
||||||
|
done</code></pre>
|
||||||
|
|
||||||
|
<h2>HTML</h2>
|
||||||
|
<pre><code><article class="post">
|
||||||
|
<h1>Hello, World!</h1>
|
||||||
|
<p>A short excerpt.</p>
|
||||||
|
</article></code></pre>
|
||||||
|
|
||||||
|
<h2>CSS</h2>
|
||||||
|
<pre><code>.post-list li {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h2>YAML</h2>
|
||||||
|
<pre><code>site:
|
||||||
|
name: Novaconium Website
|
||||||
|
theme: dark
|
||||||
|
tags:
|
||||||
|
- php
|
||||||
|
- twig
|
||||||
|
- highlighting</code></pre>
|
||||||
|
|
||||||
|
<h2>Python</h2>
|
||||||
|
<pre><code>def excerpt(text, length=140):
|
||||||
|
return text[:length].rsplit(" ", 1)[0] + "..."</code></pre>
|
||||||
|
|
||||||
|
<h2>JavaScript</h2>
|
||||||
|
<pre><code>function toggleTheme() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const next = html.dataset.theme === "light" ? "dark" : "light";
|
||||||
|
html.setAttribute("data-theme", next);
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h2>JSON</h2>
|
||||||
|
<pre><code>{
|
||||||
|
"title": "Code Highlighting",
|
||||||
|
"tags": ["highlighting", "reference"],
|
||||||
|
"published": true
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h2>INI / .env</h2>
|
||||||
|
<pre><code>; App/config.php equivalent, .ini-style
|
||||||
|
[matomo]
|
||||||
|
url = https://matomo.example.com/
|
||||||
|
site_id = 1</code></pre>
|
||||||
|
|
||||||
|
<p>YAML, JSON, and INI aren't part of highlight.js's core bundle the way PHP/Bash/CSS/Python/JavaScript/XML are — they're vendored as three separate files under <code>public/vendor/highlightjs/languages/</code>, loaded after the core bundle. See <a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> for how to add another language the same way.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// /blog/feed — sidecar-only (no index.twig), like sitemap.xml/search: no
|
||||||
|
// point rendering Twig just to return XML. Project-owned (App/pages/),
|
||||||
|
// since this is blog content specifically, not generic framework
|
||||||
|
// machinery like sitemap.xml/search are. Deliberately has zero dependency
|
||||||
|
// on the (off-by-default) content index — it reads the exact same
|
||||||
|
// hand-written $posts array App/pages/blog/index.php itself renders from,
|
||||||
|
// so this feed works on a bare install with content_index_enabled left at
|
||||||
|
// its shipped default of false. Only the per-tag variant
|
||||||
|
// (App/pages/blog/tag/[tag]/feed/index.php) needs the content index, since
|
||||||
|
// tags only exist there.
|
||||||
|
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Rss;
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../../../../novaconium/config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = (require __DIR__ . '/../index.php')['posts'];
|
||||||
|
|
||||||
|
// Newest first, the RSS convention — the array itself (and therefore the
|
||||||
|
// /blog listing page, which isn't touched here) keeps its own order;
|
||||||
|
// sorting only affects this feed's output.
|
||||||
|
usort($posts, fn (array $a, array $b) => strcmp($b['published'], $a['published']));
|
||||||
|
|
||||||
|
$items = array_map(
|
||||||
|
fn (array $post) => [
|
||||||
|
'title' => $post['title'],
|
||||||
|
'link' => '/blog/' . $post['slug'],
|
||||||
|
'guid' => '/blog/' . $post['slug'],
|
||||||
|
'pubDateTimestamp' => strtotime($post['published']),
|
||||||
|
'description' => $post['excerpt'],
|
||||||
|
],
|
||||||
|
$posts
|
||||||
|
);
|
||||||
|
|
||||||
|
$xml = Rss::render(
|
||||||
|
$config['site_name'] . ' Blog',
|
||||||
|
'/blog',
|
||||||
|
'Posts from ' . $config['site_name'] . '.',
|
||||||
|
$items
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response::xml($xml);
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block description %}The first post on this blog — a plain sidecar-less page, like every other post here now.{% endblock %}
|
{% block description %}The first post on this blog — a plain sidecar-less page, like every other post here now.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}welcome, meta{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}article{% endblock %}
|
{% block og_type %}article{% endblock %}
|
||||||
|
|||||||
@@ -4,27 +4,41 @@
|
|||||||
// with its own index.twig — none of them are driven by a repository or
|
// with its own index.twig — none of them are driven by a repository or
|
||||||
// database, so this listing is just a hand-maintained array pointing at
|
// database, so this listing is just a hand-maintained array pointing at
|
||||||
// each one. Add a new entry here whenever a new post directory is added.
|
// each one. Add a new entry here whenever a new post directory is added.
|
||||||
|
// 'published' (YYYY-MM-DD) is used by App/pages/blog/feed/index.php to
|
||||||
|
// order and date entries in the RSS feed — illustrative dates here, not
|
||||||
|
// derived from real history (this repo's posts all arrived in one batch
|
||||||
|
// import, so there's no authentic per-post date to pull from).
|
||||||
return [
|
return [
|
||||||
'posts' => [
|
'posts' => [
|
||||||
[
|
[
|
||||||
'slug' => 'hello-world',
|
'slug' => 'hello-world',
|
||||||
'title' => 'Hello, World!',
|
'title' => 'Hello, World!',
|
||||||
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.',
|
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.',
|
||||||
|
'published' => '2026-07-11',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'second-post',
|
'slug' => 'second-post',
|
||||||
'title' => 'A Second Post',
|
'title' => 'A Second Post',
|
||||||
'excerpt' => 'A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.',
|
'excerpt' => 'A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.',
|
||||||
|
'published' => '2026-07-11',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'twig-syntax-guide',
|
'slug' => 'twig-syntax-guide',
|
||||||
'title' => 'Twig Syntax Guide',
|
'title' => 'Twig Syntax Guide',
|
||||||
'excerpt' => 'A tour of the Twig syntax used throughout this site — output, filters, control structures, template inheritance, and a few gotchas worth knowing.',
|
'excerpt' => 'A tour of the Twig syntax used throughout this site — output, filters, control structures, template inheritance, and a few gotchas worth knowing.',
|
||||||
|
'published' => '2026-07-13',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'style-guide',
|
'slug' => 'style-guide',
|
||||||
'title' => 'Style Guide',
|
'title' => 'Style Guide',
|
||||||
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
|
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
|
||||||
|
'published' => '2026-07-13',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'code-highlighting',
|
||||||
|
'title' => 'Code Highlighting',
|
||||||
|
'excerpt' => 'How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.',
|
||||||
|
'published' => '2026-07-14',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block description %}A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.{% endblock %}
|
{% block description %}A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}meta{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}article{% endblock %}
|
{% block og_type %}article{% endblock %}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block description %}A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.{% endblock %}
|
{% block description %}A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}css, reference{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}article{% endblock %}
|
{% block og_type %}article{% endblock %}
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
|
|
||||||
<h2>Code</h2>
|
<h2>Code</h2>
|
||||||
<p>Inline: <code>(new Mailer())->send($old['name'], $old['email'], $old['message']);</code></p>
|
<p>Inline: <code>(new Mailer())->send($old['name'], $old['email'], $old['message']);</code></p>
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// blog/tag/[tag]/feed/ — same [param] capture as blog/tag/[tag]/index.php
|
||||||
|
// one level up ($params['tag'] is already populated by the time Router
|
||||||
|
// resolves this deeper path — see that file's comments for how the
|
||||||
|
// capture works). Project-owned, mirrors blog/tag/[tag]/index.php's
|
||||||
|
// query almost exactly, just rendered as RSS instead of an HTML list.
|
||||||
|
|
||||||
|
use App\ContentIndexer;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Rss;
|
||||||
|
|
||||||
|
// 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__ . '/../../../../../../novaconium/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. Unlike App/pages/blog/feed/ (the main feed,
|
||||||
|
// which has zero content-index dependency), this per-tag feed reads
|
||||||
|
// content_tags/content_pages directly, so it 404s the same way
|
||||||
|
// blog/tag/[tag]/index.php does when the index is off, and never
|
||||||
|
// constructs a Lib\Db connection in that case.
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentIndexer::ensureFresh();
|
||||||
|
|
||||||
|
$tag = $params['tag'];
|
||||||
|
|
||||||
|
// Same query as blog/tag/[tag]/index.php, plus source_mtime — used below
|
||||||
|
// as pubDate. This is the page's own source-file mtime, not a true
|
||||||
|
// "published" date (the content index has no separate published concept
|
||||||
|
// the way the hand-written main feed's $posts array does) — an honest
|
||||||
|
// stand-in, not presented as more precise than it is.
|
||||||
|
$posts = Db::query(
|
||||||
|
'SELECT content_pages.route, content_pages.title, content_pages.description, content_pages.source_mtime ' .
|
||||||
|
'FROM content_tags ' .
|
||||||
|
'JOIN content_pages ON content_pages.route = content_tags.route ' .
|
||||||
|
'WHERE content_tags.tag = ? ' .
|
||||||
|
"AND content_pages.route LIKE '/blog/%' " .
|
||||||
|
'ORDER BY content_pages.source_mtime DESC',
|
||||||
|
[$tag]
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$items = array_map(
|
||||||
|
fn (array $post) => [
|
||||||
|
'title' => $post['title'],
|
||||||
|
'link' => $post['route'],
|
||||||
|
'guid' => $post['route'],
|
||||||
|
'pubDateTimestamp' => (int) $post['source_mtime'],
|
||||||
|
'description' => $post['description'],
|
||||||
|
],
|
||||||
|
$posts
|
||||||
|
);
|
||||||
|
|
||||||
|
$xml = Rss::render(
|
||||||
|
$config['site_name'] . ' Blog — tagged "' . $tag . '"',
|
||||||
|
'/blog/tag/' . $tag,
|
||||||
|
'Posts tagged "' . $tag . '" from ' . $config['site_name'] . '.',
|
||||||
|
$items
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response::xml($xml);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// blog/tag/[tag]/ — the [param] segment captures anything after
|
||||||
|
// /blog/tag/ into $params['tag'] (see /admin/docs/routing). This page is
|
||||||
|
// project-owned (unlike sitemap.xml/search, which are framework defaults
|
||||||
|
// under novaconium/pages/) because blog/ itself is project content, not
|
||||||
|
// framework machinery.
|
||||||
|
|
||||||
|
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__ . '/../../../../../novaconium/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). Also
|
||||||
|
// guards against reentrancy: ContentIndexer's own crawl renders every
|
||||||
|
// page, but it never renders *this* route, since blog/tag/[tag] is a
|
||||||
|
// wildcard directory and Overlay::listPageDirs() skips [param]-wildcard
|
||||||
|
// dirs entirely (concrete tag values aren't knowable without a data
|
||||||
|
// source — see ContentIndexer's docblock).
|
||||||
|
ContentIndexer::ensureFresh();
|
||||||
|
|
||||||
|
$tag = $params['tag'];
|
||||||
|
|
||||||
|
// content_tags is a derived index built from each post's own
|
||||||
|
// {% block tags %} (see /admin/docs/content-index) — it's populated
|
||||||
|
// entirely by ContentIndexer::reindex(), never written to directly here.
|
||||||
|
// The route LIKE '/blog/%' filter matters because content_tags isn't
|
||||||
|
// blog-specific — any page anywhere on the site can declare tags, so this
|
||||||
|
// scopes results to blog posts only, the same way App/pages/blog/index.php
|
||||||
|
// itself only ever lists blog posts.
|
||||||
|
$posts = Db::query(
|
||||||
|
'SELECT content_pages.route, content_pages.title, content_pages.description ' .
|
||||||
|
'FROM content_tags ' .
|
||||||
|
'JOIN content_pages ON content_pages.route = content_tags.route ' .
|
||||||
|
'WHERE content_tags.tag = ? ' .
|
||||||
|
"AND content_pages.route LIKE '/blog/%' " .
|
||||||
|
'ORDER BY content_pages.route',
|
||||||
|
[$tag]
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// $posts is [] (not an error) when nothing matches — the twig template
|
||||||
|
// renders a plain "no posts tagged ..." message for that case, same as
|
||||||
|
// /search does for a query with no results.
|
||||||
|
return [
|
||||||
|
'tag' => $tag,
|
||||||
|
'posts' => $posts,
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Posts tagged “{{ tag }}”{% endblock %}
|
||||||
|
{% block description %}Blog posts tagged {{ tag }}.{% endblock %}
|
||||||
|
{% block robots %}noindex, follow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.tag() }}Posts tagged “{{ tag }}”</h1>
|
||||||
|
|
||||||
|
{% if posts|length > 0 %}
|
||||||
|
<ul class="post-list">
|
||||||
|
{% for post in posts %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ post.route }}">{{ post.title }}</a>
|
||||||
|
{% if post.description %}<p>{{ post.description }}</p>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No posts tagged “{{ tag }}”.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block description %}A tour of the Twig syntax used throughout this site — output, filters, control structures, inheritance, and a few gotchas.{% endblock %}
|
{% block description %}A tour of the Twig syntax used throughout this site — output, filters, control structures, inheritance, and a few gotchas.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}twig, reference{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}article{% endblock %}
|
{% block og_type %}article{% endblock %}
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
|
|
||||||
<h2>Output & variables</h2>
|
<h2>Output & variables</h2>
|
||||||
<p>Twig prints an expression with <code>{{ '{{ ... }}' }}</code>. Sidecar data, route params, and a handful of framework-provided variables (<code>request_path</code>, <code>layout</code>, <code>site_name</code>) are all just variables in scope:</p>
|
<p>Twig prints an expression with <code>{{ '{{ ... }}' }}</code>. Sidecar data, route params, and a handful of framework-provided variables (<code>request_path</code>, <code>layout</code>, <code>site_name</code>) are all just variables in scope:</p>
|
||||||
<pre><code>{% verbatim %}{{ title }}
|
<pre><code class="nohighlight">{% verbatim %}{{ title }}
|
||||||
{{ params.slug }}
|
{{ params.slug }}
|
||||||
{{ post.title }}{% endverbatim %}</code></pre>
|
{{ post.title }}{% endverbatim %}</code></pre>
|
||||||
<p>Dot notation (<code>post.title</code>) works whether <code>post</code> is an array key or an object property — Twig tries both, so templates don't need to care which.</p>
|
<p>Dot notation (<code>post.title</code>) works whether <code>post</code> is an array key or an object property — Twig tries both, so templates don't need to care which.</p>
|
||||||
@@ -45,29 +46,29 @@
|
|||||||
|
|
||||||
<h2>Control structures</h2>
|
<h2>Control structures</h2>
|
||||||
<p>The two workhorses are <code>{% verbatim %}{% if %}{% endverbatim %}</code> and <code>{% verbatim %}{% for %}{% endverbatim %}</code>:</p>
|
<p>The two workhorses are <code>{% verbatim %}{% if %}{% endverbatim %}</code> and <code>{% verbatim %}{% for %}{% endverbatim %}</code>:</p>
|
||||||
<pre><code>{% verbatim %}{% if sent %}
|
<pre><code class="nohighlight">{% verbatim %}{% if sent %}
|
||||||
<p>Thanks — your message has been sent.</p>
|
<p>Thanks — your message has been sent.</p>
|
||||||
{% endif %}{% endverbatim %}</code></pre>
|
{% endif %}{% endverbatim %}</code></pre>
|
||||||
<pre><code>{% verbatim %}{% for post in posts %}
|
<pre><code class="nohighlight">{% verbatim %}{% for post in posts %}
|
||||||
<li><a href="/blog/{{ post.slug }}">{{ post.title }}</a></li>
|
<li><a href="/blog/{{ post.slug }}">{{ post.title }}</a></li>
|
||||||
{% endfor %}{% endverbatim %}</code></pre>
|
{% endfor %}{% endverbatim %}</code></pre>
|
||||||
<p>This exact loop is what renders the <a href="/blog">blog listing page</a> you probably followed a link from to get here.</p>
|
<p>This exact loop is what renders the <a href="/blog">blog listing page</a> you probably followed a link from to get here.</p>
|
||||||
|
|
||||||
<h2>Comments</h2>
|
<h2>Comments</h2>
|
||||||
<p>Anything between <code>{% verbatim %}{# and #}{% endverbatim %}</code> is stripped entirely from the output — unlike an HTML comment, it never reaches the browser:</p>
|
<p>Anything between <code>{% verbatim %}{# and #}{% endverbatim %}</code> is stripped entirely from the output — unlike an HTML comment, it never reaches the browser:</p>
|
||||||
<pre><code>{% verbatim %}{# Open Graph / Facebook #}{% endverbatim %}</code></pre>
|
<pre><code class="nohighlight">{% verbatim %}{# Open Graph / Facebook #}{% endverbatim %}</code></pre>
|
||||||
<p>That one's real — it's the comment sitting above the Open Graph block in <code>novaconium/pages/_layout/layout.twig</code>.</p>
|
<p>That one's real — it's the comment sitting above the Open Graph block in <code>novaconium/pages/_layout/layout.twig</code>.</p>
|
||||||
|
|
||||||
<h2>Template inheritance & includes</h2>
|
<h2>Template inheritance & includes</h2>
|
||||||
<p><code>{% verbatim %}{% extends %}{% endverbatim %}</code> is how every page on this site gets its <code><html></code>/<code><head></code>/nav/footer for free — a child template only fills in named <code>{% verbatim %}{% block %}{% endverbatim %}</code> slots the parent declares:</p>
|
<p><code>{% verbatim %}{% extends %}{% endverbatim %}</code> is how every page on this site gets its <code><html></code>/<code><head></code>/nav/footer for free — a child template only fills in named <code>{% verbatim %}{% block %}{% endverbatim %}</code> slots the parent declares:</p>
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block title %}Blog{% endblock %}
|
{% block title %}Blog{% endblock %}
|
||||||
{% block blog_content %}
|
{% block blog_content %}
|
||||||
...
|
...
|
||||||
{% endblock %}{% endverbatim %}</code></pre>
|
{% endblock %}{% endverbatim %}</code></pre>
|
||||||
<p><code>{% verbatim %}{% include %}{% endverbatim %}</code> pulls in a whole template inline (used for <code>_layout/nav.twig</code> and <code>_layout/matomo.twig</code>), while <code>{% verbatim %}{% import %}{% endverbatim %}</code> pulls in reusable <strong>macros</strong> — parameterized snippets like the icons used throughout this page:</p>
|
<p><code>{% verbatim %}{% include %}{% endverbatim %}</code> pulls in a whole template inline (used for <code>_layout/nav.twig</code> and <code>_layout/matomo.twig</code>), while <code>{% verbatim %}{% import %}{% endverbatim %}</code> pulls in reusable <strong>macros</strong> — parameterized snippets like the icons used throughout this page:</p>
|
||||||
<pre><code>{% verbatim %}{% import '_layout/icons.twig' as icons %}
|
<pre><code class="nohighlight">{% verbatim %}{% import '_layout/icons.twig' as icons %}
|
||||||
{{ icons.book() }}{% endverbatim %}</code></pre>
|
{{ icons.book() }}{% endverbatim %}</code></pre>
|
||||||
<p>See <a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> for how <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution walks the <code>App/</code>-over-<code>novaconium/</code> override chain.</p>
|
<p>See <a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> for how <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution walks the <code>App/</code>-over-<code>novaconium/</code> override chain.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,20 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages
|
|||||||
- **SEO boilerplate out of the box** — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.
|
- **SEO boilerplate out of the box** — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.
|
||||||
- **Built-in Matomo analytics** — set `matomo_url` and `matomo_site_id` in `App/config.php` to enable tracking site-wide, including automatic 404 tracking. Off by default.
|
- **Built-in Matomo analytics** — set `matomo_url` and `matomo_site_id` in `App/config.php` to enable tracking site-wide, including automatic 404 tracking. Off by default.
|
||||||
- **Admin authentication** — gate every `/admin/*` route behind HTTP Basic Auth by setting `admin_username`/`admin_password_hash` in `App/config.php`; reusable for any admin page a project adds later, with a `/admin/logout` link to clear cached credentials and a built-in `/admin/password-hash` form so generating the hash doesn't require the CLI. Off by default.
|
- **Admin authentication** — gate every `/admin/*` route behind HTTP Basic Auth by setting `admin_username`/`admin_password_hash` in `App/config.php`; reusable for any admin page a project adds later, with a `/admin/logout` link to clear cached credentials and a built-in `/admin/password-hash` form so generating the hash doesn't require the CLI. Off by default.
|
||||||
|
- **Draft pages** — list a route under `draft_routes` in `App/config.php` to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt. Reuses the admin auth check directly, and is excluded from static caching so a cached copy can't leak the draft to the public. See `/admin/docs/drafts`.
|
||||||
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
|
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
|
||||||
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
|
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
|
||||||
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
|
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
|
||||||
|
- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
|
||||||
|
- **Sessions with flash data** — `Lib\Session`, a thin wrapper around native PHP sessions with CodeIgniter-style flash values (set now, readable on exactly the next request) for post/redirect/GET flows without a query-string flag. Lazy-start, same mechanism `Lib\Csrf` already uses. See `/admin/docs/session`.
|
||||||
|
- **Content index: sitemap, search, tags** — `/sitemap.xml`, full-text `/search` (SQLite FTS5), and blog tag browsing all share one crawler that renders every page and harvests `keywords`/`tags`/`changefreq`/`priority` Twig blocks via Twig's own `renderBlock()` — no front-matter, no separate metadata files. Off by default (depends on SQLite); reindexes lazily on demand or via `php novaconium/bin/index-content.php`. See `/admin/docs/content-index`.
|
||||||
|
- **Blog RSS feed** — `/blog/feed`, built from the same hand-written post list `App/pages/blog/index.php` itself renders from, so it works with no database at all. `Lib\Rss` (a small RSS 2.0 envelope builder) also backs a per-tag feed, `/blog/tag/<tag>/feed`, once the content index above is enabled. Auto-discovered via a `<link rel="alternate">` on `/blog/*` pages.
|
||||||
|
- **Syntax-highlighted code blocks** — vendored [highlight.js](https://highlightjs.org/) colors PHP/Bash/HTML code blocks site-wide, auto-detected with no per-block markup, swapping between dark (`ir-black`) and light (`github`) themes along with the existing dark/light toggle. Twig-syntax samples (which highlight.js can't parse) are left plain rather than colored wrong. See `/admin/docs/upgrading-highlightjs`.
|
||||||
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
|
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`.
|
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) with the `pdo_sqlite` extension (bundled with PHP, just needs to be enabled — no separate install; add `pdo_mysql` too if using a MySQL connection), and, for production, Apache with `mod_rewrite` and `AllowOverride All`. The content index's search (`/admin/docs/content-index`) additionally needs SQLite's FTS5 extension, bundled with `pdo_sqlite` on virtually every modern PHP build — only relevant if `content_index_enabled` is turned on.
|
||||||
|
|
||||||
### Run it locally (no Apache needed)
|
### Run it locally (no Apache needed)
|
||||||
|
|
||||||
@@ -36,6 +42,30 @@ Visit `http://127.0.0.1:8000/` for the static home page, then click around — `
|
|||||||
|
|
||||||
Point the vhost's document root at `public/`, make sure `mod_rewrite` is enabled and `AllowOverride All` is set for that directory so `public/.htaccess` takes effect, and it just works — no build step required.
|
Point the vhost's document root at `public/`, make sure `mod_rewrite` is enabled and `AllowOverride All` is set for that directory so `public/.htaccess` takes effect, and it just works — no build step required.
|
||||||
|
|
||||||
|
### Starting a new project
|
||||||
|
|
||||||
|
Clone this repo and drop its Git history — no Composer scaffold or installer:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone --depth 1 <novaconium-repo-url> my-new-project
|
||||||
|
cd my-new-project
|
||||||
|
rm -rf .git && git init && git add -A && git commit -m "Initial commit from novaconium template"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace the example content under `App/pages/` with your own; leave `novaconium/` and `public/` alone.
|
||||||
|
|
||||||
|
### Updating the framework
|
||||||
|
|
||||||
|
Since the framework core lives entirely under `novaconium/`, pick up a new release by overwriting just that directory against a tag and committing the diff:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone --depth 1 --branch <release-tag> <novaconium-repo-url> /tmp/nova-update
|
||||||
|
rm -rf novaconium && cp -r /tmp/nova-update/novaconium ./novaconium && rm -rf /tmp/nova-update
|
||||||
|
git add novaconium && git commit -m "Update novaconium framework to <release-tag>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe by construction — `App/` always overrides `novaconium/`, so an update can't clobber project customizations. See [Getting started](http://127.0.0.1:8000/admin/docs/getting-started) for the full write-up.
|
||||||
|
|
||||||
### Add a page
|
### Add a page
|
||||||
|
|
||||||
Create a directory under `App/pages/` with an `index.twig` — the directory path *is* the URL:
|
Create a directory under `App/pages/` with an `index.twig` — the directory path *is* the URL:
|
||||||
@@ -52,17 +82,23 @@ php novaconium/bin/create-static-page.php blog/my-new-post
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The full framework documentation — routing, sidecars, libraries, layouts, static caching, SEO, Matomo analytics, admin authentication, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
|
The full framework documentation — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
|
||||||
|
|
||||||
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
|
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
|
||||||
- [Routing](http://127.0.0.1:8000/admin/docs/routing)
|
- [Routing](http://127.0.0.1:8000/admin/docs/routing)
|
||||||
- [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars)
|
- [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars)
|
||||||
- [Libraries](http://127.0.0.1:8000/admin/docs/libraries)
|
- [Libraries](http://127.0.0.1:8000/admin/docs/libraries)
|
||||||
|
- [Database](http://127.0.0.1:8000/admin/docs/database)
|
||||||
|
- [Session](http://127.0.0.1:8000/admin/docs/session)
|
||||||
|
- [Content index](http://127.0.0.1:8000/admin/docs/content-index)
|
||||||
|
- [XML sitemap](http://127.0.0.1:8000/admin/docs/sitemap)
|
||||||
|
- [RSS feeds](http://127.0.0.1:8000/admin/docs/rss)
|
||||||
- [Layouts](http://127.0.0.1:8000/admin/docs/layouts)
|
- [Layouts](http://127.0.0.1:8000/admin/docs/layouts)
|
||||||
- [Static caching](http://127.0.0.1:8000/admin/docs/caching)
|
- [Static caching](http://127.0.0.1:8000/admin/docs/caching)
|
||||||
- [SEO](http://127.0.0.1:8000/admin/docs/seo)
|
- [SEO](http://127.0.0.1:8000/admin/docs/seo)
|
||||||
- [Matomo](http://127.0.0.1:8000/admin/docs/matomo)
|
- [Matomo](http://127.0.0.1:8000/admin/docs/matomo)
|
||||||
- [Admin authentication](http://127.0.0.1:8000/admin/docs/admin-auth)
|
- [Admin authentication](http://127.0.0.1:8000/admin/docs/admin-auth)
|
||||||
|
- [Draft pages](http://127.0.0.1:8000/admin/docs/drafts)
|
||||||
- [Styling](http://127.0.0.1:8000/admin/docs/styling)
|
- [Styling](http://127.0.0.1:8000/admin/docs/styling)
|
||||||
- [Project layout](http://127.0.0.1:8000/admin/docs/project-layout)
|
- [Project layout](http://127.0.0.1:8000/admin/docs/project-layout)
|
||||||
- [Third-party](http://127.0.0.1:8000/admin/docs/third-party)
|
- [Third-party](http://127.0.0.1:8000/admin/docs/third-party)
|
||||||
|
|||||||
+432
-281
@@ -51,189 +51,33 @@ expected vs. actual behavior. For features, include the motivating use case.>
|
|||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
Suggested build order (foundations first, since admin login builds on two
|
Suggested build order (foundations first, since admin login builds on
|
||||||
of the others):
|
three of the others):
|
||||||
|
|
||||||
1. **SQLite groundwork** — no dependencies.
|
1. **Media/file manager** — no hard dependency; usable standalone, though
|
||||||
2. **MySQL support** — builds directly on SQLite groundwork's `Db`
|
|
||||||
abstraction; do right after so the abstraction is driver-agnostic from
|
|
||||||
the start rather than retrofitted.
|
|
||||||
3. **Session handling** — no dependencies; can be built in parallel with 1.
|
|
||||||
4. **Blog tags/categories** — no dependencies, but now needs a metadata
|
|
||||||
source design decision first (`PostRepository` was removed when
|
|
||||||
`hello-world`/`second-post` became plain Twig pages — see the entry
|
|
||||||
below), so worth doing first or together with Blog RSS feed.
|
|
||||||
5. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
|
|
||||||
once tags/categories exist.
|
|
||||||
6. **Internal search** — needs SQLite groundwork for storage.
|
|
||||||
7. **XML sitemap** — no hard dependency, but shares crawling logic with
|
|
||||||
Internal search, so easiest right after (or alongside) it.
|
|
||||||
8. **Media/file manager** — no hard dependency; usable standalone, though
|
|
||||||
best gated behind admin login once that exists.
|
best gated behind admin login once that exists.
|
||||||
9. **Draft pages (admin-only preview)** — no hard dependency; the admin
|
2. **Admin login & user management** — SQLite groundwork and session
|
||||||
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
|
handling it needed are both done; ready to build.
|
||||||
10. **Copy-to-clipboard button on code blocks** — no dependency; a
|
3. **In-house comments** — needs admin login & user management (comments
|
||||||
self-contained styling/JS feature, can be picked up any time.
|
are tied to real user accounts, not anonymous); build right after it.
|
||||||
11. **Syntax highlighting on code blocks** — no hard dependency on the
|
4. **Ecommerce functionality** — needs admin login & user management
|
||||||
copy button above, but touches the same `<pre><code>` markup, so
|
(order/product admin, and customer accounts); SQLite groundwork and
|
||||||
worth sequencing together to avoid two separate passes over every
|
session handling (cart) it needed are both done.
|
||||||
code block.
|
5. **Paywall functionality** — needs everything Ecommerce needs, plus
|
||||||
12. **Admin login & user management** — needs both SQLite groundwork (user
|
|
||||||
store) and session handling (logged-in state).
|
|
||||||
13. **Ecommerce functionality** — needs SQLite groundwork, session handling
|
|
||||||
(cart), and admin login & user management (order/product admin, and
|
|
||||||
customer accounts).
|
|
||||||
14. **Paywall functionality** — needs everything Ecommerce needs, plus
|
|
||||||
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
||||||
build after it rather than in parallel.
|
build after it rather than in parallel — also now has a concrete
|
||||||
|
precedent to follow for the "gated content must skip the static cache"
|
||||||
|
part of its design (see Draft pages (admin-only preview) in Done, and
|
||||||
|
the caching/auth standing rule in `AGENTS.md`), which was still an open
|
||||||
|
question when this entry was originally written.
|
||||||
|
|
||||||
|
MySQL support, Session handling (with flash sessions), Draft pages
|
||||||
|
(admin-only preview), Blog tags/categories + Internal search + XML
|
||||||
|
sitemap (shipped together as one content index — see Done), Blog RSS
|
||||||
|
feed, and Syntax highlighting on code blocks all shipped 2026-07-14.
|
||||||
|
|
||||||
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
||||||
|
|
||||||
### SQLite groundwork
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** High
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Lay the groundwork for optional SQLite storage (a `Db` or similar `Lib\`
|
|
||||||
wrapper around `PDO`/`sqlite3`, a data directory outside `public/`, a
|
|
||||||
migration/schema convention) so features that need persistence — 404
|
|
||||||
tracking and admin login below, and anything future — have a common place
|
|
||||||
to store data instead of ad hoc flat files. No ORM; stay consistent with
|
|
||||||
the project's no-Composer, no-build-step philosophy. Foundational — nothing
|
|
||||||
else here depends on this being skipped, but 404 tracking and admin login
|
|
||||||
both depend on it existing. Design the `Db` wrapper's interface with MySQL
|
|
||||||
support (below) in mind from the start — PDO already abstracts most of the
|
|
||||||
driver difference, so the schema/migration convention should avoid
|
|
||||||
SQLite-only syntax where a MySQL-compatible equivalent exists, to avoid a
|
|
||||||
retrofit.
|
|
||||||
|
|
||||||
### MySQL support
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** SQLite groundwork
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Let a project point the `Db` wrapper at MySQL instead of SQLite — via a
|
|
||||||
`db_driver` (or similar) `App/config.php` key plus connection settings
|
|
||||||
(host/user/password/database) — for projects that want a real MySQL
|
|
||||||
server rather than an embedded file, without maintaining two separate data
|
|
||||||
layers. PDO already supports both drivers under one API, so this should be
|
|
||||||
mostly a matter of: (1) not writing SQLite-only SQL in the groundwork
|
|
||||||
above, (2) a config-driven DSN builder, (3) a migration convention that
|
|
||||||
works on both (or per-driver migration files if syntax genuinely diverges).
|
|
||||||
No new persistence features depend on this — it's an alternate backend for
|
|
||||||
the same `Db` abstraction, not a separate feature surface.
|
|
||||||
|
|
||||||
### Session handling (with flash sessions)
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** High
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
A `Lib\`/framework-core wrapper around PHP's native session handling
|
|
||||||
(`session_start()` etc., not a custom session store) so sidecars and the
|
|
||||||
future admin login have a consistent way to read/write session data
|
|
||||||
instead of touching `$_SESSION` directly. Include CodeIgniter-style flash
|
|
||||||
data — a value set now that survives exactly one subsequent request (e.g.
|
|
||||||
`$session->flash('message', 'Saved.')` readable on the next request only,
|
|
||||||
via a set-now/expire-after-read scheme) — for post/redirect/GET flows like
|
|
||||||
`App/pages/contact/index.php` already does manually with `?sent=1`.
|
|
||||||
Foundational alongside SQLite groundwork — no dependencies of its own, but
|
|
||||||
admin login depends on it.
|
|
||||||
|
|
||||||
### Blog tags/categories
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Tag (or category) each blog post so posts can be browsed/filtered by
|
|
||||||
topic — e.g. `App/pages/blog/tag/[tag]/index.php` listing matching posts,
|
|
||||||
using the `[param]` route-capture mechanism (see `/admin/docs/routing`;
|
|
||||||
this project's own `App/pages/blog/` doesn't currently use `[param]` for
|
|
||||||
posts, every post is its own plain directory named after its slug).
|
|
||||||
`Lib\PostRepository` (which used to back `hello-world`/`second-post`) was
|
|
||||||
removed when those two posts became plain Twig pages, like
|
|
||||||
`twig-syntax-guide` and `style-guide` — so there's no shared metadata
|
|
||||||
store anymore. `App/pages/blog/index.php` now just hand-lists each
|
|
||||||
post's slug/title/excerpt in a plain array; adding tags means adding a
|
|
||||||
`tags` field to each entry there and a lookup by tag over that same
|
|
||||||
array. Fine at current scale (4 posts); if the array grows unwieldy or
|
|
||||||
tags need real querying, that's a SQLite-groundwork question — no need
|
|
||||||
to block on it now. Once a feed exists, consider a per-tag feed too
|
|
||||||
(e.g. `App/pages/blog/tag/[tag]/feed/index.php`).
|
|
||||||
|
|
||||||
### Blog RSS feed
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
An RSS (or Atom) feed for the blog, e.g. `App/pages/blog/feed/index.php`
|
|
||||||
returning `Response::xml(...)` — the sidecar contract already supports
|
|
||||||
this, no new mechanism needed. `App/pages/blog/index.php` already
|
|
||||||
hand-lists every post's slug/title/excerpt in a plain array (no
|
|
||||||
`PostRepository` anymore — see the Blog tags/categories entry above for
|
|
||||||
why); the remaining gap is a published-date field per entry so a feed
|
|
||||||
can sort them, regardless of whether that array stays hardcoded or moves
|
|
||||||
onto SQLite later. Should link from `<link rel="alternate"
|
|
||||||
type="application/rss+xml">` in the blog layout (or the root layout) for
|
|
||||||
feed auto-discovery, and probably wants its own `App/pages/blog/_layout/`
|
|
||||||
or a sidecar-only page — no `index.twig` needed since the sidecar returns
|
|
||||||
XML directly (see `/admin/docs/sidecars`'s JSON-only example for the same
|
|
||||||
pattern, just with `Response::json()` instead of `Response::xml()`).
|
|
||||||
|
|
||||||
### Internal search
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** SQLite groundwork
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Crawl the site's own pages (likely via `App/pages/` + rendered output,
|
|
||||||
rather than an external HTTP crawl, to avoid hitting the static cache/
|
|
||||||
`.htaccess` layer) and index the content into SQLite once the groundwork
|
|
||||||
above exists, so a search box can query it — a `search` sidecar (e.g.
|
|
||||||
`App/pages/search/index.php`) that reads the index and returns matching
|
|
||||||
pages, no external search service. Needs a decision on crawl trigger
|
|
||||||
(on-demand via an admin action vs. a cron-like re-crawl) and on indexing
|
|
||||||
granularity (whole-page vs. per-section). Consider reusing the SQLite
|
|
||||||
FTS5 extension if available, rather than hand-rolling text search.
|
|
||||||
|
|
||||||
### XML sitemap
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Generate a `/sitemap.xml` listing every routable page (`App/pages/` +
|
|
||||||
`novaconium/pages/` overlay, same resolution `Router`/`Overlay` already do)
|
|
||||||
for search engine discovery — one more piece of the SEO groundwork already
|
|
||||||
laid (`/admin/docs/seo`, canonical links, etc.). No hard dependency on
|
|
||||||
SQLite: a first version can be built by walking `pages_dirs` directly
|
|
||||||
(skipping reserved `_`/`404` segments, resolving `[param]` routes only if
|
|
||||||
their concrete values are knowable from some data source — moot for
|
|
||||||
`App/pages/blog/` today, since every post there is now a plain directory
|
|
||||||
rather than a `[param]` route) the same way `Router::resolve()` walks it,
|
|
||||||
with no separate crawl step.
|
|
||||||
That said, it may be worth sharing a crawler with Internal search rather
|
|
||||||
than building two separate page-enumeration mechanisms — if Internal
|
|
||||||
search's crawler already walks and records every real URL (including
|
|
||||||
resolved `[param]` values), the sitemap can just be a different
|
|
||||||
serialization of that same data instead of its own logic. Worth deciding
|
|
||||||
together when either is picked up. Should also exclude `/admin/docs/*` if
|
|
||||||
it isn't publicly reachable (see admin authentication) and the `noindex`
|
|
||||||
pages already marked via the `robots` block.
|
|
||||||
|
|
||||||
### Media/file manager
|
### Media/file manager
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -253,112 +97,12 @@ wanted later, in which case that part could ride on SQLite groundwork).
|
|||||||
Needs basic safety handling: extension allowlist, filename sanitization,
|
Needs basic safety handling: extension allowlist, filename sanitization,
|
||||||
and a max upload size, since this is a file-write surface.
|
and a max upload size, since this is a file-write surface.
|
||||||
|
|
||||||
### Draft pages (admin-only preview)
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-13
|
|
||||||
|
|
||||||
Let a page under `App/pages/` (or `novaconium/pages/`) be written and
|
|
||||||
previewed by an admin without being visible to the public — a draft blog
|
|
||||||
post, an in-progress redesign of a page, etc. Likely a config-driven list
|
|
||||||
(e.g. `'draft_routes' => ['blog/upcoming-post']` in `App/config.php`) or a
|
|
||||||
per-sidecar flag (`return ['draft' => true, ...]`), checked in
|
|
||||||
`novaconium/bootstrap.php` right alongside the existing `/admin/*` gate —
|
|
||||||
reusing `AdminAuth::requireLogin()` (see `/admin/docs/admin-auth`) rather
|
|
||||||
than inventing a second auth mechanism: not logged in → 404 (not a login
|
|
||||||
prompt, so a draft's existence isn't revealed to anyone poking at the
|
|
||||||
URL), logged in → renders normally.
|
|
||||||
|
|
||||||
**The gotcha to design around from day one:** sidecar-less pages get
|
|
||||||
written to the static HTML cache and served by `.htaccess` *before PHP
|
|
||||||
ever runs* (see `/admin/docs/caching`) — if a draft page took that path,
|
|
||||||
the cached file would be world-readable the moment an admin previewed it
|
|
||||||
once, completely bypassing the auth check for anyone hitting the same URL
|
|
||||||
afterward. So a draft page must either always go through a sidecar (never
|
|
||||||
cached, checked via the config/flag above) or `Renderer`/`Cache` need an
|
|
||||||
explicit "never write this route to the cache" exception. Whichever
|
|
||||||
approach ships, add a line to `/admin/docs/caching` and `AGENTS.md`
|
|
||||||
calling this out, the same way the `mb_substr`/`|slice` gotcha got a
|
|
||||||
standing-rule note — it's exactly the kind of interaction between two
|
|
||||||
independently-reasonable features that's easy to get wrong once and
|
|
||||||
should only need explaining once.
|
|
||||||
|
|
||||||
### Copy-to-clipboard button on code blocks
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Low
|
|
||||||
- **Added:** 2026-07-13
|
|
||||||
|
|
||||||
Every `<pre><code>` block across `/admin/docs/*` and the blog's reference
|
|
||||||
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — add a
|
|
||||||
small button on hover that copies the block's text via the
|
|
||||||
[Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
|
|
||||||
(`navigator.clipboard.writeText(...)`), consistent with this project's
|
|
||||||
no-build-step philosophy: vanilla JS, no dependency, same pattern as the
|
|
||||||
dark/light theme toggle (`novaconium/pages/_layout/theme-toggle.twig`) —
|
|
||||||
a small inline script using event delegation rather than a script per
|
|
||||||
button. Needs a new icon (a "copy"/clipboard glyph — see
|
|
||||||
`novaconium/pages/_layout/icons.twig` for the existing set and its
|
|
||||||
inline-SVG-not-icon-font convention) and matching Sass in
|
|
||||||
`novaconium/sass/main.sass`, plus a way to actually grab a code block's
|
|
||||||
raw text without the HTML entities used to keep literal tags/Twig syntax
|
|
||||||
from rendering inside `<pre><code>` (e.g. `<h1>` in the SEO starter
|
|
||||||
template) — reading `textContent` rather than `innerHTML` handles the
|
|
||||||
entity-decoding automatically, so this should be low-risk, but worth
|
|
||||||
calling out since it's the same class of escaping issue documented in
|
|
||||||
`AGENTS.md`/fixed across `/admin/docs/seo` and the Twig Syntax Guide.
|
|
||||||
Should show a brief "Copied!" confirmation (swap the button label/icon
|
|
||||||
for ~1–2 seconds) rather than a silent copy, so it's clear it worked.
|
|
||||||
|
|
||||||
### Syntax highlighting on code blocks
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Low
|
|
||||||
- **Added:** 2026-07-13
|
|
||||||
|
|
||||||
Color the PHP/Twig/Bash/HTML snippets across `/admin/docs/*` and the
|
|
||||||
blog's reference posts instead of the current flat, single-color
|
|
||||||
`<pre><code>` rendering. PHP has a built-in `highlight_string()`, but it
|
|
||||||
only understands PHP — everything else on this site's code blocks (Twig
|
|
||||||
template syntax, Bash/Docker commands in `/admin/docs/styling`, plain
|
|
||||||
HTML) needs something else, so use [highlight.js](https://highlightjs.org/)
|
|
||||||
client-side for everything uniformly, with the **ir-black** theme (dark,
|
|
||||||
high-contrast — fits this project's existing dark/teal default palette).
|
|
||||||
ir-black is a dark-only theme, though, and this site now has a light
|
|
||||||
theme too (see the dark/light toggle) — decide whether code blocks stay
|
|
||||||
ir-black regardless of site theme (simplest, and arguably fine since
|
|
||||||
code blocks are visually distinct boxes already), or whether a second,
|
|
||||||
light-appropriate highlight.js theme gets swapped in via the same
|
|
||||||
`data-theme` attribute the color-palette toggle already sets.
|
|
||||||
Staying consistent with the no-CDN, no-build-step philosophy (Twig itself
|
|
||||||
is vendored, not `npm install`ed) means vendoring highlight.js's built
|
|
||||||
`highlight.min.js` + the `ir-black.min.css` theme file directly under
|
|
||||||
`novaconium/vendor/` next to `twig/`, following the same
|
|
||||||
one-subdirectory-per-vendor convention established there — rather than
|
|
||||||
pulling from a CDN. highlight.js doesn't ship a Twig grammar out of the
|
|
||||||
box; either register a custom language definition for it (Twig's syntax
|
|
||||||
is close enough to Jinja2 that an existing community Jinja grammar may
|
|
||||||
mostly work) or leave Twig snippets using the plain/no-highlight class
|
|
||||||
and accept that only PHP/Bash/HTML/XML get colored initially.
|
|
||||||
|
|
||||||
No hard dependency on the copy-to-clipboard button above, but both touch
|
|
||||||
every `<pre><code>` block on the site, so doing them in the same pass
|
|
||||||
avoids visiting each doc page's code samples twice. If both ship, the
|
|
||||||
copy button must keep copying the plain, unhighlighted text (via
|
|
||||||
`textContent`, not `innerHTML` — see that entry) even after this adds
|
|
||||||
`<span>` wrappers around tokens, so a copied snippet doesn't come out
|
|
||||||
full of stray markup.
|
|
||||||
|
|
||||||
### Admin login & user management
|
### Admin login & user management
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
- **Status:** Backlog
|
- **Status:** Backlog
|
||||||
- **Priority:** Medium
|
- **Priority:** Medium
|
||||||
- **Depends on:** SQLite groundwork, Session handling (with flash sessions)
|
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done)
|
||||||
- **Added:** 2026-07-12
|
- **Added:** 2026-07-12
|
||||||
|
|
||||||
A single-user HTTP Basic Auth stopgap now gates `/admin/*`
|
A single-user HTTP Basic Auth stopgap now gates `/admin/*`
|
||||||
@@ -372,12 +116,40 @@ session handling above), and basic user management (create/disable a
|
|||||||
user, change password). Ship this by replacing `AdminAuth::requireLogin()`
|
user, change password). Ship this by replacing `AdminAuth::requireLogin()`
|
||||||
with the new mechanism, not layering on top of it.
|
with the new mechanism, not layering on top of it.
|
||||||
|
|
||||||
|
### In-house comments
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Backlog
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Depends on:** Admin login & user management, SQLite groundwork (Done)
|
||||||
|
- **Added:** 2026-07-14
|
||||||
|
|
||||||
|
A self-hosted comments library — no third-party service (Disqus,
|
||||||
|
Commento, etc.) — a `Lib\` class any sidecar can call to attach comments
|
||||||
|
to any page, not just blog posts, the same way `Lib\SpamGuard`/
|
||||||
|
`Lib\FormValidator` are reusable across any form rather than hardcoded to
|
||||||
|
the contact page. Comments tied to a real user account rather than
|
||||||
|
anonymous name/email fields, which is why this rides on Admin login &
|
||||||
|
user management rather than SQLite groundwork alone — needs that
|
||||||
|
feature's user store to exist first. Likely a `comments` table
|
||||||
|
(route/user/body/created_at/approved or similar — a migration under
|
||||||
|
`App/migrations/`, following the two-root convention documented in
|
||||||
|
`/admin/docs/database`) plus a small set of sidecar-callable methods
|
||||||
|
(list comments for a route, submit one, moderate one). Needs a decision
|
||||||
|
on moderation model (auto-approve vs. admin-approval queue, reusing the
|
||||||
|
`/admin/*` auth gate for the moderation UI) and on spam handling (reuse
|
||||||
|
`Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second
|
||||||
|
mechanism, consistent with how CSRF protection already works — see
|
||||||
|
`/admin/docs/sidecars`'s "Form security" section for the existing
|
||||||
|
input-cleaning/CSRF/spam-prevention layers a comment form should compose
|
||||||
|
the same way the contact form does).
|
||||||
|
|
||||||
### Ecommerce functionality
|
### Ecommerce functionality
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
- **Status:** Backlog
|
- **Status:** Backlog
|
||||||
- **Priority:** Low
|
- **Priority:** Low
|
||||||
- **Depends on:** SQLite groundwork, Session handling (with flash sessions), Admin login & user management
|
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
|
||||||
- **Added:** 2026-07-12
|
- **Added:** 2026-07-12
|
||||||
|
|
||||||
Product catalog, cart, checkout, and order storage — a `products` /
|
Product catalog, cart, checkout, and order storage — a `products` /
|
||||||
@@ -397,7 +169,7 @@ planning it all up front here.
|
|||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
- **Status:** Backlog
|
- **Status:** Backlog
|
||||||
- **Priority:** Low
|
- **Priority:** Low
|
||||||
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork, Session handling (with flash sessions), Admin login & user management
|
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
|
||||||
- **Added:** 2026-07-12
|
- **Added:** 2026-07-12
|
||||||
|
|
||||||
Subscription/membership content gating, similar to OnlyFans/Patreon:
|
Subscription/membership content gating, similar to OnlyFans/Patreon:
|
||||||
@@ -420,7 +192,386 @@ _Nothing yet._
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
_Nothing yet._
|
### Syntax highlighting on code blocks
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Low
|
||||||
|
- **Added:** 2026-07-13
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Colors `<pre><code>` blocks site-wide via vendored highlight.js v11.11.1
|
||||||
|
(pinned to that stable tag, not `main`, which tracks an in-progress
|
||||||
|
`11.0.0-beta1`), auto-detected and restricted to `configure({ languages:
|
||||||
|
['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json',
|
||||||
|
'ini'] })` — no per-block markup needed for the ~60 code blocks across the
|
||||||
|
site. `css`/`python`/`javascript` ship in the core bundle alongside the
|
||||||
|
original `php`/`bash`/`xml`; `yaml`/`json`/`ini` (the last covers
|
||||||
|
`.env`-style files too) don't and are vendored as three separate
|
||||||
|
per-language files under `public/vendor/highlightjs/languages/` — added
|
||||||
|
after initial shipment, once `/blog/code-highlighting` (a new reference
|
||||||
|
post, worked example of each of the nine) needed them. Themes swap with the
|
||||||
|
existing dark/light toggle: **ir-black** (dark, as originally specified)
|
||||||
|
+ **github** (light, new — resolving the open question this entry
|
||||||
|
originally left for "decide whether code blocks stay ir-black regardless
|
||||||
|
of site theme"), via the same `data-theme`-driven mechanism as the main
|
||||||
|
palette (`novaconium/pages/_layout/syntax-highlight-init.twig`/
|
||||||
|
`syntax-highlight.twig`, mirroring `theme-init.twig`/`nav.twig`'s split —
|
||||||
|
a `MutationObserver` on `data-theme` swaps the theme `<link>` live,
|
||||||
|
without touching the existing toggle button's own click handler at all).
|
||||||
|
|
||||||
|
Twig-syntax code blocks (no highlight.js grammar exists for Twig) are
|
||||||
|
marked `class="nohighlight"` by hand at the source — the entry's own
|
||||||
|
suggested fallback — rather than force-matched into the restricted
|
||||||
|
candidate set, which would color them *wrong* rather than leave them
|
||||||
|
plain (auto-detection with a restricted language list always returns its
|
||||||
|
best guess among the allowed set, never "gives up"). 15 blocks across 9
|
||||||
|
files needed the marker; verified by finding every `<pre><code>`
|
||||||
|
containing literal `{% %}`/`{{ }}` syntax rather than guessing, and
|
||||||
|
confirmed two files matching that initial grep (`sidecars`, one block in
|
||||||
|
`forms`) turned out to be `{% verbatim %}`-wrapped *PHP* snippets
|
||||||
|
(verbatim just protecting a stray `{{ }}` mention), correctly left alone
|
||||||
|
for auto-detection. Same class of gotcha hit again writing the bash
|
||||||
|
example for `/blog/code-highlighting`: a command starting with the
|
||||||
|
literal word `php` (`php -S 127.0.0.1:8000 ...`) auto-detects as PHP, not
|
||||||
|
bash — not a bug, just a reminder that short/ambiguous snippets can
|
||||||
|
mis-detect regardless of the restricted candidate set; the shipped bash
|
||||||
|
example uses a `#!/bin/bash` shebang instead, a strong, reliable signal,
|
||||||
|
verified against the real detector before committing to it.
|
||||||
|
|
||||||
|
**One correction to this entry's own suggested approach, found while
|
||||||
|
implementing it:** vendoring highlight.js under `novaconium/vendor/` next
|
||||||
|
to Twig (as originally suggested) would have been wrong and silently
|
||||||
|
broken — Twig is server-side PHP, never fetched by a browser, but
|
||||||
|
highlight.js's `.js`/`.css` files are, and only `public/` is web
|
||||||
|
-reachable. Vendored to `public/vendor/highlightjs/` instead — see the
|
||||||
|
standing rule added to `AGENTS.md` and `/admin/docs/upgrading-highlightjs`
|
||||||
|
for the consequence: `public/` isn't touched by the usual
|
||||||
|
`novaconium/`-swap framework-update workflow, so a future highlight.js
|
||||||
|
version bump won't propagate to existing projects automatically the way
|
||||||
|
everything else under `novaconium/` does.
|
||||||
|
|
||||||
|
**A real bug caught by testing, not review:** `hljs.highlightAll()`
|
||||||
|
doesn't defer itself if called while `document.readyState` is still
|
||||||
|
`"loading"` — it silently no-ops permanently rather than waiting and
|
||||||
|
retrying, confirmed with an actual DOM test (jsdom) before it was
|
||||||
|
noticed, not assumed safe just because the script tag sits near the end
|
||||||
|
of `<body>`. Fixed by wrapping the call in the same `DOMContentLoaded`
|
||||||
|
pattern `code-copy.twig` already uses. Verified end-to-end with a real
|
||||||
|
`highlight.js` execution against real rendered page HTML (not a hand
|
||||||
|
-rolled mock): correct language detection on real PHP/Bash blocks,
|
||||||
|
`nohighlight` blocks left untouched, and `code.textContent` (what the
|
||||||
|
copy-to-clipboard button reads) confirmed unchanged after `<span>`
|
||||||
|
-wrapping — the specific regression this entry flagged as a risk.
|
||||||
|
|
||||||
|
### Blog RSS feed
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
`App/pages/blog/feed/index.php`, sidecar-only, `Response::xml(...)` — as
|
||||||
|
originally scoped, no new mechanism needed. Deliberately independent of
|
||||||
|
the content index above: it reads the same hand-written `$posts` array
|
||||||
|
`App/pages/blog/index.php` itself renders from (now with a `published`
|
||||||
|
date field added per entry, illustrative — this repo's posts all arrived
|
||||||
|
in one batch import, no authentic per-post history to derive real dates
|
||||||
|
from), so it works with `content_index_enabled` left at its shipped
|
||||||
|
default of `false`. Sorted newest-first for the feed only; the array's
|
||||||
|
own order (and the `/blog` listing page) is untouched. `<link>`/`<guid>`
|
||||||
|
are site-relative paths, consistent with how `canonical`/`og:url` already
|
||||||
|
work in this framework (no site-wide base-URL config exists to build
|
||||||
|
absolute URLs from — not adding one for this alone); `<guid
|
||||||
|
isPermaLink="false">` is the spec-correct way to mark a non-absolute
|
||||||
|
identifier.
|
||||||
|
|
||||||
|
Shipped a per-tag feed too (`App/pages/blog/tag/[tag]/feed/index.php`),
|
||||||
|
gated on `content_index_enabled` the same way `blog/tag/[tag]/index.php`
|
||||||
|
is, `<pubDate>` from each page's `source_mtime` (a stand-in for a real
|
||||||
|
publish date, which the content index doesn't track). Both feeds share
|
||||||
|
`Lib\Rss::render()` (`novaconium/lib/Rss.php`, new — a generic RSS 2.0
|
||||||
|
envelope builder, framework-default since only its two call sites are
|
||||||
|
blog-specific, not the class itself) rather than duplicating the same XML
|
||||||
|
-building logic twice.
|
||||||
|
|
||||||
|
Feed auto-discovery needed a new `head_extra` block in the root layout
|
||||||
|
(`novaconium/pages/_layout/layout.twig`, empty by default, rendered right
|
||||||
|
before `</head>`) — the root layout had no open-ended "extra head
|
||||||
|
content" extension point before this; `App/pages/blog/_layout/layout.twig`
|
||||||
|
overrides it with the `<link rel="alternate">`, so it only appears on
|
||||||
|
`/blog/*` pages, not site-wide.
|
||||||
|
|
||||||
|
### Content index: keywords, tags/categories, search, XML sitemap
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Depends on:** SQLite groundwork (Done)
|
||||||
|
- **Added:** 2026-07-12 (Blog tags/categories, Internal search, XML
|
||||||
|
sitemap entries) / 2026-07-14 (keywords, combined)
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Shipped Blog tags/categories, Internal search, and XML sitemap together,
|
||||||
|
plus a new meta-keywords request, as one feature rather than three —
|
||||||
|
exactly the "worth deciding together when either is picked up" call this
|
||||||
|
file made when XML sitemap was first written. All three (plus a fourth,
|
||||||
|
new: `<meta name="keywords">`) turned out to be one shared crawler with
|
||||||
|
thin consumers, not separate mechanisms.
|
||||||
|
|
||||||
|
Design: content stays in files (Twig pages, no CMS-style body-in-database
|
||||||
|
— keeps the Hugo-style file-based-routing pitch intact). Per-page metadata
|
||||||
|
is four Twig blocks in `novaconium/pages/_layout/layout.twig`, the same
|
||||||
|
override mechanism already used for `title`/`description`/`og_*`:
|
||||||
|
`keywords` (rendered, new `<meta>` tag), `tags` (comma-separated, not
|
||||||
|
rendered), `changefreq`/`priority` (sitemap hints, not rendered). No
|
||||||
|
front-matter, no separate metadata file convention. `App\ContentIndexer`
|
||||||
|
(`novaconium/src/ContentIndexer.php`) crawls every routable page
|
||||||
|
(`Overlay::listPageDirs()`, a new method — skips `_`/`404`/`[param]`
|
||||||
|
directories, matching `Router::resolve()`'s reserved-segment rule and the
|
||||||
|
original XML sitemap entry's stated V1 limitation that wildcard routes
|
||||||
|
aren't crawled without a data source to resolve concrete values) and pulls
|
||||||
|
each block's value via `Renderer::renderForIndex()` (new method) calling
|
||||||
|
Twig's own `TemplateWrapper::renderBlock()` — not regex-parsing `.twig`
|
||||||
|
source — so overrides and layout inheritance resolve exactly like a real
|
||||||
|
render. Rendered HTML is `strip_tags()`-stripped into a SQLite FTS5 table
|
||||||
|
for search. A page listed in `draft_routes` or whose `robots` block
|
||||||
|
resolves to `noindex` is skipped entirely (never indexed), same convention
|
||||||
|
`/admin/docs/seo` already documents for admin/internal pages.
|
||||||
|
|
||||||
|
**Off by default** (`content_index_enabled`, default `false`) — all three
|
||||||
|
consumers depend on SQLite, a real dependency plenty of sites built on
|
||||||
|
this framework won't want, same reasoning that already keeps Matomo/admin
|
||||||
|
auth off by default. Verified end-to-end that disabling it is a true
|
||||||
|
zero-footprint no-op: no `data/novaconium.sqlite` gets created just
|
||||||
|
because the feature exists in the codebase, and all three consumer routes
|
||||||
|
404 exactly as if they didn't exist.
|
||||||
|
|
||||||
|
Two trigger paths sharing one `reindex()`: lazy (`content_index_auto`,
|
||||||
|
default `true` — a cheap mtime-staleness check on first touch of a
|
||||||
|
consumer route, never on a normal page view) and explicit
|
||||||
|
(`php novaconium/bin/index-content.php`, same shape as `bin/migrate.php`,
|
||||||
|
ignores `content_index_auto`).
|
||||||
|
|
||||||
|
**Two real bugs caught by testing, not review:**
|
||||||
|
1. **Reentrancy** — the crawl renders every page, including `/search`
|
||||||
|
itself, whose own sidecar calls `ContentIndexer::ensureFresh()`;
|
||||||
|
without a guard this triggered a nested `reindex()` mid-transaction and
|
||||||
|
fataled on a second `PDO::beginTransaction()`. Fixed with a
|
||||||
|
`private static bool $indexing` guard checked at the top of both
|
||||||
|
`ensureFresh()` and `reindex()`.
|
||||||
|
2. **Wrong PDO constant** (`PDO::KEY_PAIR` instead of
|
||||||
|
`PDO::FETCH_KEY_PAIR`) in the search sidecar, caught immediately by
|
||||||
|
actually hitting `/search` with a real query rather than trusting the
|
||||||
|
code read correctly.
|
||||||
|
|
||||||
|
Also fixed two unrelated pre-existing bugs discovered while building this
|
||||||
|
(both blocked/were adjacent to the crawler rendering every page for real):
|
||||||
|
`novaconium/pages/admin/docs/sidecars/index.twig` had a literal
|
||||||
|
un-escaped `{{ }}` in prose text that fataled Twig with a syntax error on
|
||||||
|
any real render of that page (it had apparently never actually been
|
||||||
|
visited before); and both that page and `Lib\Input`'s doc-comment still
|
||||||
|
said "there's no database layer in this framework yet" despite `Lib\Db`
|
||||||
|
having shipped weeks earlier.
|
||||||
|
|
||||||
|
**`migrations_dir` (`Lib\Db`) now accepts an ordered list of roots, not
|
||||||
|
just one path** — needed so the content index's schema
|
||||||
|
(`novaconium/migrations/0001_create_content_index.sql`) could ship as a
|
||||||
|
framework migration without colliding with project migrations in
|
||||||
|
`App/migrations/`. This is the first framework-shipped migration, and the
|
||||||
|
two-root extension point `AGENTS.md` flagged as a future need when SQLite
|
||||||
|
groundwork shipped. Migrations are now tracked by path relative to the
|
||||||
|
repo root (not bare filename) specifically to prevent two roots each
|
||||||
|
containing a same-named file from shadowing one another —
|
||||||
|
`realpath()`-normalized so a `migrations_dir` containing `..` (like the
|
||||||
|
default connection's own `__DIR__ . '/../App/migrations'`) doesn't produce
|
||||||
|
an ugly, unstable tracked name.
|
||||||
|
|
||||||
|
New consumer routes: `novaconium/pages/sitemap.xml/index.php` (framework
|
||||||
|
-default — confirmed a directory literally named `sitemap.xml` resolves
|
||||||
|
correctly, since `Router` only splits on `/`), `novaconium/pages/search/`
|
||||||
|
(framework-default, FTS5-backed, the search term wrapped as an escaped
|
||||||
|
quoted phrase before binding — parameter binding stops SQL injection but
|
||||||
|
not FTS5's own query-language parsing of the bound value, verified against
|
||||||
|
a literal `"` and several FTS operator characters, not just assumed safe),
|
||||||
|
and `App/pages/blog/tag/[tag]/` (project-owned, since `blog/` is project
|
||||||
|
content — `App/pages/blog/index.php`'s hand-written post array is
|
||||||
|
untouched, `content_tags` is a derived index on top of it, not a
|
||||||
|
replacement). Added `{% block tags %}` to the 4 existing blog posts so tag
|
||||||
|
browsing has real content to demonstrate against — an exception to the
|
||||||
|
"ship mechanism only, no demo content" pattern of prior sessions, since
|
||||||
|
here the target content already existed and tag browsing is meaningless
|
||||||
|
to verify without it. Documented at `/admin/docs/content-index`, with
|
||||||
|
supporting updates to `/admin/docs/seo`, `/admin/docs/database`, and
|
||||||
|
`novaconium/bin/create-static-page.php`'s scaffolded template.
|
||||||
|
|
||||||
|
### Draft pages (admin-only preview)
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Added:** 2026-07-13
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Let a page under `App/pages/` be written and previewed by an admin without
|
||||||
|
being visible to the public — a config-driven list, `draft_routes` in
|
||||||
|
`App/config.php` (`Route::$dir`-format entries, e.g. `'blog/upcoming-post'`),
|
||||||
|
checked in `novaconium/bootstrap.php` right alongside the existing
|
||||||
|
`/admin/*` gate. Reuses `AdminAuth::isAuthenticated()` (a new method,
|
||||||
|
extracted out of `requireLogin()` so the credential check could be reused
|
||||||
|
with a different failure response) rather than a second auth mechanism:
|
||||||
|
not authenticated → the same plain 404 an unmatched route gets (not a
|
||||||
|
login prompt, so a draft's existence isn't revealed to anyone poking at
|
||||||
|
the URL), authenticated → renders normally. No separate login flow needed
|
||||||
|
— an admin authenticates once at `/admin`, and the browser then resends
|
||||||
|
those same Basic Auth credentials to draft URLs automatically, since
|
||||||
|
they're scoped to the whole origin/realm.
|
||||||
|
|
||||||
|
The gotcha flagged when this entry was written was designed around
|
||||||
|
correctly: sidecar-less pages get written to the static HTML cache and
|
||||||
|
served by `.htaccess` before PHP ever runs, so a draft without its own
|
||||||
|
sidecar needed an explicit exclusion, not just the auth gate —
|
||||||
|
`Renderer::render()` gained an `$excludeFromCache` param for this.
|
||||||
|
**A second, real instance of the same bug was found while testing this
|
||||||
|
feature, pre-dating it entirely:** `/admin` itself
|
||||||
|
(`novaconium/pages/admin/index.twig`) has no sidecar, so it was already
|
||||||
|
being written to the static cache — meaning once any admin visited
|
||||||
|
`/admin` once, the admin panel was served to every subsequent visitor,
|
||||||
|
unauthenticated, straight from `public/cache/admin/`, completely
|
||||||
|
bypassing `AdminAuth`. Fixed in the same change by passing
|
||||||
|
`$excludeFromCache = true` for every `/admin/*` route too, not just
|
||||||
|
drafts. Documented as a standing rule in `AGENTS.md` (next to the
|
||||||
|
`mb_substr`/`|slice` and `|escape('js')` notes) and at
|
||||||
|
`/admin/docs/drafts`/`/admin/docs/caching`: any future mechanism that
|
||||||
|
conditionally hides page content from the public has to make the same
|
||||||
|
check, not just gate the initial request.
|
||||||
|
|
||||||
|
### Session handling (with flash sessions)
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** High
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
A `Lib\` wrapper around PHP's native session handling (`session_start()`,
|
||||||
|
`$_SESSION`, not a custom session store) — `Lib\Session`
|
||||||
|
(`novaconium/lib/Session.php`), all-static and lazy-start, same shape as
|
||||||
|
the already-shipped `Lib\Csrf` (which also touches the native session; the
|
||||||
|
two coexist in the same request without conflict). Ships
|
||||||
|
`get()`/`set()`/`has()`/`remove()` plus CodeIgniter-style flash data
|
||||||
|
(`flash()`/`getFlash()`) — a value set now that's readable on exactly the
|
||||||
|
next request, then gone, for post/redirect/GET flows like
|
||||||
|
`App/pages/contact/index.php`'s hand-rolled `?sent=1` (not refactored to
|
||||||
|
use it in this change — cited in the original spec as the motivating
|
||||||
|
example, not a mandate to touch working demo code). The flash mechanism is
|
||||||
|
a single per-request swap (snapshot last request's flash bucket into an
|
||||||
|
in-memory static on first touch, then clear the stored bucket so this
|
||||||
|
request's `flash()` calls fill a fresh one for the request after), not a
|
||||||
|
separate expiry/sweep pass — verified end-to-end across three real,
|
||||||
|
separate HTTP requests sharing a cookie jar (not three calls in one PHP
|
||||||
|
process), confirming a flashed value appears on exactly the next request
|
||||||
|
and is gone on the one after. Foundational alongside SQLite groundwork —
|
||||||
|
admin login (next up) depends on it for logged-in state. Documented at
|
||||||
|
`/admin/docs/session` and in `AGENTS.md` next to the `Lib\Csrf`/`Lib\Db`
|
||||||
|
sections.
|
||||||
|
|
||||||
|
### MySQL support
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Depends on:** SQLite groundwork (Done)
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Let a project point `Lib\Db` at MySQL — but went further than the original
|
||||||
|
spec ("point the `Db` wrapper at MySQL instead of SQLite"): a real
|
||||||
|
requirement surfaced during implementation that a single request may need
|
||||||
|
**both** at once (sidecars have full access to any `Lib\` class, so nothing
|
||||||
|
stops one from querying this site's own SQLite data and a legacy MySQL
|
||||||
|
database in the same request). So `Lib\Db` was redesigned around multiple,
|
||||||
|
independently-configured, simultaneously-open named connections
|
||||||
|
(`config['db_connections']`, keyed by name — `'default'` is the only
|
||||||
|
required one) rather than one global connection switched by a single
|
||||||
|
`db_driver` key. This superseded the flat `db_driver`/`db_path`/
|
||||||
|
`db_migrations_dir` keys the SQLite groundwork entry above originally
|
||||||
|
shipped with (which had no downstream consumers yet, so no migration path
|
||||||
|
was needed). `Db::query(string $sql, array $params = [], string
|
||||||
|
$connection = 'default')` and `Db::connection(string $name = 'default')`
|
||||||
|
both default to `'default'` so the common single-database case reads the
|
||||||
|
same as before; a third/first argument targets any other configured
|
||||||
|
connection. Each connection has its own lazy PDO connect (`'sqlite'` and
|
||||||
|
`'mysql'` drivers implemented), its own optional `migrations_dir`, and its
|
||||||
|
own independent `schema_migrations` table — verified for real (not just by
|
||||||
|
inspection) by running a local MariaDB instance alongside the existing
|
||||||
|
SQLite connection and executing queries against both from the same script.
|
||||||
|
`db_connections` needed one deliberate exception to the project's usual
|
||||||
|
shallow config-merge rule — merged one level deeper, by connection name, so
|
||||||
|
an `App/config.php` adding a `legacy` connection doesn't silently delete
|
||||||
|
the framework's `default` one — documented in `AGENTS.md` and
|
||||||
|
`/admin/docs/database`, including the exact "capture defaults before the
|
||||||
|
top-level `array_merge()` overwrites them" ordering bug hit once while
|
||||||
|
building this (caught by the end-to-end MySQL test, not by review).
|
||||||
|
|
||||||
|
### SQLite groundwork
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** High
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Laid the groundwork for optional SQLite storage: `Lib\Db`
|
||||||
|
(`novaconium/lib/Db.php`) is a thin, no-ORM PDO wrapper (prepared
|
||||||
|
statements only, no string-interpolation helper ever, per `Lib\Input`'s
|
||||||
|
existing documented security stance) so features that need persistence —
|
||||||
|
404 tracking (see Won't Do; superseded by Matomo before this shipped),
|
||||||
|
admin login, blog tags, internal search, and anything future — have a
|
||||||
|
common place to store data instead of ad hoc flat files. Data lives in a
|
||||||
|
new top-level `data/` directory — deliberately outside both `public/`
|
||||||
|
(would be web-accessible) and `novaconium/` (gets wholesale-replaced by the
|
||||||
|
"Updating the framework" workflow documented at
|
||||||
|
`/admin/docs/getting-started`, so anything persisted there would be
|
||||||
|
destroyed by the next update) — gitignored per-file, with a tracked
|
||||||
|
`.gitkeep`. Designed driver-agnostic (no SQLite-only SQL in the mechanism
|
||||||
|
itself) specifically so MySQL support wouldn't need a retrofit — see that
|
||||||
|
entry below (shipped 2026-07-14) for the connection/config/migration API,
|
||||||
|
which superseded the single-connection shape (`db_driver`/`db_path`/
|
||||||
|
`db_migrations_dir` config keys) this entry originally shipped with.
|
||||||
|
|
||||||
|
### Copy-to-clipboard button on code blocks
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Low
|
||||||
|
- **Added:** 2026-07-13
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Every `<pre><code>` block across `/admin/docs/*` and the blog's reference
|
||||||
|
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — added a
|
||||||
|
small button on hover that copies the block's text via the
|
||||||
|
[Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
|
||||||
|
(`navigator.clipboard.writeText(...)`), consistent with this project's
|
||||||
|
no-build-step philosophy: vanilla JS, no dependency, same event-delegation
|
||||||
|
pattern as the dark/light theme toggle (`novaconium/pages/_layout/nav.twig`).
|
||||||
|
Implemented as a single site-wide partial
|
||||||
|
(`novaconium/pages/_layout/code-copy.twig`, included from
|
||||||
|
`_layout/layout.twig`'s footer) that injects a button into every `<pre>`
|
||||||
|
containing a `<code>` on `DOMContentLoaded`, rather than touching each doc
|
||||||
|
page's markup individually. Added `copy`/`check` icons to
|
||||||
|
`novaconium/pages/_layout/icons.twig` and matching styles in
|
||||||
|
`novaconium/sass/main.sass` (hover/focus-revealed button, `.copied` state).
|
||||||
|
Icon markup reaches JS via two `<template>` elements read through
|
||||||
|
`.innerHTML`, not Twig's `|escape('js')` — that filter calls
|
||||||
|
`Twig\Runtime\mb_ord()` and fatals without the `mbstring` extension, hit
|
||||||
|
for real once on a bare-PHP install; see the standing rule added to
|
||||||
|
`AGENTS.md` next to the existing `|slice`/`mb_substr` gotcha.
|
||||||
|
Copies via `code.textContent`, not `innerHTML`, so HTML-entity-escaped
|
||||||
|
samples (e.g. `<h1>` in the SEO starter template) come out as literal
|
||||||
|
characters rather than escaped markup. Shows a "Copied!" label/checkmark
|
||||||
|
for 1.5s after a successful copy.
|
||||||
|
|
||||||
## Won't Do
|
## Won't Do
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ $template = <<<TWIG
|
|||||||
{% block description %}One or two sentences describing this page.{% endblock %}
|
{% block description %}One or two sentences describing this page.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block keywords %}{% endblock %}
|
||||||
|
{% block tags %}{% endblock %}
|
||||||
|
{% block changefreq %}monthly{% endblock %}
|
||||||
|
{% block priority %}0.5{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}website{% endblock %}
|
{% block og_type %}website{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\ContentIndexer;
|
||||||
|
|
||||||
|
require __DIR__ . '/../autoload.php';
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
echo "content_index_enabled is false — nothing to do. See /admin/docs/content-index.\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignores content_index_auto (the lazy-vs-CLI-only toggle) — this script
|
||||||
|
// is the explicit-trigger path, so it always reindexes when run.
|
||||||
|
ContentIndexer::reindex();
|
||||||
|
|
||||||
|
echo "Content index rebuilt.\n";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Lib\Db;
|
||||||
|
|
||||||
|
require __DIR__ . '/../autoload.php';
|
||||||
|
|
||||||
|
// Db::connection() applies any pending migrations for that connection as a
|
||||||
|
// side effect of opening it (see Lib\Db::migrate()) — this script triggers
|
||||||
|
// that explicitly for every configured connection, e.g. from a deploy
|
||||||
|
// script, without serving a request first.
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$appConfig = require $appConfigFile;
|
||||||
|
$defaultConnections = $config['db_connections'];
|
||||||
|
$appConnections = $appConfig['db_connections'] ?? [];
|
||||||
|
$config = array_merge($config, $appConfig);
|
||||||
|
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($config['db_connections']) as $name) {
|
||||||
|
Db::connection($name);
|
||||||
|
echo "Migrated connection '{$name}'.\n";
|
||||||
|
}
|
||||||
@@ -64,7 +64,8 @@ $route = $router->resolve($requestUri);
|
|||||||
// moment it exists; nothing to remember to wire up. No-op (open access)
|
// moment it exists; nothing to remember to wire up. No-op (open access)
|
||||||
// when admin_password_hash is empty, which is the default. See
|
// when admin_password_hash is empty, which is the default. See
|
||||||
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
|
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
|
||||||
if ($route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'))) {
|
$isAdminRoute = $route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'));
|
||||||
|
if ($isAdminRoute) {
|
||||||
AdminAuth::requireLogin($config['admin_username'], $config['admin_password_hash']);
|
AdminAuth::requireLogin($config['admin_username'], $config['admin_password_hash']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,16 +78,34 @@ $matomoUrl = $config['matomo_url'] !== '' ? rtrim($config['matomo_url'], '/') .
|
|||||||
$adminAuthEnabled = $config['admin_password_hash'] !== '';
|
$adminAuthEnabled = $config['admin_password_hash'] !== '';
|
||||||
|
|
||||||
$cache = new Cache($config['cache_dir']);
|
$cache = new Cache($config['cache_dir']);
|
||||||
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']);
|
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name'], $config['content_index_enabled']);
|
||||||
|
|
||||||
|
// A route listed in draft_routes is only visible to an authenticated admin
|
||||||
|
// — anyone else gets treated exactly like a route that doesn't exist at
|
||||||
|
// all (a plain 404, not a login prompt), so a draft's existence isn't
|
||||||
|
// revealed to anyone poking at the URL. See /admin/docs/drafts. Reuses the
|
||||||
|
// same credential check /admin/* uses (AdminAuth::isAuthenticated()) —
|
||||||
|
// in practice an admin authenticates by visiting /admin once first; the
|
||||||
|
// browser then resends those same Basic Auth credentials to draft URLs
|
||||||
|
// too, since they share the same origin/realm.
|
||||||
|
$isDraftRoute = $route->found && in_array($route->dir, $config['draft_routes'], true);
|
||||||
|
|
||||||
// $route->found is false for anything Router couldn't match to a real page
|
// $route->found is false for anything Router couldn't match to a real page
|
||||||
// (no index.twig or index.php at the resolved directory) — render the 404
|
// (no index.twig or index.php at the resolved directory) — render the 404
|
||||||
// page and stop. Otherwise render the matched page: runs its sidecar (if
|
// page and stop. Otherwise render the matched page: runs its sidecar (if
|
||||||
// any), resolves the nearest layout, renders Twig, and writes the static
|
// any), resolves the nearest layout, renders Twig, and writes the static
|
||||||
// cache for sidecar-less pages. See novaconium/src/Renderer.php.
|
// cache for sidecar-less pages — except for drafts and $isAdminRoute (see
|
||||||
if (!$route->found) {
|
// Renderer::render()'s $excludeFromCache param). Every /admin/* route is
|
||||||
|
// excluded from the cache for the same reason a draft is: a sidecar-less
|
||||||
|
// admin page (e.g. novaconium/pages/admin/index.twig) would otherwise get
|
||||||
|
// written to public/cache/ as plain HTML the first time an authenticated
|
||||||
|
// admin visited it, and .htaccess serves a cached file before PHP (and
|
||||||
|
// therefore AdminAuth::requireLogin()) ever runs again — permanently
|
||||||
|
// serving the admin panel to anyone, unauthenticated, straight from the
|
||||||
|
// static cache. See novaconium/src/Renderer.php.
|
||||||
|
if (!$route->found || ($isDraftRoute && !AdminAuth::isAuthenticated($config['admin_username'], $config['admin_password_hash']))) {
|
||||||
$renderer->renderNotFound($requestUri);
|
$renderer->renderNotFound($requestUri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$renderer->render($route, $requestUri);
|
$renderer->render($route, $requestUri, $isDraftRoute || $isAdminRoute);
|
||||||
|
|||||||
@@ -38,4 +38,62 @@ return [
|
|||||||
// 'admin_password_hash' => '$2y$10$...',
|
// 'admin_password_hash' => '$2y$10$...',
|
||||||
'admin_username' => 'admin',
|
'admin_username' => 'admin',
|
||||||
'admin_password_hash' => '',
|
'admin_password_hash' => '',
|
||||||
|
|
||||||
|
// Lib\Db (see /admin/docs/database) — named, simultaneously-usable
|
||||||
|
// connections, keyed by name; 'default' is the only one required. A
|
||||||
|
// sidecar can use more than one at once, e.g. Db::query(...) (default)
|
||||||
|
// alongside Db::query(..., 'legacy'). Supported drivers: 'sqlite',
|
||||||
|
// 'mysql'. The default connection's path deliberately lives outside
|
||||||
|
// both public/ (must never be web-accessible) and novaconium/ (gets
|
||||||
|
// wholly replaced on a framework update — see
|
||||||
|
// /admin/docs/getting-started's "Updating the framework" section) — a
|
||||||
|
// top-level data/ directory, project-owned like App/, is the only safe
|
||||||
|
// place for it. migrations_dir is optional per connection (omit it to
|
||||||
|
// never run migrations against that connection, e.g. a read-only
|
||||||
|
// legacy database) and accepts either one path or an ordered list of
|
||||||
|
// roots — the default connection lists novaconium/migrations/ (framework
|
||||||
|
// -shipped schema, e.g. the content index — see /admin/docs/content-index)
|
||||||
|
// before App/migrations/ (project migrations), so framework migrations
|
||||||
|
// always apply first. NOTE: unlike every other key here, App/config.php
|
||||||
|
// merges into db_connections one level deeper than a normal shallow
|
||||||
|
// override — see the comment on Lib\Db::config() — so adding a second
|
||||||
|
// connection there doesn't require repeating 'default'.
|
||||||
|
'db_connections' => [
|
||||||
|
'default' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||||
|
'migrations_dir' => [
|
||||||
|
__DIR__ . '/migrations',
|
||||||
|
__DIR__ . '/../App/migrations',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Routes an admin can preview before the public can see them (see
|
||||||
|
// /admin/docs/drafts) — a list of Route::$dir-format paths, no leading
|
||||||
|
// slash, e.g. 'blog/upcoming-post'. Not authenticated as admin (per
|
||||||
|
// AdminAuth::isAuthenticated()) → 404, same as a route that doesn't
|
||||||
|
// exist at all, so a draft's existence isn't revealed to anyone
|
||||||
|
// poking at the URL. Authenticated → renders normally, and — critically
|
||||||
|
// — is never written to the static HTML cache regardless of whether
|
||||||
|
// the page has a sidecar (see Renderer::render()'s $isDraft param),
|
||||||
|
// since a world-readable cached copy would otherwise permanently leak
|
||||||
|
// the draft the first time an admin previewed it.
|
||||||
|
'draft_routes' => [],
|
||||||
|
|
||||||
|
// Content index (see /admin/docs/content-index) — backs /sitemap.xml,
|
||||||
|
// /search, and blog tag browsing. Off by default: all three depend on
|
||||||
|
// SQLite (Lib\Db), a real dependency plenty of sites built on this
|
||||||
|
// framework won't want at all, the same reasoning that keeps Matomo
|
||||||
|
// and admin auth off by default above. When false, all three routes
|
||||||
|
// 404 exactly as if they didn't exist, and nothing ever touches
|
||||||
|
// Lib\Db because of this feature — no data/novaconium.sqlite gets
|
||||||
|
// created just because the code exists. content_index_auto only
|
||||||
|
// matters once enabled: true (the default) reindexes lazily,
|
||||||
|
// on-demand, the first time a stale index is actually needed (never on
|
||||||
|
// a normal page view); false disables that and leaves indexing
|
||||||
|
// entirely to `php novaconium/bin/index-content.php`, e.g. from a
|
||||||
|
// deploy step.
|
||||||
|
'content_index_enabled' => false,
|
||||||
|
'content_index_auto' => true,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Lib;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin PDO wrapper — the SQLite/MySQL groundwork tracked in
|
||||||
|
* novaconium/ISSUES.md. No ORM, no query builder, consistent with this
|
||||||
|
* project's no-Composer, no-build-step philosophy: just lazily-opened PDO
|
||||||
|
* connections plus a minimal per-connection migration runner.
|
||||||
|
*
|
||||||
|
* Supports multiple, simultaneously-open, independently-configured named
|
||||||
|
* connections (config['db_connections'], keyed by name) rather than one
|
||||||
|
* global connection — because sidecars are plain PHP with full access to
|
||||||
|
* any Lib\ class, a single request may legitimately need more than one
|
||||||
|
* database at once (e.g. this site's own SQLite data plus a MySQL
|
||||||
|
* connection to a legacy/external database). The common single-database
|
||||||
|
* case still reads the same as a single-connection API would:
|
||||||
|
* Db::query('SELECT ...', [...]) always targets the 'default' connection
|
||||||
|
* unless a different connection name is passed explicitly.
|
||||||
|
*
|
||||||
|
* Db::query() is the only query-running helper, and it only ever accepts a
|
||||||
|
* SQL string plus a params array for PDO to bind — there is deliberately no
|
||||||
|
* string-interpolation convenience method. See Lib\Input's doc-comment: the
|
||||||
|
* only real defense against SQL injection is parameterized queries, never
|
||||||
|
* string concatenation or sanitize-then-interpolate, however "cleaned" input
|
||||||
|
* looks. Call Db::connection() directly for anything Db::query() doesn't
|
||||||
|
* cover (transactions, lastInsertId(), etc.) — it returns the raw PDO
|
||||||
|
* instance for the named connection.
|
||||||
|
*
|
||||||
|
* Lazy-connect, same shape as Lib\Csrf's lazy session start: nothing opens
|
||||||
|
* a database connection or runs a migration until the first real call to a
|
||||||
|
* given connection name, so a request that never touches a particular
|
||||||
|
* database never pays for it.
|
||||||
|
*/
|
||||||
|
final class Db
|
||||||
|
{
|
||||||
|
/** @var array<string, PDO> */
|
||||||
|
private static array $connections = [];
|
||||||
|
|
||||||
|
public static function connection(string $name = 'default'): PDO
|
||||||
|
{
|
||||||
|
return self::$connections[$name] ??= self::connect($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string,mixed> $params
|
||||||
|
*/
|
||||||
|
public static function query(string $sql, array $params = [], string $connection = 'default'): \PDOStatement
|
||||||
|
{
|
||||||
|
$statement = self::connection($connection)->prepare($sql);
|
||||||
|
$statement->execute($params);
|
||||||
|
|
||||||
|
return $statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function connect(string $name): PDO
|
||||||
|
{
|
||||||
|
$connections = self::config()['db_connections'];
|
||||||
|
|
||||||
|
if (!isset($connections[$name])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"No db_connections entry named '{$name}' in config — configured connections: " .
|
||||||
|
(empty($connections) ? '(none)' : implode(', ', array_keys($connections)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionConfig = $connections[$name];
|
||||||
|
$driver = $connectionConfig['driver'] ?? null;
|
||||||
|
|
||||||
|
$pdo = match ($driver) {
|
||||||
|
'sqlite' => self::connectSqlite($connectionConfig),
|
||||||
|
'mysql' => self::connectMysql($connectionConfig),
|
||||||
|
default => throw new RuntimeException(
|
||||||
|
"Connection '{$name}' has unsupported driver " .
|
||||||
|
(is_string($driver) ? "'{$driver}'" : 'null') . " — only 'sqlite' and 'mysql' are implemented."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
self::migrate($pdo, $connectionConfig['migrations_dir'] ?? null);
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function connectSqlite(array $config): PDO
|
||||||
|
{
|
||||||
|
$path = $config['path'];
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $path, options: [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function connectMysql(array $config): PDO
|
||||||
|
{
|
||||||
|
$charset = $config['charset'] ?? 'utf8mb4';
|
||||||
|
$dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$charset}";
|
||||||
|
|
||||||
|
return new PDO($dsn, $config['username'], $config['password'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies any *.sql file under $migrationsDirs not yet recorded in this
|
||||||
|
* connection's own schema_migrations table. $migrationsDirs is an
|
||||||
|
* ordered list of roots (a single string is accepted too, wrapped
|
||||||
|
* internally) — each root's own files are applied in filename order
|
||||||
|
* within that root (numeric prefixes, e.g. 0001_create_x.sql,
|
||||||
|
* 0002_add_y.sql, control order within a root), and roots are fully
|
||||||
|
* processed one at a time in the order given, not interleaved by
|
||||||
|
* filename across roots. This lets framework-shipped migrations (e.g.
|
||||||
|
* novaconium/migrations/) always apply before a project's own
|
||||||
|
* (App/migrations/) on the same connection — see
|
||||||
|
* novaconium/config.php's db_connections.default.migrations_dir and
|
||||||
|
* AGENTS.md.
|
||||||
|
*
|
||||||
|
* Each file is tracked once applied and never re-run, keyed by its path
|
||||||
|
* relative to the repo root (e.g. novaconium/migrations/0001_x.sql) —
|
||||||
|
* not bare filename, because two roots can each contain a same-named
|
||||||
|
* file (a framework migration and an unrelated project migration both
|
||||||
|
* numbered 0001_...); tracking by bare filename would make the second
|
||||||
|
* one seen look "already applied" and silently skip it. A relative
|
||||||
|
* path is also portable across environments, unlike a full absolute
|
||||||
|
* path, which would make every migration look "new" again after a
|
||||||
|
* clone/deploy to a different directory.
|
||||||
|
*
|
||||||
|
* A connection with no migrations_dir set skips this entirely — e.g. a
|
||||||
|
* connection to a legacy database this project shouldn't manage schema
|
||||||
|
* for. Runs automatically on every first connection() call per
|
||||||
|
* process, per connection name — cheap (one query plus a directory
|
||||||
|
* glob per root), so no separate "migrate" step is required, matching
|
||||||
|
* the framework's zero-config philosophy elsewhere (e.g. static
|
||||||
|
* caching). novaconium/bin/migrate.php exists to run it explicitly for
|
||||||
|
* every configured connection (e.g. from a deploy script) without
|
||||||
|
* serving a request first.
|
||||||
|
*
|
||||||
|
* @param string|string[]|null $migrationsDirs
|
||||||
|
*/
|
||||||
|
private static function migrate(PDO $pdo, string|array|null $migrationsDirs): void
|
||||||
|
{
|
||||||
|
if ($migrationsDirs === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS schema_migrations (' .
|
||||||
|
'filename VARCHAR(255) PRIMARY KEY, ' .
|
||||||
|
'applied_at VARCHAR(32) NOT NULL' .
|
||||||
|
')'
|
||||||
|
);
|
||||||
|
|
||||||
|
$applied = $pdo->query('SELECT filename FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$applied = array_flip($applied);
|
||||||
|
|
||||||
|
// novaconium/lib/ -> novaconium/ -> repo root, two levels up.
|
||||||
|
$repoRoot = rtrim(realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2), '/') . '/';
|
||||||
|
|
||||||
|
foreach ((array) $migrationsDirs as $dir) {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = glob(rtrim($dir, '/') . '/*.sql') ?: [];
|
||||||
|
sort($files);
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
// realpath() resolves any ".." left over from a
|
||||||
|
// migrations_dir like __DIR__ . '/../App/migrations' (glob()
|
||||||
|
// doesn't normalize the paths it returns), so the tracked
|
||||||
|
// name is clean, e.g. "App/migrations/0001_x.sql" rather
|
||||||
|
// than "novaconium/../App/migrations/0001_x.sql".
|
||||||
|
$resolved = realpath($file) ?: $file;
|
||||||
|
$trackedName = str_starts_with($resolved, $repoRoot) ? substr($resolved, strlen($repoRoot)) : basename($file);
|
||||||
|
|
||||||
|
if (isset($applied[$trackedName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec((string) file_get_contents($file));
|
||||||
|
|
||||||
|
$insert = $pdo->prepare('INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)');
|
||||||
|
$insert->execute([$trackedName, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads config the same way bootstrap.php/bin scripts do (framework
|
||||||
|
* defaults shallow-merged with App/config.php), except for
|
||||||
|
* db_connections specifically: a shallow array_merge would let a
|
||||||
|
* project's App/config.php silently drop the framework's 'default'
|
||||||
|
* connection just by adding a second named connection (array_merge
|
||||||
|
* replaces the whole key, it doesn't merge inside it). db_connections
|
||||||
|
* is therefore merged one level deeper, by connection name, so adding
|
||||||
|
* e.g. 'legacy' in App/config.php doesn't require repeating 'default'.
|
||||||
|
* This is the one config key in the project that isn't plain
|
||||||
|
* shallow-merge — see AGENTS.md.
|
||||||
|
*
|
||||||
|
* @return array{db_connections: array<string, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
private static function config(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$appConfig = require $appConfigFile;
|
||||||
|
$defaultConnections = $config['db_connections'];
|
||||||
|
$appConnections = $appConfig['db_connections'] ?? [];
|
||||||
|
$config = array_merge($config, $appConfig);
|
||||||
|
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,10 @@ namespace Lib;
|
|||||||
* novaconium/src/Renderer.php), so this cleaning is a second layer, not the
|
* novaconium/src/Renderer.php), so this cleaning is a second layer, not the
|
||||||
* only one. The real defense against SQL injection is parameterized queries
|
* only one. The real defense against SQL injection is parameterized queries
|
||||||
* (PDO prepared statements) — never string concatenation or
|
* (PDO prepared statements) — never string concatenation or
|
||||||
* sanitize-then-interpolate, however "cleaned" the input looks. There's no
|
* sanitize-then-interpolate, however "cleaned" the input looks. Lib\Db (see
|
||||||
* database layer in this framework yet (SQLite groundwork is tracked as
|
* /admin/docs/database) is the database layer — its query() method uses
|
||||||
* Backlog in novaconium/ISSUES.md); when one lands, use PDO prepared statements
|
* PDO prepared statements exclusively for this reason. This class
|
||||||
* exclusively. This class deliberately does not (and will not) expose an
|
* deliberately does not (and will not) expose an
|
||||||
* "sqlSafe()"-style method — no string transform makes arbitrary input safe
|
* "sqlSafe()"-style method — no string transform makes arbitrary input safe
|
||||||
* to concatenate into SQL, and a method implying otherwise would be actively
|
* to concatenate into SQL, and a method implying otherwise would be actively
|
||||||
* dangerous.
|
* dangerous.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Lib;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal RSS 2.0 envelope builder — plain string concatenation, no
|
||||||
|
* DOMDocument, same style as novaconium/pages/sitemap.xml/index.php.
|
||||||
|
* Generic on purpose (title/link/description/items in, XML string out) —
|
||||||
|
* it doesn't know about blog posts specifically; App/pages/blog/feed/ and
|
||||||
|
* App/pages/blog/tag/[tag]/feed/ are the two call sites that supply
|
||||||
|
* blog-shaped data to it.
|
||||||
|
*
|
||||||
|
* Links/guids are expected to be site-relative paths (e.g.
|
||||||
|
* "/blog/hello-world"), consistent with how this framework already
|
||||||
|
* handles canonical/og:url (see /admin/docs/seo) — there's no site-wide
|
||||||
|
* base-URL config to build absolute URLs from. Every <guid> is emitted
|
||||||
|
* with isPermaLink="false" for exactly this reason: it's a stable
|
||||||
|
* identifier, not a real absolute permalink.
|
||||||
|
*/
|
||||||
|
final class Rss
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array{title: string, link: string, guid: string, pubDateTimestamp: int, description: string}> $items
|
||||||
|
*/
|
||||||
|
public static function render(string $channelTitle, string $channelLink, string $channelDescription, array $items): string
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
$xml .= '<rss version="2.0">' . "\n";
|
||||||
|
$xml .= ' <channel>' . "\n";
|
||||||
|
$xml .= ' <title>' . htmlspecialchars($channelTitle, ENT_XML1) . '</title>' . "\n";
|
||||||
|
$xml .= ' <link>' . htmlspecialchars($channelLink, ENT_XML1) . '</link>' . "\n";
|
||||||
|
$xml .= ' <description>' . htmlspecialchars($channelDescription, ENT_XML1) . '</description>' . "\n";
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$xml .= ' <item>' . "\n";
|
||||||
|
$xml .= ' <title>' . htmlspecialchars($item['title'], ENT_XML1) . '</title>' . "\n";
|
||||||
|
$xml .= ' <link>' . htmlspecialchars($item['link'], ENT_XML1) . '</link>' . "\n";
|
||||||
|
$xml .= ' <guid isPermaLink="false">' . htmlspecialchars($item['guid'], ENT_XML1) . '</guid>' . "\n";
|
||||||
|
$xml .= ' <pubDate>' . date(DATE_RSS, $item['pubDateTimestamp']) . '</pubDate>' . "\n";
|
||||||
|
$xml .= ' <description>' . htmlspecialchars($item['description'], ENT_XML1) . '</description>' . "\n";
|
||||||
|
$xml .= ' </item>' . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml .= ' </channel>' . "\n";
|
||||||
|
$xml .= '</rss>' . "\n";
|
||||||
|
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Lib;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin wrapper around PHP's native session handling — session_start()
|
||||||
|
* etc., not a custom session store — so sidecars have a consistent
|
||||||
|
* get/set/flash API instead of touching $_SESSION directly. All-static,
|
||||||
|
* lazy-start like Lib\Csrf: nothing calls session_start() until the first
|
||||||
|
* real call to any method here, so a page that never touches Session (or
|
||||||
|
* Csrf, which starts a session the same way) never gets a session cookie.
|
||||||
|
*
|
||||||
|
* ensureSession()'s body is deliberately duplicated from Csrf::ensureSession()
|
||||||
|
* rather than extracted into a shared helper — keeps Csrf standalone with
|
||||||
|
* zero new dependencies rather than coupling it to a class that didn't
|
||||||
|
* exist when it shipped, consistent with this project's tolerance for
|
||||||
|
* small duplication over premature coupling (see the config-load block
|
||||||
|
* duplicated across bootstrap.php/bin/clear-cache.php/Lib\Db::config()).
|
||||||
|
* Both classes touching the same native session in the same request is
|
||||||
|
* safe either way — session_status() guards against a double session_start().
|
||||||
|
*
|
||||||
|
* Flash data: a value set now via flash() is readable via getFlash() on
|
||||||
|
* exactly the next request, then gone — for post/redirect/GET flows like
|
||||||
|
* "message sent" banners, without a query-string flag. See
|
||||||
|
* /admin/docs/session for the mechanism and a worked example.
|
||||||
|
*/
|
||||||
|
final class Session
|
||||||
|
{
|
||||||
|
private const FLASH_KEY = '_flash';
|
||||||
|
|
||||||
|
private static bool $flashLoaded = false;
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private static array $currentFlash = [];
|
||||||
|
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
return $_SESSION[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function has(string $key): bool
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
return isset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function remove(string $key): void
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
unset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores $value so it's readable via getFlash($key) on the next
|
||||||
|
* request only, then gone — regardless of whether getFlash() was
|
||||||
|
* actually called on that next request.
|
||||||
|
*/
|
||||||
|
public static function flash(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
$_SESSION[self::FLASH_KEY][$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a value flashed on the previous request. Never reflects a
|
||||||
|
* value flashed during this same request — that value will be
|
||||||
|
* readable on the next request instead.
|
||||||
|
*/
|
||||||
|
public static function getFlash(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
return self::$currentFlash[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureSession(): void
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
// Must be called before session_start() — after is a silent no-op.
|
||||||
|
session_set_cookie_params([
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs once per request, on whichever Session method is called
|
||||||
|
// first: snapshot last request's flash bucket for this request's
|
||||||
|
// getFlash() reads, then immediately reset the session's bucket so
|
||||||
|
// flash() calls made during this request go to a fresh bucket —
|
||||||
|
// the one the *next* request will snapshot. This single swap is
|
||||||
|
// the entire flash mechanism; no separate expiry/sweep step needed,
|
||||||
|
// since static properties don't persist across requests.
|
||||||
|
if (!self::$flashLoaded) {
|
||||||
|
self::$currentFlash = $_SESSION[self::FLASH_KEY] ?? [];
|
||||||
|
$_SESSION[self::FLASH_KEY] = [];
|
||||||
|
self::$flashLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS content_pages (
|
||||||
|
route TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
keywords TEXT NOT NULL DEFAULT '',
|
||||||
|
changefreq TEXT NOT NULL DEFAULT '',
|
||||||
|
priority TEXT NOT NULL DEFAULT '',
|
||||||
|
source_mtime INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_tags (
|
||||||
|
route TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (route, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_tags_tag ON content_tags(tag);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS content_search USING fts5(route UNINDEXED, title, body);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_index_meta (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
newest_source_mtime INTEGER NOT NULL,
|
||||||
|
indexed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
{# Adds a hover-revealed copy button to every <pre><code> block on the
|
||||||
|
page, without touching any individual doc/blog page's markup. Reads
|
||||||
|
textContent (not innerHTML) when copying, so HTML-entity-escaped
|
||||||
|
samples (e.g. <h1> in the SEO starter template) come out as their
|
||||||
|
literal, unescaped characters rather than the escaped markup.
|
||||||
|
|
||||||
|
The icon markup is passed to JS via <template> elements (plain HTML
|
||||||
|
output, default autoescaping) rather than Twig's `|escape('js')`
|
||||||
|
filter — that filter calls Twig\Runtime\mb_ord() under the hood, which
|
||||||
|
hard-requires the mbstring extension and fatals
|
||||||
|
(`Call to undefined function Twig\Runtime\mb_ord()`) without it, the
|
||||||
|
same class of mbstring gotcha documented in AGENTS.md for `|slice` on
|
||||||
|
strings. #}
|
||||||
|
<template id="copy-code-icon-copy">{{ icons.copy() }}<span class="copy-code-label">Copy</span></template>
|
||||||
|
<template id="copy-code-icon-copied">{{ icons.check() }}<span class="copy-code-label">Copied!</span></template>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var copyIconHtml = document.getElementById('copy-code-icon-copy').innerHTML;
|
||||||
|
var checkIconHtml = document.getElementById('copy-code-icon-copied').innerHTML;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('pre').forEach(function (pre) {
|
||||||
|
if (!pre.querySelector('code')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'copy-code-button icon-link';
|
||||||
|
button.setAttribute('aria-label', 'Copy code to clipboard');
|
||||||
|
button.innerHTML = copyIconHtml + '<span class="copy-code-label">Copy</span>';
|
||||||
|
pre.appendChild(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var button = event.target.closest('.copy-code-button');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = button.closest('pre').querySelector('code');
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(code.textContent).then(function () {
|
||||||
|
var originalHtml = button.innerHTML;
|
||||||
|
|
||||||
|
button.innerHTML = checkIconHtml + '<span class="copy-code-label">Copied!</span>';
|
||||||
|
button.classList.add('copied');
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
button.innerHTML = originalHtml;
|
||||||
|
button.classList.remove('copied');
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
surrounding link/text color, including on :hover, with no extra CSS.
|
surrounding link/text color, including on :hover, with no extra CSS.
|
||||||
|
|
||||||
Available: home, git, book, link, sitemap, email, search, rss, tag,
|
Available: home, git, book, link, sitemap, email, search, rss, tag,
|
||||||
lock, trash, external_link, menu, back_to_top, sun, moon. Some
|
lock, trash, external_link, menu, back_to_top, sun, moon, copy, check.
|
||||||
(search, rss, tag) are ahead of the features that will use them (see
|
Some (search, rss, tag) are ahead of the features that will use them
|
||||||
novaconium/ISSUES.md) — added now so those features don't need an
|
(see novaconium/ISSUES.md) — added now so those features don't need an
|
||||||
icons.twig change later. #}
|
icons.twig change later. #}
|
||||||
|
|
||||||
{% macro home(class) %}
|
{% macro home(class) %}
|
||||||
@@ -144,3 +144,16 @@
|
|||||||
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
|
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro copy(class) %}
|
||||||
|
<svg class="icon icon-copy {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
|
<rect x="9" y="9" width="11" height="11" rx="1.5" />
|
||||||
|
<path d="M5.5 15H4.5A1.5 1.5 0 0 1 3 13.5v-9A1.5 1.5 0 0 1 4.5 3h9A1.5 1.5 0 0 1 15 4.5v1" />
|
||||||
|
</svg>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro check(class) %}
|
||||||
|
<svg class="icon icon-check {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M4.5 12.5 9.5 17.5 19.5 6.5" />
|
||||||
|
</svg>
|
||||||
|
{% endmacro %}
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
{% include '_layout/theme-init.twig' %}
|
{% include '_layout/theme-init.twig' %}
|
||||||
|
{% include '_layout/syntax-highlight-init.twig' %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}{{ site_name }}{% endblock %}</title>
|
<title>{% block title %}{{ site_name }}{% endblock %}</title>
|
||||||
<meta name="description" content="{% block description %}A tiny, Hugo-flavored PHP micro-framework site.{% endblock %}">
|
<meta name="description" content="{% block description %}A tiny, Hugo-flavored PHP micro-framework site.{% endblock %}">
|
||||||
<meta name="robots" content="{% block robots %}index, follow{% endblock %}">
|
<meta name="robots" content="{% block robots %}index, follow{% endblock %}">
|
||||||
|
<meta name="keywords" content="{% block keywords %}{% endblock %}">
|
||||||
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
|
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
|
||||||
|
{# tags/changefreq/priority (below) are metadata-only, not meant to be
|
||||||
|
visible on the page. A block tag always emits its content wherever
|
||||||
|
it's declared, though — and a Twig comment tag can't wrap a block
|
||||||
|
tag (Twig comments are stripped before parsing, so a nested block
|
||||||
|
tag inside one would never compile; don't put literal Twig
|
||||||
|
delimiter syntax inside a Twig comment's text either, for the same
|
||||||
|
reason — it terminates the comment early). So these three are
|
||||||
|
wrapped in a real HTML comment instead: invisible to a
|
||||||
|
reader/browser, but still genuine Twig blocks, overridable per-page
|
||||||
|
and harvestable by ContentIndexer via Twig's renderBlock() API
|
||||||
|
exactly like every SEO block above. See /admin/docs/content-index. #}
|
||||||
|
<!--
|
||||||
|
{% block tags %}{% endblock %}
|
||||||
|
{% block changefreq %}monthly{% endblock %}
|
||||||
|
{% block priority %}0.5{% endblock %}
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
{# Open Graph / Facebook #}
|
{# Open Graph / Facebook #}
|
||||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||||
<meta property="og:title" content="{% block og_title %}{{ block('title') }}{% endblock %}">
|
<meta property="og:title" content="{% block og_title %}{{ block('title') }}{% endblock %}">
|
||||||
@@ -25,6 +46,15 @@
|
|||||||
<link rel="stylesheet" href="/css/main.css">
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
|
||||||
{% include '_layout/matomo.twig' %}
|
{% include '_layout/matomo.twig' %}
|
||||||
|
|
||||||
|
{# Open-ended extension point for anything a subtree's own layout
|
||||||
|
needs in <head> that doesn't fit an existing named block — e.g.
|
||||||
|
App/pages/blog/_layout/layout.twig overrides this with a
|
||||||
|
<link rel="alternate" type="application/rss+xml"> for feed
|
||||||
|
auto-discovery, scoped to /blog/* only since only that layout
|
||||||
|
overrides it. Empty by default, so nothing changes for a page that
|
||||||
|
doesn't need it. #}
|
||||||
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -35,6 +65,12 @@
|
|||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<small>© {{ "now"|date("Y") }} {{ site_name }}</small>
|
<small>© {{ "now"|date("Y") }} {{ site_name }}</small>
|
||||||
|
<nav class="footer-menu">
|
||||||
|
{% if content_index_enabled %}<a class="icon-link" href="/sitemap.xml">{{ icons.sitemap() }}Sitemap</a>{% endif %}
|
||||||
|
<a class="icon-link" href="/blog/feed">{{ icons.rss() }}RSS Feed</a>
|
||||||
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% include '_layout/code-copy.twig' %}
|
||||||
|
{% include '_layout/syntax-highlight.twig' %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{# Creates the highlight.js theme <link> with the correct href before
|
||||||
|
paint, so switching to light doesn't flash the dark (ir-black) code
|
||||||
|
theme first — same FOUC-avoidance trick theme-init.twig uses for the
|
||||||
|
main palette. Must run after theme-init.twig (data-theme needs to
|
||||||
|
already be set on <html>) and before the stylesheet link — see
|
||||||
|
novaconium/pages/_layout/layout.twig. The live swap (when the toggle
|
||||||
|
button is clicked after page load) is handled separately by
|
||||||
|
novaconium/pages/_layout/syntax-highlight.twig's MutationObserver. #}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||||
|
var link = document.createElement('link');
|
||||||
|
link.id = 'hljs-theme';
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = isLight ? '/vendor/highlightjs/styles/github.min.css' : '/vendor/highlightjs/styles/ir-black.min.css';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{# Colors <pre><code> blocks site-wide via vendored highlight.js
|
||||||
|
(public/vendor/highlightjs/ — see /admin/docs/upgrading-highlightjs),
|
||||||
|
auto-detected and restricted to only the languages this site actually
|
||||||
|
uses (hljs.configure below) so it doesn't waste cycles or misfire
|
||||||
|
trying to match ~40 bundled languages against a handful of short
|
||||||
|
snippets. php/bash/css/python/javascript/xml ship in the core
|
||||||
|
highlight.min.js bundle; yaml/json/ini don't (checked, not assumed —
|
||||||
|
see /admin/docs/upgrading-highlightjs) and are vendored as separate
|
||||||
|
per-language files under languages/, loaded after the core bundle so
|
||||||
|
their hljs.registerLanguage(...) self-registration calls have a global
|
||||||
|
hljs to register against. Twig-syntax code blocks have no highlight.js
|
||||||
|
grammar and are NOT auto-detected against — forcing one through the
|
||||||
|
restricted candidate set above would still force-match it to whichever
|
||||||
|
configured language scores highest, coloring it *wrong* rather than
|
||||||
|
leaving it plain. Those blocks are marked class="nohighlight" by hand
|
||||||
|
at the source (a real highlight.js convention meaning "skip this block
|
||||||
|
entirely") — see AGENTS.md for which files have them and why.
|
||||||
|
|
||||||
|
The copy-to-clipboard button (code-copy.twig) needs no changes for
|
||||||
|
this: it already reads code.textContent, not innerHTML, which stays
|
||||||
|
the original plain text regardless of the <span> wrapping
|
||||||
|
highlightAll() adds.
|
||||||
|
|
||||||
|
hljs.highlightAll() does NOT defer itself if called while the document
|
||||||
|
is still parsing (document.readyState === "loading") — it just silently
|
||||||
|
no-ops, permanently, rather than waiting and retrying. Confirmed this
|
||||||
|
with a real DOM test, not assumed: calling it immediately (unwrapped)
|
||||||
|
produced zero highlighted blocks even though this script tag sits near
|
||||||
|
the end of <body>, since the document can still be mid-parse at that
|
||||||
|
exact point. So this is wrapped in the same DOMContentLoaded pattern
|
||||||
|
code-copy.twig already uses for its own button injection, rather than
|
||||||
|
called directly. #}
|
||||||
|
<script src="/vendor/highlightjs/highlight.min.js"></script>
|
||||||
|
<script src="/vendor/highlightjs/languages/yaml.min.js"></script>
|
||||||
|
<script src="/vendor/highlightjs/languages/json.min.js"></script>
|
||||||
|
<script src="/vendor/highlightjs/languages/ini.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
hljs.configure({ languages: ['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json', 'ini'] });
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
hljs.highlightAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
var themeLink = document.getElementById('hljs-theme');
|
||||||
|
|
||||||
|
new MutationObserver(function () {
|
||||||
|
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||||
|
themeLink.href = isLight ? '/vendor/highlightjs/styles/github.min.css' : '/vendor/highlightjs/styles/ir-black.min.css';
|
||||||
|
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -13,7 +13,13 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li>
|
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a></li>
|
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a></li>
|
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
|
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
||||||
@@ -23,6 +29,7 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a></li>
|
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a></li>
|
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a></li>
|
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="docs-content">
|
<div class="docs-content">
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ return [
|
|||||||
|
|
||||||
<p><code>novaconium/src/AdminAuth.php</code> is a single reusable check — <code>AdminAuth::requireLogin($username, $passwordHash)</code> — called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists.</p>
|
<p><code>novaconium/src/AdminAuth.php</code> is a single reusable check — <code>AdminAuth::requireLogin($username, $passwordHash)</code> — called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists.</p>
|
||||||
|
|
||||||
|
<p>The credential check itself is a separate method, <code>AdminAuth::isAuthenticated($username, $passwordHash)</code> — <code>requireLogin()</code> is just that check plus the <code>401</code>-challenge response on failure. <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> reuse <code>isAuthenticated()</code> directly with a different failure response (a plain <code>404</code>, not a login prompt), rather than duplicating the credential logic.</p>
|
||||||
|
|
||||||
<h2>Logging out</h2>
|
<h2>Logging out</h2>
|
||||||
|
|
||||||
<p>HTTP Basic Auth has no real server-side logout — the browser just keeps resending the same cached credentials on every request to that realm. Visiting <code>/admin/logout</code> works around this: <code>AdminAuth::logout()</code> always issues a fresh <code>401</code> challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time <code>/admin</code> is visited. Credentials themselves aren't invalidated server-side (there's nothing to invalidate — it's just a password check on every request), so this is a client-side-only logout, same as any Basic Auth site. The "Logout" link only appears on <code>/admin</code> when <code>admin_auth_enabled</code> is true (i.e. a password is actually set).</p>
|
<p>HTTP Basic Auth has no real server-side logout — the browser just keeps resending the same cached credentials on every request to that realm. Visiting <code>/admin/logout</code> works around this: <code>AdminAuth::logout()</code> always issues a fresh <code>401</code> challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time <code>/admin</code> is visited. Credentials themselves aren't invalidated server-side (there's nothing to invalidate — it's just a password check on every request), so this is a client-side-only logout, same as any Basic Auth site. The "Logout" link only appears on <code>/admin</code> when <code>admin_auth_enabled</code> is true (i.e. a password is actually set).</p>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
{% block title %}Static caching{% endblock %}
|
{% block title %}Static caching{% endblock %}
|
||||||
|
|
||||||
{% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %}
|
{% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %}
|
||||||
@@ -11,6 +13,8 @@
|
|||||||
|
|
||||||
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/<path>/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p>
|
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/<path>/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p>
|
||||||
|
|
||||||
|
<p>Two kinds of route are excluded from the cache unconditionally, regardless of whether they have a sidecar: every <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft page</a> and every <code>/admin/*</code> route. Both are gated by <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}HTTP Basic Auth</a>, and a cached copy would bypass that check entirely — <code>.htaccess</code> serves a cached file before PHP (and therefore any auth check) ever runs, so a cached admin or draft page would be served to anyone, unauthenticated, forever after the first authenticated view. See <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> for the full write-up.</p>
|
||||||
|
|
||||||
<p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p>
|
<p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1>Configuration</h1>
|
<h1>Configuration</h1>
|
||||||
|
|
||||||
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_username</code>, <code>admin_password_hash</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
|
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_username</code>, <code>admin_password_hash</code>, <code>db_connections</code>, <code>draft_routes</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
|
||||||
|
|
||||||
<p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p>
|
<p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p>
|
||||||
|
|
||||||
@@ -39,6 +39,10 @@ return [
|
|||||||
|
|
||||||
<p><code>admin_username</code> / <code>admin_password_hash</code> gate every <code>/admin/*</code> route behind HTTP Basic Auth — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up. Both are set via <code>App/config.php</code>; leaving <code>admin_password_hash</code> empty (the default) disables the gate.</p>
|
<p><code>admin_username</code> / <code>admin_password_hash</code> gate every <code>/admin/*</code> route behind HTTP Basic Auth — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up. Both are set via <code>App/config.php</code>; leaving <code>admin_password_hash</code> empty (the default) disables the gate.</p>
|
||||||
|
|
||||||
|
<h2>Draft pages</h2>
|
||||||
|
|
||||||
|
<p><code>draft_routes</code> (default <code>[]</code>) is a list of routes only an authenticated admin can see — everyone else gets a plain <code>404</code>. Requires <code>admin_password_hash</code> above to be set to actually gate anything. See <a href="/admin/docs/drafts">Draft pages</a> for the full write-up, including why a cached draft page would be a security problem and how that's avoided.</p>
|
||||||
|
|
||||||
<h2>For developers: using <code>Cache.php</code> directly</h2>
|
<h2>For developers: using <code>Cache.php</code> directly</h2>
|
||||||
|
|
||||||
<p><code>novaconium/src/Cache.php</code> is the class behind the <code>cache_dir</code> config key above — a small, dependency-free wrapper around writing/deleting the static HTML files under <code>public/cache/</code> that <a href="/admin/docs/caching">Static caching</a> describes. Like <code>Router</code> (see <code>/admin/docs/routing</code>'s "For developers" section), it's plain and easy to reason about in isolation: no Twig, no request state, just a path convention and some filesystem calls.</p>
|
<p><code>novaconium/src/Cache.php</code> is the class behind the <code>cache_dir</code> config key above — a small, dependency-free wrapper around writing/deleting the static HTML files under <code>public/cache/</code> that <a href="/admin/docs/caching">Static caching</a> describes. Like <code>Router</code> (see <code>/admin/docs/routing</code>'s "For developers" section), it's plain and easy to reason about in isolation: no Twig, no request state, just a path convention and some filesystem calls.</p>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Content index{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}The shared crawler behind /sitemap.xml, /search, and blog tag browsing.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Content index</h1>
|
||||||
|
|
||||||
|
<p>Three features — <a class="icon-link" href="/sitemap.xml">{{ icons.sitemap() }}/sitemap.xml</a>, <a class="icon-link" href="/search">{{ icons.search() }}/search</a>, and blog <a class="icon-link" href="/admin/docs/seo">{{ icons.tag() }}tag browsing</a> — share one underlying mechanism rather than three separate ones: a crawler (<code>App\ContentIndexer</code>, <code>novaconium/src/ContentIndexer.php</code>) that renders every routable page, pulls its metadata, and stores it in SQLite.</p>
|
||||||
|
|
||||||
|
<p>Content itself stays in files — plain Twig pages, same as everywhere else in this framework. Nothing here moves a page's body into a database; only metadata is extracted and indexed.</p>
|
||||||
|
|
||||||
|
<h2>Off by default</h2>
|
||||||
|
|
||||||
|
<p>All three features depend on <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}SQLite</a> — a real dependency plenty of sites built on this framework won't want at all, the same reasoning that keeps Matomo and admin authentication off until a project opts in. <code>content_index_enabled</code> (default <code>false</code>) gates the whole subsystem:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
// App/config.php
|
||||||
|
return [
|
||||||
|
'content_index_enabled' => true,
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p>When it's off, <code>/sitemap.xml</code>, <code>/search</code>, and every <code>/blog/tag/<tag></code> route return a plain <code>404</code> — exactly as if they didn't exist — and nothing ever touches <code>Lib\Db</code> because of this feature. <code>data/novaconium.sqlite</code> isn't created just because the code is present in the codebase; each consumer checks the flag before constructing anything that would open a connection.</p>
|
||||||
|
|
||||||
|
<h2>Declaring metadata on a page</h2>
|
||||||
|
|
||||||
|
<p>Four Twig blocks, declared in <code>novaconium/pages/_layout/layout.twig</code> next to the rest of the <a href="/admin/docs/seo">SEO blocks</a> — same override mechanism, just harvested rather than always rendered:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Block</th><th>Default</th><th>Used for</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>keywords</code></td><td>empty</td><td>rendered as <code><meta name="keywords"></code> on every page (see <a href="/admin/docs/seo">SEO</a>)</td></tr>
|
||||||
|
<tr><td><code>tags</code></td><td>empty</td><td>comma-separated; indexed into <code>content_tags</code> for tag browsing</td></tr>
|
||||||
|
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td><code>/sitemap.xml</code>'s <code><changefreq></code></td></tr>
|
||||||
|
<tr><td><code>priority</code></td><td><code>0.5</code></td><td><code>/sitemap.xml</code>'s <code><priority></code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight">{% verbatim %}{% block tags %}yellow, the best, summer, hot{% endblock %}
|
||||||
|
{% block changefreq %}weekly{% endblock %}
|
||||||
|
{% block priority %}1.0{% endblock %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
|
<p>See any post under <code>App/pages/blog/</code> for a working <code>tags</code> example.</p>
|
||||||
|
|
||||||
|
<h2>How the crawl works</h2>
|
||||||
|
|
||||||
|
<p><code>ContentIndexer::reindex()</code> walks every page under both page roots (<code>Overlay::listPageDirs()</code>, skipping <code>_</code>-prefixed, <code>404</code>, and <code>[param]</code>-wildcard directories — a wildcard route's concrete values aren't knowable without a data source, so dynamic routes aren't crawled yet), renders each one via <code>Renderer::renderForIndex()</code> (the same sidecar-then-Twig path a real request takes, minus HTTP output and the static cache write), and pulls each metadata block via Twig's own <code>renderBlock()</code> API — not by parsing <code>.twig</code> source — so App-over-novaconium overrides and layout inheritance resolve exactly the way they do for a real visit.</p>
|
||||||
|
|
||||||
|
<p>A page is skipped entirely (not stored) if it's listed in <a href="/admin/docs/drafts">{{ icons.lock() }}draft_routes</a>, or if its resolved <code>robots</code> block contains <code>noindex</code> — the same convention <a href="/admin/docs/seo">SEO</a> already documents for admin/internal pages. The rendered HTML is stripped with <code>strip_tags()</code> for a plain-text copy stored in a SQLite <a href="https://sqlite.org/fts5.html">FTS5</a> virtual table for search.</p>
|
||||||
|
|
||||||
|
<p>Every reindex is a full rebuild, not incremental — all three tables are truncated and repopulated inside one transaction, simple and correct at this scale rather than trying to diff what changed. Because the crawl renders every page's sidecar as a plain <code>GET</code> (forcing <code>$_SERVER['REQUEST_METHOD']</code> to <code>'GET'</code> for the duration, regardless of what triggered the reindex, and restoring it afterward), a POST-guarded sidecar action is never accidentally triggered by indexing — the same HTTP-safe-method hygiene any GET handler is already expected to have.</p>
|
||||||
|
|
||||||
|
<h2>When it runs</h2>
|
||||||
|
|
||||||
|
<p>Two paths, both calling the same <code>reindex()</code>:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Lazy (default):</strong> <code>ContentIndexer::ensureFresh()</code>, called from the <code>/sitemap.xml</code>, <code>/search</code>, and tag-browsing sidecars themselves — never from a normal page view, so browsing the rest of the site never pays any indexing cost. It compares the newest source-file mtime across every page (a cheap stat pass, no rendering) against the last indexed time, and only reindexes if something actually changed. Set <code>content_index_auto</code> to <code>false</code> to disable this and rely only on the CLI below.</li>
|
||||||
|
<li><strong>Explicit:</strong> <code>php novaconium/bin/index-content.php</code> — same shape as <code>bin/migrate.php</code>, for a deploy step. Always reindexes (ignores <code>content_index_auto</code>); exits immediately if <code>content_index_enabled</code> is <code>false</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Search</h2>
|
||||||
|
|
||||||
|
<p><code>/search?q=...</code> runs an FTS5 <code>MATCH</code> query. The search term is wrapped as a quoted phrase (embedded <code>"</code> doubled) before binding — parameter binding stops SQL injection, but the bound value is still parsed as its own FTS5 query-language expression, so an unescaped <code>"</code> or FTS operator in user input could otherwise throw a syntax error or search for something unintended.</p>
|
||||||
|
|
||||||
|
<h2>Sitemap</h2>
|
||||||
|
|
||||||
|
<p>See <a href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> for the full write-up — per-page <code>changefreq</code>/<code>priority</code>, what's included/excluded, and a known limitation around relative <code><loc></code> URLs.</p>
|
||||||
|
|
||||||
|
<h2>Blog tag browsing</h2>
|
||||||
|
|
||||||
|
<p><code>App/pages/blog/tag/[tag]/index.php</code> — project-owned, since <code>blog/</code> itself is project content — uses the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture to read the tag from the URL and queries <code>content_tags</code> joined to <code>content_pages</code>. <code>App/pages/blog/index.php</code>'s own hand-written post list is untouched by any of this — it stays the source of truth for the main blog listing; <code>content_tags</code> is a derived index built from each post's own <code>tags</code> block, not a replacement for it.</p>
|
||||||
|
|
||||||
|
<p><code>App/pages/blog/tag/[tag]/feed/index.php</code> is the same query rendered as an RSS feed instead of an HTML list, one directory deeper — <code>/blog/tag/<tag>/feed</code>. Unlike the main blog feed, this one depends on the content index (gated the same way <code>blog/tag/[tag]/index.php</code> itself is), since tags only exist once it's enabled. See <a href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> for the full write-up — <code>Lib\Rss</code>, the main blog feed, and how to add more feeds for other content collections.</p>
|
||||||
|
|
||||||
|
<h2>Migrations, and the two-root scan</h2>
|
||||||
|
|
||||||
|
<p>The schema (<code>content_pages</code>, <code>content_tags</code>, <code>content_search</code>, <code>content_index_meta</code>) ships as a framework migration, <code>novaconium/migrations/0001_create_content_index.sql</code> — the first framework-owned migration, and the reason <a href="/admin/docs/database">{{ icons.book() }}<code>migrations_dir</code></a> now accepts an ordered list of roots instead of a single path: the default connection's <code>migrations_dir</code> is <code>[novaconium/migrations, App/migrations]</code>, so framework migrations always apply before a project's own on the same connection.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Database{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Lib\Db — a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Database</h1>
|
||||||
|
|
||||||
|
<p><code>Lib\Db</code> (<code>novaconium/lib/Db.php</code>) is the SQLite/MySQL groundwork tracked in <code>novaconium/ISSUES.md</code> — a thin PDO wrapper plus a minimal migration runner, no ORM and no query builder, consistent with this project's no-Composer, no-build-step philosophy. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Db.php</code>.</p>
|
||||||
|
|
||||||
|
<p>It supports multiple, independently-configured, <strong>simultaneously open</strong> named connections rather than a single global one — because a sidecar is plain PHP with full access to any <code>Lib\</code> class, a single request can legitimately need more than one database at once, e.g. this site's own SQLite data alongside a MySQL connection to a legacy or external database.</p>
|
||||||
|
|
||||||
|
<h2>Using it</h2>
|
||||||
|
|
||||||
|
<pre><code>use Lib\Db;
|
||||||
|
|
||||||
|
// Targets the 'default' connection — reads exactly like a single-database API.
|
||||||
|
$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])->fetchAll();
|
||||||
|
|
||||||
|
// A third argument targets any other configured connection by name, and can
|
||||||
|
// be used in the same request/script as the default connection above.
|
||||||
|
$legacyRows = Db::query('SELECT * FROM widgets', [], 'legacy')->fetchAll();</code></pre>
|
||||||
|
|
||||||
|
<p><code>Db::query(string $sql, array $params = [], string $connection = 'default')</code> prepares and executes against the named connection in one call, returning the <code>PDOStatement</code>. It's the only query-running helper this class exposes — there is deliberately no string-interpolation convenience method. <code>Db::connection(string $name = 'default')</code> returns the raw <code>PDO</code> instance for anything <code>query()</code> doesn't cover (transactions, <code>lastInsertId()</code>, etc.).</p>
|
||||||
|
|
||||||
|
<p><strong>Always use parameter binding, never string-concatenate values into SQL</strong> — the same rule <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Input</a>'s own documentation already commits to: cleaning input is defense-in-depth against HTML/script injection, not SQL injection, and no string transform makes arbitrary input safe to concatenate into a query. Parameterized queries are the only real defense, so <code>Db</code> never grows an <code>sqlSafe()</code>-style shortcut.</p>
|
||||||
|
|
||||||
|
<p>Each connection is opened lazily and independently — nothing touches a given database or runs its migrations until the first real call naming that connection, so a request that only ever uses <code>default</code> never pays to open <code>legacy</code>.</p>
|
||||||
|
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
|
||||||
|
<p>Connections are a named map under a single <code>db_connections</code> key. The framework default defines only <code>default</code> (SQLite):</p>
|
||||||
|
|
||||||
|
<pre><code>// novaconium/config.php (framework default)
|
||||||
|
'db_connections' => [
|
||||||
|
'default' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||||
|
'migrations_dir' => [
|
||||||
|
__DIR__ . '/migrations',
|
||||||
|
__DIR__ . '/../App/migrations',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],</code></pre>
|
||||||
|
|
||||||
|
<p>Add a MySQL connection alongside it from <code>App/config.php</code>:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
// App/config.php
|
||||||
|
return [
|
||||||
|
'db_connections' => [
|
||||||
|
'legacy' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 3306,
|
||||||
|
'database' => 'legacy_app',
|
||||||
|
'username' => 'root',
|
||||||
|
'password' => '...',
|
||||||
|
'charset' => 'utf8mb4', // optional, defaults to utf8mb4
|
||||||
|
'migrations_dir' => __DIR__ . '/migrations/legacy', // optional
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p><strong>This is the one config key in the project that doesn't follow the usual shallow-merge rule.</strong> Every other <code>App/config.php</code> key replaces the framework default outright (see <a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a>) — but a plain shallow merge on <code>db_connections</code> would let the snippet above silently delete the framework's <code>default</code> connection just by adding <code>legacy</code>. So <code>Lib\Db</code> merges <code>db_connections</code> one level deeper, by connection name: the example above ends up with both <code>default</code> (SQLite, from the framework) and <code>legacy</code> (MySQL, from <code>App/config.php</code>) configured at once. To actually replace <code>default</code>, redeclare a <code>default</code> key yourself.</p>
|
||||||
|
|
||||||
|
<p>Only <code>'sqlite'</code> and <code>'mysql'</code> are implemented as <code>driver</code> values. <code>migrations_dir</code> is optional per connection — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for) — and accepts either a single path (<code>legacy</code>'s example above) or an ordered list of roots (the default connection's example above), each scanned for its own <code>*.sql</code> files.</p>
|
||||||
|
|
||||||
|
<p>The default connection's <code>path</code> lives in a top-level <code>data/</code> directory — a sibling of <code>App/</code>, <code>novaconium/</code>, and <code>public/</code>, not nested inside any of them. This is deliberate: it can't live under <code>public/</code> (would be directly web-accessible), and it can't live under <code>novaconium/</code> either, since <a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}updating the framework</a> means overwriting that whole directory — anything persisted there would be destroyed by the next update. <code>data/</code> is project-owned, like <code>App/</code>, and untouched by a framework update. Its contents (<code>*.sqlite</code> and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a <code>.gitkeep</code> is tracked so the directory exists in a fresh clone.</p>
|
||||||
|
|
||||||
|
<h2>Migrations</h2>
|
||||||
|
|
||||||
|
<p>Plain <code>.sql</code> files under each connection's own <code>migrations_dir</code> root(s), applied in filename order within each root — name them with a numeric prefix to control ordering:</p>
|
||||||
|
|
||||||
|
<pre><code>-- App/migrations/0001_create_posts.sql
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL
|
||||||
|
);</code></pre>
|
||||||
|
|
||||||
|
<p>Each file is tracked by its path relative to the repo root (e.g. <code>novaconium/migrations/0001_x.sql</code>) in that connection's own <code>schema_migrations</code> table (created automatically in that connection's database) and only ever run once — <code>default</code> and <code>legacy</code> each track their own applied migrations independently. Tracking the repo-relative path rather than the bare filename matters once a connection has more than one <code>migrations_dir</code> root: two roots can each contain a same-named file (e.g. a framework <code>0001_...sql</code> and an unrelated project <code>0001_...sql</code>) without one being mistaken for the other already having run. Migrations for a given connection apply automatically the first time it's used in a process — zero-config, the same "just works" philosophy as static caching — or explicitly for every configured connection at once, without serving a request first:</p>
|
||||||
|
|
||||||
|
<pre><code>php novaconium/bin/migrate.php</code></pre>
|
||||||
|
|
||||||
|
<p>When a connection has multiple <code>migrations_dir</code> roots, each root is fully processed in the order given — the default connection's own framework root (<code>novaconium/migrations/</code>) always applies completely before its project root (<code>App/migrations/</code>), not interleaved by filename across the two. <code>novaconium/migrations/0001_create_content_index.sql</code> (see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>) is the first framework-shipped migration — the reason this two-root support exists at all, extending the same App-over-novaconium override pattern used for pages and lib to migrations too. Point two connections' <code>migrations_dir</code> at different directories (e.g. <code>App/migrations/</code> for <code>default</code>, <code>App/migrations/legacy/</code> for <code>legacy</code>) if their SQL genuinely diverges between drivers; otherwise the same directory works for both as long as the SQL in it is portable.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Draft pages{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Let an admin preview a page before the public can see it, without a second login mechanism.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Draft pages</h1>
|
||||||
|
|
||||||
|
<p>List a page's route under <code>draft_routes</code> in <code>App/config.php</code> to make it visible only to an authenticated admin — anyone else gets a plain <code>404</code>, exactly as if the page didn't exist at all:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
// App/config.php
|
||||||
|
return [
|
||||||
|
'admin_username' => 'admin',
|
||||||
|
'admin_password_hash' => '$2y$10$...',
|
||||||
|
'draft_routes' => ['blog/upcoming-post'],
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p>Each entry matches the same path format <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> resolves internally — no leading slash, directory segments joined with <code>/</code> (e.g. <code>App/pages/blog/upcoming-post/</code> is listed as <code>'blog/upcoming-post'</code>).</p>
|
||||||
|
|
||||||
|
<h2>Not a login prompt</h2>
|
||||||
|
|
||||||
|
<p>An unauthenticated visitor to a draft route gets the site's normal 404 page — not a <code>401</code> Basic Auth challenge like <code>/admin/*</code> gives. This is deliberate: prompting for a login would itself reveal that something is gated at that URL. A draft is indistinguishable from a URL that was never routable in the first place.</p>
|
||||||
|
|
||||||
|
<p>There's no separate login flow for drafts, and none is needed — <code>AdminAuth::isAuthenticated()</code> (the same credential check <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a>'s <code>requireLogin()</code> uses) is reused directly. In practice, an admin authenticates once by visiting <code>/admin</code> and entering credentials there; HTTP Basic Auth credentials are scoped to the whole origin/realm, not a single path, so the browser then resends those same credentials automatically on later requests to a draft URL too, without a second prompt.</p>
|
||||||
|
|
||||||
|
<h2>The caching interaction</h2>
|
||||||
|
|
||||||
|
<p>Sidecar-less pages normally get pre-rendered once and served as static HTML straight from <code>public/cache/</code> on every later request (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — <code>.htaccess</code> checks for that cached file <strong>before PHP, and therefore any auth check, ever runs</strong>. A draft page without its own sidecar would otherwise take that exact path: the moment an authenticated admin previewed it, the rendered HTML would be written to the cache as a plain file, and every subsequent visitor — authenticated or not — would be served it directly by Apache, permanently bypassing the draft gate.</p>
|
||||||
|
|
||||||
|
<p>Draft routes are therefore excluded from the static cache unconditionally, regardless of whether the page has a sidecar — <code>novaconium/bootstrap.php</code> passes this down to <code>Renderer::render()</code>'s <code>$excludeFromCache</code> parameter. Every <code>/admin/*</code> route gets the same exclusion, for the identical reason (most admin pages have no sidecar either).</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -87,7 +87,7 @@ return [
|
|||||||
|
|
||||||
<p>The honeypot field and the hidden timestamp are the two pieces every form needs regardless of its actual fields — copy them as-is:</p>
|
<p>The honeypot field and the hidden timestamp are the two pieces every form needs regardless of its actual fields — copy them as-is:</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block title %}Newsletter{% endblock %}
|
{% block title %}Newsletter{% endblock %}
|
||||||
{% block description %}Sign up for occasional updates.{% endblock %}
|
{% block description %}Sign up for occasional updates.{% endblock %}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1>Getting started</h1>
|
<h1>Getting started</h1>
|
||||||
|
|
||||||
<p><strong>Requirements:</strong> PHP 8.1+ and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>.</p>
|
<p><strong>Requirements:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>. The <a href="/admin/docs/database">Database</a>/<a href="/admin/docs/content-index">Content index</a> features are optional and off by default — see <a href="/admin/docs">Overview</a> for the extensions they need (<code>pdo_sqlite</code>, optionally <code>pdo_mysql</code>/FTS5) if you turn them on.</p>
|
||||||
|
|
||||||
<h2>Run it locally (no Apache needed)</h2>
|
<h2>Run it locally (no Apache needed)</h2>
|
||||||
|
|
||||||
@@ -26,6 +26,32 @@
|
|||||||
|
|
||||||
<p>Point the vhost's document root at <code>public/</code>, make sure <code>mod_rewrite</code> is enabled and <code>AllowOverride All</code> is set for that directory so <code>public/.htaccess</code> takes effect, and it just works — no build step required.</p>
|
<p>Point the vhost's document root at <code>public/</code>, make sure <code>mod_rewrite</code> is enabled and <code>AllowOverride All</code> is set for that directory so <code>public/.htaccess</code> takes effect, and it just works — no build step required.</p>
|
||||||
|
|
||||||
|
<h2>Starting a new project</h2>
|
||||||
|
|
||||||
|
<p>Clone this repo and drop its Git history — that's it, there's no Composer scaffold or installer:</p>
|
||||||
|
|
||||||
|
<pre><code>git clone --depth 1 <novaconium-repo-url> my-new-project
|
||||||
|
cd my-new-project
|
||||||
|
rm -rf .git
|
||||||
|
git init
|
||||||
|
git add -A
|
||||||
|
git commit -m "Initial commit from novaconium template"</code></pre>
|
||||||
|
|
||||||
|
<p>Then replace the example content that ships under <code>App/pages/</code> (the <code>about</code>/<code>blog</code>/<code>contact</code> sample pages) with your own pages, lib classes, and config. Leave <code>novaconium/</code> and <code>public/</code> as-is.</p>
|
||||||
|
|
||||||
|
<h2>Updating the framework</h2>
|
||||||
|
|
||||||
|
<p>Because the framework core lives entirely under <code>novaconium/</code> — separate from your project's <code>App/</code> — picking up a new release is a matter of overwriting that one directory and committing the diff:</p>
|
||||||
|
|
||||||
|
<pre><code>git clone --depth 1 --branch <release-tag> <novaconium-repo-url> /tmp/nova-update
|
||||||
|
rm -rf novaconium
|
||||||
|
cp -r /tmp/nova-update/novaconium ./novaconium
|
||||||
|
rm -rf /tmp/nova-update
|
||||||
|
git add novaconium
|
||||||
|
git commit -m "Update novaconium framework to <release-tag>"</code></pre>
|
||||||
|
|
||||||
|
<p>This is safe by construction: the override-by-path design means <code>App/</code> always wins over <code>novaconium/</code> for pages, lib classes, and Sass colors (see <a href="/admin/docs/project-layout">Project layout</a>), so an update can't clobber your project's customizations. Diff before committing to see what changed, and run <code>php novaconium/bin/clear-cache.php</code> afterward since a framework update can change rendered output.</p>
|
||||||
|
|
||||||
<h2>Adding a new page</h2>
|
<h2>Adding a new page</h2>
|
||||||
|
|
||||||
<p>Create a directory under <code>App/pages/</code> with an <code>index.twig</code> — the directory path <em>is</em> the URL (see <a href="/admin/docs/routing">Routing</a>). <a href="/admin/docs/seo">SEO</a> has a ready-to-paste starter template with every overridable block (title, description, Open Graph, Twitter Card) plus a content stub — copy it in and fill in the blanks.</p>
|
<p>Create a directory under <code>App/pages/</code> with an <code>index.twig</code> — the directory path <em>is</em> the URL (see <a href="/admin/docs/routing">Routing</a>). <a href="/admin/docs/seo">SEO</a> has a ready-to-paste starter template with every overridable block (title, description, Open Graph, Twitter Card) plus a content stub — copy it in and fill in the blanks.</p>
|
||||||
|
|||||||
@@ -4,13 +4,28 @@
|
|||||||
|
|
||||||
{% block title %}Docs{% endblock %}
|
{% block title %}Docs{% endblock %}
|
||||||
|
|
||||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, layouts, caching, styling.{% endblock %}
|
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, layouts, caching, styling.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}noindex, nofollow{% endblock %}
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1 class="icon-heading">{{ icons.book() }}Project documentation</h1>
|
<h1 class="icon-heading">{{ icons.book() }}Project documentation</h1>
|
||||||
<p>Framework docs, rendered as plain Twig pages — no internet connection needed.</p>
|
<p>Framework docs, rendered as plain Twig pages — no internet connection needed.</p>
|
||||||
|
|
||||||
|
<h2>Requirements</h2>
|
||||||
|
|
||||||
|
<p><strong>Minimum:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code> — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
||||||
|
|
||||||
|
<p><strong>Optional, for the database/content-index features</strong> (<a href="/admin/docs/database">Database</a>, <a href="/admin/docs/content-index">Content index</a> — both off by default):</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>The <code>pdo_sqlite</code> extension — bundled with PHP, just needs to be enabled, no separate install. Required for <code>Lib\Db</code>'s default (SQLite) connection.</li>
|
||||||
|
<li><code>pdo_mysql</code> — only if using a MySQL <code>db_connections</code> entry alongside or instead of SQLite.</li>
|
||||||
|
<li>SQLite's FTS5 extension — bundled with <code>pdo_sqlite</code> on virtually every modern PHP build. Only needed for <code>/search</code>, i.e. only relevant if <code>content_index_enabled</code> is turned on.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
|
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
|
||||||
@@ -18,7 +33,13 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
|
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> — plain PHP classes under <code>Lib\</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> — plain PHP classes under <code>Lib\</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a> — override framework settings from <code>App/config.php</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a> — override framework settings from <code>App/config.php</code>.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a> — the shared crawler behind <code>/sitemap.xml</code>, <code>/search</code>, and blog tag browsing. Off by default.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> — per-page <code>changefreq</code>/<code>priority</code>, what's included, and submitting it to search engines.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> — <code>Lib\Rss</code>, building one feed or several for any content collection.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
|
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
|
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
|
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
|
||||||
@@ -28,5 +49,6 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a> — vendored Twig and its license.</li>
|
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a> — vendored Twig and its license.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a> — the original design rationale.</li>
|
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a> — the original design rationale.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a> — how to bump the vendored copy.</li>
|
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a> — how to bump the vendored copy.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> — how to bump the vendored copy, and why it's not automatic on a framework update.</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ App/pages/blog/_layout/layout.twig <- overrides it for everything under
|
|||||||
|
|
||||||
<p>A nested layout can extend the parent one (paths are resolved against the page roots, not relative to the current file):</p>
|
<p>A nested layout can extend the parent one (paths are resolved against the page roots, not relative to the current file):</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends 'admin/docs/_layout/layout.twig' %}{% endverbatim %}</code></pre>
|
<pre><code class="nohighlight">{% verbatim %}{% extends 'admin/docs/_layout/layout.twig' %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
<p>Every <code>index.twig</code> extends whichever layout was resolved for it, via the <code>layout</code> variable that's always injected into the context:</p>
|
<p>Every <code>index.twig</code> extends whichever layout was resolved for it, via the <code>layout</code> variable that's always injected into the context:</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
{% block content %}...{% endblock %}{% endverbatim %}</code></pre>
|
{% block content %}...{% endblock %}{% endverbatim %}</code></pre>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}RSS feeds{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Lib\Rss — building one feed, or several, for any content collection.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.rss() }}RSS feeds</h1>
|
||||||
|
|
||||||
|
<p><code>Lib\Rss</code> (<code>novaconium/lib/Rss.php</code>) is a small, generic RSS 2.0 envelope builder — plain string concatenation, no <code>DOMDocument</code>, the same style as <code>/sitemap.xml</code> (see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>). It doesn't know anything about blog posts, or content in general — it just turns a channel title/link/description plus a list of items into an XML string. Every feed on this site, and any feed a project adds, is a plain sidecar-only page (like <code>sitemap.xml</code>/<code>search</code>) that gathers its own items from wherever they live and hands them to it.</p>
|
||||||
|
|
||||||
|
<h2><code>Lib\Rss::render()</code></h2>
|
||||||
|
|
||||||
|
<pre><code>use Lib\Rss;
|
||||||
|
|
||||||
|
$xml = Rss::render(
|
||||||
|
'My Site Blog', // channel title
|
||||||
|
'/blog', // channel link
|
||||||
|
'Posts from My Site.', // channel description
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'title' => 'Post title',
|
||||||
|
'link' => '/blog/post-slug',
|
||||||
|
'guid' => '/blog/post-slug',
|
||||||
|
'pubDateTimestamp' => strtotime('2026-07-11'),
|
||||||
|
'description' => 'A short excerpt or summary.',
|
||||||
|
],
|
||||||
|
// ...one array per item
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response::xml($xml);</code></pre>
|
||||||
|
|
||||||
|
<p><code>link</code>/<code>guid</code> are expected to be site-relative paths (e.g. <code>/blog/post-slug</code>), consistent with how this framework already handles <code>canonical</code>/<code>og:url</code> (see <a href="/admin/docs/seo">{{ icons.book() }}SEO</a>) — there's no site-wide base-URL config to build absolute URLs from. Every <code><guid></code> is emitted with <code>isPermaLink="false"</code> for exactly this reason: it's a stable identifier, not a real absolute permalink.</p>
|
||||||
|
|
||||||
|
<h2>The two feeds shipped with this site</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>/blog/feed</code> (<code>App/pages/blog/feed/index.php</code>) — the main blog feed. Reads the same hand-written <code>$posts</code> array <code>App/pages/blog/index.php</code> itself renders from, so it has <strong>zero dependency on the content index</strong> — it works even with <code>content_index_enabled</code> left at its default <code>false</code>.</li>
|
||||||
|
<li><code>/blog/tag/<tag>/feed</code> (<code>App/pages/blog/tag/[tag]/feed/index.php</code>) — one feed per tag, generated dynamically via the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture rather than a static file per tag. This one <em>does</em> need the content index (same gate as <code>blog/tag/[tag]/index.php</code>), since tags only exist there — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>. Its <code><pubDate></code> is each page's <code>source_mtime</code>, a stand-in for a real publish date the content index doesn't separately track.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Adding another feed</h2>
|
||||||
|
|
||||||
|
<p>Nothing is registered or limited to "one feed" — any sidecar-only page that builds an item list and calls <code>Rss::render()</code> is a feed. For a second content collection (say, <code>App/pages/products/</code>, with its own hand-written array like <code>App/pages/blog/index.php</code>'s), a new <code>App/pages/products/feed/index.php</code> looks almost identical to <code>blog/feed</code>:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Rss;
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../../../../novaconium/config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = (require __DIR__ . '/../index.php')['products'];
|
||||||
|
|
||||||
|
$items = array_map(
|
||||||
|
fn (array $p) => [
|
||||||
|
'title' => $p['title'],
|
||||||
|
'link' => '/products/' . $p['slug'],
|
||||||
|
'guid' => '/products/' . $p['slug'],
|
||||||
|
'pubDateTimestamp' => strtotime($p['published']),
|
||||||
|
'description' => $p['excerpt'],
|
||||||
|
],
|
||||||
|
$products
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response::xml(Rss::render(
|
||||||
|
$config['site_name'] . ' Products',
|
||||||
|
'/products',
|
||||||
|
'New products from ' . $config['site_name'] . '.',
|
||||||
|
$items
|
||||||
|
));</code></pre>
|
||||||
|
|
||||||
|
<p>Two independent feeds now exist side by side — <code>/blog/feed</code> and <code>/products/feed</code> — with no shared state, registry, or configuration between them. A project can have as many as it has content collections.</p>
|
||||||
|
|
||||||
|
<h2>Auto-discovery for more than one feed</h2>
|
||||||
|
|
||||||
|
<p><a href="/admin/docs/seo">{{ icons.book() }}SEO</a>'s <code>head_extra</code> block is how a feed gets a <code><link rel="alternate" type="application/rss+xml"></code> tag in <code><head></code> so browsers/feed readers can discover it — <code>App/pages/blog/_layout/layout.twig</code> overrides it with exactly one such tag, scoped to <code>/blog/*</code> pages only since only that layout overrides the block. A layout isn't limited to one <code><link></code> in that override — list several, each with its own <code>title</code> attribute so a feed reader can tell them apart:</p>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight">{% verbatim %}{% block head_extra %}
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Products" href="/products/feed">
|
||||||
|
{% endblock %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
|
<p>Where that override lives determines which pages advertise which feeds — put it in the root layout (<code>novaconium/pages/_layout/layout.twig</code>) for a feed that should be discoverable site-wide, or in a subtree's own <code>_layout/layout.twig</code> (like <code>App/pages/blog/_layout/layout.twig</code> does today) to scope it to just that subtree. A page can also declare a feed link that isn't a listing of that page's own subtree at all — there's no rule tying a <code>head_extra</code> override to the feed(s) "belonging" to that directory, it's just the natural place to put it for the common case.</p>
|
||||||
|
|
||||||
|
<h2>Verifying a feed</h2>
|
||||||
|
|
||||||
|
<p>No test suite — check a feed is well-formed XML with matching item counts before trusting it, the same way this site's own feeds were verified while being built:</p>
|
||||||
|
|
||||||
|
<pre><code>curl -s http://127.0.0.1:8000/blog/feed | php -r '
|
||||||
|
$xml = stream_get_contents(STDIN);
|
||||||
|
$parsed = simplexml_load_string($xml);
|
||||||
|
echo $parsed === false ? "INVALID XML\n" : "valid, " . count($parsed->channel->item) . " items\n";
|
||||||
|
'</code></pre>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1>SEO boilerplate</h1>
|
<h1>SEO boilerplate</h1>
|
||||||
|
|
||||||
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code><head></code>: viewport, description, robots, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <code><head></code>.</p>
|
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code><head></code>: viewport, description, robots, keywords, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <code><head></code>. Three more blocks — <code>tags</code>, <code>changefreq</code>, <code>priority</code> — are declared the same way but never rendered into the page at all; see <a href="/admin/docs/content-index">Content index</a> for what reads them.</p>
|
||||||
|
|
||||||
<h2>Blocks you can override</h2>
|
<h2>Blocks you can override</h2>
|
||||||
|
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
<tr><td><code>title</code></td><td><code>site_name</code> config value</td><td><code><title></code>, and reused by <code>og:title</code> / <code>twitter:title</code></td></tr>
|
<tr><td><code>title</code></td><td><code>site_name</code> config value</td><td><code><title></code>, and reused by <code>og:title</code> / <code>twitter:title</code></td></tr>
|
||||||
<tr><td><code>description</code></td><td>generic site description</td><td><code><meta name="description"></code>, and reused by <code>og:description</code> / <code>twitter:description</code></td></tr>
|
<tr><td><code>description</code></td><td>generic site description</td><td><code><meta name="description"></code>, and reused by <code>og:description</code> / <code>twitter:description</code></td></tr>
|
||||||
<tr><td><code>robots</code></td><td><code>index, follow</code></td><td><code><meta name="robots"></code></td></tr>
|
<tr><td><code>robots</code></td><td><code>index, follow</code></td><td><code><meta name="robots"></code></td></tr>
|
||||||
|
<tr><td><code>keywords</code></td><td>empty</td><td><code><meta name="keywords"></code></td></tr>
|
||||||
|
<tr><td><code>tags</code></td><td>empty</td><td>not rendered — comma-separated, harvested by <a href="/admin/docs/content-index">the content index</a> for blog tag browsing</td></tr>
|
||||||
|
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code><changefreq></code></td></tr>
|
||||||
|
<tr><td><code>priority</code></td><td><code>0.5</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code><priority></code></td></tr>
|
||||||
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code><link rel="canonical"></code>, and reused by <code>og:url</code></td></tr>
|
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code><link rel="canonical"></code>, and reused by <code>og:url</code></td></tr>
|
||||||
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code><meta property="og:type"></code></td></tr>
|
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code><meta property="og:type"></code></td></tr>
|
||||||
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta property="og:title"></code></td></tr>
|
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta property="og:title"></code></td></tr>
|
||||||
@@ -29,6 +33,7 @@
|
|||||||
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code><meta name="twitter:card"></code></td></tr>
|
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code><meta name="twitter:card"></code></td></tr>
|
||||||
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta name="twitter:title"></code></td></tr>
|
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta name="twitter:title"></code></td></tr>
|
||||||
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta name="twitter:description"></code></td></tr>
|
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta name="twitter:description"></code></td></tr>
|
||||||
|
<tr><td><code>head_extra</code></td><td>empty</td><td>open-ended — anything a subtree's own layout needs in <code><head></code> that doesn't fit an existing block. <code>App/pages/blog/_layout/layout.twig</code> overrides it with the blog's RSS <code><link rel="alternate"></code>, scoped to <code>/blog/*</code> only since only that layout overrides it.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -38,7 +43,7 @@
|
|||||||
|
|
||||||
<p>Any <code>index.twig</code> can override any subset of these blocks, same as <code>title</code> or <code>content</code>:</p>
|
<p>Any <code>index.twig</code> can override any subset of these blocks, same as <code>title</code> or <code>content</code>:</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block title %}Pricing{% endblock %}
|
{% block title %}Pricing{% endblock %}
|
||||||
{% block description %}Plans and pricing for the whole team.{% endblock %}
|
{% block description %}Plans and pricing for the whole team.{% endblock %}
|
||||||
@@ -54,12 +59,16 @@
|
|||||||
|
|
||||||
<p>Every <code>App/pages/*/index.twig</code> page in this project already includes the full block below as a reference — copy it into a new page and fill in the blanks. Nothing here is required (the layout's defaults are fine on their own), but having every knob visible up front makes it obvious what's available. Don't want to copy-paste by hand? <code>php novaconium/bin/create-static-page.php <path></code> scaffolds this exact template for you — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
<p>Every <code>App/pages/*/index.twig</code> page in this project already includes the full block below as a reference — copy it into a new page and fill in the blanks. Nothing here is required (the layout's defaults are fine on their own), but having every knob visible up front makes it obvious what's available. Don't want to copy-paste by hand? <code>php novaconium/bin/create-static-page.php <path></code> scaffolds this exact template for you — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block title %}Page title{% endblock %}
|
{% block title %}Page title{% endblock %}
|
||||||
{% block description %}One or two sentences describing this page.{% endblock %}
|
{% block description %}One or two sentences describing this page.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block keywords %}{% endblock %}
|
||||||
|
{% block tags %}{% endblock %}
|
||||||
|
{% block changefreq %}monthly{% endblock %}
|
||||||
|
{% block priority %}0.5{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}website{% endblock %}
|
{% block og_type %}website{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Session{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Lib\Session — a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Session</h1>
|
||||||
|
|
||||||
|
<p><code>Lib\Session</code> (<code>novaconium/lib/Session.php</code>) is a thin wrapper around PHP's native session handling — plain <code>session_start()</code>/<code>$_SESSION</code>, not a custom session store — so sidecars have a consistent get/set API instead of touching <code>$_SESSION</code> directly. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Session.php</code>.</p>
|
||||||
|
|
||||||
|
<h2>Using it</h2>
|
||||||
|
|
||||||
|
<pre><code>use Lib\Session;
|
||||||
|
|
||||||
|
Session::set('user_id', 42);
|
||||||
|
$userId = Session::get('user_id'); // 42
|
||||||
|
$loggedIn = Session::has('user_id'); // true
|
||||||
|
Session::remove('user_id');</code></pre>
|
||||||
|
|
||||||
|
<p>The session is started lazily — nothing calls <code>session_start()</code> until the first real call to any <code>Session</code> method, so a page that never touches <code>Session</code> never gets a session cookie. <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Csrf</a> uses the exact same lazy-start mechanism to run its own session-token CSRF protection — both classes can touch the same native session in the same request without conflict, since <code>session_start()</code> is only ever actually called once (PHP no-ops a second call).</p>
|
||||||
|
|
||||||
|
<h2>Flash data</h2>
|
||||||
|
|
||||||
|
<p>A flashed value is readable on exactly the next request, then gone — useful for post/redirect/GET flows (a "message sent" banner after a redirect) without a query-string flag like <code>?sent=1</code>:</p>
|
||||||
|
|
||||||
|
<pre><code>use Lib\Session;
|
||||||
|
use App\Response;
|
||||||
|
|
||||||
|
// In the sidecar handling the POST:
|
||||||
|
Session::flash('message', 'Sent! We\'ll be in touch soon.');
|
||||||
|
return Response::redirect('/contact');
|
||||||
|
|
||||||
|
// In the sidecar handling the following GET (the redirect target):
|
||||||
|
return [
|
||||||
|
'flashMessage' => Session::getFlash('message'),
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p><code>Session::getFlash($key, $default = null)</code> returns the value on the request immediately after <code>flash()</code> was called, and the default on every request after that — regardless of whether <code>getFlash()</code> was actually called on that one request in between. A value flashed during the current request is never visible to <code>getFlash()</code> during that same request; it becomes visible on the next one.</p>
|
||||||
|
|
||||||
|
<p>Mechanically, this is a single swap rather than a separate expiry/sweep step: the first time any <code>Session</code> method runs in a request, it snapshots whatever was flashed on the previous request into an in-memory value for that request's <code>getFlash()</code> calls, then immediately clears the stored flash bucket so <code>flash()</code> calls made during the current request start filling a fresh bucket — the one the next request will snapshot in turn.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -63,7 +63,7 @@ $all = Input::post(); // the whole cleaned $_POST array</code><
|
|||||||
|
|
||||||
<p>Calling <code>post()</code>/<code>get()</code> with no key returns the entire cleaned array (handy for passing straight to <code>SpamGuard::isSpam()</code>, as below); calling it with a key returns that key's cleaned value, or the given default if it's absent. Nested arrays (e.g. a checkbox group posted as <code>tags[]</code>) are cleaned recursively. The result is memoized per request, so calling <code>Input::post()</code> repeatedly across a sidecar doesn't re-clean the superglobal each time.</p>
|
<p>Calling <code>post()</code>/<code>get()</code> with no key returns the entire cleaned array (handy for passing straight to <code>SpamGuard::isSpam()</code>, as below); calling it with a key returns that key's cleaned value, or the given default if it's absent. Nested arrays (e.g. a checkbox group posted as <code>tags[]</code>) are cleaned recursively. The result is memoized per request, so calling <code>Input::post()</code> repeatedly across a sidecar doesn't re-clean the superglobal each time.</p>
|
||||||
|
|
||||||
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries (PDO prepared statements). There's no database layer in this framework yet (SQLite groundwork is Backlog — see <code>novaconium/ISSUES.md</code>); when one lands, use prepared statements exclusively. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ '{{ }}' }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries. <a href="/admin/docs/database">Lib\Db</a>'s <code>query()</code> method uses PDO prepared statements exclusively for exactly this reason. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
||||||
|
|
||||||
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later. See <code>novaconium/pages/admin/password-hash/index.php</code> for the one place this framework does that on purpose.</p>
|
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later. See <code>novaconium/pages/admin/password-hash/index.php</code> for the one place this framework does that on purpose.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}XML sitemap{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}/sitemap.xml — generated from the content index, with per-page changefreq/priority.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.sitemap() }}XML sitemap</h1>
|
||||||
|
|
||||||
|
<p><code>/sitemap.xml</code> (<code>novaconium/pages/sitemap.xml/index.php</code>) lists every indexed page for search engine discovery, following the <a href="https://www.sitemaps.org/protocol.html">sitemaps.org protocol</a>: one <code><url></code> entry per page with <code><loc></code>, <code><lastmod></code>, <code><changefreq></code>, and <code><priority></code>. It's a framework default — a directory literally named <code>sitemap.xml</code> under <code>novaconium/pages/</code>; <code>Router</code> only ever splits the request path on <code>/</code>, so that resolves the literal <code>/sitemap.xml</code> URL correctly, no special extension-routing involved.</p>
|
||||||
|
|
||||||
|
<p>Sidecar-only — no <code>index.twig</code>, since <code>Response::xml(...)</code> bypasses Twig entirely (see <a href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' JSON-only example for the same pattern). Built entirely from the <a href="/admin/docs/content-index">{{ icons.search() }}content index</a> — it's one of the three routes gated by <code>content_index_enabled</code> (default <code>false</code>), so it 404s exactly like a route that doesn't exist until that's turned on.</p>
|
||||||
|
|
||||||
|
<h2>Per-page <code>changefreq</code>/<code>priority</code></h2>
|
||||||
|
|
||||||
|
<p>Two Twig blocks, declared in <code>novaconium/pages/_layout/layout.twig</code> alongside the rest of the <a href="/admin/docs/seo">{{ icons.book() }}SEO</a> blocks — same override mechanism, just harvested by the content index rather than rendered into the page:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Block</th><th>Default</th><th>Sitemap element</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td><code><changefreq></code> — how often the page is expected to change (<code>always</code>/<code>hourly</code>/<code>daily</code>/<code>weekly</code>/<code>monthly</code>/<code>yearly</code>/<code>never</code>, per the protocol)</td></tr>
|
||||||
|
<tr><td><code>priority</code></td><td><code>0.5</code></td><td><code><priority></code> — relative priority against this site's own other pages, <code>0.0</code>–<code>1.0</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight">{% verbatim %}{% block changefreq %}weekly{% endblock %}
|
||||||
|
{% block priority %}1.0{% endblock %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
|
<p>A page that doesn't override either just gets the layout's defaults — nothing to set for most pages. Bump <code>priority</code> on a handful of pages that matter most (the homepage, key landing pages) rather than trying to rank every page precisely; search engines treat this as a hint, not a strict ordering.</p>
|
||||||
|
|
||||||
|
<h2>What's included, what isn't</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Only pages the content index actually indexes — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a> for the full crawl rules. In short: any page whose resolved <code>robots</code> block contains <code>noindex</code> (every <code>/admin/*</code> page already does) or that's listed in <a href="/admin/docs/drafts">{{ icons.lock() }}draft_routes</a> is skipped.</li>
|
||||||
|
<li><code>[param]</code>-wildcard routes (e.g. <code>blog/tag/[tag]</code>) aren't crawled at all — a wildcard's concrete values aren't knowable without a data source, so dynamic routes like individual tag pages don't get their own sitemap entries. This is a known V1 limitation, not an oversight.</li>
|
||||||
|
<li><code><lastmod></code> is the page's own source file's mtime (<code>content_pages.source_mtime</code>), not when the sitemap itself was last regenerated — it reflects when the content actually last changed.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>A known limitation: relative <code><loc></code> URLs</h2>
|
||||||
|
|
||||||
|
<p>The sitemaps.org protocol calls for <code><loc></code> to be a fully-qualified absolute URL. This framework's <code><loc></code> values are site-relative paths instead (e.g. <code>/about</code>, not <code>https://example.com/about</code>) — consistent with how <code>canonical</code>/<code>og:url</code> already work (see <a href="/admin/docs/seo">{{ icons.book() }}SEO</a>), since there's no site-wide base-URL config to build absolute URLs from. Most tooling tolerates this, but it isn't strictly spec-compliant, and a search engine or validator that enforces the letter of the protocol may reject entries. If that matters for a given deployment, override <code>novaconium/pages/sitemap.xml/index.php</code> with your own <code>App/pages/sitemap.xml/index.php</code> (same override-by-path mechanism as any other framework default) and prefix each <code><loc></code> with the site's real domain.</p>
|
||||||
|
|
||||||
|
<h2>How it's built</h2>
|
||||||
|
|
||||||
|
<p>Calls <code>ContentIndexer::ensureFresh()</code> (the lazy reindex-if-stale check — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>), queries <code>content_pages</code>, and builds the XML with plain string concatenation — no <code>DOMDocument</code>, this site's scale doesn't need one. Every value is still <code>htmlspecialchars(..., ENT_XML1)</code>-escaped, since <code>route</code>/<code>changefreq</code>/<code>priority</code> all ultimately come from page-author-controlled Twig blocks, not hardcoded constants.</p>
|
||||||
|
|
||||||
|
<h2>Submitting it to search engines</h2>
|
||||||
|
|
||||||
|
<p>This framework doesn't ship a <code>public/robots.txt</code> — add one yourself with a <code>Sitemap:</code> line pointing at wherever <code>/sitemap.xml</code> ends up being served, and/or submit the URL directly through each search engine's own webmaster tools (e.g. Google Search Console). Re-submission isn't needed on every change — crawlers revisit periodically on their own, and <code><lastmod></code> is the signal that tells them what's actually changed since their last visit.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -57,4 +57,14 @@ docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
|
|||||||
<p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code><html></code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code><head></code> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.</p>
|
<p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code><html></code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code><head></code> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.</p>
|
||||||
|
|
||||||
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p>
|
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p>
|
||||||
|
|
||||||
|
<h2>Syntax-highlighted code blocks follow the same toggle</h2>
|
||||||
|
|
||||||
|
<p><a href="/admin/docs/upgrading-highlightjs">highlight.js</a> colors PHP/Bash/HTML(XML) <code><pre><code></code> blocks site-wide, and swaps between two vendored themes — <strong>ir-black</strong> (dark) and <strong>github</strong> (light) — the same way the rest of the palette does, but as a separate mechanism from the Sass variables above, since these are two plain vendored CSS files, not compiled from this project's own <code>_colors.sass</code>. <code>novaconium/pages/_layout/syntax-highlight-init.twig</code> creates the correct theme <code><link></code> before paint (same FOUC-avoidance trick as <code>theme-init.twig</code>), and <code>novaconium/pages/_layout/syntax-highlight.twig</code> watches the <code>data-theme</code> attribute with a <code>MutationObserver</code> to swap it live when the toggle button is clicked — it doesn't need to know about the toggle button itself, only the attribute it already mutates.</p>
|
||||||
|
|
||||||
|
<p>A code block written in Twig syntax has no highlight.js grammar to match against — those are marked <code>class="nohighlight"</code> by hand at the source rather than highlighted incorrectly. See <code>AGENTS.md</code> for which files have them.</p>
|
||||||
|
|
||||||
|
<h2>Copy-to-clipboard on code blocks</h2>
|
||||||
|
|
||||||
|
<p><code>novaconium/pages/_layout/code-copy.twig</code>, included once from <code>_layout/layout.twig</code>'s footer, injects a hover-revealed copy button into every <code><pre></code> containing a <code><code></code> on the page — no per-page markup needed. It copies via <code>code.textContent</code> (not <code>innerHTML</code>), so HTML-entity-escaped samples like <code>&lt;h1&gt;</code> in the <a href="/admin/docs/seo">SEO</a> starter template come out as literal characters, not escaped markup. Uses the same event-delegation pattern as the theme toggle in <code>_layout/nav.twig</code>: one document-level click listener rather than a listener per button.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block title %}Third-party{% endblock %}
|
{% block title %}Third-party{% endblock %}
|
||||||
|
|
||||||
{% block description %}Vendored Twig and its license.{% endblock %}
|
{% block description %}Vendored Twig and highlight.js, and their licenses.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}noindex, nofollow{% endblock %}
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
@@ -10,4 +10,6 @@
|
|||||||
<h1>Third-party</h1>
|
<h1>Third-party</h1>
|
||||||
|
|
||||||
<p><a href="https://twig.symfony.com/">Twig</a> is vendored in source form under <code>novaconium/vendor/twig/</code> (no Composer — see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a> for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at <code>novaconium/vendor/twig/LICENSE</code>.</p>
|
<p><a href="https://twig.symfony.com/">Twig</a> is vendored in source form under <code>novaconium/vendor/twig/</code> (no Composer — see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a> for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at <code>novaconium/vendor/twig/LICENSE</code>.</p>
|
||||||
|
|
||||||
|
<p><a href="https://highlightjs.org/">highlight.js</a> (v11.11.1) is vendored as its built distribution under <code>public/vendor/highlightjs/</code> — not <code>novaconium/vendor/</code> like Twig, since it's fetched by the browser and only <code>public/</code> is web-reachable (see <a href="/admin/docs/upgrading-highlightjs">Upgrading highlight.js</a>). Also BSD-3-Clause; the license text ships at <code>public/vendor/highlightjs/LICENSE</code>.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Upgrading highlight.js{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}How to bump the vendored copy of highlight.js.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Upgrading vendored highlight.js</h1>
|
||||||
|
|
||||||
|
<p>Like Twig (see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a>), highlight.js is vendored by hand — no Composer, no npm, no lockfile. Upgrading is a manual copy-and-verify process.</p>
|
||||||
|
|
||||||
|
<h2>The one thing that makes this different from every other vendored/framework file</h2>
|
||||||
|
|
||||||
|
<p>Everything else under <code>novaconium/</code> gets refreshed automatically when a project runs the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow (<code>rm -rf novaconium && cp -r <new-novaconium></code>). highlight.js is vendored under <code>public/vendor/highlightjs/</code> instead, because its files are fetched by the browser and only <code>public/</code> is the Apache document root — <code>novaconium/</code> isn't web-reachable at all. <code>public/</code> is project-owned and that update workflow never touches it. <strong>A future framework release that bumps the vendored highlight.js version will not update it on an existing project automatically</strong> — re-vendoring it is a separate, manual step, following this page, even after an otherwise-routine framework update.</p>
|
||||||
|
|
||||||
|
<h2>What's currently vendored</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Version: <strong>11.11.1</strong> (see the version comment at the top of <code>public/vendor/highlightjs/highlight.min.js</code> — that comment is always the source of truth, this doc can drift).</li>
|
||||||
|
<li>The root <code>build/highlight.min.js</code> bundle from <a href="https://github.com/highlightjs/cdn-release">highlightjs/cdn-release</a> — includes <code>php</code>, <code>bash</code>, <code>css</code>, <code>python</code>, <code>javascript</code>, and <code>xml</code> (covers HTML) out of the box.</li>
|
||||||
|
<li>Three separate per-language files under <code>public/vendor/highlightjs/languages/</code> — <code>yaml.min.js</code>, <code>json.min.js</code>, <code>ini.min.js</code> (covers <code>.env</code>-style key/value files too) — checked, not assumed, that these aren't part of the core bundle before vendoring them separately from <code>build/languages/</code> in the same <code>cdn-release</code> repo, at the same pinned tag.</li>
|
||||||
|
<li>Two themes: <code>public/vendor/highlightjs/styles/ir-black.min.css</code> (dark) and <code>styles/github.min.css</code> (light), swapped via the site's existing <code>data-theme</code> toggle — see <a href="/admin/docs/styling">Styling</a>.</li>
|
||||||
|
<li><code>public/vendor/highlightjs/LICENSE</code> (BSD-3-Clause) — covers the per-language files too, same license, same repo.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>All nine languages are why <code>hljs.configure({ languages: [...] })</code> in <code>novaconium/pages/_layout/syntax-highlight.twig</code> lists <code>['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json', 'ini']</code> — see <a href="/blog/code-highlighting">the Code Highlighting post</a> for a worked example of each.</p>
|
||||||
|
|
||||||
|
<h2>How to upgrade</h2>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Pick a target version from the <a href="https://github.com/highlightjs/cdn-release/tags">cdn-release tags</a> — always a stable tag (e.g. <code>11.x.y</code>), never the <code>main</code> branch, which tracks an in-progress pre-release (this project's own vendored copy was pinned from tag <code>11.11.1</code> specifically for this reason, not <code>main</code>).</li>
|
||||||
|
<li>Download, from that same tag: <code>build/highlight.min.js</code>, <code>build/languages/yaml.min.js</code>, <code>build/languages/json.min.js</code>, <code>build/languages/ini.min.js</code>, <code>build/styles/ir-black.min.css</code>, <code>build/styles/github.min.css</code>, and <code>build/LICENSE</code>.</li>
|
||||||
|
<li>Replace the seven files under <code>public/vendor/highlightjs/</code> wholesale (<code>highlight.min.js</code>, the three files under <code>languages/</code>, the two under <code>styles/</code>, and <code>LICENSE</code>).</li>
|
||||||
|
<li>Confirm <code>php</code>, <code>bash</code>, <code>css</code>, <code>python</code>, and <code>javascript</code> are still present in the new core bundle (e.g. <code>grep -o '"php"' highlight.min.js</code>) — the root bundle's included-language set can change between releases; if one of these ever drops out, either vendor that language's individual file from <code>build/languages/</code> the same way <code>yaml</code>/<code>json</code>/<code>ini</code> already are, or adjust <code>hljs.configure({ languages: [...] })</code> to match what's actually available.</li>
|
||||||
|
<li>Run the app and check: code blocks still get colored on <a href="/blog/code-highlighting">the Code Highlighting post</a> (all nine languages, one worked example each) and the theme still swaps live with the dark/light toggle, and a <code>class="nohighlight"</code> Twig-syntax block (e.g. on <code>/admin/docs/seo</code>) still renders plain, uncolored. There's no automated test suite, so this is the real regression check.</li>
|
||||||
|
<li>Update the version note at the top of this page.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Adding a new highlighted language</h2>
|
||||||
|
|
||||||
|
<p>If a project adds code blocks in a language outside the nine above, vendor that language's file from <code>build/languages/<name>.min.js</code> in <code>cdn-release</code> (at the same pinned tag as everything else) into <code>public/vendor/highlightjs/languages/</code>, add a <code><script src="/vendor/highlightjs/languages/<name>.min.js"></script></code> tag after the core bundle (and after the other <code>languages/</code> scripts, order between them doesn't matter) in <code>novaconium/pages/_layout/syntax-highlight.twig</code>, and add the language's name to the <code>hljs.configure({ languages: [...] })</code> call there — each language file self-registers against the global <code>hljs</code> via its own <code>hljs.registerLanguage(...)</code> call once loaded, no other wiring needed.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// /search — a framework default (novaconium/pages/, not App/pages/) since
|
||||||
|
// full-text search over the whole site is generic machinery, not project
|
||||||
|
// content. Has both this sidecar and an index.twig, unlike sitemap.xml —
|
||||||
|
// it renders a real HTML page (a form plus results), not a bypass-Twig
|
||||||
|
// Response.
|
||||||
|
|
||||||
|
use App\ContentIndexer;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Input;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input::get(), not $_GET directly — see /admin/docs/sidecars' "Form
|
||||||
|
// security" section. Only Lib\Input's HTML/script-injection cleaning
|
||||||
|
// matters here (this value never reaches SQL unparameterized either way,
|
||||||
|
// see the FTS5 escaping note below).
|
||||||
|
$query = trim((string) Input::get('q', ''));
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
// Bare /search (no ?q=) just shows the empty form — skip the query and
|
||||||
|
// the reindex-freshness check entirely, so landing on the page cold costs
|
||||||
|
// nothing beyond the normal page render.
|
||||||
|
if ($query !== '') {
|
||||||
|
// Lazy reindex-if-stale — a no-op on most requests (only actually
|
||||||
|
// reindexes when a page's source file changed since the last index).
|
||||||
|
// Deliberately guarded by $query !== '' rather than called
|
||||||
|
// unconditionally at the top of the file: ContentIndexer's own crawl
|
||||||
|
// renders every real page, including this one, so /search visiting
|
||||||
|
// itself with an empty query during a crawl must NOT trigger another
|
||||||
|
// reindex — ContentIndexer also has its own reentrancy guard for this
|
||||||
|
// (see its docblock), but not paying the freshness-check cost on
|
||||||
|
// every bare page load is a second, independent reason this call sits
|
||||||
|
// inside the if.
|
||||||
|
ContentIndexer::ensureFresh();
|
||||||
|
|
||||||
|
// Parameter binding prevents SQL injection, but the bound value is
|
||||||
|
// still parsed as its own FTS5 query-language expression, not a plain
|
||||||
|
// string — a literal " or an FTS operator in $query could otherwise
|
||||||
|
// throw a syntax error or search for something unintended. Wrapping it
|
||||||
|
// as a quoted phrase (doubling any embedded ") makes the whole query
|
||||||
|
// an FTS5 phrase-match literal, neutralizing that syntax entirely.
|
||||||
|
// Verified against a literal ", "*", "OR", and a "'; DROP TABLE ..."
|
||||||
|
// attempt — all return normal (possibly empty) results, no fatal, no
|
||||||
|
// effect on the database.
|
||||||
|
$ftsQuery = '"' . str_replace('"', '""', $query) . '"';
|
||||||
|
|
||||||
|
// content_search is the FTS5 virtual table ContentIndexer::reindex()
|
||||||
|
// populates with route/title/stripped-HTML body — "rank" is an FTS5
|
||||||
|
// built-in column (not one we define) giving relevance ordering, most
|
||||||
|
// relevant first. LIMIT 50 is just a sanity cap, not real pagination.
|
||||||
|
$results = Db::query(
|
||||||
|
'SELECT route, title FROM content_search WHERE content_search MATCH ? ORDER BY rank LIMIT 50',
|
||||||
|
[$ftsQuery]
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// A second query rather than joining content_pages into the FTS5
|
||||||
|
// query directly — content_search is a virtual table, and mixing a
|
||||||
|
// real table JOIN into an FTS5 MATCH query is more fragile than doing
|
||||||
|
// the description lookup separately. IN (...) with one placeholder
|
||||||
|
// per route, built from the exact route set the first query returned
|
||||||
|
// — never string-interpolating $query or user input into this SQL,
|
||||||
|
// only the already-fetched, already-trusted route values.
|
||||||
|
if ($results !== []) {
|
||||||
|
$routes = array_column($results, 'route');
|
||||||
|
$placeholders = implode(',', array_fill(0, count($routes), '?'));
|
||||||
|
$descriptions = Db::query(
|
||||||
|
"SELECT route, description FROM content_pages WHERE route IN ({$placeholders})",
|
||||||
|
$routes
|
||||||
|
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
foreach ($results as &$result) {
|
||||||
|
$result['description'] = $descriptions[$result['route']] ?? '';
|
||||||
|
}
|
||||||
|
unset($result); // break the foreach-by-reference alias, standard PHP gotcha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// $results stays [] (not an error) for a blank query or one with zero
|
||||||
|
// matches — the twig template only distinguishes those two cases by
|
||||||
|
// checking $query itself, not by any error flag.
|
||||||
|
return [
|
||||||
|
'query' => $query,
|
||||||
|
'results' => $results,
|
||||||
|
];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Search{% endblock %}
|
||||||
|
{% block description %}Search this site.{% endblock %}
|
||||||
|
{% block robots %}noindex, follow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.search() }}Search</h1>
|
||||||
|
|
||||||
|
<form method="get" action="/search">
|
||||||
|
<input type="search" name="q" value="{{ query }}" placeholder="Search…" aria-label="Search">
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if query != '' %}
|
||||||
|
{% if results|length > 0 %}
|
||||||
|
<ul class="post-list">
|
||||||
|
{% for result in results %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ result.route }}">{{ result.title }}</a>
|
||||||
|
{% if result.description %}<p>{{ result.description }}</p>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No results for “{{ query }}”.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?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);
|
||||||
@@ -131,6 +131,7 @@ code
|
|||||||
font-size: 0.9em
|
font-size: 0.9em
|
||||||
|
|
||||||
pre
|
pre
|
||||||
|
position: relative
|
||||||
background: var(--surface)
|
background: var(--surface)
|
||||||
border: 1px solid var(--border-color)
|
border: 1px solid var(--border-color)
|
||||||
border-radius: 6px
|
border-radius: 6px
|
||||||
@@ -142,6 +143,48 @@ pre
|
|||||||
padding: 0
|
padding: 0
|
||||||
color: var(--text-color)
|
color: var(--text-color)
|
||||||
|
|
||||||
|
&:hover .copy-code-button, .copy-code-button:focus-visible
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
.copy-code-button
|
||||||
|
position: absolute
|
||||||
|
top: 0.5rem
|
||||||
|
right: 0.5rem
|
||||||
|
display: inline-flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.3rem
|
||||||
|
background: var(--bg)
|
||||||
|
border: 1px solid var(--border-color)
|
||||||
|
border-radius: 4px
|
||||||
|
color: var(--muted-color)
|
||||||
|
font-size: 0.75rem
|
||||||
|
padding: 0.25rem 0.5rem
|
||||||
|
cursor: pointer
|
||||||
|
opacity: 0
|
||||||
|
transition: opacity 0.15s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: var(--bg)
|
||||||
|
color: var(--text-color)
|
||||||
|
border-color: var(--accent)
|
||||||
|
|
||||||
|
&.copied
|
||||||
|
color: var(--accent)
|
||||||
|
border-color: var(--accent)
|
||||||
|
|
||||||
|
// Vendored highlight.js themes (public/vendor/highlightjs/, see
|
||||||
|
// /admin/docs/upgrading-highlightjs) each set their own background and
|
||||||
|
// pre code.hljs padding — overridden here so a highlighted block blends
|
||||||
|
// into the existing pre box above instead of introducing a second,
|
||||||
|
// mismatched background/padding. Loaded after the theme stylesheet (see
|
||||||
|
// novaconium/pages/_layout/syntax-highlight-init.twig), so this wins by
|
||||||
|
// source order alone, no !important needed.
|
||||||
|
.hljs
|
||||||
|
background: transparent
|
||||||
|
|
||||||
|
pre code.hljs
|
||||||
|
padding: 0
|
||||||
|
|
||||||
hr
|
hr
|
||||||
border: none
|
border: none
|
||||||
border-top: 1px solid var(--border-color)
|
border-top: 1px solid var(--border-color)
|
||||||
@@ -156,6 +199,17 @@ label
|
|||||||
small
|
small
|
||||||
color: var(--muted-color)
|
color: var(--muted-color)
|
||||||
|
|
||||||
|
footer
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: space-between
|
||||||
|
gap: 1rem
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
|
.footer-menu
|
||||||
|
display: flex
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
// Honeypot field for spam prevention (see App/pages/contact/index.php).
|
// Honeypot field for spam prevention (see App/pages/contact/index.php).
|
||||||
// Off-screen positioning rather than display:none/visibility:hidden,
|
// Off-screen positioning rather than display:none/visibility:hidden,
|
||||||
// since some spam bots specifically skip fields hidden that way.
|
// since some spam bots specifically skip fields hidden that way.
|
||||||
|
|||||||
@@ -23,18 +23,7 @@ final class AdminAuth
|
|||||||
*/
|
*/
|
||||||
public static function requireLogin(string $username, string $passwordHash): void
|
public static function requireLogin(string $username, string $passwordHash): void
|
||||||
{
|
{
|
||||||
if ($passwordHash === '') {
|
if (self::isAuthenticated($username, $passwordHash)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$providedUser = $_SERVER['PHP_AUTH_USER'] ?? null;
|
|
||||||
$providedPass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
$providedUser === $username
|
|
||||||
&& $providedPass !== null
|
|
||||||
&& password_verify($providedPass, $passwordHash)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +34,31 @@ final class AdminAuth
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credential check on its own, with no response side effects —
|
||||||
|
* reused by requireLogin() above (401 challenge on failure) and by
|
||||||
|
* novaconium/bootstrap.php's draft-page gate (see /admin/docs/drafts),
|
||||||
|
* which needs a *different* response on failure: a plain 404, not a
|
||||||
|
* login prompt, so an unauthenticated visitor can't tell a draft
|
||||||
|
* exists at all. Returns true (open access) when $passwordHash is
|
||||||
|
* empty, matching requireLogin()'s existing no-op-when-unset posture —
|
||||||
|
* a draft behaves like the rest of /admin/*: wide open until a
|
||||||
|
* password is configured, gated once one is.
|
||||||
|
*/
|
||||||
|
public static function isAuthenticated(string $username, string $passwordHash): bool
|
||||||
|
{
|
||||||
|
if ($passwordHash === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$providedUser = $_SERVER['PHP_AUTH_USER'] ?? null;
|
||||||
|
$providedPass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
||||||
|
|
||||||
|
return $providedUser === $username
|
||||||
|
&& $providedPass !== null
|
||||||
|
&& password_verify($providedPass, $passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP Basic Auth has no real server-side "log out" — the browser just
|
* HTTP Basic Auth has no real server-side "log out" — the browser just
|
||||||
* keeps resending the cached credentials. The standard workaround: always
|
* keeps resending the cached credentials. The standard workaround: always
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Lib\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crawls every routable page, renders it, and indexes it into SQLite —
|
||||||
|
* the shared mechanism behind /sitemap.xml, /search, and blog tag browsing
|
||||||
|
* (see /admin/docs/content-index). Content itself stays in files (Twig
|
||||||
|
* pages); this only extracts and stores metadata (title/description/
|
||||||
|
* keywords/tags/changefreq/priority, each pulled via Renderer::
|
||||||
|
* renderForIndex()'s Twig renderBlock() calls) plus a stripped-HTML plain-
|
||||||
|
* text copy of the body for full-text search (SQLite FTS5).
|
||||||
|
*
|
||||||
|
* Off by default: config['content_index_enabled'] gates the whole
|
||||||
|
* subsystem, since this is a real SQLite dependency plenty of sites built
|
||||||
|
* on this framework won't want at all — the same posture as Matomo/admin
|
||||||
|
* auth. Every public method here is a no-op when it's false.
|
||||||
|
*
|
||||||
|
* Two trigger paths, both funneling into reindex():
|
||||||
|
* - ensureFresh() — lazy, called from the sitemap/search/tag-browsing
|
||||||
|
* sidecars themselves (never from a normal page view), reindexing only
|
||||||
|
* if something changed since the last index (a cheap filemtime() scan,
|
||||||
|
* no rendering, decides this) and only if config['content_index_auto']
|
||||||
|
* is true (the default).
|
||||||
|
* - novaconium/bin/index-content.php — explicit, ignores content_index_auto,
|
||||||
|
* for projects that would rather trigger indexing from a deploy step.
|
||||||
|
*
|
||||||
|
* A crawl invokes every page's sidecar the same way a real GET request
|
||||||
|
* would (this is how it discovers noindex pages and pulls metadata) —
|
||||||
|
* sidecars are expected to be side-effect-free for non-POST requests
|
||||||
|
* anyway (ordinary HTTP-safe-method hygiene, not a new constraint this
|
||||||
|
* introduces), but reindex() additionally forces $_SERVER['REQUEST_METHOD']
|
||||||
|
* to 'GET' for the duration of the crawl and restores whatever it was
|
||||||
|
* before, so a lazy reindex triggered from within a POST request (however
|
||||||
|
* unlikely for the sidecars this ships with) can never leak that POST into
|
||||||
|
* an unrelated page's sidecar.
|
||||||
|
*/
|
||||||
|
final class ContentIndexer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Guards against reentrancy: the crawl itself renders every routable
|
||||||
|
* page, including /search (a real content_index_enabled consumer,
|
||||||
|
* since it has no other reason not to be crawled) — and /search's own
|
||||||
|
* sidecar calls ensureFresh(). Without this guard, that nested call
|
||||||
|
* would see itself as "in progress" and either start a second
|
||||||
|
* reindex() mid-transaction (PDO fatals on a nested beginTransaction())
|
||||||
|
* or, if it didn't fatal, silently corrupt the outer crawl's result.
|
||||||
|
* Both ensureFresh() and reindex() no-op while a reindex is already
|
||||||
|
* running on this call stack.
|
||||||
|
*/
|
||||||
|
private static bool $indexing = false;
|
||||||
|
|
||||||
|
public static function ensureFresh(): void
|
||||||
|
{
|
||||||
|
if (self::$indexing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = self::config();
|
||||||
|
|
||||||
|
if (!$config['content_index_enabled'] || !$config['content_index_auto']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isStale($config)) {
|
||||||
|
self::reindex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reindex(): void
|
||||||
|
{
|
||||||
|
if (self::$indexing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = self::config();
|
||||||
|
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Db::connection();
|
||||||
|
$renderer = self::renderer($config);
|
||||||
|
$routes = Overlay::listPageDirs($config['pages_dirs']);
|
||||||
|
|
||||||
|
$originalMethod = $_SERVER['REQUEST_METHOD'] ?? null;
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
self::$indexing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$pdo->exec('DELETE FROM content_pages');
|
||||||
|
$pdo->exec('DELETE FROM content_tags');
|
||||||
|
$pdo->exec('DELETE FROM content_search');
|
||||||
|
|
||||||
|
$insertPage = $pdo->prepare(
|
||||||
|
'INSERT INTO content_pages (route, title, description, keywords, changefreq, priority, source_mtime) ' .
|
||||||
|
'VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$insertTag = $pdo->prepare('INSERT INTO content_tags (route, tag) VALUES (?, ?)');
|
||||||
|
$insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)');
|
||||||
|
|
||||||
|
$newestMtime = 0;
|
||||||
|
|
||||||
|
foreach ($routes as $dir) {
|
||||||
|
if (in_array($dir, $config['draft_routes'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mtime = self::sourceMtime($config['pages_dirs'], $dir);
|
||||||
|
$newestMtime = max($newestMtime, $mtime);
|
||||||
|
|
||||||
|
$route = new Route($dir, [], true);
|
||||||
|
$indexed = $renderer->renderForIndex($route);
|
||||||
|
|
||||||
|
if ($indexed === null || str_contains($indexed->robots, 'noindex')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeUrl = $dir === '' ? '/' : '/' . $dir;
|
||||||
|
|
||||||
|
$insertPage->execute([
|
||||||
|
$routeUrl,
|
||||||
|
$indexed->title,
|
||||||
|
$indexed->description,
|
||||||
|
$indexed->keywords,
|
||||||
|
$indexed->changefreq,
|
||||||
|
$indexed->priority,
|
||||||
|
$mtime,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (self::splitList($indexed->tags) as $tag) {
|
||||||
|
$insertTag->execute([$routeUrl, $tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$insertSearch->execute([$routeUrl, $indexed->title, strip_tags($indexed->html)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM content_index_meta')->execute();
|
||||||
|
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, indexed_at) VALUES (1, ?, ?)')
|
||||||
|
->execute([$newestMtime, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
if ($originalMethod === null) {
|
||||||
|
unset($_SERVER['REQUEST_METHOD']);
|
||||||
|
} else {
|
||||||
|
$_SERVER['REQUEST_METHOD'] = $originalMethod;
|
||||||
|
}
|
||||||
|
self::$indexing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function isStale(array $config): bool
|
||||||
|
{
|
||||||
|
$pdo = Db::connection();
|
||||||
|
|
||||||
|
$meta = $pdo->query('SELECT newest_source_mtime FROM content_index_meta WHERE id = 1')->fetch();
|
||||||
|
if ($meta === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newest = 0;
|
||||||
|
foreach (Overlay::listPageDirs($config['pages_dirs']) as $dir) {
|
||||||
|
$newest = max($newest, self::sourceMtime($config['pages_dirs'], $dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $newest > (int) $meta['newest_source_mtime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $pagesDirs
|
||||||
|
*/
|
||||||
|
private static function sourceMtime(array $pagesDirs, string $dir): int
|
||||||
|
{
|
||||||
|
$twig = Overlay::findFile($pagesDirs, $dir === '' ? 'index.twig' : $dir . '/index.twig');
|
||||||
|
$php = Overlay::findFile($pagesDirs, $dir === '' ? 'index.php' : $dir . '/index.php');
|
||||||
|
|
||||||
|
$mtimes = array_filter([
|
||||||
|
$twig !== null ? filemtime($twig) : false,
|
||||||
|
$php !== null ? filemtime($php) : false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $mtimes === [] ? 0 : (int) max($mtimes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function splitList(string $value): array
|
||||||
|
{
|
||||||
|
$items = array_map('trim', explode(',', $value));
|
||||||
|
|
||||||
|
return array_values(array_filter($items, fn (string $item) => $item !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function renderer(array $config): Renderer
|
||||||
|
{
|
||||||
|
return new Renderer($config['pages_dirs'], new Cache($config['cache_dir']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function config(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$appConfig = require $appConfigFile;
|
||||||
|
$defaultConnections = $config['db_connections'];
|
||||||
|
$appConnections = $appConfig['db_connections'] ?? [];
|
||||||
|
$config = array_merge($config, $appConfig);
|
||||||
|
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of Renderer::renderForIndex() — everything App\ContentIndexer
|
||||||
|
* needs from a single rendered page, without any HTTP output or cache
|
||||||
|
* write. Each metadata field is pulled via Twig's TemplateWrapper::
|
||||||
|
* renderBlock(), so it reflects whatever that specific page (or, absent an
|
||||||
|
* override, the layout's default) declared — see novaconium/pages/_layout/
|
||||||
|
* layout.twig and /admin/docs/seo.
|
||||||
|
*/
|
||||||
|
final class IndexedPage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $html,
|
||||||
|
public readonly string $title,
|
||||||
|
public readonly string $description,
|
||||||
|
public readonly string $keywords,
|
||||||
|
public readonly string $tags,
|
||||||
|
public readonly string $robots,
|
||||||
|
public readonly string $changefreq,
|
||||||
|
public readonly string $priority,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,59 @@ final class Overlay
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deduped set of every relative path, across both roots, that
|
||||||
|
* Router::resolve() could route to (has an index.twig or index.php) —
|
||||||
|
* used by App\ContentIndexer to crawl the whole site. `_`-prefixed and
|
||||||
|
* `[param]`-wildcard directories are skipped entirely, matching
|
||||||
|
* Router::resolve()'s own reserved-segment rule and the sitemap/search
|
||||||
|
* groundwork's stated V1 limitation: a wildcard route's concrete
|
||||||
|
* values aren't knowable without a data source, so dynamic routes
|
||||||
|
* aren't crawled (yet).
|
||||||
|
*
|
||||||
|
* @param string[] $roots
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function listPageDirs(array $roots): array
|
||||||
|
{
|
||||||
|
$found = [];
|
||||||
|
|
||||||
|
foreach ($roots as $root) {
|
||||||
|
self::walk($root, '', $found);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,true> $found
|
||||||
|
*/
|
||||||
|
private static function walk(string $root, string $relative, array &$found): void
|
||||||
|
{
|
||||||
|
$dir = self::join($root, $relative);
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($dir . '/index.twig') || is_file($dir . '/index.php')) {
|
||||||
|
$found[$relative] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($dir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..' || !is_dir($dir . '/' . $entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entry[0] === '_' || $entry[0] === '[' || $entry === '404') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$childRelative = $relative === '' ? $entry : $relative . '/' . $entry;
|
||||||
|
self::walk($root, $childRelative, $found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static function join(string $root, string $relative): string
|
private static function join(string $root, string $relative): string
|
||||||
{
|
{
|
||||||
$root = rtrim($root, '/');
|
$root = rtrim($root, '/');
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ final class Renderer
|
|||||||
string $matomoUrl = '',
|
string $matomoUrl = '',
|
||||||
string $matomoSiteId = '',
|
string $matomoSiteId = '',
|
||||||
string $siteName = 'My Site',
|
string $siteName = 'My Site',
|
||||||
|
bool $contentIndexEnabled = false,
|
||||||
) {
|
) {
|
||||||
$loader = new FilesystemLoader($this->pagesDirs);
|
$loader = new FilesystemLoader($this->pagesDirs);
|
||||||
$this->twig = new Environment($loader, [
|
$this->twig = new Environment($loader, [
|
||||||
@@ -40,9 +41,27 @@ final class Renderer
|
|||||||
$this->twig->addGlobal('matomo_site_id', $matomoSiteId);
|
$this->twig->addGlobal('matomo_site_id', $matomoSiteId);
|
||||||
$this->twig->addGlobal('site_name', $siteName);
|
$this->twig->addGlobal('site_name', $siteName);
|
||||||
$this->twig->addGlobal('is_404', false);
|
$this->twig->addGlobal('is_404', false);
|
||||||
|
// Lets the footer conditionally link to /sitemap.xml only when
|
||||||
|
// that route actually exists (content_index_enabled — see
|
||||||
|
// /admin/docs/content-index) rather than linking to a 404. The
|
||||||
|
// blog RSS feed doesn't need an equivalent flag: /blog/feed has no
|
||||||
|
// content-index dependency, it's always routable.
|
||||||
|
$this->twig->addGlobal('content_index_enabled', $contentIndexEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(Route $route, string $requestUri): void
|
/**
|
||||||
|
* $excludeFromCache (see novaconium/bootstrap.php) skips the
|
||||||
|
* static-cache write below unconditionally, regardless of $hasSidecar
|
||||||
|
* — bootstrap.php passes true for a draft route (see
|
||||||
|
* /admin/docs/drafts) or any /admin/* route. Either would otherwise
|
||||||
|
* still take the normal sidecar-less caching path (most pages under
|
||||||
|
* novaconium/pages/admin/ have no sidecar) and get written to
|
||||||
|
* public/cache/ as plain, world-readable HTML the first time an
|
||||||
|
* authenticated admin viewed it — permanently bypassing the auth check
|
||||||
|
* for anyone hitting that URL afterward, since .htaccess serves a
|
||||||
|
* cached file before PHP (and therefore any auth check) runs again.
|
||||||
|
*/
|
||||||
|
public function render(Route $route, string $requestUri, bool $excludeFromCache = false): void
|
||||||
{
|
{
|
||||||
$sidecarRel = $this->withFile($route->dir, 'index.php');
|
$sidecarRel = $this->withFile($route->dir, 'index.php');
|
||||||
$sidecar = Overlay::findFile($this->pagesDirs, $sidecarRel);
|
$sidecar = Overlay::findFile($this->pagesDirs, $sidecarRel);
|
||||||
@@ -66,11 +85,52 @@ final class Renderer
|
|||||||
header('Content-Type: text/html; charset=utf-8');
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
echo $html;
|
echo $html;
|
||||||
|
|
||||||
if (!$hasSidecar) {
|
if (!$hasSidecar && !$excludeFromCache) {
|
||||||
$this->cache->write($requestUri, $html);
|
$this->cache->write($requestUri, $html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders $route the same way render() does (runs its sidecar if any,
|
||||||
|
* resolves the nearest layout) but returns the result instead of
|
||||||
|
* emitting HTTP output or writing the static cache — used by
|
||||||
|
* App\ContentIndexer to crawl the site for /sitemap.xml, /search, and
|
||||||
|
* blog tag browsing (see /admin/docs/content-index). Returns null if
|
||||||
|
* the sidecar returns a Response (a redirect/JSON/XML endpoint has
|
||||||
|
* nothing meaningful to index). There's no real request URI to derive
|
||||||
|
* request_path from here, so it's synthesized from the route itself.
|
||||||
|
*/
|
||||||
|
public function renderForIndex(Route $route): ?IndexedPage
|
||||||
|
{
|
||||||
|
$sidecarRel = $this->withFile($route->dir, 'index.php');
|
||||||
|
$sidecar = Overlay::findFile($this->pagesDirs, $sidecarRel);
|
||||||
|
$hasSidecar = $sidecar !== null;
|
||||||
|
|
||||||
|
$result = $hasSidecar ? $this->runSidecar($sidecar, $route->params) : [];
|
||||||
|
|
||||||
|
if ($result instanceof Response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = array_merge((array) $result, ['params' => $route->params]);
|
||||||
|
$data['layout'] = $this->relativeLayoutPath($route->dir);
|
||||||
|
$data['request_path'] = $route->dir === '' ? '/' : '/' . $route->dir;
|
||||||
|
|
||||||
|
$templateName = $this->withFile($route->dir, 'index.twig');
|
||||||
|
$wrapper = $this->twig->load($templateName);
|
||||||
|
|
||||||
|
return new IndexedPage(
|
||||||
|
html: $wrapper->render($data),
|
||||||
|
title: $wrapper->renderBlock('title', $data),
|
||||||
|
description: $wrapper->renderBlock('description', $data),
|
||||||
|
keywords: $wrapper->renderBlock('keywords', $data),
|
||||||
|
tags: $wrapper->renderBlock('tags', $data),
|
||||||
|
robots: $wrapper->renderBlock('robots', $data),
|
||||||
|
changefreq: $wrapper->renderBlock('changefreq', $data),
|
||||||
|
priority: $wrapper->renderBlock('priority', $data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function renderNotFound(string $requestUri): void
|
public function renderNotFound(string $requestUri): void
|
||||||
{
|
{
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
|
position: relative;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -144,6 +145,44 @@ pre code {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
pre:hover .copy-code-button, pre .copy-code-button:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--muted-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.copy-code-button:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.copy-code-button.copied {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code.hljs {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -163,6 +202,19 @@ small {
|
|||||||
color: var(--muted-color);
|
color: var(--muted-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hp-field {
|
.hp-field {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
|
|||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2006, Ivan Sagalaev.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
+1244
File diff suppressed because one or more lines are too long
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/*! `ini` grammar compiled for Highlight.js 11.11.1 */
|
||||||
|
(()=>{var e=(()=>{"use strict";return e=>{const n=e.regex,a={className:"number",
|
||||||
|
relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{begin:e.NUMBER_RE}]
|
||||||
|
},s=e.COMMENT();s.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];const i={
|
||||||
|
className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)\}/
|
||||||
|
}]},t={className:"literal",begin:/\bon|off|true|false|yes|no\b/},r={
|
||||||
|
className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",
|
||||||
|
end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'
|
||||||
|
},{begin:"'",end:"'"}]},l={begin:/\[/,end:/\]/,contains:[s,t,i,r,a,"self"],
|
||||||
|
relevance:0},c=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{
|
||||||
|
name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/,
|
||||||
|
contains:[s,{className:"section",begin:/\[+/,end:/\]+/},{
|
||||||
|
begin:n.concat(c,"(\\s*\\.\\s*",c,")*",n.lookahead(/\s*=\s*[^#\s]/)),
|
||||||
|
className:"attr",starts:{end:/$/,contains:[s,l,t,i,r,a]}}]}}})()
|
||||||
|
;hljs.registerLanguage("ini",e)})();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*! `json` grammar compiled for Highlight.js 11.11.1 */
|
||||||
|
(()=>{var e=(()=>{"use strict";return e=>{const a=["true","false","null"],s={
|
||||||
|
scope:"literal",beginKeywords:a.join(" ")};return{name:"JSON",aliases:["jsonc"],
|
||||||
|
keywords:{literal:a},contains:[{className:"attr",
|
||||||
|
begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{match:/[{}[\],:]/,
|
||||||
|
className:"punctuation",relevance:0
|
||||||
|
},e.QUOTE_STRING_MODE,s,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE],
|
||||||
|
illegal:"\\S"}}})();hljs.registerLanguage("json",e)})();
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/*! `yaml` grammar compiled for Highlight.js 11.11.1 */
|
||||||
|
(()=>{var e=(()=>{"use strict";return e=>{
|
||||||
|
const n="true false yes no null",a="[\\w#;/?:@&=+$,.~*'()[\\]]+",s={
|
||||||
|
className:"string",relevance:0,variants:[{begin:/"/,end:/"/},{begin:/\S+/}],
|
||||||
|
contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{
|
||||||
|
begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(s,{variants:[{
|
||||||
|
begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]},{begin:/"/,end:/"/},{
|
||||||
|
begin:/[^\s,{}[\]]+/}]}),l={end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,
|
||||||
|
relevance:0},t={begin:/\{/,end:/\}/,contains:[l],illegal:"\\n",relevance:0},c={
|
||||||
|
begin:"\\[",end:"\\]",contains:[l],illegal:"\\n",relevance:0},r=[{
|
||||||
|
className:"attr",variants:[{begin:/[\w*@][\w*@ :()\./-]*:(?=[ \t]|$)/},{
|
||||||
|
begin:/"[\w*@][\w*@ :()\./-]*":(?=[ \t]|$)/},{
|
||||||
|
begin:/'[\w*@][\w*@ :()\./-]*':(?=[ \t]|$)/}]},{className:"meta",
|
||||||
|
begin:"^---\\s*$",relevance:10},{className:"string",
|
||||||
|
begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{
|
||||||
|
begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,
|
||||||
|
relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type",
|
||||||
|
begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a
|
||||||
|
},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",
|
||||||
|
begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",
|
||||||
|
relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{
|
||||||
|
className:"number",
|
||||||
|
begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"
|
||||||
|
},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},t,c,{
|
||||||
|
className:"string",relevance:0,begin:/'/,end:/'/,contains:[{match:/''/,
|
||||||
|
scope:"char.escape",relevance:0}]},s],g=[...r]
|
||||||
|
;return g.pop(),g.push(i),l.contains=g,{name:"YAML",case_insensitive:!0,
|
||||||
|
aliases:["yml"],contains:r}}})();hljs.registerLanguage("yaml",e)})();
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||||
|
Theme: GitHub
|
||||||
|
Description: Light theme as seen on github.com
|
||||||
|
Author: github.com
|
||||||
|
Maintainer: @Hirse
|
||||||
|
Updated: 2021-05-15
|
||||||
|
|
||||||
|
Outdated base version: https://github.com/primer/github-syntax-light
|
||||||
|
Current colors taken from GitHub's CSS
|
||||||
|
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#000;color:#f8f8f8}.hljs-comment,.hljs-meta,.hljs-quote{color:#7c7c7c}.hljs-keyword,.hljs-name,.hljs-selector-tag,.hljs-tag{color:#96cbfe}.hljs-attribute,.hljs-selector-id{color:#ffffb6}.hljs-addition,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-string{color:#a8ff60}.hljs-subst{color:#daefa3}.hljs-link,.hljs-regexp{color:#e9c062}.hljs-doctag,.hljs-section,.hljs-title,.hljs-type{color:#ffffb6}.hljs-bullet,.hljs-literal,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#c6c5fe}.hljs-deletion,.hljs-number{color:#ff73fd}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||||
Reference in New Issue
Block a user