Merge pull request #1 from jerusdp/webmentions

feat(webmentions): add webmentions support templates
This commit is contained in:
gortavoher 2025-02-11 15:58:38 +00:00 committed by GitHub
commit 74ca55ddf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 825 additions and 22 deletions

View file

@ -328,6 +328,7 @@ allowed_domains = [
{ directive = "script-src", domains = ["'self'"] },
{ directive = "style-src", domains = ["'self'"] },
{ directive = "frame-src", domains = ["player.vimeo.com", "https://www.youtube-nocookie.com"] },
# { directive = "connect-src", domains = [ "https://webmention.io"] },
]
# Enable the CSP directives configured (or default).
@ -363,6 +364,47 @@ service = "goatcounter"
# Leave this field empty if you're using the service's default hosting.
self_hosted_url = "https://tabi-stats.osc.garden"
[extra.webmentions]
# enable = true
# Specify the domain registered with webmention.io.
# domain = "www.example.com"
# The HTML ID for the object to fill in with the webmention data.
# Defaults to "webmentions"
# id = "webmentions"
# data configuration for the webmention.min.js script
# The base URL to use for this page. Defaults to window.location
page_url = "https://beesbuzz.biz/code/439-Falsehoods-programmers-believe-about-email"
# Additional URLs to check, separated by |s
# add_urls
# The maximum number of words to render in reply mentions.
# wordcount = 20
# The maximum number of mentions to retrieve. Defaults to 30.
# max_webmentions
# By default, Webmentions render using the mf2 'url' element, which plays
# nicely with webmention bridges (such as brid.gy and telegraph)
# but allows certain spoofing attacks. If you would like to prevent
# spoofing, set this to a non-empty string (e.g. "true").
# prevent_spoofing
# What to order the responses by; defaults to 'published'. See
# https://github.com/aaronpk/webmention.io#api
# sort_by
# The order to sort the responses by; defaults to 'up' (i.e. oldest
# first). See https://github.com/aaronpk/webmention.io#api
# sort_dir
# If set to a non-empty string (e.g. "true"), will display comment-type responses
# (replies/mentions/etc.) as being part of the reactions
# (favorites/bookmarks/etc.) instead of in a separate comment list.
# comments_are_reactions = "true"
# giscus support for comments. https://giscus.app
# Setup instructions: https://welpo.github.io/tabi/blog/comments/#setup
[extra.giscus]

View file

@ -1,7 +1,7 @@
+++
title = "Domina la configuració de tabi: guia completa"
date = 2023-09-18
updated = 2025-02-09
updated = 2025-02-11
description = "Descobreix les múltiples maneres en què pots personalitzar tabi."
[taxonomies]

View file

@ -1,7 +1,7 @@
+++
title = "Domina la configuración de tabi: guía completa"
date = 2023-09-18
updated = 2025-02-09
updated = 2025-02-11
description = "Descubre las múltiples maneras en que puedes personalizar tabi."
[taxonomies]

View file

@ -1,7 +1,7 @@
+++
title = "Mastering tabi Settings: A Comprehensive Guide"
date = 2023-09-18
updated = 2025-02-09
updated = 2025-02-11
description = "Discover the many ways you can customise your tabi site."
[taxonomies]
@ -767,6 +767,37 @@ If you have enabled a system globally, but want to disable it on a specific page
Read [the docs](@/blog/comments/index.md) for more information on the available systems and their setup.
## Webmentions
As described by the recommended W3C standard [Webmention][1] is a simple way to notify any URL when you mention it on your site. From the receiver's perspective, it's a way to request notifications when other sites mention it.
For static sites [webmention.io][2] hosts a webmention endpoint that can be used to receive webmentions. This feature fetches the webmentions stored at webmention.io and displays them for a page. You will need to have setup an account for your website at webmention.io. When you enable the webmention feature it will advertise your webmention.io endpoint and display the webmentions for any post using the webmention shortcut.
### Configuration and Usage
Enable webmentions for your site by adding the following to your `config.toml` file. A directive for CSP is required to allow the webmention.io endpoint to be used.
```toml
[extra]
allowed_domains = [
{ directive = "connect-src", domains = [ "https://webmention.io"] },
]
[extra.webmentions]
enable = true
# Specify the domain registered with webmention.io.
domain = "www.example.com"
```
Add the shortcut on the page where you want to display the webmentions:
```
{{/* webmentions() */}}
```
[1]: https://www.w3.org/TR/webmention/#abstract-p-1
[2]: https://webmention.io/
### Analytics
| Page | Section | `config.toml` | Follows Hierarchy | Requires JavaScript |

View file

