Merge branch 'latest' of forge.dmz.skyfritt.net:stopplidelsen/innhold into latest

This commit is contained in:
Ruben Solvang 2026-02-06 20:36:03 +01:00
commit ee7143d1fa
7 changed files with 589 additions and 43 deletions

View file

@ -1,37 +1,77 @@
## Philosophy
Minimal PHP for modern conveniences. Prioritize longevity (decade-scale maintainability) by avoiding volatile dependencies. Strictly add only what's essential—readable, simple, and future-proof.
# Stopp lidelsen - Site built on FolderWeb
Advocacy site for medical cannabis patients in Norway. Built on FolderWeb (minimal file-based CMS).
## Edit instructions
All edits to this AGENT.md file should be done in a compressed shortform for LLM consumption.
## Two Repos, One Site
- `app/` is a **symlink** to the FolderWeb framework (`folderweb/app`). **NEVER modify files in `app/`.**
- Only modify files in `custom/`, `content/`, and project root.
- Framework docs: read `app/docs/` when needed for understanding routing, hooks, templates, etc.
- Site-specific docs: read `docs/` in this repo for guidance on this site's features and customizations.
## Philosophy
Minimal PHP for modern conveniences. Decade-scale maintainability. No volatile dependencies. Only essential code.
## Core Constraints
**Minimalism:** Only essential tech (HTML, PHP 8.4+, CSS). No JS, frameworks, build tools, or package managers. Comments only for major sections.
**Stack:** HTML5, PHP 8.4+, CSS. no frameworks, no build tools, no package managers.
**Frontend:**
- Classless, semantic HTML5
- Modern CSS: nesting, `oklch()`, grid, `clamp()`, logical props
- Responsive via fluid typography + flexible layouts
**Frontend:** Classless semantic HTML5. Modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props). Responsive via fluid typography + flexible layouts. Comments only for major sections.
**Security:**
- Path validation blocks traversal
- Files restricted to document root
- Strict MIME types + no direct user-input execution
**Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC, CSV injection prevention.
## Code Style
**PHP:** Modern syntax (arrow functions, null coalescing, match). Type hints where practical. Ternary for simple conditionals. Single-purpose functions.
**PHP:** Arrow functions, null coalescing, match. Type hints where practical. Single-purpose functions.
**CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units.
**Templates:** `<?= htmlspecialchars($var) ?>` for UGC. `<?= $content ?>` for pre-rendered HTML.
**CSS:** Variables, native nesting, grid layouts. `clamp()` over `@media`. Relative units > pixels.
## Project Structure
**Templates:** Escape output (`htmlspecialchars()` for UGC). Short echo tags (`<?= $var ?>`).
```
content/ # Site content (folders = URLs)
custom/
config.ini # Site config (languages, plugins)
templates/ # Template overrides (base, list, page, list-card-grid, list-faq, list-grid)
styles/ # CSS overrides
plugins/page/ # Page plugins (petition-form, newsletter-signup)
languages/ # Translation files (no.ini, en.ini)
data/ # Runtime data (petition CSVs, rate limits, SMTP logs)
vendor/ # PHPMailer.Lite
app/ -> symlink # FolderWeb framework (DO NOT EDIT)
docs/ # Site-specific LLM documentation
```
## Development Environment and workflow
## Key Features
The host does not have PHP, always use `podman compose` with the `compose.yaml` configuration when interacting with the app through CLI. Check localhost:4040 when you need to look at the development site to check your changes.
**Content:** Folders = URLs. `metadata.ini` controls titles, slugs, menu order, templates, feeds.
**Languages:** Norwegian (default), English available. Language plugin via `[en]` sections in metadata.
**Templates:** base.php wraps all pages. List templates: list.php, list-card-grid.php, list-faq.php, list-grid.php. Selected via `page_template` in metadata.
**Atom feeds:** Enabled per-section via `feed = true` in metadata. URL: `/{section}/feed.xml`. Feed link auto-added to `<head>` when `$feedUrl` is set.
**Petition system:** GDPR-compliant, CSV-based, double opt-in email confirmation, file locking, rate limiting. See `docs/petition-system.md`.
**Newsletter:** Listmonk integration via public API. Hero and small themes. See `docs/newsletter-plugin.md`.
**LLM Agent Testing Protocol:**
**Container isolation:** ALL test data (CSV, temp files, generated content) MUST remain inside container filesystem (not mounted volumes)
*Testing website:** `curl localhost:4040/path` to fetch pages, test forms with `curl -X POST -d "field=value" localhost:4040/path`
- **Test data paths:** Use `/tmp/` or `/var/test/` inside container—never write to `/var/www/html` or mounted dirs during tests
## Knowledge Base
Read these docs on-demand when working on related areas:
| Topic | File | Read When |
|---|---|---|
| Templates & variables | `docs/templates.md` | Modifying templates, understanding available variables |
| Content & metadata | `docs/content-system.md` | Adding/modifying content, metadata fields, feeds |
| Petition system | `docs/petition-system.md` | Modifying petition form, CSV format, email flow |
| Newsletter plugin | `docs/newsletter-plugin.md` | Modifying newsletter signup, Listmonk integration |
| Framework internals | `app/docs/` | Understanding routing, hooks, plugins, rendering pipeline |
## Development Environment
Host has no PHP. Always use `podman compose` with `compose.yaml`.
**Dev server:** `localhost:4040`
**Testing:** `curl localhost:4040/path` to fetch pages; `curl -X POST -d "field=value" localhost:4040/path` for forms
**Container isolation:** ALL test data (CSV, temp files) MUST stay inside container filesystem, never in mounted volumes
**Test data paths:** Use `/tmp/` or `/var/test/` inside container
**Running tests:** `podman exec stopplidelsen.no php /path/to/test.php` or `podman-compose run --rm custom php /tmp/test-script.php`
**Syntax checks:** `podman exec stopplidelsen.no php -l /var/www/custom/file.php`
**Cleanup:** `podman-compose down && podman-compose up -d` between test runs
**Syntax checks:** `podman exec stopplidelsen.no php -l /var/www/custom/file.php`
**Cleanup:** `podman-compose down && podman-compose up -d` between test runs

