Search by

lexislav / station0

lexislav

Grand zero flat file CMS — core library.

Package info

github.com/lexislav/station0

pkg:composer/lexislav/station0

Statistics

Installs: 75

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 1

v0.10.3 2026-10-27 08:55 UTC

README

Lightweight flat-file CMS built on PHP 8.4+. Pages are stored as plain text files on disk — no database migrations, fully Git-friendly. SQLite is used only for authentication.

Stack

  • Framework: Slim 4
  • Templates: Twig 3
  • Markdown: League/CommonMark (GFM + front matter)
  • Auth: Delight\Auth (SQLite)
  • DI: PHP-DI 7

Requirements

  • PHP ≥ 8.4.1 (with pdo_sqlite extension)
  • Composer

Installation

composer create-project lexislav/get-station0 mysite
cd mysite
php -S localhost:8080 -t public public/index.php

On first visit to http://localhost:8080/admin, a setup form lets you create the first admin account in the browser.

Apache deployment

If you're serving via Apache (Homebrew httpd, XAMPP, system Apache, …):

  1. Point your vhost DocumentRoot at the project's public/ directory.
  2. Allow .htaccess overrides for that directory and ensure mod_rewrite is loaded.

Example vhost (Homebrew httpd at /opt/homebrew/etc/httpd/extra/httpd-vhosts.conf):

<VirtualHost *:80>
    ServerName mysite.local
    DocumentRoot "/path/to/mysite/public"

    <Directory "/path/to/mysite/public">
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

In httpd.conf make sure this line is uncommented:

LoadModule rewrite_module lib/httpd/modules/mod_rewrite.so

Restart: brew services restart httpd.

Symptom of misconfiguration: Apache's default "Not Found — The requested URL was not found on this server." page on /admin (or any sub-path) means the rewrite isn't being applied — check AllowOverride and mod_rewrite.

Security headers: every response under the admin path already carries X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', Referrer-Policy: same-origin and X-Content-Type-Options: nosniff, sent by station0 itself. Public pages get none of these from station0. Set the ones you want in the vhost (e.g. Header always set X-Content-Type-Options "nosniff" with mod_headers), because only you know whether the site is meant to be framed or embedded elsewhere.

This package

lexislav/station0 is the core library — it contains the PHP classes, admin templates, and CLI binary. It is installed into vendor/ by the skeleton project.

For a ready-to-use project skeleton, see lexislav/get-station0.

Page visibility

Publication and listing are separate (station0 ≥ 0.9, full reference in docs/visibility.md):

Status: published          # draft | published | archived (legacy: Published: true|false)
PublishAt: 2026-06-01 09:00
ExpireAt: 2026-09-30 23:59 # archived / expired → 410 Gone
Listing: nav-hidden        # listed | nav-hidden | unlisted
Cascade: false             # when not live, keep the subpages live
---

A page is live only when its parents are (homepage excluded). Menus use nav_pages() / top_level_pages(), listings child_pages(). Signed-in editors preview non-live pages with a "not public" bar. Set timezone in site/config.php; dates are stored in it.

Media & uploads

Uploaded assets are stored next to the page they belong to:

site/content/pages/about/
  page.txt
  team-photo.jpg          ← uploaded asset

Filenames are slugified on upload (Team Photo.JPG → team-photo.jpg) and collisions resolve as team-photo-2.jpg, -3, … Allowed types: jpg, png, gif, webp, svg (max 8 MB). Pages do not accept .txt uploads, because a page's content file is the .txt in its folder; use .md or .pdf instead.

SVG uploads are sanitized: scripts, on* handlers, <foreignObject> and other embeds, links that are not #fragment or a raster data:image/…, and external url() / @import are removed before the file is stored. A file that is not a valid SVG, or declares entities in a DOCTYPE, is rejected. /media serves SVG inline under a sandbox CSP. To clean SVGs stored before this (or copied in by hand), run php vendor/bin/console assets:sanitize-svg [--dry-run].

In stored content, asset references are page-relative — bare filenames:

- type: gallery
  images:
    - src: team-photo.jpg
      alt: The team

At render time, the bare filename is resolved against the current page (/media/about/team-photo.jpg). Move or rename the page directory and the references still work — they were never absolute.

