@rmdes/indiekit-endpoint-microsub 1.0.0 → 1.0.2

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/index.js CHANGED
@@ -83,10 +83,7 @@ export default class MicrosubEndpoint {
83
83
  "/channels/:uid/settings",
84
84
  readerController.updateSettings,
85
85
  );
86
- readerRouter.post(
87
- "/channels/:uid/delete",
88
- readerController.deleteChannel,
89
- );
86
+ readerRouter.post("/channels/:uid/delete", readerController.deleteChannel);
90
87
  readerRouter.get("/channels/:uid/feeds", readerController.feeds);
91
88
  readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
92
89
  readerRouter.post(
@@ -13,8 +13,8 @@ import {
13
13
  getFeedByUrl,
14
14
  getFeedsForChannel,
15
15
  } from "../storage/feeds.js";
16
- import { createFeedResponse } from "../utils/jf2.js";
17
16
  import { getUserId } from "../utils/auth.js";
17
+ import { createFeedResponse } from "../utils/jf2.js";
18
18
  import { validateChannel, validateUrl } from "../utils/validation.js";
19
19
  import {
20
20
  unsubscribe as websubUnsubscribe,
@@ -392,10 +392,16 @@ export async function submitCompose(request, response) {
392
392
  body: micropubData.toString(),
393
393
  });
394
394
 
395
- if (micropubResponse.ok || micropubResponse.status === 201 || micropubResponse.status === 202) {
395
+ if (
396
+ micropubResponse.ok ||
397
+ micropubResponse.status === 201 ||
398
+ micropubResponse.status === 202
399
+ ) {
396
400
  // Success - get the Location header for the new post URL
397
401
  const location = micropubResponse.headers.get("Location");
398
- console.info(`[Microsub] Created post via Micropub: ${location || "success"}`);
402
+ console.info(
403
+ `[Microsub] Created post via Micropub: ${location || "success"}`,
404
+ );
399
405
 
400
406
  // Redirect back to reader with success message
401
407
  return response.redirect(`${request.baseUrl}/channels`);
@@ -403,7 +409,9 @@ export async function submitCompose(request, response) {
403
409
 
404
410
  // Handle error
405
411
  const errorBody = await micropubResponse.text();
406
- console.error(`[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`);
412
+ console.error(
413
+ `[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`,
414
+ );
407
415
 
408
416
  return response.status(micropubResponse.status).render("error", {
409
417
  title: "Error",
@@ -150,7 +150,7 @@ function extractSelfFromLinkHeader(linkHeader) {
150
150
  * @returns {Promise<object>} Parsed feed
151
151
  */
152
152
  export async function fetchAndParseFeed(url, options = {}) {
153
- const { parseFeed } = await import("./parser.js");
153
+ const { parseFeed, detectFeedType } = await import("./parser.js");
154
154
 
155
155
  const result = await fetchFeed(url, options);
156
156
 
@@ -161,6 +161,32 @@ export async function fetchAndParseFeed(url, options = {}) {
161
161
  };
162
162
  }
163
163
 
164
+ // Check if we got a parseable feed
165
+ const feedType = detectFeedType(result.content, result.contentType);
166
+
167
+ // If we got ActivityPub or unknown, try common feed paths
168
+ if (feedType === "activitypub" || feedType === "unknown") {
169
+ const fallbackFeed = await tryCommonFeedPaths(url, options);
170
+ if (fallbackFeed) {
171
+ // Fetch and parse the discovered feed
172
+ const feedResult = await fetchFeed(fallbackFeed.url, options);
173
+ if (!feedResult.notModified) {
174
+ const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
175
+ contentType: feedResult.contentType,
176
+ });
177
+ return {
178
+ ...feedResult,
179
+ ...parsed,
180
+ hub: feedResult.hub || parsed._hub,
181
+ discoveredFrom: url,
182
+ };
183
+ }
184
+ }
185
+ throw new Error(
186
+ `Unable to find a feed at ${url}. Try the direct feed URL.`,
187
+ );
188
+ }
189
+
164
190
  const parsed = await parseFeed(result.content, url, {
165
191
  contentType: result.contentType,
166
192
  });
@@ -172,6 +198,48 @@ export async function fetchAndParseFeed(url, options = {}) {
172
198
  };
173
199
  }
174
200
 
201
+ /**
202
+ * Common feed paths to try when discovery fails
203
+ */
204
+ const COMMON_FEED_PATHS = ["/feed/", "/feed", "/rss", "/rss.xml", "/atom.xml"];
205
+
206
+ /**
207
+ * Try to fetch a feed from common paths
208
+ * @param {string} baseUrl - Base URL of the site
209
+ * @param {object} options - Fetch options
210
+ * @returns {Promise<object|undefined>} Feed result or undefined
211
+ */
212
+ async function tryCommonFeedPaths(baseUrl, options = {}) {
213
+ const base = new URL(baseUrl);
214
+
215
+ for (const feedPath of COMMON_FEED_PATHS) {
216
+ const feedUrl = new URL(feedPath, base).href;
217
+ try {
218
+ const result = await fetchFeed(feedUrl, { ...options, timeout: 10_000 });
219
+ const contentType = result.contentType?.toLowerCase() || "";
220
+
221
+ // Check if we got a feed
222
+ if (
223
+ contentType.includes("xml") ||
224
+ contentType.includes("rss") ||
225
+ contentType.includes("atom") ||
226
+ (contentType.includes("json") &&
227
+ result.content?.includes("jsonfeed.org"))
228
+ ) {
229
+ return {
230
+ url: feedUrl,
231
+ type: contentType.includes("json") ? "jsonfeed" : "xml",
232
+ rel: "alternate",
233
+ };
234
+ }
235
+ } catch {
236
+ // Try next path
237
+ }
238
+ }
239
+
240
+ return;
241
+ }
242
+
175
243
  /**
176
244
  * Discover feeds from a URL
177
245
  * @param {string} url - Page URL
@@ -187,19 +255,62 @@ export async function discoverFeedsFromUrl(url, options = {}) {
187
255
  if (
188
256
  contentType.includes("xml") ||
189
257
  contentType.includes("rss") ||
190
- contentType.includes("atom") ||
191
- contentType.includes("json")
258
+ contentType.includes("atom")
192
259
  ) {
193
260
  return [
194
261
  {
195
262
  url,
196
- type: contentType.includes("json") ? "jsonfeed" : "xml",
263
+ type: "xml",
197
264
  rel: "self",
198
265
  },
199
266
  ];
200
267
  }
201
268
 
202
- // Otherwise, discover feeds from HTML
203
- const feeds = await discoverFeeds(result.content, url);
204
- return feeds;
269
+ // Check for JSON Feed specifically
270
+ if (
271
+ contentType.includes("json") &&
272
+ result.content?.includes("jsonfeed.org")
273
+ ) {
274
+ return [
275
+ {
276
+ url,
277
+ type: "jsonfeed",
278
+ rel: "self",
279
+ },
280
+ ];
281
+ }
282
+
283
+ // Check if we got ActivityPub JSON or other non-feed JSON
284
+ // This happens with WordPress sites using ActivityPub plugin
285
+ if (
286
+ contentType.includes("json") ||
287
+ (result.content?.trim().startsWith("{") &&
288
+ result.content?.includes("@context"))
289
+ ) {
290
+ // Try common feed paths as fallback
291
+ const fallbackFeed = await tryCommonFeedPaths(url, options);
292
+ if (fallbackFeed) {
293
+ return [fallbackFeed];
294
+ }
295
+ }
296
+
297
+ // If content looks like HTML, discover feeds from it
298
+ if (
299
+ contentType.includes("html") ||
300
+ result.content?.includes("<!DOCTYPE html") ||
301
+ result.content?.includes("<html")
302
+ ) {
303
+ const feeds = await discoverFeeds(result.content, url);
304
+ if (feeds.length > 0) {
305
+ return feeds;
306
+ }
307
+ }
308
+
309
+ // Last resort: try common feed paths
310
+ const fallbackFeed = await tryCommonFeedPaths(url, options);
311
+ if (fallbackFeed) {
312
+ return [fallbackFeed];
313
+ }
314
+
315
+ return [];
205
316
  }
@@ -7,6 +7,66 @@ import crypto from "node:crypto";
7
7
 
8
8
  import sanitizeHtml from "sanitize-html";
9
9
 
10
+ /**
11
+ * Parse a date string with fallback for non-standard formats
12
+ * @param {string|Date} dateInput - Date string or Date object
13
+ * @returns {Date|undefined} Parsed Date or undefined if invalid
14
+ */
15
+ function parseDate(dateInput) {
16
+ if (!dateInput) {
17
+ return;
18
+ }
19
+
20
+ // Already a valid Date
21
+ if (dateInput instanceof Date && !Number.isNaN(dateInput.getTime())) {
22
+ return dateInput;
23
+ }
24
+
25
+ const dateString = String(dateInput).trim();
26
+
27
+ // Try standard parsing first
28
+ let date = new Date(dateString);
29
+ if (!Number.isNaN(date.getTime())) {
30
+ return date;
31
+ }
32
+
33
+ // Handle "YYYY-MM-DD HH:MM" format (missing seconds and timezone)
34
+ // e.g., "2026-01-28 08:40"
35
+ const shortDateTime = dateString.match(
36
+ /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})$/,
37
+ );
38
+ if (shortDateTime) {
39
+ date = new Date(`${shortDateTime[1]}T${shortDateTime[2]}:00Z`);
40
+ if (!Number.isNaN(date.getTime())) {
41
+ return date;
42
+ }
43
+ }
44
+
45
+ // Handle "YYYY-MM-DD HH:MM:SS" without timezone
46
+ const dateTimeNoTz = dateString.match(
47
+ /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})$/,
48
+ );
49
+ if (dateTimeNoTz) {
50
+ date = new Date(`${dateTimeNoTz[1]}T${dateTimeNoTz[2]}Z`);
51
+ if (!Number.isNaN(date.getTime())) {
52
+ return date;
53
+ }
54
+ }
55
+
56
+ // If all else fails, return undefined
57
+ return;
58
+ }
59
+
60
+ /**
61
+ * Safely convert date to ISO string
62
+ * @param {string|Date} dateInput - Date input
63
+ * @returns {string|undefined} ISO string or undefined
64
+ */
65
+ function toISOStringSafe(dateInput) {
66
+ const date = parseDate(dateInput);
67
+ return date ? date.toISOString() : undefined;
68
+ }
69
+
10
70
  /**
11
71
  * Sanitize HTML options
12
72
  */
