@rmdes/indiekit-endpoint-posts 1.0.0-beta.27 → 1.0.0-beta.29

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
@@ -1,24 +1,38 @@
1
1
  # @rmdes/indiekit-endpoint-posts
2
2
 
3
- Post management endpoint for Indiekit. View posts published by your Micropub endpoint and publish new posts to it.
3
+ Post management endpoint for Indiekit. View, create, edit, and delete posts published through your Micropub endpoint.
4
4
 
5
5
  ## Fork Notice
6
6
 
7
- This is a fork of `@indiekit/endpoint-posts` with a critical bug fix for the syndication form.
7
+ This is a **fork** of `@indiekit/endpoint-posts` with three critical bug fixes:
8
8
 
9
- ### Bug Fixed
9
+ ### 1. Syndication Form Bug Fix (CRITICAL)
10
10
 
11
- The syndicate form button was using `data.url` for the `source_url` value, but `data` is never defined in the template context (the controller sets `properties`, not `data`). This caused the wrong post to be syndicated when clicking the "Syndicate" button.
11
+ The syndicate button was using `data.url` for the `source_url` value, but `data` is never defined in the template context. The controller passes `properties`, not `data`. This caused syndication to fail or syndicate the wrong post.
12
+
13
+ **Fixed:** Changed `value: data.url` to `value: properties.url` in `includes/@indiekit-endpoint-posts-syndicate.njk`
12
14
 
13
15
  **PR submitted upstream:** https://github.com/getindiekit/indiekit/pull/828
14
16
 
17
+ ### 2. MongoDB Query Performance Fix
18
+
19
+ The original `getPostProperties()` function queried the Micropub endpoint without any filter (`q=source`), fetching only the first 40 posts and searching through them client-side. **This made posts older than 40 items completely inaccessible**, causing 404 errors when trying to view or edit them.
20
+
21
+ **Fixed:** Now queries the MongoDB database directly by `_id`, which is efficient and works regardless of how many posts exist.
22
+
23
+ ### 3. BSON Version Conflict Fix
24
+
25
+ Using `ObjectId` from the MongoDB driver caused BSON version conflicts between Indiekit's MongoDB version and plugin dependencies, resulting in runtime errors.
26
+
27
+ **Fixed:** Use aggregation pipeline with `$toString` to compare `_id` as string, avoiding the need to import `ObjectId`.
28
+
15
29
  ## Installation
16
30
 
17
31
  ```bash
18
32
  npm install @rmdes/indiekit-endpoint-posts
19
33
  ```
20
34
 
21
- ### Using npm overrides (recommended)
35
+ ### Using npm Overrides (Recommended)
22
36
 
23
37
  Add to your `package.json`:
24
38
 
@@ -30,7 +44,36 @@ Add to your `package.json`:
30
44
  }
