@rmdes/indiekit-endpoint-micropub 1.0.0-beta.25

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.
@@ -0,0 +1,285 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
3
+ import { IndiekitError } from "@indiekit/error";
4
+ import { getCanonicalUrl, getDate } from "@indiekit/util";
5
+ import makeDebug from "debug";
6
+
7
+ import { getSyndicateToProperty, normaliseProperties } from "./jf2.js";
8
+ import { getPostType } from "./post-type-discovery.js";
9
+ import * as updateMf2 from "./update.js";
10
+ import { getPostTemplateProperties, renderPath } from "./utils.js";
11
+
12
+ const debug = makeDebug("indiekit:endpoint-micropub:post-data");
13
+
14
+ export const postData = {
15
+ /**
16
+ * Create post data
17
+ * @param {object} application - Application configuration
18
+ * @param {object} publication - Publication configuration
19
+ * @param {object} properties - JF2 properties
20
+ * @param {boolean} [draftMode] - Draft mode
21
+ * @returns {Promise<object>} Post data
22
+ */
23
+ async create(application, publication, properties, draftMode = false) {
24
+ debug(`Create %O`, { draftMode, properties });
25
+
26
+ const { timeZone } = application;
27
+ const { me, postTypes, syndicationTargets } = publication;
28
+
29
+ // Add syndication targets
30
+ const syndicateTo = getSyndicateToProperty(properties, syndicationTargets);
31
+ if (syndicateTo) {
32
+ properties["mp-syndicate-to"] = syndicateTo;
33
+ }
34
+
35
+ // Normalise properties
36
+ properties = normaliseProperties(publication, properties, timeZone);
37
+
38
+ // Post type
39
+ const type = getPostType(postTypes, properties);
40
+ properties["post-type"] = type;
41
+
42
+ // Get post type configuration
43
+ const typeConfig = postTypes[type];
44
+ if (!typeConfig || !typeConfig.post?.path) {
45
+ throw IndiekitError.notImplemented(type);
46
+ }
47
+
48
+ // Post paths
49
+ const path = await renderPath(
50
+ typeConfig.post.path,
51
+ properties,
52
+ application,
53
+ publication,
54
+ );
55
+ const url = await renderPath(
56
+ typeConfig.post.url || typeConfig.post.path,
57
+ properties,
58
+ application,
59
+ publication,
60
+ );
61
+ properties.url = getCanonicalUrl(url, me);
62
+
63
+ // Post status
64
+ // Draft mode: Only create post with a `draft` post-status
65
+ properties["post-status"] = draftMode
66
+ ? "draft"
67
+ : properties["post-status"] || "published";
68
+
69
+ const postData = { path, properties };
70
+
71
+ // Add data to posts collection (or replace existing if present)
72
+ const postsCollection = application?.collections?.get("posts");
73
+ if (postsCollection) {
74
+ const query = { "properties.url": properties.url };
75
+ await postsCollection.replaceOne(query, postData, { upsert: true });
76
+ }
77
+
78
+ return postData;
79
+ },
80
+
81
+ /**
82
+ * Read post data
83
+ * @param {object} application - Application configuration
84
+ * @param {string} url - URL of existing post
85
+ * @returns {Promise<object>} Post data
86
+ */
87
+ async read(application, url) {
88
+ debug(`Read ${url}`);
89
+
90
+ const query = { "properties.url": url };
91
+ const postsCollection = application?.collections?.get("posts");
92
+
93
+ const postData = await postsCollection.findOne(query);
94
+ if (!postData) {
95
+ throw IndiekitError.notFound(url);
96
+ }
97
+
98
+ return postData;
99
+ },
100
+
101
+ /**
102
+ * Update post data
103
+ *
104
+ * Add, delete or replace properties and/or replace property values
105
+ * @param {object} application - Application configuration
106
+ * @param {object} publication - Publication configuration
107
+ * @param {string} url - URL of existing post
108
+ * @param {object} operation - Requested operation(s)
109
+ * @returns {Promise<object>} Post data
110
+ */
111
+ async update(application, publication, url, operation) {
112
+ debug(`Update ${url} %O`, { operation });
113
+
114
+ const { timeZone } = application;
115
+ const { me, postTypes } = publication;
116
+ const postsCollection = application?.collections?.get("posts");
117
+
118
+ // Read properties
119
+ let { path: _originalPath, properties } = await this.read(application, url);
120
+
121
+ // Save incoming properties for later comparison
122
+ let oldProperties = structuredClone(properties);
123
+
124
+ // Add properties
125
+ if (operation.add) {
126
+ properties = updateMf2.addProperties(properties, operation.add);
127
+ }
128
+
129
+ // Replace property entries
130
+ if (operation.replace) {
131
+ properties = await updateMf2.replaceEntries(
132
+ properties,
133
+ operation.replace,
134
+ );
135
+ }
136
+
137
+ // Remove properties and/or property entries
138
+ if (operation.delete) {
139
+ properties = Array.isArray(operation.delete)
140
+ ? updateMf2.deleteProperties(properties, operation.delete)
141
+ : updateMf2.deleteEntries(properties, operation.delete);
142
+ }
143
+
144
+ // Normalise properties
145
+ properties = normaliseProperties(publication, properties, timeZone);
146
+ oldProperties = normaliseProperties(publication, oldProperties, timeZone);
147
+
148
+ // Post type
149
+ const type = getPostType(postTypes, properties);
150
+ const typeConfig = postTypes[type];
151
+ properties["post-type"] = type;
152
+
153
+ // Post paths
154
+ const path = await renderPath(
155
+ typeConfig.post.path,
156
+ properties,
157
+ application,
158
+ publication,
159
+ );
160
+ const updatedUrl = await renderPath(
161
+ typeConfig.post.url,
162
+ properties,
163
+ application,
164
+ publication,
165
+ );
166
+ properties.url = getCanonicalUrl(updatedUrl, me);
167
+
168
+ // Return if no changes to template properties detected
169
+ const newProperties = getPostTemplateProperties(properties);
170
+ oldProperties = getPostTemplateProperties(oldProperties);
171
+ if (isDeepStrictEqual(newProperties, oldProperties)) {
172
+ return;
173
+ }
174
+
175
+ // Add updated date
176
+ properties.updated = getDate(timeZone);
177
+
178
+ // Update data in posts collection
179
+ const postData = { _originalPath, path, properties };
180
+ const query = { "properties.url": url };
181
+ await postsCollection.replaceOne(query, postData);
182
+
183
+ return postData;
184
+ },
185
+
186
+ /**
187
+ * Delete post data
188
+ *
189
+ * Delete (most) properties, keeping a record of deleted for later retrieval
190
+ * @param {object} application - Application configuration
191
+ * @param {object} publication - Publication configuration
192
+ * @param {string} url - URL of existing post
193
+ * @returns {Promise<object>} Post data
194
+ */
195
+ async delete(application, publication, url) {
196
+ debug(`Delete ${url}`);
197
+
198
+ const { timeZone } = application;
199
+ const { postTypes } = publication;
200
+ const postsCollection = application?.collections?.get("posts");
201
+
202
+ // Read properties
203
+ const { properties } = await this.read(application, url);
204
+
205
+ // Make a copy of existing properties
206
+ const _deletedProperties = structuredClone(properties);
207
+
208
+ // Delete all properties, except those required for path creation
209
+ for (const key in _deletedProperties) {
210
+ if (!["post-type", "published", "slug", "type", "url"].includes(key)) {
211
+ delete properties[key];
212
+ }
213
+ }
214
+
215
+ // Add deleted date
216
+ properties.deleted = getDate(timeZone);
217
+
218
+ // Post type
219
+ const type = properties["post-type"];
220
+ const typeConfig = postTypes[type];
221
+
222
+ // Post paths
223
+ const path = await renderPath(
224
+ typeConfig.post.path,
225
+ properties,
226
+ application,
227
+ publication,
228
+ );
229
+
230
+ // Update data in posts collection
231
+ const postData = { path, properties, _deletedProperties };
232
+ const query = { "properties.url": url };
233
+ await postsCollection.replaceOne(query, postData);
234
+
235
+ return postData;
236
+ },
237
+
238
+ /**
239
+ * Undelete post data
240
+ *
241
+ * Restore previously deleted properties
242
+ * @param {object} application - Application configuration
243
+ * @param {object} publication - Publication configuration
244
+ * @param {string} url - URL of existing post
245
+ * @param {boolean} [draftMode] - Draft mode
246
+ * @returns {Promise<object>} Post data
247
+ */
248
+ async undelete(application, publication, url, draftMode) {
249
+ debug(`Undelete ${url} %O`, { draftMode });
250
+
251
+ const { postTypes } = publication;
252
+ const postsCollection = application?.collections?.get("posts");
253
+
254
+ // Read deleted properties
255
+ const { _deletedProperties } = await this.read(application, url);
256
+
257
+ // Restore previously deleted properties
258
+ const properties = _deletedProperties;
259
+
260
+ // Post type
261
+ const type = properties["post-type"];
262
+ const typeConfig = postTypes[type];
263
+
264
+ // Post paths
265
+ const path = await renderPath(
266
+ typeConfig.post.path,
267
+ properties,
268
+ application,
269
+ publication,
270
+ );
271
+
272
+ // Post status
273
+ // Draft mode: Only restore post with a `draft` post-status
274
+ properties["post-status"] = draftMode
275
+ ? "draft"
276
+ : properties["post-status"] || "published";
277
+
278
+ // Update data in posts collection
279
+ const postData = { path, properties };
280
+ const query = { "properties.url": url };
281
+ await postsCollection.replaceOne(query, postData);
282
+
283
+ return postData;
284
+ },
285
+ };
@@ -0,0 +1,49 @@
1
+ import { getObjectId } from "@indiekit/util";
2
+
3
+ export const postTypeCount = {
4
+ /**
5
+ * Count the number of posts of a given type
6
+ * @param {object} postsCollection - Posts database collection
7
+ * @param {object} properties - JF2 properties
8
+ * @returns {Promise<object>} Post count
9
+ */
10
+ async get(postsCollection, properties) {
11
+ if (!postsCollection || !postsCollection.count()) {
12
+ console.warn("No database configuration provided");
13
+ console.info(
14
+ "See https://getindiekit.com/configuration/application/#mongodburl",
15
+ );
16
+
17
+ return;
18
+ }
19
+
20
+ // Post type
21
+ const postType = properties["post-type"];
22
+ const postUid = properties.uid;
23
+ const startDate = new Date(new Date(properties.published).toDateString());
24
+ const endDate = new Date(startDate);
25
+ endDate.setDate(endDate.getDate() + 1);
26
+ const response = await postsCollection
27
+ .aggregate([
28
+ {
29
+ $addFields: {
30
+ convertedDate: {
31
+ $toDate: "$properties.published",
32
+ },
33
+ },
34
+ },
35
+ {
36
+ $match: {
37
+ _id: getObjectId(postUid),
38
+ "properties.post-type": postType,
39
+ convertedDate: {
40
+ $gte: startDate,
41
+ $lt: endDate,
42
+ },
43
+ },
44
+ },
45
+ ])
46
+ .toArray();
47
+ return response.length;
48
+ },
49
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Accepts a JF2 object and attempts to determine the post type
3
+ * @param {object} postTypes - Configured post types
4
+ * @param {object} properties - JF2 properties
5
+ * @returns {string|null} The post type or null if unknown
6
+ * @see {@link https://ptd.spec.indieweb.org/#algorithm}
7
+ */
8
+ export const getPostType = (postTypes, properties) => {
9
+ const propertiesMap = new Map(Object.entries(properties));
10
+
11
+ // If post has the event type, it’s an event
12
+ if (properties.type && properties.type === "event") {
13
+ return properties.type;
14
+ }
15
+
16
+ const basePostTypes = new Map([
17
+ ["rsvp", "rsvp"],
18
+ ["repost", "repost-of"],
19
+ ["like", "like-of"],
20
+ ["reply", "in-reply-to"],
21
+ ["video", "video"],
22
+ ["photo", "photo"],
23
+ ]);
24
+
25
+ // Types defined in Post Type Discovery specification
26
+
27
+ // Types defined in post type configuration
28
+ for (const [type, { discovery }] of Object.entries(postTypes)) {
29
+ if (!discovery) {
30
+ continue;
31
+ }
32
+
33
+ basePostTypes.set(type, discovery);
34
+ }
35
+
36
+ for (const basePostType of basePostTypes) {
37
+ if (propertiesMap.has(basePostType[1])) {
38
+ return basePostType[0];
39
+ }
40
+ }
41
+
42
+ // If has `children` property that is populated, is collection type
43
+ if (
44
+ properties.children &&
45
+ Array.isArray(properties.children) &&
46
+ properties.children.length > 0
47
+ ) {
48
+ return "collection";
49
+ }
50
+
51
+ // Use summary value for content if no content value
52
+ let content;
53
+ if (propertiesMap.has("content")) {
54
+ content =
55
+ properties.content.text || properties.content.html || properties.content;
56
+ } else if (propertiesMap.has("summary")) {
57
+ content = properties.summary;
58
+ }
59
+
60
+ // If post has `name` and content, it’s an article
61
+ // This is a deviation from the Post Type Algorithm, which identifies a post
62
+ // as a note if the content is prefixed with the `name` value.
63
+ if (properties.name && content) {
64
+ return "article";
65
+ }
66
+
67
+ return "note";
68
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Reserved body property names
3
+ * @see {@link https://micropub.spec.indieweb.org/#reserved-properties}
4
+ */
5
+ export const reservedProperties = Object.freeze([
6
+ "access_token",
7
+ "h",
8
+ "action",
9
+ "url",
10
+ ]);
package/lib/scope.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Check provided scope(s) satisfies required scope
3
+ * @param {string} scope - Provided scope (space separated)
4
+ * @param {string} [action] - Required action
5
+ * @returns {boolean|string} `true` if provided scope includes action,
6
+ * `draft` if draft scope, otherwise `false`
7
+ */
8
+ export const checkScope = (scope, action = "create") => {
9
+ // Default scope request is `create`
10
+ if (!scope) {
11
+ scope = "create";
12
+ }
13
+
14
+ // Undeleting a post is equivalent to creating a post
15
+ if (action === "undelete") {
16
+ action = "create";
17
+ }
18
+
19
+ // Check for scope matching desired action
20
+ let hasScope = scope.includes(action);
21
+
22
+ // Handle deprecated `post` scope
23
+ if (!hasScope && action === "create") {
24
+ hasScope = scope.includes("post");
25
+ }
26
+
27
+ // Check for draft scope
28
+ const draftScope = scope.includes("draft");
29
+
30
+ // Can create/update with `draft` scope, but using draft post status
31
+ if (draftScope && (action === "create" || action === "update")) {
32
+ hasScope = "draft";
33
+ }
34
+
35
+ return hasScope;
36
+ };
package/lib/update.js ADDED
@@ -0,0 +1,129 @@
1
+ import _ from "lodash";
2
+
3
+ import { mf2ToJf2 } from "./jf2.js";
4
+
5
+ /**
6
+ * Add properties to object
7
+ * @param {object} object - Object to update
8
+ * @param {object} additions - Properties to add (mf2)
9
+ * @returns {object|undefined} Updated object
10
+ */
11
+ export const addProperties = (object, additions) => {
12
+ for (const key in additions) {
13
+ if (Object.prototype.hasOwnProperty.call(additions, key)) {
14
+ const newValue = additions[key];
15
+ let existingValue = object[key];
16
+
17
+ // If no existing value, add it
18
+ if (!existingValue) {
19
+ object[key] = newValue;
20
+ return object;
21
+ }
22
+
23
+ // If existing value, add to it
24
+ if (existingValue) {
25
+ existingValue = Array.isArray(existingValue)
26
+ ? existingValue
27
+ : [existingValue];
28
+
29
+ const updatedValue = [...existingValue];
30
+
31
+ for (const value of newValue) {
32
+ updatedValue.push(value);
33
+ }
34
+
35
+ object = _.set(object, key, updatedValue);
36
+ return object;
37
+ }
38
+ }
39
+ }
40
+ };
41
+
42
+ /**
43
+ * Replace entries of a property. If property doesn’t exist, create it.
44
+ * @param {object} object - Object to update
45
+ * @param {object} replacements - Properties to replace (mf2)
46
+ * @returns {Promise<object>} Updated object (JF2)
47
+ */
48
+ export const replaceEntries = async (object, replacements) => {
49
+ for await (const [key, value] of Object.entries(replacements)) {
50
+ if (!Array.isArray(value)) {
51
+ throw new TypeError("Replacement value should be an array");
52
+ }
53
+
54
+ // Replacement given as mf2, but data stored as JF2
55
+ switch (value.length) {
56
+ case 0: {
57
+ // Array is empty, don’t perform replacement
58
+ continue;
59
+ }
60
+
61
+ case 1: {
62
+ // Array contains a single value, save as JF2
63
+ const jf2 = await mf2ToJf2(value[0], false);
64
+ object = _.set(object, key, jf2);
65
+ break;
66
+ }
67
+
68
+ default: {
69
+ // Array contains multiple values, save as array
70
+ object = _.set(object, key, value);
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ return object;
77
+ };
78
+
79
+ /**
80
+ * Delete entries for properties of object
81
+ * @param {object} object - Object to update
82
+ * @param {object} deletions - Property entries to delete (mf2)
83
+ * @returns {object} Updated object
84
+ */
85
+ export const deleteEntries = (object, deletions) => {
86
+ for (const key in deletions) {
87
+ if (Object.prototype.hasOwnProperty.call(deletions, key)) {
88
+ const valuesToDelete = deletions[key];
89
+
90
+ if (!Array.isArray(valuesToDelete)) {
91
+ throw new TypeError(`${key} should be an array`);
92
+ }
93
+
94
+ const values = object[key];
95
+ if (!valuesToDelete || !values) {
96
+ return object;
97
+ }
98
+
99
+ for (const value of valuesToDelete) {
100
+ const index = values.indexOf(value);
101
+ if (index !== -1) {
102
+ values.splice(index, 1);
103
+ }
104
+
105
+ if (values.length === 0) {
106
+ delete object[key]; // Delete property if no values remain
107
+ } else {
108
+ object[key] = values;
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ return object;
115
+ };
116
+
117
+ /**
118
+ * Delete properties of object
119
+ * @param {object} object - Object to update
120
+ * @param {Array} deletions - Properties to delete (mf2)
121
+ * @returns {object} Updated object
122
+ */
123
+ export const deleteProperties = (object, deletions) => {
124
+ for (const key of deletions) {
125
+ delete object[key];
126
+ }
127
+
128
+ return object;
129
+ };
package/lib/utils.js ADDED
@@ -0,0 +1,121 @@
1
+ import { dateTokens, formatDate, isDate, supplant } from "@indiekit/util";
2
+ import newbase60 from "newbase60";
3
+
4
+ import { postTypeCount } from "./post-type-count.js";
5
+
6
+ /**
7
+ * Decode form-encoded query parameter
8
+ * @param {string} value - Parameter value to decode
9
+ * @returns {string} Decoded string, else original parameter value
10
+ * @example decodeQueryParameter(['foo', 'bar']) => ['foo', 'bar']
11
+ * @example decodeQueryParameter('2024-02-14T13:24:00+0100') => '2024-02-14T13:24:00+0100'
12
+ * @example decodeQueryParameter('https%3A%2F%2Ffoo.bar') => 'https://foo.bar'
13
+ * @example decodeQueryParameter('foo+bar') => 'foo bar'
14
+ */
15
+ export const decodeQueryParameter = (value) => {
16
+ if (typeof value !== "string") {
17
+ return value;
18
+ }
19
+
20
+ return isDate(value)
21
+ ? decodeURIComponent(value)
22
+ : decodeURIComponent(value.replaceAll("+", " "));
23
+ };
24
+
25
+ /**
26
+ * Get post template properties
27
+ * @param {object} properties - JF2 properties
28
+ * @returns {object} Template properties
29
+ */
30
+ export const getPostTemplateProperties = (properties) => {
31
+ const templateProperties = structuredClone(properties);
32
+
33
+ // mp- properties to preserve for the template (needed for pre-syndication markup)
34
+ // mp-syndicate-to must appear in frontmatter so themes can render u-syndication
35
+ // links BEFORE the syndication webmention is sent (required by IndieNews, etc.)
36
+ const preserveMpProperties = ["mp-syndicate-to"];
37
+
38
+ for (let key in templateProperties) {
39
+ // Remove server commands from post template properties
40
+ // Exception: preserve mp-syndicate-to for pre-syndication u-syndication links
41
+ if (key.startsWith("mp-") && !preserveMpProperties.includes(key)) {
42
+ delete templateProperties[key];
43
+ }
44
+
45
+ // Remove post-type property, only needed internally
46
+ if (key === "post-type") {
47
+ delete templateProperties["post-type"];
48
+ }
49
+ }
50
+
51
+ return templateProperties;
52
+ };
53
+
54
+ /**
55
+ * Render relative path if URL is on publication
56
+ * @param {string} url - External URL
57
+ * @param {string} me - Publication URL
58
+ * @returns {string} Path
59
+ */
60
+ export const relativeMediaPath = (url, me) =>
61
+ url.includes(me) ? url.replace(me, "") : url;
62
+
63
+ /**
64
+ * Render path from URI template and properties
65
+ * @param {string} path - URI template path
66
+ * @param {object} properties - JF2 properties
67
+ * @param {object} application - Application configuration
68
+ * @param {object} publication - Publication configuration
69
+ * @returns {Promise<string>} Path
70
+ */
71
+ export const renderPath = async (
72
+ path,
73
+ properties,
74
+ application,
75
+ publication,
76
+ ) => {
77
+ const dateObject = new Date(properties.published);
78
+ const { timeZone } = Intl.DateTimeFormat().resolvedOptions();
79
+ const { slugSeparator } = publication;
80
+ let tokens = {};
81
+
82
+ // Add date tokens
83
+ for (const dateToken of dateTokens) {
84
+ tokens[dateToken] = formatDate(properties.published, dateToken, {
85
+ locale: application.locale,
86
+ timeZone:
87
+ application.timeZone === "server" ? timeZone : application.timeZone,
88
+ useAdditionalDayOfYearTokens: true,
89
+ });
90
+ }
91
+
92
+ // Add day of the year (NewBase60) token
93
+ tokens.D60 = newbase60.DateToSxg(dateObject);
94
+
95
+ // Add count of post-type for the day
96
+ const postsCollection = application?.collections?.get("posts");
97
+ const count = await postTypeCount.get(postsCollection, properties);
98
+ tokens.n = count + 1;
99
+
100
+ // Add slug token
101
+ tokens.slug = properties.slug;
102
+
103
+ // Add channel token
104
+ if (properties.channel) {
105
+ tokens.channel = Array.isArray(properties.channel)
106
+ ? properties.channel.join(slugSeparator)
107
+ : properties.channel;
108
+ }
109
+
110
+ // Populate URI template path with properties
111
+ path = supplant(path, tokens);
112
+
113
+ return path;
114
+ };
115
+
116
+ /**
117
+ * Convert string to array if not already an array
118
+ * @param {string|Array} object - String or array to convert
119
+ * @returns {Array} Array
120
+ */
121
+ export const toArray = (object) => (Array.isArray(object) ? object : [object]);