# Publiser Tool Reference For LLM agents working on the `/publiser` publishing tool. Read when modifying anything under `content/publiser/`, `custom/plugins/publiser-lib.php`, or `custom/plugins/global/scheduled-publisher.php`. ## Overview Standalone admin app for authoring, managing, and scheduling `content/nyheter/` and `content/artikler/` posts without hand-editing files - built for non-technical editors. Password-protected (Basic Auth), served entirely outside the CMS routing/templating pipeline. Located in `content/publiser/`. - `content/publiser/index.php` - the whole app: routing, auth, views, POST actions, image upload/serve, and the bounded Markdown<->HTML conversion used by the block editor. - `custom/plugins/publiser-lib.php` - shared logic (paths, metadata I/O, block/file operations, publish/unpublish/trash/restore, scheduling). Required by both `index.php` and the scheduler plugin, so there is exactly one implementation of "what publishing a post means." - `custom/plugins/global/scheduled-publisher.php` - auto-publishes due scheduled posts via a `Hook::CONTEXT_READY` callback (fires on every normal site page load). - `content/publiser/publiser.css` / `publiser.js` - editor UI and the client-side half of the Markdown serialization. - `custom/tools/set-publiser-password.php` - CLI to set/reset the login. ## Why It's Not Routed Through the CMS `content/publiser/index.php` is served directly by Apache, not via `app/router.php`. It has no `Context`, no templates, no site chrome - it only reuses the pure, hook-free helpers from `app/helpers.php` (`findCoverImage`, `getSubdirectories`, `extractRawDateFromFolder`). This keeps the admin tool decoupled from `app/`'s request-routing internals (a separate git repo - see main README) while still sharing its file-format conventions. ## Routing & Auth `content/.htaccess.base` (synced to `content/.htaccess` - see `docs/security-cpanel.md`) has two `/publiser`-specific additions: 1. A `` exception so `.php`/`.html`/`.md`/`.ini` files under `/publiser` aren't blocked by the CMS's "no direct file access" rule. 2. A `RewriteRule` dispatching everything under `/publiser` (except `.css`/`.js`/image requests) to `content/publiser/index.php`, bypassing the CMS's own catch-all-to-`/index.php` rule. `content/publiser/.htaccess` sets `DirectorySlash On` (the site-wide default is `Off` - see Critical section below) and forwards the `Authorization` header via `RewriteRule ... [E=HTTP_AUTHORIZATION:...]` for SAPIs (LSAPI/CGI/FastCGI, used on cPanel) that strip it by default. Auth itself is checked in PHP (`publiserRequireAuth()` in `index.php`), not via Apache's `AuthUserFile` - that directive needs an absolute path that differs between the podman dev container and cPanel, so a bcrypt `.htpasswd` (same format `mod_authn_file` would use) is read and verified manually instead. Set the login with: ```bash php custom/tools/set-publiser-password.php ``` This writes `content/publiser/.htpasswd` (gitignored, `0644` - see comment in the script for why world-readable is fine here). ## Content Model Drafts and trash are staged **outside** the public content tree, mirroring the exact folder shape the CMS reads (so "publish" is just a directory move): ``` custom/data/publiser/ (gitignored - runtime state, not source content) drafts/ nyheter// # metadata.ini, 10-*.md, 20-*.html, cover.*, images... artikler// trash/ nyheter/~/ artikler/~/ ``` - **Folder naming**: draft/trash folders are always just the slug (no date prefix), for both sections. Live `nyheter` folders get a `YYYY-MM-DD-` prefix at publish time; live `artikler` folders don't (matches the pre-existing convention - `nyheter` posts are dated, `artikler` aren't). - **Blocks**: each content file (`.md`/`.html`/`.php`) in a folder is one editable "block" (`publiserListBlocks()`). `.md` is edited as WYSIWYG rich text; `.html`/`.php` as raw code (never parsed/rendered in the editor). Order is filename natural-sort; the tool renumbers to a step-10 prefix (`10-`, `20-`...) whenever blocks are reordered or added, leaving room to insert between existing ones. - **Non-block files** (subfolders, PDFs, `.en.*` translations) are listed read-only in the editor (`publiserListExtraFiles()`) with a note to edit them directly - the tool doesn't try to manage arbitrary file types or translations. - **Metadata**: `metadata.ini` is read with `INI_SCANNER_TYPED` (not the CMS's default scanner mode) so bare `true`/`false` round-trip as real booleans instead of PHP's normal-mode "1"/"" quirk - see Critical section. ## Editing Model: the H1/Title Convention The site has no metadata-driven page heading - `page.php` just outputs `$content` directly, so the visible `

` comes entirely from a leading `# Title` line in the first content file (same convention `extractTitle()` in `app/helpers.php` relies on). The editor's "Tittel" field owns that line: - On load, `publiserStripLeadingH1()` (in `index.php`) strips a leading `# ...` line from the **first richtext block only**, so it doesn't show up duplicated inside the editable body. - On save, `publiserApplyFormSave()` always re-prepends `# {title}\n\n` to that same block, keeping the file's real H1 in sync with the title field - even if the block's body wasn't otherwise touched. ## Markdown <-> HTML (Bounded, Not General-Purpose) No markdown library is used for the editor - a small, closed-world parser handles only the constructs the toolbar can produce: paragraphs, `##`/`###`, bold/italic, links, images, `>` blockquotes, `-`/`1.` lists, and raw HTML blocks (see below). This is deliberate: it doesn't need to handle arbitrary CommonMark, only what this tool itself writes. - **Server -> client** (`publiserBlockMdToHtml()` in `index.php`): runs once, when populating the `contenteditable` div on page load. - **Client -> server** (`serializeNode()`/`serializeChildren()` in `publiser.js`): runs on save, walking the edited DOM back to Markdown. - **Untouched blocks are never rewritten**: each block has a hidden `.p-block-hidden` textarea (default value = the block's raw, unmodified content) and a `.p-dirty-flag` (default `0`). JS only overwrites the hidden field and flips the flag when the user actually edits that specific block. The backend skips saving any block whose dirty flag is `0`. This protects legacy content with markdown constructs the bounded parser doesn't understand from being silently flattened/corrupted by a save the user never intended to touch that block. - **Raw HTML embedded in markdown** (e.g. an `