@hhkaos/webmentions-widget 0.5.4 → 0.6.0

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/README.md CHANGED
@@ -197,6 +197,12 @@ Finds the anchor in the source page that points back at you and quotes the
197
197
  sentence around it, rather than excerpting from the top of the post. Falls back to
198
198
  `content.text`. Pass `parseHTML` to run outside a browser.
199
199
 
200
+ ### `getMentionAuthor(mention)`
201
+
202
+ Returns `{name, url, photo, initial, hue}`. `name` falls back to the source host,
203
+ and `initial`/`hue` are there so a mention whose h-card has no `photo` — a plain
204
+ blog, or your own site — still renders an avatar instead of a gap.
205
+
200
206
  ### `getMentionSourceUrl(mention, content)`
201
207
 
202
208
  Appends a `#:~:text=` fragment so the source link lands on the quoted sentence.
@@ -209,6 +215,35 @@ can hydrate from a build-time snapshot without touching the network.
209
215
  Every remote string is written with `textContent`. Nothing in this package ever
210
216
  assigns remote HTML.
211
217
 
218
+ ## Avatars
219
+
220
+ Both renderers always emit an avatar element per mention. With `author.photo`
221
+ it is an `<img>` carrying the photo class (`webmention-photo` in React,
222
+ `webmentions__photo` in the DOM renderer); without one it is a `<span>` holding
223
+ the author's initial, carrying that same class plus `is-initial` and a
224
+ `--webmention-avatar-hue` custom property (0–359, stable per author name).
225
+
226
+ The package ships no CSS. Style the fallback yourself — the hue is offered, not
227
+ imposed:
228
+
229
+ ```css
230
+ .webmention-photo {
231
+ border-radius: 50%;
232
+ flex: 0 0 40px;
233
+ height: 40px;
234
+ width: 40px;
235
+ }
236
+
237
+ .webmention-photo.is-initial {
238
+ align-items: center;
239
+ background: hsl(var(--webmention-avatar-hue, 220) 45% 92%);
240
+ color: hsl(var(--webmention-avatar-hue, 220) 45% 30%);
241
+ display: flex;
242
+ font-weight: 700;
243
+ justify-content: center;
244
+ }
245
+ ```
246
+
212
247
  ## Notes on webmention.io quirks
213
248
 
214
249
  - The jf2 feed says `mention-of`; some payloads say `mention`. `normalizeProperty`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hhkaos/webmentions-widget",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Framework-agnostic, dependency-free widget to fetch and render webmention.io mentions for the current page.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/core.js CHANGED
@@ -46,6 +46,47 @@ export function cleanText(value) {
46
46
  return String(value ?? '').replace(/\s+/g, ' ').trim();
47
47
  }
48
48
 
49
+ /**
50
+ * The author as a renderer needs it: never an empty name, never an undefined
51
+ * photo. An h-card without `photo` is common — a mention from a plain blog, or
52
+ * from your own site — so `initial` is always there to fall back on and every
53
+ * mention can carry an avatar-shaped element.
54
+ */
55
+ export function getMentionAuthor(mention) {
56
+ const author = mention?.author || {};
57
+ const source = mention?.url || mention?.['wm-source'] || '';
58
+ let host = '';
59
+
60
+ try {
61
+ host = new URL(source).hostname.replace(/^www\./, '');
62
+ } catch {
63
+ host = '';
64
+ }
65
+
66
+ const name = cleanText(author.name) || host || 'Someone';
67
+
68
+ return {
69
+ name,
70
+ url: author.url || source,
71
+ photo: author.photo || '',
72
+ initial: (name.charAt(0) || '?').toUpperCase(),
73
+ // A stable hue per author, so a wall of initials reads as distinct people
74
+ // rather than as one repeated placeholder. Only useful to a stylesheet, so
75
+ // it travels as a custom property rather than as an inline colour.
76
+ hue: getAuthorHue(name),
77
+ };
78
+ }
79
+
80
+ function getAuthorHue(name) {
81
+ let hash = 0;
82
+
83
+ for (let index = 0; index < name.length; index += 1) {
84
+ hash = (hash * 31 + name.charCodeAt(index)) % 360;
85
+ }
86
+
87
+ return hash;
88
+ }
89
+
49
90
  /**
50
91
  * webmention.io and Bridgy mangle emoji into runs of "?" (and sometimes U+FFFD)
51
92
  * when extracting plain text from a toot or skeet. The emoji is unrecoverable,
package/src/react.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  fetchWebmentions,
13
13
  filterMentionsByTargets,
14
14
  formatMentionDate,
15
+ getMentionAuthor,
15
16
  getMentionContent,
16
17
  getMentionSourceUrl,
17
18
  getMentionType,
@@ -147,8 +148,36 @@ function renderLabel(label, keyPrefix) {
147
148
  ));
148
149
  }
149
150
 
151
+ /**
152
+ * Every mention gets an avatar-shaped element, photo or not: a row that
153
+ * sometimes starts with a 40px image and sometimes with nothing has two
154
+ * different layouts, and the one without reads as a rendering failure.
155
+ */
156
+ function Avatar({author, className, fallbackClassName, size}) {
157
+ if (author.photo) {
158
+ return h('img', {
159
+ className,
160
+ src: author.photo,
161
+ alt: '',
162
+ loading: 'lazy',
163
+ width: size,
164
+ height: size,
165
+ });
166
+ }
167
+
168
+ return h(
169
+ 'span',
170
+ {
171
+ className: fallbackClassName,
172
+ 'aria-hidden': 'true',
173
+ style: {'--webmention-avatar-hue': author.hue},
174
+ },
175
+ author.initial,
176
+ );
177
+ }
178
+
150
179
  function Face({mention, classNames}) {
151
- const author = mention.author || {};
180
+ const author = getMentionAuthor(mention);
152
181
  const sourceUrl = mention.url || mention['wm-source'] || author.url;
153
182
 
154
183
  return h(
@@ -161,24 +190,20 @@ function Face({mention, classNames}) {
161
190
  href: sourceUrl,
162
191
  rel: 'nofollow noopener',
163
192
  target: '_blank',
164
- title: `${author.name || 'Someone'} — ${getMentionType(mention)}`,
193
+ title: `${author.name} — ${getMentionType(mention)}`,
165
194
  },
166
- author.photo
167
- ? h('img', {
168
- className: classNames.facepilePhoto,
169
- src: author.photo,
170
- alt: '',
171
- loading: 'lazy',
172
- width: 32,
173
- height: 32,
174
- })
175
- : h('span', {'aria-hidden': 'true'}, (author.name || '?').slice(0, 1).toUpperCase()),
195
+ h(Avatar, {
196
+ author,
197
+ className: classNames.facepilePhoto,
198
+ fallbackClassName: classNames.facepileInitial,
199
+ size: 32,
200
+ }),
176
201
  ),
177
202
  );
178
203
  }
179
204
 
180
205
  function Thread({mention, classNames, labels, locale, maxLength}) {
181
- const author = mention.author || {};
206
+ const author = getMentionAuthor(mention);
182
207
  const published = mention.published || mention['wm-received'];
183
208
  const formattedDate = formatMentionDate(published, locale);
184
209
  const content = useMemo(
@@ -190,16 +215,12 @@ function Thread({mention, classNames, labels, locale, maxLength}) {
190
215
  return h(
191
216
  'li',
192
217
  {className: classNames.threadItem},
193
- author.photo
194
- ? h('img', {
195
- className: classNames.threadPhoto,
196
- src: author.photo,
197
- alt: '',
198
- loading: 'lazy',
199
- width: 40,
200
- height: 40,
201
- })
202
- : null,
218
+ h(Avatar, {
219
+ author,
220
+ className: classNames.threadPhoto,
221
+ fallbackClassName: classNames.threadInitial,
222
+ size: 40,
223
+ }),
203
224
  h(
204
225
  'div',
205
226
  {className: classNames.threadBody},
@@ -214,7 +235,7 @@ function Thread({mention, classNames, labels, locale, maxLength}) {
214
235
  rel: 'nofollow noopener',
215
236
  target: '_blank',
216
237
  },
217
- h('span', {className: 'p-name'}, author.name || mention.url),
238
+ h('span', {className: 'p-name'}, author.name),
218
239
  ),
219
240
  ' ',
220
241
  h('span', null, getMentionType(mention, labels)),
@@ -280,9 +301,11 @@ const REACT_CLASS_NAMES = {
280
301
  facepileItem: 'webmention-facepile-item',
281
302
  facepileLink: 'webmention-facepile-link',
282
303
  facepilePhoto: 'webmention-facepile-photo',
304
+ facepileInitial: 'webmention-facepile-photo is-initial',
283
305
  threadList: 'webmentions-list',
284
306
  threadItem: 'h-cite webmention',
285
307
  threadPhoto: 'webmention-photo',
308
+ threadInitial: 'webmention-photo is-initial',
286
309
  threadBody: 'webmention-body',
287
310
  threadMeta: 'webmention-meta',
288
311
  threadAuthor: 'p-author h-card u-url',
package/src/render.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  describeTimestamp,
11
11
  fetchWebmentions,
12
12
  formatMentionDate,
13
+ getMentionAuthor,
13
14
  getMentionContent,
14
15
  getMentionSourceUrl,
15
16
  getMentionType,
@@ -26,6 +27,7 @@ export const DEFAULT_CLASS_NAMES = {
26
27
  facepileItem: 'webmentions__facepile-item',
27
28
  facepileLink: 'webmentions__facepile-link',
28
29
  facepilePhoto: 'webmentions__facepile-photo',
30
+ facepileInitial: 'webmentions__facepile-photo is-initial',
29
31
  threadList: 'webmentions__list',
30
32
  threadItem: 'webmentions__item h-cite',
31
33
  threadBody: 'webmentions__body',
@@ -33,7 +35,8 @@ export const DEFAULT_CLASS_NAMES = {
33
35
  threadAuthor: 'webmentions__author p-author h-card u-url',
34
36
  threadContent: 'webmentions__content p-content',
35
37
  threadSource: 'webmentions__source u-url',
36
- threadPhoto: '',
38
+ threadPhoto: 'webmentions__photo',
39
+ threadInitial: 'webmentions__photo is-initial',
37
40
  updated: 'webmentions__updated',
38
41
  };
39
42
 
@@ -78,26 +81,14 @@ function resolveElement(value, root, doc) {
78
81
  return value;
79
82
  }
80
83
 
81
- function authorOf(mention) {
82
- const author = mention.author || {};
83
- const source = mention.url || mention['wm-source'] || '';
84
- let host = '';
85
-
86
- try {
87
- host = new URL(source).hostname.replace(/^www\./, '');
88
- } catch {
89
- host = '';
90
- }
91
-
92
- return {
93
- name: String(author.name || '').trim() || host || 'Someone',
94
- url: author.url || source,
95
- photo: author.photo || '',
96
- };
97
- }
98
-
99
- function createAvatar(author, className, doc, size) {
84
+ /**
85
+ * Every mention gets an avatar-shaped element, photo or not: a row that
86
+ * sometimes starts with an image and sometimes with nothing has two different
87
+ * layouts, and the one without reads as a rendering failure.
88
+ */
89
+ function createAvatar(author, classNames, doc, size) {
100
90
  let element;
91
+ let className;
101
92
 
102
93
  if (author.photo) {
103
94
  element = doc.createElement('img');
@@ -106,10 +97,13 @@ function createAvatar(author, className, doc, size) {
106
97
  element.loading = 'lazy';
107
98
  element.width = size;
108
99
  element.height = size;
100
+ className = classNames.photo;
109
101
  } else {
110
102
  element = doc.createElement('span');
111
103
  element.setAttribute('aria-hidden', 'true');
112
- element.textContent = (author.name.charAt(0) || '?').toUpperCase();
104
+ element.textContent = author.initial;
105
+ element.style.setProperty('--webmention-avatar-hue', author.hue);
106
+ className = classNames.initial ?? classNames.photo;
113
107
  }
114
108
 
115
109
  if (className) {
@@ -120,7 +114,7 @@ function createAvatar(author, className, doc, size) {
120
114
  }
121
115
 
122
116
  function createFace(mention, classNames, doc) {
123
- const author = authorOf(mention);
117
+ const author = getMentionAuthor(mention);
124
118
  const item = doc.createElement('li');
125
119
  item.className = classNames.facepileItem;
126
120
 
@@ -130,7 +124,12 @@ function createFace(mention, classNames, doc) {
130
124
  link.rel = 'nofollow noopener';
131
125
  link.target = '_blank';
132
126
  link.title = `${author.name} — ${getMentionType(mention)}`;
133
- link.appendChild(createAvatar(author, classNames.facepilePhoto, doc, 32));
127
+ link.appendChild(createAvatar(
128
+ author,
129
+ {photo: classNames.facepilePhoto, initial: classNames.facepileInitial},
130
+ doc,
131
+ 32,
132
+ ));
134
133
 
135
134
  item.appendChild(link);
136
135
 
@@ -171,7 +170,7 @@ function createFacepileGroup(property, mentions, {classNames, labels, doc}) {
171
170
  }
172
171
 
173
172
  function createThreadItem(mention, {classNames, labels, locale, maxLength, doc}) {
174
- const author = authorOf(mention);
173
+ const author = getMentionAuthor(mention);
175
174
  const published = mention.published || mention['wm-received'];
176
175
  const formattedDate = formatMentionDate(published, locale);
177
176
  const content = getMentionContent(mention, {maxLength});
@@ -180,9 +179,12 @@ function createThreadItem(mention, {classNames, labels, locale, maxLength, doc})
180
179
  const item = doc.createElement('li');
181
180
  item.className = classNames.threadItem;
182
181
 
183
- if (classNames.threadPhoto) {
184
- item.appendChild(createAvatar(author, classNames.threadPhoto, doc, 40));
185
- }
182
+ item.appendChild(createAvatar(
183
+ author,
184
+ {photo: classNames.threadPhoto, initial: classNames.threadInitial},
185
+ doc,
186
+ 40,
187
+ ));
186
188
 
187
189
  const body = doc.createElement('div');
188
190
  body.className = classNames.threadBody;