Add documentation for content system, newsletter plugin, petition

system, and templates

Add content system documentation

Add newsletter plugin documentation

Add petition system documentation

Add templates documentation
This commit is contained in:
Ruben 2026-02-06 19:15:29 +01:00
parent 2f024e28be
commit a142b0562f
4 changed files with 521 additions and 0 deletions

163
docs/content-system.md Normal file
View file

@ -0,0 +1,163 @@
# Content System Reference
For LLM agents working with site content. Read when adding/modifying content or metadata.
## Folder = URL
Every directory under `content/` becomes a URL path. The folder name is the default slug.
```
content/nyheter/2025-09-26-banebrytende-studie/ -> /nyheter/banebrytende-studie/
content/faq/Hva-er-MC/ -> /faq/hva-er-mc/
content/kontakt/ -> /kontakt/
```
Date prefixes (`YYYY-MM-DD-`) are stripped from URLs and used as the item date.
Numeric prefixes (`NN-`) control ordering and are stripped from URLs.
## Content Files
Place these inside a content directory:
| File | Purpose |
|---|---|
| `article.md` or `index.md` | Main content (Markdown) |
| `article.en.md` | English translation |
| `index.html` | HTML content alternative |
| `index.php` | PHP content (has access to `$GLOBALS['ctx']`) |
| `metadata.ini` | Configuration for this directory |
| `cover.png/jpg/webp` | Cover image for list display |
| `styles.css` | Page-specific CSS |
| `script.js` | Page-specific JS |
| `*.pdf` | Downloadable PDF |
Multiple content files in one directory are rendered in sequence (alphabetical order).
## Page vs List Detection
- Directory with **no subdirectories** (or `hide_list = true`) -> renders as **page** using `page.php`
- Directory with **subdirectories** -> renders as **list** using `list.php` or `page_template` override
## metadata.ini Reference
### Core Fields
```ini
title = "Page Title" # Display title
summary = "Short description" # Shown in lists and meta description
date = "2026-01-15" # Explicit date (overrides folder date prefix)
slug = "custom-url" # Custom URL segment (overrides folder name)
```
### Menu & Ordering
```ini
menu = true # Show in main navigation
menu_order = 1 # Navigation sort order (lower = first)
order = 5 # Sort order within parent list
```
### Display & Templates
```ini
page_template = "list-faq" # Override list template (list-card-grid, list-faq, list-grid)
show_date = false # Hide dates in list display
hide_list = true # Treat directory-with-subdirs as a page instead of list
```
### Feeds
```ini
feed = true # Enable Atom feed at /{section}/feed.xml
author = "Stopp lidelsen" # Feed author name
```
When `feed = true` is set:
1. `/{section}/feed.xml` serves Atom XML with full content of each item
2. `$feedUrl` is set in template context, enabling `<link rel="alternate">` in `<head>`
3. Feed includes all list items with rendered HTML content
### Plugins
```ini
plugins = "petition-form" # Comma-separated page plugins to load
petition_id = "my-petition" # Plugin-specific config
thank_you_page = "takk" # Plugin-specific config
```
### Language Overrides
```ini
[en]
title = "English Title"
summary = "English description"
slug = "english-url"
```
Language sections are merged into base metadata when that language is active. Any field can be overridden per language.
### Metadata Priority
Language-specific `[lang]` section > root fields > auto-extracted (date from folder name) > defaults
### Categories & Tags
```ini
tags = "nyhetsbrev, oppsummering"
categories = "Nyhetsbrev"
[en]
tags = "newsletter, summary"
categories = "Newsletters"
```
Displayed by `page.php` template if present.
## Current Site Sections
| Section | Folder | Template | Menu | Feed | Notes |
|---|---|---|---|---|---|
| Home | `content/` | page | - | - | Has newsletter-signup plugin |
| News | `content/nyheter/` | list | Yes (1) | Eligible | Date-prefixed articles |
| Articles | `content/artikler/` | list | Yes (2) | - | Subsections with articles |
| Brochures | `content/brosjyrer/` | list-card-grid | No | - | PDFs + redirects to articles |
| FAQ | `content/faq/` | list-faq | Yes | - | Accordion layout |
| Docs | `content/docs/` | list | No | - | Internal documentation |
| Petitions | `content/underskriftskampanje/` | list | No | - | Has petition-form plugin |
| Contact | `content/kontakt/` | page | Yes (10) | - | |
| About | `content/om-oss/` | page | No | - | `hide_list=true` |
| Privacy | `content/personvern/` | page | No | - | `hide_list=true` |
## Adding Content
### New Article in Existing Section
```
content/nyheter/YYYY-MM-DD-slug-name/
article.md # Norwegian content
article.en.md # English content (optional)
metadata.ini # tags, categories, [en] overrides
cover.png # Optional cover image
```
### New Section
```
content/new-section/
metadata.ini # menu=true, menu_order=N, page_template=...
article.md # Intro content (shown above list)
subsection-1/
article.md
metadata.ini
subsection-2/
...
```
## Language System
- Default language: Norwegian (`no`)
- Available: `no`, `en` (configured in `custom/config.ini`)
- Norwegian URLs: `/nyheter/`, `/faq/`, etc.
- English URLs: `/en/news/`, `/en/faq/`, etc. (with `slug` from `[en]` section)
- Content files: `article.md` (Norwegian), `article.en.md` (English)
- Translation strings: `custom/languages/no.ini`, `custom/languages/en.ini`

