4862526fa1
Lets a page under App/pages/ be previewed by an admin before the public can see it: list its route in draft_routes (App/config.php), checked in bootstrap.php alongside the existing /admin/* gate. Not authenticated -> same plain 404 an unmatched route gets, not a login prompt, so a draft's existence isn't revealed. Authenticated -> renders normally. No separate login flow needed - Basic Auth credentials are scoped to the whole origin/realm, so authenticating once at /admin covers draft URLs too. AdminAuth::isAuthenticated() is extracted out of requireLogin() so the draft gate can reuse the same credential check with a different failure response (404 vs. a 401 challenge). Renderer::render() gains an $excludeFromCache param so a draft without its own sidecar can't get written to the static HTML cache - .htaccess serves a cached file before PHP, and therefore any auth check, ever runs again, so an uncached exception is required, not just the auth gate. While testing this, found the same bug already existed for /admin itself: novaconium/pages/admin/index.twig has no sidecar, so it was already being cached - meaning any admin visiting /admin once caused the panel to be served to everyone, unauthenticated, straight from public/cache/admin/. Fixed in this change by excluding every /admin/* route from the cache the same way, and documented as a standing rule in AGENTS.md: any future mechanism that conditionally hides page content from the public has to make the same check, not just gate the initial request. Closes the "Draft pages (admin-only preview)" backlog item in novaconium/ISSUES.md.
381 lines
23 KiB
Markdown
381 lines
23 KiB
Markdown
# AGENTS.md
|
|
|
|
Context for any coding agent working in this repo — Claude, DeepSeek, or
|
|
otherwise; this file (and the maintenance rule below) applies regardless of
|
|
which model or CLI is driving. Full narrative docs live at `/admin/docs`
|
|
when the app is running (also the *only* place Twig upgrade instructions
|
|
live now — see `/admin/docs/upgrading-twig`; there's no separate
|
|
MAINTENANCE.md, keeping one copy in the docs page avoids drift). `README.md`
|
|
is the GitHub-facing pitch, `novaconium/ISSUES.md` is the roadmap/backlog,
|
|
and this file is the short, agent-facing version. The original design
|
|
rationale used to live in a standalone `plan.md`; it's now folded into
|
|
`/admin/docs/design-notes` (everything in it shipped) and the file was
|
|
deleted. There used to also be a
|
|
`GUIDE.md` mirroring `/admin/docs` for offline reading — it was removed to
|
|
cut a doc copy that had to be kept in sync; `/admin/docs` is the only
|
|
narrative reference now.
|
|
|
|
## Documentation is duplicated on purpose — keep all copies in sync
|
|
|
|
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
|
|
admin authentication, styling, project layout, third-party) exists in
|
|
**two** places: a page under `novaconium/pages/admin/docs/<topic>/index.twig`
|
|
(the canonical reference), and (for anything a README-reading human needs
|
|
up front) a mention in `README.md`. This is intentional — `/admin/docs` is
|
|
for reading against a running instance with no internet needed, and
|
|
`README.md` is the GitHub-facing pitch — but it means **any agent that
|
|
changes framework behavior or adds a feature must update both copies in
|
|
the same change**, not just the one that was open. Concretely, after
|
|
touching routing/rendering/caching/SEO behavior or adding a new top-level
|
|
docs topic:
|
|
|
|
1. Update (or add) the matching page under
|
|
`novaconium/pages/admin/docs/<topic>/index.twig`, and if it's a new
|
|
topic, link it from both `admin/docs/index.twig` and the nav in
|
|
`admin/docs/_layout/layout.twig`.
|
|
2. Update `README.md` if the change affects the feature list, getting
|
|
started steps, or the docs index there.
|
|
3. Update this file if the change affects a convention an agent needs to
|
|
know before editing code (not just narrative docs).
|
|
|
|
A doc change that only touches one of these copies is incomplete —
|
|
verify the other copy before considering the task done.
|
|
|
|
## What this is
|
|
|
|
A dependency-light PHP + Twig micro-framework: directories under `pages/`
|
|
map directly to URLs (Hugo-style page bundles), optional `index.php`
|
|
sidecars supply data or short-circuit to a `Response`, and sidecar-less
|
|
pages get pre-rendered to static HTML on first request and served straight
|
|
from Apache after that. No Composer, no build step to install — Twig is
|
|
vendored as plain source files.
|
|
|
|
## The two-root split — read this before editing anything under `pages/` or `lib/`
|
|
|
|
Everything lives in one of two places:
|
|
|
|
- **`App/`** — the actual project: `App/pages/` (routes/content) and
|
|
`App/lib/` (project's own `Lib\` classes). This is the only directory a
|
|
site author is expected to touch.
|
|
- **`novaconium/`** — the framework itself: router/renderer core
|
|
(`novaconium/src/`), default pages (`novaconium/pages/` — root layout,
|
|
404, the `/admin` tools), default `Lib\` classes (`novaconium/lib/`),
|
|
vendored Twig, autoloader, config, bootstrap.
|
|
|
|
Routing and rendering resolve against **both roots, in order** —
|
|
`App/pages/` first, `novaconium/pages/` as fallback — via
|
|
`novaconium/src/Overlay.php`. Same mechanism for `Lib\` classes:
|
|
`App/lib/` is checked before `novaconium/lib/` in `novaconium/autoload.php`.
|
|
Concretely: dropping a file at the same relative path in `App/` overrides
|
|
the `novaconium/` default; nothing needs to be duplicated for the site to
|
|
work, since `novaconium/pages/` already supplies a working layout and 404.
|
|
|
|
Twig's `FilesystemLoader` is constructed with both paths as an array, so
|
|
`{% extends %}` / `{% include %}` get this override-then-fallback
|
|
resolution for free — no custom logic needed there.
|
|
|
|
The same override-by-presence pattern applies to `novaconium/config.php`:
|
|
if `App/config.php` exists, `novaconium/bootstrap.php` and
|
|
`novaconium/bin/clear-cache.php` shallow-merge it over the framework defaults
|
|
with `array_merge()`. A project only needs to list the keys it's changing
|
|
— never edit `novaconium/config.php` directly.
|
|
|
|
`config['matomo_url']` / `config['matomo_site_id']` (both default `''`)
|
|
gate the Matomo tracking script emitted by the root layout — set both via
|
|
`App/config.php` to enable it, since either being empty disables tracking
|
|
entirely. `bootstrap.php` normalizes a missing trailing slash on
|
|
`matomo_url` before passing it to `Renderer`, which exposes `matomo_url`,
|
|
`matomo_site_id`, and `is_404` as Twig globals (`is_404` is overridden to
|
|
`true` in the 404 template's local render context by
|
|
`Renderer::renderNotFound()`, per Twig's local-context-over-global
|
|
precedence). Any new Twig global added to `Renderer`'s constructor should
|
|
follow this same pattern: default value, `addGlobal()` call, documented
|
|
here and in `/admin/docs`.
|
|
|
|
`config['site_name']` (default `'My Site'`) is the same pattern — passed to
|
|
`Renderer` and exposed as the `site_name` Twig global, used by
|
|
`novaconium/pages/_layout/layout.twig` for the default `title` block,
|
|
`og:site_name`, and the footer copyright line. Any other hardcoded
|
|
site-identity string that shows up in a shared template (as opposed to a
|
|
per-page override) should become a `config.php` key the same way, not stay
|
|
hardcoded in the template.
|
|
|
|
`config['admin_username']` / `config['admin_password_hash']` (username
|
|
defaults to `'admin'`, password hash defaults to `''`) gate every
|
|
`/admin/*` route behind HTTP Basic Auth — this replaced the old
|
|
`docs_enabled` flag entirely (removed); a single gate over all of
|
|
`/admin/*` (docs included) made a docs-only toggle redundant. Unlike the
|
|
Twig-global pattern above, the gate itself is enforced in `bootstrap.php`,
|
|
before rendering: `AdminAuth::requireLogin(...)`
|
|
(`novaconium/src/AdminAuth.php`) is called once for any resolved route
|
|
whose path is `admin` or starts with `admin/`. **Any new admin page
|
|
dropped under `App/pages/admin/` or `novaconium/pages/admin/` is
|
|
automatically protected — no per-page wiring needed.** `bootstrap.php`
|
|
also special-cases the literal path `/admin/logout` *before* router
|
|
resolution — no page exists there — to call `AdminAuth::logout()`, which
|
|
always issues a fresh 401 so the browser drops its cached credentials
|
|
(there's no server-side session to invalidate). `Renderer` separately
|
|
exposes an `admin_auth_enabled` Twig global (true when a password hash is
|
|
set) so `admin/index.twig` can conditionally show the "Logout" link — this
|
|
is a derived display flag, not the enforcement mechanism itself, which
|
|
never depends on Twig. `novaconium/pages/admin/password-hash/` is a
|
|
built-in `password_hash()` form (no CLI needed) for generating
|
|
`admin_password_hash` — a normal admin page, so it's covered by the same
|
|
gate: reachable while no password is set yet (to generate the first
|
|
one), then protected like everything else under `/admin/*` afterward. It
|
|
computes and displays the hash per-request only; nothing is persisted or
|
|
logged. This is a single-user HTTP Basic Auth stopgap, not
|
|
the full multi-user system tracked in `novaconium/ISSUES.md` ("Admin login & user
|
|
management"); don't extend this class toward multi-user/session-based
|
|
auth — that's a separate, larger feature that
|
|
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`, filename-ordered, tracked in that connection's own
|
|
auto-created `schema_migrations` table (each connection's applied
|
|
migrations are independent of any other connection's), run once each.
|
|
`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. Only `App/migrations/` (the framework default connection's
|
|
`migrations_dir`) is scanned by default — the framework ships no core
|
|
tables of its own yet — if a future framework feature needs a shipped
|
|
migration, extend this to the same App-over-novaconium two-root scan
|
|
`Overlay.php` already does for pages/lib, don't invent a second mechanism.
|
|
|
|
`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.
|
|
|
|
## Running it
|
|
|
|
```
|
|
php -S 127.0.0.1:8000 -t public public/router.php
|
|
```
|
|
|
|
`public/router.php` is dev-only, mimics `public/.htaccess`. There is no
|
|
test suite — verification is manual route-by-route (see
|
|
`/admin/docs/design-notes`'s Verification section for the checklist used
|
|
after any framework change).
|
|
After testing, clear stray cache with `php novaconium/bin/clear-cache.php` or
|
|
POST `/admin/clear-cache`, and remove anything written to `App/lib/` or
|
|
`App/pages/` that was only for testing an override — nothing here is
|
|
gitignored except `public/cache/*` and `novaconium/contact-log.txt`, so
|
|
test debris left in `App/` will otherwise get committed or silently change
|
|
site behavior for the next person.
|
|
|
|
## Conventions worth knowing
|
|
|
|
- Reserved segments: any path segment starting with `_` (e.g. `_layout/`)
|
|
or literally named `404` is never routable — `Router::resolve()` 404s on
|
|
sight, don't try to serve content directly at those paths.
|
|
- Sidecars (`index.php`) return either an array (Twig context) or a
|
|
`Response` (redirect/json/xml/html — `novaconium/src/Response.php`).
|
|
`$params` (route captures) and `$cache` (the `Cache` instance, e.g. for
|
|
`$cache->clear()`) are both in scope automatically — see
|
|
`novaconium/src/Renderer.php::runSidecar()`.
|
|
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader.
|
|
Adding a new framework-core class means adding it under `App\` in
|
|
`novaconium/src/`; a new `Lib\` class goes in `App/lib/` or
|
|
`novaconium/lib/` depending on whether it's project- or
|
|
framework-specific.
|
|
- `novaconium/bin/` holds standalone CLI entry points meant to be run
|
|
directly (`php novaconium/bin/<script>.php`) — distinct from
|
|
`bootstrap.php`/`autoload.php`/`config.php`, which are only ever
|
|
`require`'d, never invoked directly. `clear-cache.php` and
|
|
`create-static-page.php` (scaffolds a new page from the `/admin/docs/seo`
|
|
starter template) both live there; a new CLI tool goes there too.
|
|
- CSS is compiled from `novaconium/sass/main.sass` (indented syntax) to
|
|
`public/css/main.css`. `dart-sass` is installed in this environment
|
|
(Arch: `pacman -S dart-sass`) — after editing Sass source, run:
|
|
`sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css`
|
|
and commit the regenerated `public/css/main.css` (`--no-source-map`
|
|
avoids a stray `main.css.map` the project doesn't otherwise use). If
|
|
`sass` isn't available in whatever environment you're in, either run it
|
|
via Docker — `/admin/docs/styling` has a copy-pasteable
|
|
Dockerfile that installs the same standalone Dart Sass release used in
|
|
this environment (`1.101.0`) directly from GitHub, not via npm, plus
|
|
the `docker build`/`docker run` commands adjusted to this repo's paths
|
|
— or hand-edit both files in parallel and keep them in sync — that's
|
|
how the dark/teal theme and the homepage hero/animation
|
|
styling were originally written before `sass` was installed here.
|
|
- The Sass color palette follows the same App-over-novaconium override
|
|
pattern as pages/lib, but with a twist worth understanding before
|
|
touching it: `novaconium/sass/main.sass` does `@use 'colors' as *`, and
|
|
its own directory (`novaconium/sass/`) deliberately has **no**
|
|
`_colors.sass` sibling. Dart Sass resolves a bare `@use` relative to the
|
|
importing file's own directory *before* consulting `--load-path`
|
|
entries, so if `novaconium/sass/_colors.sass` existed next to
|
|
`main.sass`, it would always win regardless of load-path order —
|
|
silently defeating the override. Keeping the framework default at
|
|
`novaconium/sass/defaults/_colors.sass` (a different directory) forces
|
|
resolution through the load path, where `App/sass` (checked first) can
|
|
actually override it with `App/sass/_colors.sass`. Don't move
|
|
`defaults/_colors.sass` back next to `main.sass` — it was moved out on
|
|
purpose, and doing so reintroduces this bug.
|
|
- Every color rule in `main.sass` reads a CSS custom property
|
|
(`var(--bg)`, `var(--accent)`, etc.), never a Sass variable directly —
|
|
that indirection is what makes the dark/light theme toggle possible,
|
|
since Sass only runs at compile time and can't react to a runtime
|
|
choice on its own. The two `_colors.sass` files seed `:root` (dark,
|
|
the default) and `:root[data-theme="light"]` (via `-light`-suffixed
|
|
variables — `$bg-light`, `$accent-light`, etc., same files, same
|
|
override mechanism) once at compile time; the toggle button in
|
|
`novaconium/pages/_layout/nav.twig` flips the `data-theme` attribute on
|
|
`<html>` at runtime and persists it to `localStorage`.
|
|
`novaconium/pages/_layout/theme-init.twig` re-applies a saved choice
|
|
early in `<head>` (before the stylesheet link) to avoid a flash of the
|
|
wrong theme on load. If you add a new color to the palette, add both
|
|
the plain and `-light` variable in **both** `_colors.sass` files and
|
|
wire it into both `:root` blocks in `main.sass` — a color that's only
|
|
themed in one direction will look wrong after a toggle.
|
|
- Sidecars should read request data via `Lib\Input::post()`/`::get()`
|
|
(`novaconium/lib/Input.php`) rather than `$_POST`/`$_GET` directly — it
|
|
trims, strips tags, and strips null bytes automatically. This is
|
|
defense-in-depth against HTML/script injection, **not** SQL-injection
|
|
protection (no string transform makes input safe to concatenate into a
|
|
query — use PDO prepared statements once a database layer exists); don't
|
|
add an `sqlSafe()`-style method to `Input`. One documented exception: a
|
|
field needing an exact, unmodified value (e.g. a password about to be
|
|
hashed) should read `$_POST` directly instead — see
|
|
`novaconium/pages/admin/password-hash/index.php`. `Lib\Csrf`
|
|
(`novaconium/lib/Csrf.php`) is standalone session-token CSRF protection, not
|
|
wired into `FormValidator` — a sidecar calls `Csrf::verify()` directly.
|
|
It's the first thing in the framework to start a native PHP session (only
|
|
lazily, when a form actually calls it), which is otherwise unrelated to
|
|
`AdminAuth`'s own session-free Basic Auth.
|
|
- Don't use Twig's `|slice` filter on a **string** (as opposed to an
|
|
array) — it unconditionally calls PHP's `mb_substr()` with no fallback
|
|
(`novaconium/vendor/twig/src/Extension/CoreExtension.php`), which
|
|
hard-requires the `mbstring` extension and will fatal
|
|
(`Call to undefined function Twig\Extension\mb_substr()`) on a PHP
|
|
install without it — a real regression this project hit once already,
|
|
back when `/blog/hello-world` had a sidecar computing an excerpt this
|
|
way (see the footnote on `App/pages/blog/twig-syntax-guide/index.twig`
|
|
for the full story). Truncate strings in PHP instead, guarded with
|
|
`function_exists('mb_substr')` falling back to `substr()`, and pass the
|
|
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.
|