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
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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</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);
|
||||
|
||||
Reference in New Issue
Block a user