Add typed text pages; serve CSS as compiled-on-demand /css/main.css
Sidecar-less page bundles whose directory ends in a MediaType extension
(.txt/.css/.xml/.json/.js) now render raw (no HTML layout, autoescape off),
are served with that extension's Content-Type, and cache as a flat file
(public/cache/<path>) so Apache serves them directly via a new .htaccess
rule. They're excluded from the content index.
CSS becomes one of these: novaconium/pages/css/main.css/ holds index.twig
({{ sass('main.sass') }}), main.sass, and _colors.sass. App\SassExtension's
sass() Twig function resolves the entry file through the overlay and shells
out to Dart Sass on a cache miss; Lib\SassCompiler wraps the CLI. This
drops the committed App/css/main.css build artifact and the public/css
bind mount. Override styling by copying the whole bundle to App/pages/.
Also ship robots.txt and humans.txt as framework bundles, and drop the
php -S dev router (public/router.php) - Docker/Apache only now.
New: novaconium/src/MediaType.php, SassExtension.php, lib/SassCompiler.php.
Moved: novaconium/sass/ -> novaconium/pages/css/main.css/ (+ App/sass merged).
Docs: AGENTS.md, new /admin/docs/text-pages, and styling/caching/docker/
design-notes/getting-started/project-layout/config docs updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q9cAXC9xcXYnmP7qjsnw7
This commit is contained in:
@@ -50,18 +50,19 @@ 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.
|
||||
`App/config.php`, `App/migrations/`. 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.
|
||||
(`novaconium/src/`), default pages/libs (including the `css/main.css/`,
|
||||
`robots.txt/`, `humans.txt/` bundles), 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.
|
||||
`config.php` and Twig's `FilesystemLoader`. 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
|
||||
@@ -139,19 +140,39 @@ 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
|
||||
## Typed text pages (CSS, robots.txt, humans.txt, …)
|
||||
|
||||
`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.
|
||||
A sidecar-less page bundle whose **directory segment ends in an extension**
|
||||
in `App\MediaType::MAP` (`.txt` `.css` `.xml` `.json` `.js`) is a *typed
|
||||
text page*: `Renderer` renders its `index.twig` with **no** HTML layout and
|
||||
**autoescaping off**, serves it with that extension's Content-Type, and
|
||||
`Cache` writes it as a flat file (`public/cache/css/main.css`, not
|
||||
`.../index.html`). A rewrite in `public/.htaccess` (rule 2b) then serves
|
||||
that file straight from Apache on every later request — so the extension
|
||||
list is duplicated there; keep it in sync with `MediaType::MAP`. These
|
||||
pages are excluded from the content index (`ContentIndexer::isCrawlable()`).
|
||||
See `/admin/docs/text-pages`.
|
||||
|
||||
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.
|
||||
The framework ships three: `novaconium/pages/css/main.css/` (route
|
||||
`/css/main.css`), `novaconium/pages/robots.txt/`, `novaconium/pages/humans.txt/`.
|
||||
A project overrides one the normal App-over-novaconium way — put its own
|
||||
bundle at the same relative path under `App/pages/`.
|
||||
|
||||
CSS is the interesting one: `novaconium/pages/css/main.css/` holds
|
||||
`index.twig` (`{{- sass('main.sass') -}}`), `main.sass`, and `_colors.sass`.
|
||||
The `sass()` Twig function (`App\SassExtension` → `Lib\SassCompiler`)
|
||||
resolves the entry file through the overlay (App first) and shells out to
|
||||
Dart Sass with that file's own directory as the sole `--load-path`, so
|
||||
`@use 'colors'` resolves to the sibling `_colors.sass` in whichever copy of
|
||||
the bundle won. To customise styling, copy the **whole** `css/main.css/`
|
||||
bundle to `App/pages/css/main.css/` — there's no partial "just
|
||||
`_colors.sass`" override.
|
||||
Output is cached like any typed page; run
|
||||
`php novaconium/bin/clear-cache.php` after editing Sass. Every color rule
|
||||
reads a CSS custom property (`var(--bg)` …), never a Sass variable directly
|
||||
— required for the runtime dark/light toggle; adding a color means adding
|
||||
its plain and `-light` variable in `_colors.sass` and wiring both into the
|
||||
two `:root` blocks in `main.sass`.
|
||||
|
||||
## Input handling
|
||||
|
||||
@@ -166,12 +187,14 @@ to be hashed) read `$_POST` directly — see login/users sidecars.
|
||||
|
||||
## Running it
|
||||
|
||||
Only via the Docker image built from `Dockerfile` (Apache reads
|
||||
`public/.htaccess` directly — there is no `php -S` dev router). Typically:
|
||||
|
||||
```
|
||||
php -S 127.0.0.1:8000 -t public public/router.php
|
||||
docker compose up # image + volumes from docker-compose.yml
|
||||
```
|
||||
|
||||
`public/router.php` is dev-only, mimics `public/.htaccess`. There is no
|
||||
test suite — verification is manual route-by-route (see
|
||||
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
|
||||
@@ -190,13 +213,11 @@ except `public/cache/*` and `novaconium/contact-log.txt`.
|
||||
- `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
|
||||
`App/css/main.css` (project-owned; docker-compose mounts `App/css` to
|
||||
the container's `public/css`, and it's served at `/css/main.css`):
|
||||
`sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass App/css/main.css`
|
||||
— commit the regenerated CSS. The Docker image ships Dart Sass standalone
|
||||
(pinned `DART_SASS_VERSION` in the `Dockerfile`, symlinked to
|
||||
`/usr/local/bin/sass`), so the same command runs inside the container;
|
||||
bump that version deliberately, like the PHP base tag. See
|
||||
`/admin/docs/styling` for the Docker fallback if `sass` isn't installed
|
||||
locally.
|
||||
- CSS is a **typed text page** (see that section above), not a build
|
||||
artifact: the Sass lives in `novaconium/pages/css/main.css/` (override at
|
||||
`App/pages/css/main.css/`) and is compiled on demand by the `sass()` Twig
|
||||
function, cached to `public/cache/css/main.css`. There is nothing to
|
||||
pre-build or commit. The Docker image ships Dart Sass standalone (pinned
|
||||
`DART_SASS_VERSION` in the `Dockerfile`, symlinked to
|
||||
`/usr/local/bin/sass`) as a runtime dependency. See `/admin/docs/styling`
|
||||
and `/admin/docs/text-pages`.
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
:root {
|
||||
--bg: #14181c;
|
||||
--surface: #1b2126;
|
||||
--text-color: #e7ebee;
|
||||
--muted-color: #98a3ac;
|
||||
--border-color: #2a3238;
|
||||
--accent: #2dd4bf;
|
||||
--accent-hover: #5eead4;
|
||||
}
|
||||
|
||||
:root[data-theme=light] {
|
||||
--bg: #ffffff;
|
||||
--surface: #f1f4f6;
|
||||
--text-color: #14181c;
|
||||
--muted-color: #5b6570;
|
||||
--border-color: #d8dee3;
|
||||
--accent: #0f9488;
|
||||
--accent-hover: #0c7a70;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text-color);
|
||||
max-width: 40rem;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.25rem 0 0.25rem 1rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--muted-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 1.5rem 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.icon-heading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-right: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-weight: normal;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: none;
|
||||
}
|
||||
.theme-toggle .icon-moon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-theme=light] .theme-toggle .icon-sun {
|
||||
display: none;
|
||||
}
|
||||
:root[data-theme=light] .theme-toggle .icon-moon {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
pre:hover .copy-code-button, pre .copy-code-button:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-code-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--muted-color);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.copy-code-button:hover {
|
||||
background: var(--bg);
|
||||
color: var(--text-color);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.copy-code-button.copied {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hljs {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
pre code.hljs {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-menu {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hp-field {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button, select, textarea, input:not([type=radio]):not([type=checkbox]) {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
background: var(--surface);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type=radio], input[type=checkbox] {
|
||||
accent-color: var(--accent);
|
||||
margin-right: 0.35rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.blog-layout {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
.blog-layout aside {
|
||||
color: var(--muted-color);
|
||||
flex: 0 0 8rem;
|
||||
}
|
||||
.blog-layout article {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.75rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes glow-drift {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 3rem 0 2.5rem;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 40rem;
|
||||
height: 40rem;
|
||||
background: conic-gradient(from 0deg, transparent 0deg, rgba(45, 212, 191, 0.16) 90deg, transparent 180deg);
|
||||
animation: glow-drift 18s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.hero > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
animation: fade-in-up 0.6s ease-out both;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
animation-delay: 0.08s;
|
||||
}
|
||||
.hero .hero-lede {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
.hero .hero-actions {
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
.hero .hero-badges {
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
display: inline-block;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hero-lede {
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
color: var(--muted-color);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.button-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem 1.25rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.button-link:hover {
|
||||
background: var(--accent-hover);
|
||||
color: var(--bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
.button-link.button-link--ghost {
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.button-link.button-link--ghost:hover {
|
||||
background: var(--surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--muted-color);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
opacity: 0;
|
||||
animation: fade-in-up 0.5s ease-out forwards;
|
||||
}
|
||||
.feature-card:nth-child(1) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
.feature-card:nth-child(2) {
|
||||
animation-delay: 0.42s;
|
||||
}
|
||||
.feature-card:nth-child(3) {
|
||||
animation-delay: 0.48s;
|
||||
}
|
||||
.feature-card:nth-child(4) {
|
||||
animation-delay: 0.54s;
|
||||
}
|
||||
.feature-card:nth-child(5) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
.feature-card:nth-child(6) {
|
||||
animation-delay: 0.66s;
|
||||
}
|
||||
.feature-card:nth-child(7) {
|
||||
animation-delay: 0.72s;
|
||||
}
|
||||
.feature-card:nth-child(8) {
|
||||
animation-delay: 0.78s;
|
||||
}
|
||||
.feature-card:nth-child(9) {
|
||||
animation-delay: 0.84s;
|
||||
}
|
||||
.feature-card:nth-child(10) {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
.feature-card:nth-child(11) {
|
||||
animation-delay: 0.96s;
|
||||
}
|
||||
.feature-card:nth-child(12) {
|
||||
animation-delay: 1.02s;
|
||||
}
|
||||
.feature-card h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.feature-card p {
|
||||
color: var(--muted-color);
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero::before {
|
||||
animation: none;
|
||||
}
|
||||
.hero > *, .feature-card {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.next-steps {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.next-steps ul {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.post-list li {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.post-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.post-list h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.post-list p {
|
||||
color: var(--muted-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
.footnotes ol {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
@@ -27,8 +27,8 @@
|
||||
<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>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. Name a bundle's directory with an extension (<code>css/main.css/</code>, <code>robots.txt/</code>, <code>humans.txt/</code>) and the same mechanism serves it with the right <code>Content-Type</code> — the stylesheet is Sass compiled on the first request, then a plain cached file.</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, and <code>Lib\</code> class. 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>
|
||||
@@ -45,7 +45,7 @@
|
||||
<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>
|
||||
<li><strong>No build step, no Composer</strong> — run the Docker image (Apache + PHP + Dart Sass) and it works. 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>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
{% 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>
|
||||
<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/pages/css/main.css/main.sass</code> — no per-page CSS involved. If you've reskinned the site (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>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// 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
|
||||
+7
-5
@@ -17,11 +17,13 @@ RUN apt-get update \
|
||||
&& docker-php-ext-install pdo_sqlite pdo_mysql \
|
||||
&& a2enmod rewrite
|
||||
|
||||
# Dart Sass standalone, so the container can compile
|
||||
# novaconium/sass/main.sass -> App/css/main.css itself (see AGENTS.md
|
||||
# "Conventions worth knowing" and /admin/docs/styling). Debian trixie has
|
||||
# no usable dart-sass package, so pull the pinned upstream release tarball.
|
||||
# Bump DART_SASS_VERSION deliberately, like the PHP base tag above.
|
||||
# Dart Sass standalone. It's a *runtime* dependency: the /main.css route
|
||||
# (a "typed text page" — see /admin/docs/text-pages and /admin/docs/styling)
|
||||
# has App\SassExtension shell out to `sass` on a cache miss to compile
|
||||
# App/pages/main.css/main.sass, then the result is cached like any other
|
||||
# page. Debian trixie has no usable dart-sass package, so pull the pinned
|
||||
# upstream release tarball. Bump DART_SASS_VERSION deliberately, like the
|
||||
# PHP base tag above.
|
||||
ENV DART_SASS_VERSION=1.83.4
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
|
||||
@@ -12,18 +12,17 @@ This project is currently in beta testing. The issue tracker is at https://git.4
|
||||
|
||||
```bash
|
||||
PROJECTDIR="${PROJECTDIR:-/data}"
|
||||
mkdir -p "$PROJECTDIR"/{css,novaconium/{cache,uploads,data}}
|
||||
mkdir -p "$PROJECTDIR"/novaconium/{cache,uploads,data}
|
||||
|
||||
docker pull git.4lt.ca/4lt/novaconium:2.0.0-beta
|
||||
|
||||
docker run -d \
|
||||
--name novaconium \
|
||||
-p 8080:80 \
|
||||
-v "$PROJECTDIR":/var/www/html/App \
|
||||
-v "$PROJECTDIR"/css:/var/www/html/public/css \
|
||||
-v "$PROJECTDIR"/novaconium/cache:/var/www/html/public/cache \
|
||||
-v "$PROJECTDIR"/novaconium/uploads:/var/www/html/public/uploads \
|
||||
-v "$PROJECTDIR"/novaconium/data:/var/www/html/data \
|
||||
-v "$(pwd)/$PROJECTDIR"/novaconium/App:/var/www/html/App \
|
||||
-v "$(pwd)/$PROJECTDIR"/novaconium/cache:/var/www/html/public/cache \
|
||||
-v "$(pwd)/$PROJECTDIR"/novaconium/uploads:/var/www/html/public/uploads \
|
||||
-v "$(pwd)/$PROJECTDIR"/novaconium/data:/var/www/html/data \
|
||||
git.4lt.ca/4lt/novaconium:2.0.0-beta
|
||||
|
||||
```
|
||||
|
||||
@@ -5,7 +5,6 @@ services:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ${PROJECT_PATH:-/data}:/var/www/html/App
|
||||
- ${PROJECT_PATH:-/data}/css:/var/www/html/public/css
|
||||
- ${VOL_PATH:-/data}/novaconium/cache:/var/www/html/public/cache
|
||||
- ${VOL_PATH:-/data}/novaconium/uploads:/var/www/html/public/uploads
|
||||
- ${VOL_PATH:-/data}/novaconium/data:/var/www/html/data
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?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:
|
||||
* Front-controller wiring. Every request — via public/index.php under
|
||||
* Apache — 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)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* Thin wrapper around the Dart Sass CLI (`sass`, shipped in the Docker
|
||||
* image and symlinked to /usr/local/bin/sass — see the Dockerfile).
|
||||
*
|
||||
* It does no caching of its own: the caller (App\SassExtension, via a
|
||||
* `sass()` Twig function) is a sidecar-less typed page, so Renderer writes
|
||||
* the *rendered* CSS to public/cache/ and Apache serves it directly
|
||||
* afterwards — the compile only ever re-runs on a cache miss (a deploy, a
|
||||
* `clear-cache.php`, or debug mode). See /admin/docs/text-pages.
|
||||
*/
|
||||
final class SassCompiler
|
||||
{
|
||||
public function __construct(private readonly string $binary = 'sass')
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile $entry (an indented-syntax or SCSS file) to a CSS string.
|
||||
*
|
||||
* @param string[] $loadPaths directories added with --load-path, in order
|
||||
*
|
||||
* @throws \RuntimeException if `sass` isn't runnable or reports an error
|
||||
* (the message carries its stderr/stdout)
|
||||
*/
|
||||
public function compileToString(string $entry, array $loadPaths = []): string
|
||||
{
|
||||
$cmd = escapeshellarg($this->binary) . ' --no-source-map';
|
||||
|
||||
foreach ($loadPaths as $path) {
|
||||
$cmd .= ' --load-path=' . escapeshellarg($path);
|
||||
}
|
||||
|
||||
// No output file → Dart Sass writes the compiled CSS to stdout.
|
||||
$cmd .= ' ' . escapeshellarg($entry) . ' 2>&1';
|
||||
|
||||
$output = [];
|
||||
$exitCode = null;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new \RuntimeException(
|
||||
"sass failed (exit $exitCode) for $entry:\n" . implode("\n", $output)
|
||||
);
|
||||
}
|
||||
|
||||
return implode("\n", $output) . "\n";
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
<li><a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/text-pages">{{ icons.book() }}Typed text pages</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/matomo">{{ icons.book() }}Matomo</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a></li>
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/<path>/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p>
|
||||
|
||||
<p>A <a href="/admin/docs/text-pages">typed text page</a> (a bundle whose directory ends in <code>.css</code>, <code>.txt</code>, …, such as <code>/css/main.css</code>) follows the same rule but is cached as a flat file — <code>public/cache/css/main.css</code>, not <code>.../index.html</code> — so Apache serves it with the right <code>Content-Type</code>. This is how the Sass stylesheet is compiled once and then served statically.</p>
|
||||
|
||||
<p>Two kinds of route are excluded from the cache unconditionally, regardless of whether they have a sidecar: every <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft page</a> and every <code>/admin/*</code> route. Both are gated by the <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin login</a>, and a cached copy would bypass that check entirely — <code>.htaccess</code> serves a cached file before PHP (and therefore any auth check) ever runs, so a cached admin or draft page would be served to anyone, unauthenticated, forever after the first authenticated view. See <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> for the full write-up.</p>
|
||||
|
||||
<p>To opt a single page out of caching permanently, give it a sidecar — presence is all that's checked, so an <strong>empty <code>index.php</code></strong> is enough (<code><?php return [];</code> if you'd rather it read as deliberate). The page then re-renders through PHP/Twig on every request. Reach for this when a sidecar-less page's output actually varies (a current-time stamp, a random pick) but you don't otherwise need sidecar logic.</p>
|
||||
|
||||
@@ -39,7 +39,7 @@ return [
|
||||
<ul>
|
||||
<li><strong>PHP errors are shown.</strong> <code>novaconium/bootstrap.php</code> sets <code>error_reporting(E_ALL)</code> and <code>display_errors</code> — notices, warnings, and fatals render in the response instead of being swallowed. With it off, nothing is changed, so you get whatever <code>php.ini</code> / the server config decides (typically: logged, not displayed).</li>
|
||||
<li><strong>Twig debug mode is on.</strong> <code>Renderer</code> builds the Twig <code>Environment</code> with <code>debug</code> and <code>auto_reload</code> enabled and registers <code>DebugExtension</code>, so <code>{{ dump(some_var) }}</code> works in templates and edited templates take effect immediately.</li>
|
||||
<li><strong>The static HTML cache is bypassed.</strong> Sidecar-less pages normally get frozen into <code>public/cache/</code> on first request (see <a href="/admin/docs/caching">Static caching</a>); in debug mode <code>Renderer</code> stops writing those files, the dev router (<code>public/router.php</code>) stops serving them, and <code>bootstrap.php</code> calls <code>$cache->clear()</code> on the first request that reaches PHP to flush any stale leftovers. Every page renders fresh on every request. Turn <code>debug</code> off and normal caching resumes as pages are re-requested.</li>
|
||||
<li><strong>The static cache is bypassed.</strong> Sidecar-less pages normally get frozen into <code>public/cache/</code> on first request (see <a href="/admin/docs/caching">Static caching</a>); in debug mode <code>Renderer</code> stops writing those files and <code>bootstrap.php</code> calls <code>$cache->clear()</code> on the first request that reaches PHP to flush any stale leftovers. Every page renders fresh on every request (so <code>/css/main.css</code> recompiles its Sass each time too). Turn <code>debug</code> off and normal caching resumes as pages are re-requested.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Site name</h2>
|
||||
|
||||
@@ -59,10 +59,9 @@ require __DIR__ . '/../novaconium/bootstrap.php';</code></pre>
|
||||
|
||||
<h2>Sass (indented syntax, not SCSS)</h2>
|
||||
<ul>
|
||||
<li>Source lives in <code>novaconium/sass/</code> (structure in <code>main.sass</code>, default colors in <code>defaults/_colors.sass</code>) using indented syntax. <code>App/sass/_colors.sass</code> overrides the color palette.</li>
|
||||
<li>Compiled output goes to <code>public/css/main.css</code>.</li>
|
||||
<li>Compilation is a build step (not a PHP runtime concern) — use the <code>sass</code> CLI (Dart Sass, which supports indented syntax) e.g. <code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code>. No PHP Sass library is needed/assumed since PHP-based compilers (scssphp) target SCSS, not indented syntax.</li>
|
||||
<li><code>main.sass</code> does <code>@use 'colors' as *</code> but its own directory has no <code>_colors.sass</code> — on purpose, so resolution falls through to the load paths above (<code>App/sass</code> checked first) instead of resolving to a same-directory sibling, which Dart Sass would otherwise prefer regardless of load-path order. Same override-by-presence mechanism used for pages/lib, extended to a non-PHP asset.</li>
|
||||
<li>The stylesheet is a <a href="/admin/docs/text-pages">typed text page</a>, not a build artifact: the framework bundle <code>novaconium/pages/css/main.css/</code> (overridable at <code>App/pages/css/main.css/</code>) holds <code class="nohighlight">index.twig</code> (just <code class="nohighlight">{% verbatim %}{{- sass('main.sass') -}}{% endverbatim %}</code>), <code>main.sass</code> (structure), and <code>_colors.sass</code> (palette), all indented syntax.</li>
|
||||
<li><code>App\SassExtension</code>'s <code>sass()</code> function shells out to the Dart Sass CLI (which, unlike PHP compilers such as scssphp, handles indented syntax) with the bundle directory as the sole <code>--load-path</code>, so <code>@use 'colors'</code> resolves to the sibling <code>_colors.sass</code>. There is no PHP Sass library.</li>
|
||||
<li>The compile runs only on a cache miss: <code>Renderer</code> freezes the rendered CSS into <code>public/cache/css/main.css</code> and Apache serves it directly afterwards. Run <code>php novaconium/bin/clear-cache.php</code> after editing Sass.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Autoloading (no Composer)</h2>
|
||||
@@ -79,6 +78,6 @@ require __DIR__ . '/../novaconium/bootstrap.php';</code></pre>
|
||||
<li>Confirm <code>App/pages/blog/_layout/layout.twig</code> overrides <code>App/pages/_layout/layout.twig</code> for pages under <code>blog/</code>, and that <code>_layout/</code> directories are never reachable as routes (404 if requested directly).</li>
|
||||
<li>Drop a same-named class into <code>App/lib/</code> and confirm it's used instead of the <code>novaconium/lib/</code> default (override mechanism works).</li>
|
||||
<li>Delete <code>App/pages/_layout/</code> and <code>App/pages/404/</code> (if present) and confirm the site still renders and 404s using <code>novaconium/pages/</code>'s defaults; then drop a <code>_layout/layout.twig</code> into <code>App/pages/</code> and confirm it takes precedence.</li>
|
||||
<li>Run <code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code> and confirm compiled CSS loads on a page.</li>
|
||||
<li>Visit <code>/css/main.css</code> → returns <code>Content-Type: text/css</code>, writes <code>public/cache/css/main.css</code>, and the page styles load. Visit <code>/robots.txt</code> → <code>text/plain</code>. Then <code>php novaconium/bin/clear-cache.php</code> and confirm both recompile on the next request.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<ul>
|
||||
<li><code>App</code> → <code>/var/www/html/App</code> — the project itself (pages, lib, config, migrations). Edit it on the host; changes show up after <code>docker compose restart web</code>, no rebuild.</li>
|
||||
<li><code>cache</code> → <code>public/cache/</code> — the static HTML page cache (see <a href="/admin/docs/caching">Static caching</a>). Disposable; clear it from inside the container with <code>docker compose exec web php novaconium/bin/clear-cache.php</code>.</li>
|
||||
<li><code>cache</code> → <code>public/cache/</code> — the static cache: rendered HTML pages plus the compiled <code>/css/main.css</code> and other <a href="/admin/docs/text-pages">typed text pages</a> (see <a href="/admin/docs/caching">Static caching</a>). Disposable; clear it from inside the container with <code>docker compose exec web php novaconium/bin/clear-cache.php</code>.</li>
|
||||
<li><code>uploads</code> → <code>public/uploads/</code> — files uploaded through <a class="icon-link" href="/admin/docs/media-manager">Media manager</a> (<code>/admin/media</code>).</li>
|
||||
<li><code>data</code> → <code>data/</code> — holds <code>data/novaconium.sqlite</code> if <a href="/admin/docs/database">Database</a>-backed features (admin auth, content index) are enabled.</li>
|
||||
</ul>
|
||||
@@ -49,5 +49,5 @@
|
||||
|
||||
<p>The base image ships Apache with <code>mod_rewrite</code> disabled and <code>AllowOverride None</code>, no <code>pdo_sqlite</code>/<code>pdo_mysql</code>, and a <code>/var/www/html</code> document root. The Dockerfile runs <code>a2enmod rewrite</code>, <code>docker-php-ext-install pdo_sqlite pdo_mysql</code> (after installing <code>libsqlite3-dev</code>, needed to build <code>pdo_sqlite</code>), and rewrites both the vhost and <code>apache2.conf</code> to point the document root at <code>public/</code> and set <code>AllowOverride All</code> there.</p>
|
||||
|
||||
<p>This is a separate Dockerfile from the one-off Dart Sass build tool described in <a href="/admin/docs/styling">Styling</a> — that one lives at <code>Dockerfile.sass</code>, a Debian-based image whose only job is running the <code>sass</code> CLI, not serving the app.</p>
|
||||
<p>It also installs the standalone <a href="https://sass-lang.com/dart-sass/">Dart Sass</a> release (pinned <code>DART_SASS_VERSION</code>, symlinked to <code>/usr/local/bin/sass</code>) — a runtime dependency, because the <code>/css/main.css</code> route compiles Sass on demand. See <a href="/admin/docs/styling">Styling</a> and <a href="/admin/docs/text-pages">Typed text pages</a>.</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -121,7 +121,7 @@ return [
|
||||
</article>
|
||||
{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>The <code>.hp-field</code> class (defined once in <code>novaconium/sass/main.sass</code>) positions the honeypot off-screen rather than hiding it with <code>display: none</code>, since some spam bots specifically skip fields hidden that way — see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> for why. Nothing about that field or the timestamp needs to change between forms; only the real fields (<code>email</code> here, <code>name</code>/<code>email</code>/<code>message</code> on the contact form) differ.</p>
|
||||
<p>The <code>.hp-field</code> class (defined once in <code>novaconium/pages/css/main.css/main.sass</code>) positions the honeypot off-screen rather than hiding it with <code>display: none</code>, since some spam bots specifically skip fields hidden that way — see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> for why. Nothing about that field or the timestamp needs to change between forms; only the real fields (<code>email</code> here, <code>name</code>/<code>email</code>/<code>message</code> on the contact form) differ.</p>
|
||||
|
||||
<h2>Why it's never cached</h2>
|
||||
|
||||
|
||||
@@ -9,22 +9,17 @@
|
||||
{% block docs_content %}
|
||||
<h1>Getting started</h1>
|
||||
|
||||
<p><strong>Requirements:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>. The <a href="/admin/docs/database">Database</a>/<a href="/admin/docs/content-index">Content index</a>/<a href="/admin/docs/admin-auth">Admin authentication</a> features are optional and off by default — see <a href="/admin/docs">Overview</a> for the extensions they need (<code>pdo_sqlite</code>, optionally <code>pdo_mysql</code>/FTS5) if you turn them on.</p>
|
||||
<p><strong>Requirements:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties), Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>, and Dart Sass on <code>PATH</code> (the <code>/css/main.css</code> route compiles Sass on demand — see <a href="/admin/docs/styling">Styling</a>). All three are already set up in the Docker image, which is the only supported way to run Novaconium. The <a href="/admin/docs/database">Database</a>/<a href="/admin/docs/content-index">Content index</a>/<a href="/admin/docs/admin-auth">Admin authentication</a> features are optional and off by default — see <a href="/admin/docs">Overview</a> for the extensions they need (<code>pdo_sqlite</code>, optionally <code>pdo_mysql</code>/FTS5) if you turn them on.</p>
|
||||
|
||||
<h2>Run it locally (no Apache needed)</h2>
|
||||
<h2>Run it</h2>
|
||||
|
||||
<pre><code>php -S 127.0.0.1:8000 -t public public/router.php</code></pre>
|
||||
<pre><code>docker compose up</code></pre>
|
||||
|
||||
<p><code>public/router.php</code> is a dev-only script that mimics the <code>.htaccess</code> rules (canonical redirects + static cache lookup) so you can develop without Apache. It is never used in production — Apache reads <code>public/.htaccess</code> directly.</p>
|
||||
<p>The image (built from the repo <code>Dockerfile</code>) bundles Apache + <code>public/.htaccess</code>, PHP, and Dart Sass; <code>docker-compose.yml</code> bind-mounts <code>App/</code>, <code>public/cache/</code>, <code>public/uploads/</code>, and <code>data/</code> from the host. Then open <code>http://localhost:8080/</code>. See <a href="/admin/docs/docker">Docker</a> for the image/volume details and the plain <code>docker run</code> form.</p>
|
||||
|
||||
<p>Visit:</p>
|
||||
<ul>
|
||||
<li><code>http://127.0.0.1:8000/</code> — static home page</li>
|
||||
</ul>
|
||||
<h2>Deploy</h2>
|
||||
|
||||
<h2>Deploy on Apache</h2>
|
||||
|
||||
<p>Point the vhost's document root at <code>public/</code>, make sure <code>mod_rewrite</code> is enabled and <code>AllowOverride All</code> is set for that directory so <code>public/.htaccess</code> takes effect, and it just works — no build step required.</p>
|
||||
<p>Run the same image behind your reverse proxy / on your host. If you deploy without Docker instead, point the vhost's document root at <code>public/</code>, enable <code>mod_rewrite</code>, set <code>AllowOverride All</code> so <code>public/.htaccess</code> takes effect, and make sure the <code>sass</code> binary is installed — no build step is required.</p>
|
||||
|
||||
<h2>Starting a new project</h2>
|
||||
|
||||
|
||||
@@ -12,17 +12,14 @@
|
||||
<pre><code>public/ Apache document root
|
||||
index.php thin front controller — just requires novaconium/bootstrap.php
|
||||
.htaccess cache short-circuit, canonical redirects, rewrite to index.php
|
||||
router.php dev-only helper for `php -S` (not used in production)
|
||||
cache/ generated static HTML (safe to delete anytime)
|
||||
css/ compiled CSS output
|
||||
cache/ generated static output — HTML pages plus css/main.css, robots.txt etc. (safe to delete anytime)
|
||||
App/ your project — the only directory you're expected to edit
|
||||
pages/ your routes — directory tree = URL tree, checked before novaconium/pages/ so you can add or override pages/layouts
|
||||
lib/ your PHP classes (Lib\), checked before novaconium/lib/ so you can override defaults
|
||||
sass/ your Sass overrides — _colors.sass, checked before novaconium/sass/defaults/
|
||||
config.php your config overrides — ships as an empty, commented placeholder; shallow-merged over novaconium/config.php
|
||||
novaconium/ the framework itself — boilerplate, not meant to be edited per-project
|
||||
pages/ default pages: _layout/layout.twig (root layout) and 404/index.twig (used when App/pages/ doesn't override them)
|
||||
sass/ Sass source: main.sass (structure) + defaults/_colors.sass (default palette, overridable from App/sass/)
|
||||
pages/ default pages: _layout/layout.twig (root layout), 404/index.twig, and the css/main.css/ + robots.txt/ + humans.txt/ bundles (all overridable from App/pages/)
|
||||
css/main.css/ the site stylesheet as a page bundle — index.twig + main.sass + _colors.sass, compiled on demand (see Styling / Typed text pages)
|
||||
lib/ default Lib\ classes (used when App/lib/ doesn't override them)
|
||||
src/ Router, Route, Renderer, Response, Cache, Overlay (the App/-over-novaconium/ lookup used for both pages and lib)
|
||||
vendor/twig/ vendored Twig source (no Composer)
|
||||
@@ -39,7 +36,7 @@ novaconium/ the framework itself — boilerplate, not meant to be edite
|
||||
|
||||
<ol>
|
||||
<li><strong><code>public/.htaccess</code></strong> runs first, before PHP does anything. If <code>public/cache/<path>/index.html</code> exists for the requested URL, Apache serves that file directly and nothing below this line ever executes — see <a href="/admin/docs/caching">Static caching</a>. Otherwise it strips a trailing slash (301 redirect) and rewrites everything else to <code>public/index.php</code>.</li>
|
||||
<li><strong><code>public/index.php</code></strong> is intentionally one line: <code>require __DIR__ . '/../novaconium/bootstrap.php';</code>. (Running locally via <code>php -S</code> instead of Apache? <code>public/router.php</code> mimics the same three <code>.htaccess</code> rules in PHP, then requires <code>index.php</code> the same way — see <a href="/admin/docs/getting-started">Getting started</a>.)</li>
|
||||
<li><strong><code>public/index.php</code></strong> is intentionally one line: <code>require __DIR__ . '/../novaconium/bootstrap.php';</code>.</li>
|
||||
<li><strong><code>novaconium/bootstrap.php</code></strong> is where the real wiring happens, top-to-bottom:
|
||||
<ol>
|
||||
<li>Requires <strong><code>novaconium/autoload.php</code></strong>, registering the <code>Twig\</code>/<code>App\</code>/<code>Lib\</code> class autoloader (see <a href="/admin/docs/libraries">Libraries</a>) before anything below tries to instantiate a class.</li>
|
||||
|
||||
@@ -2,61 +2,40 @@
|
||||
|
||||
{% block title %}Styling{% endblock %}
|
||||
|
||||
{% block description %}Sass, indented syntax, compiled to App/css/main.css, with an overridable color palette.{% endblock %}
|
||||
{% block description %}Sass, indented syntax, compiled on demand for /css/main.css, with an overridable color palette.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Styling (Sass, indented syntax)</h1>
|
||||
|
||||
<p>Source lives in <code>novaconium/sass/main.sass</code> (indented syntax, not SCSS). Compile it with <a href="https://sass-lang.com/dart-sass/">Dart Sass</a>, passing both Sass directories as load paths. The output goes to <code>App/css/main.css</code> — a project-owned directory that docker-compose mounts onto the container's <code>public/css</code>, served at <code>/css/main.css</code>:</p>
|
||||
<p>The stylesheet is a <a href="/admin/docs/text-pages">typed text page</a>, not a build artifact. It ships as a framework bundle at <code>novaconium/pages/css/main.css/</code>:</p>
|
||||
|
||||
<pre><code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass App/css/main.css</code></pre>
|
||||
<pre><code class="nohighlight">novaconium/pages/css/main.css/
|
||||
index.twig {% verbatim %}{{- sass('main.sass') -}}{% endverbatim %}
|
||||
main.sass structure and rules (indented syntax, not SCSS)
|
||||
_colors.sass the color palette</code></pre>
|
||||
|
||||
<p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p>
|
||||
<p><code>/css/main.css</code> has no <code>index.php</code> sidecar, so <code>Renderer</code> renders <code>index.twig</code> as <code>text/css</code>. The <code>sass()</code> function (<code>App\SassExtension</code> → <code>Lib\SassCompiler</code>) shells out to <a href="https://sass-lang.com/dart-sass/">Dart Sass</a> — the only Sass compiler that handles indented syntax; PHP options like <code>scssphp</code> are SCSS-only — with the bundle directory as the sole <code>--load-path</code>, so <code>@use 'colors'</code> resolves to the sibling <code>_colors.sass</code>. The rendered CSS is frozen into <code>public/cache/css/main.css</code> and served straight from Apache on every later request (a rewrite in <code>public/.htaccess</code>), so the compile runs at most once per cache clear, not per request.</p>
|
||||
|
||||
<p>Don't have Dart Sass installed? Run it via Docker instead — copy-paste this into a file named <code>Dockerfile.sass</code> (the project's own root <code>Dockerfile</code> is the app container, see <a href="/admin/docs/docker">Docker</a> — this is a separate, one-off build tool, so it gets its own filename), which installs the same official standalone Dart Sass release used in this environment (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p>
|
||||
<p><strong>After editing any <code>.sass</code> file, run <code>php novaconium/bin/clear-cache.php</code></strong> (or delete <code>public/cache/css/main.css</code>) so the next request recompiles. In <code>debug</code> mode nothing is cached, so edits show up immediately. The Docker image ships the <code>sass</code> binary; running outside Docker, it must be on <code>PATH</code>.</p>
|
||||
|
||||
<pre><code>FROM debian:bookworm-slim
|
||||
<h2>Reskinning the site</h2>
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSLo /tmp/dart-sass.tar.gz \
|
||||
https://github.com/sass/dart-sass/releases/download/1.101.0/dart-sass-1.101.0-linux-x64.tar.gz \
|
||||
&& tar -xzf /tmp/dart-sass.tar.gz -C /usr/local/lib \
|
||||
&& ln -s /usr/local/lib/dart-sass/sass /usr/local/bin/sass \
|
||||
&& rm /tmp/dart-sass.tar.gz \
|
||||
&& apt-get purge -y curl \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
<p>Override the bundle the same way you'd override any framework page — copy the <strong>whole</strong> directory to <code>App/pages/css/main.css/</code> and edit your copy. (There's no partial "just <code>_colors.sass</code>" override: <code>sass()</code> compiles the <code>main.sass</code> that won the overlay with <em>its own</em> directory as the load path, so a lone <code>App/pages/css/main.css/_colors.sass</code> next to the framework's <code>main.sass</code> is never seen.)</p>
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
<pre><code>mkdir -p App/pages/css
|
||||
cp -r novaconium/pages/css/main.css App/pages/css/main.css</code></pre>
|
||||
|
||||
ENTRYPOINT ["sass"]</code></pre>
|
||||
|
||||
<p>Check <a href="https://github.com/sass/dart-sass/releases">the dart-sass releases page</a> for a newer version and swap both occurrences of <code>1.101.0</code> in the download URL if you want to track latest instead of matching this environment.</p>
|
||||
|
||||
<p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p>
|
||||
|
||||
<pre><code>docker build -t novaconium-sass -f Dockerfile.sass .
|
||||
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
|
||||
--load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass App/css/main.css</code></pre>
|
||||
|
||||
<p>See <a href="https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Sass.md">git.4lt.ca/4lt/novaconium/docs/Sass.md</a> for this project's own write-up of the Docker approach in general; the Dockerfile and paths/flags above are this project's own, adjusted to use Dart Sass directly and this repo's current layout.</p>
|
||||
|
||||
<h2>Overriding just the colors</h2>
|
||||
|
||||
<p><code>novaconium/sass/main.sass</code> starts with <code>@use 'colors' as *</code>, but its own directory has no <code>_colors.sass</code> of its own — on purpose, so that <code>@use</code> falls through to the Sass load path above rather than resolving to a sibling file. <code>App/sass/_colors.sass</code> is checked first; <code>novaconium/sass/defaults/_colors.sass</code> (the framework's own palette) is the fallback. Same override-by-presence mechanism as <code>App/pages/</code> over <code>novaconium/pages/</code>, just applied to Sass instead of Twig/PHP.</p>
|
||||
|
||||
<p>To reskin the whole site, edit the seven variables in <code>App/sass/_colors.sass</code> — <code>$bg</code>, <code>$surface</code>, <code>$text-color</code>, <code>$muted-color</code>, <code>$border-color</code>, <code>$accent</code>, <code>$accent-hover</code> — and recompile. Nothing under <code>novaconium/</code> needs to change. Delete <code>App/sass/_colors.sass</code> entirely to fall back to the framework's default palette instead.</p>
|
||||
<p>Then edit the seven variables in <code>App/pages/css/main.css/_colors.sass</code> — <code>$bg</code>, <code>$surface</code>, <code>$text-color</code>, <code>$muted-color</code>, <code>$border-color</code>, <code>$accent</code>, <code>$accent-hover</code> (plus their <code>-light</code> counterparts, below) — and clear the cache. Renaming the route to <code>/default.css</code> is just renaming the directory (and its <code>main.sass</code> → <code>default.sass</code>, and the one line in <code>index.twig</code>); a second stylesheet like <code>/print.css</code> is another bundle of the same shape.</p>
|
||||
|
||||
<h2>Dark/light theme toggle</h2>
|
||||
|
||||
<p>Every color rule in <code>main.sass</code> reads a CSS custom property (<code>var(--bg)</code>, <code>var(--accent)</code>, etc.) instead of a Sass variable directly. The Sass variables only seed the initial <code>:root</code> values at compile time; a <code>:root[data-theme="light"]</code> block overrides all seven using <code>-light</code>-suffixed variables from the same <code>_colors.sass</code> files (<code>$bg-light</code>, <code>$surface-light</code>, etc.) — same override mechanism, same files, a second palette.</p>
|
||||
<p>Every color rule in <code>main.sass</code> reads a CSS custom property (<code>var(--bg)</code>, <code>var(--accent)</code>, etc.) instead of a Sass variable directly. The Sass variables only seed the initial <code>:root</code> values at compile time; a <code>:root[data-theme="light"]</code> block overrides all seven using the <code>-light</code>-suffixed variables from the same <code>_colors.sass</code> (<code>$bg-light</code>, <code>$surface-light</code>, etc.) — one file, a second palette.</p>
|
||||
|
||||
<p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code><html></code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code><head></code> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.</p>
|
||||
|
||||
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p>
|
||||
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in your <code>_colors.sass</code> and clear the cache.</p>
|
||||
|
||||
<h2>Syntax-highlighted code blocks follow the same toggle</h2>
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Typed text pages{% endblock %}
|
||||
|
||||
{% block description %}Sidecar-less pages whose directory name ends in an extension — /css/main.css, /robots.txt, /humans.txt — rendered by Twig as text, not HTML.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Typed text pages</h1>
|
||||
|
||||
<p>A normal page bundle serves HTML. A <strong>typed text page</strong> serves CSS, plain text, or XML instead. You get one whenever a page bundle's <strong>directory name ends in a known extension</strong> and it has <strong>no <code>index.php</code> sidecar</strong>. The framework ships three, in <code>novaconium/pages/</code>:</p>
|
||||
|
||||
<pre><code class="nohighlight">novaconium/pages/
|
||||
robots.txt/index.twig → GET /robots.txt (text/plain)
|
||||
humans.txt/index.twig → GET /humans.txt (text/plain)
|
||||
css/main.css/index.twig → GET /css/main.css (text/css)</code></pre>
|
||||
|
||||
<p>Override any of them, or add your own (<code>/print.css</code>, <code>/ads.txt</code>, …), by placing a bundle at the same path under <code>App/pages/</code> — the usual App-over-novaconium page override.</p>
|
||||
|
||||
<p>The recognised extensions and their <code>Content-Type</code>s live in one place, <code>App\MediaType::MAP</code>:</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Extension</th><th>Content-Type</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>.txt</code></td><td><code>text/plain; charset=utf-8</code></td></tr>
|
||||
<tr><td><code>.css</code></td><td><code>text/css; charset=utf-8</code></td></tr>
|
||||
<tr><td><code>.xml</code></td><td><code>application/xml; charset=utf-8</code></td></tr>
|
||||
<tr><td><code>.json</code></td><td><code>application/json</code></td></tr>
|
||||
<tr><td><code>.js</code></td><td><code>text/javascript; charset=utf-8</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>What's different from a normal page</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>No layout.</strong> <code>index.twig</code> is rendered exactly as written — it must not <code>{% verbatim %}{% extends %}{% endverbatim %}</code> anything. Its whole output is the response body.</li>
|
||||
<li><strong>No autoescaping.</strong> <code>{% verbatim %}{{ site_name }}{% endverbatim %}</code> in a <code>.txt</code>/<code>.css</code> file is emitted raw, not HTML-entity-escaped. (Twig decides this per template from the bundle's extension; use <code>|e</code> explicitly if you ever need escaping.)</li>
|
||||
<li><strong>Right <code>Content-Type</code>.</strong> <code>Renderer</code> sends the type from the table above instead of <code>text/html</code>.</li>
|
||||
<li><strong>Flat cache file.</strong> Because the bundle has no sidecar, the rendered output is written to the static cache — but as <code>public/cache/css/main.css</code>, not <code>public/cache/css/main.css/index.html</code>. A rewrite in <code>public/.htaccess</code> then serves that file straight from Apache on every later request, and Apache labels it with the real extension's MIME type for free. So PHP runs once per cache clear, not per request.</li>
|
||||
<li><strong>Not indexed.</strong> <code>ContentIndexer</code> skips these — they never show up in <code>/search</code> or <code>/sitemap.xml</code>.</li>
|
||||
</ul>
|
||||
|
||||
<p>Invalidation is the same as any cached page: <code>php novaconium/bin/clear-cache.php</code> (or delete the one file). In <code>debug</code> mode nothing is cached and every request re-renders.</p>
|
||||
|
||||
<p><strong>Keep two lists in sync:</strong> Apache can't call into PHP, so <code>public/.htaccess</code> repeats the extension list from <code>App\MediaType::MAP</code>. Add an extension in one place, add it in the other.</p>
|
||||
|
||||
<h2>Example: robots.txt</h2>
|
||||
|
||||
<p><code>novaconium/pages/robots.txt/index.twig</code> (copy to <code>App/pages/robots.txt/index.twig</code> to change it):</p>
|
||||
|
||||
<pre><code class="nohighlight">User-agent: *
|
||||
Allow: /</code></pre>
|
||||
|
||||
<p>Twig still runs, so you can use globals (<code>{% verbatim %}{{ site_name }}{% endverbatim %}</code>) or conditionals. A relative <code>Sitemap:</code> line isn't valid in robots.txt, so add an absolute one only once you have a site URL to hardcode.</p>
|
||||
|
||||
<h2>Example: CSS (the sass() function)</h2>
|
||||
|
||||
<p>The site stylesheet is a typed text page whose body is compiled Sass. The framework bundle <code>novaconium/pages/css/main.css/</code> holds:</p>
|
||||
|
||||
<pre><code class="nohighlight">index.twig {% verbatim %}{{- sass('main.sass') -}}{% endverbatim %}
|
||||
main.sass the rules
|
||||
_colors.sass the palette (@use 'colors' resolves to this sibling)</code></pre>
|
||||
|
||||
<p><code>sass()</code> (from <code>App\SassExtension</code>, backed by <code>Lib\SassCompiler</code>) resolves the entry file through the overlay (<code>App/pages/</code> first) and shells out to the Dart Sass CLI, passing that file's own directory as the sole <code>--load-path</code>. It picks the bundle from the request path, so the same one-line template keeps working if you rename the directory to <code>default.css/</code> (with <code>default.sass</code> inside). Customise styling by copying the whole <code>css/main.css/</code> bundle to <code>App/pages/css/main.css/</code>; add a second stylesheet (<code>/print.css</code>) by copying the pattern. If <code>sass</code> reports an error the request 500s and nothing is cached; fix the Sass and reload. See <a href="/admin/docs/styling">Styling</a>.</p>
|
||||
|
||||
<h2>Not this mechanism</h2>
|
||||
|
||||
<p><code>/sitemap.xml</code> and <code>/blog/feed</code> also sit at extensioned URLs, but they're <code>index.php</code> sidecars that build a string and <code>return Response::xml(...)</code> — the sidecar path (<a href="/admin/docs/sidecars">Sidecars</a>), not this one. A sidecar always wins; typed-text rendering only applies to a bundle with no <code>index.php</code>.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
// The default site color palette. `main.sass` does `@use 'colors' as *` and
|
||||
// seeds the CSS custom properties in :root from these. To reskin the site,
|
||||
// copy the whole css/main.css/ bundle to App/pages/css/main.css/ and edit
|
||||
// these values there, then run `php novaconium/bin/clear-cache.php`
|
||||
// (see /admin/docs/styling).
|
||||
$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.
|
||||
$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
|
||||
@@ -0,0 +1 @@
|
||||
{{- sass('main.sass') -}}
|
||||
@@ -1,13 +1,23 @@
|
||||
// Colors resolve via the Sass load path: App/sass first, novaconium/sass
|
||||
// as fallback (see novaconium/sass/defaults/_colors.sass and
|
||||
// App/sass/_colors.sass). `as *` imports the variables unqualified, so
|
||||
// they're used below only to seed the CSS custom properties in :root —
|
||||
// every rule after that reads the color via var(--bg), var(--accent),
|
||||
// etc., not the Sass variable directly. That indirection is what makes
|
||||
// the dark/light theme toggle possible: novaconium/pages/_layout/theme-
|
||||
// init.twig flips a data-theme attribute on <html> at runtime (something
|
||||
// Sass, which only runs at compile time, can't do on its own), and the
|
||||
// second block below swaps every custom property in response.
|
||||
// Framework default site stylesheet. This bundle is a "typed text page":
|
||||
// /css/main.css has no index.php sidecar, so index.twig
|
||||
// ({{ sass('main.sass') }}) renders this file through App\SassExtension, and
|
||||
// the result is frozen into public/cache/css/main.css and served straight
|
||||
// from Apache thereafter (run `php novaconium/bin/clear-cache.php` after
|
||||
// editing). See /admin/docs/text-pages and /admin/docs/styling.
|
||||
//
|
||||
// A project customises styling by copying this whole bundle to
|
||||
// App/pages/css/main.css/ (the usual App-over-novaconium page override) and
|
||||
// editing its copy.
|
||||
//
|
||||
// `@use 'colors'` resolves to the sibling _colors.sass — the winning
|
||||
// bundle's own directory is the sole --load-path. `as *` imports the palette variables unqualified;
|
||||
// they're used below only to seed the CSS custom properties in :root, and
|
||||
// every rule after that reads the color via var(--bg), var(--accent), etc.,
|
||||
// never the Sass variable directly. That indirection is what makes the
|
||||
// dark/light theme toggle possible: novaconium/pages/_layout/theme-init.twig
|
||||
// flips a data-theme attribute on <html> at runtime (something Sass, which
|
||||
// only runs at compile time, can't do on its own), and the second block
|
||||
// below swaps every custom property in response.
|
||||
@use 'colors' as *
|
||||
|
||||
:root
|
||||
@@ -0,0 +1,7 @@
|
||||
/* TEAM */
|
||||
Name: Your Name Here
|
||||
Site: {{ site_name }}
|
||||
|
||||
/* SITE */
|
||||
Standards: HTML5, CSS
|
||||
Software: Novaconium (PHP + Twig)
|
||||
@@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Uncomment and set an absolute URL once you have one (a relative path is
|
||||
# not valid here):
|
||||
# Sitemap: https://example.com/sitemap.xml
|
||||
@@ -1,27 +0,0 @@
|
||||
// Framework default color palette. Used only when a project doesn't
|
||||
// supply App/sass/_colors.sass. novaconium/sass/main.sass does
|
||||
// `@use 'colors' as *` — its own directory (novaconium/sass/) has no
|
||||
// _colors.sass of its own on purpose, so that bare `@use` falls through
|
||||
// to the Sass load path, compiled with App/sass first and this
|
||||
// directory (novaconium/sass/defaults/) as the fallback:
|
||||
// sass --load-path=App/sass --load-path=novaconium/sass/defaults \
|
||||
// novaconium/sass/main.sass public/css/main.css
|
||||
// Not meant to be edited per-project; override by creating
|
||||
// App/sass/_colors.sass with the same variable names instead.
|
||||
$bg: #16181d
|
||||
$surface: #1e2129
|
||||
$text-color: #e8eaed
|
||||
$muted-color: #9099a6
|
||||
$border-color: #2b2f3a
|
||||
$accent: #5b8cff
|
||||
$accent-hover: #7ea1ff
|
||||
|
||||
// Light theme counterpart, used when a visitor toggles it. Same naming
|
||||
// convention as the dark set above: -light suffix, same seven names.
|
||||
$bg-light: #ffffff
|
||||
$surface-light: #eef1f6
|
||||
$text-color-light: #16181d
|
||||
$muted-color-light: #5a6270
|
||||
$border-color-light: #d7dce3
|
||||
$accent-light: #3b6fe0
|
||||
$accent-hover-light: #2f5bc0
|
||||
@@ -3,9 +3,14 @@
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* Static HTML cache mirroring the public URL tree, e.g. /blog/hello
|
||||
* Static cache mirroring the public URL tree, e.g. /blog/hello
|
||||
* -> public/cache/blog/hello/index.html. Apache's .htaccess serves these
|
||||
* directly, bypassing PHP entirely, when present.
|
||||
*
|
||||
* A typed text page (App\MediaType — /css/main.css, /robots.txt, …) is
|
||||
* stored as a flat file at its own path instead (public/cache/css/main.css),
|
||||
* so Apache serves it with the real extension's MIME type rather than
|
||||
* text/html. The matching rewrite in .htaccess is what serves it.
|
||||
*/
|
||||
final class Cache
|
||||
{
|
||||
@@ -16,6 +21,11 @@ final class Cache
|
||||
public function path(string $requestUri): string
|
||||
{
|
||||
$path = trim(strtok($requestUri, '?') ?: '/', '/');
|
||||
|
||||
if ($path !== '' && MediaType::guess($path) !== null) {
|
||||
return rtrim($this->cacheDir, '/') . '/' . $path;
|
||||
}
|
||||
|
||||
$suffix = $path === '' ? '' : '/' . $path;
|
||||
|
||||
return rtrim($this->cacheDir, '/') . $suffix . '/index.html';
|
||||
|
||||
@@ -107,7 +107,7 @@ final class ContentIndexer
|
||||
$sourceCount = 0;
|
||||
|
||||
foreach ($routes as $dir) {
|
||||
if (in_array($dir, $config['draft_routes'], true)) {
|
||||
if (!self::isCrawlable($dir, $config['draft_routes'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ final class ContentIndexer
|
||||
$newest = 0;
|
||||
$count = 0;
|
||||
foreach (Overlay::listPageDirs($config['pages_dirs']) as $dir) {
|
||||
if (in_array($dir, $config['draft_routes'], true)) {
|
||||
if (!self::isCrawlable($dir, $config['draft_routes'])) {
|
||||
continue;
|
||||
}
|
||||
$count++;
|
||||
@@ -197,6 +197,21 @@ final class ContentIndexer
|
||||
return $newest > (int) $meta['newest_source_mtime'] || $count !== (int) $meta['source_count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $dir is a page the crawler should render and index. Excludes
|
||||
* draft routes (admin-only, see /admin/docs/drafts) and typed text
|
||||
* pages (App\MediaType — /css/main.css, /robots.txt, /sitemap.xml, …): those
|
||||
* emit raw CSS/text/XML, never something a search result or sitemap
|
||||
* entry should point at. Applied at *both* enumeration sites so
|
||||
* reindex() and isStale() agree on the page count.
|
||||
*
|
||||
* @param string[] $draftRoutes
|
||||
*/
|
||||
private static function isCrawlable(string $dir, array $draftRoutes): bool
|
||||
{
|
||||
return !in_array($dir, $draftRoutes, true) && MediaType::guess($dir) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $pagesDirs
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* Maps a URL/path file extension to a response Content-Type.
|
||||
*
|
||||
* This is what makes a "typed text page" work: a sidecar-less page bundle
|
||||
* whose directory name ends in one of these extensions (e.g.
|
||||
* novaconium/pages/robots.txt/, novaconium/pages/css/main.css/, or a
|
||||
* project's override under App/pages/) is rendered by Twig as plain text — no HTML
|
||||
* layout, no autoescaping — and both Renderer (first render) and Apache
|
||||
* (every cache hit thereafter) serve it with the type below instead of
|
||||
* text/html. See Renderer::render(), Cache::path(), and /admin/docs/text-pages.
|
||||
*
|
||||
* The extension list is mirrored by a rewrite rule in public/.htaccess
|
||||
* (Apache can't call into PHP to ask) — keep the two in sync.
|
||||
*/
|
||||
final class MediaType
|
||||
{
|
||||
/** extension (lowercase, no dot) => full Content-Type header value */
|
||||
public const MAP = [
|
||||
'txt' => 'text/plain; charset=utf-8',
|
||||
'css' => 'text/css; charset=utf-8',
|
||||
'xml' => 'application/xml; charset=utf-8',
|
||||
'json' => 'application/json',
|
||||
'js' => 'text/javascript; charset=utf-8',
|
||||
];
|
||||
|
||||
/**
|
||||
* The Content-Type for $path's extension, or null when the extension
|
||||
* isn't a recognised text-page type (the normal text/html case).
|
||||
*/
|
||||
public static function guess(string $path): ?string
|
||||
{
|
||||
return self::MAP[strtolower(pathinfo($path, PATHINFO_EXTENSION))] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -38,14 +38,27 @@ final class Renderer
|
||||
// 'debug' also drives PHP error display (see bootstrap.php). When on,
|
||||
// it enables Twig's debug mode too: {{ dump() }} in templates plus
|
||||
// auto_reload so template edits take effect without clearing cache.
|
||||
//
|
||||
// autoescape: HTML for normal pages, but *off* for a typed text page
|
||||
// (a bundle whose directory segment ends in a MediaType extension,
|
||||
// e.g. css/main.css/index.twig, robots.txt/index.twig) — those render
|
||||
// raw text/CSS/XML, not HTML, so entity-escaping their {{ }} output
|
||||
// would corrupt it. Twig binds this per template at compile time and
|
||||
// 'cache' is false here, so it's re-evaluated every render.
|
||||
$typedExtensions = implode('|', array_keys(MediaType::MAP));
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => false,
|
||||
'debug' => $debug,
|
||||
'auto_reload' => $debug,
|
||||
'autoescape' => static fn (?string $name): string|false =>
|
||||
$name !== null && preg_match('#(?:^|/)[^/]+\.(?:' . $typedExtensions . ')/#', $name)
|
||||
? false
|
||||
: 'html',
|
||||
]);
|
||||
if ($debug) {
|
||||
$this->twig->addExtension(new DebugExtension());
|
||||
}
|
||||
$this->twig->addExtension(new SassExtension($this->pagesDirs));
|
||||
$this->twig->addGlobal('admin_auth_enabled', $adminAuthEnabled);
|
||||
$this->twig->addGlobal('matomo_url', $matomoUrl);
|
||||
$this->twig->addGlobal('matomo_site_id', $matomoSiteId);
|
||||
@@ -84,15 +97,21 @@ final class Renderer
|
||||
return;
|
||||
}
|
||||
|
||||
// A typed text page (css/main.css, robots.txt, …) renders raw — no HTML
|
||||
// layout wrap — and is served with its extension's Content-Type
|
||||
// instead of text/html, both here and (on later requests) straight
|
||||
// from public/cache/ by Apache. See App\MediaType and .htaccess.
|
||||
$mime = MediaType::guess($route->dir);
|
||||
|
||||
$data = array_merge((array) $result, ['params' => $route->params]);
|
||||
$data['layout'] = $this->relativeLayoutPath($route->dir);
|
||||
$data['layout'] = $mime === null ? $this->relativeLayoutPath($route->dir) : null;
|
||||
$data['request_path'] = parse_url($requestUri, PHP_URL_PATH) ?: '/';
|
||||
|
||||
$templateName = $this->withFile($route->dir, 'index.twig');
|
||||
$html = $this->twig->render($templateName, $data);
|
||||
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Content-Type: ' . ($mime ?? 'text/html; charset=utf-8'));
|
||||
echo $html;
|
||||
|
||||
// In debug mode a sidecar-less page is re-rendered on every request
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Lib\SassCompiler;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Adds a single Twig function, `sass()`, used by a CSS "typed text page"
|
||||
* (a sidecar-less <name>.css/ bundle whose index.twig is just
|
||||
* `{{- sass('<name>.sass') -}}`). It compiles the bundle's own Sass sources
|
||||
* on the spot; Renderer then caches the rendered stylesheet to public/cache/
|
||||
* like any other sidecar-less page, so Apache serves it directly on every
|
||||
* later request. See /admin/docs/text-pages and /admin/docs/styling.
|
||||
*
|
||||
* The framework ships the stylesheet bundle at novaconium/pages/css/main.css/;
|
||||
* a project overrides it by copying the bundle to App/pages/css/main.css/
|
||||
* (the usual App-over-novaconium page override). `sass()` resolves the entry
|
||||
* file through the same overlay (App first) and compiles it with its own
|
||||
* directory as the sole --load-path, so `@use 'colors'` resolves to the
|
||||
* sibling `_colors.sass` in whichever copy of the bundle won.
|
||||
*
|
||||
* The bundle directory is taken from the request path (the context's
|
||||
* `request_path`, e.g. `/css/main.css` → css/main.css/), so the same
|
||||
* one-line template keeps working after renaming or relocating the bundle.
|
||||
*/
|
||||
final class SassExtension extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* @param string[] $pagesDirs ordered page roots (App/pages first)
|
||||
*/
|
||||
public function __construct(private readonly array $pagesDirs)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction(
|
||||
'sass',
|
||||
$this->compile(...),
|
||||
['needs_context' => true, 'is_safe' => ['html']],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
*/
|
||||
public function compile(array $context, string $entryFile): string
|
||||
{
|
||||
$bundle = trim((string) ($context['request_path'] ?? ''), '/');
|
||||
|
||||
$entry = Overlay::findFile($this->pagesDirs, $bundle . '/' . $entryFile);
|
||||
if ($entry === null) {
|
||||
throw new \RuntimeException("sass(): $entryFile not found in any /$bundle bundle");
|
||||
}
|
||||
|
||||
return (new SassCompiler())->compileToString($entry, [dirname($entry)]);
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -8,7 +8,14 @@ RewriteRule ^(.+)/$ /$1 [R=301,L]
|
||||
RewriteCond %{DOCUMENT_ROOT}/cache/$1/index.html -f
|
||||
RewriteRule ^(.*)$ /cache/$1/index.html [L]
|
||||
|
||||
# 3) Serve real files/directories as-is (css, cache assets, etc.).
|
||||
# 2b) Same, for a cached "typed text page" (/css/main.css, /robots.txt, …).
|
||||
# It's stored as a flat file, not <dir>/index.html, so Apache labels it
|
||||
# with the real extension's MIME type instead of text/html. Extension
|
||||
# list mirrors App\MediaType::MAP.
|
||||
RewriteCond %{DOCUMENT_ROOT}/cache/$1 -f
|
||||
RewriteRule ^(.+\.(?:txt|css|xml|json|js))$ /cache/$1 [L]
|
||||
|
||||
# 3) Serve real files/directories as-is (cache assets, favicon, etc.).
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
// Dev-only router for `php -S`, mimicking the .htaccess rules for local testing.
|
||||
// Not used in production (Apache reads .htaccess directly).
|
||||
|
||||
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
||||
|
||||
if ($uri !== '/' && str_ends_with($uri, '/')) {
|
||||
// Preserve the query string across the canonical redirect — Apache's
|
||||
// .htaccess does this automatically, so dev must too or post/redirect
|
||||
// flows like /contact/?sent=1 lose their flags under `php -S`.
|
||||
$query = parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY);
|
||||
header('Location: ' . rtrim($uri, '/') . ($query !== null ? '?' . $query : ''), true, 301);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = __DIR__ . $uri;
|
||||
if ($uri !== '/' && (is_file($file) || is_dir($file))) {
|
||||
return false; // let the built-in server handle real files/dirs (css, cache)
|
||||
}
|
||||
|
||||
// Mirror .htaccess's "serve the pre-rendered static file, bypassing PHP"
|
||||
// rule — but skip it when debug is on, so sidecar-less pages render fresh
|
||||
// on every request (Renderer also stops writing the cache in that mode).
|
||||
$novaconiumConfig = require __DIR__ . '/../novaconium/config.php';
|
||||
$appConfigFile = __DIR__ . '/../App/config.php';
|
||||
$config = is_file($appConfigFile)
|
||||
? array_merge($novaconiumConfig, require $appConfigFile)
|
||||
: $novaconiumConfig;
|
||||
$debug = (bool) $config['debug'];
|
||||
|
||||
$cacheFile = __DIR__ . '/cache' . $uri . '/index.html';
|
||||
if (!$debug && is_file($cacheFile)) {
|
||||
readfile($cacheFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
require __DIR__ . '/index.php';
|
||||
Reference in New Issue
Block a user