@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.
- package/README.md +76 -0
- package/assets/icon.svg +4 -0
- package/index.js +33 -0
- package/lib/config.js +82 -0
- package/lib/controllers/action.js +141 -0
- package/lib/controllers/query.js +117 -0
- package/lib/jf2.js +318 -0
- package/lib/markdown.js +56 -0
- package/lib/media.js +45 -0
- package/lib/mf2.js +80 -0
- package/lib/post-content.js +147 -0
- package/lib/post-data.js +285 -0
- package/lib/post-type-count.js +49 -0
- package/lib/post-type-discovery.js +68 -0
- package/lib/reserved-properties.js +10 -0
- package/lib/scope.js +36 -0
- package/lib/update.js +129 -0
- package/lib/utils.js +121 -0
- package/package.json +54 -0
package/lib/post-data.js
ADDED
|
@@ -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
|
+
};
|
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]);
|