folderweb/docs/explanation/architecture.md
2025-11-02 13:46:47 +01:00

739 lines
16 KiB
Markdown

# Architecture
Understanding how FolderWeb works under the hood.
## High-Level Overview
FolderWeb follows a simple request-response flow:
```
HTTP Request
router.php (entry point)
Parse request path
Find content files
Determine content type (page/list/file)
Render content
Wrap in templates
HTTP Response
```
## Core Components
### 1. Router (`app/router.php`)
**Purpose**: Entry point for all requests, determines what to serve
**Responsibilities**:
- Receive HTTP requests
- Check for root-level assets (`/custom/assets/`)
- Parse request path
- Dispatch to appropriate renderer
- Handle redirects (trailing slashes)
- Serve 404 for missing content
**Key Flow**:
```php
// 1. Check for root-level assets
if (file_exists("/custom/assets/$path")) {
serve_static_file();
}
// 2. Empty path = home page (render all root content files)
if (empty($path)) {
render_all_files_in_root();
}
// 3. Parse request path
$result = parseRequestPath($ctx);
// 4. Handle based on type
match($result['type']) {
'page' => renderMultipleFiles(...),
'file' => renderFile(...),
'directory' => renderListView(...),
'not_found' => show_404()
};
```
**Location**: `/app/router.php` (lines 1-100+)
### 2. Content Discovery (`app/content.php`)
**Purpose**: Find and parse content files and directories
**Key Functions**:
#### `parseRequestPath($ctx)`
Analyzes request path and determines content type.
**Returns**:
```php
[
'type' => 'page' | 'file' | 'directory' | 'not_found',
'path' => '/full/system/path',
'files' => [...], // For page type
// ... other data
]
```
**Logic**:
1. Resolve translated slugs to real paths
2. Check if path exists
3. If directory:
- Has subdirectories? → `type: 'directory'` (list view)
- Has content files only? → `type: 'page'` (multi-file)
4. If matches file? → `type: 'file'`
5. Otherwise → `type: 'not_found'`
#### `findAllContentFiles($dir, $lang, $defaultLang, $availableLangs)`
Scans directory for content files.
**Process**:
1. Read directory contents
2. Filter for valid extensions (`.md`, `.html`, `.php`)
3. Parse filenames for language suffix
4. Filter by current language:
- Show `.{lang}.ext` files for that language
- Show default files (no suffix) only if no language variant
5. Sort alphanumerically
6. Return array of file paths
**Example**:
```php
// Directory contains:
// - index.md
// - index.no.md
// - about.md
// English request (lang=en, default=en):
findAllContentFiles() ['index.md', 'about.md']
// Norwegian request (lang=no):
findAllContentFiles() ['index.no.md', 'about.md']
```
#### `loadMetadata($dirPath, $lang, $defaultLang)`
Loads and merges metadata for a directory.
**Process**:
1. Check for `metadata.ini` in directory
2. Parse INI file with sections
3. Start with base values
4. Override with language-specific section if exists
5. Return merged array
**Example**:
```ini
title = "About"
summary = "Learn about us"
[no]
title = "Om"
summary = "Lær om oss"
```
For Norwegian request:
```php
loadMetadata(..., 'no', 'en') [
'title' => 'Om', // Overridden
'summary' => 'Lær om oss' // Overridden
]
```
#### `resolveTranslatedPath($ctx, $requestPath)`
Maps translated slugs back to real directory names.
**Example**:
```ini
; In content/about/metadata.ini:
[no]
slug = "om-oss"
```
Request to `/no/om-oss/` resolves to `content/about/`.
**Process**:
1. Split path into segments
2. For each segment:
- Load metadata of parent directory
- Check if any subdirectory has matching translated slug
- Replace segment with real directory name
3. Return resolved path
### 3. Rendering Engine (`app/rendering.php`)
**Purpose**: Convert content to HTML and wrap in templates
**Key Functions**:
#### `renderContentFile($filePath)`
Converts a single content file to HTML.
**Process**:
```php
switch (extension) {
case 'md':
return Parsedown->text(file_contents);
case 'html':
return file_contents;
case 'php':
ob_start();
include $filePath; // $ctx available
return ob_get_clean();
}
```
#### `renderFile($ctx, $filePath)`
Renders single file wrapped in templates.
**Process**:
1. Convert file to HTML
2. Load metadata
3. Wrap in page template
4. Wrap in base template
5. Return HTML
#### `renderMultipleFiles($ctx, $filePaths, $pageDir)`
Renders multiple files as single page.
**Process**:
1. Convert each file to HTML
2. Concatenate HTML (in order)
3. Load metadata
4. Wrap in page template
5. Wrap in base template
6. Return HTML
**Used for**: Multi-file pages (documentation, long articles)
#### `renderTemplate($ctx, $content, $statusCode = 200)`
Wraps content in base template.
**Process**:
1. Extract variables for template
2. Set HTTP status code
3. Include base template
4. Return HTML
**Variables provided**:
- `$content` - Rendered HTML
- `$ctx` - Context object
- `$currentLang`, `$navigation`, `$homeLabel`, etc.
### 4. Context Object (`app/context.php`)
**Purpose**: Immutable request context with computed properties
**Implementation**:
```php
readonly class Context {
public function __construct(
public private(set) string $contentDir,
public private(set) string $currentLang,
// ... other properties
) {}
// Computed property (PHP 8.4 hook)
public string $langPrefix {
get => $this->currentLang !== $this->defaultLang
? "/{$this->currentLang}"
: '';
}
// Lazy-loaded computed property
public array $navigation {
get => buildNavigation($this);
}
}
```
**Benefits**:
- **Immutability**: Cannot be changed after creation
- **Type safety**: All properties typed
- **Computed values**: Calculated on-demand
- **No globals**: Passed explicitly
**Creation**:
```php
$ctx = createContext();
```
This function:
1. Loads configuration
2. Extracts language from URL
3. Determines content directory
4. Resolves template paths
5. Returns readonly Context object
### 5. Configuration (`app/config.php`)
**Purpose**: Load and merge configuration
**Process**:
1. Parse `/app/config.ini` (defaults)
2. Parse `/custom/config.ini` if exists
3. Merge arrays (custom overrides defaults)
4. Extract language settings
5. Validate configuration
**Configuration Used**:
```ini
[languages]
default = "en"
available = "en,no,fr"
```
### 6. Helper Functions (`app/helpers.php`)
**Purpose**: Utility functions used throughout
**Key Helpers**:
| Function | Purpose |
|----------|---------|
| `resolveTemplate($name, $type)` | Find custom or default template |
| `getSubdirectories($dir)` | List subdirectories only |
| `extractTitle($filePath, $lang, $defaultLang)` | Extract H1 from content |
| `formatNorwegianDate($date)` | Format date as "2. november 2025" |
| `extractDateFromFolder($name)` | Parse date from folder name |
| `findCoverImage($dir)` | Locate cover image |
| `findPdfFile($dir)` | Find first PDF |
### 7. Static File Server (`app/static.php`)
**Purpose**: Serve CSS, fonts, and other static assets
**Process**:
1. Validate path (prevent directory traversal)
2. Resolve real path
3. Check file exists and is readable
4. Determine MIME type
5. Set headers
6. Output file contents
**Routes**:
- `/app/styles/base.css` → Custom or default CSS
- `/app/default-styles/base.css` → Default CSS
- `/custom/fonts/*` → Custom fonts
## Data Flow
### Request Flow Diagram
```
1. HTTP Request: /blog/2025-11-02-post/
2. router.php receives request
3. createContext()
├─ Load config
├─ Extract language from URL
├─ Determine content directory
└─ Return Context object
4. parseRequestPath($ctx)
├─ resolveTranslatedPath() - map slug to real path
├─ Check path exists
├─ findAllContentFiles() - scan for content
└─ Return ['type' => 'file', 'path' => '...']
5. renderFile($ctx, $filePath)
├─ renderContentFile() - convert to HTML
├─ loadMetadata() - get metadata
├─ Apply page template
└─ Apply base template
6. HTTP Response: HTML
```
### List View Flow
```
1. Request: /blog/
2. parseRequestPath() → type: 'directory'
3. Load directory metadata
├─ Get page_template setting
└─ Get other directory metadata
4. getSubdirectories() - find all subdirs
5. For each subdirectory:
├─ loadMetadata() - get title, date, summary
├─ findCoverImage() - locate cover
├─ findPdfFile() - locate PDF
└─ Build item array
6. Render list template with $items
7. Wrap in base template
8. Return HTML
```
## File Organization
### Separation of Concerns
```
app/
├── router.php # Entry point, request handling
├── content.php # Content discovery, parsing
├── rendering.php # HTML generation, templates
├── context.php # Request context
├── config.php # Configuration loading
├── helpers.php # Utility functions
├── constants.php # Constants (extensions)
└── static.php # Static file serving
```
Each file has a single responsibility.
### Template Resolution
Templates use fallback chain:
```
1. /custom/templates/{name}.php
↓ (if not found)
2. /app/default/templates/{name}.php
```
**Implementation**:
```php
function resolveTemplate($name, $type = 'templates') {
$custom = __DIR__ . "/../custom/$type/$name";
$default = __DIR__ . "/default/$type/$name";
return file_exists($custom) ? $custom : $default;
}
```
This pattern applies to:
- Templates
- Styles
- Languages
- Any overridable resource
## Language Handling
### URL Structure
```
/ → Default language
/no/ → Norwegian
/fr/page/ → French page
```
### Language Extraction
From URL path:
```php
// Request: /no/blog/post/
$segments = explode('/', trim($path, '/'));
$firstSegment = $segments[0] ?? '';
if (in_array($firstSegment, $availableLangs)) {
$currentLang = $firstSegment;
$pathWithoutLang = implode('/', array_slice($segments, 1));
} else {
$currentLang = $defaultLang;
$pathWithoutLang = $path;
}
```
### Content Filtering
Files with language suffixes (`.{lang}.ext`) are filtered:
```php
// Parse filename
$parts = explode('.', $filename);
$lastPart = $parts[count($parts) - 2] ?? null;
// Check if second-to-last part is a language
if (in_array($lastPart, $availableLangs)) {
$fileLang = $lastPart;
} else {
$fileLang = $defaultLang;
}
// Include file if:
// - It matches current language, OR
// - It's default language AND no specific variant exists
```
## Navigation Building
### Process
```php
function buildNavigation($ctx) {
$items = [];
// 1. Scan content root for directories
$dirs = getSubdirectories($ctx->contentDir);
// 2. For each directory
foreach ($dirs as $dir) {
// Load metadata
$metadata = loadMetadata($dir, $ctx->currentLang, $ctx->defaultLang);
// Skip if menu = false
if (!($metadata['menu'] ?? false)) continue;
// Build item
$items[] = [
'title' => $metadata['title'] ?? basename($dir),
'url' => $ctx->langPrefix . '/' . basename($dir) . '/',
'order' => $metadata['menu_order'] ?? 999
];
}
// 3. Sort by menu_order
usort($items, fn($a, $b) => $a['order'] <=> $b['order']);
return $items;
}
```
### Caching
Navigation is a computed property, calculated once per request:
```php
public array $navigation {
get => buildNavigation($this);
}
```
PHP memoizes the result automatically.
## Performance Characteristics
### Time Complexity
| Operation | Complexity | Notes |
|-----------|------------|-------|
| Route resolution | O(1) | Direct file checks |
| Content file scan | O(n) | n = files in directory |
| Metadata loading | O(1) | Single file read |
| Template rendering | O(m) | m = content size |
| Navigation build | O(d) | d = top-level directories |
### Space Complexity
- **Memory**: O(c) where c = content size
- **No caching**: Each request independent
- **Stateless**: No session storage
### Optimization Points
1. **OPcache**: PHP bytecode caching (biggest impact)
2. **Web server cache**: Serve cached HTML
3. **Reverse proxy**: Varnish, Cloudflare
4. **Minimize file reads**: Context created once per request
## Security Architecture
### Path Validation
Multiple layers prevent directory traversal:
```php
// 1. Remove .. segments
$path = str_replace('..', '', $path);
// 2. Resolve to real path
$realPath = realpath($path);
// 3. Ensure within content directory
if (!str_starts_with($realPath, $contentDir)) {
return 404;
}
// 4. Check readable
if (!is_readable($realPath)) {
return 404;
}
```
### Output Escaping
All user-generated content escaped:
```php
<?= htmlspecialchars($metadata['title']) ?>
```
This prevents XSS attacks.
### MIME Type Validation
Static files served with correct MIME types:
```php
$mimeTypes = [
'css' => 'text/css',
'woff2' => 'font/woff2',
'jpg' => 'image/jpeg',
// ...
];
header('Content-Type: ' . $mimeTypes[$extension]);
```
## Error Handling
### HTTP Status Codes
- **200 OK**: Successful content render
- **301 Moved Permanently**: Missing trailing slash
- **404 Not Found**: Content doesn't exist
### 404 Handling
When content not found:
```php
renderTemplate($ctx, '<h1>404 Not Found</h1>', 404);
```
Base template rendered with 404 status.
## Extension Points
### Custom Templates
Override any template:
```php
// Framework checks:
$custom = '/custom/templates/list-my-custom.php';
if (file_exists($custom)) {
include $custom;
} else {
include '/app/default/templates/list.php';
}
```
### Custom Functions
Add your own in `/custom/functions.php`:
```php
<?php
// Custom helper functions
function myCustomFunction() {
// Your code
}
```
Include in router:
```php
if (file_exists(__DIR__ . '/../custom/functions.php')) {
require_once __DIR__ . '/../custom/functions.php';
}
```
### Content Files as PHP
`.php` content files have full access:
```php
<?php
// In content/dynamic/index.php
$currentTime = date('Y-m-d H:i:s');
?>
# Dynamic Content
Current time: <?= $currentTime ?>
The language is: <?= $ctx->currentLang ?>
```
## Testing Architecture
### Manual Testing
1. Create test content
2. Start dev server: `php -S localhost:8000 -t . app/router.php`
3. Visit URLs, verify output
4. Check different languages
5. Test edge cases (missing files, invalid paths)
### Automated Testing (Future)
Possible test structure:
```php
// tests/RouterTest.php
test('renders home page', function() {
$response = request('/');
expect($response->status)->toBe(200);
expect($response->body)->toContain('<h1>');
});
test('handles 404', function() {
$response = request('/nonexistent/');
expect($response->status)->toBe(404);
});
```
## Deployment Architecture
### Simple Deployment
```bash
# 1. Clone repository
git clone https://github.com/you/your-site
# 2. Point web server to directory
# Document root: /path/to/site
# Rewrite all requests to: /app/router.php
# 3. Done
```
### With Build Step (Optional)
```bash
# 1. Clone and build
git clone ...
cd site
# 2. Process custom styles (optional)
# E.g., PostCSS, autoprefixer
# 3. Deploy
rsync -av . server:/var/www/site/
```
### Zero-Downtime Deployment
```bash
# 1. Deploy to new directory
rsync -av . server:/var/www/site-new/
# 2. Symlink switch
ln -sfn /var/www/site-new /var/www/site-current
# 3. Web server serves from /var/www/site-current
```
## Related
- [Philosophy](philosophy.md)
- [Getting Started Tutorial](../tutorial/00-getting-started.md)
- [File Structure Reference](../reference/file-structure.md)
- [Template Reference](../reference/templates.md)