31
45
  ```
32
46
 
33
- This replaces the upstream package with this fork without changing your plugin configuration.
47
+ This replaces the upstream package with this fork automatically, without changing your plugin configuration.
48
+
49
+ ## Usage
50
+
51
+ ```javascript
52
+ import PostsEndpoint from "@rmdes/indiekit-endpoint-posts";
53
+
54
+ export default {
55
+ plugins: [
56
+ new PostsEndpoint({
57
+ mountPath: "/posts", // Optional, default: "/posts"
58
+ }),
59
+ ],
60
+ };
61
+ ```
62
+
63
+ ## Features
64
+
65
+ - **List posts** with cursor-based pagination
66
+ - **View post details** with status badges and syndication info
67
+ - **Create posts** with dynamic forms based on post type
68
+ - **Edit posts** with all properties and advanced options
69
+ - **Delete/undelete posts** with confirmation
70
+ - **Syndicate posts** to configured targets via button
71
+ - **Multi-language support** (14 languages)
72
+ - **Draft mode** for unpublished posts
73
+ - **Media uploads** (audio, photo, video)
74
+ - **Scheduled publishing** with timezone support
75
+ - **Multi-channel publishing** (if configured)
76
+ - **Geographic coordinates** (ISO 6709 format)
34
77
 
35
78
  ## Options
36
79
 
@@ -38,6 +81,28 @@ This replaces the upstream package with this fork without changing your plugin c
38
81
  | :---------- | :------- | :-------------------------------------------------------------- |
39
82
  | `mountPath` | `string` | Path to management interface. _Optional_, defaults to `/posts`. |
40
83
 
84
+ ## Differences from Upstream
85
+
86
+ 1. **Syndicate form uses correct variable** (`properties.url` instead of `data.url`)
87
+ 2. **Posts are queried directly from MongoDB** (not via Micropub endpoint)
88
+ 3. **No BSON version dependency** (uses aggregation with string comparison)
89
+
90
+ ## Related Plugins
91
+
92
+ - **`@rmdes/indiekit-endpoint-micropub`**: Posts are created/updated/deleted via Micropub
93
+ - **`@rmdes/indiekit-endpoint-syndicate`**: Receives syndicate button POSTs
94
+ - **Post type plugins**: Define fields and validation for each post type (note, article, etc.)
95
+
96
+ ## Documentation
97
+
98
+ See [CLAUDE.md](./CLAUDE.md) for comprehensive technical reference, including:
99
+ - Complete architecture overview
100
+ - File-by-file documentation
101
+ - Data flow diagrams
102
+ - Configuration examples
103
+ - Inter-plugin relationships
104
+ - Known gotchas and workarounds
105
+
41
106
  ## License
42
107
 
43
- MIT - Original work by Paul Robert Lloyd, bug fix by Ricardo Mendes.
108
+ MIT - Original work by Paul Robert Lloyd, bug fixes by Ricardo Mendes.
@@ -2,75 +2,105 @@ import path from "node:path";
2
2
 
3
3
  import { checkScope } from "@indiekit/endpoint-micropub/lib/scope.js";
4
4
  import { excerpt } from "@indiekit/util";
5
- import { mf2tojf2 } from "@paulrobertlloyd/mf2tojf2";
6
5
 
7
- import { endpoint } from "../endpoint.js";
8
6
  import { statusTypes } from "../status-types.js";
9
- import { getPostStatusBadges, getPostName, getPhotoUrl } from "../utils.js";
7
+ import {
8
+ getPosts,
9
+ getPostTypeCounts,
10
+ getPostStatusBadges,
11
+ getPostName,
12
+ getPhotoUrl,
13
+ } from "../utils.js";
10
14
 
11
15
  /**
12
- * List published posts
16
+ * Build query string fragment for preserving filters in pagination/links
17
+ * @param {object} params - Current filter state
18
+ * @returns {string} Query string fragment (e.g. "&type=note&sort=oldest")
19
+ */
20
+ function buildFilterQuery({ type, status, search, sort }) {
21
+ const params = new URLSearchParams();
22
+ if (type && type !== "all") params.set("type", type);
23
+ if (status && status !== "published") params.set("status", status);
24
+ if (search) params.set("search", search);
25
+ if (sort && sort !== "newest") params.set("sort", sort);
26
+ const str = params.toString();
27
+ return str ? `&${str}` : "";
28
+ }
29
+
30
+ /**
31
+ * List posts with filtering, search, sort, and pagination
13
32
  * @type {import("express").RequestHandler}
14
33
  */
15
34
  export const postsController = async (request, response, next) => {
16
35
  try {
17
36
  const { application, publication } = request.app.locals;
18
- const { access_token, scope } = request.session;
19
- const { after, before, success } = request.query;
20
- const limit = Number(request.query.limit) || 12;
21
-
22
- const micropubUrl = new URL(application.micropubEndpoint);
23
- micropubUrl.searchParams.append("q", "source");
24
- micropubUrl.searchParams.append("limit", String(limit));
37
+ const { scope } = request.session;
25
38
 
26
- if (after) {
27
- micropubUrl.searchParams.append("after", String(after));
28
- }
29
-
30
- if (before) {
31
- micropubUrl.searchParams.append("before", String(before));
32
- }
39
+ // Extract filter/search/sort/pagination from query string
40
+ const postType = request.query.type || "all";
41
+ const status = request.query.status || "published";
42
+ const search = request.query.search || "";
43
+ const sort = request.query.sort || "newest";
44
+ const page = Number(request.query.page) || 0;
45
+ const limit = Number(request.query.limit) || 20;
46
+ const success = request.query.success;
33
47
 
34
- const micropubResponse = await endpoint.get(micropubUrl.href, access_token);
48
+ // Build query options (only include non-default values)
49
+ const queryOptions = { page, limit, sort };
50
+ if (postType !== "all") queryOptions.postType = postType;
51
+ if (status !== "published") queryOptions.status = status;
52
+ if (search) queryOptions.search = search;
35
53
 
36
- let posts;
37
- if (micropubResponse?.items?.length > 0) {
38
- const jf2 = mf2tojf2(micropubResponse);
39
- const items = jf2.children || [jf2];
54
+ // Query MongoDB directly
55
+ const { items, total } = await getPosts(application, queryOptions);
56
+ const typeCounts = await getPostTypeCounts(application);
40
57
 
41
- posts = items.map((item) => {
42
- item.id = item.uid;
43
- item.icon = item["post-type"];
44
- item.locale = application.locale;
45
- item.photo = getPhotoUrl(publication, item);
46
- item.description = {
47
- text:
48
- item.summary ||
49
- (item.content?.text &&
50
- excerpt(item.content.text, 30, publication.locale)),
51
- };
52
- item.title = getPostName(publication, item);
53
- item.url = path.join(request.baseUrl, request.path, item.uid);
54
- item.badges = getPostStatusBadges(item, response);
58
+ // Map items to card format
59
+ const posts = items.map((postData) => {
60
+ const item = { ...postData.properties, uid: postData._id.toString() };
61
+ item.id = item.uid;
62
+ item.icon = item["post-type"];
63
+ item.locale = application.locale;
64
+ item.photo = getPhotoUrl(publication, item);
65
+ item.description = {
66
+ text:
67
+ item.summary ||
68
+ (item.content?.text &&
69
+ excerpt(item.content.text, 30, publication.locale)),
70
+ };
71
+ item.title = getPostName(publication, item);
72
+ item.url = path.join(request.baseUrl, request.path, item.uid);
73
+ item.badges = getPostStatusBadges(item, response);
74
+ return item;
75
+ });
55
76
 
56
- return item;
57
- });
58
- }
77
+ // Build filter query string for pagination links
78
+ const filterQuery = buildFilterQuery({
79
+ type: postType,
80
+ status,
81
+ search,
82
+ sort,
83
+ });
59
84
 
85
+ // Offset-based pagination
86
+ const totalPages = Math.ceil(total / limit);
60
87
  const cursor = {};
61
-
62
- if (micropubResponse?.paging?.after) {
63
- cursor.next = {
64
- href: `?after=${micropubResponse.paging.after}`,
65
- };
88
+ if (page < totalPages - 1) {
89
+ cursor.next = { href: `?page=${page + 1}${filterQuery}` };
66
90
  }
67
-
68
- if (micropubResponse?.paging?.before) {
69
- cursor.previous = {
70
- href: `?before=${micropubResponse.paging.before}`,
71
- };
91
+ if (page > 0) {
92
+ cursor.previous = { href: `?page=${page - 1}${filterQuery}` };
72
93
  }
73
94
 
95
+ // Available post types from publication config with counts
96
+ const postTypes = Object.entries(publication.postTypes).map(
97
+ ([id, config]) => ({
98
+ id,
99
+ name: config.name,
100
+ count: typeCounts.find((t) => t._id === id)?.count || 0,
101
+ }),
102
+ );
103
+
74
104
  response.render("posts", {
75
105
  title: response.locals.__("posts.posts.title"),
76
106
  actions: [
@@ -84,8 +114,9 @@ export const postsController = async (request, response, next) => {
84
114
  ],
85
115
  cursor,
86
116
  posts,
87
- limit,
88
- count: micropubResponse._count,
117
+ total,
118
+ filters: { postType, status, search, sort },
119
+ postTypes,
89
120
  parentUrl: request.baseUrl + request.path,
90
121
  statusTypes,
91
122
  success,
package/lib/utils.js CHANGED
@@ -3,7 +3,6 @@ import { Buffer } from "node:buffer";
3
3
  import { sanitise, ISO_6709_RE } from "@indiekit/util";
4
4
  import { mf2tojf2 } from "@paulrobertlloyd/mf2tojf2";
5
5
  import formatcoords from "formatcoords";
6
- import { ObjectId } from "mongodb";
7
6
 
8
7
  import { endpoint } from "./endpoint.js";
9
8
  import { statusTypes } from "./status-types.js";
@@ -160,10 +159,19 @@ export const getPostProperties = async (uid, application) => {
160
159
  return false;
161
160
  }
162
161
 
163
- // Query directly by MongoDB _id
164
- const postData = await postsCollection.findOne({
165
- _id: new ObjectId(uid),
166
- });
162
+ // Query using aggregation to match _id as string (avoids BSON version issues)
163
+ const results = await postsCollection
164
+ .aggregate([
165
+ {
166
+ $match: {
167
+ $expr: { $eq: [{ $toString: "$_id" }, uid] },
168
+ },
169
+ },
170
+ { $limit: 1 },
171
+ ])
172
+ .toArray();
173
+
174
+ const postData = results[0];
167
175
 
168
176
  if (!postData?.properties) {
169
177
  return false;
@@ -181,6 +189,94 @@ export const getPostProperties = async (uid, application) => {
181
189
  }
182
190
  };
183
191
 
192
+ /**
193
+ * Query posts collection with filters, search, sort, and pagination
194
+ * @param {object} application - Application config (has collections Map)
195
+ * @param {object} options - Query options
196
+ * @param {string} [options.postType] - Filter by post-type (e.g. "note", "article")
197
+ * @param {string} [options.status] - Filter by post-status ("published", "draft", "deleted")
198
+ * @param {string} [options.search] - Text search (regex on name/content.text)
199
+ * @param {string} [options.sort] - Sort direction ("newest" or "oldest")
200
+ * @param {number} [options.page] - Page number (0-indexed)
201
+ * @param {number} [options.limit] - Items per page
202
+ * @returns {Promise<{items: Array, total: number}>}
203
+ */
204
+ export const getPosts = async (application, options = {}) => {
205
+ const { postType, status, search, sort = "newest", page = 0, limit = 20 } =
206
+ options;
207
+
208
+ const postsCollection = application?.collections?.get("posts");
209
+ if (!postsCollection) {
210
+ return { items: [], total: 0 };
211
+ }
212
+
213
+ // Build MongoDB filter
214
+ const filter = {};
215
+
216
+ if (postType) {
217
+ filter["properties.post-type"] = postType;
218
+ }
219
+
220
+ if (status === "draft") {
221
+ filter["properties.post-status"] = "draft";
222
+ } else if (status === "deleted") {
223
+ filter["properties.deleted"] = true;
224
+ } else if (status === "all") {
225
+ // No status filter — show everything
226
+ } else {
227
+ // Default: "published" — exclude drafts and deleted
228
+ filter["properties.post-status"] = { $ne: "draft" };
229
+ filter["properties.deleted"] = { $ne: true };
230
+ }
231
+
232
+ if (search) {
233
+ // Escape regex special characters in search term
234
+ const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
235
+ filter.$or = [
236
+ { "properties.name": { $regex: escaped, $options: "i" } },
237
+ { "properties.content.text": { $regex: escaped, $options: "i" } },
238
+ ];
239
+ }
240
+
241
+ const sortDirection = sort === "oldest" ? 1 : -1;
242
+
243
+ const [items, total] = await Promise.all([
244
+ postsCollection
245
+ .find(filter)
246
+ .sort({ "properties.published": sortDirection })
247
+ .skip(page * limit)
248
+ .limit(limit)
249
+ .toArray(),
250
+ postsCollection.countDocuments(filter),
251
+ ]);
252
+
253
+ return { items, total };
254
+ };
255
+
256
+ /**
257
+ * Get post counts grouped by post-type
258
+ * @param {object} application - Application config
259
+ * @returns {Promise<Array<{_id: string, count: number}>>}
260
+ */
261
+ export const getPostTypeCounts = async (application) => {
262
+ const postsCollection = application?.collections?.get("posts");
263
+ if (!postsCollection) {
264
+ return [];
265
+ }
266
+
267
+ return postsCollection
268
+ .aggregate([
269
+ {
270
+ $group: {
271
+ _id: "$properties.post-type",
272
+ count: { $sum: 1 },
273
+ },
274
+ },
275
+ { $sort: { count: -1 } },
276
+ ])
277
+ .toArray();
278
+ };
279
+
184
280
  /**
185
281
  * Get post URL from ID
186
282
  * @param {string} id - ID
package/locales/en.json CHANGED
@@ -119,9 +119,24 @@
119
119
  "syndicate": "Syndicate post"
120
120
  },
121
121
  "posts": {
122
- "title": "Published posts",
122
+ "title": "Posts",
123
123
  "none": "No posts"
124
124
  },
125
+ "filter": {
126
+ "type": "Type",
127
+ "status": "Status",
128
+ "all": "All",
129
+ "status_published": "Published",
130
+ "status_draft": "Drafts",
131
+ "status_deleted": "Deleted",
132
+ "status_all": "All",
133
+ "searchPlaceholder": "Search posts\u2026",
134
+ "searchButton": "Search",
135
+ "clear": "Clear",
136
+ "newest": "Newest",
137
+ "oldest": "Oldest",
138
+ "results": "posts"
139
+ },
125
140
  "title": "Posts"
126
141
  }
127
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-posts",
3
- "version": "1.0.0-beta.27",
3
+ "version": "1.0.0-beta.29",
4
4
  "description": "Post management endpoint for Indiekit with syndicate form fix. View posts published by your Micropub endpoint and publish new posts to it.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -47,8 +47,7 @@
47
47
  "@paulrobertlloyd/mf2tojf2": "^3.0.0",
48
48
  "express": "^5.0.0",
49
49
  "express-validator": "^7.0.0",
50
- "formatcoords": "^1.1.3",
51
- "mongodb": "^6.0.0"
50
+ "formatcoords": "^1.1.3"
52
51
  },
53
52
  "publishConfig": {
54
53
  "access": "public"
package/views/posts.njk CHANGED
@@ -1,6 +1,119 @@
1
1
  {% extends "document.njk" %}
2
2
 
3
3
  {% block content %}
4
+ <style>
5
+ .posts-filter-row {
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ gap: var(--space-xs, 0.5rem);
9
+ align-items: center;
10
+ margin-block-end: var(--space-s, 0.75rem);
11
+ }
12
+ .posts-filter-label {
13
+ font: var(--font-body, 0.875rem/1.5 sans-serif);
14
+ color: var(--color-on-offset, #666);
15
+ }
16
+ .posts-filter-pills {
17
+ display: flex;
18
+ flex-wrap: wrap;
19
+ gap: 0.25rem;
20
+ }
21
+ .posts-filter-pills a {
22
+ text-decoration: none;
23
+ padding: 0.25rem 0.75rem;
24
+ border-radius: 1rem;
25
+ font-size: 0.875rem;
26
+ color: var(--color-on-offset, #666);
27
+ background: var(--color-offset, #f0f0f0);
28
+ }
29
+ .posts-filter-pills a.active {
30
+ background: var(--color-primary, #0066cc);
31
+ color: var(--color-on-primary, #fff);
32
+ }
33
+ .posts-filter-pills .count {
34
+ font-size: 0.75rem;
35
+ opacity: 0.7;
36
+ }
37
+ .posts-search {
38
+ display: flex;
39
+ gap: var(--space-xs, 0.5rem);
40
+ align-items: center;
41
+ }
42
+ .posts-search input[type="search"] {
43
+ padding: 0.25rem 0.5rem;
44
+ border: 1px solid var(--color-border, #ccc);
45
+ border-radius: var(--border-radius-small, 0.25rem);
46
+ font: inherit;
47
+ }
48
+ .posts-meta {
49
+ font-size: 0.875rem;
50
+ color: var(--color-on-offset, #666);
51
+ margin-block-end: var(--space-s, 0.75rem);
52
+ }
53
+ </style>
54
+
55
+ <div class="posts-filters">
56
+ {# Row 1: Post type pills #}
57
+ <div class="posts-filter-row">
58
+ <span class="posts-filter-label">{{ __("posts.filter.type") }}:</span>
59
+ <div class="posts-filter-pills">
60
+ <a href="?status={{ filters.status }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort={{ filters.sort }}"
61
+ class="{% if filters.postType == 'all' %}active{% endif %}">
62
+ {{ __("posts.filter.all") }}
63
+ </a>
64
+ {% for pt in postTypes %}
65
+ {% if pt.count > 0 %}
66
+ <a href="?type={{ pt.id }}&status={{ filters.status }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort={{ filters.sort }}"
67
+ class="{% if filters.postType == pt.id %}active{% endif %}">
68
+ {{ pt.name }} <span class="count">({{ pt.count }})</span>
69
+ </a>
70
+ {% endif %}
71
+ {% endfor %}
72
+ </div>
73
+ </div>
74
+
75
+ {# Row 2: Status pills #}
76
+ <div class="posts-filter-row">
77
+ <span class="posts-filter-label">{{ __("posts.filter.status") }}:</span>
78
+ <div class="posts-filter-pills">
79
+ {% for s in ["published", "draft", "deleted", "all"] %}
80
+ <a href="?{% if filters.postType != 'all' %}type={{ filters.postType }}&{% endif %}status={{ s }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort={{ filters.sort }}"
81
+ class="{% if filters.status == s %}active{% endif %}">
82
+ {{ __("posts.filter.status_" + s) }}
83
+ </a>
84
+ {% endfor %}
85
+ </div>
86
+ </div>
87
+
88
+ {# Row 3: Search + Sort #}
89
+ <div class="posts-filter-row" style="justify-content: space-between;">
90
+ <form class="posts-search" method="get" action="">
91
+ {% if filters.postType != "all" %}<input type="hidden" name="type" value="{{ filters.postType }}">{% endif %}
92
+ {% if filters.status != "published" %}<input type="hidden" name="status" value="{{ filters.status }}">{% endif %}
93
+ <input type="hidden" name="sort" value="{{ filters.sort }}">
94
+ <input type="search" name="search" value="{{ filters.search }}" placeholder="{{ __('posts.filter.searchPlaceholder') }}">
95
+ <button type="submit" class="button button--small">{{ __("posts.filter.searchButton") }}</button>
96
+ {% if filters.search %}
97
+ <a href="?{% if filters.postType != 'all' %}type={{ filters.postType }}&{% endif %}status={{ filters.status }}&sort={{ filters.sort }}">{{ __("posts.filter.clear") }}</a>
98
+ {% endif %}
99
+ </form>
100
+ <div class="posts-filter-pills">
101
+ <a href="?{% if filters.postType != 'all' %}type={{ filters.postType }}&{% endif %}status={{ filters.status }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort=newest"
102
+ class="{% if filters.sort == 'newest' %}active{% endif %}">{{ __("posts.filter.newest") }}</a>
103
+ <a href="?{% if filters.postType != 'all' %}type={{ filters.postType }}&{% endif %}status={{ filters.status }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort=oldest"
104
+ class="{% if filters.sort == 'oldest' %}active{% endif %}">{{ __("posts.filter.oldest") }}</a>
105
+ </div>
106
+ </div>
107
+ </div>
108
+
109
+ {# Results meta #}
110
+ {% if total %}
111
+ <div class="posts-meta">
112
+ <span>{{ total }} {{ __("posts.filter.results") }}{% if filters.search %} for "{{ filters.search }}"{% endif %}</span>
113
+ </div>
114
+ {% endif %}
115
+
116
+ {# Post grid #}
4
117
  {%- if posts.length > 0 %}
5
118
  {{ cardGrid({
6
119
  cardSize: "16rem",
@@ -10,4 +123,4 @@
10
123
  {%- else -%}
11
124
  {{ prose({ text: __("posts.posts.none") }) }}
12
125
  {%- endif %}
13
- {% endblock %}
126
+ {% endblock %}