folderweb/docs/04-development/02-content-system.md
Ruben 33943a907b Document author setting for Atom feeds
**Date deduplication:** Atom requires unique `<updated>` timestamps per
entry. Items sharing the same date get seconds incremented to preserve
sort order.

**Cache-Control for assets:** JSON/GeoJSON get 60s TTL; others get 1
year immutable.
2026-03-17 11:23:19 +01:00

7 KiB

Content System

Content Discovery

findAllContentFiles(string $dir): array — Returns sorted file paths from $dir.

  • Scans for files with extensions in CONTENT_EXTENSIONS (md, html, php)
  • Skips index.php (reserved for server entry points)
  • Fires Hook::PROCESS_CONTENT($files, $dir) — plugins filter files (e.g., language variants)
  • Sorts by filename via strnatcmp (natural sort: 00-hero, 01-intro, 02-body)
  • Returns flat array of absolute paths

File ordering convention: Prefix filenames with NN- for explicit ordering. Files without prefix sort after numbered files.

URL Routing

Trailing slash enforcement: All page and list URLs are canonicalized with a trailing slash. Requests without one receive a 301 redirect (e.g., /about/about/). The frontpage (/) is exempt.

Frontpage handling: Empty request path is handled before parseRequestPath() — it renders the content root directly via renderMultipleFiles(). The "frontpage" type from parseRequestPath() is never reached in the router's switch statement.

parseRequestPath(Context $ctx): array — Returns ['type' => string, 'path' => string].

Types:

  • "frontpage" — empty request path (handled before this function is called)
  • "page" — resolved directory with no subdirectories
  • "list" — resolved directory with subdirectories
  • "not_found" — slug resolution failed

resolveSlugToFolder(string $parentDir, string $slug): ?string — Matches URL slug to directory name.

Resolution order:

  1. Exact folder name match ($slug === $item)
  2. Metadata slug match (metadata.ini slug field)

Each URL segment is resolved independently, walking the directory tree. This enables language-specific slugs (e.g., /no/om/content/about/ via [no] slug = "om").

Metadata

loadMetadata(string $dirPath): ?array — Parses metadata.ini if present.

Returns flat key-value array with a special _raw key containing the full parsed INI structure (including sections). The _raw key is an internal contract — the language plugin reads $data['_raw'][$lang] to merge language-specific overrides. Plugins processing metadata must preserve _raw if present. Fires Hook::PROCESS_CONTENT($metadata, $dirPath, 'metadata').

Core Fields

Field Type Default Purpose
title string First # heading or folder name Page title, list item title, <title> tag
summary string List item description, fallback meta description
date string (YYYY-MM-DD) Folder date prefix or file mtime Sorting, display
search_description string Falls back to summary <meta name="description">
slug string Folder name URL override
menu bool/int 0 Include in navigation
menu_order int 999 Navigation sort order (ascending)
order string "descending" List sort direction (ascending|descending)
redirect string External URL (list items can redirect)
feed bool false Enable Atom feed on list pages (feed.xml)
author string title Atom feed author name (falls back to page title)
plugins string Comma-separated page-level plugin names

Settings Section

[settings]
page_template = "list-grid"    # List template override (without .php)
show_date = true                # Show date in list items (default: true)
hide_list = false               # Force page rendering even with subdirectories

Language Sections

title = "About"
slug = "about"

[no]
title = "Om oss"
slug = "om"

Supported language-overridable fields: title, summary, search_description, slug.

Custom Fields

Any key not listed above is passed through to templates/plugins unchanged. Add whatever fields your templates need.

Date Extraction

extractRawDateFromFolder(string $folderName): ?string — Extracts raw YYYY-MM-DD string from folder name prefix. Returns null if no date prefix. No hook processing.

extractDateFromFolder(string $folderName): ?string — Calls extractRawDateFromFolder() then passes the result through Hook::PROCESS_CONTENT($date, 'date_format') for plugin formatting (e.g., "1. January 2025").

If no date prefix exists and no date metadata is set, falls back to file modification time (filemtime). All dates pass through Hook::PROCESS_CONTENT($date, 'date_format') for plugin formatting.

Note: Date extraction uses regex only — 2025-13-45-slug would extract 2025-13-45 without validation. Invalid dates pass through to templates as-is.

Sorting with null dates: Items without any date are sorted as empty strings via strcmp. Their relative order among other dateless items is undefined.

List Item Building

buildListItems(string $dir, Context $ctx, ?array $parentMetadata): array — Builds and sorts the items array for list views. Defined in helpers.php.

For each subdirectory: loads metadata, extracts title/date/cover/PDF, builds URL with lang prefix and slug. Returns sorted array — direction controlled by order metadata on parent (descending default).

Each item contains both a formatted date (hook-processed for display) and a rawDate (ISO YYYY-MM-DD for Atom feeds and <time> elements). Also includes dirPath (filesystem path) used by the feed generator to render full content.

Used by both the list case in router.php and the Atom feed generator.

Navigation

buildNavigation(Context $ctx): array — Scans top-level content directories.

Returns items with menu = 1 metadata, sorted by menu_order. Each item: ['title' => string, 'url' => string, 'order' => int]. URLs include language prefix if applicable.

Cover Images

findCoverImage(string $dirPath): ?string — Finds cover.* file.

Checks extensions in COVER_IMAGE_EXTENSIONS order: jpg, jpeg, png, webp, gif. Returns filename (not full path) or null.

PDF Discovery

findPdfFile(string $dirPath): ?string — Returns basename of first *.pdf found, or null.

Page-Specific CSS

findPageCss(string $dirPath, string $contentDir): ?array — Checks for styles.css in content directory.

Returns ['url' => string, 'hash' => string] or null. Hash is MD5 of file content for cache busting.

Page-Specific JavaScript

findPageJs(string $dirPath, string $contentDir): ?array — Checks for script.js in content directory.

Returns ['url' => string, 'hash' => string] or null. Hash is MD5 of file content for cache busting. The script is loaded with the defer attribute in base.php, placed before </body> for non-blocking progressive enhancement.

Meta Description Extraction

extractMetaDescription(string $dirPath, ?array $metadata): ?string

Priority: search_descriptionsummary → first paragraph from content files (>20 chars).

Title Extraction

extractTitle(string $filePath): ?string — Reads first content file in directory.

Markdown: first # heading. HTML/PHP: first <h1> tag. Returns null if no heading found.