@rmdes/indiekit-endpoint-microsub 1.0.32 → 1.0.34
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/lib/controllers/follow.js +11 -0
- package/lib/controllers/reader.js +28 -0
- package/lib/feeds/capabilities.js +204 -0
- package/lib/feeds/normalizer.js +12 -4
- package/lib/polling/processor.js +40 -0
- package/package.json +1 -1
- package/views/partials/item-card.njk +18 -5
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { IndiekitError } from "@indiekit/error";
|
|
7
7
|
|
|
8
|
+
import { detectCapabilities } from "../feeds/capabilities.js";
|
|
8
9
|
import { refreshFeedNow } from "../polling/scheduler.js";
|
|
9
10
|
import { getChannel } from "../storage/channels.js";
|
|
10
11
|
import {
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
deleteFeed,
|
|
13
14
|
getFeedByUrl,
|
|
14
15
|
getFeedsForChannel,
|
|
16
|
+
updateFeed,
|
|
15
17
|
} from "../storage/feeds.js";
|
|
16
18
|
import { getUserId } from "../utils/auth.js";
|
|
17
19
|
import { notifyBlogroll } from "../utils/blogroll-notify.js";
|
|
@@ -79,6 +81,15 @@ export async function follow(request, response) {
|
|
|
79
81
|
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
|
80
82
|
});
|
|
81
83
|
|
|
84
|
+
// Detect source capabilities (fire-and-forget)
|
|
85
|
+
detectCapabilities(url).then((capabilities) => {
|
|
86
|
+
updateFeed(application, feed._id, { capabilities }).catch((error) => {
|
|
87
|
+
console.error(`[Microsub] Capability storage error:`, error.message);
|
|
88
|
+
});
|
|
89
|
+
}).catch((error) => {
|
|
90
|
+
console.error(`[Microsub] Capability detection error for ${url}:`, error.message);
|
|
91
|
+
});
|
|
92
|
+
|
|
82
93
|
// Notify blogroll plugin (fire-and-forget)
|
|
83
94
|
notifyBlogroll(application, "follow", {
|
|
84
95
|
url,
|
|
@@ -347,6 +347,20 @@ function ensureString(value) {
|
|
|
347
347
|
return String(value);
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Detect the protocol of a URL for auto-syndication targeting
|
|
352
|
+
* @param {string} url - URL to classify
|
|
353
|
+
* @returns {string} "atmosphere" | "fediverse" | "web"
|
|
354
|
+
*/
|
|
355
|
+
function detectProtocol(url) {
|
|
356
|
+
if (!url || typeof url !== "string") return "web";
|
|
357
|
+
const lower = url.toLowerCase();
|
|
358
|
+
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
|
|
359
|
+
if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
|
|
360
|
+
lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.")) return "fediverse";
|
|
361
|
+
return "web";
|
|
362
|
+
}
|
|
363
|
+
|
|
350
364
|
/**
|
|
351
365
|
* Fetch syndication targets from Micropub config
|
|
352
366
|
* @param {object} application - Indiekit application
|
|
@@ -406,6 +420,20 @@ export async function compose(request, response) {
|
|
|
406
420
|
? await getSyndicationTargets(application, token)
|
|
407
421
|
: [];
|
|
408
422
|
|
|
423
|
+
// Auto-select syndication target based on interaction URL protocol
|
|
424
|
+
const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost);
|
|
425
|
+
if (interactionUrl && syndicationTargets.length > 0) {
|
|
426
|
+
const protocol = detectProtocol(interactionUrl);
|
|
427
|
+
for (const target of syndicationTargets) {
|
|
428
|
+
const targetId = (target.uid || target.name || "").toLowerCase();
|
|
429
|
+
if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) {
|
|
430
|
+
target.checked = true;
|
|
431
|
+
} else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) {
|
|
432
|
+
target.checked = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
409
437
|
response.render("compose", {
|
|
410
438
|
title: request.__("microsub.compose.title"),
|
|
411
439
|
replyTo: ensureString(replyTo || reply),
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source capability detection
|
|
3
|
+
* Detects what a feed source supports (webmention, micropub, platform API)
|
|
4
|
+
* @module feeds/capabilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Known Fediverse domain patterns
|
|
9
|
+
*/
|
|
10
|
+
const FEDIVERSE_PATTERNS = [
|
|
11
|
+
"mastodon.",
|
|
12
|
+
"mstdn.",
|
|
13
|
+
"fosstodon.",
|
|
14
|
+
"pleroma.",
|
|
15
|
+
"misskey.",
|
|
16
|
+
"pixelfed.",
|
|
17
|
+
"fediverse",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect the capabilities of a feed source
|
|
22
|
+
* @param {string} feedUrl - The feed URL
|
|
23
|
+
* @param {string} [siteUrl] - Optional site homepage URL (if different from feed)
|
|
24
|
+
* @returns {Promise<object>} Capability profile
|
|
25
|
+
*/
|
|
26
|
+
export async function detectCapabilities(feedUrl, siteUrl) {
|
|
27
|
+
const result = {
|
|
28
|
+
source_type: "publication",
|
|
29
|
+
webmention: null,
|
|
30
|
+
micropub: null,
|
|
31
|
+
platform_api: null,
|
|
32
|
+
author_mode: "single",
|
|
33
|
+
interactions: [],
|
|
34
|
+
detected_at: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// 1. Pattern-match feed URL for known platforms
|
|
39
|
+
const platformMatch = matchPlatform(feedUrl);
|
|
40
|
+
if (platformMatch) {
|
|
41
|
+
result.source_type = platformMatch.type;
|
|
42
|
+
result.platform_api = platformMatch.api;
|
|
43
|
+
result.interactions = platformMatch.interactions;
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Fetch site homepage and check for rel links
|
|
48
|
+
const homepageUrl = siteUrl || deriveHomepage(feedUrl);
|
|
49
|
+
if (homepageUrl) {
|
|
50
|
+
const endpoints = await discoverEndpoints(homepageUrl);
|
|
51
|
+
result.webmention = endpoints.webmention;
|
|
52
|
+
result.micropub = endpoints.micropub;
|
|
53
|
+
|
|
54
|
+
if (endpoints.webmention && endpoints.micropub) {
|
|
55
|
+
result.source_type = "indieweb";
|
|
56
|
+
result.interactions = ["reply", "like", "repost", "bookmark"];
|
|
57
|
+
} else if (endpoints.webmention) {
|
|
58
|
+
result.source_type = "web";
|
|
59
|
+
result.interactions = ["reply"];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(
|
|
64
|
+
`[Microsub] Capability detection failed for ${feedUrl}:`,
|
|
65
|
+
error.message,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pattern-match a feed URL against known platforms
|
|
74
|
+
* @param {string} url - Feed URL
|
|
75
|
+
* @returns {object|null} Platform match or null
|
|
76
|
+
*/
|
|
77
|
+
function matchPlatform(url) {
|
|
78
|
+
const lower = url.toLowerCase();
|
|
79
|
+
|
|
80
|
+
// Bluesky
|
|
81
|
+
if (lower.includes("bsky.app") || lower.includes("bluesky")) {
|
|
82
|
+
return {
|
|
83
|
+
type: "bluesky",
|
|
84
|
+
api: { type: "atproto", authed: false },
|
|
85
|
+
interactions: ["reply", "like", "repost"],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Mastodon / Fediverse RSS (e.g., mastodon.social/@user.rss)
|
|
90
|
+
if (FEDIVERSE_PATTERNS.some((pattern) => lower.includes(pattern))) {
|
|
91
|
+
return {
|
|
92
|
+
type: "mastodon",
|
|
93
|
+
api: { type: "activitypub", authed: false },
|
|
94
|
+
interactions: ["reply", "like", "repost"],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// WordPress (common RSS patterns)
|
|
99
|
+
if (lower.includes("/wp-json/") || lower.includes("/feed/")) {
|
|
100
|
+
// Could be WordPress but also others — don't match too broadly
|
|
101
|
+
// Only match the /wp-json/ pattern which is WordPress-specific
|
|
102
|
+
if (lower.includes("/wp-json/")) {
|
|
103
|
+
return {
|
|
104
|
+
type: "wordpress",
|
|
105
|
+
api: { type: "wp-rest", authed: false },
|
|
106
|
+
interactions: ["reply"],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Derive a homepage URL from a feed URL
|
|
116
|
+
* @param {string} feedUrl - Feed URL
|
|
117
|
+
* @returns {string|null} Homepage URL
|
|
118
|
+
*/
|
|
119
|
+
function deriveHomepage(feedUrl) {
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(feedUrl);
|
|
122
|
+
return `${url.protocol}//${url.host}/`;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Discover webmention and micropub endpoints from a URL
|
|
130
|
+
* @param {string} url - URL to check for endpoint links
|
|
131
|
+
* @returns {Promise<object>} Discovered endpoints
|
|
132
|
+
*/
|
|
133
|
+
async function discoverEndpoints(url) {
|
|
134
|
+
const endpoints = {
|
|
135
|
+
webmention: null,
|
|
136
|
+
micropub: null,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(url, {
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
headers: {
|
|
146
|
+
Accept: "text/html",
|
|
147
|
+
"User-Agent": "Microsub/1.0 (+https://indieweb.org/Microsub)",
|
|
148
|
+
},
|
|
149
|
+
redirect: "follow",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!response.ok) return endpoints;
|
|
153
|
+
|
|
154
|
+
// Check Link headers first
|
|
155
|
+
const linkHeader = response.headers.get("link");
|
|
156
|
+
if (linkHeader) {
|
|
157
|
+
const wmMatch = linkHeader.match(
|
|
158
|
+
/<([^>]+)>;\s*rel="?webmention"?/i,
|
|
159
|
+
);
|
|
160
|
+
if (wmMatch) endpoints.webmention = wmMatch[1];
|
|
161
|
+
|
|
162
|
+
const mpMatch = linkHeader.match(
|
|
163
|
+
/<([^>]+)>;\s*rel="?micropub"?/i,
|
|
164
|
+
);
|
|
165
|
+
if (mpMatch) endpoints.micropub = mpMatch[1];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// If not found in headers, check HTML
|
|
169
|
+
if (!endpoints.webmention || !endpoints.micropub) {
|
|
170
|
+
const html = await response.text();
|
|
171
|
+
|
|
172
|
+
if (!endpoints.webmention) {
|
|
173
|
+
const wmHtml = html.match(
|
|
174
|
+
/<link[^>]+rel="?webmention"?[^>]+href="([^"]+)"/i,
|
|
175
|
+
) ||
|
|
176
|
+
html.match(
|
|
177
|
+
/<link[^>]+href="([^"]+)"[^>]+rel="?webmention"?/i,
|
|
178
|
+
);
|
|
179
|
+
if (wmHtml) endpoints.webmention = wmHtml[1];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!endpoints.micropub) {
|
|
183
|
+
const mpHtml = html.match(
|
|
184
|
+
/<link[^>]+rel="?micropub"?[^>]+href="([^"]+)"/i,
|
|
185
|
+
) ||
|
|
186
|
+
html.match(
|
|
187
|
+
/<link[^>]+href="([^"]+)"[^>]+rel="?micropub"?/i,
|
|
188
|
+
);
|
|
189
|
+
if (mpHtml) endpoints.micropub = mpHtml[1];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.name !== "AbortError") {
|
|
194
|
+
console.debug(
|
|
195
|
+
`[Microsub] Endpoint discovery failed for ${url}:`,
|
|
196
|
+
error.message,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timeout);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return endpoints;
|
|
204
|
+
}
|
package/lib/feeds/normalizer.js
CHANGED
|
@@ -150,7 +150,9 @@ export function normalizeItem(item, feedUrl, feedType) {
|
|
|
150
150
|
type: "entry",
|
|
151
151
|
uid,
|
|
152
152
|
url,
|
|
153
|
-
name: item.title
|
|
153
|
+
name: item.title
|
|
154
|
+
? sanitizeHtml(item.title, { allowedTags: [] }).trim()
|
|
155
|
+
: undefined,
|
|
154
156
|
published: toISOStringSafe(item.pubdate),
|
|
155
157
|
updated: toISOStringSafe(item.date),
|
|
156
158
|
_source: {
|
|
@@ -241,7 +243,9 @@ export function normalizeItem(item, feedUrl, feedType) {
|
|
|
241
243
|
*/
|
|
242
244
|
export function normalizeFeedMeta(meta, feedUrl) {
|
|
243
245
|
const normalized = {
|
|
244
|
-
name: meta.title
|
|
246
|
+
name: meta.title
|
|
247
|
+
? sanitizeHtml(meta.title, { allowedTags: [] }).trim()
|
|
248
|
+
: feedUrl,
|
|
245
249
|
};
|
|
246
250
|
|
|
247
251
|
if (meta.description) {
|
|
@@ -303,7 +307,9 @@ export function normalizeJsonFeedItem(item, feedUrl) {
|
|
|
303
307
|
type: "entry",
|
|
304
308
|
uid,
|
|
305
309
|
url,
|
|
306
|
-
name: item.title
|
|
310
|
+
name: item.title
|
|
311
|
+
? sanitizeHtml(item.title, { allowedTags: [] }).trim()
|
|
312
|
+
: undefined,
|
|
307
313
|
published: item.date_published
|
|
308
314
|
? new Date(item.date_published).toISOString()
|
|
309
315
|
: undefined,
|
|
@@ -400,7 +406,9 @@ export function normalizeJsonFeedItem(item, feedUrl) {
|
|
|
400
406
|
*/
|
|
401
407
|
export function normalizeJsonFeedMeta(feed, feedUrl) {
|
|
402
408
|
const normalized = {
|
|
403
|
-
name: feed.title
|
|
409
|
+
name: feed.title
|
|
410
|
+
? sanitizeHtml(feed.title, { allowedTags: [] }).trim()
|
|
411
|
+
: feedUrl,
|
|
404
412
|
};
|
|
405
413
|
|
|
406
414
|
if (feed.description) {
|
package/lib/polling/processor.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
|
7
|
+
import { detectCapabilities } from "../feeds/capabilities.js";
|
|
7
8
|
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
|
8
9
|
import { getChannel } from "../storage/channels.js";
|
|
9
10
|
import {
|
|
11
|
+
updateFeed,
|
|
10
12
|
updateFeedAfterFetch,
|
|
11
13
|
updateFeedStatus,
|
|
12
14
|
updateFeedWebsub,
|
|
@@ -82,6 +84,15 @@ export async function processFeed(application, feed) {
|
|
|
82
84
|
item._source.name = feed.title || parsed.name;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// Attach source_type from feed capabilities (for protocol indicators)
|
|
88
|
+
// Falls back to URL-based inference when capabilities haven't been detected yet
|
|
89
|
+
item._source = item._source || {};
|
|
90
|
+
if (feed.capabilities?.source_type) {
|
|
91
|
+
item._source.source_type = feed.capabilities.source_type;
|
|
92
|
+
} else {
|
|
93
|
+
item._source.source_type = inferSourceType(feed.url);
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
// Store the item
|
|
86
97
|
const stored = await addItem(application, {
|
|
87
98
|
channelId: feed.channelId,
|
|
@@ -177,6 +188,20 @@ export async function processFeed(application, feed) {
|
|
|
177
188
|
success: true,
|
|
178
189
|
itemCount: parsed.items?.length || 0,
|
|
179
190
|
});
|
|
191
|
+
|
|
192
|
+
// Detect source capabilities on first successful fetch (if not yet detected)
|
|
193
|
+
if (!feed.capabilities) {
|
|
194
|
+
detectCapabilities(feed.url)
|
|
195
|
+
.then((capabilities) => {
|
|
196
|
+
updateFeed(application, feed._id, { capabilities }).catch(() => {});
|
|
197
|
+
})
|
|
198
|
+
.catch((error) => {
|
|
199
|
+
console.debug(
|
|
200
|
+
`[Microsub] Capability detection skipped for ${feed.url}:`,
|
|
201
|
+
error.message,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
180
205
|
} catch (error) {
|
|
181
206
|
result.error = error.message;
|
|
182
207
|
|
|
@@ -208,6 +233,21 @@ export async function processFeed(application, feed) {
|
|
|
208
233
|
return result;
|
|
209
234
|
}
|
|
210
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Infer source type from feed URL when capabilities haven't been detected yet
|
|
238
|
+
* @param {string} url - Feed URL
|
|
239
|
+
* @returns {string} Source type
|
|
240
|
+
*/
|
|
241
|
+
function inferSourceType(url) {
|
|
242
|
+
if (!url) return "web";
|
|
243
|
+
const lower = url.toLowerCase();
|
|
244
|
+
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "bluesky";
|
|
245
|
+
if (lower.includes("mastodon.") || lower.includes("mstdn.") ||
|
|
246
|
+
lower.includes("fosstodon.") || lower.includes("pleroma.") ||
|
|
247
|
+
lower.includes("misskey.") || lower.includes("pixelfed.")) return "mastodon";
|
|
248
|
+
return "web";
|
|
249
|
+
}
|
|
250
|
+
|
|
211
251
|
/**
|
|
212
252
|
* Check if an item passes channel filters
|
|
213
253
|
* @param {object} item - Feed item
|
package/package.json
CHANGED
|
@@ -63,13 +63,26 @@
|
|
|
63
63
|
{% endif %}
|
|
64
64
|
<div class="item-card__author-info">
|
|
65
65
|
<span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
|
|
66
|
-
{% if item._source
|
|
66
|
+
{% if item._source %}
|
|
67
67
|
<span class="item-card__source">
|
|
68
|
-
|
|
69
|
-
{
|
|
68
|
+
{# Protocol source indicator #}
|
|
69
|
+
{% set sourceUrl = item._source.url or item.author.url or "" %}
|
|
70
|
+
{% set sourceType = item._source.source_type or item._source.type %}
|
|
71
|
+
{% if sourceType == "activitypub" or sourceType == "mastodon" or ("mastodon." in sourceUrl) or ("mstdn." in sourceUrl) or ("fosstodon." in sourceUrl) or ("pleroma." in sourceUrl) or ("misskey." in sourceUrl) %}
|
|
72
|
+
<svg class="item-card__source-icon" viewBox="0 0 24 24" fill="#6364ff" aria-label="Fediverse" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
|
73
|
+
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
|
|
74
|
+
</svg>
|
|
75
|
+
{% elif sourceType == "bluesky" or ("bsky.app" in sourceUrl) or ("bluesky" in sourceUrl) %}
|
|
76
|
+
<svg class="item-card__source-icon" viewBox="0 0 568 501" fill="#0085ff" aria-label="ATmosphere" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
|
77
|
+
<path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/>
|
|
78
|
+
</svg>
|
|
79
|
+
{% else %}
|
|
80
|
+
<svg class="item-card__source-icon" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2" aria-label="Web" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
|
81
|
+
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
|
82
|
+
</svg>
|
|
83
|
+
{% endif %}
|
|
84
|
+
{{ item._source.name or item._source.url }}
|
|
70
85
|
</span>
|
|
71
|
-
{% elif item._source %}
|
|
72
|
-
<span class="item-card__source">{{ item._source.name or item._source.url }}</span>
|
|
73
86
|
{% elif item.author.url %}
|
|
74
87
|
<span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
|
75
88
|
{% endif %}
|