To migrate any older absolute /media/{path}/file.ext references in stored pages to the new bare-filename form:

php vendor/bin/console assets:relink           # apply
php vendor/bin/console assets:relink --dry-run # preview

Public asset URLs follow the page URL: /media/{page-path}/{filename}. The root page uses /media/~/{filename}.

Thumbnails

Resize images in templates with the thumb filter. It takes the resolved /media/... URL and returns a URL to a smaller copy; the copy is generated with GD on first request and cached in writable/cache/thumbs/.

<img src="{{ image.src|thumb(600) }}"                         {# max width 600 px #}
     srcset="{{ image.src|thumb_srcset([400, 800, 1200]) }}"
     sizes="(min-width: 900px) 33vw, 100vw" loading="lazy" alt="">
{{ hero|thumb(0, 400) }}                                       {# max height 400 px #}
{{ photo|thumb(300, 300) }}                                    {# fit inside the box #}
{{ photo|thumb(300, 300, 'cover') }}                           {# crop to fill the box #}
{{ photo|thumb(600, format='webp') }}                          {# convert to WebP #}
  • Images are never upscaled. External URLs, SVG, GIF, documents, missing files and sizes that are not smaller than the original come back unchanged, so the filter is safe to apply everywhere. Without ext-gd it returns the original.
  • JPEG, PNG (with transparency) and WebP keep their format. EXIF rotation of phone photos is applied (needs ext-exif); EXIF metadata such as GPS is dropped from the thumbnail.
  • URLs are signed (/thumb/600x0/{signature}/{page-path}/{file}) so nobody can request arbitrary sizes. The signing key is created in writable/thumbs.key (override with 'thumbs' => ['secret' => ...] in site/config.php). Replacing the source image changes the URL.
  • Markdown images in text blocks are thumbnailed automatically: src is capped at 1200 px with a 2x candidate in srcset, plus loading="lazy".
  • The admin editor shows 160 px previews instead of the originals.

Optional settings in site/config.php:

'thumbs' => [
    'format'   => 'webp', // convert all thumbnails to WebP ('original' per call keeps the format)
    'markdown' => 1200,   // max width of markdown images; 0 = leave them alone
    'static'   => true,   // write thumbnails to public/thumb/ so the web server serves them
    'secret'   => '…',    // signing key; default: generated into writable/thumbs.key
],

With static, each thumbnail is written to the path of its own URL under public/thumb/, so after the first request the web server sends the file without starting PHP (the stock .htaccess, nginx try_files $uri … and php -S … public/index.php all serve existing files first). A copy stays reachable after its source image is deleted until thumbs:clear.

php vendor/bin/console thumbs:warm   # pre-generate thumbnails of all published pages (server must run)
php vendor/bin/console thumbs:clear  # delete generated thumbnails

Per-template block restrictions

A template can restrict which block types its pages may use, and pre-seed a new page with starter blocks. Drop an optional manifest beside the template file:

site/templates/gallery.twig         ← the template
site/templates/gallery.blocks.yaml  ← its block manifest (optional)
# site/templates/gallery.blocks.yaml
allowedBlocks:        # the "+ Add block" palette shows only these, in order
  - text
  - gallery
defaultBlocks:        # a NEW page of this template opens with these inserted
  - gallery

Both keys are optional and compose:

  • allowedBlocks — restricts the editor palette. No declaration ⇒ all blocks are available (the default).
  • defaultBlocks — pre-inserts blocks into new pages only, at their schema default field values. No declaration ⇒ a single empty text block. Pre-seeded blocks must be a subset of the allow-list.

Unknown block names are ignored, and an allow-list that filters down to nothing falls back to the full palette. This mirrors how a page's AllowedChildTemplates front-matter field restricts which child templates a page accepts — here it is the block types inside a page that are restricted, keyed by template.

Select options from a collection

A select field — in a block schema, in a list item, or in a collection schema — can take its options from a collection instead of a static list:

# site/templates/blocks/route/schema.yaml
label: Route
fields:
  from:
    type: select
    label: From
    options_from: collection:points   # one option per published item
    group_by: river                   # optional: item field → <optgroup>
    sort_by: -km                      # optional: item field, "-" = descending
    option_label: "{title} (km {km})" # optional: {title}, {slug}, {<field>}
    placeholder: "— choose a point —" # optional, defaults to "—"

The stored value is the item slug; resolve it in the block template with collection_item('points', block.from). Numeric sort values accept a decimal comma (318,5); items without the field sort last. If a stored slug no longer exists, the editor keeps it as a "(not found)" option instead of dropping it.

Letting the editor pick the collection

options_from: collections offers the items of every collection (or collections:banners,shared-blocks for just those, in that order), one <optgroup> per collection. The stored value is <collection>/<slug>:

{% set item = collection_item(block.ref) %}   {# one-arg form: "banners/summer-sale" #}
{% if item %}{{ render_collection_item(item) }}{% endif %}

{collection} is available in option_label / sort_by next to the item fields.

Select options from pages

Relations between pages ("article → related article") use options_from: pages:

related:
  type: list
  label: Related articles
  item:
    page:
      type: select
      label: Article
      options_from: pages:/blog         # descendants of /blog; plain "pages" = all
      template: article                 # optional: string or list of templates
      sort_by: -date
      option_label: "{title} ({path})"

Only live pages are offered (unlisted ones included). The stored value is the page's URL path; page(value) returns the page (or null if it is missing or not live):

{% for rel in fields.related %}
  {% set p = page(rel.page) %}
  {% if p %}<a href="{{ p.urlPath }}">{{ p.title }}</a> {{ page_fields(p).subtitle }}{% endif %}
{% endfor %}

Label/sort/group fields: title, slug, path, template, sort, date, parent, parent_title, plus the page's own fields. Renaming or moving a page does not rewrite references to it — the editor then shows the old path as "(not found)".

Static options also accept richer forms now — {value: label} maps and [{value, label, group}] entries — next to the plain string list.

Computed collection titles

A collection can build each item's title from its fields instead of having editors type it:

# site/content/collections/people/_collection.yaml
label: People
title: "{name} {surname}"            # shorthand: input readonly
fields:
  name:    { type: text, label: Name }
  surname: { type: text, label: Surname }
title:
  from: "{brand} – {model} ({year})" # any text around the placeholders is kept
  input: editable                    # readonly (default) | editable | hidden
  • Placeholders: schema fields, {slug} and {collection}. When a field is empty, the separator next to it (- – — , ; : | / ·) and empty () / [] are dropped. {brand} - {model} without a model gives Škoda, not Škoda -.
  • readonly: the title input fills itself while the editor types; a posted title is ignored. hidden: no title input, only a preview line.
  • editable: the title follows the fields until the editor types their own; that title is then kept even when the fields change. Clearing it, or the "Use built title" button, makes it follow the fields again.
  • The title is stored in the item's Title: on save (also when a site task saves the item), so item.title, lists, option_label and hooks work as before. The slug of a new item comes from the built title. If every referenced field is empty, the form refuses to save.
  • {field} is the stored value: a select gives its slug / path, an image its filename, a boolean true / false.
  • Existing items change on their next save, or all at once with php vendor/bin/console collections:retitle [name…] [--dry-run] (in editable mode only items without a title of their own).

Menu groups (admin menu tabs)

Collections and page subtrees can be grouped into their own tab in the admin menu. For a collection, it is one key in its _collection.yaml:

# site/content/collections/products/_collection.yaml
label: Products
group: Shop

Every collection with group: Shop moves out of the generic Collections tab into a Shop tab. For pages, put Group: into the front matter of the page that roots a section — the page and everything below it join the group:

# site/content/pages/downloads/page.txt
Title: Downloads
Group: Downloads
---

The Downloads tab then shows that subtree (drag & drop works as in the Structure tree; a stream shows all its records). The pages stay in the Structure tree too, marked with a group badge; grouped streams leave the Streams tab. A nested page with its own Group: starts a separate group. Pages and collections can share a group — Group: Shop on /shop and group: Shop on the products collection make one tab with both. A group of a single collection links straight to its items.

When a group needs more settings, declare it centrally (optional):

# site/content/_groups.yaml   (site/content/collections/_groups.yaml still works)
shop:                 # id — `group: shop` and `group: Shop` both match
  label: E-shop       # overrides the inline label
  icon: "🛒"          # text/emoji, or inline <svg …> markup
  roles: [editor]     # who sees the tab; admins always do; omit = everyone

Tabs follow the order of _groups.yaml, then inline-only groups alphabetically. A central group without any member produces no tab. For collections, roles is enforced server-side too: the collection's list, forms, saves and uploads return 403 for users without the role. For pages, roles only hides the tab; the pages remain editable through the Structure tree.

Members-only access

Gate the whole public site (or parts of it) behind a sign-in — site/config.php:

'access' => [
    'mode'      => 'members',       // 'public' (default) | 'members'
    'public'    => ['/'],           // always-public paths; `*` = prefix
    'redirect'  => '/',             // where anonymous visitors are sent
    'media'     => true,            // also gate /media + /thumb
    'loginPath' => '/login',        // member sign-in: password, e-mail link, new password, sign-out
],

Access: public / Access: members in a page's front matter overrides the mode for that page and its sub-pages — Access: members on /blog gates just the blog on a public site, its media included. Anonymous visitors don't see gated pages in top_level_pages(), nav_pages(), child_pages() or page(); pass includeGated=true for a public teaser list. With thumbs.static: true, thumbnails of gated media are kept out of public/thumb/ and served through PHP; run console thumbs:clear after gating a section that already has static thumbnails. Templates get a visitor global ({% if visitor.authenticated %}…{{ visitor.label }}) and an access global with the sign-in paths. Member accounts have the member role (console user:create <user> <email> member) and cannot enter the admin. Provide site/templates/login.twig for a sign-in page in the site's design.

Site actions

site/actions/<name>.php adds a form handler or small endpoint to the site:

return [
    'path'    => '/newsletter',
    'handler' => function (Station0\Service\ActionContext $ctx) {
        $ctx->state()->flash('thanks', true);   // {{ action_state('newsletter').thanks }}
        return $ctx->back();
    },
];

Forms post with the csrf fields. A handler can grant a time-limited guest pass ($ctx->visitor()->grantPass(...)), e.g. after its own verification.

Plugins (experimental)

Optional features are enabled per site in site/config.php. A plugin that is not listed is never loaded.

'plugins' => [
    'redirects' => ['log404' => true],          // bundled with station0
    \Acme\Station0Sitemap\Plugin::class => [],  // from a Composer package
],

A plugin can add routes, admin pages and menu tabs, middleware, content event listeners, 404 handling, Twig functions, console commands, SQLite tables, and panels and badges on pages and collection items. See docs/plugins.md.

Redirects ('redirects' => [])

The bundled redirect manager adds a 301 automatically when a page's slug changes or the page moves, covering its whole subtree. It also answers exact, pattern (/blog/{year}/{slug}, /docs/{path*}) and regex rules and 410 Gone from site/content/_redirects.yaml, and logs 404s. The admin gets a Redirects tab, a 404 log, and a panel on each page listing the old URLs that lead to it. Rules are consulted only on the 404 path, so pages that exist pay nothing. See docs/redirects.md.

CLI (via skeleton)

php vendor/bin/console user:create <username> <email> [role]   # role: admin | editor | member
php vendor/bin/console user:reset-password <email>
php vendor/bin/console cache:clear
php vendor/bin/console assets:relink [--dry-run]
php vendor/bin/console assets:sanitize-svg [--dry-run]   # clean stored SVGs
php vendor/bin/console thumbs:warm [baseUrl]      # pre-generate thumbnails (server must run)
php vendor/bin/console thumbs:clear
php vendor/bin/console collections:retitle [name…] [--dry-run]   # recompute computed titles
php vendor/bin/console task:list                  # site tasks (site/tasks/)
php vendor/bin/console task:run <name> [--param=value]
php vendor/bin/console plugins:list               # enabled plugins, their commands and migrations
php vendor/bin/console redirects:test <url>       # redirects plugin: also list|add|import|compile|404

Package structure

admin/templates/   Admin Twig templates
bin/console        CLI binary (exposed via composer bin)
config/roles.php   Role definitions
src/               PHP source (namespace Station0\)
src/Plugin/        Plugin API and bundled plugins
docs/              Reference docs (visibility, plugins, redirects)

License

MIT