A lightweight, database-free CMS engine for PHP websites. Content is stored as plain Markdown files on disk, and editing happens from the live site with a draft-first workflow: a logged-in editor browses the pages exactly as visitors see them, clicks an editable fragment, saves a draft, previews it, then publishes when ready. Anonymous visitors are served plain rendered HTML with zero editing overhead.
Pagecore is designed for small, single-editor sites (the bundled configuration targets a Polish-language site) where a full CMS such as WordPress would be overkill: no database, no admin dashboard, no build step — just PHP, Markdown files, and a folder of uploads.
| Perspective | What Pagecore gives you |
|---|---|
| Site visitor | A normal, fast PHP website. The CMS is invisible: no scripts, no styles, no cookies related to editing are delivered to anonymous users. |
| Content editor | In-place editing of any marked fragment of the site, draft previews, one-click backup restore, plus simple management of dated posts (news, rulings, events…) — all from the browser, after logging in at a private URL. |
| Developer / integrator | A drop-in cms/ directory and a handful of template functions (cms_editable(), cms_posts(), cms_post(), cms_assets()) that add editing to any existing PHP site without touching its markup structure. |
- Log in at
/cms/login.php(the URL is deliberately not linked anywhere on the site). A toolbar appears confirming you are logged in, with a logout link. - Browse the site normally. Every editable fragment is outlined; hovering reveals an ✎ Edytuj (Edit) button. Empty fragments show a placeholder so they can still be found and filled in.
- Edit in a panel that opens over the page:
- Content is written in Markdown, including tables.
- A server-side preview shows exactly how the fragment will render.
- Save draft stores work under
content/.drafts/without changing what visitors see. Podgląd szkicu opens a standalone draft preview link. Opublikuj copies the current editor state to the live Markdown file. - Images and PDFs can be pasted or dragged straight into the editor — they are uploaded automatically and the correct Markdown snippet is inserted. PDFs render on the page as an embedded viewer with a download fallback link.
- The Media link opens
/cms/media.php, a searchable library of existing uploads. Editors can reuse an asset in the current editor, update alt text and captions stored in sidecar metadata files, preview the original file, and delete files that are not referenced by content. - The Content link opens
/cms/content.php, an inventory of configured pages, editable regions, posts, categories, missing Markdown files, and the editable navigation JSON. Ctrl+Ssaves a draft,Esccancels (with a confirmation if there are unsaved changes).
- Manage posts on listing pages (e.g. Orzeczenia / Wydarzenia /
Uchwały): a + Dodaj wpis (Add post) button creates a new post in that
category. Each post has a title, date, category and optional excerpt
(editable as post metadata), a featured-image drop area (JPEG/PNG only,
using the configured upload limit, uploaded and saved to the draft automatically), plus a
Markdown body edited the same way as any other fragment. Post URLs are generated automatically from the title
(with Polish-character transliteration, e.g. "Uchwała nr 5" →
uchwala-nr-5).
- Drafts — editor work is saved under
content/.drafts/and remains invisible to anonymous visitors until it is published. - Backups — before a fragment or post is overwritten, the previous version
is copied to
content/.backups/(the newest 20 versions are kept per fragment). Logged-in editors can restore a backup directly from the editor. - Search index —
search-index.jsonis regenerated from configured pages and all posts, powering the site's search page. - Sitemap —
sitemap.xmlis regenerated with all pages, posts and category listings. - Post excerpts — if no excerpt is provided, one is derived automatically from the first ~28 words of the body.
All content lives under content/ as Markdown files — the engine never
modifies PHP templates:
content/
├── pages/<page>/<region>.md # editable page fragments
├── posts/<slug>.md # dated posts with front matter
├── nav.json # optional editable navigation tree
├── .drafts/ # unpublished editor drafts
└── .backups/ # automatic per-save version history
uploads/
├── YYYY/MM/<name>-<random>.ext # editor-uploaded images and PDFs
└── YYYY/MM/<name>-<random>.ext.meta.json # optional alt text and caption sidecar files
Posts carry simple front matter:
---
title: Uchwała nr 5/2026
date: 2026-06-15
category: uchwaly
excerpt: Optional hand-written summary.
---
Body of the post in Markdown…Because everything is plain files, the whole site can be backed up, versioned
in git, migrated, or edited over FTP/SSH with any text editor. Deleting a post
is simply deleting its .md file.
Markdown is rendered server-side with Parsedown, with a few site-friendly extras:
- Raw HTML is always escaped and unsafe Markdown URL schemes are neutralized. The WordPress importer converts its maintained safe-tag allowlist to Markdown, turns supported embeds into ordinary links, and discards active markup.
pdf:/uploads/path/file.pdf "Label"on its own line becomes a labelled PDF download link.- Standalone images are wrapped in
<figure>for styling. - Tables get a
cms-tableclass hook. - Dates display in long form (15 June 2026). Set
date_monthsto twelve localized month names for a non-English site — in Polish, the genitive forms render 15 czerwca 2026.
Logged-in editors can open /cms/media.php directly or use Media library
inside an active edit panel. The library lists files under the configured
uploads_dir, searches by path, alt text and caption, and shows image
thumbnails or a PDF tile. In picker mode it inserts the same Markdown snippet
used by uploads, so existing assets can be reused without re-uploading.
Alt text and captions are stored beside the asset as
<filename>.meta.json. Deleting is intentionally conservative: the CMS scans
published pages, posts and drafts for the asset URL and refuses to delete a
file that is still referenced.
Logged-in editors can open /cms/content.php directly or use Content in
the editor toolbar. The screen shows:
- the installed Pagecore version;
- configured
search_pages, including whether each linked Markdown region exists; - editable regions found from Markdown files, configured page regions, and
cms_editable()calls scanned from PHP templates; - missing Markdown region files, with a Create file action for safe template-backed placeholders;
- all posts and configured post categories with counts;
- editable navigation JSON stored at
content/nav.jsonby default.
Navigation items use a small JSON tree:
[
{ "label": "Home", "url": "/", "children": [] },
{ "label": "News", "url": "/news/", "children": [] }
]Templates can render that file with cms_nav_items() or cms_nav_html().
If nav.json does not exist or is invalid, Pagecore falls back to the
configured search_pages.
Add require 'cms/engine.php'; to the site bootstrap, then:
| Function | Purpose |
|---|---|
cms_editable('page/region') |
Render a fragment; wraps it in an editable element only for logged-in editors. |
cms_posts('category') |
List posts (newest first) for a listing page — title, date, excerpt, URL. |
cms_post($slug) |
Fetch one post with rendered body for a post template. |
cms_post_url($slug) |
Build a post URL from post_url; defensively restores a missing {slug} segment. |
cms_post_social_meta($post) |
Emit escaped description, canonical, and Open Graph tags using the post excerpt/body summary and featured image. |
cms_listing_controls('category') |
"Add post" button on listing pages (editors only). |
cms_nav_items() / cms_nav_html() |
Read or render the editable navigation tree from content/nav.json. |
cms_assets() |
Emit editor CSS/JS before </body> (empty for visitors). |
Site-specific settings (credentials, categories, searchable pages, site URL,
upload limits) live in a private configuration file outside the document root,
selected with PAGECORE_CONFIG or CMS_CONFIG_FILE. See
cms/README.md for the full install and operations guide.
Pagecore is meant to be added around an existing PHP site, not to replace the site's templates. Keep your current routes, layout, CSS, JavaScript and server-side PHP logic. Convert only the content that editors should control.
Put only the public site and cms/ engine under the document root. Place the
configuration, source Markdown, drafts, backups, and uploads in a sibling
private directory:
site/
├── public/
│ ├── index.php
│ ├── post.php
│ └── cms/
└── pagecore-private/
├── config.php
├── content/
├── backups/
└── uploads/
Copy deployment/pagecore-config.php.example
to the private directory, configure it, and set PAGECORE_CONFIG to its
absolute path in the PHP process environment. If the site has a shared
bootstrap or layout include, load the engine there:
<?php require __DIR__ . '/cms/engine.php'; ?>If there is no shared include, add the same require near the top of every PHP
page that will render editable content or listings.
Then emit editor assets once before </body> in the shared footer:
<?= cms_assets() ?>cms_assets() returns an empty string for visitors, so anonymous traffic keeps
receiving the normal site without editor CSS or JavaScript.
Find pieces of hard-coded HTML that should be editable: hero text, body copy,
contact information, callouts, FAQ answers, table content and similar regions.
Replace each region with cms_editable().
Before:
<section class="hero">
<h1>About our company</h1>
<p>Long hand-written text...</p>
</section>After:
<section class="hero">
<?= cms_editable('about/hero') ?>
</section>Create the matching Markdown file:
content/pages/about/hero.md
With content such as:
# About our company
Long hand-written text...Use stable, lowercase keys made from letters, numbers and hyphens:
<?= cms_editable('home/intro') ?>
<?= cms_editable('services/pricing-table') ?>
<?= cms_editable('contact/opening-hours') ?>Keys map directly to Markdown files under content/pages/. The engine accepts
up to three path segments, for example services/websites/intro maps to
content/pages/services/websites/intro.md.
Pagecore renders Markdown into HTML. Put design-critical classes and layout containers in the PHP template, then let the editor manage the content inside.
Good:
<section class="section section--narrow">
<div class="prose">
<?= cms_editable('privacy/body') ?>
</div>
</section>Avoid moving required layout wrappers, JavaScript hooks, forms or PHP business logic into Markdown. Pagecore content is best used for editorial HTML generated from Markdown, not for application code.
If you need the editable wrapper to be a specific element, pass the tag name:
<?= cms_editable('home/sidebar-note', 'aside') ?>Visitors still receive only the rendered Markdown. Logged-in editors receive the same content wrapped with editor attributes.
Use Pagecore posts when editors need to add dated items such as news, events, articles, rulings or announcements.
First configure categories in cms/config.php. Each category entry is used by
the editor UI, listing URLs and sitemap generation:
'categories' => array(
'news' => array('News', '/news/'),
'events' => array('Events', '/events/'),
),
'post_url' => '/post/{slug}/',On the listing page, replace hard-coded repeated items with cms_posts():
<?php require __DIR__ . '/cms/engine.php'; ?>
<main>
<h1>News</h1>
<?= cms_listing_controls('news') ?>
<div class="news-list">
<?php foreach (cms_posts('news') as $post): ?>
<article class="news-card">
<time datetime="<?= htmlspecialchars($post['date']) ?>">
<?= htmlspecialchars($post['date_display']) ?>
</time>
<h2>
<a href="<?= htmlspecialchars($post['url']) ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
</h2>
<p><?= htmlspecialchars($post['excerpt']) ?></p>
</article>
<?php endforeach; ?>
</div>
</main>
<?= cms_assets() ?>cms_listing_controls('news') renders the "Add post" button only for logged-in
editors. It is invisible to visitors.
Create or adapt a detail route such as post.php. Fetch the slug from your
router or query string, load the post with cms_post(), and render its
metadata plus editable body.
<?php
require __DIR__ . '/cms/engine.php';
$slug = isset($_GET['slug']) ? $_GET['slug'] : '';
$post = cms_post($slug, cms_is_logged_in());
if (!$post) {
http_response_code(404);
echo 'Post not found';
exit;
}
?>
<main>
<article>
<p class="eyebrow">
<?= htmlspecialchars($post['category_label']) ?>
· <?= htmlspecialchars($post['date_display']) ?>
</p>
<h1><?= htmlspecialchars($post['title']) ?></h1>
<div class="prose">
<?php if (cms_is_logged_in()): ?>
<div class="cms-editable" data-cms-key="post:<?= htmlspecialchars($post['slug'], ENT_QUOTES, 'UTF-8') ?>">
<?= $post['body_html'] ?>
</div>
<?php else: ?>
<?= $post['body_html'] ?>
<?php endif; ?>
</div>
</article>
</main>
<?= cms_assets() ?>The special key post:<slug> edits the Markdown body inside
content/posts/<slug>.md. Post detail templates render body_html for
visitors, and add the cms-editable wrapper only for logged-in editors. Passing
cms_is_logged_in() as the second argument also gives an authenticated editor
an intentional review route for imported non-public posts. Anonymous requests
receive no post for any front-matter status other than publish. The
editor panel also exposes post metadata: title, date, category and optional
excerpt.
If your server supports pretty URLs, route /post/my-title/ to
post.php?slug=my-title. Otherwise set post_url to a query-string pattern,
for example /post.php?slug={slug}.
Keep the literal {slug} placeholder in the configured pattern. Pagecore
recomputes cached listing URLs from each post slug and defensively appends the
placeholder if a migrated configuration accidentally omitted it.
Each existing list item becomes one file in content/posts/:
---
title: Existing announcement
date: 2026-06-15
category: news
excerpt: Optional summary shown on listing pages.
status: publish
---
Full post body in Markdown.The filename is the slug used in URLs:
content/posts/existing-announcement.md
The listing page is sorted newest first by date. If excerpt is omitted,
Pagecore derives one from the post body.
Missing status is treated as publish for existing content. private,
draft, and other values are excluded from listings, tags, search indexes,
sitemaps, and anonymous detail routes. The WordPress importer defaults to
--status=publish; requesting additional states requires the explicit
--include-non-public=1 acknowledgement. Non-public imported posts remain in
content/posts/ for authenticated review, while non-public imported pages are
staged under content/.drafts/imported-pages/ and never added to public search
configuration or navigation.
For pages that should appear in search-index.json and sitemap.xml, add
entries to search_pages in cms/config.php:
'search_pages' => array(
'/' => array('Home', 'Page', 'home/intro'),
'/about/' => array('About', 'Page', 'about/hero'),
'/news/' => array('News', 'Listing', null),
),The third value is an optional editable fragment key used to generate a search
excerpt for that page. Posts and category listing URLs are added automatically
from content/posts/ and categories.
For the mechanics of getting these right on a control-panel host — directory layout, FTP, permissions, PHP version and diagnosing a 500 — see Deploying to shared hosting.
- The domain runs PHP 8.3+; the engine refuses to boot on anything older.
- The private configuration exists outside
DOCUMENT_ROOTand has the production password hash,site_url,site_root,content_dir,uploads_dir, categories and search pages. - The PHP worker can write to the private
content/,content/.drafts/, backups, and uploads,search-index.jsonandsitemap.xml. - Direct HTTP access to
cms/engine.php,cms/auth.php, andcms/lib/is denied; private storage has no HTTP route. - Media is delivered only by
/cms/media-file.php; the private upload directory is not executable or HTTP-addressable. - The private
login_rate_limit_diris shared by all PHP workers so account and source attempt budgets survive cookie rotation. PAGECORE_DEVELOPMENTis absent in production; the engine rejects any configuration, content, backup, or upload path belowDOCUMENT_ROOT.- Post URL rewrites match the configured
post_url. - Every page that calls
cms_editable(),cms_posts(),cms_post()orcms_listing_controls()has loadedcms/engine.php. <?= cms_assets() ?>appears once before</body>on pages where editors should edit content.
Keep structure in PHP. Move words, tables, images, PDFs and post bodies into Markdown. This keeps the existing site design intact while giving editors the Pagecore in-place editing workflow.
This section covers a typical control-panel host (DirectAdmin, cPanel and similar) reached over FTP, with no shell access. The failure modes below are the ones that actually occur; each is cheap to avoid and expensive to diagnose after the fact.
Nothing here can be verified by the bundled development server. php -S
ignores .htaccess entirely and runs with PAGECORE_DEVELOPMENT=1, so a site
that works perfectly in development can still fail every request in
production. Treat the checks in this section as a separate lane.
Run the layout validator against a built deployment before uploading it. It
boots the engine with no PAGECORE_DEVELOPMENT, exactly as a host would, and
catches most of what follows without a round trip to the server:
scripts\Test-ProductionLayout.ps1 -PublicRoot .\deploy\public_html -ConfigFile .\deploy\pagecore-private\config.phpPagecore fails closed in production if the configuration, content,
backups, uploads or rate-limit directory resolve anywhere below
DOCUMENT_ROOT:
Pagecore private storage must be outside DOCUMENT_ROOT: content_dir, uploads_dir
This is not advisory. A single-folder layout that mixes templates and content cannot be deployed, whatever else is configured. Split the site in two, with the private directory a sibling of the document root:
/home/<user>/domains/<domain>/
├── public_html/ <- DOCUMENT_ROOT: templates, assets/, cms/, .htaccess
├── private_html/ <- usually a symlink to public_html (see below)
├── logs/
└── pagecore-private/ <- outside the document root
├── config.php
├── content/
├── uploads/
└── state/
The domain folder is therefore the smallest single directory that can hold a complete deployment — useful when building an upload artifact, because it maps one local folder onto one remote folder.
cms/config.php must not ship to the public root. The engine's default
config location is inside cms/, which is below the document root and would
be rejected; production loads the private config instead.
Note that content/.backups is created by the engine on first publish rather
than shipped, which is one reason content/ itself has to be writable.
Panels that predate universal SNI serve http:// from public_html and
https:// from private_html. Two real directories mean maintaining two
copies of every template. Prefer the panel's "use a symbolic link from
private_html to public_html" option, then upload once. pagecore-private is
a sibling of both, so the choice does not affect it, and the engine boots
correctly with either as DOCUMENT_ROOT.
- Enable hidden files in your FTP client. Many clients skip dot-files
silently. A missing
.htaccessproduces a working homepage with 404s on every other route, and the private configuration is never found at all. - Merge, do not mirror. A mirroring client pointed at the domain folder
can delete
logs/and other panel-managed siblings. - Clear the previous application out of the document root first. Merging
leaves the old
index.php,.htaccessand framework directories in place, where they compete with the new rewrite rules. - Upload
pagecore-privatein the same pass, as a sibling — not inside the document root.
Under PHP-FPM or suPHP the PHP worker runs as the domain user, so owner-permissions are what matter:
| Path | Mode | Why |
|---|---|---|
pagecore-private/config.php |
600 |
Contains the bcrypt password hash; 644 is world-readable on a shared server |
pagecore-private/content/ |
755 |
Engine writes drafts, backups, posts-index.json |
pagecore-private/uploads/ |
755 |
Editor uploads land here |
pagecore-private/state/ |
755 |
Rate-limit counters and the audit log |
Files inside content/ |
644 |
Read and rewritten by the engine |
755 is what a control panel creates by default and what these directories
should keep. They sit outside the document root, so the web server has no
route to them regardless of mode, and the owner bits are the ones the PHP
worker uses. Tightening them further tends to cause more trouble than it
prevents: some panels run backup, quota and file-manager tasks under a
different account, which loses access at 700.
Never use 777. Beyond the obvious exposure, suPHP and some PHP-FPM
configurations refuse to execute anything under a group- or world-writable
directory and return 500.
Permissions alone do not prove writability — that depends on which user PHP runs as. Confirm it with the diagnostics file below rather than assuming.
Pagecore requires PHP 8.3 or newer and refuses to boot below it:
Pagecore requires PHP 8.3.0 or newer; running 7.4.33
Hosts frequently default a domain to an old branch, and a domain migrated from a legacy application often keeps whatever version that application needed. Set the version per domain before uploading.
Two things regularly obscure this:
- The panel's global PHP settings page may be restricted to the account's
default domain, reporting that PHP settings cannot be controlled for this
domain. That page governs
php.inivalues, not the version. - The version selector usually lives on the domain's own settings screen (Domains → the domain → select PHP version), which remains available even when the global page is locked.
Choose a branch still receiving security fixes, comfortably above the 8.3 minimum.
The configuration lives outside the document root, so the engine has to be
told where it is. Add one line to the document root's .htaccess:
SetEnv PAGECORE_CONFIG /home/<user>/domains/<domain>/pagecore-private/config.phpSetEnv reaches getenv() under mod_php and CGI, but only $_SERVER under
PHP-FPM; the engine reads both, so this works across SAPIs. If mod_env is
unavailable, set the variable in the panel's PHP-FPM configuration for the
domain instead (env[PAGECORE_CONFIG] = …).
PAGECORE_DEVELOPMENT is deliberately read from getenv() only. It is a
security switch, and $_SERVER holds a start-up snapshot that putenv()
cannot clear, so honouring it there would let a stale value pin a production
site in development mode.
Derive the two postures from that one variable so the engine and the configuration can never disagree:
$production = getenv('PAGECORE_DEVELOPMENT') !== '1';A configuration that invents its own flag — PAGECORE_PRODUCTION or similar —
will report development values on a host where nothing is set, while the
engine treats the same request as production. The result is a 500 on every
request with production cannot use development or demo credentials.
Because uploads/ is private, map the public URL onto the engine's media
endpoint. Content then keeps ordinary /uploads/... URLs and needs no
rewriting:
RewriteRule ^uploads/(.+)$ cms/media-file.php?path=$1 [L,QSA]With require_https enabled the engine answers plain HTTP with a bare
400 HTTPS is required. Redirect before PHP is reached:
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]The X-Forwarded-Proto condition prevents a redirect loop where TLS
terminates at a proxy. If PHP still fails to see HTTPS in that setup, add the
proxy to trusted_proxies; leave it empty otherwise, since trusting forwarded
headers unconditionally lets a client claim any scheme or address.
Two constructs fail hard and take the whole site with them, because Apache rejects the entire file and returns 500 for every request — including static assets:
<Directory>sections are not valid in.htaccess. They belong in the server configuration. Per-directory rules go in that directory's own.htaccess.php_flag/php_valueonly exist under mod_php. On PHP-FPM, LiteSpeed or CGI they are unknown directives. Guard them, and rely on a<FilesMatch>denial as the enforcing rule:
<FilesMatch "\.(php|phtml|phar|cgi|pl)$">
Require all denied
</FilesMatch>
<IfModule mod_php.c>
php_flag engine off
</IfModule>First, find out whether PHP is involved at all. Request a static asset and a PHP entry point.
POSIX shell:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/assets/style.csscurl -s -o /dev/null -w '%{http_code}\n' https://example.com/index.phpPowerShell:
'/assets/style.css', '/index.php' | ForEach-Object {
$r = Invoke-WebRequest "https://example.com$_" -SkipHttpErrorCheck -MaximumRedirection 0
'{0,-20} {1}' -f $_, $r.StatusCode
}-SkipHttpErrorCheck requires PowerShell 7+; without it a 500 raises a
terminating error instead of reporting the status. Note also that Windows
curl.exe has no /dev/null — use NUL if you prefer the curl form.
- Static 500 → Apache is rejecting
.htaccess. Look for the constructs above; renaming.htaccessaside confirms it in one request. - Static 200, PHP 500 → the failure is inside PHP. Continue below.
Do not expect the domain error log to help. An uncaught PHP exception
under PHP-FPM is not an Apache error, so a panel's per-domain
<domain>.error.log can sit at 0 bytes through a completely broken site while
the access log fills normally. An empty error log is not a broken log; it is
the wrong log.
Create a diagnostics file in the document root — this reports in one request everything the engine needs and prints the real exception:
<?php
ini_set('display_errors', '1');
error_reporting(E_ALL);
header('Content-Type: text/plain; charset=utf-8');
echo 'php=', PHP_VERSION, ' sapi=', PHP_SAPI, "\n";
echo 'docroot=', $_SERVER['DOCUMENT_ROOT'], "\n";
echo 'getenv=', var_export(getenv('PAGECORE_CONFIG'), true), "\n";
echo 'server=', var_export($_SERVER['PAGECORE_CONFIG'] ?? null, true), "\n";
$private = dirname($_SERVER['DOCUMENT_ROOT']) . '/pagecore-private';
echo 'config readable=', var_export(is_readable($private . '/config.php'), true), "\n";
foreach (array('content', 'content/.backups', 'uploads', 'state') as $dir) {
printf("%-18s exists=%-3s writable=%s\n", $dir,
is_dir("$private/$dir") ? 'yes' : 'NO',
is_writable("$private/$dir") ? 'yes' : 'NO');
}
echo "--- boot ---\n";
require __DIR__ . '/cms/engine.php';
echo "BOOTED OK\n";Expect a PHP version of 8.3+, both getenv and server naming the private
config, writable=yes throughout, and BOOTED OK. content/.backups reports
exists=NO until the first publish, which is normal.
Delete the file as soon as you have read it. It discloses absolute paths
and PHP internals. Avoid posix_* calls in it — hosts commonly disable them,
and the resulting fatal masks the answer you are looking for.
Walk the routes rather than only the homepage — a wrong DOCUMENT_ROOT,
missing .htaccess or bad post_url shows up on the second page, not the
first. Expect 200 on every line; a 500 on the media path alone points at
the /uploads/ rewrite, and a 404 on a post at post_url.
POSIX shell:
for p in / /about/ /blog/ /blog/some-post/ /cms/login.php /uploads/2026/01/photo.jpg; do
printf '%-34s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "https://example.com$p")"
donePowerShell:
$base = 'https://example.com'
'/', '/about/', '/blog/', '/blog/some-post/', '/cms/login.php', '/uploads/2026/01/photo.jpg' | ForEach-Object {
$r = Invoke-WebRequest "$base$_" -SkipHttpErrorCheck -MaximumRedirection 0 -TimeoutSec 25
'{0,-34} {1}' -f $_, $r.StatusCode
}Keep -MaximumRedirection 0 in both: it makes the HTTP-to-HTTPS redirect and
any post_url redirect visible as 301 rather than being silently followed.
Then log in and publish one small edit. That is the only way to exercise the write path — drafts, backup creation, search index and sitemap regeneration — and it is the step most likely to reveal a permissions problem that read-only browsing hides.
- Single-account login with a bcrypt password hash; browser-session-scoped progressive delays and a 5-failure, 5-minute lockout. This avoids a shared lockout that an unauthenticated client could use to deny editor access.
- Sessions are HttpOnly, SameSite=Lax, secure over HTTPS, with an absolute lifetime; the session ID is regenerated on login.
- CSRF protection — every state-changing API call requires a per-session token header.
- Path safety — fragment keys and post slugs are strictly validated and
resolved inside
content/only. - Safe Markdown boundary — raw HTML is always escaped and unsafe Markdown URL schemes are neutralized; site configuration cannot disable safe mode. Imported active elements are removed or converted to non-executable links.
- Upload validation and isolation — the upload allowlist is limited to
raster images and PDFs, with a size limit, server-side MIME sniffing (never
trusts the client), and raster decoding checks. Active SVG/XML uploads are
rejected. PDFs are served through a download-only endpoint with
nosniffand a restrictive sandbox policy. Uploaded files get randomized names, and uploads live outside the document root. Both raster images and PDFs are delivered through a controlled handler; PDFs cannot render inline. - Engine internals (
config.php,engine.php,auth.php,cms/lib/) are not reachable over HTTP. - Atomic writes (temp file + rename, Windows-safe) so a failed save never corrupts live content.
- PHP 8.3+ on a branch still receiving security fixes; the
fileinfoextension is used when present, with a magic-byte fallback otherwise. The version is enforced at boot, so a host still defaulting the domain to an older branch fails every request until the per-domain PHP version is raised. - A PHP-capable web server whose document root contains only public templates
and
cms/; private storage must be a sibling or otherwise external path. The bundled PHP router is for loopback development only. - Write access for the web-server user to
content/,content/.drafts/,uploads/,search-index.jsonandsitemap.xml.
This repository includes a working sample site under sample-site/. It uses
the reusable cms/ directory directly, but points the engine at
sample-site/config.php through the PAGECORE_CONFIG environment variable.
You can also define a CMS_CONFIG_FILE constant before requiring
cms/engine.php if an integration needs a per-site config file.
Install the test runner and start the sample site:
npm install
npm run sample:startOpen http://127.0.0.1:8765/sample-site/ and sign in at /cms/login.php with
admin / pagecore-demo.
These credentials are intentionally public and cannot boot in production
mode. The sample configuration is marked development_only; Pagecore requires
the explicit development opt-in and rejects non-loopback requests. The
launcher therefore binds to 127.0.0.1 and must not be exposed to a network.
Run the Playwright suite against the sample site:
npm run test:e2eRun the separate, reusable migration-output contract lane:
npm run test:migrationThe base configuration discovers only tests/sample-site.spec.js; the migration
configuration discovers only tests/migration.spec.js. Site-specific deployment
data such as a local zagozda/ checkout remains ignored and cannot change either
lane's test discovery.
Build one checksummed deployment archive with npm run release:build; verify
the complete build/install/drift contract with npm run release:test. Private
sites consume that archive through scripts/Install-PagecoreRelease.ps1 and
retain only their own cms/config.php, templates, and content. The Zagozda
launcher performs this verified install before it starts, so its ignored
fixture cannot become a second CMS implementation.
The Playwright config starts the PHP built-in server with php/php.exe. Test
content is reset from sample-site/fixtures/ into ignored runtime folders
before each run. The suite covers visitor rendering, drafts, preview, publish,
revision restore, post creation, upload validation, media-library search,
metadata sidecars, picker insertion, deletion of unused uploads, content
inventory, missing Markdown creation and editable navigation.
cms/ # the reusable engine
├── engine.php # core: config, rendering, content model, index generation
├── auth.php # login/logout, CSRF, brute-force lockout
├── api.php # JSON API used by the in-browser editor
├── assets/ # editor UI (vanilla JS + CSS, no build step)
├── lib/Parsedown.php # Markdown renderer
└── README.md # install & operations guide
sample-site/ # runnable demo site and content fixtures
scripts/ # sample reset/start helpers
tests/ # Playwright browser tests
content/ # protected site-content root; add Markdown beneath it
uploads/ # protected media root; PHP execution is blocked here