@ -1,7 +1,7 @@
+++
title = "Custom shortcodes"
date = 2023-02-19
updated = 2024-12-28
updated = 2025-02-11
description = "This theme includes some useful custom shortcodes that you can use to enhance your posts. Whether you want to display images that adapt to light and dark themes, or format a professional-looking reference section, these custom shortcodes have got you covered."
[taxonomies]
@ -79,9 +79,9 @@ classDiagram
+MakeAssumptions()
}
CognitiveDistortions *-- AllOrNothingThinking
CognitiveDistortions *-- Overgeneralization
CognitiveDistortions*-- Overgeneralization
CognitiveDistortions *-- MentalFilter
CognitiveDistortions *-- JumpingToConclusions
CognitiveDistortions*-- JumpingToConclusions
{% end %}
The Mermaid shortcode supports two parameters:
@ -121,6 +121,7 @@ Useful if you want to use a different image for the light and dark themes:
{{ dual_theme_image(light_src="img/paris_day.webp", dark_src="img/paris_night.webp" alt="The Eiffel tower") }}
#### Usage
```
{{/* dual_theme_image(light_src="img/paris_day.webp", dark_src="img/paris_night.webp" alt="The Eiffel tower") */}}
```
@ -151,7 +152,7 @@ Images with too much brightness or contrast can be jarring against a dark backgr
### Swap image on hover
Povides an interaction where the image displayed changes as the user hovers over it. Useful for before-after comparisons, for example.
Provides an interaction where the image displayed changes as the user hovers over it. Useful for before-after comparisons, for example.
{{ image_hover(default_src="img/edited.webp", hovered_src="img/raw.webp", default_alt="Edited picture", hovered_alt="Original shot") }}
@ -193,7 +194,7 @@ All other image shortcodes can be made into full-width by setting the optional p
Display a path or URL on the next code block found. If it starts with "http", it will become a link. Particularly useful when used in conjunction with the [remote text shortcode](#remote-text).
{{ add_src_to_code_block(src="https://github.com/welpo/doteki/blob/main/.gitignore") }}
{{ add_src_to_code_block(src="<https://github.com/welpo/doteki/blob/main/.gitignore>") }}
```.gitignore
{{ remote_text(src="https://raw.githubusercontent.com/welpo/doteki/main/.gitignore") }}
@ -292,19 +293,19 @@ Display lines 3 to 7 (both inclusive) of a local file:
Bring attention to information with these admonition shortcodes. They come in five `type`s: `note`, `tip`, `info`, `warning`, and `danger`.
{{ admonition(type="note", text="Some **content** with _Markdown_ `syntax`. Check [this `api`](#).") }}
{{ admonition(type="note", text="Some **content** with *Markdown* `syntax`. Check [this `api`](#).") }}
{{ admonition(type="tip", text="Some **content** with _Markdown_ `syntax`. Check [this `api`](#).") }}
{{ admonition(type="tip", text="Some **content** with *Markdown* `syntax`. Check [this `api`](#).") }}
{{ admonition(type="info", text="Some **content** with _Markdown_ `syntax`. Check [this `api`](#).") }}
{{ admonition(type="info", text="Some **content** with *Markdown* `syntax`. Check [this `api`](#).") }}
{{ admonition(type="warning", text="Some **content** with _Markdown_ `syntax`. Check [this `api`](#).") }}
{{ admonition(type="warning", text="Some **content** with *Markdown* `syntax`. Check [this `api`](#).") }}
{{ admonition(type="danger", text="Some **content** with _Markdown_ `syntax`. Check [this `api`](#).") }}
{{ admonition(type="danger", text="Some **content** with *Markdown* `syntax`. Check [this `api`](#).") }}
You can change the `title` and `icon` of the admonition. Both parameters take a string and default to the type of admonition. `icon` can be any of the available admonition types.
{{ admonition(type="note", icon="tip", title="Custom title and icon", text="Some **content** with _Markdown_ `syntax`. Check [this `api`](#).") }}
{{ admonition(type="note", icon="tip", title="Custom title and icon", text="Some **content** with *Markdown* `syntax`. Check [this `api`](#).") }}
#### Usage
@ -332,12 +333,12 @@ Both methods support the same parameters (`type`, `icon`, and `title`), with the
This shortcode allows you to display both the translated and original text for a quote. The quotation marks will be added automatically:
{{ multilingual_quote(original="Qué sosiego, ir por la vida en silencio, saludando sólo a los amigos.", translated="What tranquility, to go through life in silence, greeting only friends.", author="Francisco Umbral") }}
{{ multilingual_quote(original="Qué sosiego, ir por la vida en silencio, saludando sólo a los amigos.", translated="What tranquillity, to go through life in silence, greeting only friends.", author="Francisco Umbral") }}
#### Usage
```
{{/* multilingual_quote(original="Qué sosiego, ir por la vida en silencio, saludando sólo a los amigos.", translated="What tranquility, to go through life in silence, greeting only friends.", author="Francisco Umbral") */}}
{{/* multilingual_quote(original="Qué sosiego, ir por la vida en silencio, saludando sólo a los amigos.", translated="What tranquillity, to go through life in silence, greeting only friends.", author="Francisco Umbral") */}}
```
### References with hanging indent
@ -394,7 +395,7 @@ Use this shortcode if you want to have a wider table, paragraph, code block… O
| Title | Year | Director | Cinematographer | Genre | IMDb | Duration |
|-------------------|-------|----------------------|-----------------------|---------------|-------|--------------|
| Beoning | 2018 | Lee Chang-dong | Hong Kyung-pyo | Drama/Mystery | 7.5 | 148 min |
| Burning | 2018 | Lee Chang-dong | Hong Kyung-pyo | Drama/Mystery | 7.5 | 148 min |
| The Master | 2012 | Paul Thomas Anderson | Mihai Mălaimare Jr. | Drama/History | 7.1 | 137 min |
| The Tree of Life | 2011 | Terrence Malick | Emmanuel Lubezki | Drama | 6.8 | 139 min |
@ -419,10 +420,12 @@ Force the text direction of a content block. Overrides both the global `force_co
Accepts the parameter `direction`: the desired text direction. This can be either "ltr" (left-to-right) or "rtl" (right-to-left). Defaults to "ltr".
{% force_text_direction(direction="rtl") %}
```python
def مرحباالعالم():
print("مرحبا بالعالم!")
```
{% end %}
#### Usage
@ -439,3 +442,29 @@ def مرحباالعالم():
{%/* end */%}
````
## Displaying webmentions for your posts
As described by the recommended W3C standard [Webmention][1] is a simple way to notify any URL when you mention it on your site. From the receiver's perspective, it's a way to request notifications when other sites mention it.
For static sites [webmention.io][2] hosts a webmention endpoint that can be used to receive webmentions. This feature fetches the webmentions stored at webmention.io and displays them for a page. You will need to have setup an account for your website at webmention.io. When you enable the webmention feature it will advertise your webmention.io endpoint and display the webmentions for any post using the webmention shortcut.
### Configuration and Usage
Enable webmentions for your site, add the following to your `config.toml` file:
```toml
[extra.webmentions]
enable = true
# Specify the domain registered with webmention.io.
domain = "www.example.com"
```
Add the shortcut on the page where you want to display the webmentions:
```
{{/* webmentions() */}}
```
[1]: https://www.w3.org/TR/webmention/#abstract-p-1
[2]: https://webmention.io/

View file

@ -23,6 +23,7 @@
@use 'parts/_tags.scss';
@use 'parts/_theme-switch.scss';
@use 'parts/_zola-error.scss';
@use 'parts/_webmention.scss';
@font-face {
src: local('Inter'),

View file

@ -0,0 +1,96 @@
#webmentions {
margin: 0;
position: relative;
z-index: 100;
line-height: 1.2em;
color: var(--text-color);
background-color: var(--background-color);
}
#webmentions .comments {
max-height: 20em;
overflow-x: hidden;
overflow-y: scroll;
font-size: 80%;
}
#webmentions h2 {
font-size: medium;
margin: 0;
margin-top: 1.4em;
margin-bottom: 1.2em;
padding: 2px;
background: var(--background-color);
}
#webmentions .reacts img {
margin: 3px -1ex 1px 0;
display: inline;
}
#webmentions img.missing {
background: white;
border: dashed black 1px;
}
#webmentions ul {
list-style-type: none;
margin: 0;
padding: 4px;
}
#webmentions li {
text-indent: -1em;
padding-left: 1em;
}
#webmentions a.reaction {
position: relative;
text-decoration: none;
text-shadow: 0px 0px 3px white;
margin-right: 0;
letter-spacing: -1ex;
margin-right: 1ex;
}
#webmentions a.source {
margin-right: 1ex;
text-decoration: none;
color: var(--primary-color);
background-color: var(--background-color);
}
#webmentions a.reaction img {
max-height: 1.3em;
display: inline;
width: auto;
margin-right: -1ex;
border-radius: 25%;
}
#webmentions a.reaction sub {
font-size: 50%;
}
#webmentions .comments li {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
#webmentions .comments li .text {
color: var(--meta-color);
font-style: italic;
text-decoration: none;
text-wrap: wrap;
padding-left: 0.5em;
}
#webmentions .comments li .name {
color: var(--meta-color);
padding-left: 0.5em;
}
#webmentions .comments li .emoji {
display: none;
}

