@riebeckite/plugin-webmention 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1159 @@
1
+ // index.ts
2
+ import {
3
+ createStyleAsset,
4
+ definePlugin
5
+ } from "@riebeckite/core";
6
+
7
+ // src/provider.ts
8
+ var WEBMENTION_CAPABILITIES = ["store", "query"];
9
+ function supportsWebmentionCapability(provider, capability) {
10
+ return provider.capabilities.has(capability);
11
+ }
12
+ var UnsupportedWebmentionQueryError = class extends Error {
13
+ constructor(query, requiredCapability) {
14
+ super(
15
+ `Webmention provider does not support ${requiredCapability} operations.`
16
+ );
17
+ this.query = query;
18
+ this.requiredCapability = requiredCapability;
19
+ }
20
+ query;
21
+ requiredCapability;
22
+ name = "UnsupportedWebmentionQueryError";
23
+ };
24
+ function requiredCapabilityForQuery(_query) {
25
+ return "query";
26
+ }
27
+ function assertWebmentionQuerySupported(provider, query) {
28
+ const capability = requiredCapabilityForQuery(query);
29
+ if (!supportsWebmentionCapability(provider, capability)) {
30
+ throw new UnsupportedWebmentionQueryError(query, capability);
31
+ }
32
+ }
33
+
34
+ // src/diagnostics.ts
35
+ var WEBMENTION_PLUGIN_NAME = "webmention";
36
+ function buildWebmentionDiagnostics(provider, options) {
37
+ const diagnostics = [];
38
+ if (options.render && !supportsWebmentionCapability(provider, "query")) {
39
+ diagnostics.push({
40
+ code: "webmention-render-requires-query",
41
+ severity: "warning",
42
+ pluginName: WEBMENTION_PLUGIN_NAME,
43
+ message: "The webmention provider cannot query, so stored mentions will not render near articles.",
44
+ suggestion: 'Use a provider that advertises the "query" capability or set render: false.'
45
+ });
46
+ }
47
+ if (!supportsWebmentionCapability(provider, "store")) {
48
+ diagnostics.push({
49
+ code: "webmention-receive-requires-store",
50
+ severity: "warning",
51
+ pluginName: WEBMENTION_PLUGIN_NAME,
52
+ message: "The webmention provider cannot store, so the receive endpoint rejects deliveries with 503.",
53
+ suggestion: 'Use a provider that advertises the "store" capability or disable the plugin.'
54
+ });
55
+ }
56
+ return diagnostics;
57
+ }
58
+
59
+ // src/http.ts
60
+ function jsonResponse(status, value) {
61
+ return {
62
+ status,
63
+ headers: { "content-type": "application/json; charset=utf-8" },
64
+ json: value
65
+ };
66
+ }
67
+ function errorResponse(status, error) {
68
+ return jsonResponse(status, { error });
69
+ }
70
+
71
+ // src/url.ts
72
+ function normalizeWebmentionUrl(value) {
73
+ let url;
74
+ try {
75
+ url = new URL(value);
76
+ } catch {
77
+ return null;
78
+ }
79
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
80
+ if (url.username !== "" || url.password !== "") return null;
81
+ url.hash = "";
82
+ url.hostname = url.hostname.toLowerCase();
83
+ return url.toString();
84
+ }
85
+ function urlComparisonKey(value) {
86
+ const normalized = normalizeWebmentionUrl(value);
87
+ if (normalized === null) return value;
88
+ const url = new URL(normalized);
89
+ url.hash = "";
90
+ if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
91
+ url.pathname = url.pathname.slice(0, -1);
92
+ }
93
+ return url.toString();
94
+ }
95
+ function resolvePublicUrl(permalink, baseUrl) {
96
+ try {
97
+ return new URL(permalink, baseUrl ?? "https://example.invalid").toString();
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ // src/targets.ts
104
+ function entryPublicUrl(entry, config) {
105
+ if (config?.site.baseUrl) {
106
+ return resolvePublicUrl(entry.permalink, config.site.baseUrl);
107
+ }
108
+ return normalizeWebmentionUrl(entry.permalink);
109
+ }
110
+ function findTargetEntry(manifest, config, target) {
111
+ const key = urlComparisonKey(target);
112
+ for (const entry of manifest.entries) {
113
+ const publicUrl = entryPublicUrl(entry, config);
114
+ if (publicUrl !== null && urlComparisonKey(publicUrl) === key) return entry;
115
+ }
116
+ return void 0;
117
+ }
118
+ function isAllowedTarget(target, allowedTargets, config) {
119
+ const key = urlComparisonKey(target);
120
+ for (const candidate of allowedTargets) {
121
+ const resolved = config?.site.baseUrl ? resolvePublicUrl(candidate, config.site.baseUrl) : normalizeWebmentionUrl(candidate);
122
+ if (resolved !== null && urlComparisonKey(resolved) === key) return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ // src/feed.ts
128
+ function createWebmentionFeedHandler(options, provider) {
129
+ return async (context) => {
130
+ if (!supportsWebmentionCapability(provider, "query")) {
131
+ return errorResponse(501, "unsupported_query");
132
+ }
133
+ const target = context.request.query.target;
134
+ const limit = parsePositiveInteger(context.request.query.limit) ?? options.limit;
135
+ const since = context.request.query.since;
136
+ let mentions;
137
+ try {
138
+ if (target !== void 0 && target !== "") {
139
+ const result = await provider.query({
140
+ type: "mentions_for_target",
141
+ target,
142
+ limit
143
+ });
144
+ mentions = result.mentions;
145
+ } else {
146
+ const result = await provider.query({
147
+ type: "all_mentions",
148
+ limit,
149
+ since
150
+ });
151
+ mentions = result.mentions;
152
+ }
153
+ } catch (error) {
154
+ if (error instanceof UnsupportedWebmentionQueryError) {
155
+ return errorResponse(501, "unsupported_query");
156
+ }
157
+ return errorResponse(503, "storage_unavailable");
158
+ }
159
+ return {
160
+ headers: {
161
+ "content-type": "application/json; charset=utf-8",
162
+ "cache-control": "public, max-age=60"
163
+ },
164
+ json: buildFeed(context.manifest, context.config, mentions)
165
+ };
166
+ };
167
+ }
168
+ function buildFeed(manifest, config, mentions) {
169
+ return {
170
+ version: "1",
171
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
172
+ count: mentions.length,
173
+ mentions: mentions.map((mention) => ({
174
+ ...mention,
175
+ entry: summarizeEntry(manifest, config, mention.target)
176
+ }))
177
+ };
178
+ }
179
+ function summarizeEntry(manifest, config, target) {
180
+ const entry = findTargetEntry(manifest, config, target);
181
+ if (!entry) return null;
182
+ return {
183
+ slug: entry.slug,
184
+ permalink: entry.permalink,
185
+ title: entry.title
186
+ };
187
+ }
188
+ function parsePositiveInteger(value) {
189
+ if (value === void 0 || value.trim() === "") return void 0;
190
+ const parsed = Number(value);
191
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1e3)
192
+ return void 0;
193
+ return parsed;
194
+ }
195
+
196
+ // src/parse.ts
197
+ var ANCHOR_PATTERN = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
198
+ var ATTRIBUTE_PATTERN = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
199
+ var META_PATTERN = /<meta\b([^>]*?)\/?>/gi;
200
+ var TITLE_PATTERN = /<title\b[^>]*>([\s\S]*?)<\/title>/i;
201
+ var TIME_PATTERN = /<time\b([^>]*)>([\s\S]*?)<\/time>/gi;
202
+ var PARAGRAPH_PATTERN = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
203
+ var H_CARD_PATTERN = /<[a-z][a-z0-9-]*\b([^>]*)>([\s\S]*?)<\/[a-z][a-z0-9-]*>/gi;
204
+ var MAX_TEXT_LENGTH = 280;
205
+ function parseWebmentionSource(html, sourceUrl) {
206
+ const meta = extractMeta(html);
207
+ const title = firstText(
208
+ extractTagText(html, TITLE_PATTERN),
209
+ meta.get("og:title"),
210
+ meta.get("twitter:title")
211
+ );
212
+ const description = firstText(
213
+ meta.get("description"),
214
+ meta.get("og:description"),
215
+ extractTagText(html, PARAGRAPH_PATTERN)
216
+ );
217
+ const publishedAt = firstText(
218
+ meta.get("article:published_time"),
219
+ meta.get("og:updated_time"),
220
+ extractFirstTime(html)
221
+ );
222
+ const author = extractAuthor(html, sourceUrl, meta);
223
+ return {
224
+ ...title === void 0 ? {} : { title: truncate(title) },
225
+ ...description === void 0 ? {} : { excerpt: truncate(description) },
226
+ ...author === void 0 ? {} : { author },
227
+ ...publishedAt === void 0 ? {} : { publishedAt },
228
+ links: extractLinks(html, sourceUrl)
229
+ };
230
+ }
231
+ function findTargetLink(source, target) {
232
+ const targetKey = normalizeWebmentionUrl(target) ?? target;
233
+ const targetWithoutSlash = targetKey.replace(/\/$/, "");
234
+ for (const link of source.links) {
235
+ if (link.normalized === null) continue;
236
+ const linkWithoutSlash = link.normalized.replace(/\/$/, "");
237
+ if (linkWithoutSlash === targetWithoutSlash) return link;
238
+ }
239
+ return null;
240
+ }
241
+ function webmentionTypeForRels(rels) {
242
+ const values = rels.map((rel) => rel.toLowerCase());
243
+ if (values.includes("in-reply-to") || values.includes("reply")) {
244
+ return "reply";
245
+ }
246
+ if (values.includes("repost") || values.includes("reblog")) return "repost";
247
+ if (values.includes("like")) return "like";
248
+ if (values.includes("bookmark")) return "bookmark";
249
+ return "mention";
250
+ }
251
+ function extractLinks(html, sourceUrl) {
252
+ const links = [];
253
+ for (const match of html.matchAll(ANCHOR_PATTERN)) {
254
+ const attributes = parseAttributes(match[1] ?? "");
255
+ const href = attributes.get("href");
256
+ if (!href) continue;
257
+ links.push({
258
+ href,
259
+ normalized: resolve(href, sourceUrl),
260
+ rels: (attributes.get("rel") ?? "").split(/\s+/).filter((rel) => rel !== ""),
261
+ text: truncate(stripTags(decodeEntities(match[2] ?? "")))
262
+ });
263
+ }
264
+ return links;
265
+ }
266
+ function extractAuthor(html, sourceUrl, meta) {
267
+ const hCard = extractHCard(html, sourceUrl);
268
+ if (hCard) return hCard;
269
+ const name = firstText(meta.get("author"), meta.get("article:author"));
270
+ const url = firstText(meta.get("author:url"), extractAuthorLink(html));
271
+ if (!name && !url) return void 0;
272
+ return {
273
+ ...name === void 0 ? {} : { name: truncate(name) },
274
+ ...url === void 0 || resolve(url, sourceUrl) === null ? {} : { url: resolve(url, sourceUrl) }
275
+ };
276
+ }
277
+ function extractHCard(html, sourceUrl) {
278
+ for (const match of html.matchAll(H_CARD_PATTERN)) {
279
+ const classes = parseAttributes(match[1] ?? "").get("class") ?? "";
280
+ if (!/\bh-card\b/.test(classes)) continue;
281
+ const body = match[2] ?? "";
282
+ const name = extractElementText(body, "p-name");
283
+ const url = extractElementAttribute(body, "u-url", "href");
284
+ if (!name && !url) continue;
285
+ return {
286
+ ...name === void 0 ? {} : { name: truncate(name) },
287
+ ...url === void 0 || resolve(url, sourceUrl) === null ? {} : { url: resolve(url, sourceUrl) }
288
+ };
289
+ }
290
+ return void 0;
291
+ }
292
+ function extractElementText(html, classToken) {
293
+ const pattern = new RegExp(
294
+ `<[a-z][a-z0-9-]*\\b[^>]*\\bclass\\s*=\\s*(?:"[^"]*\\b${classToken}\\b[^"]*"|'[^']*\\b${classToken}\\b[^']*')[^>]*>([\\s\\S]*?)</[a-z][a-z0-9-]*>`,
295
+ "i"
296
+ );
297
+ const match = html.match(pattern);
298
+ return match ? stripTags(decodeEntities(match[1] ?? "")) : void 0;
299
+ }
300
+ function extractElementAttribute(html, classToken, attribute) {
301
+ const pattern = new RegExp(
302
+ `<[a-z][a-z0-9-]*\\b([^>]*\\bclass\\s*=\\s*(?:"[^"]*\\b${classToken}\\b[^"]*"|'[^']*\\b${classToken}\\b[^']*')[^>]*)>`,
303
+ "i"
304
+ );
305
+ const match = html.match(pattern);
306
+ if (!match) return void 0;
307
+ return parseAttributes(match[1] ?? "").get(attribute);
308
+ }
309
+ function extractAuthorLink(html) {
310
+ for (const match of html.matchAll(ANCHOR_PATTERN)) {
311
+ const attributes = parseAttributes(match[1] ?? "");
312
+ const rels = (attributes.get("rel") ?? "").toLowerCase().split(/\s+/);
313
+ if (rels.includes("author")) return attributes.get("href");
314
+ }
315
+ return void 0;
316
+ }
317
+ function extractMeta(html) {
318
+ const meta = /* @__PURE__ */ new Map();
319
+ for (const match of html.matchAll(META_PATTERN)) {
320
+ const attributes = parseAttributes(match[1] ?? "");
321
+ const key = (attributes.get("property") ?? attributes.get("name") ?? attributes.get("itemprop") ?? "").toLowerCase();
322
+ const content = attributes.get("content");
323
+ if (key && content !== void 0 && !meta.has(key)) {
324
+ meta.set(key, content);
325
+ }
326
+ }
327
+ return meta;
328
+ }
329
+ function extractFirstTime(html) {
330
+ for (const match of html.matchAll(TIME_PATTERN)) {
331
+ const datetime = parseAttributes(match[1] ?? "").get("datetime");
332
+ const value = datetime ?? stripTags(decodeEntities(match[2] ?? ""));
333
+ if (value) return value;
334
+ }
335
+ return void 0;
336
+ }
337
+ function extractTagText(html, pattern) {
338
+ const match = html.match(pattern);
339
+ if (!match) return void 0;
340
+ const text = stripTags(decodeEntities(match[1] ?? ""));
341
+ return text === "" ? void 0 : text;
342
+ }
343
+ function parseAttributes(input) {
344
+ const attributes = /* @__PURE__ */ new Map();
345
+ for (const match of input.matchAll(ATTRIBUTE_PATTERN)) {
346
+ const name = (match[1] ?? "").toLowerCase();
347
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
348
+ if (name !== "") attributes.set(name, value);
349
+ }
350
+ return attributes;
351
+ }
352
+ function resolve(value, base) {
353
+ const decoded = decodeEntities(value.trim());
354
+ if (decoded === "") return null;
355
+ try {
356
+ return normalizeWebmentionUrl(new URL(decoded, base).toString());
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+ function stripTags(value) {
362
+ return collapseWhitespace(value.replace(/<[^>]*>/g, " "));
363
+ }
364
+ function collapseWhitespace(value) {
365
+ return value.replace(/\s+/g, " ").trim();
366
+ }
367
+ function truncate(value) {
368
+ const text = collapseWhitespace(value);
369
+ return text.length <= MAX_TEXT_LENGTH ? text : `${text.slice(0, MAX_TEXT_LENGTH - 1).trimEnd()}…`;
370
+ }
371
+ function firstText(...values) {
372
+ for (const value of values) {
373
+ if (value !== void 0 && value.trim() !== "") return value;
374
+ }
375
+ return void 0;
376
+ }
377
+ var NAMED_ENTITIES = {
378
+ amp: "&",
379
+ lt: "<",
380
+ gt: ">",
381
+ quot: '"',
382
+ apos: "'",
383
+ nbsp: " "
384
+ };
385
+ function decodeEntities(value) {
386
+ return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (entity, body) => {
387
+ if (body.startsWith("#")) {
388
+ const isHex = body[1] === "x" || body[1] === "X";
389
+ const code = Number.parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10);
390
+ if (Number.isFinite(code) && code > 0 && code <= 1114111) {
391
+ return String.fromCodePoint(code);
392
+ }
393
+ return entity;
394
+ }
395
+ return NAMED_ENTITIES[body.toLowerCase()] ?? entity;
396
+ });
397
+ }
398
+
399
+ // src/verify.ts
400
+ var DEFAULT_WEBMENTION_TIMEOUT_MS = 1e4;
401
+ var DEFAULT_WEBMENTION_MAX_BYTES = 1e6;
402
+ var DEFAULT_WEBMENTION_USER_AGENT = "Riebeckite-Webmention/1.0 (+https://github.com/Rerurate514/riebeckite)";
403
+ var MAX_REDIRECTS = 10;
404
+ var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
405
+ function createWebmentionSourceFetcher(options = {}) {
406
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WEBMENTION_TIMEOUT_MS;
407
+ const maxBytes = options.maxBytes ?? DEFAULT_WEBMENTION_MAX_BYTES;
408
+ const userAgent = options.userAgent ?? DEFAULT_WEBMENTION_USER_AGENT;
409
+ const allowPrivateHosts = options.allowPrivateHosts ?? false;
410
+ const resolveHostname = options.resolveHostname ?? defaultResolveHostname;
411
+ return async (rawUrl) => {
412
+ const url = normalizeWebmentionUrl(rawUrl);
413
+ if (url === null) return null;
414
+ if (typeof globalThis.fetch !== "function") return null;
415
+ const controller = new AbortController();
416
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
417
+ try {
418
+ let currentUrl = url;
419
+ for (let redirects = 0; ; redirects += 1) {
420
+ if (!allowPrivateHosts && await isBlockedSourceUrl(currentUrl, resolveHostname)) {
421
+ return null;
422
+ }
423
+ const response = await globalThis.fetch(currentUrl, {
424
+ redirect: "manual",
425
+ signal: controller.signal,
426
+ headers: {
427
+ accept: "text/html,application/xhtml+xml",
428
+ "user-agent": userAgent
429
+ }
430
+ });
431
+ if (REDIRECT_STATUSES.has(response.status)) {
432
+ const location = response.headers.get("location");
433
+ await cancelBody(response);
434
+ if (location === null || redirects >= MAX_REDIRECTS) return null;
435
+ const next = resolveRedirectUrl(location, currentUrl);
436
+ if (next === null) return null;
437
+ currentUrl = next;
438
+ continue;
439
+ }
440
+ if (!response.ok) return null;
441
+ const contentType = response.headers.get("content-type") ?? "";
442
+ if (contentType !== "" && !isHtmlContentType(contentType)) return null;
443
+ const html = await readBodyWithinLimit(response, maxBytes);
444
+ if (html === null) return null;
445
+ return { url: response.url || currentUrl, html };
446
+ }
447
+ } catch {
448
+ return null;
449
+ } finally {
450
+ clearTimeout(timer);
451
+ }
452
+ };
453
+ }
454
+ async function verifyWebmention(input) {
455
+ const source = normalizeWebmentionUrl(input.source);
456
+ const target = normalizeWebmentionUrl(input.target);
457
+ if (source === null) return { ok: false, reason: "invalid_source" };
458
+ if (target === null) return { ok: false, reason: "invalid_target" };
459
+ const document = await input.fetchSource(source);
460
+ if (document === null) return { ok: false, reason: "source_unreachable" };
461
+ const parsed = parseWebmentionSource(document.html, source);
462
+ const link = findTargetLink(parsed, target);
463
+ if (link === null) return { ok: false, reason: "no_link_found" };
464
+ return {
465
+ ok: true,
466
+ mention: {
467
+ source,
468
+ target,
469
+ type: webmentionTypeForRels(link.rels),
470
+ verifiedAt: input.verifiedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
471
+ ...parsed.publishedAt === void 0 ? {} : { publishedAt: parsed.publishedAt },
472
+ ...parsed.title === void 0 ? {} : { title: parsed.title },
473
+ ...parsed.excerpt === void 0 ? {} : { excerpt: parsed.excerpt },
474
+ ...parsed.author === void 0 ? {} : { author: parsed.author }
475
+ }
476
+ };
477
+ }
478
+ var defaultResolveHostname = async (hostname) => {
479
+ const { lookup } = await import("node:dns/promises");
480
+ const records = await lookup(hostname, { all: true });
481
+ return records.map((record) => record.address);
482
+ };
483
+ async function isBlockedSourceUrl(url, resolveHostname) {
484
+ const hostname = new URL(url).hostname;
485
+ if (isDisallowedHostname(hostname)) return true;
486
+ const host = normalizeHostname(hostname);
487
+ if (isIpAddressLiteral(host)) return false;
488
+ let addresses;
489
+ try {
490
+ addresses = await resolveHostname(host);
491
+ } catch {
492
+ return false;
493
+ }
494
+ return addresses.some((address) => isDisallowedIpAddress(address));
495
+ }
496
+ function isDisallowedHostname(hostname) {
497
+ const host = normalizeHostname(hostname);
498
+ if (host === "" || host === "localhost" || host.endsWith(".localhost")) {
499
+ return true;
500
+ }
501
+ return isIpAddressLiteral(host) && isDisallowedIpAddress(host);
502
+ }
503
+ function isDisallowedIpAddress(address) {
504
+ const host = normalizeHostname(address);
505
+ return host.includes(":") ? isDisallowedIpv6(host) : isDisallowedIpv4(host);
506
+ }
507
+ function normalizeHostname(hostname) {
508
+ return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
509
+ }
510
+ function isIpAddressLiteral(host) {
511
+ return host.includes(":") || /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
512
+ }
513
+ function isDisallowedIpv4(host) {
514
+ const octets = parseIpv4(host);
515
+ if (octets === null) return true;
516
+ const [a, b, c] = octets;
517
+ if (a === 0) return true;
518
+ if (a === 10) return true;
519
+ if (a === 127) return true;
520
+ if (a === 169 && b === 254) return true;
521
+ if (a === 172 && b >= 16 && b <= 31) return true;
522
+ if (a === 192 && b === 168) return true;
523
+ if (a === 100 && b >= 64 && b <= 127) return true;
524
+ if (a === 192 && b === 0 && c === 0) return true;
525
+ if (a === 192 && b === 0 && c === 2) return true;
526
+ if (a === 192 && b === 88 && c === 99) return true;
527
+ if (a === 198 && (b === 18 || b === 19)) return true;
528
+ if (a === 198 && b === 51 && c === 100) return true;
529
+ if (a === 203 && b === 0 && c === 113) return true;
530
+ return a >= 224;
531
+ }
532
+ function isDisallowedIpv6(host) {
533
+ const bytes = parseIpv6(host);
534
+ if (bytes === null) return true;
535
+ if (bytes.every((byte) => byte === 0)) return true;
536
+ if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) {
537
+ return true;
538
+ }
539
+ if (bytes.slice(0, 10).every((byte) => byte === 0)) {
540
+ return isDisallowedIpv4Bytes(bytes, 12);
541
+ }
542
+ if ((bytes[0] & 254) === 252) return true;
543
+ if (bytes[0] === 254 && (bytes[1] & 192) === 128) return true;
544
+ if (bytes[0] === 254 && (bytes[1] & 192) === 192) return true;
545
+ if (bytes[0] === 255) return true;
546
+ if (bytes[0] === 32 && bytes[1] === 2) return true;
547
+ if (bytes[0] === 32 && bytes[1] === 1 && bytes[2] === 13 && bytes[3] === 184) {
548
+ return true;
549
+ }
550
+ if (bytes[0] === 1 && bytes[1] === 0) return true;
551
+ if (bytes[0] === 0 && bytes[1] === 100 && bytes[2] === 255 && bytes[3] === 155) {
552
+ return true;
553
+ }
554
+ return false;
555
+ }
556
+ function isDisallowedIpv4Bytes(bytes, offset) {
557
+ const a = bytes[offset];
558
+ const b = bytes[offset + 1];
559
+ const c = bytes[offset + 2];
560
+ const d = bytes[offset + 3];
561
+ return isDisallowedIpv4(`${a}.${b}.${c}.${d}`);
562
+ }
563
+ function parseIpv4(host) {
564
+ const parts = host.split(".");
565
+ if (parts.length !== 4) return null;
566
+ const octets = [];
567
+ for (const part of parts) {
568
+ if (!/^\d{1,3}$/.test(part)) return null;
569
+ const value = Number(part);
570
+ if (value > 255) return null;
571
+ octets.push(value);
572
+ }
573
+ const [a, b, c, d] = octets;
574
+ if (a === void 0 || b === void 0 || c === void 0 || d === void 0) {
575
+ return null;
576
+ }
577
+ return [a, b, c, d];
578
+ }
579
+ function parseIpv6(host) {
580
+ const withoutZone = host.split("%", 1)[0] ?? host;
581
+ if (!withoutZone.includes(":")) return null;
582
+ const sections = withoutZone.split("::");
583
+ if (sections.length > 2) return null;
584
+ const head = parseHextets(sections[0] ?? "");
585
+ const tail = sections.length === 2 ? parseHextets(sections[1] ?? "") : [];
586
+ if (head === null || tail === null) return null;
587
+ let hextets;
588
+ if (sections.length === 2) {
589
+ const missing = 8 - head.length - tail.length;
590
+ if (missing < 0) return null;
591
+ hextets = [...head, ...new Array(missing).fill(0), ...tail];
592
+ } else {
593
+ hextets = head;
594
+ }
595
+ if (hextets.length !== 8) return null;
596
+ const bytes = new Uint8Array(16);
597
+ for (let index = 0; index < 8; index += 1) {
598
+ const value = hextets[index];
599
+ bytes[index * 2] = value >> 8 & 255;
600
+ bytes[index * 2 + 1] = value & 255;
601
+ }
602
+ return bytes;
603
+ }
604
+ function parseHextets(segment) {
605
+ if (segment === "") return [];
606
+ const pieces = segment.split(":");
607
+ const hextets = [];
608
+ for (let index = 0; index < pieces.length; index += 1) {
609
+ const piece = pieces[index];
610
+ if (piece === void 0) return null;
611
+ if (piece.includes(".")) {
612
+ if (index !== pieces.length - 1) return null;
613
+ const octets = parseIpv4(piece);
614
+ if (octets === null) return null;
615
+ const [first, second, third, fourth] = octets;
616
+ hextets.push((first << 8 | second) >>> 0);
617
+ hextets.push((third << 8 | fourth) >>> 0);
618
+ continue;
619
+ }
620
+ if (!/^[0-9a-f]{1,4}$/.test(piece)) return null;
621
+ hextets.push(Number.parseInt(piece, 16));
622
+ }
623
+ return hextets;
624
+ }
625
+ function resolveRedirectUrl(location, baseUrl) {
626
+ try {
627
+ return normalizeWebmentionUrl(new URL(location, baseUrl).toString());
628
+ } catch {
629
+ return null;
630
+ }
631
+ }
632
+ async function readBodyWithinLimit(response, maxBytes) {
633
+ const declared = response.headers.get("content-length");
634
+ if (declared !== null) {
635
+ const length = Number(declared);
636
+ if (Number.isFinite(length) && length > maxBytes) return null;
637
+ }
638
+ const body = response.body;
639
+ if (body === null) {
640
+ const text = await response.text();
641
+ return new TextEncoder().encode(text).byteLength > maxBytes ? null : text;
642
+ }
643
+ const reader = body.getReader();
644
+ const chunks = [];
645
+ let total = 0;
646
+ try {
647
+ for (; ; ) {
648
+ const { done, value } = await reader.read();
649
+ if (done) break;
650
+ if (value === void 0) continue;
651
+ total += value.byteLength;
652
+ if (total > maxBytes) {
653
+ await reader.cancel();
654
+ return null;
655
+ }
656
+ chunks.push(value);
657
+ }
658
+ } finally {
659
+ reader.releaseLock();
660
+ }
661
+ const merged = new Uint8Array(total);
662
+ let offset = 0;
663
+ for (const chunk of chunks) {
664
+ merged.set(chunk, offset);
665
+ offset += chunk.byteLength;
666
+ }
667
+ return new TextDecoder().decode(merged);
668
+ }
669
+ async function cancelBody(response) {
670
+ try {
671
+ await response.body?.cancel();
672
+ } catch {
673
+ }
674
+ }
675
+ function isHtmlContentType(contentType) {
676
+ return /(?:text\/html|application\/xhtml\+xml)/i.test(contentType);
677
+ }
678
+
679
+ // src/receive.ts
680
+ var MAX_REQUEST_BODY_BYTES = 64 * 1024;
681
+ function createWebmentionReceiveHandler(options, provider) {
682
+ return async (context) => {
683
+ if (!supportsWebmentionCapability(provider, "store")) {
684
+ return errorResponse(503, "storage_unavailable");
685
+ }
686
+ const { body, headers } = context.request;
687
+ if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BODY_BYTES) {
688
+ return errorResponse(413, "payload_too_large");
689
+ }
690
+ const params = parseWebmentionRequestBody(body, headers);
691
+ if (params === null) return errorResponse(400, "invalid_request");
692
+ const { source, target } = params;
693
+ if (!source || !target) {
694
+ return errorResponse(400, "missing_source_or_target");
695
+ }
696
+ const entry = findTargetEntry(context.manifest, context.config, target);
697
+ const allowed = entry !== void 0 || isAllowedTarget(target, options.allowedTargets, context.config);
698
+ if (!allowed) return errorResponse(400, "target_not_found");
699
+ const verification = await verifyWebmention({
700
+ source,
701
+ target,
702
+ fetchSource: options.fetchSource
703
+ });
704
+ if ("reason" in verification) {
705
+ return errorResponse(400, verification.reason);
706
+ }
707
+ try {
708
+ await provider.store(verification.mention);
709
+ } catch {
710
+ return errorResponse(503, "storage_unavailable");
711
+ }
712
+ return jsonResponse(202, { status: "accepted" });
713
+ };
714
+ }
715
+ function parseWebmentionRequestBody(body, headers) {
716
+ const contentType = headers["content-type"]?.toLowerCase() ?? "";
717
+ if (contentType.includes("application/json")) {
718
+ let value;
719
+ try {
720
+ value = JSON.parse(body);
721
+ } catch {
722
+ return null;
723
+ }
724
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
725
+ return null;
726
+ }
727
+ const record = value;
728
+ return {
729
+ ...typeof record.source === "string" ? { source: record.source } : {},
730
+ ...typeof record.target === "string" ? { target: record.target } : {}
731
+ };
732
+ }
733
+ if (contentType !== "" && !contentType.includes("urlencoded")) {
734
+ return null;
735
+ }
736
+ const search = new URLSearchParams(body);
737
+ const source = search.get("source");
738
+ const target = search.get("target");
739
+ return {
740
+ ...source === null ? {} : { source },
741
+ ...target === null ? {} : { target }
742
+ };
743
+ }
744
+
745
+ // src/endpoints.ts
746
+ function createWebmentionEndpoints(options) {
747
+ return [
748
+ {
749
+ path: options.endpoint,
750
+ method: "POST",
751
+ handler: createWebmentionReceiveHandler(options, options.provider)
752
+ },
753
+ {
754
+ path: options.endpoint,
755
+ method: "GET",
756
+ handler: createWebmentionFeedHandler(options, options.provider)
757
+ }
758
+ ];
759
+ }
760
+
761
+ // src/mention.ts
762
+ var WEBMENTION_TYPES = [
763
+ "mention",
764
+ "reply",
765
+ "like",
766
+ "repost",
767
+ "bookmark"
768
+ ];
769
+ function webmentionKey(mention) {
770
+ return `${mention.source}
771
+ ${mention.target}`;
772
+ }
773
+
774
+ // src/memory_provider.ts
775
+ var MemoryWebmentionProvider = class {
776
+ capabilities = new Set(WEBMENTION_CAPABILITIES);
777
+ #mentions = /* @__PURE__ */ new Map();
778
+ constructor(initialMentions = []) {
779
+ for (const mention of initialMentions) {
780
+ this.#mentions.set(webmentionKey(mention), mention);
781
+ }
782
+ }
783
+ async store(mention) {
784
+ this.#mentions.set(webmentionKey(mention), mention);
785
+ }
786
+ async query(query) {
787
+ assertWebmentionQuerySupported(this, query);
788
+ const since = query.type === "all_mentions" ? query.since : void 0;
789
+ const mentions = [...this.#mentions.values()].filter(
790
+ (mention) => withinSince(mention, since)
791
+ );
792
+ if (query.type === "mentions_for_target") {
793
+ const key = urlComparisonKey(query.target);
794
+ return {
795
+ type: query.type,
796
+ target: query.target,
797
+ mentions: applyLimit(
798
+ mentions.filter((mention) => urlComparisonKey(mention.target) === key).sort(compareMentions),
799
+ query.limit
800
+ )
801
+ };
802
+ }
803
+ return {
804
+ type: query.type,
805
+ mentions: applyLimit(mentions.sort(compareMentions), query.limit)
806
+ };
807
+ }
808
+ };
809
+ function withinSince(mention, since) {
810
+ return since === void 0 || mention.verifiedAt >= since;
811
+ }
812
+ function applyLimit(mentions, limit) {
813
+ if (limit === void 0) return mentions;
814
+ return mentions.slice(0, Math.max(0, limit));
815
+ }
816
+ function compareMentions(left, right) {
817
+ return (right.publishedAt ?? right.verifiedAt).localeCompare(
818
+ left.publishedAt ?? left.verifiedAt
819
+ ) || left.source.localeCompare(right.source);
820
+ }
821
+
822
+ // src/options.ts
823
+ var DEFAULT_WEBMENTION_ENDPOINT = "/webmentions";
824
+ var DEFAULT_WEBMENTION_HEADING_TEXT = "Mentions";
825
+ var DEFAULT_WEBMENTION_LIMIT = 20;
826
+ var DEFAULT_WEBMENTION_CLASS_NAME = "rr-webmention";
827
+ function resolveWebmentionOptions(options = {}) {
828
+ return {
829
+ provider: options.provider ?? new MemoryWebmentionProvider(),
830
+ endpoint: normalizeEndpoint(options.endpoint),
831
+ render: options.render ?? true,
832
+ headingText: normalizeText(
833
+ options.headingText,
834
+ DEFAULT_WEBMENTION_HEADING_TEXT
835
+ ),
836
+ limit: normalizeLimit(options.limit),
837
+ className: normalizeText(options.className, DEFAULT_WEBMENTION_CLASS_NAME),
838
+ allowedTargets: options.allowedTargets ?? [],
839
+ fetchSource: options.fetchSource ?? createWebmentionSourceFetcher({
840
+ timeoutMs: options.timeoutMs,
841
+ maxBytes: options.maxBytes,
842
+ userAgent: options.userAgent,
843
+ allowPrivateHosts: options.allowPrivateHosts
844
+ }),
845
+ nofollow: options.nofollow ?? true
846
+ };
847
+ }
848
+ function validateWebmentionOptions(options) {
849
+ if (!options) return [];
850
+ const issues = [];
851
+ if (options.provider !== void 0 && !isWebmentionProvider(options.provider)) {
852
+ issues.push({
853
+ path: "provider",
854
+ message: "Expected a webmention provider with capabilities, store, and query."
855
+ });
856
+ }
857
+ if (options.endpoint !== void 0 && !isEndpointPath(options.endpoint)) {
858
+ issues.push({
859
+ path: "endpoint",
860
+ message: 'Expected a site-relative path starting with "/" without query, hash, or whitespace.'
861
+ });
862
+ }
863
+ for (const key of ["headingText", "className", "userAgent"]) {
864
+ const value = options[key];
865
+ if (value !== void 0 && (typeof value !== "string" || value.trim() === "")) {
866
+ issues.push({ path: key, message: "Expected a non-empty string." });
867
+ }
868
+ }
869
+ if (options.limit !== void 0 && !isNonNegativeInteger(options.limit)) {
870
+ issues.push({
871
+ path: "limit",
872
+ message: "Expected a non-negative integer."
873
+ });
874
+ }
875
+ if (options.timeoutMs !== void 0 && !isPositiveInteger(options.timeoutMs)) {
876
+ issues.push({
877
+ path: "timeoutMs",
878
+ message: "Expected a positive integer."
879
+ });
880
+ }
881
+ if (options.maxBytes !== void 0 && !isPositiveInteger(options.maxBytes)) {
882
+ issues.push({
883
+ path: "maxBytes",
884
+ message: "Expected a positive integer."
885
+ });
886
+ }
887
+ for (const key of ["render", "nofollow", "allowPrivateHosts"]) {
888
+ const value = options[key];
889
+ if (value !== void 0 && typeof value !== "boolean") {
890
+ issues.push({ path: key, message: "Expected a boolean." });
891
+ }
892
+ }
893
+ if (options.fetchSource !== void 0 && typeof options.fetchSource !== "function") {
894
+ issues.push({
895
+ path: "fetchSource",
896
+ message: "Expected a source fetcher function."
897
+ });
898
+ }
899
+ if (options.allowedTargets !== void 0) {
900
+ if (!Array.isArray(options.allowedTargets)) {
901
+ issues.push({
902
+ path: "allowedTargets",
903
+ message: "Expected an array of absolute http(s) URLs."
904
+ });
905
+ } else {
906
+ options.allowedTargets.forEach((target, index) => {
907
+ if (!isAbsoluteHttpUrl(target)) {
908
+ issues.push({
909
+ path: `allowedTargets.${index}`,
910
+ message: "Expected an absolute http(s) URL."
911
+ });
912
+ }
913
+ });
914
+ }
915
+ }
916
+ return issues;
917
+ }
918
+ function isWebmentionProvider(value) {
919
+ return typeof value === "object" && value !== null && "capabilities" in value && "store" in value && typeof value.store === "function" && "query" in value && typeof value.query === "function";
920
+ }
921
+ function normalizeEndpoint(value) {
922
+ if (value === void 0) return DEFAULT_WEBMENTION_ENDPOINT;
923
+ const trimmed = value.trim();
924
+ if (!isEndpointPath(trimmed)) return DEFAULT_WEBMENTION_ENDPOINT;
925
+ return trimmed === "/" ? DEFAULT_WEBMENTION_ENDPOINT : trimmed.replace(/\/$/, "");
926
+ }
927
+ function isEndpointPath(value) {
928
+ if (typeof value !== "string" || !value.startsWith("/")) return false;
929
+ if (value.length < 2) return false;
930
+ if (value.includes("?") || value.includes("#")) return false;
931
+ if (/\s/.test(value) || value.includes("//")) return false;
932
+ if (value === "/assets" || value.startsWith("/assets/")) return false;
933
+ return true;
934
+ }
935
+ function normalizeText(value, fallback) {
936
+ if (typeof value !== "string" || value.trim() === "") return fallback;
937
+ return value.trim();
938
+ }
939
+ function normalizeLimit(value) {
940
+ if (value === void 0 || !isNonNegativeInteger(value)) {
941
+ return DEFAULT_WEBMENTION_LIMIT;
942
+ }
943
+ return value;
944
+ }
945
+ function isNonNegativeInteger(value) {
946
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
947
+ }
948
+ function isPositiveInteger(value) {
949
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
950
+ }
951
+ function isAbsoluteHttpUrl(value) {
952
+ if (typeof value !== "string" || value.trim() === "") return false;
953
+ try {
954
+ const url = new URL(value);
955
+ return url.protocol === "http:" || url.protocol === "https:";
956
+ } catch {
957
+ return false;
958
+ }
959
+ }
960
+
961
+ // src/render.ts
962
+ import { escapeHtml, escapeHtmlAttribute } from "@riebeckite/core";
963
+ var WEBMENTION_ATTRIBUTE = "data-webmention";
964
+ function renderWebmentionSection(mentions, options) {
965
+ if (mentions.length === 0) return "";
966
+ const className = escapeHtmlAttribute(options.className);
967
+ const items = mentions.map((mention) => renderWebmentionItem(mention, options)).join("");
968
+ return `<section class="${className}" ${WEBMENTION_ATTRIBUTE} data-webmention-count="${mentions.length}"><h2 class="${className}__heading">${escapeHtml(options.headingText)}</h2><ul class="${className}__list">${items}</ul></section>`;
969
+ }
970
+ function renderWebmentionItem(mention, options) {
971
+ const className = escapeHtmlAttribute(options.className);
972
+ const rel = options.nofollow ? ' rel="nofollow ugc"' : "";
973
+ const label = firstNonEmpty(mention.title, mention.author?.name) ?? mention.source;
974
+ const author = mention.author?.name !== void 0 && mention.title !== void 0 ? `<span class="${className}__author">${escapeHtml(mention.author.name)}</span>` : "";
975
+ const date = mention.publishedAt === void 0 ? "" : `<time class="${className}__date" datetime="${escapeHtmlAttribute(
976
+ mention.publishedAt
977
+ )}">${escapeHtml(formatDate(mention.publishedAt))}</time>`;
978
+ const excerpt = mention.excerpt === void 0 ? "" : `<p class="${className}__excerpt">${escapeHtml(mention.excerpt)}</p>`;
979
+ return `<li class="${className}__item ${className}__item--${mention.type}" data-webmention-type="${mention.type}"><a class="${className}__source" href="${escapeHtmlAttribute(
980
+ mention.source
981
+ )}"${rel}>${escapeHtml(label)}</a>${author}${date}${excerpt}</li>`;
982
+ }
983
+ function firstNonEmpty(...values) {
984
+ for (const value of values) {
985
+ if (value !== void 0 && value.trim() !== "") return value;
986
+ }
987
+ return void 0;
988
+ }
989
+ function formatDate(value) {
990
+ const date = new Date(value);
991
+ if (Number.isNaN(date.getTime())) return value;
992
+ return date.toISOString().slice(0, 10);
993
+ }
994
+
995
+ // src/server.ts
996
+ async function getWebmentionsForEntry(input) {
997
+ const entry = input.manifest.bySlug.get(input.slug);
998
+ if (!entry) return [];
999
+ const target = entryPublicUrl(entry, input.config);
1000
+ if (target === null) return [];
1001
+ const result = await input.provider.query({
1002
+ type: "mentions_for_target",
1003
+ target,
1004
+ limit: input.limit
1005
+ });
1006
+ return [...result.mentions].sort(compareMentions2);
1007
+ }
1008
+ function groupMentionsBySlug(manifest, config, mentions) {
1009
+ const byTarget = /* @__PURE__ */ new Map();
1010
+ for (const mention of mentions) {
1011
+ const key = urlComparisonKey(mention.target);
1012
+ const list = byTarget.get(key);
1013
+ if (list) list.push(mention);
1014
+ else byTarget.set(key, [mention]);
1015
+ }
1016
+ const grouped = /* @__PURE__ */ new Map();
1017
+ for (const entry of manifest.entries) {
1018
+ const target = entryPublicUrl(entry, config);
1019
+ if (target === null) continue;
1020
+ const list = byTarget.get(urlComparisonKey(target));
1021
+ if (list && list.length > 0) {
1022
+ grouped.set(entry.slug, [...list].sort(compareMentions2));
1023
+ }
1024
+ }
1025
+ return grouped;
1026
+ }
1027
+ function countUnmatchedMentions(manifest, config, mentions) {
1028
+ let unmatched = 0;
1029
+ for (const mention of mentions) {
1030
+ if (findTargetEntry(manifest, config, mention.target) === void 0) {
1031
+ unmatched += 1;
1032
+ }
1033
+ }
1034
+ return unmatched;
1035
+ }
1036
+ function compareMentions2(left, right) {
1037
+ return (right.publishedAt ?? right.verifiedAt).localeCompare(
1038
+ left.publishedAt ?? left.verifiedAt
1039
+ ) || left.source.localeCompare(right.source);
1040
+ }
1041
+
1042
+ // index.ts
1043
+ var webmentionPlugin = webmention;
1044
+ function webmention(options = {}) {
1045
+ const resolved = resolveWebmentionOptions(options);
1046
+ const provider = resolved.provider;
1047
+ const processed = /* @__PURE__ */ new Map();
1048
+ return definePlugin({
1049
+ name: WEBMENTION_PLUGIN_NAME,
1050
+ options,
1051
+ validateOptions: validateWebmentionOptions,
1052
+ onPostProcessed: ({ slug, content }) => {
1053
+ processed.set(slug, content);
1054
+ },
1055
+ onManifestCreated: async (context) => {
1056
+ if (!resolved.render || !supportsWebmentionCapability(provider, "query")) {
1057
+ processed.clear();
1058
+ return;
1059
+ }
1060
+ let mentions;
1061
+ try {
1062
+ const result = await provider.query({ type: "all_mentions" });
1063
+ mentions = result.mentions;
1064
+ } catch (error) {
1065
+ context.diagnostics.push({
1066
+ code: "webmention-query-failed",
1067
+ severity: "warning",
1068
+ pluginName: WEBMENTION_PLUGIN_NAME,
1069
+ message: "The webmention provider could not be queried, so mentions were not rendered.",
1070
+ meta: {
1071
+ cause: error instanceof Error ? error.message : String(error)
1072
+ }
1073
+ });
1074
+ processed.clear();
1075
+ return;
1076
+ }
1077
+ const grouped = groupMentionsBySlug(
1078
+ context.manifest,
1079
+ context.config,
1080
+ mentions
1081
+ );
1082
+ for (const [slug, list] of grouped) {
1083
+ const entry = context.manifest.bySlug.get(slug);
1084
+ if (!entry || entry.html.includes(WEBMENTION_ATTRIBUTE)) continue;
1085
+ const section = renderWebmentionSection(
1086
+ list.slice(0, resolved.limit),
1087
+ resolved
1088
+ );
1089
+ if (section === "") continue;
1090
+ entry.html = `${entry.html}${section}`;
1091
+ const content = processed.get(slug);
1092
+ if (content) content.html = entry.html;
1093
+ }
1094
+ const unmatched = countUnmatchedMentions(
1095
+ context.manifest,
1096
+ context.config,
1097
+ mentions
1098
+ );
1099
+ if (unmatched > 0) {
1100
+ context.diagnostics.push({
1101
+ code: "webmention-unmatched-target",
1102
+ severity: "info",
1103
+ pluginName: WEBMENTION_PLUGIN_NAME,
1104
+ message: `${unmatched} verified webmention(s) target a URL that is not a published entry and were not rendered.`
1105
+ });
1106
+ }
1107
+ processed.clear();
1108
+ },
1109
+ endpoints: createWebmentionEndpoints(resolved),
1110
+ assets: [createStyleAsset(WEBMENTION_PLUGIN_NAME)],
1111
+ addDiagnostics: () => buildWebmentionDiagnostics(provider, resolved)
1112
+ });
1113
+ }
1114
+ export {
1115
+ DEFAULT_WEBMENTION_CLASS_NAME,
1116
+ DEFAULT_WEBMENTION_ENDPOINT,
1117
+ DEFAULT_WEBMENTION_HEADING_TEXT,
1118
+ DEFAULT_WEBMENTION_LIMIT,
1119
+ DEFAULT_WEBMENTION_MAX_BYTES,
1120
+ DEFAULT_WEBMENTION_TIMEOUT_MS,
1121
+ DEFAULT_WEBMENTION_USER_AGENT,
1122
+ MemoryWebmentionProvider,
1123
+ UnsupportedWebmentionQueryError,
1124
+ WEBMENTION_ATTRIBUTE,
1125
+ WEBMENTION_CAPABILITIES,
1126
+ WEBMENTION_TYPES,
1127
+ assertWebmentionQuerySupported,
1128
+ buildFeed,
1129
+ buildWebmentionDiagnostics,
1130
+ compareMentions2 as compareMentions,
1131
+ countUnmatchedMentions,
1132
+ createWebmentionEndpoints,
1133
+ createWebmentionFeedHandler,
1134
+ createWebmentionReceiveHandler,
1135
+ createWebmentionSourceFetcher,
1136
+ entryPublicUrl,
1137
+ findTargetEntry,
1138
+ findTargetLink,
1139
+ getWebmentionsForEntry,
1140
+ groupMentionsBySlug,
1141
+ isAllowedTarget,
1142
+ normalizeWebmentionUrl,
1143
+ parseWebmentionRequestBody,
1144
+ parseWebmentionSource,
1145
+ renderWebmentionItem,
1146
+ renderWebmentionSection,
1147
+ requiredCapabilityForQuery,
1148
+ resolvePublicUrl,
1149
+ resolveWebmentionOptions,
1150
+ summarizeEntry,
1151
+ supportsWebmentionCapability,
1152
+ urlComparisonKey,
1153
+ validateWebmentionOptions,
1154
+ verifyWebmention,
1155
+ webmention,
1156
+ webmentionKey,
1157
+ webmentionPlugin,
1158
+ webmentionTypeForRels
1159
+ };