From ceae28609b424d26f48fc5f30f2f80186b668288 Mon Sep 17 00:00:00 2001 From: Protocolbot Date: Tue, 21 Jul 2026 08:24:07 -0600 Subject: [PATCH] feat: dynamic page titles + social media meta tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 with enriched version Frontend: - Default og/twitter meta tags on homepage - Path-based URLs (shared links) redirect to hash routes for SPA routing --- frontend/app.js | 11 ++++++ frontend/index.html | 7 ++++ index.php | 93 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/frontend/app.js b/frontend/app.js index 9224335..fd74ea3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -39,6 +39,13 @@ async function api(path, options = {}) { // --- Init --- async function init() { + // If path looks like /protocols/slug, /collections/slug, or /users/username + // (shared link without hash), redirect to hash-based route + var pathMatch = location.pathname.match(/^\/?(protocols|collections|users)\/([^\/]+)/); + if (pathMatch && !location.hash) { + history.replaceState(null, '', '#' + pathMatch[1] + '/' + pathMatch[2]); + } + // Route immediately — don't wait for API calls route(); window.addEventListener('hashchange', route); @@ -98,6 +105,7 @@ function navigate(view) { document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); closeMenu(); updateBreadcrumb(view); + document.title = siteConfig.name || 'Protocol Droid'; if (!isRouting) { if (view === 'library') location.hash = 'library'; if (view === 'collections') location.hash = 'collections'; @@ -329,6 +337,7 @@ async function showDetail(slug) { try { const p = await api('/protocols/' + slug); + document.title = (p.title || slug) + ' · ' + (siteConfig.name || 'Protocol Droid'); document.getElementById('detailContent').innerHTML = renderDetail(p); renderDetailActions(p); } catch (e) { @@ -730,6 +739,7 @@ async function showCollectionDetail(slug) { try { var c = await api('/collections/' + slug); + document.title = (c.title || slug) + ' · ' + (siteConfig.name || 'Protocol Droid'); var itemsHtml = ''; for (var i = 0; i < (c.items || []).length; i++) { var item = c.items[i]; @@ -802,6 +812,7 @@ async function showUser(username) { closeMenu(); try { const data = await api('/users/' + username); + document.title = '@' + (data.user.display_name || data.user.username) + ' · ' + (siteConfig.name || 'Protocol Droid'); const p = data.protocols || []; const c = data.collections || []; document.getElementById('detailContent').innerHTML = diff --git a/frontend/index.html b/frontend/index.html index 911aaa5..f9f97d3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,6 +10,13 @@ <link rel="manifest" href="manifest.json"> <link rel="stylesheet" href="frontend/style.css"> <meta name="theme-color" content="#0a1929"> +<meta property="og:title" content="Protocol Droid"> +<meta property="og:description" content="A commons of social protocols"> +<meta property="og:type" content="website"> +<meta property="og:site_name" content="Protocol Droid"> +<meta name="twitter:card" content="summary"> +<meta name="twitter:title" content="Protocol Droid"> +<meta name="twitter:description" content="A commons of social protocols"> </head> <body> diff --git a/index.php b/index.php index f46614a..f008983 100755 --- a/index.php +++ b/index.php @@ -29,12 +29,103 @@ $file = __DIR__ . '/frontend' . $uri; $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 -$serve_index = function() use ($basePath) { +// 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\n"; + $metaTags .= "\n"; + $metaTags .= "\n"; + $metaTags .= "\n"; + $metaTags .= "\n"; + $metaTags .= "\n"; + $metaTags .= "\n"; + $metaTags .= "\n"; + $metaTags .= "\n"; + $metaTags .= "\n"; + + // Replace the default tag with our enriched version + $html = preg_replace('#<title>[^<]*#', $metaTags, $html, 1); + } + // Rewrite absolute /frontend/ paths to include the base path if ($basePath) { $html = str_replace('/frontend/', $basePath . '/frontend/', $html);