16 KiB
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:
// 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:
[
'type' => 'page' | 'file' | 'directory' | 'not_found',
'path' => '/full/system/path',
'files' => [...], // For page type
// ... other data
]
Logic:
- Resolve translated slugs to real paths
- Check if path exists
- If directory:
- Has subdirectories? →
type: 'directory'(list view) - Has content files only? →
type: 'page'(multi-file)
- Has subdirectories? →
- If matches file? →
type: 'file' - Otherwise →
type: 'not_found'
findAllContentFiles($dir, $lang, $defaultLang, $availableLangs)
Scans directory for content files.
Process:
- Read directory contents
- Filter for valid extensions (
.md,.html,.php) - Parse filenames for language suffix
- Filter by current language:
- Show
.{lang}.extfiles for that language - Show default files (no suffix) only if no language variant
- Show
- Sort alphanumerically
- Return array of file paths
Example:
// 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:
- Check for
metadata.iniin directory - Parse INI file with sections
- Start with base values
- Override with language-specific section if exists
- Return merged array
Example:
title = "About"
summary = "Learn about us"
[no]
title = "Om"
summary = "Lær om oss"
For Norwegian request:
loadMetadata(..., 'no', 'en') → [
'title' => 'Om', // Overridden
'summary' => 'Lær om oss' // Overridden
]
resolveTranslatedPath($ctx, $requestPath)
Maps translated slugs back to real directory names.
Example:
; In content/about/metadata.ini:
[no]
slug = "om-oss"
Request to /no/om-oss/ resolves to content/about/.
Process:
- Split path into segments
- For each segment:
- Load metadata of parent directory
- Check if any subdirectory has matching translated slug
- Replace segment with real directory name
- 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:
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:
- Convert file to HTML
- Load metadata
- Wrap in page template
- Wrap in base template
- Return HTML
renderMultipleFiles($ctx, $filePaths, $pageDir)
Renders multiple files as single page.
Process:
- Convert each file to HTML
- Concatenate HTML (in order)
- Load metadata
- Wrap in page template
- Wrap in base template
- Return HTML
Used for: Multi-file pages (documentation, long articles)
renderTemplate($ctx, $content, $statusCode = 200)
Wraps content in base template.
Process:
- Extract variables for template
- Set HTTP status code
- Include base template
- 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:
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:
$ctx = createContext();
This function:
- Loads configuration
- Extracts language from URL
- Determines content directory
- Resolves template paths
- Returns readonly Context object
5. Configuration (app/config.php)
Purpose: Load and merge configuration
Process:
- Parse
/app/config.ini(defaults) - Parse
/custom/config.iniif exists - Merge arrays (custom overrides defaults)
- Extract language settings
- Validate configuration
Configuration Used:
[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:
- Validate path (prevent directory traversal)
- Resolve real path
- Check file exists and is readable
- Determine MIME type
- Set headers
- 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:
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:
// 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:
// 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
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:
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
- OPcache: PHP bytecode caching (biggest impact)
- Web server cache: Serve cached HTML
- Reverse proxy: Varnish, Cloudflare
- Minimize file reads: Context created once per request
Security Architecture
Path Validation
Multiple layers prevent directory traversal:
// 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:
<?= htmlspecialchars($metadata['title']) ?>
This prevents XSS attacks.
MIME Type Validation
Static files served with correct MIME types:
$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:
renderTemplate($ctx, '<h1>404 Not Found</h1>', 404);
Base template rendered with 404 status.
Extension Points
Custom Templates
Override any template:
// 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
// Custom helper functions
function myCustomFunction() {
// Your code
}
Include in router:
if (file_exists(__DIR__ . '/../custom/functions.php')) {
require_once __DIR__ . '/../custom/functions.php';
}
Content Files as PHP
.php content files have full access:
<?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
- Create test content
- Start dev server:
php -S localhost:8000 -t . app/router.php - Visit URLs, verify output
- Check different languages
- Test edge cases (missing files, invalid paths)
Automated Testing (Future)
Possible test structure:
// 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
# 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)
# 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
# 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