Files
protocol-droid/index.php
T
Protocolbot ceae28609b feat: dynamic page titles + social media meta tags
Browser tab titles:
- Protocol pages: 'Title · Site Name'
- Collection pages: 'Title · Site Name'
- User pages: '@username · Site Name'
- Resets to site name on library/collections/about

Social media previews (server-side, for crawlers):
- index.php detects /protocols/slug, /collections/slug, /users/username
- Looks up title + description from DB
- Injects og:title, og:description, og:type, og:site_name, og:url
- Injects twitter:card, twitter:title, twitter:description
- Replaces default <title> with enriched version

Frontend:
- Default og/twitter meta tags on homepage
- Path-based URLs (shared links) redirect to hash routes for SPA routing
2026-07-21 08:24:07 -06:00

160 lines
6.0 KiB
PHP
Executable File

<?php
// Root router — serve frontend or API
// Works at web root or in a subdirectory (e.g. /protocol-droid/)
// Detect the base path (for subdirectory deployments)
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
$basePath = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
// If basePath is just / or . , clear it
if ($basePath === '/' || $basePath === '.') $basePath = '';
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Strip base path from URI for routing
if ($basePath && strpos($uri, $basePath) === 0) {
$uri = substr($uri, strlen($basePath));
}
if (preg_match('#^/api(/|$)#', $uri)) {
require __DIR__ . '/api/index.php';
exit;
}
// Everything else: serve frontend files
// Normalize the path to prevent traversal
$uri = preg_replace('#/+#', '/', $uri);
$file = __DIR__ . '/frontend' . $uri;
// Resolve real path and verify it's within the frontend directory
$real_file = realpath($file);
$frontend_dir = realpath(__DIR__ . '/frontend');
// Helper: get meta tags for a given path (for social media previews)
// Returns ['title' => ..., 'description' => ..., 'url' => ...] or null
function get_meta_for_path(string $uri): ?array {
// Match protocol/collection/user routes (path-based, not hash-based)
// e.g. /protocols/slug, /collections/slug, /users/username
if (!preg_match('#^/(protocols|collections|users)/([^/]+)#', $uri, $m)) {
return null;
}
$type = $m[1];
$slug = $m[2];
// Load minimal DB access without full API overhead
require_once __DIR__ . '/api/db.php';
require_once __DIR__ . '/api/config.php';
if ($type === 'protocols') {
$row = db_one(
"SELECT p.title, p.description, p.source, u.username AS author_username
FROM protocols p LEFT JOIN users u ON p.author_id = u.id
WHERE p.slug = ? AND p.visibility = 'public'",
[$slug]
);
if (!$row) return null;
return [
'title' => $row['title'],
'description' => $row['description'] ?: ($row['source'] ? 'Source: ' . $row['source'] : ''),
'type' => 'article',
];
}
if ($type === 'collections') {
$row = db_one(
"SELECT c.title, c.description, u.username AS author_username
FROM collections c LEFT JOIN users u ON c.author_id = u.id
WHERE c.slug = ? AND c.visibility = 'public'",
[$slug]
);
if (!$row) return null;
return [
'title' => $row['title'],
'description' => $row['description'] ?: '',
'type' => 'website',
];
}
if ($type === 'users') {
$row = db_one("SELECT username, display_name, bio FROM users WHERE username = ?", [$slug]);
if (!$row) return null;
$name = $row['display_name'] ?: '@' . $row['username'];
return [
'title' => $name,
'description' => $row['bio'] ?: 'Protocols by ' . $name,
'type' => 'profile',
];
}
return null;
}
// Helper: serve index.html with asset paths rewritten for subdirectory deployments
// Injects social media meta tags when viewing a specific protocol/collection/user
$serve_index = function() use ($basePath, $uri) {
$index = __DIR__ . '/frontend/index.html';
if (file_exists($index)) {
header('Content-Type: text/html');
$html = file_get_contents($index);
// Inject meta tags for social media previews
$meta = get_meta_for_path($uri);
if ($meta) {
require_once __DIR__ . '/api/config.php';
$siteName = config_get('site_name', 'Protocol Droid');
$fullTitle = $meta['title'] . ' · ' . $siteName;
$desc = $meta['description'];
$canonical = ($basePath ?: '') . $uri;
// Build canonical URL (scheme + host + path)
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$canonicalUrl = $scheme . '://' . $host . $canonical;
$metaTags = "<title>$fullTitle</title>\n";
$metaTags .= "<meta name=\"description\" content=\"" . htmlspecialchars($desc, ENT_QUOTES) . "\">\n";
$metaTags .= "<meta property=\"og:title\" content=\"" . htmlspecialchars($meta['title'], ENT_QUOTES) . "\">\n";
$metaTags .= "<meta property=\"og:description\" content=\"" . htmlspecialchars($desc, ENT_QUOTES) . "\">\n";
$metaTags .= "<meta property=\"og:type\" content=\"" . htmlspecialchars($meta['type'], ENT_QUOTES) . "\">\n";
$metaTags .= "<meta property=\"og:site_name\" content=\"" . htmlspecialchars($siteName, ENT_QUOTES) . "\">\n";
$metaTags .= "<meta property=\"og:url\" content=\"" . htmlspecialchars($canonicalUrl, ENT_QUOTES) . "\">\n";
$metaTags .= "<meta name=\"twitter:card\" content=\"summary\">\n";
$metaTags .= "<meta name=\"twitter:title\" content=\"" . htmlspecialchars($meta['title'], ENT_QUOTES) . "\">\n";
$metaTags .= "<meta name=\"twitter:description\" content=\"" . htmlspecialchars($desc, ENT_QUOTES) . "\">\n";
// Replace the default <title> tag with our enriched version
$html = preg_replace('#<title>[^<]*</title>#', $metaTags, $html, 1);
}
// Rewrite absolute /frontend/ paths to include the base path
if ($basePath) {
$html = str_replace('/frontend/', $basePath . '/frontend/', $html);
}
echo $html;
exit;
}
http_response_code(404);
echo 'Frontend not found';
exit;
};
if ($real_file === false || strpos($real_file, $frontend_dir) !== 0) {
$serve_index();
}
if (!is_file($real_file)) {
$serve_index();
}
$ext = pathinfo($real_file, PATHINFO_EXTENSION);
$types = [
'css' => 'text/css',
'js' => 'application/javascript',
'html' => 'text/html',
'svg' => 'image/svg+xml',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'json' => 'application/json',
];
if (isset($types[$ext])) header('Content-Type: ' . $types[$ext]);
readfile($real_file);