@@ -91,9 +151,10 @@ export function normalizeItem(item, feedUrl, feedType) {
91
151
  uid,
92
152
  url,
93
153
  name: item.title || undefined,
94
- published: item.pubdate ? new Date(item.pubdate).toISOString() : undefined,
95
- updated: item.date ? new Date(item.date).toISOString() : undefined,
154
+ published: toISOStringSafe(item.pubdate),
155
+ updated: toISOStringSafe(item.date),
96
156
  _source: {
157
+ url: feedUrl,
97
158
  feedUrl,
98
159
  feedType,
99
160
  originalId: item.guid,
@@ -250,6 +311,7 @@ export function normalizeJsonFeedItem(item, feedUrl) {
250
311
  ? new Date(item.date_modified).toISOString()
251
312
  : undefined,
252
313
  _source: {
314
+ url: feedUrl,
253
315
  feedUrl,
254
316
  feedType: "jsonfeed",
255
317
  originalId: item.id,
@@ -391,6 +453,7 @@ export function normalizeHfeedItem(entry, feedUrl) {
391
453
  uid,
392
454
  url,
393
455
  _source: {
456
+ url: feedUrl,
394
457
  feedUrl,
395
458
  feedType: "hfeed",
396
459
  originalId: getFirst(properties.uid),
@@ -536,6 +599,24 @@ export function normalizeHfeedMeta(hfeed, feedUrl) {
536
599
  return normalized;
537
600
  }
538
601
 
602
+ /**
603
+ * Extract URL string from a photo value
604
+ * @param {object|string} photo - Photo value (can be string URL or object with value/url)
605
+ * @returns {string|undefined} Photo URL string
606
+ */
607
+ function extractPhotoUrl(photo) {
608
+ if (!photo) {
609
+ return;
610
+ }
611
+ if (typeof photo === "string") {
612
+ return photo;
613
+ }
614
+ if (typeof photo === "object") {
615
+ return photo.value || photo.url || photo.src;
616
+ }
617
+ return;
618
+ }
619
+
539
620
  /**
540
621
  * Normalize h-card author
541
622
  * @param {object|string} hcard - h-card or author name string
@@ -556,7 +637,7 @@ function normalizeHcard(hcard) {
556
637
  type: "card",
557
638
  name: getFirst(properties.name),
558
639
  url: getFirst(properties.url),
559
- photo: getFirst(properties.photo),
640
+ photo: extractPhotoUrl(getFirst(properties.photo)),
560
641
  };
561
642
  }
562
643
 
@@ -51,13 +51,18 @@ export function detectFeedType(content, contentType = "") {
51
51
  // Fall back to content inspection
52
52
  const trimmed = content.trim();
53
53
 
54
- // JSON Feed
54
+ // JSON content
55
55
  if (trimmed.startsWith("{")) {
56
56
  try {
57
57
  const json = JSON.parse(trimmed);
58
+ // JSON Feed
58
59
  if (json.version && json.version.includes("jsonfeed.org")) {
59
60
  return "jsonfeed";
60
61
  }
62
+ // ActivityPub - return special type to indicate we need feed discovery
63
+ if (json["@context"] || json.type === "Group" || json.inbox) {
64
+ return "activitypub";
65
+ }
61
66
  } catch {
62
67
  // Not JSON
63
68
  }
@@ -112,6 +117,12 @@ export async function parseFeed(content, feedUrl, options = {}) {
112
117
  return parseHfeed(content, feedUrl);
113
118
  }
114
119
 
120
+ case "activitypub": {
121
+ throw new Error(
122
+ `URL returns ActivityPub JSON instead of a feed. Try the direct feed URL (e.g., ${feedUrl}feed/)`,
123
+ );
124
+ }
125
+
115
126
  default: {
116
127
  throw new Error(`Unable to detect feed type for ${feedUrl}`);
117
128
  }
@@ -9,13 +9,13 @@ import { getCache, setCache } from "../cache/redis.js";
9
9
 
10
10
  const MAX_SIZE = 2 * 1024 * 1024; // 2MB max image size
11
11
  const CACHE_TTL = 4 * 60 * 60; // 4 hours
12
- const ALLOWED_TYPES = [
12
+ const ALLOWED_TYPES = new Set([
13
13
  "image/jpeg",
14
14
  "image/png",
15
15
  "image/gif",
16
16
  "image/webp",
17
17
  "image/svg+xml",
18
- ];
18
+ ]);
19
19
 
20
20
  /**
21
21
  * Generate a hash for a URL to use as cache key
@@ -120,15 +120,19 @@ export async function fetchImage(redis, url) {
120
120
  });
121
121
 
122
122
  if (!response.ok) {
123
- console.error(`[Microsub] Media proxy fetch failed: ${response.status} for ${url}`);
124
- return null;
123
+ console.error(
124
+ `[Microsub] Media proxy fetch failed: ${response.status} for ${url}`,
125
+ );
126
+ return;
125
127
  }
126
128
 
127
129
  // Check content type
128
130
  const contentType = response.headers.get("content-type")?.split(";")[0];
129
- if (!ALLOWED_TYPES.includes(contentType)) {
130
- console.error(`[Microsub] Media proxy rejected type: ${contentType} for ${url}`);
131
- return null;
131
+ if (!ALLOWED_TYPES.has(contentType)) {
132
+ console.error(
133
+ `[Microsub] Media proxy rejected type: ${contentType} for ${url}`,
134
+ );
135
+ return;
132
136
  }
133
137
 
134
138
  // Check content length
@@ -137,14 +141,16 @@ export async function fetchImage(redis, url) {
137
141
  10,
138
142
  );
139
143
  if (contentLength > MAX_SIZE) {
140
- console.error(`[Microsub] Media proxy rejected size: ${contentLength} for ${url}`);
141
- return null;
144
+ console.error(
145
+ `[Microsub] Media proxy rejected size: ${contentLength} for ${url}`,
146
+ );
147
+ return;
142
148
  }
143
149
 
144
150
  // Read the body
145
151
  const buffer = await response.arrayBuffer();
146
152
  if (buffer.byteLength > MAX_SIZE) {
147
- return null;
153
+ return;
148
154
  }
149
155
 
150
156
  const imageData = {
@@ -161,7 +167,7 @@ export async function fetchImage(redis, url) {
161
167
  return imageData;
162
168
  } catch (error) {
163
169
  console.error(`[Microsub] Media proxy error: ${error.message} for ${url}`);
164
- return null;
170
+ return;
165
171
  }
166
172
  }
167
173
 
@@ -73,6 +73,11 @@ export async function processFeed(application, feed) {
73
73
  continue;
74
74
  }
75
75
 
76
+ // Enrich item source with feed metadata
77
+ if (item._source) {
78
+ item._source.name = feed.title || parsed.name;
79
+ }
80
+
76
81
  // Store the item
77
82
  const stored = await addItem(application, {
78
83
  channelId: feed.channelId,
@@ -5,9 +5,10 @@
5
5
 
6
6
  import { ObjectId } from "mongodb";
7
7
 
8
+ import { generateChannelUid } from "../utils/jf2.js";
9
+
8
10
  import { deleteFeedsForChannel } from "./feeds.js";
9
11
  import { deleteItemsForChannel } from "./items.js";
10
- import { generateChannelUid } from "../utils/jf2.js";
11
12
 
12
13
  /**
13
14
  * Get channels collection from application
@@ -121,6 +121,50 @@ export async function getTimelineItems(application, channelId, options = {}) {
121
121
  };
122
122
  }
123
123
 
124
+ /**
125
+ * Extract URL string from a media value
126
+ * @param {object|string} media - Media value (can be string URL or object)
127
+ * @returns {string|undefined} URL string
128
+ */
129
+ function extractMediaUrl(media) {
130
+ if (!media) {
131
+ return;
132
+ }
133
+ if (typeof media === "string") {
134
+ return media;
135
+ }
136
+ if (typeof media === "object") {
137
+ return media.value || media.url || media.src;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Normalize media array to URL strings
143
+ * @param {Array} mediaArray - Array of media items
144
+ * @returns {Array} Array of URL strings
145
+ */
146
+ function normalizeMediaArray(mediaArray) {
147
+ if (!mediaArray || !Array.isArray(mediaArray)) {
148
+ return [];
149
+ }
150
+ return mediaArray.map((media) => extractMediaUrl(media)).filter(Boolean);
151
+ }
152
+
153
+ /**
154
+ * Normalize author object to ensure photo is a URL string
155
+ * @param {object} author - Author object
156
+ * @returns {object} Normalized author
157
+ */
158
+ function normalizeAuthor(author) {
159
+ if (!author) {
160
+ return;
161
+ }
162
+ return {
163
+ ...author,
164
+ photo: extractMediaUrl(author.photo),
165
+ };
166
+ }
167
+
124
168
  /**
125
169
  * Transform database item to jf2 format
126
170
  * @param {object} item - Database item
@@ -142,11 +186,17 @@ function transformToJf2(item, userId) {
142
186
  if (item.content) jf2.content = item.content;
143
187
  if (item.summary) jf2.summary = item.summary;
144
188
  if (item.updated) jf2.updated = item.updated.toISOString();
145
- if (item.author) jf2.author = item.author;
189
+ if (item.author) jf2.author = normalizeAuthor(item.author);
146
190
  if (item.category?.length > 0) jf2.category = item.category;
147
- if (item.photo?.length > 0) jf2.photo = item.photo;
148
- if (item.video?.length > 0) jf2.video = item.video;
149
- if (item.audio?.length > 0) jf2.audio = item.audio;
191
+
192
+ // Normalize media arrays to ensure they contain URL strings
193
+ const photos = normalizeMediaArray(item.photo);
194
+ const videos = normalizeMediaArray(item.video);
195
+ const audios = normalizeMediaArray(item.audio);
196
+
197
+ if (photos.length > 0) jf2.photo = photos;
198
+ if (videos.length > 0) jf2.video = videos;
199
+ if (audios.length > 0) jf2.audio = audios;
150
200
 
151
201
  // Interaction types
152
202
  if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf;
@@ -161,18 +211,33 @@ function transformToJf2(item, userId) {
161
211
  }
162
212
 
163
213
  /**
164
- * Get an item by ID
214
+ * Get an item by ID (MongoDB _id or uid)
165
215
  * @param {object} application - Indiekit application
166
- * @param {ObjectId|string} id - Item ObjectId
216
+ * @param {ObjectId|string} id - Item ObjectId or uid string
167
217
  * @param {string} [userId] - User ID for read state
168
- * @returns {Promise<object|null>} jf2 item or null
218
+ * @returns {Promise<object|undefined>} jf2 item or undefined
169
219
  */
170
220
  export async function getItemById(application, id, userId) {
171
221
  const collection = getCollection(application);
172
- const objectId = typeof id === "string" ? new ObjectId(id) : id;
173
222
 
174
- const item = await collection.findOne({ _id: objectId });
175
- if (!item) return;
223
+ let item;
224
+
225
+ // Try MongoDB ObjectId first
226
+ try {
227
+ const objectId = typeof id === "string" ? new ObjectId(id) : id;
228
+ item = await collection.findOne({ _id: objectId });
229
+ } catch {
230
+ // Invalid ObjectId format, will try uid lookup
231
+ }
232
+
233
+ // If not found by _id, try uid
234
+ if (!item) {
235
+ item = await collection.findOne({ uid: id });
236
+ }
237
+
238
+ if (!item) {
239
+ return;
240
+ }
176
241
 
177
242
  return transformToJf2(item, userId);
178
243
  }
@@ -229,7 +294,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
229
294
  try {
230
295
  return new ObjectId(id);
231
296
  } catch {
232
- return undefined;
297
+ return;
233
298
  }
234
299
  })
235
300
  .filter(Boolean);
@@ -277,7 +342,7 @@ export async function markItemsUnread(
277
342
  try {
278
343
  return new ObjectId(id);
279
344
  } catch {
280
- return undefined;
345
+ return;
281
346
  }
282
347
  })
283
348
  .filter(Boolean);
@@ -316,7 +381,7 @@ export async function removeItems(application, channelId, entryIds) {
316
381
  try {
317
382
  return new ObjectId(id);
318
383
  } catch {
319
- return undefined;
384
+ return;
320
385
  }
321
386
  })
322
387
  .filter(Boolean);
package/lib/utils/auth.js CHANGED
@@ -10,7 +10,6 @@
10
10
  * 1. request.session.userId (if explicitly set)
11
11
  * 2. request.session.me (from token introspection)
12
12
  * 3. application.publication.me (single-user fallback)
13
- *
14
13
  * @param {object} request - Express request
15
14
  * @returns {string|undefined} User ID
16
15
  */
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { getUserId } from "../utils/auth.js";
7
+
7
8
  import { processWebmention } from "./processor.js";
8
9
 
9
10
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
package/views/404.njk ADDED
@@ -0,0 +1,17 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block main %}
4
+ <article class="main__container -!-container">
5
+ <header class="heading">
6
+ <h1 class="heading__title">
7
+ {{ __("microsub.error.notFound.title") | default("Not found") }}
8
+ </h1>
9
+ </header>
10
+ {{ prose({ text: __("microsub.error.notFound.description") | default("The item you're looking for could not be found.") }) }}
11
+ <p>
12
+ <a href="{{ baseUrl }}/channels" class="button button--secondary">
13
+ {{ __("microsub.reader.backToChannels") | default("Back to channels") }}
14
+ </a>
15
+ </p>
16
+ </article>
17
+ {% endblock %}
package/views/item.njk CHANGED
@@ -50,19 +50,19 @@
50
50
  </div>
51
51
  {% endif %}
52
52
 
53
- {% if item.inReplyTo or item.likeOf or item.repostOf or item.bookmarkOf %}
53
+ {% if item["in-reply-to"] or item["like-of"] or item["repost-of"] or item["bookmark-of"] %}
54
54
  <div class="item__context">
55
- {% if item.inReplyTo %}
56
- <p>{{ icon("reply") }} {{ __("Reply to") }}: <a href="{{ item.inReplyTo[0] }}">{{ item.inReplyTo[0] }}</a></p>
55
+ {% if item["in-reply-to"] %}
56
+ <p>{{ icon("reply") }} {{ __("Reply to") }}: <a href="{{ item["in-reply-to"][0] }}">{{ item["in-reply-to"][0] }}</a></p>
57
57
  {% endif %}
58
- {% if item.likeOf %}
59
- <p>{{ icon("like") }} {{ __("Liked") }}: <a href="{{ item.likeOf[0] }}">{{ item.likeOf[0] }}</a></p>
58
+ {% if item["like-of"] %}
59
+ <p>{{ icon("like") }} {{ __("Liked") }}: <a href="{{ item["like-of"][0] }}">{{ item["like-of"][0] }}</a></p>
60
60
  {% endif %}
61
- {% if item.repostOf %}
62
- <p>{{ icon("repost") }} {{ __("Reposted") }}: <a href="{{ item.repostOf[0] }}">{{ item.repostOf[0] }}</a></p>
61
+ {% if item["repost-of"] %}
62
+ <p>{{ icon("repost") }} {{ __("Reposted") }}: <a href="{{ item["repost-of"][0] }}">{{ item["repost-of"][0] }}</a></p>
63
63
  {% endif %}
64
- {% if item.bookmarkOf %}
65
- <p>{{ icon("bookmark") }} {{ __("Bookmarked") }}: <a href="{{ item.bookmarkOf[0] }}">{{ item.bookmarkOf[0] }}</a></p>
64
+ {% if item["bookmark-of"] %}
65
+ <p>{{ icon("bookmark") }} {{ __("Bookmarked") }}: <a href="{{ item["bookmark-of"][0] }}">{{ item["bookmark-of"][0] }}</a></p>
66
66
  {% endif %}
67
67
  </div>
68
68
  {% endif %}
@@ -1,6 +1,6 @@
1
1
  {# Item card for timeline display #}
2
2
  <article class="item-card{% if item._is_read %} item-card--read{% endif %}">
3
- <a href="{{ baseUrl }}/item/{{ item.uid }}" class="item-card__link">
3
+ <a href="{{ baseUrl }}/item/{{ item._id }}" class="item-card__link">
4
4
  {% if item.author %}
5
5
  <div class="item-card__author">
6
6
  {% if item.author.photo %}