@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/lib/jf2.js ADDED
@@ -0,0 +1,318 @@
1
+ import { excerpt, getDate, md5, slugify } from "@indiekit/util";
2
+ import {
3
+ fetchReferences,
4
+ mf2tojf2,
5
+ mf2tojf2referenced,
6
+ } from "@paulrobertlloyd/mf2tojf2";
7
+
8
+ import { markdownToHtml, htmlToMarkdown } from "./markdown.js";
9
+ import { reservedProperties } from "./reserved-properties.js";
10
+ import { decodeQueryParameter, relativeMediaPath, toArray } from "./utils.js";
11
+
12
+ /**
13
+ * Create JF2 object from form-encoded request
14
+ * @param {object} body - Form-encoded request body
15
+ * @param {boolean} [requestReferences] - Request data for any referenced URLs
16
+ * @returns {Promise<object>} Micropub action
17
+ */
18
+ export const formEncodedToJf2 = async (body, requestReferences) => {
19
+ const jf2 = {
20
+ type: body.h || "entry",
21
+ };
22
+
23
+ for (const key in body) {
24
+ if (Object.prototype.hasOwnProperty.call(body, key)) {
25
+ // Delete reserved properties
26
+ const isReservedProperty = reservedProperties.includes(key);
27
+ if (isReservedProperty) {
28
+ delete body[key];
29
+ continue;
30
+ }
31
+
32
+ // Add decoded string value to JF2 object
33
+ jf2[key] = decodeQueryParameter(body[key]);
34
+ }
35
+ }
36
+
37
+ if (requestReferences) {
38
+ return fetchReferences(jf2);
39
+ }
40
+
41
+ return jf2;
42
+ };
43
+
44
+ /**
45
+ * Convert mf2 to JF2
46
+ * @param {object} body - Form-encoded request body
47
+ * @param {boolean} [requestReferences] - Request data for any referenced URLs
48
+ * @returns {Promise<object>} Micropub action
49
+ */
50
+ export const mf2ToJf2 = async (body, requestReferences) => {
51
+ const mf2 = {
52
+ items: [body],
53
+ };
54
+
55
+ if (requestReferences) {
56
+ return mf2tojf2referenced(mf2);
57
+ }
58
+
59
+ return mf2tojf2(mf2);
60
+ };
61
+
62
+ /**
63
+ * Normalise JF2 properties
64
+ * @param {object} publication - Publication configuration
65
+ * @param {object} properties - JF2 properties
66
+ * @param {string} timeZone - Application time zone
67
+ * @returns {object} Normalised JF2 properties
68
+ */
69
+ export const normaliseProperties = (publication, properties, timeZone) => {
70
+ const { channels, me, slugSeparator } = publication;
71
+
72
+ properties.published = getDate(timeZone, properties.published);
73
+
74
+ if (properties.name) {
75
+ properties.name = properties.name.trim();
76
+ }
77
+
78
+ if (properties.content) {
79
+ properties.content = getContentProperty(properties);
80
+ }
81
+
82
+ if (properties.location) {
83
+ properties.location = getLocationProperty(properties);
84
+ }
85
+
86
+ if (properties.audio) {
87
+ properties.audio = getAudioProperty(properties, me);
88
+ }
89
+
90
+ if (properties.photo) {
91
+ properties.photo = getPhotoProperty(properties, me);
92
+ }
93
+
94
+ if (properties.video) {
95
+ properties.video = getVideoProperty(properties, me);
96
+ }
97
+
98
+ properties.slug = getSlugProperty(properties, slugSeparator);
99
+
100
+ const publicationHasChannels = channels && Object.keys(channels).length > 0;
101
+ if (publicationHasChannels) {
102
+ properties.channel = getChannelProperty(properties, channels);
103
+ delete properties["mp-channel"];
104
+ }
105
+
106
+ if (properties["mp-syndicate-to"]) {
107
+ properties["mp-syndicate-to"] = toArray(properties["mp-syndicate-to"]);
108
+ }
109
+
110
+ if (properties.syndication) {
111
+ properties.syndication = toArray(properties.syndication);
112
+ }
113
+
114
+ return properties;
115
+ };
116
+
117
+ /**
118
+ * Get audio property
119
+ * @param {object} properties - JF2 properties
120
+ * @param {object} me - Publication URL
121
+ * @returns {Array} `audio` property
122
+ */
123
+ export const getAudioProperty = (properties, me) => {
124
+ let { audio } = properties;
125
+ audio = Array.isArray(audio) ? audio : [audio];
126
+
127
+ return audio.map((item) => ({
128
+ url: relativeMediaPath(item.url || item, me),
129
+ }));
130
+ };
131
+
132
+ /**
133
+ * Get channel property.
134
+ *
135
+ * If a publication has configured channels, but no channel has been selected,
136
+ * the default channel is used.
137
+ *
138
+ * If `mp-channel` provides a UID that does not appear in the publication’s
139
+ * channels, the default channel is used.
140
+ *
141
+ * The first item in a publication’s configured channels is considered the
142
+ * default channel.
143
+ * @param {object} properties - JF2 properties
144
+ * @param {object} channels - Publication channels
145
+ * @returns {Array} `mp-channel` property
146
+ * @see {@link https://github.com/indieweb/micropub-extensions/issues/40}
147
+ */
148
+ export const getChannelProperty = (properties, channels) => {
149
+ channels = Object.keys(channels);
150
+ const mpChannel = properties["mp-channel"];
151
+ const providedChannels = Array.isArray(mpChannel) ? mpChannel : [mpChannel];
152
+ const selectedChannels = new Set();
153
+
154
+ // Only select channels that have been configured
155
+ for (const uid of providedChannels) {
156
+ if (channels.includes(uid)) {
157
+ selectedChannels.add(uid);
158
+ }
159
+ }
160
+
161
+ // If no channels provided, use default channel UID
162
+ if (selectedChannels.size === 0) {
163
+ const defaultChannel = channels[0];
164
+ selectedChannels.add(defaultChannel);
165
+ }
166
+
167
+ return toArray([...selectedChannels]);
168
+ };
169
+
170
+ /**
171
+ * Get content property.
172
+ *
173
+ * JF2 allows for the provision of both plaintext and HTML representations.
174
+ * Use existing values, or add HTML representation if only plaintext provided.
175
+ * @param {object} properties - JF2 properties
176
+ * @returns {object} `content` property
177
+ * @see {@link https://www.w3.org/TR/jf2/#html-content}
178
+ */
179
+ export const getContentProperty = (properties) => {
180
+ const { content } = properties;
181
+ let { html, text } = content;
182
+
183
+ // Return existing text and HTML representations, unamended
184
+ if (html && text) {
185
+ return { html, text };
186
+ }
187
+
188
+ // If HTML representation only, add text representation
189
+ if (html && !text) {
190
+ return { html, text: htmlToMarkdown(html) };
191
+ }
192
+
193
+ // If text representation only, add HTML representation
194
+ if (!html && text) {
195
+ return { html: markdownToHtml(text), text };
196
+ }
197
+
198
+ // If content is a string, add `html` and move plaintext to `text.
199
+ if (typeof content === "string") {
200
+ text = content;
201
+ html = markdownToHtml(content);
202
+ }
203
+
204
+ return { html, text };
205
+ };
206
+
207
+ /**
208
+ * Get location property, parsing a Geo URI if provided
209
+ * @param {object|string} properties - JF2 properties
210
+ * @returns {object} `location` property
211
+ */
212
+ export const getLocationProperty = (properties) => {
213
+ let { location } = properties;
214
+
215
+ if (typeof location === "string" && location.startsWith("geo:")) {
216
+ const geoUriRegexp =
217
+ /geo:(?<latitude>[\d+.?-]*),(?<longitude>[\d+.?-]*)(?:,(?<altitude>[\d+.?-]*))?/;
218
+ const { latitude, longitude, altitude } =
219
+ location.match(geoUriRegexp).groups;
220
+
221
+ location = {
222
+ type: "geo",
223
+ latitude,
224
+ longitude,
225
+ ...(altitude ? { altitude } : {}),
226
+ };
227
+ }
228
+
229
+ return location;
230
+ };
231
+
232
+ /**
233
+ * Get photo property (adding text alternatives where provided)
234
+ * @param {object} properties - JF2 properties
235
+ * @param {object} me - Publication URL
236
+ * @returns {Array} `photo` property
237
+ */
238
+ export const getPhotoProperty = (properties, me) => {
239
+ let { photo } = properties;
240
+ photo = Array.isArray(photo) ? photo : [photo];
241
+
242
+ let photoAlt = properties["mp-photo-alt"];
243
+ if (photoAlt) {
244
+ photoAlt = Array.isArray(photoAlt) ? photoAlt : [photoAlt];
245
+ }
246
+
247
+ const property = photo.map((item, index) => ({
248
+ url: relativeMediaPath(item.url || item, me),
249
+ ...(item.alt && { alt: item.alt.trim() }),
250
+ ...(photoAlt && { alt: photoAlt[index].trim() }),
251
+ }));
252
+ delete properties["mp-photo-alt"];
253
+ return property;
254
+ };
255
+
256
+ /**
257
+ * Get video property
258
+ * @param {object} properties - JF2 properties
259
+ * @param {object} me - Publication URL
260
+ * @returns {Array} `video` property
261
+ */
262
+ export const getVideoProperty = (properties, me) => {
263
+ let { video } = properties;
264
+ video = Array.isArray(video) ? video : [video];
265
+
266
+ return video.map((item) => ({
267
+ url: relativeMediaPath(item.url || item, me),
268
+ }));
269
+ };
270
+
271
+ /**
272
+ * Get slug
273
+ * @param {object} properties - JF2 properties
274
+ * @param {string} separator - Slug separator
275
+ * @returns {string} Array containing slug value
276
+ */
277
+ export const getSlugProperty = (properties, separator) => {
278
+ const suggested = properties["mp-slug"];
279
+ const { name, published } = properties;
280
+
281
+ let string;
282
+ if (suggested) {
283
+ string = suggested;
284
+ } else if (name) {
285
+ string = excerpt(name, 5);
286
+ } else {
287
+ string = md5(published).slice(0, 5);
288
+ }
289
+
290
+ return slugify(string, { separator });
291
+ };
292
+
293
+ /**
294
+ * Get `mp-syndicate-to` property
295
+ * @param {object} properties - JF2 properties
296
+ * @param {Array} syndicationTargets - Configured syndication targets
297
+ * @returns {Array|undefined} Resolved syndication targets
298
+ */
299
+ export const getSyndicateToProperty = (properties, syndicationTargets) => {
300
+ const property = [];
301
+
302
+ if (!syndicationTargets || syndicationTargets.length === 0) {
303
+ return;
304
+ }
305
+
306
+ for (const target of syndicationTargets) {
307
+ const { uid } = target.info;
308
+ const syndicateTo = properties["mp-syndicate-to"];
309
+
310
+ if (syndicateTo?.includes(uid)) {
311
+ property.push(uid);
312
+ }
313
+ }
314
+
315
+ if (property.length > 0) {
316
+ return property;
317
+ }
318
+ };
@@ -0,0 +1,56 @@
1
+ import markdownIt from "markdown-it";
2
+ import TurndownService from "turndown";
3
+
4
+ /**
5
+ * Convert Markdown to HTML
6
+ * @param {string} string - Markdown
7
+ * @returns {string} HTML
8
+ */
9
+ export const markdownToHtml = (string) => {
10
+ const options = {
11
+ html: true,
12
+ breaks: true,
13
+ typographer: true,
14
+ };
15
+
16
+ const parser = markdownIt(options);
17
+
18
+ const html = parser.render(string).trim();
19
+
20
+ return html;
21
+ };
22
+
23
+ /**
24
+ * Convert HTML to Markdown
25
+ * @param {string} string - String (may be HTML or Markdown)
26
+ * @returns {string} Markdown
27
+ */
28
+ export const htmlToMarkdown = (string) => {
29
+ // Normalise text as HTML before converting to Markdown
30
+ string = markdownToHtml(string);
31
+
32
+ const options = {
33
+ codeBlockStyle: "fenced",
34
+ emDelimiter: "*",
35
+ headingStyle: "atx",
36
+ };
37
+
38
+ const turndownService = new TurndownService(options);
39
+
40
+ /**
41
+ * Disable escaping of Markdown characters
42
+ * @param {string} string - String
43
+ * @returns {string} String
44
+ * @see {@link https://github.com/mixmark-io/turndown#escaping-markdown-characters}
45
+ */
46
+ turndownService.escape = (string) => string;
47
+
48
+ /**
49
+ * List of inline elements to keep in Markdown
50
+ */
51
+ turndownService.keep(["cite", "del", "ins"]);
52
+
53
+ const markdown = turndownService.turndown(string);
54
+
55
+ return markdown;
56
+ };
package/lib/media.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Upload attached file(s) via media endpoint
3
+ * @param {string} mediaEndpoint - Media endpoint URL
4
+ * @param {string} token - Bearer token
5
+ * @param {object} properties - JF2 properties
6
+ * @param {object} files - Files to upload
7
+ * @returns {Promise<object>} Uploaded file locations
8
+ */
9
+ export const uploadMedia = async (mediaEndpoint, token, properties, files) => {
10
+ for await (let [mediaProperty, media] of Object.entries(files)) {
11
+ // Media property may contain one or many media files
12
+ media = Array.isArray(media) ? media : [media];
13
+
14
+ for await (const file of media) {
15
+ const { data, name } = file;
16
+
17
+ // Create multipart/form-data
18
+ const formData = new FormData();
19
+ formData.append("file", new Blob([data]), name);
20
+
21
+ // Upload file via media endpoint
22
+ const response = await fetch(mediaEndpoint, {
23
+ method: "POST",
24
+ headers: {
25
+ authorization: `Bearer ${token}`,
26
+ },
27
+ body: formData,
28
+ });
29
+
30
+ if (!response.ok) {
31
+ /** @type {object} */
32
+ const body = await response.json();
33
+
34
+ const message = body.error_description || response.statusText;
35
+ throw new Error(message);
36
+ }
37
+
38
+ // Update respective media property with location of upload
39
+ properties[mediaProperty] = properties[mediaProperty] || [];
40
+ properties[mediaProperty].push(response.headers.get("location"));
41
+ }
42
+ }
43
+
44
+ return properties;
45
+ };
package/lib/mf2.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Return mf2 properties of a post
3
+ * @param {object} mf2 - mf2 object
4
+ * @param {Array<string>|string} requestedProperties - mf2 properties to select
5
+ * @returns {Promise<object>} mf2 with requested properties
6
+ */
7
+ export const getMf2Properties = (mf2, requestedProperties) => {
8
+ if (!requestedProperties) {
9
+ return mf2;
10
+ }
11
+
12
+ const item = mf2.items ? mf2.items[0] : mf2;
13
+ const { properties } = item;
14
+
15
+ // Return requested properties
16
+ if (requestedProperties) {
17
+ requestedProperties = Array.isArray(requestedProperties)
18
+ ? requestedProperties
19
+ : [requestedProperties];
20
+
21
+ const selectedProperties = {};
22
+
23
+ for (const key of requestedProperties) {
24
+ if (properties[key]) {
25
+ selectedProperties[key] = properties[key];
26
+ }
27
+ }
28
+
29
+ item.properties = selectedProperties;
30
+ }
31
+
32
+ // Return properties
33
+ delete item.type;
34
+ return item;
35
+ };
36
+
37
+ /**
38
+ * Convert JF2 post data to mf2
39
+ * @param {object} postData - Post data
40
+ * @param {boolean} [includeObjectId] - Include ObjectID from post data
41
+ * @returns {object} mf2
42
+ */
43
+ export const jf2ToMf2 = (postData, includeObjectId = true) => {
44
+ const { properties, _id } = postData;
45
+
46
+ const mf2 = {
47
+ type: [`h-${properties.type}`],
48
+ properties: {
49
+ ...(includeObjectId && _id && { uid: [_id] }),
50
+ },
51
+ };
52
+
53
+ delete properties.type;
54
+
55
+ // Move values to property object
56
+ for (const key in properties) {
57
+ // Convert nested vocabulary to mf2 (i.e. h-card, h-geo, h-adr)
58
+ if (Object.prototype.hasOwnProperty.call(properties[key], "type")) {
59
+ mf2.properties[key] = [jf2ToMf2({ properties: properties[key] }, false)];
60
+ }
61
+
62
+ // Convert values to arrays (i.e. 'a' => ['a'])
63
+ else if (Object.prototype.hasOwnProperty.call(properties, key)) {
64
+ const value = properties[key];
65
+ mf2.properties[key] = Array.isArray(value) ? value : [value];
66
+ }
67
+ }
68
+
69
+ // Update key for plaintext content
70
+ if (
71
+ mf2.properties.content &&
72
+ mf2.properties.content[0] &&
73
+ mf2.properties.content[0].text
74
+ ) {
75
+ mf2.properties.content[0].value = properties.content.text;
76
+ delete mf2.properties.content[0].text;
77
+ }
78
+
79
+ return mf2;
80
+ };
@@ -0,0 +1,147 @@
1
+ import makeDebug from "debug";
2
+
3
+ import { getPostTemplateProperties } from "./utils.js";
4
+
5
+ const debug = makeDebug("indiekit:endpoint-micropub:post-content");
6
+
7
+ export const postContent = {
8
+ /**
9
+ * Create post
10
+ * @param {object} publication - Publication configuration
11
+ * @param {object} postData - Post data
12
+ * @returns {Promise<object>} Response data
13
+ */
14
+ async create(publication, postData) {
15
+ debug(`Create %O`, { postData });
16
+
17
+ const { postTemplate, store, storeMessageTemplate } = publication;
18
+ const { path, properties } = postData;
19
+ const metaData = {
20
+ action: "create",
21
+ result: "created",
22
+ fileType: "post",
23
+ postType: properties["post-type"],
24
+ };
25
+ const templateProperties = getPostTemplateProperties(properties);
26
+ const content = await postTemplate(templateProperties);
27
+ const message = storeMessageTemplate(metaData);
28
+
29
+ await store.createFile(path, content, { message });
30
+
31
+ return {
32
+ location: properties.url,
33
+ status: 202,
34
+ json: {
35
+ success: "create_pending",
36
+ success_description: `Post will be created at ${properties.url}`,
37
+ },
38
+ };
39
+ },
40
+
41
+ /**
42
+ * Update post
43
+ * @param {object} publication - Publication configuration
44
+ * @param {object} postData - Post data
45
+ * @param {string} url - Files attached to request
46
+ * @returns {Promise<object>} Response data
47
+ */
48
+ async update(publication, postData, url) {
49
+ debug(`Update ${url} %O`, { postData });
50
+
51
+ const { postTemplate, store, storeMessageTemplate } = publication;
52
+ const { _originalPath, path, properties } = postData;
53
+ const metaData = {
54
+ action: "update",
55
+ result: "updated",
56
+ fileType: "post",
57
+ postType: properties["post-type"],
58
+ };
59
+ const templateProperties = getPostTemplateProperties(properties);
60
+ const content = await postTemplate(templateProperties);
61
+ const message = storeMessageTemplate(metaData);
62
+ const hasUpdatedUrl = url !== properties.url;
63
+
64
+ _originalPath === path
65
+ ? await store.updateFile(path, content, { message })
66
+ : await store.updateFile(_originalPath, content, {
67
+ message,
68
+ newPath: path,
69
+ });
70
+
71
+ delete postData._originalPath;
72
+
73
+ return {
74
+ location: properties.url,
75
+ status: hasUpdatedUrl ? 201 : 200,
76
+ json: {
77
+ success: "update",
78
+ success_description: hasUpdatedUrl
79
+ ? `Post updated and moved to ${properties.url}`
80
+ : `Post updated at ${url}`,
81
+ },
82
+ };
83
+ },
84
+
85
+ /**
86
+ * Delete post
87
+ * @param {object} publication - Publication configuration
88
+ * @param {object} postData - Post data
89
+ * @returns {Promise<object>} Response data
90
+ */
91
+ async delete(publication, postData) {
92
+ debug(`Delete %O`, { postData });
93
+
94
+ const { store, storeMessageTemplate } = publication;
95
+ const { path, properties } = postData;
96
+ const metaData = {
97
+ action: "delete",
98
+ result: "deleted",
99
+ fileType: "post",
100
+ postType: properties["post-type"],
101
+ };
102
+ const message = storeMessageTemplate(metaData);
103
+
104
+ await store.deleteFile(path, { message });
105
+
106
+ return {
107
+ status: 200,
108
+ json: {
109
+ success: "delete",
110
+ success_description: `Post deleted from ${properties.url}`,
111
+ },
112
+ };
113
+ },
114
+
115
+ /**
116
+ * Undelete post
117
+ * @param {object} publication - Publication configuration
118
+ * @param {object} postData - Post data
119
+ * @returns {Promise<object>} Response data
120
+ */
121
+ async undelete(publication, postData) {
122
+ debug(`Undelete %O`, { postData });
123
+
124
+ const { postTemplate, store, storeMessageTemplate } = publication;
125
+ const { path, properties } = postData;
126
+ const metaData = {
127
+ action: "undelete",
128
+ result: "undeleted",
129
+ fileType: "post",
130
+ postType: properties["post-type"],
131
+ };
132
+ const templateProperties = getPostTemplateProperties(properties);
133
+ const content = await postTemplate(templateProperties);
134
+ const message = storeMessageTemplate(metaData);
135
+
136
+ await store.createFile(path, content, { message });
137
+
138
+ return {
139
+ location: properties.url,
140
+ status: 200,
141
+ json: {
142
+ success: "delete_undelete",
143
+ success_description: `Post restored to ${properties.url}`,
144
+ },
145
+ };
146
+ },
147
+ };