commit 2994f7cf6de742d2ad64e79194dd8dffec248de3 Author: Ruben Date: Thu Oct 2 16:54:47 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86c47f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/custom* +/content* +/docs* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..64b46b8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,135 @@ +# PnP Development Guidelines + +## Philosophy + +**Just enough, nothing more.** This framework applies minimal PHP to enable modern conveniences while remaining maintainable for years or decades. Avoid rapidly changing components and dependencies. The code should be readable, simple, and strictly necessary. + +## Core Principles + +### Minimalism +- Use only what is strictly necessary to achieve the goal +- No frameworks, no build tools, no package managers for frontend code +- Avoid abstractions unless they provide clear, lasting value +- Sparse commenting—only mark main sections + +### Technology Stack +- **Allowed:** HTML, PHP (8.3+), CSS +- **Not allowed:** JavaScript +- Use modern PHP features when they improve readability or performance +- Leverage modern CSS features for smart, efficient styling + +### File-Based Routing +- Folder hierarchy dictates URL structure +- Drop a file (`.md`, `.php`, `.html`) in a folder and it renders immediately +- Assets placed in content directories are automatically accessible +- Frontpage is handled by `/content/frontpage.php` +- Directories with subfolders trigger list views + +### Template System +- Custom templates override defaults (never modify defaults) +- Custom templates live in `/app/custom/` +- Default templates provide fallback behavior +- Docs get their own template variants for specialized presentation +- Templates use PHP includes—simple and straightforward + +### Content Conventions + +#### File Naming +- Page files: `page.php`, `page.html`, `page.md` +- Single/article files: `article.md`, `post.md`, `single.md` (and `.php`/`.html` variants) +- Index overrides: `index.php` in any directory takes precedence +- Cover images: `cover.jpg`, `cover.webp`, etc. + +#### Date Formatting +- Folder names can include dates: `YYYY-MM-DD-title` +- Dates are automatically extracted and formatted (Norwegian format: "23. oktober 2025") +- Metadata can override automatic date detection + +#### Metadata +- Use `metadata.ini` files for structured data +- Common fields: `title`, `date`, `summary` +- Metadata overrides automatic title/date extraction + +### HTML & CSS Standards +- Classless CSS where possible +- HTML should be highly compliant with best practices +- Use semantic HTML5 elements +- Modern CSS features: custom properties, nesting, `oklch()` colors, grid, clamp, logical properties +- Responsive by default using fluid typography and flexible layouts + +### Security +- Path validation prevents directory traversal +- Files must be within document root +- MIME types properly set for all served content +- No direct execution of arbitrary user input + +### Code Style + +#### PHP +- Modern syntax: arrow functions, null coalescing, match expressions +- Use type hints when practical +- Ternary operators for simple conditionals +- Keep functions focused and single-purpose + +#### CSS +- Use CSS variables for theming +- Nesting for component-scoped styles +- Grid for layout, not tables or excessive flexbox +- `clamp()` for responsive sizing +- Avoid pixel values where relative units work better + +#### Templates +- Escape output: `htmlspecialchars()` for user-generated content +- Short echo tags: `` +- Minimize logic in templates—prepare data beforehand + +### Performance Considerations +- Page load time displayed in footer (transparency and pride in performance) +- CSS versioned with MD5 hash for cache busting +- Minimal HTTP requests through direct includes +- No JavaScript means no parsing delay + +### Extensibility Patterns +- Third-party code goes in `/app/vendor/` +- Custom code goes in `/app/custom/` +- Never modify defaults—always override +- Use fallback chains: custom → default → docs variant + +## Common Patterns + +### Adding Custom Styles +Place CSS in `/app/custom/styles/base.css`—it automatically overrides default styles. + +### Creating Custom Templates +Place templates in `/app/custom/templates/`—they automatically override defaults. + +### Adding Fonts +Place font files in `/app/custom/fonts/` and reference them in your custom CSS. + +### Creating Content +1. Create a directory for your content +2. Add a content file: `page.md`, `article.md`, etc. +3. Optionally add `metadata.ini` for title, date, summary +4. Optionally add `cover.jpg` for list view thumbnails +5. Content is immediately accessible at the URL matching the directory path + +### List Views +When a directory contains subdirectories: +- Default list template generates automatic listings +- Each item shows title, date, optional cover image, optional summary +- Override with custom list template for specialized presentation + +## What This Framework Is Not + +- Not a CMS with an admin panel +- Not a single-page application +- Not a JavaScript framework +- Not opinionated about design aesthetics +- Not trying to scale to millions of users + +## What This Framework Is + +- A simple, intuitive way to publish content +- A foundation that will work for decades +- A teaching tool for understanding web fundamentals +- A protest against complexity for complexity's sake diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8c142a --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# PNP (Plug'n Play) - A 'just enough' PHP framework for simple websites + +HTML lacks native support for including repeating elements, like the site header and footer. By using PHP sparsely, we can add this feature and some other modern conviniences, while providing a nearly maintanance-free framework that will chugh along for years, possibly decades, by avoiding a bunch of rapidly changing components and dependencies. Since reasonably up-to-date PHP versions is available at the vast majority of shared hosting providers, it makes sence to combine HTML with PHP. + +The mission statement is to apply just enough PHP to make the core features work. No admin panel, just an intuitive folder-based workflow. Intuitive defaults are applied, with optional overrides and customizations. + +## Main features from the users perspective + +- The user should be able to create a new folder and drop in a markdown, PHP or HTML file and have it rendered imediatly. The directory hierarchy dictate the URL, for example `/www/articles/2025/10-23-title-of-article/article.md` would translate to the following URL: `domain.tld/articles/2025/-10-23-title-of-article/`. +- The user can drop images and files in a directory and use them as assets. If a user drops a jpeg or webp into the folder mentioned above, the image can be refferenced in the markdown, like so: `[!alt text for the image](filname.jpg). The image will be renderend inline in the text. The same goes for PDF's or other document files, only they will not be rendered, but can be linked to. +- If a directory contains one or more subfolders, it is assumed that it is a parent folder containing multiple instances of the same type of content, for example a list of articles, blog posts and so on. A default list view will be applied showing title and date, but the view can be overriden and defined for each content type / folder. + +## Main features from a developers perspective + +- The code is readable and adds nothing more than strictly neccessary +- Only HTML, PHP and CSS is allowed – no JS! +- Sparsely commented, only the main sections +- Custom templates is located at /app/custom +- Third party scripts are added to /app/vendors +- Supports PHP 8.3+ and makes use of modern PHP features whenever possible +- Uses a PHP to make a simple router +- Uses .htaccess to make the PHP router handle all requests. +- The HTML is classles and highly compliant with best practices +- The CSS uses modern features to apply styles as effective and smart as possible diff --git a/app/config.ini b/app/config.ini new file mode 100644 index 0000000..d38a320 --- /dev/null +++ b/app/config.ini @@ -0,0 +1,8 @@ +; PnP Framework Configuration + +[languages] +; Default language (used when no language prefix in URL) +default = "no" + +; Available languages (comma-separated language codes) +available = "no,en" diff --git a/app/default/config.ini b/app/default/config.ini new file mode 100644 index 0000000..67c9dd9 --- /dev/null +++ b/app/default/config.ini @@ -0,0 +1,3 @@ +[languages] +default = "en" +available = "en" diff --git a/app/default/docs/styles/base.css b/app/default/docs/styles/base.css new file mode 100644 index 0000000..b2b0a64 --- /dev/null +++ b/app/default/docs/styles/base.css @@ -0,0 +1,214 @@ +/* MINIMAL RESET */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +/* VARIABLES */ +:root { + --font-body: Georgia, "Times New Roman", serif; + --font-heading: system-ui, -apple-system, sans-serif; + --color-primary: #2563eb; + --color-primary-dark: #1e40af; + --color-bg: #f8fafc; + --color-bg-alt: #ffffff; + --color-text: #1e293b; + --color-text-light: #64748b; + --color-border: #e2e8f0; +} + +/* GLOBAL */ +html { + font-family: var(--font-body); + font-size: 18px; + line-height: 1.7; +} + +body { + margin: 0; + color: var(--color-text); + background-color: var(--color-bg); +} + +img { max-width: 100%; height: auto; } + +a { + color: var(--color-primary); + text-decoration: none; +} +a:hover { + color: var(--color-primary-dark); + text-decoration: underline; +} + +/* LAYOUT */ +.docs-container { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 250px 1fr; + gap: 2rem; + padding: 2rem 1rem; +} + +@media (max-width: 768px) { + .docs-container { + grid-template-columns: 1fr; + } +} + +/* HEADER */ +header { + grid-column: 1 / -1; + border-bottom: 2px solid var(--color-border); + padding-bottom: 1rem; + margin-bottom: 1rem; +} + +header h1 { + font-family: var(--font-heading); + font-size: 1.8rem; + color: var(--color-primary); + font-weight: 600; +} + +/* SIDEBAR */ +.sidebar { + font-size: 0.9rem; +} + +.sidebar h2 { + font-family: var(--font-heading); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-light); + margin-bottom: 0.5rem; + margin-top: 1.5rem; +} + +.sidebar h2:first-child { + margin-top: 0; +} + +.sidebar ul { + list-style: none; + margin-bottom: 1rem; +} + +.sidebar li { + margin-bottom: 0.25rem; +} + +.sidebar a { + display: block; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; +} + +.sidebar a:hover { + background-color: var(--color-bg); + text-decoration: none; +} + +/* MAIN CONTENT */ +main { + background-color: var(--color-bg-alt); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +article h1 { + font-family: var(--font-heading); + font-size: 2.2rem; + margin-bottom: 1rem; + color: var(--color-text); + font-weight: 600; + line-height: 1.2; +} + +article h2 { + font-family: var(--font-heading); + font-size: 1.6rem; + margin-top: 2rem; + margin-bottom: 0.75rem; + color: var(--color-text); + font-weight: 600; +} + +article h3 { + font-family: var(--font-heading); + font-size: 1.2rem; + margin-top: 1.5rem; + margin-bottom: 0.5rem; + color: var(--color-text); + font-weight: 600; +} + +article p { + margin-bottom: 1rem; +} + +article ul, article ol { + margin-bottom: 1rem; + padding-left: 2rem; +} + +article li { + margin-bottom: 0.5rem; +} + +article code { + background-color: var(--color-bg); + padding: 0.2rem 0.4rem; + border-radius: 0.25rem; + font-size: 0.9em; + font-family: 'Courier New', monospace; +} + +article pre { + background-color: var(--color-bg); + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin-bottom: 1rem; + border: 1px solid var(--color-border); +} + +article pre code { + background: none; + padding: 0; +} + +article blockquote { + border-left: 4px solid var(--color-primary); + padding-left: 1rem; + margin: 1rem 0; + color: var(--color-text-light); + font-style: italic; +} + +/* LIST VIEW */ +.doc-list article h1 { + font-size: 1.4rem; + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +.doc-list article h1:first-of-type { + margin-top: 0; +} + +.doc-list p { + color: var(--color-text-light); + font-size: 0.95rem; +} + +/* FOOTER */ +footer { + grid-column: 1 / -1; + border-top: 2px solid var(--color-border); + padding-top: 1rem; + margin-top: 2rem; + text-align: center; + font-size: 0.85rem; + color: var(--color-text-light); +} diff --git a/app/default/docs/templates/base.php b/app/default/docs/templates/base.php new file mode 100644 index 0000000..a358672 --- /dev/null +++ b/app/default/docs/templates/base.php @@ -0,0 +1,46 @@ + + + + + + + <?= $pageTitle ?? 'Documentation' ?> + + +
+
+