94
docs/newsletter-plugin.md Normal file
View file

@ -0,0 +1,94 @@
# Newsletter Plugin Reference
For LLM agents working on the newsletter signup. Read when modifying newsletter functionality.
## Overview
Modular newsletter signup that can be embedded on any page. Uses Listmonk's public subscription API for double opt-in. Located in `custom/plugins/page/newsletter-signup.php`.
## How It Activates
Add to a page's `metadata.ini`:
```ini
plugins = "newsletter-signup"
```
Then call from PHP content files:
```php
<?= newsletter_signup('Your custom intro text here', 'hero') ?>
<?= newsletter_signup('Short text here', 'small') ?>
```
## Themes
| Theme | Description | HTML Element |
|---|---|---|
| `hero` | Full-width gradient background section (green->blue). Centered form with pill-shaped inputs. | `<section>` |
| `small` | Compact inline box with green border. Fits between paragraphs. | `<aside>` |
## Function Signature
```php
newsletter_signup(string $introText, string $theme = 'hero', string $formId = 'newsletter'): string
```
- `$introText` - Displayed above the form
- `$theme` - `'hero'` or `'small'`
- `$formId` - HTML id for the section (for anchor links)
## How It Works
1. Form submits via JavaScript `fetch()` (AJAX) - this is the ONE place JS is used on the site
2. Server validates: CSRF token, rate limit (30s), name/email
3. Calls Listmonk public subscription API
4. Returns JSON response
5. Button shows success/error state with animation
## Listmonk Integration
Config: `custom/listmonk-config.php` (not in repo, see `listmonk-config.example.php`)
```php
return [
'enabled' => true,
'url' => 'https://your-listmonk-instance.com',
'list_uuids' => ['uuid-1', 'uuid-2'],
];
```
Uses Listmonk's **public** subscription API (`/api/public/subscription`). No authentication needed. Listmonk handles its own double opt-in flow.
## Anti-Spam
1. **CSRF token** - Separate from petition (`newsletter_csrf_token`)
2. **Rate limit** - 30 seconds between submissions (session-based)
3. **Input validation** - Name 2-100 chars, valid email, max 100 chars
## Translation Keys
All use `newsletter.*` prefix in language files:
- `newsletter.name_label`, `newsletter.name_placeholder`
- `newsletter.email_label`, `newsletter.email_placeholder`
- `newsletter.notice` (e.g., "We send about 12 emails per year")
- `newsletter.submit_button`
- `newsletter.success_message`, `newsletter.error_message`
## Static Resources
CSS and JS are inlined in the output via static helper functions:
- `newsletterGetStyles()` - Included once per page (static flag)
- `newsletterGetScript()` - Included once per page (static flag)
This means multiple `newsletter_signup()` calls on one page share a single copy of CSS/JS.
## Critical: Do Not Break
1. **AJAX submission** - The form uses `fetch()` with JSON response. The `newsletterHandleSubmission()` function runs early (before any output) and calls `exit` after sending JSON.
2. **CSRF separate from petition** - Uses `newsletter_csrf_token` session key, not the petition's `csrf_token`.
3. **Static inclusion guards** - `$stylesIncluded` and `$scriptIncluded` static vars prevent duplicate CSS/JS. Do not reset these.
4. **Button states** - CSS classes `is-loading`, `is-success`, `is-error` control button feedback. The JS depends on these class names and `data-*` attributes.
5. **`escape` class on hero** - The hero section uses class `escape` to break out of the content container width. This is defined in `base.css`.
## Currently Used On
- Homepage (`content/index.php`) - hero theme with custom intro text

139
docs/petition-system.md Normal file
View file

@ -0,0 +1,139 @@
# Petition System Reference
For LLM agents working on the petition form. Read when modifying petition functionality.
## Overview
GDPR-compliant petition system with double opt-in email confirmation. File-based storage (CSV) with proper locking. Located in `custom/plugins/page/petition-form.php`.
## How It Activates
Add to a page's `metadata.ini`:
```ini
plugins = "petition-form"
petition_id = "my-petition" # Optional, defaults to folder slug
thank_you_page = "takk" # Subpage slug for post-submission redirect
```
The plugin is a **page plugin** (not global). It's loaded by the framework when the page is rendered, making its functions available to PHP content files.
## User Flow
1. User fills form (name, email, region, display preference, GDPR consent)
2. Anti-spam checks: CSRF, honeypot, time-based, referrer, rate limiting (session + IP)
3. Signature saved to CSV with `status=pending` and confirmation `token`
4. Confirmation email sent via SMTP (with retry + exponential backoff)
5. User redirected to thank-you page
6. User clicks confirmation link -> `?confirm=TOKEN` -> status changes to `confirmed`
7. Thank-you email sent with delete link
8. User can delete signature via `?delete=TOKEN` (GDPR right to erasure)
## CSV Format
File: `custom/data/petitions/{petition_id}.csv`
```
timestamp,email,firstname,surname,region,display,status,token,token_created,ip_hash
```
| Column | Description |
|---|---|
| timestamp | Unix timestamp of submission |
| email | Sanitized email (CSV injection protected) |
| firstname | Sanitized first name |
| surname | Sanitized last name |
| region | Region key (e.g., `oslo`, `vestland`) |
| display | Privacy preference: `anonymous`, `semi`, `full` |
| status | `pending` or `confirmed` |
| token | 64-char hex confirmation/delete token |
| token_created | Unix timestamp for 30-day expiry |
| ip_hash | SHA-256 of IP + petition_id |
## Key Functions
| Function | Purpose |
|---|---|
| `petitionGetPageData(?Context)` | Main entry point. Returns all petition data for template rendering. Handles GET confirm/delete and POST submission. |
| `petitionAppendSignature(csvPath, data)` | Atomic append with file locking + duplicate check inside lock |
| `petitionConfirmSignature(csvPath, token)` | Confirms pending signature, checks 30-day token expiry |
| `petitionDeleteSignature(csvPath, token)` | GDPR deletion - removes row entirely |
| `petitionEmailExists(csvPath, email)` | Duplicate check (case-insensitive) |
| `petitionGetConfirmedSignatures(csvPath)` | Returns confirmed signatures sorted newest-first |
| `petitionRenderForm(ctx, formData, errors, showForm)` | Generates form HTML |
| `petitionRenderSignatures(signatures, ctx)` | Generates signature list HTML |
| `petitionSendConfirmationEmail(...)` | Sends confirmation with retry wrapper |
| `petitionSendThankYouEmail(...)` | Sends thank-you with delete link |
| `petitionCheckIPRateLimit(id, max, window)` | IP-based rate limiting (separate CSV file) |
| `petitionT(ctx, section, key, replacements)` | Translation helper for petition strings |
| `petitionGetPendingSignatureByEmail(csvPath, email)` | Lookup for resend functionality |
| `petitionUpdateSignatureToken(csvPath, email, newToken)` | Token refresh for resend |
## Anti-Spam Measures
1. **CSRF token** - Session-based, validated on submit
2. **Honeypot field** - Hidden `website` field, rejects if filled
3. **Time check** - Rejects if form submitted in <3 seconds
4. **Referrer check** - Validates HTTP referrer matches host
5. **Session rate limit** - 1 submission per 60 seconds
6. **IP rate limit** - 3 attempts per 5 minutes per IP (stored in `custom/data/petition-rate-limit.csv`)
7. **CSV injection prevention** - Prefixes dangerous characters with `'`
## Email System
- Uses PHPMailer.Lite (`custom/vendor/PHPMailer.Lite.php`)
- Config in `custom/smtp-config.php` (not in repo, see `smtp-config.php.example`)
- Supports petition-specific SMTP overrides in config
- Retry with exponential backoff: 3 attempts at 2s, 4s, 8s delays
- All sends logged to `custom/data/smtp-log.csv`
- Pre-flight TCP connection check before attempting send
## Resend Confirmation Flow
Subpage at `send-bekreftelse-pa-nytt/` allows users to request a new confirmation email:
1. User enters email address
2. Plugin looks up pending signature by email
3. Generates new token, updates CSV
4. Sends new confirmation email
5. Always shows generic message (privacy: doesn't reveal if email exists)
## Content Structure
```
content/underskriftskampanje/medisinsk-cannabis-pa-resept/
metadata.ini # plugins="petition-form", petition_id, thank_you_page
index.php # Calls petitionGetPageData(), renders form + signatures
takk/
metadata.ini # plugins="petition-form" (needed for context)
index.php # Thank-you page with check-email message
send-bekreftelse-pa-nytt/
metadata.ini # plugins="petition-form", petition_id, petition_title
index.php # Resend confirmation form
```
## Translation Keys
All petition strings use `petition.*` prefix in language files. Key groups:
- Form labels: `petition.firstname_label`, `petition.email_label`, etc.
- Validation: `petition.firstname_required`, `petition.email_required`, etc.
- Display options: `petition.display_semi`, `petition.display_anonymous`, `petition.display_full`
- Email content: `petition.email_greeting`, `petition.email_subject`, etc.
- Confirmation: `petition.confirm_success`, `petition.confirm_expired`, etc.
- Regions: `regions.oslo`, `regions.vestland`, etc.
## Critical: Do Not Break
1. **File locking** - All CSV operations use `flock()`. Never bypass.
2. **Duplicate check inside lock** - `petitionAppendSignature()` checks duplicates while holding exclusive lock to prevent race conditions.
3. **Token expiry** - 30 days (2592000 seconds). Do not change without updating email text.
4. **PRG pattern** - Form submission redirects via Post/Redirect/Get. Session stores errors/data.
5. **Privacy** - Resend flow never reveals whether an email exists in the system.
6. **CSV sanitization** - All user input goes through `petitionSanitizeCSV()` before writing.
## CLI Tool
`custom/petition-cli.php` - Run inside container:
```bash
podman exec stopplidelsen.no php /var/www/custom/petition-cli.php [command] [args]
```
Commands: list signatures, confirm by email, delete by email, resend confirmation.

125
docs/templates.md Normal file
View file

@ -0,0 +1,125 @@
# Templates Reference
For LLM agents working on this site's templates. Read when modifying any template file.
## Template Hierarchy
Content renders inside-out: content file -> page/list template -> base.php
```
content file (md/html/php) -> rendered as $content
-> page.php or list-*.php (wraps content)
-> base.php (HTML document shell)
```
## File Locations
All in `custom/templates/`. These override `app/default/templates/`.
| Template | Purpose | Used When |
|---|---|---|
| `base.php` | HTML document wrapper (head, nav, footer) | Always |
| `page.php` | Single page/article | Directory has no subdirectories (or `hide_list=true`) |
| `list.php` | Default list layout | Directory has subdirectories (default) |
| `list-card-grid.php` | Card grid layout | `page_template = list-card-grid` in metadata |
| `list-faq.php` | FAQ accordion layout | `page_template = list-faq` in metadata |
| `list-grid.php` | Brochure grid layout | `page_template = list-grid` in metadata |
## Template Selection
List template is chosen by `page_template` field in the directory's `metadata.ini`:
```ini
page_template = list-faq
```
If not set, defaults to `list.php`.
## Variables Available in All Templates
These are extracted via `extract()` so they're direct PHP variables:
| Variable | Type | Description |
|---|---|---|
| `$content` | string | Pre-rendered HTML content (in base.php) |
| `$pageTitle` | ?string | Page title from metadata |
| `$metaDescription` | ?string | Meta description |
| `$socialImageUrl` | ?string | Social media image URL |
| `$navigation` | array | Menu items `[['title'=>..., 'url'=>...], ...]` |
| `$homeLabel` | string | Home link text |
| `$currentLang` | string | Current language code (`no` or `en`) |
| `$langPrefix` | string | URL language prefix (empty for default, `/en` for English) |
| `$languageUrls` | array | `['no'=>'/path/', 'en'=>'/en/path/']` |
| `$translations` | array | Translation strings from language .ini files |
| `$availableLangs` | array | `['no', 'en']` |
| `$pageCssUrl` | ?string | Page-specific CSS URL |
| `$pageCssHash` | ?string | CSS cache-bust hash |
| `$pageJsUrl` | ?string | Page-specific JS URL |
| `$pageJsHash` | ?string | JS cache-bust hash |
| `$feedUrl` | ?string | Atom feed URL (only on list pages with `feed=true`) |
## Additional Variables in page.php
| Variable | Type | Description |
|---|---|---|
| `$content` | string | Rendered page content HTML |
| `$pageMetadata` | array | Full metadata array from `metadata.ini` |
## Additional Variables in list-*.php Templates
| Variable | Type | Description |
|---|---|---|
| `$pageContent` | ?string | Rendered intro content from list directory's own content files |
| `$items` | array | List items (see schema below) |
| `$metadata` | array | List directory's metadata |
## List Item Schema
Each item in `$items`:
```php
[
'title' => string, // Item title
'url' => string, // Relative URL with lang prefix
'date' => string, // Formatted date (localized)
'rawDate' => string, // ISO date (YYYY-MM-DD)
'summary' => ?string, // Summary text
'cover' => ?string, // Cover image URL
'pdf' => ?string, // PDF file URL
'redirect' => ?string, // External redirect URL
'dirPath' => string, // Filesystem path to item directory
]
```
## base.php Key Sections
1. **Head**: Meta tags, social OG tags, Twitter cards, stylesheets, feed link, favicon
2. **Header**: Logo, navigation menu, language switcher
3. **Main**: `$content` (wrapped content from inner template)
4. **Footer**: Social links, page generation time, language links, GoatCounter analytics
### Critical: Feed Link in Head
```php
<?php if (!empty($feedUrl)): ?>
<link rel="alternate" type="application/atom+xml" title="<?= htmlspecialchars($pageTitle ?? 'Feed') ?>" href="<?= htmlspecialchars($feedUrl) ?>">
<?php endif; ?>
```
This MUST remain in `base.php`. It enables feed discovery for any section with `feed = true` in metadata.
### Critical: GoatCounter Analytics
```html
<img style="display:none" src="https://stopplidelsen.goatcounter.com/count?p=<?= htmlspecialchars($_SERVER['REQUEST_URI']) ?>">
```
This is the analytics tracker. Do not remove or modify the URL.
## Conventions
- Escape all user-generated content: `<?= htmlspecialchars($var) ?>`
- Pre-rendered HTML (from markdown) is safe: `<?= $content ?>`
- Use `$translations['key']` for i18n strings
- Use `$langPrefix` before all internal URLs
- Use CSS `<style>` blocks at the bottom of list templates for template-specific styles
- Use CSS nesting inside scoped selectors (e.g., `main > section { ... }`)
- Use `clamp()` for responsive sizing, avoid `@media` queries