View file

@ -26,30 +26,12 @@ De la grunnlaget. Nå fortsetter vi kampen, sammen for dem, for oss, og for
**Britt-Inger Solheim** har lang erfaring som prosjektleder i flere internasjonale selskaper. Hun har lenge vært aktiv innen foreningsarbeid og har et stort nettverk innen pasientorganisasjoner og politikk. Hun deltar aktivt i møter, webinarer og debatter, og er et viktig bindeledd mellom pasienter og beslutningstakere.
**Geir Roger Moen** er en av Norges mest synlige stemmer for medisinsk cannabis. Han har deltatt i debatter og medieopptredener, og hans pasienthistorie har inspirert mange. Geir Roger er en viktig pådriver i vårt arbeid for å endre lovgivningen. [NRK artikkel.](https://www.nrk.no/tromsogfinnmark/sykehuset-ahus-vil-ikke-lenger-gi-geir-roger-moen-cannabismedisin-1.16258980)
**Geir Roger Moen** er en av Norges mest synlige stemmer for medisinsk cannabis. Han har deltatt i debatter og [medieopptredener](https://www.nrk.no/tromsogfinnmark/sykehuset-ahus-vil-ikke-lenger-gi-geir-roger-moen-cannabismedisin-1.16258980), og hans pasienthistorie har inspirert mange. Geir Roger er en viktig pådriver i vårt arbeid for å endre lovgivningen.
**Ruben Solvang** er pårørende og har sett hvor stor forskjell cannabismedisin kan utgjøre for pasienter som ikke får lindring av regulære medisiner. Han er teknisk altmuligmann med ansvar for nettsiden og jobber til daglig som IT-konsulent. [NRK Trygdekontoret.](https://tv.nrk.no/serie/trygdekontoret-dokumentar/sesong/2019/episode/MUHU10000619)
**Ruben Solvang** er pårørende og har sett hvor stor forskjell cannabismedisin kan utgjøre for pasienter som ikke får lindring av regulære medisiner. Han er teknisk altmuligmann med ansvar for nettsiden og jobber til daglig som IT-konsulent. Han ble også intervjuet av [NRK Trygdekontoret](https://tv.nrk.no/serie/trygdekontoret-dokumentar/sesong/2019/episode/MUHU10000619) i 2019.
Kort oppsummert setter vi pasientene i fokus og skaper forandring gjennom kunnskap og samarbeid!
Kort oppsummert setter vi pasientene i fokus og skaper forandring gjennom kunnskap, opplysning og samarbeid!
![](./cover.jpg)
## Vi står på skuldrene til kjemper
Vi vil samtidig rette en stor takk til alle som har gått foran oss i denne kampen. De første pasientene og pårørende som stod frem i Norge, stod i utfordringer som gjør dagens kamp lettere å bære. Uten deres mot og offervilje hadde vi ikke vært der vi er i dag.
De la grunnlaget. Nå fortsetter vi kampen, sammen for dem, for oss, og for alle som kommer etter.
## Møt styret
**Sofie Haughom** er utdannet fysioterapeut med master i samfunnssikkerhet. Hun tar nå master i helsevitenskap. Sofie har stor interesse for fagfeltet og har skrevet fagsammendrag, kronikker, deltatt i debatter og holdt foredrag. Sofie har også personlig erfaring fra kampen for tilgang til cannabismedisin, og har stått frem med sin pasienthistorie i [NRK Ekko](https://radio.nrk.no/podkast/ekko_-_et_aktuelt_samfunnsprogram/sesong/202004/l_d183ff97-c98d-4015-83ff-97c98d4015fc) / [NRK Dokumentar](https://www.nrk.no/dokumentar/xl/sofie-sprayer-cannabis-under-tungen-fire-ganger-om-dagen-1.13458156).
**Britt-Inger Solheim** har lang erfaring som prosjektleder i flere internasjonale selskaper. Hun har lenge vært aktiv innen foreningsarbeid og har et stort nettverk innen pasientorganisasjoner og politikk. Hun deltar aktivt i møter, webinarer og debatter, og er et viktig bindeledd mellom pasienter og beslutningstakere.
**Geir Roger Moen** er en av Norges mest synlige stemmer for medisinsk cannabis. Han har deltatt i debatter og medieopptredener, og hans pasienthistorie har inspirert mange. Geir Roger er en viktig pådriver i vårt arbeid for å endre lovgivningen.
**Ruben Solvang** er pårørende og har sett hvor stor forskjell cannabismedisin kan utgjøre for pasienter som ikke får lindring av regulære medisiner. Han er teknisk altmuligmann med ansvar for nettsiden og jobber til daglig som IT-konsulent.
Kort oppsummert setter vi pasientene i fokus og skaper forandring gjennom kunnskap og samarbeid!
<a class="button" href="/om-oss/vedtekter">Les våre vedtekter</a>

View file

@ -74,6 +74,9 @@ function getActiveClass($href) { return rtrim(parse_url($_SERVER['REQUEST_URI'],
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.png" type="image/png">
<link href="/app/fonts/pt-serif/pt-serif.css" rel="stylesheet">
<?php if (!empty($feedUrl)): ?>
<link rel="alternate" type="application/atom+xml" title="<?= htmlspecialchars($pageTitle ?? 'Feed') ?>" href="<?= htmlspecialchars($feedUrl) ?>">
<?php endif; ?>
</head>
<body class="<?php if (isset($dirName)) echo 'section-' . $dirName . ' '; ?><?php if (isset($pageName)) echo 'page-' . $pageName; ?>">

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