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

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:

  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:

// 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:

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:

  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:

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:

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:

  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:

[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:

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

  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:

// 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

  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:

// 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