PnP Documentation

+
+ + + +
+ +
+ +
+

PnP Framework Documentation

+
+
+ + diff --git a/app/default/docs/templates/list.php b/app/default/docs/templates/list.php new file mode 100644 index 0000000..7ec8f6b --- /dev/null +++ b/app/default/docs/templates/list.php @@ -0,0 +1,14 @@ +
+ + + +
diff --git a/app/default/styles/base.css b/app/default/styles/base.css new file mode 100644 index 0000000..dcaff10 --- /dev/null +++ b/app/default/styles/base.css @@ -0,0 +1,47 @@ +/* MINIMAL RESET */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +/* GLOBAL */ +body { + font-family: system-ui, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 1rem; +} + +img { max-width: 100%; height: auto; } + +a { color: #0066cc; text-decoration: none; } +a:hover { text-decoration: underline; } + +/* HEADER */ +header { + border-bottom: 2px solid #eee; + padding-bottom: 1rem; + margin-bottom: 2rem; +} + +header h1 { font-size: 1.5rem; } + +/* MAIN */ +main { margin-bottom: 2rem; } + +article { margin-bottom: 2rem; } + +h1 { + font-size: 1.8rem; + margin-bottom: 0.5rem; +} + +p { margin-bottom: 1rem; } + +/* FOOTER */ +footer { + border-top: 2px solid #eee; + padding-top: 1rem; + text-align: center; + font-size: 0.9rem; + color: #666; +} diff --git a/app/default/templates/base.php b/app/default/templates/base.php new file mode 100644 index 0000000..9d6010d --- /dev/null +++ b/app/default/templates/base.php @@ -0,0 +1,29 @@ + + + + + + + <?= htmlspecialchars($pageTitle ?? 'Site') ?> + + +
+

Webfolder demo

+ + + +
+
+ +
+ + + diff --git a/app/default/templates/list.php b/app/default/templates/list.php new file mode 100644 index 0000000..40dcffe --- /dev/null +++ b/app/default/templates/list.php @@ -0,0 +1,18 @@ +
+ +
+ + <?= htmlspecialchars($item['title']) ?> + +

+ + + +

+

+ +

+ +
+ +
diff --git a/app/router.php b/app/router.php new file mode 100644 index 0000000..aba6f92 --- /dev/null +++ b/app/router.php @@ -0,0 +1,414 @@ + [], 'single' => []]; + + foreach ($extensions as $ext) { + // Language-specific files first (if not default language) + if ($lang !== $defaultLang) { + $patterns['page'][] = "page.$lang.$ext"; + $patterns['single'][] = "single.$lang.$ext"; + $patterns['single'][] = "post.$lang.$ext"; + $patterns['single'][] = "article.$lang.$ext"; + } + + // Default files + $patterns['page'][] = "page.$ext"; + $patterns['single'][] = "single.$ext"; + $patterns['single'][] = "post.$ext"; + $patterns['single'][] = "article.$ext"; + } + + return $patterns; +} + +$pageFilePatterns = buildFilePatterns($currentLang, $defaultLang); + +function findMatchingFile(string $dir, array $patterns): ?string { + foreach ($patterns as $pattern) { + if (file_exists($file = "$dir/$pattern")) return $file; + } + return null; +} + +function resolveTranslatedPath(string $requestPath, string $contentDir, string $lang, string $defaultLang): string { + // If default language, no translation needed + if ($lang === $defaultLang) { + return $requestPath; + } + + $parts = explode('/', trim($requestPath, '/')); + $resolvedParts = []; + $currentPath = $contentDir; + + foreach ($parts as $segment) { + if (empty($segment)) continue; + + // Check all subdirectories for slug matches + $found = false; + if (is_dir($currentPath)) { + $subdirs = array_filter( + scandir($currentPath) ?: [], + fn($item) => !in_array($item, ['.', '..']) && is_dir("$currentPath/$item") + ); + + foreach ($subdirs as $dir) { + $metadata = loadMetadata("$currentPath/$dir", $lang, $defaultLang); + if ($metadata && isset($metadata['slug']) && $metadata['slug'] === $segment) { + $resolvedParts[] = $dir; + $currentPath .= "/$dir"; + $found = true; + break; + } + } + } + + // If no slug match, use segment as-is + if (!$found) { + $resolvedParts[] = $segment; + $currentPath .= "/$segment"; + } + } + + return implode('/', $resolvedParts); +} + +function parseRequestPath(string $requestPath, string $contentDir, array $patterns, bool $hasTrailingSlash, string $lang, string $defaultLang): array { + // Resolve translated slugs to actual directory names + $resolvedPath = resolveTranslatedPath($requestPath, $contentDir, $lang, $defaultLang); + $contentPath = rtrim($contentDir, '/') . '/' . ltrim($resolvedPath, '/'); + + if (is_file($contentPath)) { + return ['type' => 'file', 'path' => realpath($contentPath)]; + } + + if (is_dir($contentPath)) { + if ($file = findMatchingFile($contentPath, $patterns['single']) ?: findMatchingFile($contentPath, $patterns['page'])) { + return ['type' => 'file', 'path' => $file, 'needsSlash' => !$hasTrailingSlash]; + } + return ['type' => 'directory', 'path' => realpath($contentPath)]; + } + + return ['type' => 'not_found', 'path' => $contentPath]; +} + +function loadMetadata(string $dirPath, string $lang, string $defaultLang): ?array { + $metadataFile = "$dirPath/metadata.ini"; + if (!file_exists($metadataFile)) return null; + + $metadata = parse_ini_file($metadataFile, true); + if (!$metadata) return null; + + // Extract base metadata (non-section values) + $baseMetadata = array_filter($metadata, fn($key) => !is_array($metadata[$key]), ARRAY_FILTER_USE_KEY); + + // If current language is not default, merge language-specific overrides + if ($lang !== $defaultLang && isset($metadata[$lang]) && is_array($metadata[$lang])) { + $baseMetadata = array_merge($baseMetadata, $metadata[$lang]); + } + + return $baseMetadata ?: null; +} + +function extractTitle(string $filePath, array $patterns): ?string { + $file = findMatchingFile($filePath, $patterns['single']) ?: findMatchingFile($filePath, $patterns['page']); + if (!$file) return null; + + $ext = pathinfo($file, PATHINFO_EXTENSION); + $content = file_get_contents($file); + + if ($ext === 'md' && preg_match('/^#\s+(.+)$/m', $content, $matches)) { + return trim($matches[1]); + } + if (in_array($ext, ['html', 'php']) && preg_match('/]*>(.*?)<\/h1>/i', $content, $matches)) { + return strip_tags($matches[1]); + } + return null; +} + +function formatNorwegianDate(string $dateString): string { + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $dateString, $matches)) { + $months = ['januar', 'februar', 'mars', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'desember']; + $day = (int)$matches[3]; + $month = $months[(int)$matches[2] - 1]; + $year = $matches[1]; + return "$day. $month $year"; + } + return $dateString; +} + +function extractDateFromFolder(string $folderName): ?string { + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})-/', $folderName, $matches)) { + return formatNorwegianDate($matches[1] . '-' . $matches[2] . '-' . $matches[3]); + } + return null; +} + +function findCoverImage(string $dirPath): ?string { + $extensions = ['jpg', 'jpeg', 'png', 'webp', 'gif']; + foreach ($extensions as $ext) { + if (file_exists("$dirPath/cover.$ext")) { + return "cover.$ext"; + } + } + return null; +} + +function buildNavigation(string $contentDir, string $currentLang, string $defaultLang, array $pageFilePatterns): array { + $navItems = []; + + // Scan top-level directories in content + $items = array_filter( + scandir($contentDir) ?: [], + fn($item) => !in_array($item, ['.', '..']) && is_dir("$contentDir/$item") + ); + + foreach ($items as $item) { + $itemPath = "$contentDir/$item"; + $metadata = loadMetadata($itemPath, $currentLang, $defaultLang); + + // Check if this item should be in menu + if (!$metadata || empty($metadata['menu'])) { + continue; + } + + // Check if content exists for current language + if ($currentLang !== $defaultLang) { + $extensions = ['php', 'html', 'md']; + $hasContent = false; + foreach ($extensions as $ext) { + if (file_exists("$itemPath/single.$currentLang.$ext") || + file_exists("$itemPath/post.$currentLang.$ext") || + file_exists("$itemPath/article.$currentLang.$ext") || + file_exists("$itemPath/page.$currentLang.$ext")) { + $hasContent = true; + break; + } + } + if (!$hasContent) continue; + } + + // Extract title and build URL + $title = $metadata['title'] ?? extractTitle($itemPath, $pageFilePatterns) ?? $item; + $langPrefix = $currentLang !== $defaultLang ? "/$currentLang" : ''; + + // Use translated slug if available + $urlSlug = ($currentLang !== $defaultLang && $metadata && isset($metadata['slug'])) + ? $metadata['slug'] + : $item; + + $navItems[] = [ + 'title' => $title, + 'url' => $langPrefix . '/' . urlencode($urlSlug) . '/', + 'order' => (int)($metadata['menu_order'] ?? 999) + ]; + } + + // Sort by menu_order + usort($navItems, fn($a, $b) => $a['order'] <=> $b['order']); + + return $navItems; +} + +function renderTemplate(string $content, int $statusCode = 200): void { + global $baseTemplate, $contentDir, $currentLang, $defaultLang, $pageFilePatterns; + + // Build navigation for templates + $navigation = buildNavigation($contentDir, $currentLang, $defaultLang, $pageFilePatterns); + + // Load frontpage metadata for home button label + $frontpageMetadata = loadMetadata($contentDir, $currentLang, $defaultLang); + $homeLabel = $frontpageMetadata['slug'] ?? 'Home'; + + http_response_code($statusCode); + include $baseTemplate; + exit; +} + +function renderFile(string $filePath): void { + global $baseTemplate, $contentDir, $currentLang, $defaultLang, $pageFilePatterns; + + $realPath = realpath($filePath); + if (!$realPath || !str_starts_with($realPath, $contentDir) || !is_readable($realPath)) { + renderTemplate("

403 Forbidden

Access denied.

", 403); + } + + $ext = pathinfo($realPath, PATHINFO_EXTENSION); + + if (in_array($ext, ['php', 'html', 'md'])) { + ob_start(); + if ($ext === 'md') { + if (!class_exists('Parsedown')) { + require_once __DIR__ . '/vendor/Parsedown.php'; + } + echo '
' . (new Parsedown())->text(file_get_contents($realPath)) . '
'; + } else { + include $realPath; + } + $content = ob_get_clean(); + + // Build navigation for templates + $navigation = buildNavigation($contentDir, $currentLang, $defaultLang, $pageFilePatterns); + + // Load metadata for current page/directory + $pageDir = dirname($realPath); + $pageMetadata = loadMetadata($pageDir, $currentLang, $defaultLang); + $pageTitle = $pageMetadata['title'] ?? null; + + // Load frontpage metadata for home button label + $frontpageMetadata = loadMetadata($contentDir, $currentLang, $defaultLang); + $homeLabel = $frontpageMetadata['slug'] ?? 'Home'; + + include $baseTemplate; + exit; + } + + // Serve other file types directly + header('Content-Type: ' . (mime_content_type($realPath) ?: 'application/octet-stream')); + readfile($realPath); + exit; +} + +// Handle frontpage +if (empty($requestPath)) { + // Try language-specific frontpage first, then default + $frontPage = null; + if ($currentLang !== $defaultLang && file_exists("$contentDir/frontpage.$currentLang.php")) { + $frontPage = "$contentDir/frontpage.$currentLang.php"; + } elseif (file_exists("$contentDir/frontpage.php")) { + $frontPage = "$contentDir/frontpage.php"; + } + + if ($frontPage) { + renderFile($frontPage); + } +} + +// Parse and handle request +$parsedPath = parseRequestPath($requestPath, $contentDir, $pageFilePatterns, $hasTrailingSlash, $currentLang, $defaultLang); + +switch ($parsedPath['type']) { + case 'file': + // Redirect to add trailing slash if this is a directory-based page + if (!empty($parsedPath['needsSlash'])) { + header('Location: ' . rtrim($_SERVER['REQUEST_URI'], '/') . '/', true, 301); + exit; + } + renderFile($parsedPath['path']); + + case 'directory': + $dir = $parsedPath['path']; + if (file_exists("$dir/index.php")) { + renderFile("$dir/index.php"); + } + + // Default directory listing + $subdirs = array_filter( + scandir($dir) ?: [], + fn($item) => !in_array($item, ['.', '..']) && is_dir("$dir/$item") + ); + + $items = array_filter(array_map(function($item) use ($dir, $requestPath, $currentLang, $defaultLang, $pageFilePatterns) { + $itemPath = "$dir/$item"; + + // Check if content exists for current language + if ($currentLang !== $defaultLang) { + // For non-default languages, only check language-specific files + $extensions = ['php', 'html', 'md']; + $hasContent = false; + foreach ($extensions as $ext) { + if (file_exists("$itemPath/single.$currentLang.$ext") || + file_exists("$itemPath/post.$currentLang.$ext") || + file_exists("$itemPath/article.$currentLang.$ext") || + file_exists("$itemPath/page.$currentLang.$ext")) { + $hasContent = true; + break; + } + } + if (!$hasContent) return null; + } + + // Build patterns for rendering (includes fallbacks) + $patterns = buildFilePatterns($currentLang, $defaultLang); + + $metadata = loadMetadata($itemPath, $currentLang, $defaultLang); + $coverImage = findCoverImage($itemPath); + + $title = $metadata['title'] ?? extractTitle($itemPath, $patterns) ?? $item; + $date = null; + if (isset($metadata['date'])) { + $date = formatNorwegianDate($metadata['date']); + } else { + $date = extractDateFromFolder($item) ?: date("F d, Y", filemtime($itemPath)); + } + + $langPrefix = $currentLang !== $defaultLang ? "/$currentLang" : ''; + + // Use translated slug if available, otherwise use folder name + $urlSlug = ($currentLang !== $defaultLang && $metadata && isset($metadata['slug'])) + ? $metadata['slug'] + : $item; + + return [ + 'title' => $title, + 'date' => $date, + 'url' => $langPrefix . '/' . trim($requestPath, '/') . '/' . urlencode($urlSlug), + 'cover' => $coverImage ? $langPrefix . '/' . trim($requestPath, '/') . '/' . urlencode($urlSlug) . '/' . $coverImage : null, + 'summary' => $metadata['summary'] ?? null + ]; + }, $subdirs)); + + ob_start(); + include $listTemplate; + $content = ob_get_clean(); + + // Build navigation for base template + $navigation = buildNavigation($contentDir, $currentLang, $defaultLang, $pageFilePatterns); + + include $baseTemplate; + exit; + + case 'not_found': + renderTemplate("

404 Not Found

The requested resource was not found.

", 404); +} diff --git a/app/static.php b/app/static.php new file mode 100644 index 0000000..3f2f5e7 --- /dev/null +++ b/app/static.php @@ -0,0 +1,44 @@ + 'text/css', + 'js' => 'application/javascript', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'eot' => 'application/vnd.ms-fontobject', + 'svg' => 'image/svg+xml', +]; + +$mimeType = $mimeTypes[$ext] ?? (mime_content_type($filePath) ?: 'application/octet-stream'); +header('Content-Type: ' . $mimeType); +readfile($filePath); diff --git a/app/vendor/Parsedown.php b/app/vendor/Parsedown.php new file mode 100644 index 0000000..1b9d6d5 --- /dev/null +++ b/app/vendor/Parsedown.php @@ -0,0 +1,1712 @@ +DefinitionData = array(); + + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + # remove surrounding line breaks + $text = trim($text, "\n"); + + # split text into lines + $lines = explode("\n", $text); + + # iterate through lines to identify blocks + $markup = $this->lines($lines); + + # trim line breaks + $markup = trim($markup, "\n"); + + return $markup; + } + + # + # Setters + # + + function setBreaksEnabled($breaksEnabled) + { + $this->breaksEnabled = $breaksEnabled; + + return $this; + } + + protected $breaksEnabled; + + function setMarkupEscaped($markupEscaped) + { + $this->markupEscaped = $markupEscaped; + + return $this; + } + + protected $markupEscaped; + + function setUrlsLinked($urlsLinked) + { + $this->urlsLinked = $urlsLinked; + + return $this; + } + + protected $urlsLinked = true; + + function setSafeMode($safeMode) + { + $this->safeMode = (bool) $safeMode; + + return $this; + } + + protected $safeMode; + + protected $safeLinksWhitelist = array( + 'http://', + 'https://', + 'ftp://', + 'ftps://', + 'mailto:', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/jpeg;base64,', + 'irc:', + 'ircs:', + 'git:', + 'ssh:', + 'news:', + 'steam:', + ); + + # + # Lines + # + + protected $BlockTypes = array( + '#' => array('Header'), + '*' => array('Rule', 'List'), + '+' => array('List'), + '-' => array('SetextHeader', 'Table', 'Rule', 'List'), + '0' => array('List'), + '1' => array('List'), + '2' => array('List'), + '3' => array('List'), + '4' => array('List'), + '5' => array('List'), + '6' => array('List'), + '7' => array('List'), + '8' => array('List'), + '9' => array('List'), + ':' => array('Table'), + '<' => array('Comment', 'Markup'), + '=' => array('SetextHeader'), + '>' => array('Quote'), + '[' => array('Reference'), + '_' => array('Rule'), + '`' => array('FencedCode'), + '|' => array('Table'), + '~' => array('FencedCode'), + ); + + # ~ + + protected $unmarkedBlockTypes = array( + 'Code', + ); + + # + # Blocks + # + + protected function lines(array $lines) + { + $CurrentBlock = null; + + foreach ($lines as $line) + { + if (chop($line) === '') + { + if (isset($CurrentBlock)) + { + $CurrentBlock['interrupted'] = true; + } + + continue; + } + + if (strpos($line, "\t") !== false) + { + $parts = explode("\t", $line); + + $line = $parts[0]; + + unset($parts[0]); + + foreach ($parts as $part) + { + $shortage = 4 - mb_strlen($line, 'utf-8') % 4; + + $line .= str_repeat(' ', $shortage); + $line .= $part; + } + } + + $indent = 0; + + while (isset($line[$indent]) and $line[$indent] === ' ') + { + $indent ++; + } + + $text = $indent > 0 ? substr($line, $indent) : $line; + + # ~ + + $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); + + # ~ + + if (isset($CurrentBlock['continuable'])) + { + $Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock); + + if (isset($Block)) + { + $CurrentBlock = $Block; + + continue; + } + else + { + if ($this->isBlockCompletable($CurrentBlock['type'])) + { + $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); + } + } + } + + # ~ + + $marker = $text[0]; + + # ~ + + $blockTypes = $this->unmarkedBlockTypes; + + if (isset($this->BlockTypes[$marker])) + { + foreach ($this->BlockTypes[$marker] as $blockType) + { + $blockTypes []= $blockType; + } + } + + # + # ~ + + foreach ($blockTypes as $blockType) + { + $Block = $this->{'block'.$blockType}($Line, $CurrentBlock); + + if (isset($Block)) + { + $Block['type'] = $blockType; + + if ( ! isset($Block['identified'])) + { + $Blocks []= $CurrentBlock; + + $Block['identified'] = true; + } + + if ($this->isBlockContinuable($blockType)) + { + $Block['continuable'] = true; + } + + $CurrentBlock = $Block; + + continue 2; + } + } + + # ~ + + if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted'])) + { + $CurrentBlock['element']['text'] .= "\n".$text; + } + else + { + $Blocks []= $CurrentBlock; + + $CurrentBlock = $this->paragraph($Line); + + $CurrentBlock['identified'] = true; + } + } + + # ~ + + if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) + { + $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); + } + + # ~ + + $Blocks []= $CurrentBlock; + + unset($Blocks[0]); + + # ~ + + $markup = ''; + + foreach ($Blocks as $Block) + { + if (isset($Block['hidden'])) + { + continue; + } + + $markup .= "\n"; + $markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']); + } + + $markup .= "\n"; + + # ~ + + return $markup; + } + + protected function isBlockContinuable($Type) + { + return method_exists($this, 'block'.$Type.'Continue'); + } + + protected function isBlockCompletable($Type) + { + return method_exists($this, 'block'.$Type.'Complete'); + } + + # + # Code + + protected function blockCode($Line, $Block = null) + { + if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted'])) + { + return; + } + + if ($Line['indent'] >= 4) + { + $text = substr($Line['body'], 4); + + $Block = array( + 'element' => array( + 'name' => 'pre', + 'handler' => 'element', + 'text' => array( + 'name' => 'code', + 'text' => $text, + ), + ), + ); + + return $Block; + } + } + + protected function blockCodeContinue($Line, $Block) + { + if ($Line['indent'] >= 4) + { + if (isset($Block['interrupted'])) + { + $Block['element']['text']['text'] .= "\n"; + + unset($Block['interrupted']); + } + + $Block['element']['text']['text'] .= "\n"; + + $text = substr($Line['body'], 4); + + $Block['element']['text']['text'] .= $text; + + return $Block; + } + } + + protected function blockCodeComplete($Block) + { + $text = $Block['element']['text']['text']; + + $Block['element']['text']['text'] = $text; + + return $Block; + } + + # + # Comment + + protected function blockComment($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!') + { + $Block = array( + 'markup' => $Line['body'], + ); + + if (preg_match('/-->$/', $Line['text'])) + { + $Block['closed'] = true; + } + + return $Block; + } + } + + protected function blockCommentContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + $Block['markup'] .= "\n" . $Line['body']; + + if (preg_match('/-->$/', $Line['text'])) + { + $Block['closed'] = true; + } + + return $Block; + } + + # + # Fenced Code + + protected function blockFencedCode($Line) + { + if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([^`]+)?[ ]*$/', $Line['text'], $matches)) + { + $Element = array( + 'name' => 'code', + 'text' => '', + ); + + if (isset($matches[1])) + { + /** + * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes + * Every HTML element may have a class attribute specified. + * The attribute, if specified, must have a value that is a set + * of space-separated tokens representing the various classes + * that the element belongs to. + * [...] + * The space characters, for the purposes of this specification, + * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), + * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and + * U+000D CARRIAGE RETURN (CR). + */ + $language = substr($matches[1], 0, strcspn($matches[1], " \t\n\f\r")); + + $class = 'language-'.$language; + + $Element['attributes'] = array( + 'class' => $class, + ); + } + + $Block = array( + 'char' => $Line['text'][0], + 'element' => array( + 'name' => 'pre', + 'handler' => 'element', + 'text' => $Element, + ), + ); + + return $Block; + } + } + + protected function blockFencedCodeContinue($Line, $Block) + { + if (isset($Block['complete'])) + { + return; + } + + if (isset($Block['interrupted'])) + { + $Block['element']['text']['text'] .= "\n"; + + unset($Block['interrupted']); + } + + if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text'])) + { + $Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1); + + $Block['complete'] = true; + + return $Block; + } + + $Block['element']['text']['text'] .= "\n".$Line['body']; + + return $Block; + } + + protected function blockFencedCodeComplete($Block) + { + $text = $Block['element']['text']['text']; + + $Block['element']['text']['text'] = $text; + + return $Block; + } + + # + # Header + + protected function blockHeader($Line) + { + if (isset($Line['text'][1])) + { + $level = 1; + + while (isset($Line['text'][$level]) and $Line['text'][$level] === '#') + { + $level ++; + } + + if ($level > 6) + { + return; + } + + $text = trim($Line['text'], '# '); + + $Block = array( + 'element' => array( + 'name' => 'h' . min(6, $level), + 'text' => $text, + 'handler' => 'line', + ), + ); + + return $Block; + } + } + + # + # List + + protected function blockList($Line) + { + list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]'); + + if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches)) + { + $Block = array( + 'indent' => $Line['indent'], + 'pattern' => $pattern, + 'element' => array( + 'name' => $name, + 'handler' => 'elements', + ), + ); + + if($name === 'ol') + { + $listStart = stristr($matches[0], '.', true); + + if($listStart !== '1') + { + $Block['element']['attributes'] = array('start' => $listStart); + } + } + + $Block['li'] = array( + 'name' => 'li', + 'handler' => 'li', + 'text' => array( + $matches[2], + ), + ); + + $Block['element']['text'] []= & $Block['li']; + + return $Block; + } + } + + protected function blockListContinue($Line, array $Block) + { + if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches)) + { + if (isset($Block['interrupted'])) + { + $Block['li']['text'] []= ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + unset($Block['li']); + + $text = isset($matches[1]) ? $matches[1] : ''; + + $Block['li'] = array( + 'name' => 'li', + 'handler' => 'li', + 'text' => array( + $text, + ), + ); + + $Block['element']['text'] []= & $Block['li']; + + return $Block; + } + + if ($Line['text'][0] === '[' and $this->blockReference($Line)) + { + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); + + $Block['li']['text'] []= $text; + + return $Block; + } + + if ($Line['indent'] > 0) + { + $Block['li']['text'] []= ''; + + $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); + + $Block['li']['text'] []= $text; + + unset($Block['interrupted']); + + return $Block; + } + } + + protected function blockListComplete(array $Block) + { + if (isset($Block['loose'])) + { + foreach ($Block['element']['text'] as &$li) + { + if (end($li['text']) !== '') + { + $li['text'] []= ''; + } + } + } + + return $Block; + } + + # + # Quote + + protected function blockQuote($Line) + { + if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) + { + $Block = array( + 'element' => array( + 'name' => 'blockquote', + 'handler' => 'lines', + 'text' => (array) $matches[1], + ), + ); + + return $Block; + } + } + + protected function blockQuoteContinue($Line, array $Block) + { + if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) + { + if (isset($Block['interrupted'])) + { + $Block['element']['text'] []= ''; + + unset($Block['interrupted']); + } + + $Block['element']['text'] []= $matches[1]; + + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $Block['element']['text'] []= $Line['text']; + + return $Block; + } + } + + # + # Rule + + protected function blockRule($Line) + { + if (preg_match('/^(['.$Line['text'][0].'])([ ]*\1){2,}[ ]*$/', $Line['text'])) + { + $Block = array( + 'element' => array( + 'name' => 'hr' + ), + ); + + return $Block; + } + } + + # + # Setext + + protected function blockSetextHeader($Line, array $Block = null) + { + if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) + { + return; + } + + if (chop($Line['text'], $Line['text'][0]) === '') + { + $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; + + return $Block; + } + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) + { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) + { + return; + } + + $Block = array( + 'name' => $matches[1], + 'depth' => 0, + 'markup' => $Line['text'], + ); + + $length = strlen($matches[0]); + + $remainder = substr($Line['text'], $length); + + if (trim($remainder) === '') + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + $Block['closed'] = true; + + $Block['void'] = true; + } + } + else + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + return; + } + + if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) + { + $Block['closed'] = true; + } + } + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open + { + $Block['depth'] ++; + } + + if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close + { + if ($Block['depth'] > 0) + { + $Block['depth'] --; + } + else + { + $Block['closed'] = true; + } + } + + if (isset($Block['interrupted'])) + { + $Block['markup'] .= "\n"; + + unset($Block['interrupted']); + } + + $Block['markup'] .= "\n".$Line['body']; + + return $Block; + } + + # + # Reference + + protected function blockReference($Line) + { + if (preg_match('/^\[(.+?)\]:[ ]*?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches)) + { + $id = strtolower($matches[1]); + + $Data = array( + 'url' => $matches[2], + 'title' => null, + ); + + if (isset($matches[3])) + { + $Data['title'] = $matches[3]; + } + + $this->DefinitionData['Reference'][$id] = $Data; + + $Block = array( + 'hidden' => true, + ); + + return $Block; + } + } + + # + # Table + + protected function blockTable($Line, array $Block = null) + { + if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) + { + return; + } + + if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '') + { + $alignments = array(); + + $divider = $Line['text']; + + $divider = trim($divider); + $divider = trim($divider, '|'); + + $dividerCells = explode('|', $divider); + + foreach ($dividerCells as $dividerCell) + { + $dividerCell = trim($dividerCell); + + if ($dividerCell === '') + { + continue; + } + + $alignment = null; + + if ($dividerCell[0] === ':') + { + $alignment = 'left'; + } + + if (substr($dividerCell, - 1) === ':') + { + $alignment = $alignment === 'left' ? 'center' : 'right'; + } + + $alignments []= $alignment; + } + + # ~ + + $HeaderElements = array(); + + $header = $Block['element']['text']; + + $header = trim($header); + $header = trim($header, '|'); + + $headerCells = explode('|', $header); + + foreach ($headerCells as $index => $headerCell) + { + $headerCell = trim($headerCell); + + $HeaderElement = array( + 'name' => 'th', + 'text' => $headerCell, + 'handler' => 'line', + ); + + if (isset($alignments[$index])) + { + $alignment = $alignments[$index]; + + $HeaderElement['attributes'] = array( + 'style' => 'text-align: '.$alignment.';', + ); + } + + $HeaderElements []= $HeaderElement; + } + + # ~ + + $Block = array( + 'alignments' => $alignments, + 'identified' => true, + 'element' => array( + 'name' => 'table', + 'handler' => 'elements', + ), + ); + + $Block['element']['text'] []= array( + 'name' => 'thead', + 'handler' => 'elements', + ); + + $Block['element']['text'] []= array( + 'name' => 'tbody', + 'handler' => 'elements', + 'text' => array(), + ); + + $Block['element']['text'][0]['text'] []= array( + 'name' => 'tr', + 'handler' => 'elements', + 'text' => $HeaderElements, + ); + + return $Block; + } + } + + protected function blockTableContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) + { + return; + } + + if ($Line['text'][0] === '|' or strpos($Line['text'], '|')) + { + $Elements = array(); + + $row = $Line['text']; + + $row = trim($row); + $row = trim($row, '|'); + + preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches); + + foreach ($matches[0] as $index => $cell) + { + $cell = trim($cell); + + $Element = array( + 'name' => 'td', + 'handler' => 'line', + 'text' => $cell, + ); + + if (isset($Block['alignments'][$index])) + { + $Element['attributes'] = array( + 'style' => 'text-align: '.$Block['alignments'][$index].';', + ); + } + + $Elements []= $Element; + } + + $Element = array( + 'name' => 'tr', + 'handler' => 'elements', + 'text' => $Elements, + ); + + $Block['element']['text'][1]['text'] []= $Element; + + return $Block; + } + } + + # + # ~ + # + + protected function paragraph($Line) + { + $Block = array( + 'element' => array( + 'name' => 'p', + 'text' => $Line['text'], + 'handler' => 'line', + ), + ); + + return $Block; + } + + # + # Inline Elements + # + + protected $InlineTypes = array( + '"' => array('SpecialCharacter'), + '!' => array('Image'), + '&' => array('SpecialCharacter'), + '*' => array('Emphasis'), + ':' => array('Url'), + '<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'), + '>' => array('SpecialCharacter'), + '[' => array('Link'), + '_' => array('Emphasis'), + '`' => array('Code'), + '~' => array('Strikethrough'), + '\\' => array('EscapeSequence'), + ); + + # ~ + + protected $inlineMarkerList = '!"*_&[:<>`~\\'; + + # + # ~ + # + + public function line($text, $nonNestables=array()) + { + $markup = ''; + + # $excerpt is based on the first occurrence of a marker + + while ($excerpt = strpbrk($text, $this->inlineMarkerList)) + { + $marker = $excerpt[0]; + + $markerPosition = strpos($text, $marker); + + $Excerpt = array('text' => $excerpt, 'context' => $text); + + foreach ($this->InlineTypes[$marker] as $inlineType) + { + # check to see if the current inline type is nestable in the current context + + if ( ! empty($nonNestables) and in_array($inlineType, $nonNestables)) + { + continue; + } + + $Inline = $this->{'inline'.$inlineType}($Excerpt); + + if ( ! isset($Inline)) + { + continue; + } + + # makes sure that the inline belongs to "our" marker + + if (isset($Inline['position']) and $Inline['position'] > $markerPosition) + { + continue; + } + + # sets a default inline position + + if ( ! isset($Inline['position'])) + { + $Inline['position'] = $markerPosition; + } + + # cause the new element to 'inherit' our non nestables + + foreach ($nonNestables as $non_nestable) + { + $Inline['element']['nonNestables'][] = $non_nestable; + } + + # the text that comes before the inline + $unmarkedText = substr($text, 0, $Inline['position']); + + # compile the unmarked text + $markup .= $this->unmarkedText($unmarkedText); + + # compile the inline + $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']); + + # remove the examined text + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; + } + + # the marker does not belong to an inline + + $unmarkedText = substr($text, 0, $markerPosition + 1); + + $markup .= $this->unmarkedText($unmarkedText); + + $text = substr($text, $markerPosition + 1); + } + + $markup .= $this->unmarkedText($text); + + return $markup; + } + + # + # ~ + # + + protected function inlineCode($Excerpt) + { + $marker = $Excerpt['text'][0]; + + if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(? strlen($matches[0]), + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ); + } + } + + protected function inlineEmailTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches)) + { + $url = $matches[1]; + + if ( ! isset($matches[2])) + { + $url = 'mailto:' . $url; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $matches[1], + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + protected function inlineEmphasis($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + $marker = $Excerpt['text'][0]; + + if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'strong'; + } + elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'em'; + } + else + { + return; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => $emphasis, + 'handler' => 'line', + 'text' => $matches[1], + ), + ); + } + + protected function inlineEscapeSequence($Excerpt) + { + if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) + { + return array( + 'markup' => $Excerpt['text'][1], + 'extent' => 2, + ); + } + } + + protected function inlineImage($Excerpt) + { + if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') + { + return; + } + + $Excerpt['text']= substr($Excerpt['text'], 1); + + $Link = $this->inlineLink($Excerpt); + + if ($Link === null) + { + return; + } + + $Inline = array( + 'extent' => $Link['extent'] + 1, + 'element' => array( + 'name' => 'img', + 'attributes' => array( + 'src' => $Link['element']['attributes']['href'], + 'alt' => $Link['element']['text'], + ), + ), + ); + + $Inline['element']['attributes'] += $Link['element']['attributes']; + + unset($Inline['element']['attributes']['href']); + + return $Inline; + } + + protected function inlineLink($Excerpt) + { + $Element = array( + 'name' => 'a', + 'handler' => 'line', + 'nonNestables' => array('Url', 'Link'), + 'text' => null, + 'attributes' => array( + 'href' => null, + 'title' => null, + ), + ); + + $extent = 0; + + $remainder = $Excerpt['text']; + + if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) + { + $Element['text'] = $matches[1]; + + $extent += strlen($matches[0]); + + $remainder = substr($remainder, $extent); + } + else + { + return; + } + + if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches)) + { + $Element['attributes']['href'] = $matches[1]; + + if (isset($matches[2])) + { + $Element['attributes']['title'] = substr($matches[2], 1, - 1); + } + + $extent += strlen($matches[0]); + } + else + { + if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) + { + $definition = strlen($matches[1]) ? $matches[1] : $Element['text']; + $definition = strtolower($definition); + + $extent += strlen($matches[0]); + } + else + { + $definition = strtolower($Element['text']); + } + + if ( ! isset($this->DefinitionData['Reference'][$definition])) + { + return; + } + + $Definition = $this->DefinitionData['Reference'][$definition]; + + $Element['attributes']['href'] = $Definition['url']; + $Element['attributes']['title'] = $Definition['title']; + } + + return array( + 'extent' => $extent, + 'element' => $Element, + ); + } + + protected function inlineMarkup($Excerpt) + { + if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) + { + return; + } + + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*[ ]*>/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches)) + { + return array( + 'markup' => $matches[0], + 'extent' => strlen($matches[0]), + ); + } + } + + protected function inlineSpecialCharacter($Excerpt) + { + if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text'])) + { + return array( + 'markup' => '&', + 'extent' => 1, + ); + } + + $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot'); + + if (isset($SpecialCharacter[$Excerpt['text'][0]])) + { + return array( + 'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';', + 'extent' => 1, + ); + } + } + + protected function inlineStrikethrough($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) + { + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'del', + 'text' => $matches[1], + 'handler' => 'line', + ), + ); + } + } + + protected function inlineUrl($Excerpt) + { + if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') + { + return; + } + + if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) + { + $url = $matches[0][0]; + + $Inline = array( + 'extent' => strlen($matches[0][0]), + 'position' => $matches[0][1], + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + + return $Inline; + } + } + + protected function inlineUrlTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches)) + { + $url = $matches[1]; + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + # ~ + + protected function unmarkedText($text) + { + if ($this->breaksEnabled) + { + $text = preg_replace('/[ ]*\n/', "
\n", $text); + } + else + { + $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "
\n", $text); + $text = str_replace(" \n", "\n", $text); + } + + return $text; + } + + # + # Handlers + # + + protected function element(array $Element) + { + if ($this->safeMode) + { + $Element = $this->sanitiseElement($Element); + } + + $markup = '<'.$Element['name']; + + if (isset($Element['attributes'])) + { + foreach ($Element['attributes'] as $name => $value) + { + if ($value === null) + { + continue; + } + + $markup .= ' '.$name.'="'.self::escape($value).'"'; + } + } + + $permitRawHtml = false; + + if (isset($Element['text'])) + { + $text = $Element['text']; + } + // very strongly consider an alternative if you're writing an + // extension + elseif (isset($Element['rawHtml'])) + { + $text = $Element['rawHtml']; + $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; + $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; + } + + if (isset($text)) + { + $markup .= '>'; + + if (!isset($Element['nonNestables'])) + { + $Element['nonNestables'] = array(); + } + + if (isset($Element['handler'])) + { + $markup .= $this->{$Element['handler']}($text, $Element['nonNestables']); + } + elseif (!$permitRawHtml) + { + $markup .= self::escape($text, true); + } + else + { + $markup .= $text; + } + + $markup .= ''; + } + else + { + $markup .= ' />'; + } + + return $markup; + } + + protected function elements(array $Elements) + { + $markup = ''; + + foreach ($Elements as $Element) + { + $markup .= "\n" . $this->element($Element); + } + + $markup .= "\n"; + + return $markup; + } + + # ~ + + protected function li($lines) + { + $markup = $this->lines($lines); + + $trimmedMarkup = trim($markup); + + if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '

') + { + $markup = $trimmedMarkup; + $markup = substr($markup, 3); + + $position = strpos($markup, "

"); + + $markup = substr_replace($markup, '', $position, 4); + } + + return $markup; + } + + # + # Deprecated Methods + # + + function parse($text) + { + $markup = $this->text($text); + + return $markup; + } + + protected function sanitiseElement(array $Element) + { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + 'a' => 'href', + 'img' => 'src', + ); + + if (isset($safeUrlNameToAtt[$Element['name']])) + { + $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); + } + + if ( ! empty($Element['attributes'])) + { + foreach ($Element['attributes'] as $att => $val) + { + # filter out badly parsed attribute + if ( ! preg_match($goodAttribute, $att)) + { + unset($Element['attributes'][$att]); + } + # dump onevent attribute + elseif (self::striAtStart($att, 'on')) + { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) + { + foreach ($this->safeLinksWhitelist as $scheme) + { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) + { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + + # + # Static Methods + # + + protected static function escape($text, $allowQuotes = false) + { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) + { + $len = strlen($needle); + + if ($len > strlen($string)) + { + return false; + } + else + { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + + static function instance($name = 'default') + { + if (isset(self::$instances[$name])) + { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', + 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', + 'i', 'rp', 'del', 'code', 'strike', 'marquee', + 'q', 'rt', 'ins', 'font', 'strong', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', + ); +} diff --git a/development/apache/custom.conf b/development/apache/custom.conf new file mode 100644 index 0000000..d8adda7 --- /dev/null +++ b/development/apache/custom.conf @@ -0,0 +1,27 @@ + + Options Indexes FollowSymLinks + AllowOverride All + Require all granted + + +# Alias for app assets (styles, fonts, etc.) +Alias /app/styles /var/www/custom/styles +Alias /app/fonts /var/www/custom/fonts +Alias /app/default-styles /var/www/app/default/styles + + + Require all granted + + + + Require all granted + + + + Require all granted + + +# Enable mod_rewrite + + LoadModule rewrite_module modules/mod_rewrite.so + diff --git a/development/compose.yaml b/development/compose.yaml new file mode 100644 index 0000000..7f8253e --- /dev/null +++ b/development/compose.yaml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + custom: + image: php:8.3.12-apache + container_name: folderweb-custom + working_dir: /var/www/html/ + volumes: + - ../app:/var/www/app:z + - ../content:/var/www/html:z + - ../custom:/var/www/custom:z + - ../docs:/var/www/html/docs:z + - ./apache/custom.conf:/etc/apache2/conf-available/custom.conf:z + ports: + - "4040:80" + command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground" + default: + image: php:8.3.12-apache + container_name: folderweb-default + working_dir: /var/www/html/ + volumes: + - ../app:/var/www/app:z + - ../content:/var/www/html:z + - ../docs:/var/www/html/docs:z + - ./apache/custom.conf:/etc/apache2/conf-available/custom.conf:z + ports: + - "8080:80" + command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground"