feat: add legacy webmention recovery from old URLs

- Add urlAliases.js to parse redirect maps and build old→new URL mappings
- Enhance webmentionsForUrl filter to check legacy URLs (micro.blog, Known/WP)
- Update webmentions.njk to pass urlAliases data
- Add webmention-debug.njk page at /debug/webmentions/

This recovers webmentions sent to old URL structures:
- micro.blog: /YYYY/MM/DD/slug.html
- Known/WordPress: /YYYY/slug

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-01-24 14:57:29 +01:00
parent f32565a2be
commit a316d3148d
4 changed files with 290 additions and 11 deletions

139
_data/urlAliases.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* URL Aliases for Webmention Recovery
*
* Maps new URLs to their old URLs so webmentions from previous
* URL structures can be displayed on current pages.
*
* Sources:
* - redirects.map.rmendes (micro.blog: /YYYY/MM/DD/slug.html → /notes/...)
* - old-blog-redirects.map.rmendes (Known/WP: /YYYY/slug → /content/...)
*/
import { readFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const siteUrl = process.env.SITE_URL || "https://example.com";
/**
* Parse a redirect map file into URL mappings
* Format: old_path new_path;
*/
function parseRedirectMap(filePath) {
const aliases = {};
if (!existsSync(filePath)) {
console.log(`[urlAliases] File not found: ${filePath}`);
return aliases;
}
try {
const content = readFileSync(filePath, "utf-8");
const lines = content.split("\n").filter((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith("#");
});
for (const line of lines) {
// Format: /old/path /new/path;
const match = line.match(/^(\S+)\s+(\S+);?$/);
if (match) {
const [, oldPath, newPath] = match;
// Normalize paths (remove trailing slashes, ensure leading slash)
const normalizedNew = newPath.replace(/;$/, "").replace(/\/$/, "");
const normalizedOld = oldPath.replace(/\/$/, "");
// Map new URL → array of old URLs
if (!aliases[normalizedNew]) {
aliases[normalizedNew] = [];
}
aliases[normalizedNew].push(normalizedOld);
}
}
} catch (error) {
console.error(`[urlAliases] Error parsing ${filePath}:`, error.message);
}
return aliases;
}
/**
* Merge multiple alias maps
*/
function mergeAliases(...maps) {
const merged = {};
for (const map of maps) {
for (const [newUrl, oldUrls] of Object.entries(map)) {
if (!merged[newUrl]) {
merged[newUrl] = [];
}
merged[newUrl].push(...oldUrls);
}
}
return merged;
}
// Parse redirect maps from package root (one level up from _data)
const pkgRoot = resolve(__dirname, "../..");
// Try multiple possible locations
const mapLocations = [
resolve(pkgRoot, "redirects.map.rmendes"),
resolve(pkgRoot, "old-blog-redirects.map.rmendes"),
// Fallback to template files if .rmendes versions don't exist
resolve(pkgRoot, "redirects.map"),
resolve(pkgRoot, "old-blog-redirects.map"),
];
const microblogAliases = parseRedirectMap(mapLocations[0]) || parseRedirectMap(mapLocations[2]);
const knownAliases = parseRedirectMap(mapLocations[1]) || parseRedirectMap(mapLocations[3]);
const allAliases = mergeAliases(microblogAliases, knownAliases);
// Log summary
const totalMappings = Object.keys(allAliases).length;
const totalOldUrls = Object.values(allAliases).reduce((sum, urls) => sum + urls.length, 0);
console.log(`[urlAliases] Loaded ${totalMappings} URL mappings with ${totalOldUrls} old URLs`);
export default {
// The merged alias map: new URL → [old URLs]
aliases: allAliases,
// Site URL for building absolute URLs
siteUrl,
/**
* Get all URLs (old and new) that should be checked for webmentions
* @param {string} url - Current page URL (relative)
* @returns {string[]} - Array of absolute URLs to check
*/
getAllUrls(url) {
const normalizedUrl = url.replace(/\/$/, "");
const urls = [
`${siteUrl}${url}`,
`${siteUrl}${normalizedUrl}`,
];
// Add old URL variations
const oldUrls = allAliases[normalizedUrl] || [];
for (const oldUrl of oldUrls) {
urls.push(`${siteUrl}${oldUrl}`);
// Also try with trailing slash
urls.push(`${siteUrl}${oldUrl}/`);
}
// Deduplicate
return [...new Set(urls)];
},
/**
* Get just the old URLs for a given new URL
* @param {string} url - Current page URL (relative)
* @returns {string[]} - Array of old relative URLs
*/
getOldUrls(url) {
const normalizedUrl = url.replace(/\/$/, "");
return allAliases[normalizedUrl] || [];
},
};

View File

@@ -1,7 +1,8 @@
{# Webmentions Component #}
{# Displays likes, reposts, and replies for a post #}
{# Also checks legacy URLs from micro.blog and old blog for historical webmentions #}
{% set mentions = webmentions | webmentionsForUrl(page.url) %}
{% set mentions = webmentions | webmentionsForUrl(page.url, urlAliases) %}
{% if mentions.length %}
<section class="webmentions mt-8 pt-8 border-t border-surface-200 dark:border-surface-700" id="webmentions">

View File

@@ -235,17 +235,33 @@ export default function (eleventyConfig) {
return date.toLocaleDateString("en-US", options);
});
// Webmention filters
eleventyConfig.addFilter("webmentionsForUrl", function (webmentions, url) {
// Webmention filters - with legacy URL support
// This filter checks both current URL and any legacy URLs from redirects
eleventyConfig.addFilter("webmentionsForUrl", function (webmentions, url, urlAliases) {
if (!webmentions || !url) return [];
const absoluteUrl = url.startsWith("http")
? url
: `${siteUrl}${url}`;
return webmentions.filter(
(wm) =>
wm["wm-target"] === absoluteUrl ||
wm["wm-target"] === absoluteUrl.replace(/\/$/, "")
);
// Build list of all URLs to check (current + legacy)
const urlsToCheck = new Set();
// Add current URL variations
const absoluteUrl = url.startsWith("http") ? url : `${siteUrl}${url}`;
urlsToCheck.add(absoluteUrl);
urlsToCheck.add(absoluteUrl.replace(/\/$/, ""));
urlsToCheck.add(absoluteUrl.endsWith("/") ? absoluteUrl : `${absoluteUrl}/`);
// Add legacy URLs from aliases (if provided)
if (urlAliases?.aliases) {
const normalizedUrl = url.replace(/\/$/, "");
const oldUrls = urlAliases.aliases[normalizedUrl] || [];
for (const oldUrl of oldUrls) {
urlsToCheck.add(`${siteUrl}${oldUrl}`);
urlsToCheck.add(`${siteUrl}${oldUrl}/`);
urlsToCheck.add(`${siteUrl}${oldUrl}`.replace(/\/$/, ""));
}
}
// Filter webmentions matching any of our URLs
return webmentions.filter((wm) => urlsToCheck.has(wm["wm-target"]));
});
eleventyConfig.addFilter("webmentionsByType", function (mentions, type) {

123
webmention-debug.njk Normal file
View File

@@ -0,0 +1,123 @@
---
layout: layouts/base.njk
title: Webmention Debug
permalink: /debug/webmentions/
eleventyExcludeFromCollections: true
---
<div class="page-header mb-8">
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Webmention Debug</h1>
<p class="text-surface-600 dark:text-surface-400">
Debug page for webmention recovery from legacy URLs.
This page is excluded from collections and won't appear in feeds.
</p>
</div>
<div class="space-y-8">
{# Summary #}
<section class="p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<h2 class="text-xl font-bold mb-4">Summary</h2>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="font-semibold text-surface-600 dark:text-surface-400">Total URL Mappings</dt>
<dd class="text-2xl font-bold">{{ urlAliases.aliases | length }}</dd>
</div>
<div>
<dt class="font-semibold text-surface-600 dark:text-surface-400">Total Webmentions</dt>
<dd class="text-2xl font-bold">{{ webmentions | length }}</dd>
</div>
</dl>
</section>
{# Recent Posts with Webmentions #}
<section>
<h2 class="text-xl font-bold mb-4">Posts with Webmentions</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-surface-100 dark:bg-surface-800">
<tr>
<th class="p-2 text-left">Current URL</th>
<th class="p-2 text-left">Legacy URLs</th>
<th class="p-2 text-right">Webmentions</th>
</tr>
</thead>
<tbody class="divide-y divide-surface-200 dark:divide-surface-700">
{% for post in collections.posts | head(50) %}
{% set allMentions = webmentions | webmentionsForUrl(post.url, urlAliases) %}
{% set legacyUrls = urlAliases.getOldUrls(post.url) %}
{% if allMentions.length > 0 or legacyUrls.length > 0 %}
<tr class="hover:bg-surface-50 dark:hover:bg-surface-800/50">
<td class="p-2">
<a href="{{ post.url }}" class="text-primary-600 dark:text-primary-400 hover:underline">
{{ post.url }}
</a>
</td>
<td class="p-2 text-xs text-surface-500 font-mono">
{% if legacyUrls.length %}
{% for legacyUrl in legacyUrls %}
<div>{{ legacyUrl }}</div>
{% endfor %}
{% else %}
<span class="text-surface-400">-</span>
{% endif %}
</td>
<td class="p-2 text-right">
{% if allMentions.length %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">
{{ allMentions.length }}
</span>
{% else %}
<span class="text-surface-400">0</span>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</section>
{# URL Alias Sample #}
<section>
<h2 class="text-xl font-bold mb-4">URL Alias Sample (first 20)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm font-mono">
<thead class="bg-surface-100 dark:bg-surface-800">
<tr>
<th class="p-2 text-left">New URL</th>
<th class="p-2 text-left">Old URL(s)</th>
</tr>
</thead>
<tbody class="divide-y divide-surface-200 dark:divide-surface-700">
{% set aliasEntries = urlAliases.aliases | dictsort %}
{% for newUrl, oldUrls in aliasEntries | head(20) %}
<tr>
<td class="p-2 text-xs break-all">{{ newUrl }}</td>
<td class="p-2 text-xs break-all text-surface-500">
{% for oldUrl in oldUrls %}
<div>{{ oldUrl }}</div>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{# Raw Webmention Targets (for debugging) #}
<section>
<h2 class="text-xl font-bold mb-4">Recent Webmention Targets</h2>
<p class="text-sm text-surface-600 dark:text-surface-400 mb-4">
Shows which URLs webmentions were sent to (useful for verifying legacy URL matches).
</p>
<ul class="space-y-1 font-mono text-xs">
{% for wm in webmentions | head(30) %}
<li class="p-2 bg-surface-50 dark:bg-surface-800/50 rounded">
<span class="text-surface-500">{{ wm["wm-property"] }}:</span>
<span class="break-all">{{ wm["wm-target"] }}</span>
</li>
{% endfor %}
</ul>
</section>
</div>