Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7650354abd | |||
| 0831331fe3 | |||
| 76fd1ca3ed | |||
| 15b32ed256 | |||
| d1ce803412 | |||
| 8540c6d9ea | |||
| 64defe7f74 | |||
| e699027b4b | |||
| c0455241ea | |||
| a0ae58c29e | |||
| 6624c4fdc3 | |||
| f6fb2f12c6 | |||
| b37b13120e | |||
| b882c304b1 | |||
| cb64836901 | |||
| 74c0d59019 | |||
| a51faa7208 | |||
| 37b6764431 | |||
| 4862526fa1 | |||
| 5deb298b91 | |||
| 3a59269eaa | |||
| a3b996719a | |||
| 672f997d8b | |||
| fe5f5ee131 | |||
| fbd4e92e9f |
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitignore
|
||||
graphify-out/
|
||||
.claude/
|
||||
public/cache/*
|
||||
!public/cache/.gitkeep
|
||||
data/*.sqlite*
|
||||
images/*
|
||||
!images/.gitkeep
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
Dockerfile.sass
|
||||
+11
@@ -1 +1,12 @@
|
||||
/public/cache/*
|
||||
!/public/cache/.gitkeep
|
||||
/novaconium/contact-log.txt
|
||||
/data/*.sqlite
|
||||
/data/*.sqlite-journal
|
||||
/data/*.sqlite-wal
|
||||
/data/*.sqlite-shm
|
||||
.claude/
|
||||
/graphify-out/
|
||||
/public/uploads/*
|
||||
!/public/uploads/.gitkeep
|
||||
.env
|
||||
@@ -1,59 +1,197 @@
|
||||
# NovaconiumPHP
|
||||
# AGENTS.md
|
||||
|
||||
A lightweight PHP 8.1+ MVC-ish web framework/CMS toolkit (router + controllers + Twig views + Sass styling), authored by Nick Yeoman (4lt.ca) and distributed via Composer as `4lt/novaconium`. The canonical repo is hosted on a self-hosted Gitea instance (`git.4lt.ca`), not GitHub — do not assume `gh` CLI or GitHub Actions apply here.
|
||||
Context for any coding agent working in this repo — Claude, DeepSeek, or
|
||||
otherwise. Full narrative docs live at `/admin/docs` when the app is
|
||||
running. `README.md` is the GitHub-facing pitch, `novaconium/ISSUES.md` is
|
||||
the roadmap/backlog, and this file is the short, agent-facing version:
|
||||
load-bearing gotchas and conventions only, not narrative history.
|
||||
|
||||
## Directory map
|
||||
**This repo has a graphify knowledge graph (`graphify-out/`).** For design
|
||||
rationale, "why was it built this way," or exploring how components relate,
|
||||
query the graph instead of expecting this file to carry that context — this
|
||||
file is kept intentionally short and only lists things that will cause a
|
||||
bug or a broken convention if you don't know them going in.
|
||||
|
||||
- `src/` — core framework classes, namespace `Novaconium\` (PSR-4 → `src/`): `novaconium.php` (bootstrap), `Router.php`, `Database.php`, `Session.php`, `Redirect.php`, `Post.php`, `Logger.php`, `MessageHandler.php`, `functions.php`, `twig.php`, and `src/Services/` (`Auth.php`, `TagManager.php`).
|
||||
- `controllers/` — framework-level default controllers (dashboard, pages, messages, settings, auth/, sitemap, 404, init, create_admin, etc.).
|
||||
- `config/routes.php` — framework-level default route definitions, merged with app-level `App/routes.php` at runtime.
|
||||
- `views/` — Twig views mirroring controller names (`controllers/dashboard.php` ↔ `views/dashboard.html.twig`).
|
||||
- `twig/` — shared/partial Twig templates (layout pieces: `master.html.twig`, `nav.html.twig`, `head.html.twig`, `foot.html.twig`, `cp/` control-panel partials, `javascript/`).
|
||||
- `sass/` — framework's default Sass source (indented Sass, not SCSS), 7-1-ish structure: `abstracts/`, `base/`, `framework/`, `controlPanel/`, `coming-soon/`; has its own `Dockerfile` for compiling.
|
||||
- `skeleton/` — scaffold copied into a new consumer project on install: `.env`, `docker-compose.yml`, `novaconium/App/{config.php,routes.php,controllers,views,templates}`, `novaconium/public/{index.php,.htaccess,css,js}`, `novaconium/sass/project.sass`.
|
||||
- `docs/` — one short Markdown file per topic (see below).
|
||||
- `_assets/` — static assets (e.g. logo used in README).
|
||||
## Docs live in one place: `/admin/docs` — README stays thin
|
||||
|
||||
## Build / run (Docker-only — no host tooling assumed)
|
||||
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
|
||||
admin auth, styling, Docker, project layout, third-party) has exactly one
|
||||
canonical writeup: a page under `novaconium/pages/admin/docs/<topic>/index.twig`.
|
||||
`README.md` deliberately does **not** mirror this content — it's a short
|
||||
GitHub-facing pitch (what this is, minimal steps to get it running, a
|
||||
pointer into `/admin/docs`) plus the Third-party section, nothing more. The
|
||||
full feature list lives as a blog post, `App/pages/blog/novaconium-features/`
|
||||
(sample content, replaceable like any other post), not in the README. This
|
||||
was a deliberate change (2026-07-15) away from an earlier "keep README and
|
||||
docs in sync" convention that had made the README long and hard to scan —
|
||||
don't re-add a feature list or per-topic bullet list to README.md.
|
||||
|
||||
**Agents (Claude Code, opencode, etc.) run inside containers and have no access to Docker.** Do not attempt to run any `docker`/`docker compose` command below — they will fail. These commands are documented for a human maintainer to run on their own host. If you need to verify a change, do so by reading/reasoning about the code, not by invoking Docker.
|
||||
Any change to framework behavior or a new feature:
|
||||
|
||||
1. Update/add the docs page, and if new, link it from both
|
||||
`admin/docs/index.twig` and the nav in `admin/docs/_layout/layout.twig`.
|
||||
2. Update `App/pages/blog/novaconium-features/index.twig` (and its entry in
|
||||
`App/pages/blog/index.php`) if it affects the feature tour.
|
||||
3. Update `README.md` only if it affects the one-paragraph pitch, the
|
||||
minimal getting-started steps, or the Third-party section — not a
|
||||
per-feature bullet.
|
||||
4. Update this file only if it affects a convention an agent needs to know
|
||||
before editing code.
|
||||
|
||||
## 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
|
||||
|
||||
- **`App/`** — the project: `App/pages/`, `App/lib/` (`Lib\` classes),
|
||||
`App/config.php`, `App/migrations/`, `App/sass/`. The only directory a
|
||||
site author is expected to touch.
|
||||
- **`novaconium/`** — the framework: router/renderer core
|
||||
(`novaconium/src/`), default pages/libs, vendored Twig, autoloader,
|
||||
config, bootstrap.
|
||||
|
||||
Routing/rendering resolve against **both roots, `App/` first** (via
|
||||
`novaconium/src/Overlay.php` for pages, `novaconium/autoload.php` for
|
||||
`Lib\` classes) — same override-by-presence mechanism used for
|
||||
`config.php`, Twig's `FilesystemLoader`, and Sass (see below). A project
|
||||
only lists the config keys it's changing in `App/config.php`; never edit
|
||||
`novaconium/config.php` directly.
|
||||
|
||||
**`db_connections` is the one config key that isn't a plain shallow-merge.**
|
||||
`Lib\Db::config()` (and the duplicate in `bin/migrate.php`) merges it one
|
||||
level deeper, by connection name, so adding a second connection in
|
||||
`App/config.php` can't silently delete the framework's `default`
|
||||
connection. Capture the defaults *before* the top-level `array_merge()`
|
||||
overwrites `$config['db_connections']`, not after. See `/admin/docs/database`.
|
||||
|
||||
`Lib\Db` supports multiple, simultaneously-open named connections
|
||||
(`'sqlite'`/`'mysql'` drivers only). Each connection migrates lazily on
|
||||
first use, tracked by path **relative to the repo root** (not bare
|
||||
filename — two roots can share a filename). `migrations_dir` accepts an
|
||||
ordered list of roots, each fully processed before the next.
|
||||
|
||||
**The default DB path (`data/novaconium.sqlite`) lives outside `public/`
|
||||
(web-accessible) and `novaconium/`** (wholesale-replaced by framework
|
||||
updates) — it's a project-owned top-level dir, gitignored per-content with
|
||||
a tracked `.gitkeep`. Uploaded files (see Media manager,
|
||||
`/admin/docs/media-manager`) live under `public/uploads/` instead, since
|
||||
they need to be web-reachable directly — a separate, plain static
|
||||
directory on its own volume, not coupled to the SQLite path, since a
|
||||
project may run MySQL or no DB at all.
|
||||
|
||||
## Standing rule: caching vs. any content-hiding mechanism
|
||||
|
||||
**Any mechanism that conditionally hides page content from the public
|
||||
must be threaded into `Renderer::render()`'s `$excludeFromCache` param, not
|
||||
just a pre-render auth gate.** `Renderer::render()` writes a sidecar-less
|
||||
page's output to the static HTML cache, and `.htaccess` serves a cached
|
||||
file *before PHP (and therefore any auth check) ever runs again*. A route
|
||||
gated only at the auth-check level still leaks to the public the moment an
|
||||
authorized user views it once, if the page has no sidecar. `draft_routes`
|
||||
and every `/admin/*` route already pass `true` for this reason. Any new
|
||||
feature that gates a route by anything other than a sidecar check needs the
|
||||
same treatment — this has caused a real bug before, twice.
|
||||
|
||||
Corollary: `Lib\Access` (the sidecar-level content gate, see
|
||||
`/admin/docs/access-control`) is safe by construction — a page with no
|
||||
sidecar can't call `Access`, and only sidecar-less pages get cached, so a
|
||||
gated page can never leak through the cache with no extra wiring needed.
|
||||
|
||||
## Reentrancy hazard: ContentIndexer
|
||||
|
||||
`ContentIndexer::reindex()` renders every routable page, including
|
||||
`/search` itself, which also calls `ContentIndexer::ensureFresh()`.
|
||||
Guarded by a `private static bool $indexing` flag checked at the top of
|
||||
both methods — don't remove it, any new consumer route inherits the same
|
||||
hazard automatically. `reindex()` also forces
|
||||
`$_SERVER['REQUEST_METHOD']` to `'GET'` for the duration of the crawl
|
||||
(restored in a `finally`) so a lazy reindex triggered from a POST can't
|
||||
leak that POST into an unrelated page's sidecar.
|
||||
|
||||
## Vendored dependency placement
|
||||
|
||||
**Server-side-only (PHP, autoloaded) → `novaconium/vendor/`. Anything a
|
||||
browser fetches (`.js`, `.css`, images) → `public/vendor/`** — `novaconium/`
|
||||
is never web-reachable. This matters beyond correctness: `public/` is
|
||||
project-owned and untouched by a framework update, so a `public/vendor/`
|
||||
dependency bump does **not** propagate automatically the way a
|
||||
`novaconium/vendor/` bump would — re-vendoring is a manual step per
|
||||
dependency (see `/admin/docs/upgrading-highlightjs`).
|
||||
|
||||
## Twig gotchas that will fatal without `mbstring`
|
||||
|
||||
Don't use `|slice` on a **string** (calls `mb_substr()` unconditionally) or
|
||||
`|escape('js')`/`'js'` arg to `|e` (calls `mb_ord()`) — both hard-require
|
||||
`mbstring` and fatal without it; this project deliberately avoids that
|
||||
dependency. Truncate strings in PHP with an `mb_substr`/`substr` fallback
|
||||
instead. For markup destined for inline `<script>`, render into a
|
||||
`<template>` element and read `.innerHTML` in JS rather than
|
||||
`|escape('js')`.
|
||||
|
||||
`class="nohighlight"` marks a `<pre><code>` block containing literal Twig
|
||||
syntax (`{% %}`/`{{ }}`) — highlight.js has no Twig grammar and a
|
||||
restricted auto-detect still always guesses wrong without this class. Any
|
||||
new Twig-syntax code sample needs it; PHP/Bash samples don't.
|
||||
|
||||
## Sass override quirk
|
||||
|
||||
`novaconium/sass/main.sass` does `@use 'colors' as *` with **no**
|
||||
`_colors.sass` sibling in `novaconium/sass/` — on purpose. Dart Sass
|
||||
resolves a bare `@use` relative to the importing file's own directory
|
||||
*before* `--load-path`, so a sibling file would always win and silently
|
||||
defeat the `App/sass/_colors.sass` override. The framework default lives
|
||||
at `novaconium/sass/defaults/_colors.sass` instead. Don't move it back.
|
||||
|
||||
Every color rule in `main.sass` reads a CSS custom property (`var(--bg)`,
|
||||
etc.), never a Sass variable directly — required for the runtime dark/light
|
||||
toggle. Adding a color means adding both the plain and `-light` variable in
|
||||
**both** `_colors.sass` files and wiring it into both `:root` blocks.
|
||||
|
||||
## Input handling
|
||||
|
||||
Sidecars read request data via `Lib\Input::post()`/`::get()`, not
|
||||
`$_POST`/`$_GET` directly (trims, strips tags/null bytes — XSS
|
||||
defense-in-depth, **not** SQL-injection protection; use PDO prepared
|
||||
statements via `Lib\Db::query()` for that, never string-interpolated SQL).
|
||||
Exception: fields needing an exact unmodified value (e.g. a password about
|
||||
to be hashed) read `$_POST` directly — see login/users sidecars.
|
||||
`Lib\Csrf::verify()` is called directly by a sidecar, not wired into
|
||||
`FormValidator`.
|
||||
|
||||
## Running it
|
||||
|
||||
**Composer** (per `docs/Composer.md`):
|
||||
```
|
||||
docker run --rm --interactive --tty --volume $PWD:/app composer:latest require 4lt/novaconium
|
||||
docker run --rm --interactive --tty --volume $PWD:/app composer:latest update
|
||||
php -S 127.0.0.1:8000 -t public public/router.php
|
||||
```
|
||||
|
||||
**Sass build** (per `docs/Sass.md`), using `sass/Dockerfile`:
|
||||
```
|
||||
cd sass && docker build -t sass-container .
|
||||
docker run --rm -v $(pwd):/usr/src/app -w /usr/src/app sass-container sass novaconium/sass/project.sass novaconium/public/css/novaconium.css
|
||||
```
|
||||
Compressed variant: add `--style=compressed`, e.g.
|
||||
```
|
||||
docker run --rm -v $(pwd):/usr/src/app -w /usr/src/app sass-container --style=compressed sass/novaconium.sass skeleton/novaconium/public/css/novaconium.css
|
||||
```
|
||||
`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). After testing, clear
|
||||
stray cache with `php novaconium/bin/clear-cache.php` and remove any
|
||||
test-only debris from `App/lib/`/`App/pages/` — nothing there is gitignored
|
||||
except `public/cache/*` and `novaconium/contact-log.txt`.
|
||||
|
||||
**Dev stack**: `docker compose up -d` from `skeleton/docker-compose.yml` — services: `4lights/corxn:8.5.3` (Apache+PHP), `redis`, `mariadb`, `phpmyadmin`.
|
||||
## Conventions worth knowing
|
||||
|
||||
**Testing a cloned copy without reinstalling via Composer**: see `docs/Dev-Fake_autoload.md` for the fake-autoload dev trick.
|
||||
|
||||
## No test suite / lint / CI
|
||||
|
||||
There is no `phpunit.xml`, no lint config (no PHPCS, `.editorconfig`, ESLint), and no CI pipeline in this repo. Don't hunt for `npm test`/`phpunit`/lint commands — none exist.
|
||||
|
||||
## Conventions
|
||||
|
||||
- PSR-4 autoloading: `Novaconium\` → `src/`.
|
||||
- Controllers and views are name-paired by default (e.g. `controllers/dashboard.php` ↔ `views/dashboard.html.twig`), but this is only a convention — a controller can render any view, they are not required to match.
|
||||
- Sass partials are prefixed `_` (e.g. `_forms.sass`), aggregated per-folder via `index.sass`.
|
||||
- App-level config (`App/config.php`) is a multi-dimensional PHP array: database credentials, `base_url`, `secure_key`, `logfile`, `loglevel`.
|
||||
- Versioning strategy is semantic-versioning (declared in `composer.json` `extra.versioning`).
|
||||
|
||||
## Git workflow
|
||||
|
||||
Single `master` branch (no `main`), tracking a self-hosted Gitea remote. Solo-maintainer, trunk-based, direct commits to `master` with short informal messages — no conventional-commits enforcement, no PR-based tooling assumptions.
|
||||
|
||||
## Further reading
|
||||
|
||||
See `docs/*.md` for per-topic detail: `404.md`, `Composer.md`, `ConfigurationFile.md`, `Dev-Fake_autoload.md`, `Logs.md`, `Messages.md`, `Post.md`, `Redirect.md`, `Sass.md`, `Session.md`, `StyleSheets-sass.md`, `Twig-Views.md`, `docker.md`.
|
||||
- Reserved segments: any path segment starting with `_` or literally named
|
||||
`404` is never routable — `Router::resolve()` 404s on sight.
|
||||
- Sidecars (`index.php`) return an array (Twig context) or a `Response`.
|
||||
`$params` and `$cache` are in scope automatically — see
|
||||
`novaconium/src/Renderer.php::runSidecar()`.
|
||||
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader. A
|
||||
new framework-core class goes under `App\` in `novaconium/src/`; a new
|
||||
`Lib\` class goes in `App/lib/` or `novaconium/lib/`.
|
||||
- `novaconium/bin/` holds standalone CLI entry points
|
||||
(`php novaconium/bin/<script>.php`) — distinct from
|
||||
`bootstrap.php`/`autoload.php`/`config.php`, which are only `require`'d.
|
||||
- CSS compiles from `novaconium/sass/main.sass` (indented syntax) to
|
||||
`public/css/main.css`:
|
||||
`sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css`
|
||||
— commit the regenerated CSS. See `/admin/docs/styling` for a Docker
|
||||
fallback if `sass` isn't installed locally.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
// Override any subset of novaconium/config.php's defaults here — only list
|
||||
// the keys you want to change. novaconium/bootstrap.php (and
|
||||
// novaconium/bin/clear-cache.php) shallow-merge this over the framework
|
||||
// defaults; see /admin/docs/config. Uncomment and adjust any of the
|
||||
// examples below, or leave this file returning an empty array to keep
|
||||
// every framework default as-is.
|
||||
return [
|
||||
// 'debug' => false,
|
||||
|
||||
// 'site_name' => 'My Site',
|
||||
|
||||
// Docs: /admin/docs/matomo
|
||||
// 'matomo_url' => 'https://matomo.example.com/',
|
||||
// 'matomo_site_id' => '1',
|
||||
|
||||
// Docs: /admin/docs/admin-auth — session login for /admin/* against
|
||||
// the SQLite-backed users table. After enabling, create the first user
|
||||
// at /admin/users, or beforehand (safer) with:
|
||||
// php novaconium/bin/create-admin-user.php <username>
|
||||
// 'admin_auth_enabled' => true,
|
||||
|
||||
// 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_auth_enabled above (and at
|
||||
// least one user) 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,
|
||||
|
||||
// Docs: /admin/docs/admin-auth — email verification. Set all four to
|
||||
// switch Lib\Mailer's transactional mail (verification links, not the
|
||||
// contact form) from the zero-dependency log fallback to MailJet:
|
||||
// 'mail_driver' => 'mailjet',
|
||||
// 'mail_from_email' => 'noreply@example.com',
|
||||
// 'mail_from_name' => 'Example Site',
|
||||
// 'mailjet_api_key' => '...',
|
||||
// 'mailjet_api_secret' => '...',
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}About{% endblock %}
|
||||
{% block description %}How this site is built: Novaconium, a tiny PHP micro-framework with file-based routing, Twig templates, and static caching.{% endblock %}
|
||||
|
||||
{# Every block below is optional — the layout already supplies a sensible
|
||||
default for each (see /admin/docs/seo). Shown here uncommented as a
|
||||
reference for every override a page can make; delete what you don't
|
||||
need. #}
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||
|
||||
{% block og_type %}website{% 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 content %}
|
||||
<article>
|
||||
<h1>About</h1>
|
||||
|
||||
<p>This site runs on <strong>Novaconium</strong>, a tiny PHP micro-framework built around one idea: a directory on disk <em>is</em> a route. There's no router configuration to maintain, no ORM, and no Composer — <a href="https://twig.symfony.com/">Twig</a> is the only dependency it has, and it's vendored directly into the repo as plain source files rather than pulled in through a package manager.</p>
|
||||
|
||||
<p>Pages render with Twig and are optionally backed by a PHP "sidecar" — a plain <code>index.php</code> file that supplies template data, or short-circuits templating entirely by returning a redirect, JSON, or raw HTML. Pages with no sidecar are pure and deterministic, so they're rendered once and served straight from Apache as static HTML on every later request, with no PHP or Twig overhead at all.</p>
|
||||
|
||||
<p>Everything the framework itself ships — default pages, default library classes, the root layout — lives under <code>novaconium/</code>, and can be overridden by placing a same-named file under <code>App/</code>, the only directory a site author is expected to touch. The same override mechanism covers configuration, too: any key in <code>novaconium/config.php</code> can be replaced piecemeal from <code>App/config.php</code>.</p>
|
||||
|
||||
<p>Beyond routing and templating, Novaconium ships with SEO meta tags (canonical links, Open Graph, Twitter Card), optional Matomo analytics with automatic 404 tracking, and a multi-user admin login (with browser-based user management) covering every <code>/admin/*</code> page — all off or sensible by default, and all documented at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance rather than a separate website.</p>
|
||||
|
||||
<p>It's a good fit for small marketing sites, blogs, and internal tools where a full framework would be overkill but a flat-file site generator alone falls short of real server-side logic. The source is on Git if you want to see how it's put together: <a class="icon-link" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}git.4lt.ca/4lt/novaconium</a>.</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends '_layout/layout.twig' %}
|
||||
|
||||
{% 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 %}
|
||||
<div class="blog-layout">
|
||||
<aside>
|
||||
<p>This sidebar exists because <code>App/pages/blog/_layout/layout.twig</code> overrides the site-wide root layout for everything under <code>/blog</code> — the nearest <code>_layout/layout.twig</code> wins, so a subtree can look different without touching the pages themselves.</p>
|
||||
<p><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts docs</a></p>
|
||||
</aside>
|
||||
<article>
|
||||
{% block blog_content %}{% endblock %}
|
||||
{# Only pages whose sidecar opts in by returning a 'comments'
|
||||
key get a thread — see /admin/docs/comments and
|
||||
App/pages/blog/hello-world/index.php. A sidecar-less post
|
||||
never has this key, so it's silently skipped. #}
|
||||
{% if comments is defined %}
|
||||
{% include '_partials/comments/thread.twig' %}
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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,59 @@
|
||||
<?php
|
||||
|
||||
// Demonstrates attaching Lib\Comments to a page — see /admin/docs/comments.
|
||||
// This is the one post under App/pages/blog/ with a sidecar, specifically
|
||||
// so it can carry a live comment thread; giving it one is what excludes
|
||||
// it from the static HTML cache (Renderer::render() only ever caches
|
||||
// sidecar-less pages) — every other post stays sidecar-less/cached and
|
||||
// has no thread, since blog/_layout/layout.twig only includes one when
|
||||
// 'comments' is present in the returned context.
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
use Lib\Comments;
|
||||
use Lib\Csrf;
|
||||
use Lib\FormValidator;
|
||||
use Lib\Input;
|
||||
use Lib\SpamGuard;
|
||||
|
||||
$pagePath = Comments::currentPagePath();
|
||||
$user = AdminAuth::currentUser();
|
||||
$spamGuard = new SpamGuard();
|
||||
$commentError = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return Response::redirect($pagePath . '?error=security');
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
return Response::redirect($pagePath . '?error=login');
|
||||
}
|
||||
|
||||
$body = Input::post('body', '');
|
||||
$validator = (new FormValidator())
|
||||
->required($body, 'body', 'Enter a comment.')
|
||||
->maxLength($body, 'body', 2000, 'Comments are 2000 characters max.');
|
||||
|
||||
if ($validator->passes()) {
|
||||
// Same "bot gets an identical response" reasoning as the contact
|
||||
// form — see App/pages/contact/index.php. Only the insert is
|
||||
// skipped for spam.
|
||||
if (!$spamGuard->isSpam(Input::post())) {
|
||||
Comments::create($pagePath, $user['id'], $body);
|
||||
}
|
||||
|
||||
return Response::redirect($pagePath);
|
||||
}
|
||||
|
||||
$commentError = $validator->errors()['body'] ?? null;
|
||||
}
|
||||
|
||||
return [
|
||||
'comments' => Comments::forPage($pagePath),
|
||||
'currentUser' => $user,
|
||||
'commentError' => $commentError,
|
||||
'renderedAt' => $spamGuard->renderedAt(),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Comments Demo{% endblock %}
|
||||
{% block description %}A worked example of Lib\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.{% endblock %}
|
||||
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block tags %}comments, meta{% 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>Comments Demo</h1>
|
||||
|
||||
<p>Unlike every other post under <code>App/pages/blog/</code>, this one has its own <code>index.php</code> sidecar — <code>App/pages/blog/comments-demo/index.php</code> — which reads and writes a <code>comments</code> table via <code>Lib\Comments</code> and returns a <code>comments</code> key in its context. <code>App/pages/blog/_layout/layout.twig</code> only includes the comment-thread partial (<code>novaconium/pages/_partials/comments/thread.twig</code>) when that key is present, so this is the only post here with a thread below.</p>
|
||||
|
||||
<p>Having a sidecar means this page is never served from the static HTML cache the way its sidecar-less siblings are (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — an explicit, per-page tradeoff you accept the moment a page needs comments. See <a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a> for the full write-up of <code>Lib\Comments</code>, including why comments are tied to real logged-in accounts rather than anonymous name/email fields, and why they're auto-approved with after-the-fact moderation at <code>/admin/comments</code> rather than a pending queue.</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);
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Hello, World!{% 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 tags %}welcome, meta{% 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>Hello, World!</h1>
|
||||
|
||||
<p>The first post on this blog — a plain page at <code>App/pages/blog/hello-world/index.twig</code>, sidecar-less like <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a> and the <a class="icon-link" href="/blog/style-guide">{{ icons.book() }}Style Guide</a>. Since it has no sidecar, it's rendered once and served as static HTML from <code>public/cache/blog/hello-world/index.html</code> on every later request — see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>.</p>
|
||||
|
||||
<p>This URL used to be backed by a wildcard <code>App/pages/blog/[slug]/</code> route pulling from a <code>Lib\PostRepository</code> class — a live demo of route-param capture. That mechanism (any single URL segment captured into <code>$params</code>) is still fully supported by the framework; see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a>. It just isn't what renders this particular post anymore, now that this post is fixed content rather than a stand-in for arbitrary slugs.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
// Every post under App/pages/blog/ is now a plain, sidecar-less directory
|
||||
// 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
|
||||
// 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 [
|
||||
'posts' => [
|
||||
[
|
||||
'slug' => 'hello-world',
|
||||
'title' => 'Hello, World!',
|
||||
'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',
|
||||
'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.',
|
||||
'published' => '2026-07-11',
|
||||
],
|
||||
[
|
||||
'slug' => '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.',
|
||||
'published' => '2026-07-13',
|
||||
],
|
||||
[
|
||||
'slug' => 'style-guide',
|
||||
'title' => 'Style Guide',
|
||||
'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',
|
||||
],
|
||||
[
|
||||
'slug' => 'novaconium-features',
|
||||
'title' => 'Novaconium Features',
|
||||
'excerpt' => 'A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.',
|
||||
'published' => '2026-07-15',
|
||||
],
|
||||
[
|
||||
'slug' => 'comments-demo',
|
||||
'title' => 'Comments Demo',
|
||||
'excerpt' => 'A worked example of Lib\\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.',
|
||||
'published' => '2026-07-15',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% block title %}Blog{% endblock %}
|
||||
{% block description %}All posts on this blog — a working example of a sidecar-driven listing page.{% endblock %}
|
||||
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||
|
||||
{% block og_type %}website{% 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>Blog</h1>
|
||||
<p>Rendered by <code>App/pages/blog/index.php</code> and <code>index.twig</code> — a sidecar that returns a hand-maintained array pointing at each post directory under <code>App/pages/blog/</code>. Because this page has a sidecar, it's never served from the static cache; the list is rebuilt on every request, even though the array itself only changes when a post is added.</p>
|
||||
|
||||
<ul class="post-list">
|
||||
{% for post in posts %}
|
||||
<li>
|
||||
<h2><a href="/blog/{{ post.slug }}">{{ post.title }}</a></h2>
|
||||
<p>{{ post.excerpt }}…</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Novaconium Features{% endblock %}
|
||||
{% block description %}A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.{% endblock %}
|
||||
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block tags %}features, meta{% 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>Novaconium Features</h1>
|
||||
|
||||
<p>A tour of what ships with novaconium out of the box. Every topic below has a full writeup at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a> on any running instance — this post is the overview.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>File-based routing</strong> — a directory under <code>App/pages/</code> <em>is</em> a route (Hugo-style page bundles). No route table to maintain.</li>
|
||||
<li><strong><code>[param]</code> segments</strong> — a directory literally named <code>[param]</code> (e.g. <code>App/pages/products/[id]/</code>) captures any single URL segment into <code>$params['param']</code> for clean URLs, no query strings.</li>
|
||||
<li><strong>Optional PHP "sidecars"</strong> — drop an <code>index.php</code> next to any <code>index.twig</code> to supply Twig context data, or return a <code>Response</code> (redirect/JSON/XML/HTML) to short-circuit templating entirely.</li>
|
||||
<li><strong>Static caching, zero config</strong> — sidecar-less pages render once and are written to <code>public/cache/</code>; <code>.htaccess</code> serves the cached file directly on every later hit, skipping PHP and Twig entirely.</li>
|
||||
<li><strong>Override-by-path</strong> — <code>App/</code> (your project) is checked before <code>novaconium/</code> (the framework defaults) for every page, layout, <code>Lib\</code> class, and even the Sass color palette (<code>App/sass/_colors.sass</code>). Drop a file at the same relative path to override it; nothing needs duplicating to get a working site.</li>
|
||||
<li><strong>Layout inheritance</strong> — <code>_layout/layout.twig</code> directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.</li>
|
||||
<li><strong>SEO boilerplate out of the box</strong> — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.</li>
|
||||
<li><strong>Built-in Matomo analytics</strong> — set <code>matomo_url</code> and <code>matomo_site_id</code> in <code>App/config.php</code> to enable tracking site-wide, including automatic 404 tracking. Off by default.</li>
|
||||
<li><strong>Admin authentication</strong> — gate every <code>/admin/*</code> route behind a session login with multi-user management: a SQLite-backed <code>users</code> table, <code>/admin/login</code>/<code>/admin/logout</code>, and an <code>/admin/users</code> page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a <code>novaconium/bin/create-admin-user.php</code> CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Every account after the first must verify its email (a link sent via <code>Lib\Mailer</code>, logged to a file by default or sent through MailJet if configured) before it can log in. Enabled with a single <code>admin_auth_enabled</code> flag in <code>App/config.php</code>; off by default.</li>
|
||||
<li><strong>Access control</strong> — assign a page (or a section, one line per page) to a user or group from its sidecar: <code>Access::require('group:members')</code> returns <code>null</code> or a ready-made <code>Response</code> (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction.</li>
|
||||
<li><strong>Draft pages</strong> — list a 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 404, not a login prompt.</li>
|
||||
<li><strong>Media manager</strong> — <code>/admin/media</code>, an upload/browse/delete UI for files under <code>public/uploads/</code>, covered by the existing <code>/admin/*</code> auth gate with no separate flag needed. Extension allowlist and max upload size are configurable.</li>
|
||||
<li><strong>Comments</strong> — <code>Lib\Comments</code>, a reusable comment thread any page can attach to itself via its own sidecar (see <code>App/pages/blog/comments-demo/</code>). Tied to real logged-in accounts, not anonymous name/email fields; auto-approved on submission with after-the-fact hide/delete moderation at <code>/admin/comments</code>.</li>
|
||||
<li><strong>Dark/light theme toggle</strong> — a nav button flips a <code>data-theme</code> attribute (persisted to <code>localStorage</code>) that swaps every color via CSS custom properties.</li>
|
||||
<li><strong>Self-hosted spam prevention & form validation</strong> — <code>Lib\SpamGuard</code> (honeypot + submission-timing check, no external CAPTCHA), <code>Lib\FormValidator</code>, and <code>Lib\Validate</code>, demonstrated on the contact form.</li>
|
||||
<li><strong>Form security by default</strong> — <code>Lib\Input</code> (cleaning accessor for <code>$_POST</code>/<code>$_GET</code>) and <code>Lib\Csrf</code> (standalone session-token CSRF protection), wired into the contact form and every admin form.</li>
|
||||
<li><strong>SQLite/MySQL database, zero setup</strong> — <code>Lib\Db</code>, a thin PDO wrapper (no ORM) supporting multiple named connections open at once, each with its own plain-SQL migration convention, applied automatically on first use or via <code>php novaconium/bin/migrate.php</code>.</li>
|
||||
<li><strong>Sessions with flash data</strong> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions with CodeIgniter-style flash values for post/redirect/GET flows.</li>
|
||||
<li><strong>Content index: sitemap, search, tags</strong> — <code>/sitemap.xml</code>, full-text <code>/search</code> (SQLite FTS5), and blog tag browsing all share one crawler. Off by default; reindexes lazily on demand or via <code>php novaconium/bin/index-content.php</code>.</li>
|
||||
<li><strong>Blog RSS feed</strong> — <code>/blog/feed</code>, built from the same hand-written post list <code>App/pages/blog/index.php</code> itself renders from, so it works with no database at all.</li>
|
||||
<li><strong>Syntax-highlighted code blocks</strong> — vendored <a href="https://highlightjs.org/">highlight.js</a> colors PHP/Bash/HTML code blocks site-wide, auto-detected with no per-block markup.</li>
|
||||
<li><strong>No build step, no Composer</strong> — clone it, point Apache (or <code>php -S</code>) at <code>public/</code>, and it runs. Twig is vendored as source.</li>
|
||||
</ul>
|
||||
|
||||
<p>Full details on every one of these live at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo, admin authentication, access control, draft pages, media manager, styling, project layout, and third-party notices.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}A Second Post{% 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 tags %}meta{% 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>A Second Post</h1>
|
||||
|
||||
<p>A second post at its own URL — <code>App/pages/blog/second-post/index.twig</code>. Just another plain, sidecar-less directory under <code>App/pages/blog/</code>, same as <a class="icon-link" href="/blog/hello-world">{{ icons.book() }}Hello, World!</a> next to it. Adding a new post is nothing more than a new directory with an <code>index.twig</code> — no route to register, no repository entry to add.</p>
|
||||
|
||||
<p>Both posts are listed on <a class="icon-link" href="/blog">{{ icons.book() }}the blog index</a>, built by <code>App/pages/blog/index.php</code> — see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> for how a URL maps to a directory in the first place.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,129 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Style Guide{% 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 tags %}css, 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>Style Guide</h1>
|
||||
<p>A plain page, like <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a>, this time showing off the default styling every element on this site gets for free from <code>novaconium/sass/main.sass</code> — no per-page CSS involved. If you've changed <code>App/sass/_colors.sass</code> (see <a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a>), this page is the fastest way to see the new palette applied across everything at once.</p>
|
||||
|
||||
<h2>Headings</h2>
|
||||
<h1>Heading level 1</h1>
|
||||
<h2>Heading level 2</h2>
|
||||
<h3>Heading level 3</h3>
|
||||
<h4>Heading level 4</h4>
|
||||
<h5>Heading level 5</h5>
|
||||
<h6>Heading level 6</h6>
|
||||
|
||||
<h2>Paragraph & inline text</h2>
|
||||
<p>A normal paragraph, with <strong>bold</strong>, <em>italic</em>, <code>inline code</code>, and <a href="/">a link</a> mixed in. <small>Small print, like this, is used for captions and secondary detail throughout the site.</small></p>
|
||||
|
||||
<h2>Blockquote</h2>
|
||||
<blockquote>
|
||||
<p>Design is not just what it looks like and feels like. Design is how it works.</p>
|
||||
</blockquote>
|
||||
|
||||
<h2>Lists</h2>
|
||||
<h3>Unordered</h3>
|
||||
<ul>
|
||||
<li>File-based routing</li>
|
||||
<li>Optional sidecars</li>
|
||||
<li>Static caching</li>
|
||||
</ul>
|
||||
<h3>Ordered</h3>
|
||||
<ol>
|
||||
<li>Write a Twig template</li>
|
||||
<li>Optionally add a sidecar</li>
|
||||
<li>Visit the URL</li>
|
||||
</ol>
|
||||
<h3>Nested</h3>
|
||||
<ul>
|
||||
<li>App/
|
||||
<ul>
|
||||
<li>pages/</li>
|
||||
<li>lib/</li>
|
||||
<li>sass/</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>novaconium/
|
||||
<ul>
|
||||
<li>pages/</li>
|
||||
<li>src/</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Table</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Element</th><th>Styled by</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Headings</td><td><code>h1, h2, h3, h4, h5, h6</code></td></tr>
|
||||
<tr><td>Links</td><td><code>a</code>, <code>a:hover</code></td></tr>
|
||||
<tr><td>Code</td><td><code>code</code>, <code>pre</code></td></tr>
|
||||
<tr><td>Tables</td><td><code>table</code>, <code>th</code>, <code>td</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Code</h2>
|
||||
<p>Inline: <code>(new Mailer())->send($old['name'], $old['email'], $old['message']);</code></p>
|
||||
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||
|
||||
{% block content %}
|
||||
...
|
||||
{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<h2>Horizontal rule</h2>
|
||||
<p>Above this line:</p>
|
||||
<hr>
|
||||
<p>Below this line.</p>
|
||||
|
||||
<h2>Form elements</h2>
|
||||
<form>
|
||||
<p>
|
||||
<label for="style-guide-example">A label</label><br>
|
||||
<input type="text" id="style-guide-example" placeholder="A text input">
|
||||
</p>
|
||||
<p>
|
||||
<label for="style-guide-textarea">A textarea</label><br>
|
||||
<textarea id="style-guide-textarea" rows="3" placeholder="Some longer text"></textarea>
|
||||
</p>
|
||||
<p>
|
||||
<label for="style-guide-select">A dropdown</label><br>
|
||||
<select id="style-guide-select">
|
||||
<option>Option one</option>
|
||||
<option>Option two</option>
|
||||
<option>Option three</option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
Radio buttons<br>
|
||||
<label><input type="radio" name="style-guide-radio" checked> First choice</label><br>
|
||||
<label><input type="radio" name="style-guide-radio"> Second choice</label><br>
|
||||
<label><input type="radio" name="style-guide-radio"> Third choice</label>
|
||||
</p>
|
||||
<p>
|
||||
<label><input type="checkbox" checked> A checkbox, too, while we're here</label>
|
||||
</p>
|
||||
<button type="button">A button</button>
|
||||
</form>
|
||||
|
||||
<h2>Icons</h2>
|
||||
<p>{{ icons.home() }} {{ icons.git() }} {{ icons.book() }} {{ icons.link() }} {{ icons.sitemap() }} {{ icons.email() }} {{ icons.search() }} {{ icons.rss() }} {{ icons.tag() }} {{ icons.lock() }} {{ icons.trash() }} {{ icons.external_link() }} {{ icons.menu() }} {{ icons.back_to_top() }} — every inline SVG icon in <code>novaconium/pages/_layout/icons.twig</code>, all inheriting the surrounding text color via <code>currentColor</code>.</p>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Twig Syntax Guide{% 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 tags %}twig, 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>Twig Syntax Guide</h1>
|
||||
<p>Every page on this site is a Twig template. This post is a quick tour of the syntax that shows up throughout <code>App/pages/</code> and <code>novaconium/pages/</code> — not a full Twig reference (see <a class="icon-link" href="https://twig.symfony.com/doc/3.x/templates.html">{{ icons.external_link() }}the official docs</a> for that), just the parts this framework actually leans on, plus a couple of gotchas learned the hard way<sup><a href="#fn-1">1</a></sup>.</p>
|
||||
|
||||
<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>
|
||||
<pre><code class="nohighlight">{% verbatim %}{{ title }}
|
||||
{{ params.slug }}
|
||||
{{ 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>
|
||||
|
||||
<h2>Filters</h2>
|
||||
<p>Filters transform a value with a pipe: <code>{{ '{{ value|filter }}' }}</code>, and chain left to right. A few used on this site:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Filter</th><th>Example</th><th>Does</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>default</code></td><td><code>{{ '{{ request_path|default(\'/\') }}' }}</code></td><td>Falls back when a variable is undefined or empty.</td></tr>
|
||||
<tr><td><code>date</code></td><td><code>{{ '{{ "now"|date("Y") }}' }}</code></td><td>Formats a date — used for the footer's copyright year.</td></tr>
|
||||
<tr><td><code>e</code> (escape)</td><td><code>{{ '{{ matomo_url|e(\'js\') }}' }}</code></td><td>Escapes for a context other than HTML, here JavaScript string literals.</td></tr>
|
||||
<tr><td><code>raw</code></td><td><code>{{ '{{ html_string|raw }}' }}</code></td><td>Opts out of autoescaping — see Other elements below.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>One filter to avoid:</strong> <code>|slice</code> on a <em>string</em> (not an array) calls PHP's <code>mb_substr()</code> internally with no fallback — see the footnote<sup><a href="#fn-1">1</a></sup>.</p>
|
||||
|
||||
<h2>Control structures</h2>
|
||||
<p>The two workhorses are <code>{% verbatim %}{% if %}{% endverbatim %}</code> and <code>{% verbatim %}{% for %}{% endverbatim %}</code>:</p>
|
||||
<pre><code class="nohighlight">{% verbatim %}{% if sent %}
|
||||
<p>Thanks — your message has been sent.</p>
|
||||
{% endif %}{% endverbatim %}</code></pre>
|
||||
<pre><code class="nohighlight">{% verbatim %}{% for post in posts %}
|
||||
<li><a href="/blog/{{ post.slug }}">{{ post.title }}</a></li>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||
|
||||
{% block title %}Blog{% endblock %}
|
||||
{% block blog_content %}
|
||||
...
|
||||
{% 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>
|
||||
<pre><code class="nohighlight">{% verbatim %}{% import '_layout/icons.twig' as icons %}
|
||||
{{ 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>
|
||||
|
||||
<h2>Lists, three ways</h2>
|
||||
<p>Ordered:</p>
|
||||
<ol>
|
||||
<li>Parse the template source into an AST.</li>
|
||||
<li>Compile the AST into a plain PHP class.</li>
|
||||
<li>Cache and execute that compiled class (caching is disabled in this project's <code>Renderer</code>, since <code>novaconium/vendor/twig/</code> — the source — is already about as fast to re-parse as anything else here).</li>
|
||||
</ol>
|
||||
<p>Unordered:</p>
|
||||
<ul>
|
||||
<li><code>{{ '{{ }}' }}</code> — output</li>
|
||||
<li><code>{% verbatim %}{% %}{% endverbatim %}</code> — tags/control structures</li>
|
||||
<li><code>{# #}</code> — comments</li>
|
||||
</ul>
|
||||
<p>Nested — the three page kinds this framework recognizes:</p>
|
||||
<ul>
|
||||
<li>Sidecar-less pages
|
||||
<ul>
|
||||
<li>Rendered once, then cached as static HTML</li>
|
||||
<li>e.g. this page's siblings <code>/</code>, <code>/about</code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Pages with a sidecar
|
||||
<ul>
|
||||
<li>Return array (Twig context) or a <code>Response</code></li>
|
||||
<li>Never cached, since output can vary per request</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Other elements</h2>
|
||||
<p><strong>Whitespace control:</strong> a hyphen inside a tag delimiter — <code>{% verbatim %}{%- -%}{% endverbatim %}</code> or <code>{{ '{{- -}}' }}</code> — trims adjacent whitespace/newlines from the output. Not heavily used in this project, since the HTML here isn't whitespace-sensitive, but useful when generating something like JSON or plain text from a template.</p>
|
||||
<p><strong>Autoescaping:</strong> Twig HTML-escapes every <code>{{ '{{ }}' }}</code> output by default, so a sidecar can safely return user input (like the contact form's <code>old.name</code>) without it being able to inject markup. The <code>|raw</code> filter opts out where the content is trusted and intentionally HTML — used nowhere on the public site, but by the <code>|raw</code>-free docs pages under <code>/admin/docs</code>.</p>
|
||||
<p><strong>Functions vs. filters:</strong> <code>block('title')</code> (used throughout this site's SEO blocks to reuse the <code>title</code> block's content for <code>og:title</code>) is a <em>function</em>, called with parentheses, not piped like a filter.</p>
|
||||
|
||||
<div class="footnotes">
|
||||
<ol>
|
||||
<li id="fn-1">Twig's <code>|slice</code> filter, applied to a string, calls PHP's <code>mb_substr()</code> with no fallback — a hard dependency on the <code>mbstring</code> extension that isn't guaranteed on every PHP install. This project hit that exact fatal error on <code>/blog/hello-world</code> once already, back when that page had a sidecar computing an excerpt this way. The fix: truncate in PHP instead, guarded with <code>function_exists('mb_substr')</code> falling back to plain <code>substr()</code> — see <code>AGENTS.md</code> for the standing rule.</li>
|
||||
</ol>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\FormValidator;
|
||||
use Lib\Input;
|
||||
use Lib\Mailer;
|
||||
use Lib\SpamGuard;
|
||||
|
||||
$errors = [];
|
||||
$old = ['name' => '', 'email' => '', 'message' => ''];
|
||||
$spamGuard = new SpamGuard();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return Response::redirect('/contact?error=security');
|
||||
}
|
||||
|
||||
$old = [
|
||||
'name' => Input::post('name', ''),
|
||||
'email' => Input::post('email', ''),
|
||||
'message' => Input::post('message', ''),
|
||||
];
|
||||
|
||||
$validator = (new FormValidator())
|
||||
->required($old['name'], 'name', 'Name is required.')
|
||||
->email($old['email'], 'email', 'A valid email is required.')
|
||||
->required($old['message'], 'message', 'Message is required.');
|
||||
|
||||
if ($validator->passes()) {
|
||||
// $spamGuard checks the honeypot field + submission timing (see
|
||||
// Lib\SpamGuard and index.twig's hidden fields) — a bot gets the
|
||||
// exact same redirect a human would either way, with nothing in
|
||||
// the response revealing which check it tripped, or that a check
|
||||
// exists at all. Only Mailer::send() is skipped for spam.
|
||||
if (!$spamGuard->isSpam(Input::post())) {
|
||||
(new Mailer())->send($old['name'], $old['email'], $old['message']);
|
||||
}
|
||||
|
||||
return Response::redirect('/contact?sent=1');
|
||||
}
|
||||
|
||||
$errors = $validator->errors();
|
||||
}
|
||||
|
||||
return [
|
||||
'errors' => $errors,
|
||||
'old' => $old,
|
||||
'sent' => Input::get('sent') !== null,
|
||||
'securityError' => Input::get('error') === 'security',
|
||||
'renderedAt' => $spamGuard->renderedAt(),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% block title %}Contact{% endblock %}
|
||||
{% block description %}Get in touch — send a message using the contact form.{% endblock %}
|
||||
|
||||
{# Every block below is optional — the layout already supplies a sensible
|
||||
default for each (see /admin/docs/seo). Shown here uncommented as a
|
||||
reference for every override a page can make; delete what you don't
|
||||
need. #}
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||
|
||||
{% block og_type %}website{% 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 %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.email() }}Contact</h1>
|
||||
|
||||
<p>This form is handled entirely by <code>App/pages/contact/index.php</code>, a sidecar demonstrating the classic POST/redirect/GET pattern: it validates <code>$_POST</code>, calls <code>Lib\Mailer::send()</code> on success, and redirects to <code>/contact?sent=1</code> — so refreshing the page after submitting never resubmits the form. Because this page has a sidecar, it's never served from the static cache; see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> and <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> for the full contract. It's also a working example of self-hosted spam prevention (honeypot field + submission-timing check, no external CAPTCHA service) — see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' "Spam prevention" section.</p>
|
||||
|
||||
{% if sent %}
|
||||
<p><strong>Thanks — your message has been sent.</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if securityError %}
|
||||
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/contact">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
<label for="website">Leave this field blank</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" name="rendered_at" value="{{ renderedAt }}">
|
||||
<p>
|
||||
<label for="name">Name</label><br>
|
||||
<input type="text" id="name" name="name" value="{{ old.name }}">
|
||||
{% if errors.name %}<br><small>{{ errors.name }}</small>{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
<label for="email">Email</label><br>
|
||||
<input type="text" id="email" name="email" value="{{ old.email }}">
|
||||
{% if errors.email %}<br><small>{{ errors.email }}</small>{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
<label for="message">Message</label><br>
|
||||
<textarea id="message" name="message" rows="5">{{ old.message }}</textarea>
|
||||
{% if errors.message %}<br><small>{{ errors.message }}</small>{% endif %}
|
||||
</p>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% block title %}Home{% endblock %}
|
||||
{% block description %}A tiny, Hugo-flavored PHP micro-framework: file-based routing, Twig templates, and static caching.{% endblock %}
|
||||
|
||||
{# Every block below is optional — the layout already supplies a sensible
|
||||
default for each (see /admin/docs/seo). Shown here uncommented as a
|
||||
reference for every override a page can make; delete what you don't
|
||||
need. #}
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||
|
||||
{% block og_type %}website{% 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 %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<span class="hero-eyebrow">novaconium</span>
|
||||
<h1>You're up and running.</h1>
|
||||
<p class="hero-lede">A tiny, Hugo-flavored PHP micro-framework: file-based routing, Twig templates, and static caching — no Composer, no build step. This page lives at <code>App/pages/index.twig</code>; start editing there.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button-link" href="/admin/docs">Read the docs</a>
|
||||
<a class="button-link button-link--ghost" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}View source</a>
|
||||
</div>
|
||||
<ul class="hero-badges">
|
||||
<li class="badge">PHP 8.1+</li>
|
||||
<li class="badge">No Composer</li>
|
||||
<li class="badge">Twig vendored</li>
|
||||
<li class="badge">Static caching</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="feature-grid">
|
||||
<article class="feature-card">
|
||||
<h2>File-based routing</h2>
|
||||
<p>A directory under <code>App/pages/</code> <em>is</em> a route. <code>[param]</code> segments capture into <code>$params</code>. No route table to maintain.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Optional sidecars</h2>
|
||||
<p>Drop an <code>index.php</code> next to any <code>index.twig</code> for real logic, or return a <code>Response</code> to short-circuit templating entirely.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Static caching</h2>
|
||||
<p>Sidecar-less pages render once and get served straight from Apache afterwards — this page included.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>SEO out of the box</h2>
|
||||
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Admin authentication</h2>
|
||||
<p>A multi-user session login gates every <code>/admin/*</code> route, with email verification for new accounts. Off by default in <code>App/config.php</code>.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Access control & drafts</h2>
|
||||
<p>Gate a page to a user or group with one <code>Lib\Access</code> call in its sidecar, or preview an unfinished page as an admin-only draft.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Media manager & comments</h2>
|
||||
<p>An upload/browse/delete UI at <code>/admin/media</code>, and <code>Lib\Comments</code> for a moderated comment thread on any page.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Database, zero setup</h2>
|
||||
<p><code>Lib\Db</code> wraps PDO for SQLite or MySQL, multiple named connections at once, migrating automatically on first use.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Search, sitemap & tags</h2>
|
||||
<p>One content index backs full-text <code>/search</code>, <code>/sitemap.xml</code>, and blog tag browsing — reindexed lazily, no extra steps.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Blog RSS feed</h2>
|
||||
<p><code>/blog/feed</code> is built from the same hand-written post list <code>App/pages/blog/index.php</code> renders from — no database required.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Matomo & dark/light theme</h2>
|
||||
<p>Built-in analytics tracking, off by default, alongside a nav toggle that swaps every color via CSS custom properties.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Form security by default</h2>
|
||||
<p><code>Lib\Csrf</code>, <code>Lib\SpamGuard</code>'s honeypot check, and cleaning input accessors — wired into the contact form and every admin form.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Override anything</h2>
|
||||
<p><code>App/</code> is checked before <code>novaconium/</code> for every page, layout, and <code>Lib\</code> class — override by dropping a file at the same relative path.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="next-steps">
|
||||
<h2>Where to next</h2>
|
||||
<ul>
|
||||
<li><a href="/admin/docs/getting-started">Getting started</a> — requirements, running locally, deploying on Apache.</li>
|
||||
<li><a href="/admin/docs/routing">Routing</a> and <a href="/admin/docs/sidecars">sidecars</a> — how pages and logic fit together.</li>
|
||||
<li><a href="/blog">The blog example</a> — a listing sidecar, individual posts, and a layout override.</li>
|
||||
<li><a href="/admin">Admin</a> — clear the cache and browse these docs live.</li>
|
||||
</ul>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
// This project's color palette — overrides
|
||||
// novaconium/sass/defaults/_colors.sass's framework defaults. Same
|
||||
// mechanism as App/pages/ overriding novaconium/pages/: same relative
|
||||
// filename, checked first on the Sass load path. Change any of these and
|
||||
// recompile (see /admin/docs/styling) to reskin the whole site from one
|
||||
// file. Delete this file entirely to fall back to the framework's
|
||||
// default palette instead.
|
||||
$bg: #14181c
|
||||
$surface: #1b2126
|
||||
$text-color: #e7ebee
|
||||
$muted-color: #98a3ac
|
||||
$border-color: #2a3238
|
||||
$accent: #2dd4bf
|
||||
$accent-hover: #5eead4
|
||||
|
||||
// Light theme, used when a visitor toggles it (see the theme-toggle
|
||||
// button in novaconium/pages/_layout/nav.twig) — same seven names with a
|
||||
// -light suffix, same override mechanism.
|
||||
$bg-light: #ffffff
|
||||
$surface-light: #f1f4f6
|
||||
$text-color-light: #14181c
|
||||
$muted-color-light: #5b6570
|
||||
$border-color-light: #d8dee3
|
||||
$accent-light: #0f9488
|
||||
$accent-hover-light: #0c7a70
|
||||
@@ -1 +1,6 @@
|
||||
# Agent permissions
|
||||
|
||||
- Only run `git` commands with the user's explicit permission for that specific command/action.
|
||||
- Never run `docker` commands (build, compose up, run, etc.) — leave all Docker execution to the user.
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Official PHP + Apache image for running novaconium in production.
|
||||
# See /admin/docs/docker for the bind-mounted paths, docker-entrypoint.sh's
|
||||
# seeding/permissions behavior, and optional MySQL wiring.
|
||||
|
||||
# Build: docker build --no-cache -t novaconium:latest .
|
||||
|
||||
# Fixed: full official image tag (was missing "php:")
|
||||
FROM php:8.5.8-apache-trixie
|
||||
|
||||
# Pin to a specific tag (not a floating "php:apache") so a rebuild months
|
||||
# from now installs the same PHP/Apache/Debian base instead of whatever
|
||||
# happens to be current that day. Bump the tag above deliberately (e.g. to
|
||||
# pick up a PHP security release), not as a side effect of an unrelated
|
||||
# rebuild.
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& docker-php-ext-install pdo_sqlite pdo_mysql \
|
||||
&& a2enmod rewrite
|
||||
|
||||
# Point DocumentRoot at public/ and allow .htaccess overrides there.
|
||||
RUN sed -ri -e 's#/var/www/html#/var/www/html/public#g' \
|
||||
/etc/apache2/sites-available/*.conf \
|
||||
&& sed -ri -e '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' \
|
||||
/etc/apache2/apache2.conf
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Copy application files
|
||||
COPY novaconium/ ./novaconium/
|
||||
COPY public/ ./public/
|
||||
COPY App/ ./App/
|
||||
|
||||
# Pristine copy of the starter App/, kept outside /var/www/html so
|
||||
# docker-entrypoint.sh can reseed a bind-mounted (but empty/missing) App/ on
|
||||
# first start — see docker-entrypoint.sh and /admin/docs/docker.
|
||||
RUN cp -a App/ /opt/novaconium-app-default/
|
||||
|
||||
# Runtime-writable paths — cache/uploads/App/data are bind-mounted from the
|
||||
# host by docker-compose.yml, so docker-entrypoint.sh re-chowns them at
|
||||
# every container start (a build-time chown only survives on the image
|
||||
# layer, not on a host bind mount). This chown still covers a fresh
|
||||
# container with no bind mounts configured at all.
|
||||
RUN mkdir -p public/cache public/uploads data \
|
||||
&& touch novaconium/contact-log.txt \
|
||||
&& chown -R www-data:www-data public/cache public/uploads data App novaconium/contact-log.txt
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
@@ -1,9 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 4lt
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,65 +1,44 @@
|
||||

|
||||

|
||||
|
||||
# Novaconium PHP: A PHP Framework Built from the Past
|
||||
A Hugo-flavored PHP framework. Routes are directories on disk, pages render with [Twig](https://twig.symfony.com/), and any page that needs real logic gets an optional PHP "sidecar" file. Pages without a sidecar are pre-rendered once and served as static HTML straight from Apache afterwards.
|
||||
|
||||
NovaconiumPHP is a high-performance PHP framework designed with inspiration from classic coding principles.
|
||||
For a full tour of what's included — routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more — see the [Novaconium Features](http://127.0.0.1:8000/blog/novaconium-features) post once the site is running, or `/admin/docs` (see Documentation below).
|
||||
|
||||
Pronounced: Noh-vah-koh-nee-um
|
||||
## Getting started
|
||||
|
||||
* Packagist: https://packagist.org/packages/4lt/novaconium
|
||||
* Master Repo: https://git.4lt.ca/4lt/novaconium
|
||||
### Requirements:
|
||||
|
||||
## Getting Started
|
||||
PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`. A few optional features (database, content index/search, admin authentication) need the `pdo_sqlite` extension — see `/admin/docs` for details once running.
|
||||
|
||||
Novaconium is designed to be developed primarily using Docker. Instead of relying on tools installed directly on your system, common development tasks are executed inside containers:
|
||||
### Development
|
||||
|
||||
* Composer runs inside a Docker container rather than on your host machine
|
||||
* Apache / PHP are served from containers instead of your local environment
|
||||
* Sass compilation is also handled within a container
|
||||
Run it locally, no Apache needed:
|
||||
|
||||
As long as you have Docker installed, you can use the full development environment without installing additional dependencies on your system.
|
||||
|
||||
You can [learn more about how novaconium works with composer](https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Composer.md).
|
||||
|
||||
```bash
|
||||
PROJECTNAME=novaproject;
|
||||
mkdir -p $PROJECTNAME/novaconium;
|
||||
cd $PROJECTNAME;
|
||||
|
||||
docker run --rm --interactive --tty --volume ./novaconium/:/app composer:latest require 4lt/novaconium;
|
||||
|
||||
cp -R novaconium/vendor/4lt/novaconium/skeleton/. .;
|
||||
|
||||
# Edit .env
|
||||
# pwgen -cnsB1v 12 # root password
|
||||
# pwgen -cnsB1v 12 # mysql user password (need in both config and env)
|
||||
# pwgen -cnsB1v 64 # framework key (need in config)
|
||||
# Edit novaconium/App/config.php
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
php -S 127.0.0.1:8000 -t public public/router.php
|
||||
```
|
||||
|
||||
Visit `http://127.0.0.1:8000/` — click around the example pages, then open `http://127.0.0.1:8000/admin/docs` for the complete documentation, rendered live from this same instance.
|
||||
|
||||
### Production
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
root@b2c4133264c6:/var/www/html# php novaconium/bin/create-admin-user.php nick c@nickyeoman.com
|
||||
```
|
||||
|
||||
### Webmasters
|
||||
|
||||
- Clone this repo.
|
||||
- docker build: ``` docker build -t novaconium . ```
|
||||
- docker compose up -d
|
||||
|
||||
## Documentation
|
||||
|
||||
* [Novaconiumm Official Repo](https://git.4lt.ca/4lt/novaconium)
|
||||
* [CORXN Apache and PHP Container for Novaconium](https://git.4lt.ca/4lt/CORXN)
|
||||
The full framework documentation lives inside the framework itself, at `/admin/docs` on any running instance — so it travels with the code, no internet connection needed. That's the canonical reference for everything: requirements, running locally, deploying on Apache or Docker, starting a new project, updating the framework, adding a page, routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, access control, draft pages, media manager, styling, and project layout.
|
||||
|
||||
`AGENTS.md` is the short, agent-facing version for coding assistants working in this repo, and `novaconium/ISSUES.md` is the roadmap/backlog.
|
||||
|
||||
### How it works
|
||||
## Third-party
|
||||
|
||||
#### htaccess
|
||||
|
||||
htaccess ensures that all requests are sent to index.php
|
||||
|
||||
#### index.php
|
||||
|
||||
index.php does two things:
|
||||
1. Allows you to turn on error reporting (off by default)
|
||||
2. Loads the novaconium bootstrap file novaconium.php
|
||||
|
||||
#### novaconium.php
|
||||
|
||||
What happens here:
|
||||
|
||||
1. Autoload composer
|
||||
1. Loads configurations
|
||||
[Twig](https://twig.symfony.com/) is vendored in source form under `novaconium/vendor/twig/` (no Composer — see `/admin/docs/upgrading-twig` for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at `novaconium/vendor/twig/LICENSE`.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "4lt/novaconium",
|
||||
"description": "A high-performance PHP framework built from the past.",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nick Yeoman",
|
||||
"email": "dev@4lt.ca",
|
||||
"homepage": "https://www.4lt.ca"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Novaconium\\": "src/"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"twig/twig": "*",
|
||||
"nickyeoman/php-validation-class": "^5.0"
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"extra": {
|
||||
"versioning": {
|
||||
"strategy": "semantic-versioning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
$framework_routes = [
|
||||
'/novaconium' => [
|
||||
'get' => 'NOVACONIUM/init'
|
||||
],
|
||||
'/novaconium/create_admin' => [
|
||||
'post' => 'NOVACONIUM/create_admin'
|
||||
],
|
||||
'/novaconium/login' => [
|
||||
'post' => 'NOVACONIUM/authenticate',
|
||||
'get' => 'NOVACONIUM/auth/login'
|
||||
],
|
||||
'/novaconium/dashboard' => [
|
||||
'get' => 'NOVACONIUM/dashboard'
|
||||
],
|
||||
'/novaconium/settings' => [
|
||||
'get' => 'NOVACONIUM/settings'
|
||||
],
|
||||
'/novaconium/pages' => [
|
||||
'get' => 'NOVACONIUM/pages'
|
||||
],
|
||||
'/novaconium/page/edit/{id}' => [
|
||||
'get' => 'NOVACONIUM/editpage'
|
||||
],
|
||||
'/novaconium/page/create' => [
|
||||
'get' => 'NOVACONIUM/editpage'
|
||||
],
|
||||
'/novaconium/savePage' => [
|
||||
'post' => 'NOVACONIUM/savepage'
|
||||
],
|
||||
'/novaconium/messages' => [
|
||||
'get' => 'NOVACONIUM/messages'
|
||||
],
|
||||
'/novaconium/messages/delete/{id}' => [
|
||||
'get' => 'NOVACONIUM/message_delete'
|
||||
],
|
||||
'/novaconium/messages/edit/{id}' => [
|
||||
'get' => 'NOVACONIUM/message_edit'
|
||||
],
|
||||
'/novaconium/message_save' => [
|
||||
'post' => 'NOVACONIUM/message_save'
|
||||
],
|
||||
'/novaconium/logout' => [
|
||||
'post' => 'NOVACONIUM/auth/logout',
|
||||
'get' => 'NOVACONIUM/auth/logout'
|
||||
],
|
||||
'/novaconium/sitemap.xml' => [
|
||||
'get' => 'NOVACONIUM/sitemap'
|
||||
],
|
||||
'/novaconium/sample/{slug}' => [
|
||||
'get' => 'NOVACONIUM/samples'
|
||||
],
|
||||
];
|
||||
@@ -1,4 +0,0 @@
|
||||
<?php
|
||||
http_response_code('404');
|
||||
header("Content-Type: text/html");
|
||||
view('@novacore/404');
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
$data = array_merge($data, [
|
||||
'title' => 'Novaconium Login Page',
|
||||
'pageclass' => 'novaconium'
|
||||
]);
|
||||
// Don't come here if logged in
|
||||
if ($session->get('username')) {
|
||||
$redirect->url('/novaconium/dashboard');
|
||||
makeitso();
|
||||
}
|
||||
view('@novacore/auth/login');
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
$session->kill();
|
||||
$log->info("Logout - Logout Success - " . $_SERVER['REMOTE_ADDR']);
|
||||
$redirect->url('/');
|
||||
makeitso();
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Nickyeoman\Validation;
|
||||
$v = new Nickyeoman\Validation\Validate();
|
||||
|
||||
$url_success = '/novaconium/dashboard';
|
||||
$url_fail = '/novaconium/login';
|
||||
|
||||
|
||||
// Don't go further if already logged in
|
||||
if ( !empty($session->get('username')) ) {
|
||||
$redirect->url($url_success);
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Make sure Session Token is correct
|
||||
if ($session->get('token') != $post->get('token')) {
|
||||
$messages->addMessage('error', "Invalid Session.");
|
||||
$log->error("Login Authentication - Invalid Session Token");
|
||||
}
|
||||
|
||||
// Handle Username
|
||||
$rawUsername = $post->get('username', null);
|
||||
$cleanUsername = $v->clean($rawUsername); // Clean the input
|
||||
$username = strtolower($cleanUsername); // Convert to lowercase
|
||||
if (!$username) {
|
||||
$messages->addMessage('error', "No Username given.");
|
||||
}
|
||||
|
||||
// Handle Password
|
||||
$password = $v->clean($post->get('password', null));
|
||||
if ( empty($password) ) {
|
||||
$messages->addMessage('error', "Password Empty.");
|
||||
}
|
||||
|
||||
/*************************************************************************************************************
|
||||
* Query Database
|
||||
************************************************************************************************************/
|
||||
|
||||
if ($messages->count('error') === 0) {
|
||||
$query = "SELECT id, username, email, password, blocked FROM users WHERE username = ? OR email = ?";
|
||||
$matched = $db->getRow($query, [$username, $username]);
|
||||
if (empty($matched)) {
|
||||
$messages->addMessage('error', "User or Password incorrect.");
|
||||
$log->warning("Login Authentication - Login Error, user doesn't exist");
|
||||
}
|
||||
}
|
||||
|
||||
if ($messages->count('error') === 0) {
|
||||
// Re-apply pepper
|
||||
$peppered = hash_hmac('sha3-512', $password, $config['secure_key']);
|
||||
|
||||
// Verify hashed password
|
||||
if (!password_verify($peppered, $matched['password'])) {
|
||||
$messages->addMessage('error', "User or Password incorrect.");
|
||||
$log->warning("Login Authentication - Login Error, password wrong");
|
||||
}
|
||||
}
|
||||
|
||||
// Process Login or Redirect
|
||||
if ($messages->count('error') === 0) {
|
||||
$query = "SELECT groupName FROM user_groups WHERE user_id = ?";
|
||||
$groups = $db->getRow($query, [$matched['id']]);
|
||||
$session->set('username', $cleanUsername);
|
||||
$session->set('group', $groups['groupName']);
|
||||
$redirect->url($url_success);
|
||||
$log->info("Login Authentication - Login Success");
|
||||
} else {
|
||||
$redirect->url($url_fail);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
$data = array_merge($data, [
|
||||
'title' => 'Coming Soon',
|
||||
'heading' => 'Coming Soon',
|
||||
'countdown' => true,
|
||||
'launch_date' => '2026-01-01T00:00:00'
|
||||
]);
|
||||
|
||||
view('@novacore/coming-soon', $data);
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
// Create an admin user (POST)
|
||||
|
||||
use Nickyeoman\Validation;
|
||||
|
||||
$validate = new Validation\Validate();
|
||||
$valid = true;
|
||||
$p = $post->all();
|
||||
|
||||
// Check secure key
|
||||
if (empty($p['secure_key']) || $p['secure_key'] !== $config['secure_key']) {
|
||||
$valid = false;
|
||||
}
|
||||
|
||||
// Username
|
||||
$name = $validate->clean($p['username']);
|
||||
if (!$validate->minLength($name, 1)) {
|
||||
$valid = false;
|
||||
}
|
||||
|
||||
// Email
|
||||
if (empty($p['email'])) {
|
||||
$valid = false;
|
||||
} elseif (!$validate->isEmail($p['email'])) {
|
||||
$valid = false;
|
||||
}
|
||||
|
||||
// Password
|
||||
if (empty($p['password'])) {
|
||||
$valid = false;
|
||||
} else {
|
||||
// Use pepper + Argon2id
|
||||
$peppered = hash_hmac('sha3-512', $p['password'], $config['secure_key']);
|
||||
$hashed_password = password_hash($peppered, PASSWORD_ARGON2ID);
|
||||
}
|
||||
|
||||
if ($valid) {
|
||||
// Insert user
|
||||
$query = <<<EOSQL
|
||||
INSERT INTO `users`
|
||||
(`username`, `password`, `email`, `validate`, `confirmationToken`, `reset`, `created`, `updated`, `confirmed`, `blocked`)
|
||||
VALUES
|
||||
(?, ?, ?, NULL, NULL, NULL, NOW(), NOW(), 1, 0);
|
||||
EOSQL;
|
||||
|
||||
$params = [$name, $hashed_password, $p['email']];
|
||||
$db->query($query, $params);
|
||||
$userid = $db->lastid();
|
||||
|
||||
// Assign admin group
|
||||
$groupInsertQuery = <<<EOSQL
|
||||
INSERT INTO `user_groups` (`user_id`, `groupName`) VALUES (?, ?);
|
||||
EOSQL;
|
||||
|
||||
$db->query($groupInsertQuery, [$userid, 'admin']);
|
||||
}
|
||||
|
||||
// Always redirect at end
|
||||
$redirect->url('/novaconium');
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
$data = array_merge($data, [
|
||||
'title' => 'Novaconium Dashboard Page',
|
||||
'pageclass' => 'novaconium',
|
||||
'pageid' => 'controlPanel'
|
||||
]);
|
||||
|
||||
if ( empty($session->get('username'))) {
|
||||
$redirect->url('/novaconium/login');
|
||||
$messages->error('You are not loggedin');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
view('@novacore/dashboard', $data);
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
$data = array_merge($data, [
|
||||
'title' => 'Novaconium Edit Page',
|
||||
'pageclass' => 'novaconium',
|
||||
'pageid' => 'controlPanel',
|
||||
'editor' => 'ace'
|
||||
]);
|
||||
|
||||
// Check if logged in
|
||||
if (empty($session->get('username'))) {
|
||||
$messages->error('You are not logged in');
|
||||
$redirect->url('/novaconium/login');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Get page ID from router parameters
|
||||
$pageid = $router->parameters['id'] ?? null;
|
||||
|
||||
if (!empty($pageid)) {
|
||||
// Existing page: fetch from database
|
||||
$query = <<<EOSQL
|
||||
WITH all_tags AS (
|
||||
SELECT GROUP_CONCAT(DISTINCT name ORDER BY name SEPARATOR ',') AS tags_list
|
||||
FROM tags
|
||||
)
|
||||
SELECT
|
||||
p.id,
|
||||
p.title,
|
||||
p.heading,
|
||||
p.description,
|
||||
p.keywords,
|
||||
p.author,
|
||||
p.slug,
|
||||
p.path,
|
||||
p.intro,
|
||||
p.body,
|
||||
p.notes,
|
||||
p.draft,
|
||||
p.changefreq,
|
||||
p.priority,
|
||||
p.created,
|
||||
p.updated,
|
||||
COALESCE(GROUP_CONCAT(DISTINCT t.name ORDER BY t.name SEPARATOR ','), '') AS page_tags,
|
||||
at.tags_list AS existing_tags
|
||||
FROM pages p
|
||||
LEFT JOIN page_tags pt ON p.id = pt.page_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
CROSS JOIN all_tags at -- Zero-cost join for scalar
|
||||
WHERE p.id = ?
|
||||
GROUP BY p.id;
|
||||
EOSQL;
|
||||
|
||||
$data['rows'] = $db->getRow($query, [$pageid]);
|
||||
|
||||
// If no row is found, treat as new page
|
||||
if (!$data['rows']) {
|
||||
$pageid = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($pageid)) {
|
||||
// New page: set default values for all fields
|
||||
$data['rows'] = [
|
||||
'id' => 'newpage',
|
||||
'title' => '',
|
||||
'heading' => '',
|
||||
'description' => '',
|
||||
'keywords' => '',
|
||||
'author' => $session->get('username') ?? '',
|
||||
'slug' => '',
|
||||
'path' => '',
|
||||
'intro' => '',
|
||||
'body' => '',
|
||||
'notes' => '',
|
||||
'draft' => 0,
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => 0.0,
|
||||
'created' => date('Y-m-d H:i:s'),
|
||||
'updated' => date('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
|
||||
// Render the edit page view
|
||||
view('@novacore/editpage/index', $data);
|
||||
@@ -1,202 +0,0 @@
|
||||
<?php
|
||||
|
||||
$data = [
|
||||
'secure_key' => false,
|
||||
'gen_key' => NULL,
|
||||
'users_created' => false,
|
||||
'empty_users' => false,
|
||||
'show_login' => false,
|
||||
'token' => $session->get('token'),
|
||||
'pageclass' => 'novaconium',
|
||||
'title' => 'Novaconium Admin'
|
||||
];
|
||||
|
||||
// Check if SECURE KEY is Set in
|
||||
if ($config['secure_key'] !== null && strlen($config['secure_key']) === 64) {
|
||||
$data['secure_key'] = true;
|
||||
} else {
|
||||
$data['gen_key'] = substr(bin2hex(random_bytes(32)), 0, 64);
|
||||
$log->warn('secure_key not detected');
|
||||
}
|
||||
|
||||
// Check if user table exists
|
||||
$query = <<<EOSQL
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND TABLE_NAME = 'users';
|
||||
EOSQL;
|
||||
$result = $db->query($query);
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$query = <<<EOSQL
|
||||
CREATE TABLE `users` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(30) NOT NULL,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`validate` varchar(32) DEFAULT NULL,
|
||||
`confirmationToken` varchar(255) DEFAULT NULL,
|
||||
`reset` varchar(32) DEFAULT NULL,
|
||||
`created` datetime NOT NULL,
|
||||
`updated` datetime DEFAULT NULL,
|
||||
`confirmed` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`blocked` tinyint(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
|
||||
EOSQL;
|
||||
|
||||
$db->query($query);
|
||||
$data['users_created'] = true;
|
||||
$log->info('Users Table Created');
|
||||
|
||||
}
|
||||
|
||||
// Check Usergroup
|
||||
$query = <<<EOSQL
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND TABLE_NAME = 'user_groups';
|
||||
EOSQL;
|
||||
$result = $db->query($query);
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$query = <<<EOSQL
|
||||
CREATE TABLE `user_groups` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT(11) UNSIGNED NOT NULL,
|
||||
`groupName` VARCHAR(40) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
EOSQL;
|
||||
|
||||
|
||||
$db->query($query);
|
||||
$log->info('User_groups Table Created');
|
||||
|
||||
}
|
||||
|
||||
// Check Pages Table
|
||||
$query = <<<EOSQL
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND TABLE_NAME = 'pages';
|
||||
EOSQL;
|
||||
$result = $db->query($query);
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$query = <<<EOSQL
|
||||
CREATE TABLE `pages` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`heading` varchar(255) NOT NULL,
|
||||
`description` varchar(255) NOT NULL,
|
||||
`keywords` varchar(255) NOT NULL,
|
||||
`author` varchar(255) NOT NULL,
|
||||
`slug` varchar(255) NOT NULL,
|
||||
`path` varchar(255) DEFAULT NULL,
|
||||
`intro` text DEFAULT NULL,
|
||||
`body` text DEFAULT NULL,
|
||||
`notes` text DEFAULT NULL,
|
||||
`created` datetime NOT NULL,
|
||||
`updated` datetime DEFAULT NULL,
|
||||
`draft` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`changefreq` varchar(7) NOT NULL DEFAULT 'monthly',
|
||||
`priority` float(4,1) NOT NULL DEFAULT 0.0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
|
||||
EOSQL;
|
||||
|
||||
$db->query($query);
|
||||
$log->info('Pages Table Created');
|
||||
}
|
||||
|
||||
// Check ContactForm Table
|
||||
$query = <<<EOSQL
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND TABLE_NAME = 'contactForm';
|
||||
EOSQL;
|
||||
$result = $db->query($query);
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$query = <<<EOSQL
|
||||
CREATE TABLE `contactForm` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`message` text DEFAULT NULL,
|
||||
`created` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`unread` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Unread is true by default',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
|
||||
EOSQL;
|
||||
|
||||
$db->query($query);
|
||||
$log->info('ContactForm Table Created');
|
||||
}
|
||||
|
||||
// Check if a user exists
|
||||
$result = $db->query("SELECT COUNT(*) as total FROM users");
|
||||
$row = $result->fetch_assoc();
|
||||
|
||||
if ($row['total'] < 1) {
|
||||
$data['empty_users'] = true;
|
||||
} else {
|
||||
$log->info('Init Run complete, all sql tables exist with a user.');
|
||||
// Everything is working, send them to login page
|
||||
$redirect->url('/novaconium/login');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Check Tags Table
|
||||
$query = <<<EOSQL
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND TABLE_NAME = 'tags';
|
||||
EOSQL;
|
||||
$result = $db->query($query);
|
||||
if ($result->num_rows === 0) {
|
||||
$query = <<<EOSQL
|
||||
CREATE TABLE IF NOT EXISTS `tags` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) NOT NULL UNIQUE,
|
||||
`created` datetime NOT NULL,
|
||||
`updated` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
|
||||
EOSQL;
|
||||
$db->query($query);
|
||||
$log->info('Tags Table Created');
|
||||
}
|
||||
|
||||
// Check Page Tags Junction Table (after tags table)
|
||||
$query = <<<EOSQL
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND TABLE_NAME = 'page_tags';
|
||||
EOSQL;
|
||||
$result = $db->query($query);
|
||||
if ($result->num_rows === 0) {
|
||||
$query = <<<EOSQL
|
||||
CREATE TABLE IF NOT EXISTS `page_tags` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`page_id` int(11) NOT NULL,
|
||||
`tag_id` int(11) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `page_id` (`page_id`),
|
||||
KEY `tag_id` (`tag_id`),
|
||||
FOREIGN KEY (`page_id`) REFERENCES `pages` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
|
||||
EOSQL;
|
||||
$db->query($query);
|
||||
$log->info('Page Tags Junction Table Created');
|
||||
}
|
||||
|
||||
view('@novacore/init', $data);
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
if ( empty($session->get('username'))) {
|
||||
$redirect->url('/novaconium/login');
|
||||
$messages->error('You are not loggedin');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
$messageid = $router->parameters['id'];
|
||||
$query="DELETE FROM contactForm WHERE `contactForm`.`id` = ?";
|
||||
$db->query($query, [$messageid]);
|
||||
|
||||
$redirect->url('/novaconium/messages');
|
||||
$messages->notice("Removed Message $messageid");
|
||||
makeitso();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
$data = array_merge($data, [
|
||||
'title' => 'Novaconium Message Page',
|
||||
'pageclass' => 'novaconium'
|
||||
]);
|
||||
|
||||
if ( empty($session->get('username'))) {
|
||||
$redirect->url('/novaconium/login');
|
||||
$messages->error('You are not loggedin');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
$messageid = $router->parameters['id'];
|
||||
$query = "SELECT id, name, email, message, created, unread FROM contactForm WHERE id = '$messageid'";
|
||||
|
||||
$data['themessage'] = $db->getRow($query);
|
||||
|
||||
view('@novacore/editmessage', $data);
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Nickyeoman\Validation;
|
||||
|
||||
$v = new Nickyeoman\Validation\Validate();
|
||||
|
||||
$url_success = '/novaconium/messages';
|
||||
$url_error = '/novaconium/messages/edit/' . $post->get('id'); // Redirect back to the message edit form on error
|
||||
|
||||
// Check if logged in
|
||||
if (empty($session->get('username'))) {
|
||||
$messages->error('You are not logged in');
|
||||
$redirect->url('/novaconium/login');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Check CSRF token
|
||||
if ($session->get('token') != $post->get('token')) {
|
||||
$messages->error('Invalid token');
|
||||
$redirect->url($url_success);
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Get POST data
|
||||
$id = $post->get('id');
|
||||
$name = $post->get('name');
|
||||
$email = $post->get('email');
|
||||
$message = $post->get('message');
|
||||
$unread = !empty($post->get('unread')) ? 1 : 0;
|
||||
|
||||
// Validate required fields
|
||||
if (empty($id) || empty($message) || empty($email)) {
|
||||
$messages->error('One of the required fields was empty.');
|
||||
$redirect->url($url_error);
|
||||
makeitso();
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare update query
|
||||
$query = "UPDATE `contactForm`
|
||||
SET `name` = ?, `email` = ?, `message` = ?, `unread` = ?
|
||||
WHERE `id` = ?";
|
||||
|
||||
$params = [$name, $email, $message, $unread, $id];
|
||||
|
||||
$db->query($query, $params);
|
||||
|
||||
$messages->notice('Message updated successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
$messages->error('Error updating message: ' . $e->getMessage());
|
||||
$redirect->url($url_error);
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Redirect to success page
|
||||
$redirect->url($url_success);
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
$data = array_merge($data, [
|
||||
'title' => 'Novaconium Messages',
|
||||
'pageclass' => 'novaconium',
|
||||
'pageid' => 'controlPanel'
|
||||
]);
|
||||
|
||||
if ( empty($session->get('username'))) {
|
||||
$redirect->url('/novaconium/login');
|
||||
$messages->error('You are not loggedin');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Get the pages
|
||||
$query = "SELECT id, name, email, LEFT(message, 40) AS message, created, unread FROM contactForm";
|
||||
|
||||
$matched = $db->getRows($query);
|
||||
|
||||
$data['messages'] = $matched;
|
||||
|
||||
view('@novacore/messages', $data);
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
$data = array_merge($data, [
|
||||
'title' => 'Novaconium Pages',
|
||||
'pageclass' => 'novaconium',
|
||||
'pageid' => 'controlPanel'
|
||||
]);
|
||||
|
||||
if ( empty($session->get('username'))) {
|
||||
$redirect->url('/novaconium/login');
|
||||
$messages->error('You are not loggedin');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Get the pages
|
||||
$query = "SELECT id, title, created, updated, draft FROM pages";
|
||||
$matched = $db->getRows($query);
|
||||
|
||||
$data['pages'] = $matched;
|
||||
|
||||
view('@novacore/pages', $data);
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Pure Twig, no db example
|
||||
*
|
||||
* Replicate Hugo but with html and twig (not markdown)
|
||||
**/
|
||||
|
||||
// Variables
|
||||
$pt = '@novacore/samples'; //Define the view directory
|
||||
//$pt = 'samples'; //drop the core for your project
|
||||
|
||||
//Grab the slug
|
||||
$slug = $router->parameters['slug'];
|
||||
|
||||
//build path
|
||||
$tmpl = $pt . '/' . $slug;
|
||||
|
||||
//Check if file exits
|
||||
$baseDir = (strpos($pt, 'novacore') !== false) ? FRAMEWORKPATH : BASEPATH;
|
||||
if (strpos($pt, '@novacore') !== false) {
|
||||
$baseDir = str_replace('@novacore', FRAMEWORKPATH . '/views', $pt);
|
||||
} else {
|
||||
$baseDir = str_replace('@novacore', BASEPATH . '/views', $pt);
|
||||
}
|
||||
|
||||
$possibleFile = $baseDir . '/' . $slug . '.html.twig'; // add .twig extension if needed
|
||||
|
||||
if (is_file($possibleFile) && is_readable($possibleFile)) {
|
||||
view($tmpl, $data);
|
||||
} else {
|
||||
http_response_code('404');
|
||||
header("Content-Type: text/html");
|
||||
view('@novacore/404');
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Nickyeoman\Validation;
|
||||
use Novaconium\Services\TagManager;
|
||||
|
||||
$v = new Nickyeoman\Validation\Validate();
|
||||
|
||||
$url_error = '/novaconium/page/edit/' . $post->get('id'); // fallback for errors
|
||||
|
||||
// -------------------------
|
||||
// Check login
|
||||
// -------------------------
|
||||
if (empty($session->get('username'))) {
|
||||
$messages->error('You are not logged in');
|
||||
$redirect->url('/novaconium/login');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Check CSRF token
|
||||
// -------------------------
|
||||
if ($session->get('token') != $post->get('token')) {
|
||||
$messages->error('Invalid Token');
|
||||
$redirect->url('/novaconium/pages');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Gather POST data
|
||||
// -------------------------
|
||||
$id = $post->get('id');
|
||||
$title = $_POST['title'] ?? '';
|
||||
$heading = $_POST['heading'] ?? '';
|
||||
$description = $_POST['description'] ?? '';
|
||||
$keywords = $_POST['keywords'] ?? '';
|
||||
$author = $_POST['author'] ?? '';
|
||||
$slug = $_POST['slug'] ?? '';
|
||||
$path = $_POST['path'] ?? null;
|
||||
$intro = $_POST['intro'] ?? '';
|
||||
$body = $_POST['body'] ?? '';
|
||||
$notes = $_POST['notes'] ?? '';
|
||||
$draft = !empty($post->get('draft')) ? 1 : 0;
|
||||
$changefreq = $_POST['changefreq'] ?? 'monthly';
|
||||
$priority = $_POST['priority'] ?? 0.0;
|
||||
$tags_json = $_POST['tags_json'] ?? '[]';
|
||||
|
||||
// -------------------------
|
||||
// Decode & sanitize tags
|
||||
// -------------------------
|
||||
$tags = json_decode($tags_json, true);
|
||||
if (!is_array($tags)) $tags = [];
|
||||
$tags = array_map('trim', $tags);
|
||||
$tags = array_filter($tags, fn($t) => $t !== '');
|
||||
$tags = array_unique($tags);
|
||||
|
||||
// -------------------------
|
||||
// Validate required fields
|
||||
// -------------------------
|
||||
if (empty($title) || empty($slug) || empty($body)) {
|
||||
$messages->error('Title, Slug, and Body are required.');
|
||||
$redirect->url($url_error);
|
||||
makeitso();
|
||||
}
|
||||
|
||||
try {
|
||||
$tagManager = new TagManager();
|
||||
|
||||
if ($id == 'newpage') {
|
||||
// -------------------------
|
||||
// Create new page
|
||||
// -------------------------
|
||||
$query = "INSERT INTO `pages`
|
||||
(`title`, `heading`, `description`, `keywords`, `author`,
|
||||
`slug`, `path`, `intro`, `body`, `notes`,
|
||||
`draft`, `changefreq`, `priority`, `created`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())";
|
||||
$params = [
|
||||
$title, $heading, $description, $keywords, $author,
|
||||
$slug, $path, $intro, $body, $notes,
|
||||
$draft, $changefreq, $priority
|
||||
];
|
||||
$db->query($query, $params);
|
||||
$id = $db->lastid;
|
||||
$messages->notice('Page Created');
|
||||
|
||||
} else {
|
||||
// -------------------------
|
||||
// Update existing page
|
||||
// -------------------------
|
||||
$query = "UPDATE `pages` SET
|
||||
`title` = ?, `heading` = ?, `description` = ?, `keywords` = ?, `author` = ?,
|
||||
`slug` = ?, `path` = ?, `intro` = ?, `body` = ?, `notes` = ?,
|
||||
`draft` = ?, `changefreq` = ?, `priority` = ?, `updated` = NOW()
|
||||
WHERE `id` = ?";
|
||||
$params = [
|
||||
$title, $heading, $description, $keywords, $author,
|
||||
$slug, $path, $intro, $body, $notes,
|
||||
$draft, $changefreq, $priority, $id
|
||||
];
|
||||
$db->query($query, $params);
|
||||
$messages->notice('Page Updated');
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Save tags (for both new and existing pages)
|
||||
// -------------------------
|
||||
$tagManager->setTagsForPage($id, $tags);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$messages->error($e->getMessage());
|
||||
$redirect->url($url_error);
|
||||
makeitso();
|
||||
}
|
||||
|
||||
// Redirect back to edit page
|
||||
$redirect->url('/novaconium/page/edit/' . $id);
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
$data = array_merge($data, [
|
||||
'title' => 'Novaconium Settings',
|
||||
'pageclass' => 'novaconium',
|
||||
'pageid' => 'controlPanel'
|
||||
]);
|
||||
|
||||
if ( empty($session->get('username'))) {
|
||||
$redirect->url('/novaconium/login');
|
||||
$messages->error('You are not loggedin');
|
||||
makeitso();
|
||||
}
|
||||
|
||||
view('@novacore/settings', $data);
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
header('Content-Type: text/xml');
|
||||
// https://www.sitemaps.org/protocol.html
|
||||
// Check it here: https://www.mysitemapgenerator.com/service/check.html
|
||||
|
||||
$query=<<<EOSQL
|
||||
SELECT draft, slug, updated, changefreq, priority, path
|
||||
FROM pages
|
||||
WHERE priority > 0
|
||||
AND draft = 0
|
||||
ORDER BY updated DESC;
|
||||
EOSQL;
|
||||
$thepages = $db->getRows($query);
|
||||
|
||||
// Start the view
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?>';
|
||||
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
|
||||
|
||||
// Loop through the pages
|
||||
if ( ! empty($thepages) ) {
|
||||
foreach( $thepages as $v) {
|
||||
|
||||
$date = (new \DateTime($v['updated']))->format('Y-m-d');
|
||||
|
||||
echo "<url>";
|
||||
|
||||
if ( empty($v['path']) )
|
||||
echo "<loc>" . $config['base_url'] . '/page/' . $v['slug'] . "</loc>";
|
||||
else
|
||||
echo "<loc>" . $config['base_url'] . $v['path'] . "</loc>";
|
||||
|
||||
echo "<lastmod>" . $date . "</lastmod>";
|
||||
echo "<changefreq>" . $v['changefreq'] . "</changefreq>";
|
||||
echo "<priority>" . sprintf("%.1f", $v['priority']) . "</priority>";
|
||||
echo "</url>";
|
||||
|
||||
}
|
||||
} else {
|
||||
echo "no pages added yet";
|
||||
}
|
||||
|
||||
echo "</urlset>";
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
web:
|
||||
image: novaconium:latest
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ${VOL_PATH:-/data}/novaconium/cache:/var/www/html/public/cache
|
||||
- ${VOL_PATH:-/data}/novaconium/uploads:/var/www/html/public/uploads
|
||||
- ${VOL_PATH:-/data}/novaconium/App:/var/www/html/App
|
||||
- ${VOL_PATH:-/data}/novaconium/data:/var/www/html/data
|
||||
|
||||
# Optional — only needed if App/config.php adds a db_connections entry
|
||||
# with driver: mysql. See /admin/docs/database.
|
||||
# db:
|
||||
# image: mysql:8
|
||||
# environment:
|
||||
# MYSQL_DATABASE: novaconium
|
||||
# MYSQL_USER: novaconium
|
||||
# MYSQL_PASSWORD: change-me
|
||||
# MYSQL_ROOT_PASSWORD: change-me
|
||||
# volumes:
|
||||
# - mysql-data:/var/lib/mysql
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# Runs once per container start, before Apache — see /admin/docs/docker.
|
||||
#
|
||||
# docker-compose.yml bind-mounts App/, public/cache/, public/uploads/, and
|
||||
# data/ from the host so a project's content/db survive a rebuild and can be
|
||||
# edited without one. Two problems a plain COPY-at-build-time image can't
|
||||
# solve on its own:
|
||||
#
|
||||
# 1. A bind mount to an empty (or not-yet-created) host directory shadows
|
||||
# whatever COPY baked into that path in the image, replacing it with
|
||||
# nothing — Docker does not seed bind mounts from image content the way
|
||||
# it seeds a fresh named volume. App/ is only ever the docs/starter
|
||||
# content wanted on host: seed it from the pristine copy stashed
|
||||
# at build time (/opt/novaconium-app-default) if the mounted dir is
|
||||
# empty, so `docker compose up` produces a working site on a first run
|
||||
# with no manual copy step.
|
||||
# 2. A bind-mounted host directory keeps the host's ownership, not the
|
||||
# image's — the build-time `chown` in the Dockerfile never applies to
|
||||
# it. Re-chown the mounted paths to the Apache worker user on every
|
||||
# start so they're writable regardless of the host-side UID/GID.
|
||||
set -e
|
||||
|
||||
if [ -z "$(ls -A /var/www/html/App 2>/dev/null)" ]; then
|
||||
cp -a /opt/novaconium-app-default/. /var/www/html/App/
|
||||
fi
|
||||
|
||||
chown -R www-data:www-data /var/www/html/public/cache /var/www/html/public/uploads /var/www/html/App /var/www/html/data
|
||||
|
||||
exec "$@"
|
||||
@@ -1,6 +0,0 @@
|
||||
# 404 Page
|
||||
|
||||
404 page is created like any other page.
|
||||
Create a 404.php in your controllers and a 404.html.twig in your views.
|
||||
anytime a resource is not found by the router, it will default to this controller.
|
||||
if you do not have this controller in your app, it will default to the novaconium 404 page.
|
||||
@@ -1,18 +0,0 @@
|
||||
# PHP Composer Cheatsheet
|
||||
|
||||
Install novaconium with composer: ```composer require 4lt/novaconium```
|
||||
|
||||
Install novaconium with composer in docker: ```docker run --rm --interactive --tty --volume $PWD:/app composer:latest require 4lt/novaconium```
|
||||
|
||||
Update novaconium with composer in docker: ```docker run --rm --interactive --tty --volume $PWD:/app composer:latest update```
|
||||
|
||||
## Install Composer natively on Debian
|
||||
|
||||
Assuming you have nala installed (otherwise use apt-get):
|
||||
|
||||
```bash
|
||||
sudo nala install curl php-cli php-mbstring git unzip
|
||||
curl -sS https://getcomposer.org/installer -o composer-setup.php
|
||||
sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
|
||||
rm composer-setup.php
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
# Configuration File
|
||||
|
||||
## App/config.php
|
||||
|
||||
The configuration file holds a php multi dimentional array for configuration.
|
||||
|
||||
#### database
|
||||
|
||||
This is the connection setting for mariadb.
|
||||
|
||||
```
|
||||
'database' => [
|
||||
'host' => 'ny-db',
|
||||
'name' => 'nydb',
|
||||
'user' => 'nydbu',
|
||||
'pass' => 'as7!d5fLKJ2DLKJS5',
|
||||
'port' => 3306
|
||||
],
|
||||
```
|
||||
|
||||
#### base_url
|
||||
|
||||
Defines the url to use
|
||||
```
|
||||
'base_url' => 'https://www.nickyeoman.com',
|
||||
```
|
||||
|
||||
#### secure_key
|
||||
|
||||
The security key is used to verify admin account and salt encrpytion functions.
|
||||
You can generate a key with ```pwgen -cnsB1v 64```
|
||||
but if you don't set one, novaconium will generate one for you to use (you have to explicily set it though).
|
||||
|
||||
```
|
||||
'secure_key' => '',
|
||||
```
|
||||
|
||||
#### logfile
|
||||
|
||||
sets the path of the log file.
|
||||
|
||||
```
|
||||
'logfile' => '/logs/novaconium.log',
|
||||
```
|
||||
|
||||
#### loglevel
|
||||
|
||||
Sets the logging level for the app.
|
||||
|
||||
```
|
||||
'loglevel' => 'ERROR' // 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'NONE'
|
||||
```
|
||||
@@ -1,18 +0,0 @@
|
||||
# Fake autoload for dev
|
||||
|
||||
put this in index.php
|
||||
|
||||
```
|
||||
// --- Dev-only autoloader for manually cloned vendor copy ---
|
||||
spl_autoload_register(function ($class) {
|
||||
if (str_starts_with($class, 'Novaconium\\')) {
|
||||
$baseDir = BASEPATH . '/vendor/4lt/novaconium/src/';
|
||||
$relativeClass = substr($class, strlen('Novaconium\\'));
|
||||
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
@@ -1,19 +0,0 @@
|
||||
# Logging
|
||||
|
||||
You can use the logging class to output to a file.
|
||||
|
||||
|
||||
use ```$log->info(The Message');```
|
||||
|
||||
Logging levels are:
|
||||
```
|
||||
'DEBUG' => 0,
|
||||
'INFO' => 1,
|
||||
'WARNING' => 2,
|
||||
'ERROR' => 3,
|
||||
];
|
||||
```
|
||||
|
||||
It's recommended that production is set to ERROR.
|
||||
You set the log level in /App/config.php under 'loglevel' => 'ERROR'
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Messages
|
||||
|
||||
Messages is $messages.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Post
|
||||
|
||||
There is a post class.
|
||||
It cleans the post.
|
||||
You can access it with $post.
|
||||
@@ -1,6 +0,0 @@
|
||||
# Redirect
|
||||
|
||||
How to use redirect class.
|
||||
|
||||
$redirect->url;
|
||||
it's called on every page, if you set it more than once the last one is used.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Sass
|
||||
|
||||
## Docker
|
||||
|
||||
There is a dockerfile in the sass directory you can build an image with.
|
||||
|
||||
```bash
|
||||
cd sass
|
||||
docker build -t sass-container .
|
||||
```
|
||||
|
||||
## Running Sass
|
||||
|
||||
```bash
|
||||
sudo docker run --rm -v $(pwd):/usr/src/app -w /usr/src/app sass-container sass novaconium/sass/project.sass novaconium/public/css/novaconium.css
|
||||
```
|
||||
|
||||
Compressed:
|
||||
```bash
|
||||
# Build Novaconium (compressed)
|
||||
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app sass-container --style=compressed sass/novaconium.sass skeleton/novaconium/public/css/novaconium.css
|
||||
|
||||
```
|
||||
|
||||
Dev:
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v "$(pwd)/sass:/usr/src/sass" \
|
||||
-v "/home/nick/tmp/novaproject/novaconium/public/css:/usr/src/css" \
|
||||
-w /usr/src \
|
||||
sass-container \
|
||||
sass sass/novaconium.sass css/novaconium.css --no-source-map --style=compressed
|
||||
```
|
||||
@@ -1,5 +0,0 @@
|
||||
# Sessions
|
||||
|
||||
There is a sessions handler built into Novaconium.
|
||||
|
||||
$session
|
||||
@@ -1,7 +0,0 @@
|
||||
# Style Sheets
|
||||
|
||||
The idea is to use sass to generate only what you need for style sheets.
|
||||
|
||||
```bash
|
||||
sudo docker run --rm -v $(pwd):/usr/src/app sass-container sass sass/project.sass public/css/main.css
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
# Twig
|
||||
|
||||
## Overrides
|
||||
|
||||
You can override twig templates by creating the same file in the templates directory.
|
||||
|
||||
## Calling View
|
||||
|
||||
There is a $data that the system uses to store arrays for twig you can save to this array:
|
||||
|
||||
```
|
||||
$data['newinfo'] = 'stuff';
|
||||
view('templatename');
|
||||
```
|
||||
and that will automotically go to twig.
|
||||
or you can create a new array and pass it in:
|
||||
|
||||
```
|
||||
$anotherArr['newinfo'] = 'stuff';
|
||||
view('templatename',$anotherArr);
|
||||
```
|
||||
@@ -1,9 +0,0 @@
|
||||
# Docker Cheatsheet (for Novaconium)
|
||||
|
||||
## Sample Docker Compose File
|
||||
|
||||
See the skeleton directory for an example docker setup.
|
||||
|
||||
## Start Docker
|
||||
|
||||
```docker compose up -d```
|
||||
@@ -0,0 +1,380 @@
|
||||
# Backlog & Roadmap
|
||||
|
||||
**Official issue tracker:** https://git.4lt.ca/4lt/novaconium/issues — bug
|
||||
reports and feature requests are filed and discussed there, not here.
|
||||
|
||||
This file is the roadmap that sits *above* the tracker: a curated,
|
||||
lower-noise list of what's planned, in progress, or decided against, used to
|
||||
triage and clean up the tracker (grouping related issues, deciding
|
||||
priority/sequencing, deciding what's not going to happen) rather than to
|
||||
replace it. An entry here should generally reference the tracker issue(s)
|
||||
it corresponds to once one exists; an entry can also exist here before any
|
||||
tracker issue is filed, for things that are still just an idea.
|
||||
|
||||
## How to use this file
|
||||
|
||||
- Add new items to **Backlog** using the template below. Don't build
|
||||
anything the moment it's added — Backlog is "known, not yet started."
|
||||
- Link the tracker issue once one is filed (`Issue:` line). Not every
|
||||
Backlog entry needs one yet — file the tracker issue when it's ready to
|
||||
be actionable/discussed, not necessarily when the idea is first written
|
||||
down here.
|
||||
- When work begins, move the item to **In Progress**.
|
||||
- When shipped, move it to **Done** and add a `Shipped:` line with the date
|
||||
and, once committed, the commit/PR reference. Keep a Done entry only as
|
||||
long as it's referenced by (a `Depends on:`, or otherwise relevant
|
||||
context for) something still in Backlog/In Progress — once nothing
|
||||
active points back to it, delete it rather than letting this file grow
|
||||
without bound. This is a change from the file's earlier "never delete"
|
||||
policy; if a stale Done entry's history is ever needed again, it's in
|
||||
git history / the linked tracker issue.
|
||||
- If something is decided against, move it to **Won't Do** with a `Reason:`
|
||||
line rather than deleting it — the "why not" is worth keeping. Close the
|
||||
corresponding tracker issue with a link back to that entry.
|
||||
- Keep entries terse. This file is a map, not a design doc — link out to
|
||||
`/admin/docs/design-notes`, the tracker issue, or a future `docs/` note for anything long
|
||||
enough to need one.
|
||||
|
||||
### Entry template
|
||||
|
||||
```
|
||||
### <Short title>
|
||||
|
||||
- **Type:** Feature | Bug
|
||||
- **Status:** Backlog | In Progress | Done | Won't Do
|
||||
- **Priority:** Low | Medium | High
|
||||
- **Added:** YYYY-MM-DD
|
||||
- **Depends on:** <other entry title(s)> — omit if none
|
||||
- **Issue:** https://git.4lt.ca/4lt/novaconium/issues/N — once filed
|
||||
- **Shipped:** YYYY-MM-DD (commit/PR ref) — only once Done
|
||||
|
||||
<1-3 sentence description: what and why. For bugs, include repro steps and
|
||||
expected vs. actual behavior. For features, include the motivating use case.>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backlog
|
||||
|
||||
Suggested build order (foundations first):
|
||||
|
||||
1. **Ecommerce: Lib\Money** — no dependencies, and the other two Ecommerce
|
||||
pieces below both need it.
|
||||
2. **Ecommerce: Lib\Cart** — needs Lib\Money.
|
||||
3. **Ecommerce: payment gateway helper** — needs Lib\Money; independent of
|
||||
Lib\Cart, so it could also go before it.
|
||||
4. **Paywall functionality** — needs the payment gateway helper above for
|
||||
its recurring-billing/payment plumbing; 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.
|
||||
|
||||
Session handling (with flash sessions), Draft pages (admin-only preview),
|
||||
and Admin login & user management all shipped (see Done) — every open
|
||||
entry above still depends on at least one of them. The original single
|
||||
"Ecommerce functionality" entry was scoped down into the three
|
||||
Lib\-helper pieces above on 2026-07-15 — see Lib\Money's entry for why.
|
||||
|
||||
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
||||
|
||||
### Ecommerce: Lib\Money
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Added:** 2026-07-15
|
||||
|
||||
First and smallest piece of the former "Ecommerce functionality" entry —
|
||||
scoped down (2026-07-15) from a full catalog/cart/checkout/order-admin
|
||||
system to a handful of composable `Lib\` helpers, since a project's idea
|
||||
of a "product" is too site-specific to standardize; the project builds
|
||||
its own catalog and admin UI on `Lib\Db` the same way it would for any
|
||||
other feature, the same split `Lib\Comments` already makes for what a
|
||||
"page" is. `Lib\Money`: integer-cents arithmetic (add/subtract/multiply
|
||||
by a quantity) and formatting, avoiding the classic float-rounding bugs
|
||||
of storing prices as floats. No dependencies — the foundation `Lib\Cart`
|
||||
and the payment gateway helper below both need a non-lossy way to
|
||||
represent an amount before either can be built.
|
||||
|
||||
### Ecommerce: Lib\Cart
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** Ecommerce: Lib\Money, Session handling (with flash sessions) (Done)
|
||||
- **Added:** 2026-07-15
|
||||
|
||||
Second piece of the former "Ecommerce functionality" entry (see Lib\Money
|
||||
above for the scoping note). A session-based cart primitive — add/remove/
|
||||
update line items, line and cart totals via `Lib\Money` — riding on
|
||||
`Lib\Session` the same way `Lib\Csrf`/`Lib\AdminAuth` lazily touch the
|
||||
native session. Generic on purpose: the cart holds id/qty/price entries a
|
||||
sidecar hands it, with no opinion on what a "product" is or where its
|
||||
catalog data comes from.
|
||||
|
||||
### Ecommerce: payment gateway helper
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** Ecommerce: Lib\Money
|
||||
- **Added:** 2026-07-15
|
||||
|
||||
Third piece of the former "Ecommerce functionality" entry (see Lib\Money
|
||||
above for the scoping note). A driver-dispatched `Lib\` class for taking
|
||||
a payment, mirroring `Lib\Mailer`'s `mail_driver` config-key pattern —
|
||||
Stripe first (one `charge()`-shaped call plus webhook signature
|
||||
verification), calling the provider's REST API directly via cURL rather
|
||||
than vendoring an SDK, same reasoning as vendoring only Twig's `src/`
|
||||
rather than pulling in a package manager. Adding a second provider later
|
||||
means one more driver case, same as `Lib\Mailer::sendMail()`.
|
||||
|
||||
### Paywall functionality
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** Ecommerce: payment gateway helper, SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||
- **Added:** 2026-07-12
|
||||
|
||||
Subscription/membership content gating, similar to OnlyFans/Patreon:
|
||||
recurring billing tied to a user account, content (posts, pages, media)
|
||||
marked as gated behind an active subscription, and access checks in
|
||||
sidecars (`$_SESSION`'s logged-in user + subscription status) — the
|
||||
access-check half of this now has a shipped foundation to build on:
|
||||
`Lib\Access` (see User roles, groups & page access control in Done)
|
||||
already handles login-gated/group-gated sidecar content; a paywall
|
||||
mostly adds "does this account have an active subscription" as a rule
|
||||
source on top of it. Reuses Ecommerce's payment-gateway
|
||||
plumbing for the recurring-charge side rather than integrating a payment
|
||||
provider a second time — build after Ecommerce rather than in parallel.
|
||||
Also needs a decision on how gated content is authored (a `gated: true`
|
||||
flag in a sidecar's returned context vs. a separate content root) and
|
||||
what happens to cached pages once caching only applies to sidecar-less
|
||||
pages — gated pages will need a sidecar to check access, so they're never
|
||||
statically cached, which is consistent with the existing caching model
|
||||
but worth being explicit about up front.
|
||||
|
||||
## In Progress
|
||||
|
||||
_Nothing yet._
|
||||
|
||||
## Done
|
||||
|
||||
### User roles, groups & page access control
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
- **Shipped:** 2026-07-14 (b882c30)
|
||||
|
||||
Follow-up to Admin login & user management (below), shipped the same day
|
||||
before any of it was committed — so the `users` schema change went into
|
||||
the existing `0002_create_users.sql` rather than a third migration. Two
|
||||
roles (`users.role`): the first user created is `'admin'`, everyone
|
||||
after is `'registered'` with an optional single group
|
||||
(`users.user_group`, a plain text label matched exactly — deliberately
|
||||
no groups table). `/admin/*` and draft preview are admin-only now — a
|
||||
logged-in registered user gets a plain 404 there (not a login redirect;
|
||||
they're authenticated, what they lack is the role) — and the
|
||||
last-active-user lockout guard became a last-active-*admin* guard,
|
||||
applied to both the disable and the new demote action (`/admin/users`
|
||||
also grew group-assignment and promote/demote).
|
||||
|
||||
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
|
||||
gate, per the "assign a page/section to a user or group" spec:
|
||||
`Access::require('group:members', 'user:bob')` at the top of a sidecar
|
||||
returns `null` or a ready-made `Response` (anonymous → login redirect
|
||||
carrying a `?return=` path, validated against open redirects;
|
||||
wrong-account → 404, drafts' hide-don't-tease posture). No rules = any
|
||||
logged-in user; admins pass everything. Public is the default twice
|
||||
over: sidecars that never call it are untouched, and static
|
||||
(sidecar-less) pages *can't* call it — always public, which also means
|
||||
a gated page necessarily has a sidecar and is therefore never written
|
||||
to the static HTML cache: the caching/auth standing rule satisfied by
|
||||
construction, no bootstrap exclusion needed. Sections are gated by
|
||||
composition (a shared `_access.php` file `require`'d by each sidecar in
|
||||
the section), not per-directory config. `Access::require()` has no side
|
||||
effects on deny, so the content-index crawl (anonymous GET per sidecar)
|
||||
both drops gated pages from `/search`/`/sitemap.xml` automatically and
|
||||
can't touch the visiting user's session. Verified end-to-end with three
|
||||
real accounts (admin / registered-with-group / registered-without) and
|
||||
a cookie jar each: rule matrix, return-path round trip,
|
||||
`//evil.com`-style return rejection, registered-user 404s on `/admin/*`
|
||||
and drafts, promote/demote + guards, group reassignment taking effect
|
||||
immediately, crawl exclusion, and flag-off zero-footprint posture.
|
||||
Documented at `/admin/docs/access-control` (new topic, linked from the
|
||||
docs nav/index), with supporting updates to `admin-auth`, `drafts`,
|
||||
`sidecars`, `config`, and `libraries`.
|
||||
|
||||
### Admin login & user management
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done)
|
||||
- **Added:** 2026-07-12
|
||||
- **Shipped:** 2026-07-14 (b882c30)
|
||||
|
||||
Shipped as specified: the single-user HTTP Basic Auth stopgap that gated
|
||||
`/admin/*` was **replaced, not layered on** — `AdminAuth` keeps its name
|
||||
and call sites (`bootstrap.php`'s admin gate and draft gate) but is now a
|
||||
session login (`Lib\Session`, with a new `Session::regenerate()` against
|
||||
session fixation) against a `users` table
|
||||
(`novaconium/migrations/0002_create_users.sql`, the second
|
||||
framework-shipped migration) with `password_hash()`/`password_verify()`
|
||||
and no external auth library. The `admin_username`/`admin_password_hash`
|
||||
config keys and the `/admin/password-hash` page are gone, superseded by a
|
||||
single `admin_auth_enabled` flag (default `false`) plus new
|
||||
`/admin/login`, `/admin/logout` (a real page now — the pre-router special
|
||||
case in `bootstrap.php` is gone too — and POST-only with a GET confirm
|
||||
form, because the content-index crawl runs every sidecar as a GET and a
|
||||
logout-on-GET would have ended the crawling admin's own session the first
|
||||
time a lazy reindex rendered it), and `/admin/users`
|
||||
(create/disable/enable/change-password) pages, and a
|
||||
`novaconium/bin/create-admin-user.php` CLI (password via stdin, for
|
||||
deploy scripts and lockout recovery). Documented at
|
||||
`/admin/docs/admin-auth`.
|
||||
|
||||
Decisions worth recording: same zero-footprint posture as the content
|
||||
index (flag off → the three auth routes 404 and `Lib\Db` is never
|
||||
touched, so no `data/novaconium.sqlite` appears — the route sidecars
|
||||
self-load config the same way `/search` does); first-user bootstrap keeps
|
||||
the gate open only while the `users` table is empty (creating the first
|
||||
user at `/admin/users` auto-logs you in as it and closes the gate —
|
||||
running the CLI *before* enabling the flag avoids the window entirely);
|
||||
`admin/login` is the one `/admin/*` route exempted from
|
||||
`requireLogin()`, or its redirect would loop; disabling a user kills any
|
||||
live session on its next request (`currentUser()` re-checks the row per
|
||||
request), and disabling the last active user is refused since the
|
||||
empty-table window never reopens — that would be a permanent lockout.
|
||||
Login/user passwords read `$_POST` directly, extending the documented
|
||||
`Lib\Input` exact-value exception (verified with a password containing
|
||||
`<>` end-to-end). Verified route-by-route with real HTTP requests and a
|
||||
cookie jar: flag off (404s, no DB file), setup window, first-user
|
||||
auto-login, fresh-client redirect, wrong/right password, logout,
|
||||
enable/disable/change-password, live-session lockout on disable,
|
||||
last-user guard, CSRF failure paths, CLI creation, and draft gating via
|
||||
the session cookie (including confirming a pre-existing static-cache copy
|
||||
of a page still serves after the page is marked a draft until the cache
|
||||
is cleared — the documented cache-clear step, not a new bug).
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Won't Do
|
||||
|
||||
### 404 tracking
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Won't Do
|
||||
- **Priority:** Medium
|
||||
- **Added:** 2026-07-12
|
||||
- **Reason:** Matomo (see `/admin/docs/matomo`) already tracks 404 hits —
|
||||
`Renderer::renderNotFound()` sets `is_404 => true` and the tracking
|
||||
snippet tags the document title as `404/URL = <path>/From = <referrer>`
|
||||
before `trackPageView`, so 404s are already filterable in Matomo under
|
||||
Behaviour → Page URLs. A separate in-app SQLite-backed 404 log would just
|
||||
duplicate what Matomo already captures, for no real benefit.
|
||||
|
||||
Originally proposed as: log requests that hit the 404 page (path,
|
||||
timestamp, referrer, user agent) into SQLite, similar to Joomla's 404 log,
|
||||
with an `/admin` page to view them.
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Manual PSR-4 autoloader (no Composer).
|
||||
*
|
||||
* There's no vendor/autoload.php here — this file *is* the autoloader.
|
||||
* `spl_autoload_register()` below registers a callback that PHP invokes
|
||||
* automatically the first time an unknown class/namespace is referenced
|
||||
* (e.g. `new Router(...)` or `use Lib\Mailer;`), so nothing else in this
|
||||
* project ever has to manually `require` a class file.
|
||||
*
|
||||
* Two different resolution strategies live in that one callback:
|
||||
* - `Twig\` and `App\` are plain one-directory-per-namespace PSR-4 (see
|
||||
* $__psr4_prefixes below) — no override behavior, just a straight
|
||||
* namespace-to-path mapping.
|
||||
* - `Lib\` is resolved against an *ordered list* of directories (see
|
||||
* $__lib_dirs below) — the first one where the file exists wins. This
|
||||
* is what lets a project drop a same-named class into `App/lib/` to
|
||||
* override a `novaconium/lib/` default, without editing novaconium/ at
|
||||
* all — the same App-over-novaconium pattern used for pages/layouts
|
||||
* (see Overlay.php) and Sass colors, just implemented here instead.
|
||||
*/
|
||||
|
||||
// Straight namespace -> directory mapping, one entry per prefix. No
|
||||
// override list here because neither of these is meant to be replaced by
|
||||
// a project: Twig is vendored framework code, and App\ is the framework's
|
||||
// own core classes (Router, Renderer, Cache, etc.) — see
|
||||
// novaconium/src/. (A project's own code goes under Lib\, below.)
|
||||
$__psr4_prefixes = [
|
||||
'Twig\\' => __DIR__ . '/vendor/twig/src/',
|
||||
'App\\' => __DIR__ . '/src/',
|
||||
];
|
||||
|
||||
// Lib\ is resolved against App/lib first so a project can add/override
|
||||
// classes, falling back to novaconium's default lib/ when not found there.
|
||||
// Order matters: this array is walked top-to-bottom and the first file
|
||||
// that actually exists on disk wins, so App/lib/ always gets first look.
|
||||
$__lib_dirs = [
|
||||
__DIR__ . '/../App/lib/',
|
||||
__DIR__ . '/lib/',
|
||||
];
|
||||
|
||||
// The callback PHP calls whenever a referenced class hasn't been loaded
|
||||
// yet. It only ever needs to `require` the right file (or silently return
|
||||
// if nothing matches, which just leaves the class undefined and PHP throws
|
||||
// its own "Class not found" error) — there's no class map to build or
|
||||
// cache, since every lookup is a cheap `is_file()` check against a handful
|
||||
// of candidate paths.
|
||||
spl_autoload_register(function (string $class) use (&$__psr4_prefixes, &$__lib_dirs): void {
|
||||
// Lib\Foo\Bar -> try App/lib/Foo/Bar.php, then novaconium/lib/Foo/Bar.php.
|
||||
if (str_starts_with($class, 'Lib\\')) {
|
||||
$relative = substr($class, strlen('Lib\\'));
|
||||
foreach ($__lib_dirs as $baseDir) {
|
||||
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
|
||||
if (is_file($file)) {
|
||||
require $file;
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Twig\Foo\Bar -> novaconium/vendor/twig/src/Foo/Bar.php,
|
||||
// App\Foo\Bar -> novaconium/src/Foo/Bar.php.
|
||||
// Only one prefix can ever match (they don't overlap), so the loop
|
||||
// just finds which one applies and returns right after handling it.
|
||||
foreach ($__psr4_prefixes as $prefix => $baseDir) {
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require $file;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Twig 3.x calls trigger_deprecation() (normally provided by symfony/deprecation-contracts,
|
||||
// which we don't vendor). Provide the same signature so deprecated code paths don't fatal.
|
||||
// This has nothing to do with class autoloading above — it's defined here
|
||||
// simply because this file already runs on every request before Twig does,
|
||||
// making it a convenient, guaranteed-to-run-first place for the shim to live.
|
||||
if (!function_exists('trigger_deprecation')) {
|
||||
function trigger_deprecation(string $package, string $version, string $message, mixed ...$args): void
|
||||
{
|
||||
@trigger_error(
|
||||
($package || $version ? "Since $package $version: " : '') . ($args ? vsprintf($message, $args) : $message),
|
||||
E_USER_DEPRECATED
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Cache;
|
||||
|
||||
require __DIR__ . '/../autoload.php';
|
||||
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
(new Cache($config['cache_dir']))->clear();
|
||||
|
||||
echo "Cache cleared.\n";
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use Lib\Db;
|
||||
use Lib\Validate;
|
||||
|
||||
require __DIR__ . '/../autoload.php';
|
||||
|
||||
// Creates an admin user from the command line (e.g. from a deploy script,
|
||||
// or to fix a lockout) — the CLI counterpart to /admin/users, and the way
|
||||
// to create the first user *before* flipping admin_auth_enabled on, which
|
||||
// avoids the brief open-access setup window /admin/users otherwise relies
|
||||
// on (see /admin/docs/admin-auth).
|
||||
//
|
||||
// php novaconium/bin/create-admin-user.php <username> <email>
|
||||
//
|
||||
// The password is read from stdin — typed at the prompt (echo suppressed
|
||||
// where the terminal supports it), or piped:
|
||||
//
|
||||
// echo 'the-password' | php novaconium/bin/create-admin-user.php admin admin@example.com
|
||||
//
|
||||
// Deliberately not gated on admin_auth_enabled: creating the row is
|
||||
// harmless while the gate is off, and doing it first is the safer order.
|
||||
|
||||
$username = trim((string) ($argv[1] ?? ''));
|
||||
$email = Validate::isEmail((string) ($argv[2] ?? ''));
|
||||
if ($username === '' || strlen($username) > 64 || $email === false) {
|
||||
fwrite(STDERR, "Usage: php novaconium/bin/create-admin-user.php <username> <email>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Db::connection() runs pending migrations on first touch, so the users
|
||||
// table exists after this even on a fresh clone.
|
||||
$exists = (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)', [$username])->fetchColumn();
|
||||
if ($exists) {
|
||||
fwrite(STDERR, "A user named '{$username}' already exists.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$emailExists = (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)', [$email])->fetchColumn();
|
||||
if ($emailExists) {
|
||||
fwrite(STDERR, "A user with the email '{$email}' already exists.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$interactive = stream_isatty(STDIN);
|
||||
if ($interactive) {
|
||||
fwrite(STDOUT, "Password for '{$username}': ");
|
||||
// Suppress echo while the password is typed; restore afterwards.
|
||||
// shell_exec() may be unavailable/no-op on some setups — then the
|
||||
// password just echoes, same as any basic CLI prompt.
|
||||
shell_exec('stty -echo 2> /dev/null');
|
||||
}
|
||||
|
||||
$password = rtrim((string) fgets(STDIN), "\r\n");
|
||||
|
||||
if ($interactive) {
|
||||
shell_exec('stty echo 2> /dev/null');
|
||||
fwrite(STDOUT, "\n");
|
||||
}
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
fwrite(STDERR, "Use a password of at least 8 characters.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Always role 'admin', as the script name says — /admin/users is the
|
||||
// place to create registered users; this exists for first-user setup and
|
||||
// lockout recovery, both of which need an admin. Auto-verified for the
|
||||
// same reason /admin/users auto-verifies the very first user: an admin
|
||||
// created this way has nobody else to have vouched for them, and lockout
|
||||
// recovery in particular can't depend on a mail transport being
|
||||
// configured (see /admin/docs/admin-auth's email verification section).
|
||||
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||
Db::query(
|
||||
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, 'admin', '', 0, ?, ?)",
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $now, $now]
|
||||
);
|
||||
|
||||
echo "User '{$username}' created.\n";
|
||||
echo "If admin auth isn't enabled yet, set 'admin_auth_enabled' => true in App/config.php.\n";
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Scaffolds a new static page from the same copy-paste starter template
|
||||
* documented at /admin/docs/seo, so adding a page doesn't require manually
|
||||
* copying it by hand.
|
||||
*
|
||||
* Usage:
|
||||
* php novaconium/bin/create-static-page.php <path>
|
||||
*
|
||||
* <path> is relative to App/pages/ and becomes the route — with or
|
||||
* without a trailing index.twig/.twig, so any of these are equivalent:
|
||||
* php novaconium/bin/create-static-page.php pricing
|
||||
* php novaconium/bin/create-static-page.php blog/my-new-post
|
||||
* php novaconium/bin/create-static-page.php blog/my-new-post.twig
|
||||
* php novaconium/bin/create-static-page.php blog/my-new-post/index.twig
|
||||
*
|
||||
* Creates App/pages/<path>/index.twig and nothing else — no sidecar, since
|
||||
* the point of this template is a sidecar-less, statically-cacheable page
|
||||
* (see /admin/docs/caching). Add an index.php next to it yourself if the
|
||||
* page ends up needing one (see /admin/docs/sidecars).
|
||||
*/
|
||||
|
||||
$path = $argv[1] ?? null;
|
||||
|
||||
if ($path === null || trim($path) === '') {
|
||||
fwrite(STDERR, "Usage: php novaconium/bin/create-static-page.php <path>\n");
|
||||
fwrite(STDERR, "Example: php novaconium/bin/create-static-page.php blog/my-new-post\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Normalize away a trailing /index.twig or .twig, so the path can be
|
||||
// passed either as a bare route or as a file-shaped argument.
|
||||
$path = trim($path, '/');
|
||||
$path = preg_replace('#/index\.twig$#', '', $path);
|
||||
$path = preg_replace('#\.twig$#', '', $path);
|
||||
|
||||
if ($path === '') {
|
||||
fwrite(STDERR, "Error: path cannot be empty.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Reserved segments (see Router::resolve()) can never be routed to
|
||||
// directly — refuse to scaffold a page nothing could ever visit.
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
if ($segment === '' || str_starts_with($segment, '_') || $segment === '404') {
|
||||
fwrite(STDERR, "Error: \"$segment\" is a reserved path segment (starts with _, or is literally \"404\") and can never be routed to directly.\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$pageDir = __DIR__ . '/../../App/pages/' . $path;
|
||||
$templateFile = $pageDir . '/index.twig';
|
||||
|
||||
if (is_file($templateFile)) {
|
||||
fwrite(STDERR, "Error: $templateFile already exists — not overwriting.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!is_dir($pageDir) && !mkdir($pageDir, 0775, true) && !is_dir($pageDir)) {
|
||||
fwrite(STDERR, "Error: could not create directory $pageDir\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// "my-new-post" -> "My New Post", used to pre-fill the title/heading.
|
||||
$title = ucwords(str_replace(['-', '_'], ' ', basename($path)));
|
||||
|
||||
$template = <<<TWIG
|
||||
{% extends layout %}
|
||||
|
||||
{% block title %}$title{% endblock %}
|
||||
{% block description %}One or two sentences describing this page.{% 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 og_type %}website{% 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 content %}
|
||||
<article>
|
||||
<h1>$title</h1>
|
||||
<p>...</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
|
||||
TWIG;
|
||||
|
||||
file_put_contents($templateFile, $template);
|
||||
|
||||
echo "Created $templateFile\n";
|
||||
echo "Visit it at /$path once you fill in the content.\n";
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Front-controller wiring. Every request — via public/index.php (Apache) or
|
||||
* public/router.php (php -S) — ends up require'ing this one file, which:
|
||||
* 1. loads config (framework defaults + optional App/ override)
|
||||
* 2. resolves the URL to a page directory (Router)
|
||||
* 3. gates /admin/* behind session login, if enabled (AdminAuth)
|
||||
* 4. renders the matched page, or a 404 (Renderer)
|
||||
* There's no framework "kernel" class doing this — it's just a plain
|
||||
* top-to-bottom script, deliberately, so a dev can read the whole
|
||||
* request lifecycle in one file without chasing an abstraction.
|
||||
*/
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Cache;
|
||||
use App\Renderer;
|
||||
use App\Router;
|
||||
|
||||
require __DIR__ . '/autoload.php';
|
||||
|
||||
// Framework defaults live in novaconium/config.php. If the project defines
|
||||
// its own App/config.php, shallow-merge it on top — a project only needs to
|
||||
// list the keys it wants to change (same override pattern as pages/lib,
|
||||
// just for an array instead of a file lookup). See /admin/docs/config.
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
if ($config['debug']) {
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
}
|
||||
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
|
||||
// Router::resolve() only answers "does a page exist at this URL, and if
|
||||
// so which directory / what params?" — it never touches Twig, sidecars, or
|
||||
// output. See novaconium/src/Router.php and /admin/docs/routing.
|
||||
$router = new Router($config['pages_dirs']);
|
||||
$route = $router->resolve($requestUri);
|
||||
|
||||
// Both of these are derived, request-independent config values that get
|
||||
// handed to the Renderer so it can expose them to every Twig template as
|
||||
// globals (matomo_url/matomo_site_id/admin_auth_enabled) — see
|
||||
// Renderer::__construct(). Normalizing the trailing slash here means every
|
||||
// template can safely do `matomo_url + 'matomo.php'` without checking.
|
||||
$matomoUrl = $config['matomo_url'] !== '' ? rtrim($config['matomo_url'], '/') . '/' : '';
|
||||
$adminAuthEnabled = (bool) $config['admin_auth_enabled'];
|
||||
|
||||
$cache = new Cache($config['cache_dir']);
|
||||
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name'], $config['content_index_enabled']);
|
||||
|
||||
// Every route under /admin/* — clear-cache, docs, users, and any admin
|
||||
// page a project adds later — is gated here, once, rather than in each
|
||||
// page individually. A new admin page is automatically protected the
|
||||
// moment it exists; nothing to remember to wire up. Two steps: nobody
|
||||
// logged in → redirect to the login form (requireLogin() exits); logged
|
||||
// in but not an admin (a 'registered' user — see /admin/docs/admin-auth)
|
||||
// → the same plain 404 an unmatched route gets, since bouncing an
|
||||
// already-authenticated user back to the login form would be a lie (what
|
||||
// they lack is the admin role, not a session). The one exemption is the
|
||||
// login form itself, which has to stay reachable logged-out or
|
||||
// requireLogin()'s redirect to it would loop forever. No-op (open access)
|
||||
// when admin_auth_enabled is false (the default), or while no users exist
|
||||
// yet (so the first user can be created at /admin/users). See
|
||||
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
|
||||
$isAdminRoute = $route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'));
|
||||
if ($isAdminRoute && $route->dir !== 'admin/login') {
|
||||
AdminAuth::requireLogin($config['admin_auth_enabled']);
|
||||
|
||||
if (!AdminAuth::isAdmin($config['admin_auth_enabled'])) {
|
||||
$renderer->renderNotFound($requestUri);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A route listed in draft_routes is only visible to a logged-in admin —
|
||||
// anyone else (including logged-in registered users) 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 access check /admin/* uses
|
||||
// (AdminAuth::isAdmin()) — an admin logs in once at /admin/login, and the
|
||||
// session cookie covers draft URLs too, since it's scoped to the whole
|
||||
// origin.
|
||||
$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
|
||||
// (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
|
||||
// any), resolves the nearest layout, renders Twig, and writes the static
|
||||
// cache for sidecar-less pages — except for drafts and $isAdminRoute (see
|
||||
// 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::isAdmin($config['admin_auth_enabled']))) {
|
||||
$renderer->renderNotFound($requestUri);
|
||||
return;
|
||||
}
|
||||
|
||||
$renderer->render($route, $requestUri, $isDraftRoute || $isAdminRoute);
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
// These are the framework defaults. A project overrides any subset of them
|
||||
// by creating App/config.php returning an array of just the keys it wants
|
||||
// to change — novaconium/bootstrap.php shallow-merges it over this file, the
|
||||
// same App-over-novaconium override pattern used for pages/ and lib/. This
|
||||
// file itself is not meant to be edited per-project.
|
||||
return [
|
||||
// Ordered override roots: App/pages is checked first so a project can
|
||||
// override any page, sidecar, or layout by placing one at the same
|
||||
// relative path there; novaconium/pages supplies the framework defaults.
|
||||
'pages_dirs' => [
|
||||
__DIR__ . '/../App/pages',
|
||||
__DIR__ . '/pages',
|
||||
],
|
||||
'cache_dir' => __DIR__ . '/../public/cache',
|
||||
'debug' => true,
|
||||
|
||||
// Site name used as the default page title, og:site_name, and the
|
||||
// footer copyright line in novaconium/pages/_layout/layout.twig.
|
||||
'site_name' => 'Novaconium Website',
|
||||
|
||||
// Matomo analytics. Leave both empty (the default) to disable tracking
|
||||
// entirely — the layout emits no tracking script at all in that case.
|
||||
// Set both via App/config.php to enable, e.g.:
|
||||
// 'matomo_url' => 'https://matomo.example.com/',
|
||||
// 'matomo_site_id' => '1',
|
||||
'matomo_url' => '',
|
||||
'matomo_site_id' => '',
|
||||
|
||||
// Gates every /admin/* route (clear-cache, docs, users, and any future
|
||||
// admin page) behind a session login against the `users` table on
|
||||
// Lib\Db's default connection — see /admin/docs/admin-auth. The first
|
||||
// user created is the admin; users after that are 'registered', each
|
||||
// with an optional group, and see whatever content sidecars grant via
|
||||
// Lib\Access (see /admin/docs/access-control) — /admin/* itself 404s
|
||||
// for them. Off by default because it depends on SQLite (same
|
||||
// reasoning as content_index_enabled below): when false, /admin/* is
|
||||
// wide open, /admin/login, /admin/logout, and /admin/users 404,
|
||||
// Access::require() allows everything, and nothing ever touches
|
||||
// Lib\Db because of this feature. After enabling it via
|
||||
// App/config.php, create the first user at /admin/users (open access
|
||||
// until at least one user exists) or with:
|
||||
// php novaconium/bin/create-admin-user.php <username>
|
||||
'admin_auth_enabled' => false,
|
||||
|
||||
// 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,
|
||||
|
||||
// Media manager (/admin/media — see /admin/docs/media-manager): an
|
||||
// upload/browse/delete UI for files under public/uploads/, covered by
|
||||
// the existing /admin/* auth gate the moment the page exists, so
|
||||
// there's no separate *_enabled flag here (unlike admin_auth_enabled/
|
||||
// content_index_enabled above, it has no SQLite dependency to gate).
|
||||
// media_upload_extensions is an allowlist, matched case-insensitively
|
||||
// against the uploaded filename's extension; media_upload_max_bytes
|
||||
// caps a single file's size (checked against both $_FILES' reported
|
||||
// size and PHP's own upload_max_filesize/post_max_size ini limits,
|
||||
// see /admin/docs/media-manager). 'svg' is deliberately NOT in this
|
||||
// default list: files under public/uploads/ are served directly from
|
||||
// this origin, and an SVG can carry inline <script>, making it a
|
||||
// stored-XSS vector. Add 'svg' back via App/config.php only if you
|
||||
// serve uploads with a restrictive CSP or Content-Disposition:
|
||||
// attachment.
|
||||
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'txt', 'zip'],
|
||||
'media_upload_max_bytes' => 10 * 1024 * 1024,
|
||||
|
||||
// Lib\Mailer's transactional-mail driver (see /admin/docs/admin-auth's
|
||||
// email verification section) — separate from Mailer::send(), the
|
||||
// contact form's own log-to-file stand-in, which this doesn't touch.
|
||||
// 'log' (default) writes to the same novaconium/contact-log.txt with no
|
||||
// external dependency, so a fresh checkout with admin_auth_enabled on
|
||||
// can still create/verify accounts (via the logged link) with zero
|
||||
// setup. 'mailjet' sends through MailJet's Send API v3.1
|
||||
// (https://api.mailjet.com/v3.1/send) using the four keys below — set
|
||||
// all four via App/config.php, e.g.:
|
||||
// 'mail_driver' => 'mailjet',
|
||||
// 'mail_from_email' => 'noreply@example.com',
|
||||
// 'mail_from_name' => 'Example Site',
|
||||
// 'mailjet_api_key' => '...',
|
||||
// 'mailjet_api_secret' => '...',
|
||||
'mail_driver' => 'log',
|
||||
'mail_from_email' => '',
|
||||
'mail_from_name' => '',
|
||||
'mailjet_api_key' => '',
|
||||
'mailjet_api_secret' => '',
|
||||
];
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
|
||||
/**
|
||||
* Sidecar-level access control for page content — the way a page (or a
|
||||
* whole section, one line per page) is assigned to a user, a group, or
|
||||
* just "anyone logged in". See /admin/docs/access-control. Usage, at the
|
||||
* top of a sidecar:
|
||||
*
|
||||
* if ($denied = Access::require('group:members')) {
|
||||
* return $denied;
|
||||
* }
|
||||
*
|
||||
* Rules: 'group:<name>' (the user's users.user_group matches), or
|
||||
* 'user:<username>' (that exact account); several rules mean "any of
|
||||
* these". No rules at all means any logged-in user. Admins always pass
|
||||
* every rule. Returns null when the request may proceed, or a Response
|
||||
* for the sidecar to return: a 303 to /admin/login (with a ?return= path
|
||||
* back here) when nobody is logged in, or a plain 404 when someone *is*
|
||||
* logged in but isn't allowed — same hide-don't-tease posture as draft
|
||||
* pages, and the same plain-text 404 /search returns when disabled.
|
||||
*
|
||||
* Public is the default, twice over: a sidecar that never calls this is
|
||||
* untouched, and a page with no sidecar at all *can't* call it — static
|
||||
* (cached) pages are always public. That's load-bearing, not incidental:
|
||||
* only sidecar-less pages are ever written to the static HTML cache
|
||||
* (which .htaccess serves before PHP runs — see /admin/docs/caching), so
|
||||
* a gated page, necessarily having a sidecar, is never cached. This
|
||||
* satisfies the caching/auth standing rule in AGENTS.md by construction
|
||||
* rather than by a bootstrap.php exclusion like drafts/admin need.
|
||||
*
|
||||
* Same open-until-configured posture as the rest of admin auth: with
|
||||
* admin_auth_enabled off, or while no users exist yet, require() allows
|
||||
* everything (there'd be nothing to log in as) — and never touches
|
||||
* Lib\Db, so a site that never opted in never gets a database file.
|
||||
*
|
||||
* The content-index crawl runs every sidecar as an anonymous GET, so a
|
||||
* gated page's sidecar short-circuits to the login redirect during a
|
||||
* crawl — Renderer::renderForIndex() discards Responses, meaning gated
|
||||
* pages are automatically absent from /search and /sitemap.xml, with no
|
||||
* extra wiring. require() has no side effects on deny (the return path
|
||||
* travels in the redirect URL, not the session) for the same reason: a
|
||||
* crawl must not scribble on the visitor's session.
|
||||
*/
|
||||
final class Access
|
||||
{
|
||||
private static ?bool $enabled = null;
|
||||
|
||||
public static function require(string ...$rules): ?Response
|
||||
{
|
||||
if (!self::enabled() || !AdminAuth::hasUsers()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = AdminAuth::currentUser();
|
||||
|
||||
if ($user === null) {
|
||||
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
||||
|
||||
return Response::redirect('/admin/login?return=' . rawurlencode($path), 303);
|
||||
}
|
||||
|
||||
if ($user['role'] === 'admin' || $rules === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if (str_starts_with($rule, 'user:') && substr($rule, 5) === $user['username']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($rule, 'group:') && $user['user_group'] !== '' && substr($rule, 6) === $user['user_group']) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same two-step config load bootstrap.php/bin scripts and the
|
||||
* /search sidecar use — Lib\ classes aren't handed $config, so this
|
||||
* loads its own copy to read admin_auth_enabled (memoized per
|
||||
* request; static state never survives across requests).
|
||||
*/
|
||||
private static function enabled(): bool
|
||||
{
|
||||
if (self::$enabled === null) {
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
self::$enabled = (bool) $config['admin_auth_enabled'];
|
||||
}
|
||||
|
||||
return self::$enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* A reusable comment thread any sidecar can attach 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. See
|
||||
* /admin/docs/comments and novaconium/pages/_partials/comments/thread.twig
|
||||
* for the paired Twig partial.
|
||||
*
|
||||
* Comments are tied to a real logged-in account (App\AdminAuth::currentUser()),
|
||||
* never anonymous name/email fields — and since currentUser() already
|
||||
* excludes disabled and unverified accounts, any user id passed to
|
||||
* create() is already a real, verified account with nothing further to
|
||||
* check here. Auto-approved on submission (no pending/approved state) —
|
||||
* only a verified account can post one in the first place, so there's no
|
||||
* anonymous-spam vector to pre-vet against — with a single is_hidden flag
|
||||
* an admin can flip after the fact at /admin/comments, mirroring how
|
||||
* /admin/users disables rather than pre-vets accounts.
|
||||
*/
|
||||
final class Comments
|
||||
{
|
||||
/**
|
||||
* The current route's path, e.g. "/blog/hello-world" — the natural
|
||||
* page_path key for forPage()/create(), derived from the request
|
||||
* itself so callers never hand-write a path that could drift from the
|
||||
* actual route.
|
||||
*/
|
||||
public static function currentPagePath(): string
|
||||
{
|
||||
return (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible (non-hidden) comments for a page, oldest first, joined to
|
||||
* the posting user's username.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function forPage(string $pagePath): array
|
||||
{
|
||||
return Db::query(
|
||||
'SELECT comments.id, comments.body, comments.created_at, users.username ' .
|
||||
'FROM comments JOIN users ON users.id = comments.user_id ' .
|
||||
'WHERE comments.page_path = ? AND comments.is_hidden = 0 ' .
|
||||
'ORDER BY comments.created_at ASC',
|
||||
[$pagePath]
|
||||
)->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function create(string $pagePath, int $userId, string $body): void
|
||||
{
|
||||
Db::query(
|
||||
'INSERT INTO comments (page_path, user_id, body, is_hidden, created_at) VALUES (?, ?, ?, 0, ?)',
|
||||
[$pagePath, $userId, $body, gmdate('Y-m-d\TH:i:s\Z')]
|
||||
);
|
||||
}
|
||||
|
||||
public static function setHidden(int $id, bool $hidden): void
|
||||
{
|
||||
Db::query('UPDATE comments SET is_hidden = ? WHERE id = ?', [$hidden ? 1 : 0, $id]);
|
||||
}
|
||||
|
||||
public static function delete(int $id): void
|
||||
{
|
||||
Db::query('DELETE FROM comments WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every comment, hidden or not, newest first — for the /admin/comments
|
||||
* moderation list.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return Db::query(
|
||||
'SELECT comments.id, comments.page_path, comments.body, comments.created_at, comments.is_hidden, users.username ' .
|
||||
'FROM comments JOIN users ON users.id = comments.user_id ' .
|
||||
'ORDER BY comments.created_at DESC'
|
||||
)->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* Session-token CSRF protection, standalone from Lib\FormValidator — a
|
||||
* sidecar calls Csrf::verify() directly, typically before running any other
|
||||
* validation:
|
||||
*
|
||||
* if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
* return Response::redirect('/contact?error=security');
|
||||
* }
|
||||
*
|
||||
* and the template renders a hidden field for it:
|
||||
*
|
||||
* <input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
*
|
||||
* This was the first thing in the framework to start a native PHP session
|
||||
* — but only lazily, the moment token()/verify() is actually called. A page
|
||||
* that never touches Csrf never gets a session cookie. Lib\Session and
|
||||
* App\AdminAuth (session-based admin login) now touch the same native
|
||||
* session the same lazy way — safe in any order, since ensureSession()
|
||||
* no-ops when a session is already active.
|
||||
*/
|
||||
final class Csrf
|
||||
{
|
||||
public const FIELD_NAME = 'csrf_token';
|
||||
private const SESSION_KEY = '_csrf_token';
|
||||
|
||||
public static function token(): string
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
if (empty($_SESSION[self::SESSION_KEY])) {
|
||||
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION[self::SESSION_KEY];
|
||||
}
|
||||
|
||||
public static function verify(?string $submittedToken): bool
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
$expected = $_SESSION[self::SESSION_KEY] ?? null;
|
||||
|
||||
if ($submittedToken === null || $expected === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($expected, $submittedToken);
|
||||
}
|
||||
|
||||
public static function fieldName(): string
|
||||
{
|
||||
return self::FIELD_NAME;
|
||||
}
|
||||
|
||||
private static function ensureSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?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
|
||||
{
|
||||
self::requireDriver('sqlite', 'pdo_sqlite',
|
||||
'bundled with PHP but sometimes not enabled — Debian/Ubuntu: `apt install php-sqlite3`; Arch: uncomment `extension=pdo_sqlite` in php.ini');
|
||||
|
||||
$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
|
||||
{
|
||||
self::requireDriver('mysql', 'pdo_mysql',
|
||||
'Debian/Ubuntu: `apt install php-mysql`; Arch: uncomment `extension=pdo_mysql` in php.ini');
|
||||
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing PDO driver otherwise surfaces as a bare PDOException
|
||||
* ("could not find driver") from deep inside a connect call — hit for
|
||||
* real the first time this ran on a PHP install without pdo_sqlite
|
||||
* enabled. Checking PDO::getAvailableDrivers() up front turns that
|
||||
* into an error that names the extension and how to install it.
|
||||
*/
|
||||
private static function requireDriver(string $driver, string $extension, string $installHint): void
|
||||
{
|
||||
if (!in_array($driver, PDO::getAvailableDrivers(), true)) {
|
||||
throw new RuntimeException(
|
||||
"PHP is missing the {$extension} extension, which this connection's '{$driver}' driver needs ({$installHint}). " .
|
||||
'Verify with `php -m`, and restart PHP after enabling it. See /admin/docs/database.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* A small accumulating validator for sidecar forms, so a sidecar doesn't
|
||||
* hand-roll the same required()/email() checks and $errors array for
|
||||
* every form it validates. See App/pages/contact/index.php for a working
|
||||
* example, and /admin/docs/sidecars for the full write-up.
|
||||
*
|
||||
* Each check here just records a field/message pair on failure — for the
|
||||
* lower-level validation logic itself (what counts as a valid email,
|
||||
* phone number, etc.), see Lib\Validate, which this class calls into
|
||||
* rather than duplicating.
|
||||
*
|
||||
* Usage:
|
||||
* $validator = (new FormValidator())
|
||||
* ->required($old['name'], 'name', 'Name is required.')
|
||||
* ->email($old['email'], 'email', 'A valid email is required.')
|
||||
* ->maxLength($old['message'], 'message', 2000, 'Message is too long.');
|
||||
*
|
||||
* if ($validator->passes()) { ... }
|
||||
* $errors = $validator->errors();
|
||||
*/
|
||||
final class FormValidator
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private array $errors = [];
|
||||
|
||||
public function required(string $value, string $field, string $message): static
|
||||
{
|
||||
if (trim($value) === '') {
|
||||
$this->errors[$field] = $message;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function email(string $value, string $field, string $message): static
|
||||
{
|
||||
if (Validate::isEmail($value) === false) {
|
||||
$this->errors[$field] = $message;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function minLength(string $value, string $field, int $length, string $message): static
|
||||
{
|
||||
if (Validate::minLength($value, $length) === false) {
|
||||
$this->errors[$field] = $message;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function maxLength(string $value, string $field, int $length, string $message): static
|
||||
{
|
||||
if (!Validate::maxLength($value, $length)) {
|
||||
$this->errors[$field] = $message;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function passes(): bool
|
||||
{
|
||||
return $this->errors === [];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
public function errors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* A cleaning accessor for $_POST and $_GET, so sidecars never touch the
|
||||
* superglobals directly.
|
||||
*
|
||||
* Cleaning here (trim + strip_tags, via Lib\Validate::clean(), plus null-byte
|
||||
* stripping) is defense-in-depth against HTML/script injection in output
|
||||
* contexts — it is NOT a defense against SQL injection, and must never be
|
||||
* treated as one. Twig already autoescapes {{ }} output by default (see
|
||||
* novaconium/src/Renderer.php), so this cleaning is a second layer, not the
|
||||
* only one. The real defense against SQL injection is parameterized queries
|
||||
* (PDO prepared statements) — never string concatenation or
|
||||
* sanitize-then-interpolate, however "cleaned" the input looks. Lib\Db (see
|
||||
* /admin/docs/database) is the database layer — its query() method uses
|
||||
* PDO prepared statements exclusively for this reason. This class
|
||||
* deliberately does not (and will not) expose an
|
||||
* "sqlSafe()"-style method — no string transform makes arbitrary input safe
|
||||
* to concatenate into SQL, and a method implying otherwise would be actively
|
||||
* dangerous.
|
||||
*
|
||||
* One documented exception: a field that needs an exact, unmodified value
|
||||
* (e.g. a password about to be hashed or verified) should read $_POST
|
||||
* directly instead — cleaning would silently strip characters like < and >
|
||||
* before hashing, producing a hash that doesn't match what the user
|
||||
* actually typed (or failing a login whose password actually matches). See
|
||||
* the password fields in novaconium/pages/admin/users/index.php and
|
||||
* novaconium/pages/admin/login/index.php for the places this framework
|
||||
* does that on purpose.
|
||||
*/
|
||||
final class Input
|
||||
{
|
||||
/** @var array<string,mixed>|null */
|
||||
private static ?array $cleanedPost = null;
|
||||
|
||||
/** @var array<string,mixed>|null */
|
||||
private static ?array $cleanedGet = null;
|
||||
|
||||
public static function post(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
return self::read(self::cleanedPost(), $key, $default);
|
||||
}
|
||||
|
||||
public static function get(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
return self::read(self::cleanedGet(), $key, $default);
|
||||
}
|
||||
|
||||
private static function read(array $source, ?string $key, mixed $default): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
return array_key_exists($key, $source) ? $source[$key] : $default;
|
||||
}
|
||||
|
||||
private static function cleanedPost(): array
|
||||
{
|
||||
return self::$cleanedPost ??= self::cleanArray($_POST);
|
||||
}
|
||||
|
||||
private static function cleanedGet(): array
|
||||
{
|
||||
return self::$cleanedGet ??= self::cleanArray($_GET);
|
||||
}
|
||||
|
||||
private static function cleanArray(array $values): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$result[$key] = is_array($value) ? self::cleanArray($value) : self::cleanValue($value);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function cleanValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return Validate::clean(str_replace("\0", '', $value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
final class Mailer
|
||||
{
|
||||
public function send(string $name, string $email, string $message): bool
|
||||
{
|
||||
// Stand in for a real mail call — log instead so the example has no
|
||||
// external dependency (swap this out for mail()/an API call/etc).
|
||||
$line = sprintf(
|
||||
"[%s] %s <%s>: %s\n",
|
||||
date('c'),
|
||||
$name,
|
||||
$email,
|
||||
str_replace("\n", ' ', $message)
|
||||
);
|
||||
|
||||
file_put_contents(__DIR__ . '/../contact-log.txt', $line, FILE_APPEND);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactional mail (verification links, and later password reset) —
|
||||
* separate from send() above, which is specifically the contact form's
|
||||
* "notify the site owner of a submission" shape and stays untouched.
|
||||
* Driver-dispatched via config['mail_driver'] (see /admin/docs/admin-auth
|
||||
* and novaconium/config.php): 'log' (default, zero external dependency,
|
||||
* same novaconium/contact-log.txt as send() above but a distinguishable
|
||||
* line prefix) or 'mailjet' (Send API v3.1, https://api.mailjet.com/v3.1/send,
|
||||
* Basic auth mailjet_api_key:mailjet_api_secret). Adding a future
|
||||
* provider means adding a case here and a private sendVia*() method —
|
||||
* callers never change.
|
||||
*/
|
||||
public function sendMail(string $toEmail, string $subject, string $textBody): bool
|
||||
{
|
||||
$config = self::config();
|
||||
|
||||
return match ($config['mail_driver']) {
|
||||
'mailjet' => $this->sendViaMailjet($config, $toEmail, $subject, $textBody),
|
||||
default => $this->sendViaLog($toEmail, $subject, $textBody),
|
||||
};
|
||||
}
|
||||
|
||||
private function sendViaLog(string $toEmail, string $subject, string $textBody): bool
|
||||
{
|
||||
$line = sprintf(
|
||||
"[%s] MAIL <%s> %s: %s\n",
|
||||
date('c'),
|
||||
$toEmail,
|
||||
$subject,
|
||||
str_replace("\n", ' ', $textBody)
|
||||
);
|
||||
|
||||
file_put_contents(__DIR__ . '/../contact-log.txt', $line, FILE_APPEND);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function sendViaMailjet(array $config, string $toEmail, string $subject, string $textBody): bool
|
||||
{
|
||||
$payload = [
|
||||
'Messages' => [
|
||||
[
|
||||
'From' => [
|
||||
'Email' => $config['mail_from_email'],
|
||||
'Name' => $config['mail_from_name'],
|
||||
],
|
||||
'To' => [
|
||||
['Email' => $toEmail],
|
||||
],
|
||||
'Subject' => $subject,
|
||||
'TextPart' => $textBody,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$ch = curl_init('https://api.mailjet.com/v3.1/send');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_USERPWD => $config['mailjet_api_key'] . ':' . $config['mailjet_api_secret'],
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $status >= 200 && $status < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same two-step App-over-novaconium shallow merge every entry point
|
||||
* duplicates (see novaconium/bootstrap.php, Lib\Db::config()) rather
|
||||
* than a shared Config class — no such class exists in this codebase.
|
||||
*/
|
||||
private static function config(): array
|
||||
{
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -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,128 @@
|
||||
<?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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the session id for a fresh one, keeping the session's data.
|
||||
* Call on any privilege change — after a successful login, and on
|
||||
* logout — so a session id an attacker planted or observed before the
|
||||
* change is worthless after it (session fixation). App\AdminAuth does
|
||||
* exactly this.
|
||||
*/
|
||||
public static function regenerate(): void
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,51 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* Self-hosted spam detection for any form sidecar: a honeypot field plus
|
||||
* a submission-timing check. No external CAPTCHA service, no CDN script,
|
||||
* no site key/secret key, no outbound API call — see /admin/docs/sidecars's
|
||||
* "Spam prevention" section for the full write-up and App/pages/contact/
|
||||
* for a working example.
|
||||
*
|
||||
* Pair this with a hidden honeypot input (named per $honeypotField below,
|
||||
* hidden off-screen via the .hp-field CSS class — not display:none, since
|
||||
* some bots specifically skip fields hidden that way) and a hidden
|
||||
* `renderedAt()`-valued timestamp field in the form's Twig template.
|
||||
*/
|
||||
final class SpamGuard
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $honeypotField = 'website',
|
||||
private readonly string $timestampField = 'rendered_at',
|
||||
private readonly int $minSeconds = 2,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when rendering the form (i.e. in the sidecar's return
|
||||
* array, GET or POST) and put the result in a hidden field named
|
||||
* $timestampField for isSpam() to read back on submit.
|
||||
*/
|
||||
public function renderedAt(): int
|
||||
{
|
||||
return time();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $post typically $_POST
|
||||
*/
|
||||
public function isSpam(array $post): bool
|
||||
{
|
||||
$honeypotFilled = trim((string) ($post[$this->honeypotField] ?? '')) !== '';
|
||||
|
||||
$renderedAt = (int) ($post[$this->timestampField] ?? 0);
|
||||
// Not cryptographically signed, so a determined bot could forge
|
||||
// this — it's a deterrent against unsophisticated spam, not a
|
||||
// security boundary.
|
||||
$tooFast = $renderedAt === 0 || (time() - $renderedAt) < $this->minSeconds;
|
||||
|
||||
return $honeypotFilled || $tooFast;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* General-purpose input validation primitives, modeled after the
|
||||
* project author's own reusable validation class
|
||||
* (https://github.com/nickyeoman/php-validation-class) — rewritten here
|
||||
* as stateless static methods so any sidecar can call them directly, no
|
||||
* instance to construct.
|
||||
*
|
||||
* Each method returns the cleaned/validated value itself (or `false`),
|
||||
* not just a pass/fail boolean, so a sidecar can use the normalized
|
||||
* result. For accumulating named field errors across a whole form (the
|
||||
* "is this form valid, and what's wrong with it" question), see
|
||||
* Lib\FormValidator, which uses isEmail() below internally.
|
||||
*/
|
||||
final class Validate
|
||||
{
|
||||
/**
|
||||
* Trims whitespace and strips HTML tags from a piece of user input.
|
||||
*/
|
||||
public static function clean(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim(strip_tags($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|false the lowercased, trimmed email if valid, else false
|
||||
*/
|
||||
public static function isEmail(string $email): string|false
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false ? $email : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false the trimmed string's length if it meets the minimum, else false
|
||||
*/
|
||||
public static function minLength(string $value, int $length): int|false
|
||||
{
|
||||
$len = mb_strlen(trim($value));
|
||||
|
||||
return $len >= $length ? $len : false;
|
||||
}
|
||||
|
||||
public static function maxLength(string $value, int $length): bool
|
||||
{
|
||||
return mb_strlen(trim($value)) <= $length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two values for equality after cleaning both (e.g. a
|
||||
* "confirm email" or "confirm password" field).
|
||||
*/
|
||||
public static function isMatch(string $a, string $b): bool
|
||||
{
|
||||
return self::clean($a) === self::clean($b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a 7- or 10-digit phone number, with any non-digit
|
||||
* formatting (spaces, dashes, parens) stripped. If $withExtension is
|
||||
* true, an "x123"/"ext. 123" suffix is split out separately instead
|
||||
* of causing validation to fail.
|
||||
*
|
||||
* @return string|array{number: string, ext: ?string}|false
|
||||
*/
|
||||
public static function isPhone(string $phone, bool $withExtension = false): string|array|false
|
||||
{
|
||||
$ext = null;
|
||||
|
||||
if ($withExtension && preg_match('/^(.*?)(?:x|ext\.?)\s*(\d+)$/i', $phone, $matches)) {
|
||||
$phone = $matches[1];
|
||||
$ext = $matches[2];
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $phone);
|
||||
|
||||
if (!in_array(strlen($digits), [7, 10], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $withExtension ? ['number' => $digits, 'ext' => $ext] : $digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|false the uppercased, space-normalized postal code if a valid Canadian format, else false
|
||||
*/
|
||||
public static function isPostalCode(string $postal): string|false
|
||||
{
|
||||
$postal = strtoupper(str_replace(' ', '', $postal));
|
||||
|
||||
if (!preg_match('/^[A-CEGHJ-NPR-TVXY]\d[A-CEGHJ-NPR-TV-Z]\d[A-CEGHJ-NPR-TV-Z]\d$/', $postal)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return substr($postal, 0, 3) . ' ' . substr($postal, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|false the 5 digits of a US ZIP code, else false
|
||||
*/
|
||||
public static function isZipCode(string $zip): string|false
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $zip);
|
||||
|
||||
return strlen($digits) === 5 ? $digits : false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
source_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'registered',
|
||||
user_group TEXT NOT NULL DEFAULT '',
|
||||
is_disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
verified_at TEXT,
|
||||
verification_token TEXT,
|
||||
verification_token_expires_at TEXT
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_path TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
is_hidden INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_comments_page_path ON comments(page_path);
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% block title %}Not Found{% endblock %}
|
||||
|
||||
{% block description %}The page you're looking for doesn't exist.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1>404 — Page not found</h1>
|
||||
<p>Nothing lives at this address.</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,168 @@
|
||||
{# Shared inline SVG icons — no icon font, no CDN (consistent with this
|
||||
project's no-external-dependency approach: Twig is vendored, Matomo is
|
||||
self-hosted, so icons are inline SVG rather than a Font Awesome
|
||||
webfont/CDN just for a couple of glyphs). Each macro takes an optional
|
||||
css class suffix; `currentColor` means an icon always matches its
|
||||
surrounding link/text color, including on :hover, with no extra CSS.
|
||||
|
||||
Available: home, git, book, link, sitemap, email, search, rss, tag,
|
||||
lock, trash, external_link, menu, back_to_top, sun, moon, copy, check.
|
||||
Some (search, rss, tag) are ahead of the features that will use them
|
||||
(see novaconium/ISSUES.md) — added now so those features don't need an
|
||||
icons.twig change later. #}
|
||||
|
||||
{% macro home(class) %}
|
||||
<svg class="icon icon-home {{ 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="M3 11.5 12 4l9 7.5" />
|
||||
<path d="M5.5 9.5V20a1 1 0 0 0 1 1H9a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h2.5a1 1 0 0 0 1-1V9.5" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro git(class) %}
|
||||
<svg class="icon icon-git {{ 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">
|
||||
<circle cx="6" cy="6" r="2.25" />
|
||||
<circle cx="6" cy="18" r="2.25" />
|
||||
<circle cx="18" cy="9" r="2.25" />
|
||||
<path d="M6 8.25V15.75" />
|
||||
<path d="M6 8.25a6 6 0 0 0 6 6h3.75" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro book(class) %}
|
||||
<svg class="icon icon-book {{ 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 19.5V5.5a2 2 0 0 1 2-2h6v18H6a2 2 0 0 1-2-2Z" />
|
||||
<path d="M12 3.5h6a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-6" />
|
||||
<path d="M8 7.5h2" />
|
||||
<path d="M8 11h2" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro link(class) %}
|
||||
<svg class="icon icon-link {{ 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="M9.5 14.5 14.5 9.5" />
|
||||
<path d="M11 6.5 12.5 5a3.54 3.54 0 0 1 5 5L16 11.5" />
|
||||
<path d="M13 17.5 11.5 19a3.54 3.54 0 0 1-5-5L8 12.5" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro sitemap(class) %}
|
||||
<svg class="icon icon-sitemap {{ 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">
|
||||
<circle cx="12" cy="4.5" r="2" />
|
||||
<circle cx="5" cy="19.5" r="2" />
|
||||
<circle cx="12" cy="19.5" r="2" />
|
||||
<circle cx="19" cy="19.5" r="2" />
|
||||
<path d="M12 6.5V13" />
|
||||
<path d="M5 17.5V13h14v4.5" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro email(class) %}
|
||||
<svg class="icon icon-email {{ 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="3" y="5.5" width="18" height="13" rx="2" />
|
||||
<path d="M3.5 6.5 12 13l8.5-6.5" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro search(class) %}
|
||||
<svg class="icon icon-search {{ 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">
|
||||
<circle cx="10.5" cy="10.5" r="6.5" />
|
||||
<path d="M20 20l-4.35-4.35" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro rss(class) %}
|
||||
<svg class="icon icon-rss {{ 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="M5 5a14 14 0 0 1 14 14" />
|
||||
<path d="M5 11a8 8 0 0 1 8 8" />
|
||||
<circle cx="6" cy="18" r="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro tag(class) %}
|
||||
<svg class="icon icon-tag {{ 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 4h7.5L20 12.5 12.5 20 4 11.5V4Z" />
|
||||
<circle cx="8.5" cy="8.5" r="1.25" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro lock(class) %}
|
||||
<svg class="icon icon-lock {{ 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="5" y="11" width="14" height="9" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro users(class) %}
|
||||
<svg class="icon icon-users {{ 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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro trash(class) %}
|
||||
<svg class="icon icon-trash {{ 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 7h16" />
|
||||
<path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro external_link(class) %}
|
||||
<svg class="icon icon-external-link {{ 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="M14 4h6v6" />
|
||||
<path d="M20 4 10 14" />
|
||||
<path d="M18 13v6a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h6" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro menu(class) %}
|
||||
<svg class="icon icon-menu {{ 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 6h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 18h16" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro back_to_top(class) %}
|
||||
<svg class="icon icon-back-to-top {{ 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="M12 19V6" />
|
||||
<path d="M6 11l6-6 6 6" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro sun(class) %}
|
||||
<svg class="icon icon-sun {{ 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">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="M4.93 4.93l1.41 1.41" />
|
||||
<path d="M17.66 17.66l1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="M4.93 19.07l1.41-1.41" />
|
||||
<path d="M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro moon(class) %}
|
||||
<svg class="icon icon-moon {{ 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="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
|
||||
</svg>
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
{% include '_layout/theme-init.twig' %}
|
||||
{% include '_layout/syntax-highlight-init.twig' %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ site_name }}{% endblock %}</title>
|
||||
<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="keywords" content="{% block keywords %}{% endblock %}">
|
||||
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="generator" content="novaconium">
|
||||
|
||||
{# 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 #}
|
||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||
<meta property="og:title" content="{% block og_title %}{{ block('title') }}{% endblock %}">
|
||||
<meta property="og:description" content="{% block og_description %}{{ block('description') }}{% endblock %}">
|
||||
<meta property="og:url" content="{% block og_url %}{{ block('canonical') }}{% endblock %}">
|
||||
<meta property="og:site_name" content="{{ site_name }}">
|
||||
|
||||
{# Twitter #}
|
||||
<meta name="twitter:card" content="{% block twitter_card %}summary{% endblock %}">
|
||||
<meta name="twitter:title" content="{% block twitter_title %}{{ block('title') }}{% endblock %}">
|
||||
<meta name="twitter:description" content="{% block twitter_description %}{{ block('description') }}{% endblock %}">
|
||||
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
|
||||
{% 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>
|
||||
<body>
|
||||
<header>
|
||||
{% include '_layout/nav.twig' %}
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer>
|
||||
<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>
|
||||
{% include '_layout/code-copy.twig' %}
|
||||
{% include '_layout/syntax-highlight.twig' %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{% if matomo_url and matomo_site_id %}
|
||||
<script>
|
||||
var _paq = window._paq = window._paq || [];
|
||||
{% if is_404 %}
|
||||
_paq.push(['setDocumentTitle', '404/URL = ' + encodeURIComponent(document.location.pathname + document.location.search) + '/From = ' + encodeURIComponent(document.referrer)]);
|
||||
{% endif %}
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function() {
|
||||
var u = "{{ matomo_url|e('js') }}";
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php']);
|
||||
_paq.push(['setSiteId', '{{ matomo_site_id|e('js') }}']);
|
||||
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
|
||||
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
<nav>
|
||||
<a href="/">{{ icons.home() }}Home</a>
|
||||
<a href="/about">About</a>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="/contact">Contact</a>
|
||||
<a href="/admin">Admin</a>
|
||||
<button type="button" class="theme-toggle icon-link" aria-label="Toggle dark/light theme">
|
||||
{{ icons.sun('theme-toggle-sun') }}{{ icons.moon('theme-toggle-moon') }}
|
||||
</button>
|
||||
</nav>
|
||||
<script>
|
||||
document.addEventListener('click', function (event) {
|
||||
var button = event.target.closest('.theme-toggle');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||
var next = isLight ? 'dark' : 'light';
|
||||
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
{# Applies a saved theme choice before the page paints, so switching to
|
||||
light doesn't flash dark first. Must run early in <head>, before the
|
||||
stylesheet link — see novaconium/pages/_layout/layout.twig. Pairs with
|
||||
the toggle button + its click handler in novaconium/pages/_layout/nav.twig. #}
|
||||
<script>
|
||||
(function () {
|
||||
var saved = localStorage.getItem('theme');
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
{#
|
||||
Reusable comment thread — included from any page whose sidecar
|
||||
returns 'comments' (Lib\Comments::forPage()), 'currentUser'
|
||||
(App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and
|
||||
'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See
|
||||
/admin/docs/comments for the full sidecar contract this expects, and
|
||||
App/pages/blog/comments-demo/index.php for a worked example. A page
|
||||
with no 'comments' key never includes this at all — see the
|
||||
surrounding {% if comments is defined %} in blog/_layout/layout.twig.
|
||||
#}
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
<section class="comments">
|
||||
<h2 class="icon-heading">{{ icons.users() }}Comments</h2>
|
||||
|
||||
{% if comments is empty %}
|
||||
<p>No comments yet.</p>
|
||||
{% else %}
|
||||
{% for comment in comments %}
|
||||
<article class="comment">
|
||||
<p><strong>{{ comment.username }}</strong> — <small>{{ comment.created_at }}</small></p>
|
||||
<p>{{ comment.body }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if currentUser %}
|
||||
<form method="post" action="{{ request_path|default('/') }}">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
<label for="comment-website">Leave this field blank</label>
|
||||
<input type="text" id="comment-website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" name="rendered_at" value="{{ renderedAt }}">
|
||||
<p>
|
||||
<label for="comment-body">Add a comment</label><br>
|
||||
<textarea id="comment-body" name="body" rows="4"></textarea>
|
||||
{% if commentError %}<br><small>{{ commentError }}</small>{% endif %}
|
||||
</p>
|
||||
<button type="submit">Post comment</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p><a href="/admin/login">Log in</a> to leave a comment.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user