740 lines
16 KiB
Markdown
740 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)
|