475
static/js/webmention.js Normal file
View file

@ -0,0 +1,475 @@
/* webmention.js
Simple thing for embedding webmentions from webmention.io into a page, client-side.
(c)2018-2022 fluffy (http://beesbuzz.biz)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
GitHub repo (for latest released versions, issue tracking, etc.):
https://github.com/PlaidWeb/webmention.js
Basic usage:
<script src="/path/to/webmention.js" data-param="val" ... async />
<div id="webmentions"></div>
Allowed parameters:
page-url:
The base URL to use for this page. Defaults to window.location
add-urls:
Additional URLs to check, separated by |s
id:
The HTML ID for the object to fill in with the webmention data.
Defaults to "webmentions"
wordcount:
The maximum number of words to render in reply mentions.
max-webmentions:
The maximum number of mentions to retrieve. Defaults to 30.
prevent-spoofing:
By default, Webmentions render using the mf2 'url' element, which plays
nicely with webmention bridges (such as brid.gy and telegraph)
but allows certain spoofing attacks. If you would like to prevent
spoofing, set this to a non-empty string (e.g. "true").
sort-by:
What to order the responses by; defaults to 'published'. See
https://github.com/aaronpk/webmention.io#api
sort-dir:
The order to sort the responses by; defaults to 'up' (i.e. oldest
first). See https://github.com/aaronpk/webmention.io#api
comments-are-reactions:
If set to a non-empty string (e.g. "true"), will display comment-type responses
(replies/mentions/etc.) as being part of the reactions
(favorites/bookmarks/etc.) instead of in a separate comment list.
A more detailed example:
<!-- If you want to translate the UI -->
<script src="/path/to/umd/i18next.js"></script>
<script>
// Setup i18next as described in https://www.i18next.com/overview/getting-started#basic-sample
</script>
<!-- Otherwise, only using the following is fine -->
<script src="/path/to/webmention.min.js"
data-id="webmentionContainer"
data-wordcount="30"
data-prevent-spoofing="true"
data-comments-are-reactions="true"
/>
*/
// Begin LibreJS code licensing
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt
(function () {
"use strict";
// Shim i18next
window.i18next = window.i18next || {
t: function t(/** @type {string} */key) { return key; }
}
const t = window.i18next.t.bind(window.i18next);
/**
* Read the configuration value.
*
* @param {string} key The configuration key.
* @param {string} dfl The default value.
* @returns {string}
*/
function getCfg(key, dfl) {
return document.currentScript.getAttribute("data-" + key) || dfl;
}
const refurl = getCfg("page-url", window.location.href.replace(/#.*$/, ""));
const addurls = getCfg("add-urls", undefined);
const containerID = getCfg("id", "webmentions");
/** @type {Number} */
const textMaxWords = getCfg("wordcount");
const maxWebmentions = getCfg("max-webmentions", 30);
const mentionSource = getCfg("prevent-spoofing") ? "wm-source" : "url";
const sortBy = getCfg("sort-by", "published");
const sortDir = getCfg("sort-dir", "up");
/** @type {boolean} */
const commentsAreReactions = getCfg("comments-are-reactions", false);
/**
* @typedef MentionType
* @type {"in-reply-to"|"like-of"|"repost-of"|"bookmark-of"|"mention-of"|"rsvp"|"follow-of"}
*/
/**
* Maps a reaction to a hover title.
*
* @type {Record<MentionType, string>}
*/
const reactTitle = {
"in-reply-to": t("replied"),
"like-of": t("liked"),
"repost-of": t("reposted"),
"bookmark-of": t("bookmarked"),
"mention-of": t("mentioned"),
"rsvp": t("RSVPed"),
"follow-of": t("followed")
};
/**
* Maps a reaction to an emoji.
*
* @type {Record<MentionType, string>}
*/
const reactEmoji = {
"in-reply-to": "💬",
"like-of": "❤️",
"repost-of": "🔄",
"bookmark-of": "⭐️",
"mention-of": "💬",
"rsvp": "📅",
"follow-of": "🐜"
};
/**
* @typedef RSVPEmoji
* @type {"yes"|"no"|"interested"|"maybe"|null}
*/
/**
* Maps a RSVP to an emoji.
*
* @type {Record<RSVPEmoji, string>}
*/
const rsvpEmoji = {
"yes": "✅",
"no": "❌",
"interested": "💡",
"maybe": "💭"
};
/**
* HTML escapes the string.
*
* @param {string} text The string to be escaped.
* @returns {string}
*/
function entities(text) {
return text.replace(/[&<>"]/g, (tag) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
}[tag] || tag));
}
/**
* Creates the markup for an reaction image.
*
* @param {Reaction} r
* @param {boolean} isComment
* @returns {string}
*/
function reactImage(r, isComment) {
const who = entities(
r.author?.name || r.url.split("/")[2]
);
/** @type {string} */
let response = reactTitle[r["wm-property"]] || t("reacted");
if (!isComment && r.content && r.content.text) {
response += ": " + extractComment(r);
}
let authorPhoto = '';
if (r.author && r.author.photo) {
authorPhoto = `
<img
src="${entities(r.author.photo)}"
loading="lazy"
decoding="async"
alt="${who}"
>
`;
} else {
authorPhoto = `
<img
class="missing"
src="data:image/webp;base64,UklGRkoCAABXRUJQVlA4TD4CAAAvP8APAIV0WduUOLr/m/iqY6SokDJSMD5xYX23SQizRsVdZmIj/f6goYUbiOj/BED7MOPReuBNT3vBesSzIex+SeqMFFkjebFmzH3S7POxDSJ1yaCbCmMnS2R46cRMPyQLw4GBK4esdK60pYwsZakecUCl5zsHv/5cPH08nx9/7i6rEEVCg2hR8VSd30PxMZpVoJZQO6Dixgg6X5oKFCmlVHIDmmMFShWumAXgCuyqVN8hHff/k+9fj8+ei7BVjpxBmZCUJv+6DhWGZwWvs+UoLHFCKsPYpfJtIcEXBTopEEsKwedZUv4ku1FZErKULLyQwFGgnmTs2vBD5qu44xwnG9uyjgrFOd+KRVlXyQfwQlauydaU6AVI7OjKXLUEqNtxJBmQegNDZgV7lxxqYMOMrDyC1NdAGbdiH9Ij0skjG+oTyfO0lmjdgvoH8iIgreuBMRYLSH+R3sAztXgL+XfS7E2bmfo6gnS0TrpnzHT7kL+skj7PgHuBwv/zpN8LDLQg7zfJZLBubMKnyeh6ZGyfDEfc2LYpnlUtG7JqsSHq1WoASbUS4KVaLwB8be5mfsGMDwBcm5VxbuxWxx3nkFanB6lYqsqSkOGkKicoDvXsneR7BkKU7DtaEuT7+pxBGVwx+9gVyqf2pVA9sC2CsmjZ1RJqEJHS4Tj/pCcS0JoyBYOsB91Xjh3OFfQPQhvCAYyeLJlaOoFp0XNNuD0BC8exr8uPx7D1JWkwFdZIXmD3MOPReuDNzHjBesSzIbQD"
alt="${who}$"
>
`;
}
let emoji = '';
if (reactEmoji[r['wm-property']]) {
emoji = `<span class="emoji">${reactEmoji[r['wm-property']]}</span>`;
} else {
emoji = `<span class="emoji">💥</span>`;
}
let rsvp = '';
if (r.rsvp && rsvpEmoji[r.rsvp]) {
rsvp = `${rsvpEmoji[r.rsvp]}`;
}
return`
<a
class="reaction"
rel="nofollow ugc"
title="${who} ${response}"
href="${r[mentionSource]}"
>
${authorPhoto}
${emoji}
${rsvp}
</a>
`;
}
/**
* Strip the protocol off a URL.
*
* @param {string} url The URL to strip protocol off.
* @returns {string}
*/
function stripurl(url) {
return url.substr(url.indexOf('//'));
}
/**
* Deduplicate multiple mentions from the same source URL.
*
* @param {Array<Reaction>} mentions Mentions of the source URL.
* @return {Array<Reaction>}
*/
function dedupe(mentions) {
/** @type {Array<Reaction>} */
const filtered = [];
/** @type {Record<string, boolean>} */
const seen = {};
mentions.forEach(function(r) {
// Strip off the protocol (i.e. treat http and https the same)
const source = stripurl(r.url);
if (!seen[source]) {
filtered.push(r);
seen[source] = true;
}
});
return filtered;
}
/**
* Extract comments from a reaction.
*
* @param {Reactions} c
* @returns string
*/
function extractComment(c) {
let text = entities(c.content.text);
if (textMaxWords) {
let words = text.replace(/\s+/g,' ').split(' ', textMaxWords + 1);
if (words.length > textMaxWords) {
words[textMaxWords - 1] += '&hellip;';
words = words.slice(0, textMaxWords);
text = words.join(' ');
}
}
return text;
}
/**
* Format comments as HTML.
*
* @param {Array<Reaction>} comments The comments to format.
* @returns string
*/
function formatComments(comments) {
const headline = `<h2>${t('Responses')}</h2>`;
const markup = comments
.map((c) => {
const image = reactImage(c, true);
let source = entities(c.url.split('/')[2]);
if (c.author && c.author.name) {
source = entities(c.author.name);
}
const link = `<a class="source" rel="nofollow ugc" href="${c[mentionSource]}">${source}</a>`;
let linkclass = "name";
let linktext = `(${t("mention")})`;
if (c.name) {
linkclass = "name";
linktext = entities(c.name);
} else if (c.content && c.content.text) {
linkclass = "text";
linktext = extractComment(c);
}
const type = `<span class="${linkclass}">${link} ${linktext}</span>`;
return `<li>${image} ${type}</li>`;
})
.join('');
return `
${headline}
<ul class="comments">${markup}</ul>
`;
}
/**
* @typedef {Object} Reaction
* @property {string} url
* @property {Object?} author
* @property {string?} author.name
* @property {string?} author.photo
* @property {Object?} content
* @property {string?} content.text
* @property {RSVPEmoji?} rsvp
* @property {MentionType?} wm-property
* @property {string?} wm-source
*/
/**
* Formats a list of reactions as HTML.
*
* @param {Array<Reaction>} reacts List of reactions to format
* @returns string
*/
function formatReactions(reacts) {
const headline = `<h2>${t('Reactions')}</h2>`;
const markup = reacts.map((r) => reactImage(r)).join('');
return `
${headline}
<ul class="reacts">${markup}</ul>
`;
}
/**
* @typedef WebmentionResponse
* @type {Object}
* @property {Array<Reaction>} children
*/
/**
* Register event listener.
*/
window.addEventListener("load", async function () {
const container = document.getElementById(containerID);
if (!container) {
// no container, so do nothing
return;
}
const pages = [stripurl(refurl)];
if (!!addurls) {
addurls.split('|').forEach(function (url) {
pages.push(stripurl(url));
});
}
let apiURL = `https://webmention.io/api/mentions.jf2?per-page=${maxWebmentions}&sort-by=${sortBy}&sort-dir=${sortDir}`;
pages.forEach(function (path) {
apiURL += `&target[]=${encodeURIComponent('http:' + path)}&target[]=${encodeURIComponent('https:' + path)}`;
});
/** @type {WebmentionResponse} */
let json = {};
try {
const response = await window.fetch(apiURL);
if (response.status >= 200 && response.status < 300) {
json = await response.json();
} else {
console.error("Could not parse response");
new Error(response.statusText);
}
} catch(error) {
// Purposefully not escalate further, i.e. no UI update
console.error("Request failed", error);
}
/** @type {Array<Reaction>} */
let comments = [];
/** @type {Array<Reaction>} */
const collects = [];
if (commentsAreReactions) {
comments = collects;
}
/** @type {Record<MentionType, Array<Reaction>>} */
const mapping = {
"in-reply-to": comments,
"like-of": collects,
"repost-of": collects,
"bookmark-of": collects,
"follow-of": collects,
"mention-of": comments,
"rsvp": comments
};
json.children.forEach(function(child) {
// Map each mention into its respective container
const store = mapping[child['wm-property']];
if (store) {
store.push(child);
}
});
// format the comment-type things
let formattedComments = '';
if (comments.length > 0 && comments !== collects) {
formattedComments = formatComments(dedupe(comments));
}
// format the other reactions
let reactions = '';
if (collects.length > 0) {
reactions = formatReactions(dedupe(collects));
}
container.innerHTML = `${formattedComments}${reactions}`;
});
}());
// End-of-file marker for LibreJS
// @license-end

3
static/js/webmention.min.js vendored Normal file
View file

@ -0,0 +1,3 @@
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt
!function(){"use strict";window.i18next=window.i18next||{t:function(n){return n}};const n=window.i18next.t.bind(window.i18next);function t(n,t){return document.currentScript.getAttribute("data-"+n)||t}const e=t("page-url",window.location.href.replace(/#.*$/,"")),o=t("add-urls",void 0),s=t("id","webmentions"),r=t("wordcount"),i=t("max-webmentions",30),a=t("prevent-spoofing")?"wm-source":"url",l=t("sort-by","published"),c=t("sort-dir","up"),u=t("comments-are-reactions",!1),p={"in-reply-to":n("replied"),"like-of":n("liked"),"repost-of":n("reposted"),"bookmark-of":n("bookmarked"),"mention-of":n("mentioned"),rsvp:n("RSVPed"),"follow-of":n("followed")},f={"in-reply-to":"💬","like-of":"❤️","repost-of":"🔄","bookmark-of":"⭐️","mention-of":"💬",rsvp:"📅","follow-of":"🐜"},m={yes:"✅",no:"❌",interested:"💡",maybe:"💭"};function d(n){return n.replace(/[&<>"]/g,(n=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[n]||n)))}function w(t,e){const o=d(t.author?.name||t.url.split("/")[2]);let s=p[t["wm-property"]]||n("reacted");!e&&t.content&&t.content.text&&(s+=": "+$(t));let r="";r=t.author&&t.author.photo?`\n <img\n src="${d(t.author.photo)}"\n loading="lazy"\n decoding="async"\n alt="${o}"\n >\n `:`\n <img\n class="missing"\n src="data:image/webp;base64,UklGRkoCAABXRUJQVlA4TD4CAAAvP8APAIV0WduUOLr/m/iqY6SokDJSMD5xYX23SQizRsVdZmIj/f6goYUbiOj/BED7MOPReuBNT3vBesSzIex+SeqMFFkjebFmzH3S7POxDSJ1yaCbCmMnS2R46cRMPyQLw4GBK4esdK60pYwsZakecUCl5zsHv/5cPH08nx9/7i6rEEVCg2hR8VSd30PxMZpVoJZQO6Dixgg6X5oKFCmlVHIDmmMFShWumAXgCuyqVN8hHff/k+9fj8+ei7BVjpxBmZCUJv+6DhWGZwWvs+UoLHFCKsPYpfJtIcEXBTopEEsKwedZUv4ku1FZErKULLyQwFGgnmTs2vBD5qu44xwnG9uyjgrFOd+KRVlXyQfwQlauydaU6AVI7OjKXLUEqNtxJBmQegNDZgV7lxxqYMOMrDyC1NdAGbdiH9Ij0skjG+oTyfO0lmjdgvoH8iIgreuBMRYLSH+R3sAztXgL+XfS7E2bmfo6gnS0TrpnzHT7kL+skj7PgHuBwv/zpN8LDLQg7zfJZLBubMKnyeh6ZGyfDEfc2LYpnlUtG7JqsSHq1WoASbUS4KVaLwB8be5mfsGMDwBcm5VxbuxWxx3nkFanB6lYqsqSkOGkKicoDvXsneR7BkKU7DtaEuT7+pxBGVwx+9gVyqf2pVA9sC2CsmjZ1RJqEJHS4Tj/pCcS0JoyBYOsB91Xjh3OFfQPQhvCAYyeLJlaOoFp0XNNuD0BC8exr8uPx7D1JWkwFdZIXmD3MOPReuDNzHjBesSzIbQD"\n alt="${o}$"\n >\n `;let i="";i=f[t["wm-property"]]?`<span class="emoji">${f[t["wm-property"]]}</span>`:'<span class="emoji">💥</span>';let l="";return t.rsvp&&m[t.rsvp]&&(l=`${m[t.rsvp]}`),`\n <a\n class="reaction"\n rel="nofollow ugc"\n title="${o} ${s}"\n href="${t[a]}"\n >\n ${r}\n ${i}\n ${l}\n </a>\n `}function h(n){return n.substr(n.indexOf("//"))}function g(n){const t=[],e={};return n.forEach((function(n){const o=h(n.url);e[o]||(t.push(n),e[o]=!0)})),t}function $(n){let t=d(n.content.text);if(r){let n=t.replace(/\s+/g," ").split(" ",r+1);n.length>r&&(n[r-1]+="&hellip;",n=n.slice(0,r),t=n.join(" "))}return t}window.addEventListener("load",(async function(){const t=document.getElementById(s);if(!t)return;const r=[h(e)];o&&o.split("|").forEach((function(n){r.push(h(n))}));let p=`https://webmention.io/api/mentions.jf2?per-page=${i}&sort-by=${l}&sort-dir=${c}`;r.forEach((function(n){p+=`&target[]=${encodeURIComponent("http:"+n)}&target[]=${encodeURIComponent("https:"+n)}`}));let f={};try{const n=await window.fetch(p);n.status>=200&&n.status<300?f=await n.json():(console.error("Could not parse response"),new Error(n.statusText))}catch(n){console.error("Request failed",n)}let m=[];const x=[];u&&(m=x);const y={"in-reply-to":m,"like-of":x,"repost-of":x,"bookmark-of":x,"follow-of":x,"mention-of":m,rsvp:m};f.children.forEach((function(n){const t=y[n["wm-property"]];t&&t.push(n)}));let k="";m.length>0&&m!==x&&(k=function(t){return`\n <h2>${n("Responses")}</h2>\n <ul class="comments">${t.map((t=>{const e=w(t,!0);let o=d(t.url.split("/")[2]);t.author&&t.author.name&&(o=d(t.author.name));const s=`<a class="source" rel="nofollow ugc" href="${t[a]}">${o}</a>`;let r="name",i=`(${n("mention")})`;return t.name?(r="name",i=d(t.name)):t.content&&t.content.text&&(r="text",i=$(t)),`<li>${e} <span class="${r}">${s} ${i}</span></li>`})).join("")}</ul>\n `}(g(m)));let b="";var B;x.length>0&&(B=g(x),b=`\n <h2>${n("Reactions")}</h2>\n <ul class="reacts">${B.map((n=>w(n))).join("")}</ul>\n `),t.innerHTML=`${k}${b}`}))}();
// @license-end

View file

@ -136,10 +136,12 @@
<meta property="og:site_name" content="{{ config.title }}">
{%- if config.extra.theme_switcher and config.extra.theme_switcher == true -%}
{# If JavaScript is disabled, hide the button. #}
<noscript><link rel="stylesheet" href="{{ get_url(path='no_js.css') | safe }}"/></noscript>
<script type="text/javascript" src="{{ get_url(path='js/initializeTheme.min.js') | safe }}"></script>
<script defer src="{{ get_url(path='js/themeSwitcher.min.js', trailing_slash=false) | safe }}"></script>
{# If JavaScript is disabled, hide the button. #}
<noscript>
<link rel="stylesheet" href="{{ get_url(path='no_js.css') | safe }}" />
</noscript>
<script type="text/javascript" src="{{ get_url(path='js/initializeTheme.min.js') | safe }}"></script>
<script defer src="{{ get_url(path='js/themeSwitcher.min.js', trailing_slash=false) | safe }}"></script>
{%- endif -%}
{%- if config.extra.analytics.service -%}
@ -151,6 +153,11 @@
<meta name="fediverse:creator" content="@{{ config.extra.fediverse_creator["handle"] }}@{{ config.extra.fediverse_creator["domain"]}}" />
{%- endif -%}
{# Webmentions #}
{%- if config.extra.webmentions.enable -%}
{%- include "partials/webmentions.html" -%}
{%- endif -%}
{# Search #}
{%- if config.build_search_index -%}
{%- if config.search.index_format -%}

View file

@ -0,0 +1,69 @@
{# Incorporate webmention.io links and script into the page head.
1. Provide the link to the webmention data in the at webmention.io.
2. Link to the stylesheet for styling webmentions on a page.
3. Add and configure the javascript to fetch and display the webmentions collected at webmention.io. #}
<link rel="webmention" href="https://webmention.io/{{ config.extra.webmentions.domain }}/webmention" />
{# Calculate the configured data for the script, if any #}
{% set script_data = "" %}
{% if config.extra.webmentions.id %}
{% set script_data = script_data ~ "data-id=" ~ config.extra.webmentions.id %}
{% endif %}
{% if config.extra.webmentions.page_url %}
{% set script_data = script_data ~ " data-page-url=" ~ config.extra.webmentions.page_url %}
{% endif %}
{% if config.extra.webmentions.add_urls %}
{% set script_data = script_data ~ "data-add-urls=" ~ config.extra.webmentions.add_urls %}
{% endif %}
{% if config.extra.webmentions.wordcount %}
{% set script_data = script_data ~ " data-wordcount=" ~ config.extra.webmentions.wordcount %}
{% endif %}
{% if config.extra.webmentions.max_webmentions %}
{% set script_data = script_data ~ "data-max-webmentions=" ~ config.extra.webmentions.max_webmentions %}
{% endif %}
{% if config.extra.webmentions.prevent_spoofing %}
{% set script_data = script_data ~ "data-prevent-spoofing=" ~ config.extra.webmentions.prevent_spoofing %}
{% endif %}
{% if config.extra.webmentions.sort_by %}
{% set script_data = script_data ~ "data-sort-by=" ~ config.extra.webmentions.sort_by %}
{% endif %}
{% if config.extra.webmentions.sort_dir %}
{% set script_data = script_data ~ "data-sort-dir=" ~ config.extra.webmentions.sort_dir %}
{% endif %}
{% if config.extra.webmentions.comments_are_reactions %}
{% set script_data = script_data ~ " data-comments-are-reactions=" ~ config.extra.webmentions.comments_are_reactions %}
{% endif %}
<script
async
src="{{ get_url(path='js/webmention.min.js', trailing_slash=false) | safe }}"
{{ script_data }}
>
</script>
{#
<script src="{{ get_url(path='js/webmention.js', trailing_slash=false) | safe }}"
data-id='webmentions'
data-page-url='https://beesbuzz.biz/code/439-Falsehoods-programmers-believe-about-email'
data-wordcount='20'
async>
</script>
<!-- <script src="{{ get_url(path='js/webmention.min.js', trailing_slash=false) | safe }}"
data-id="webmentions-condensed"
data-page-url="https://beesbuzz.biz/code/439-Falsehoods-programmers-believe-about-email"
data-comments-are-reactions="true"
data-wordcount="20"
async="">
</script> -->
#}

View file

@ -0,0 +1,8 @@
{%- set format = config.extra.webmentions.format | default(value="") -%}
{% if format == "" %}
{%- set dash = "" -%}
{% else %}
{%- set dash = "-" -%}
{% endif %}
<div class="webmentions-container" id="webmentions{{ dash}}{{ format }}">hello</div>

View file

@ -285,6 +285,7 @@ allowed_domains = [
{ directive = "script-src", domains = ["'self'"] },
{ directive = "style-src", domains = ["'self'"] },
{ directive = "frame-src", domains = ["player.vimeo.com", "https://www.youtube-nocookie.com"] },
# { directive = "connect-src", domains = [ "https://webmention.io"] },
]
# Enable the CSP directives configured (or default).
@ -320,6 +321,47 @@ custom_subset = true
# Leave this field empty if you're using the service's default hosting.
# self_hosted_url = ""
[extra.webmentions]
# enable = true
# Specify the domain registered with webmention.io.
# domain = "www.example.com"
# The HTML ID for the object to fill in with the webmention data.
# Defaults to "webmentions"
# id = "webmentions"
# data configuration for the webmention.min.js script
# The base URL to use for this page. Defaults to window.location
page_url = "https://beesbuzz.biz/code/439-Falsehoods-programmers-believe-about-email"
# Additional URLs to check, separated by |s
# add_urls
# The maximum number of words to render in reply mentions.
# wordcount = 20
# The maximum number of mentions to retrieve. Defaults to 30.
# max_webmentions
# By default, Webmentions render using the mf2 'url' element, which plays
# nicely with webmention bridges (such as brid.gy and telegraph)
# but allows certain spoofing attacks. If you would like to prevent
# spoofing, set this to a non-empty string (e.g. "true").
# prevent_spoofing
# What to order the responses by; defaults to 'published'. See
# https://github.com/aaronpk/webmention.io#api
# sort_by
# The order to sort the responses by; defaults to 'up' (i.e. oldest
# first). See https://github.com/aaronpk/webmention.io#api
# sort_dir
# If set to a non-empty string (e.g. "true"), will display comment-type responses
# (replies/mentions/etc.) as being part of the reactions
# (favorites/bookmarks/etc.) instead of in a separate comment list.
# comments_are_reactions = "true"
# giscus support for comments. https://giscus.app
# Setup instructions: https://welpo.github.io/tabi/blog/comments/#setup
[extra.giscus]