@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 ADDED
@@ -0,0 +1,76 @@
1
+ # @rmdes/indiekit-endpoint-micropub
2
+
3
+ Micropub endpoint for Indiekit. Enables publishing content to your website using the Micropub protocol.
4
+
5
+ ## Fork Notice
6
+
7
+ This is a fork of `@indiekit/endpoint-micropub` with a fix for syndication services that require pre-syndication markup.
8
+
9
+ ### Issue Fixed
10
+
11
+ Services like IndieNews require a `u-syndication` link in your HTML **before** they receive the syndication webmention. The upstream Micropub endpoint strips all `mp-*` properties (including `mp-syndicate-to`) before passing data to the preset's `postTemplate()`.
12
+
13
+ This fork preserves `mp-syndicate-to` so that:
14
+ 1. The property reaches the preset's `postTemplate()`
15
+ 2. The preset can include it in frontmatter (as `mpSyndicateTo` in Eleventy)
16
+ 3. The theme can render the `u-syndication` link
17
+ 4. IndieNews (and similar services) can find the link when parsing the webmention
18
+
19
+ ### Technical Details
20
+
21
+ The change is in `lib/utils.js`:
22
+
23
+ ```javascript
24
+ // mp- properties to preserve for the template (needed for pre-syndication markup)
25
+ const preserveMpProperties = ["mp-syndicate-to"];
26
+
27
+ for (let key in templateProperties) {
28
+ if (key.startsWith("mp-") && !preserveMpProperties.includes(key)) {
29
+ delete templateProperties[key];
30
+ }
31
+ // ...
32
+ }
33
+ ```
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ npm install @rmdes/indiekit-endpoint-micropub
39
+ ```
40
+
41
+ ### Using npm overrides (recommended)
42
+
43
+ Add to your `package.json`:
44
+
45
+ ```json
46
+ {
47
+ "overrides": {
48
+ "@indiekit/endpoint-micropub": "npm:@rmdes/indiekit-endpoint-micropub@^1.0.0-beta.25"
49
+ }
50
+ }
51
+ ```
52
+
53
+ This replaces the upstream package with this fork without changing your plugin configuration.
54
+
55
+ ## Options
56
+
57
+ | Option | Type | Description |
58
+ | :---------- | :------- | :------------------------------------------------------------------------ |
59
+ | `mountPath` | `string` | Path to listen to Micropub requests. _Optional_, defaults to `/micropub`. |
60
+
61
+ ## Supported endpoint queries
62
+
63
+ - Configuration: `/micropub?q=config`
64
+ - Media endpoint location: `/micropub?q=media-endpoint`
65
+ - Available syndication targets (list): `/micropub?q=syndicate-to`
66
+ - Supported queries: `/micropub?q=config`
67
+ - Supported vocabularies (list): `/micropub?q=post-types`
68
+ - Publication categories (list): `/micropub?q=category`
69
+ - Previously published posts (list): `/micropub?q=source`
70
+ - Source content: `/micropub?q=source&url=WEBSITE_URL`
71
+
72
+ List queries support `filter`, `limit` and `offset` and parameters. For example, `/micropub?q=source&filter=web&limit=10&offset=10`.
73
+
74
+ ## License
75
+
76
+ MIT - Original work by Paul Robert Lloyd, syndication fix by Ricardo Mendes.
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
2
+ <path fill="#1B2" d="M0 0h96v96H0z"/>
3
+ <path fill="#FFF" d="M60 14a23 23 0 0 1 2 46V41.2a5 5 0 1 0-5 0v40.4a2 2 0 0 1-1 .3H37V41.3a5 5 0 1 0-5 0V82H14a2 2 0 0 1-2-2V37a23 23 0 0 1 35.5-19.3C51.1 15.4 55.4 14 60 14Z"/>
4
+ </svg>
package/index.js ADDED
@@ -0,0 +1,33 @@
1
+ import express from "express";
2
+
3
+ import { actionController } from "./lib/controllers/action.js";
4
+ import { queryController } from "./lib/controllers/query.js";
5
+
6
+ const defaults = { mountPath: "/micropub" };
7
+ const router = express.Router();
8
+
9
+ export default class MicropubEndpoint {
10
+ name = "Micropub endpoint";
11
+
12
+ constructor(options = {}) {
13
+ this.options = { ...defaults, ...options };
14
+ this.mountPath = this.options.mountPath;
15
+ }
16
+
17
+ get routes() {
18
+ router.get("/", queryController);
19
+ router.post("/", actionController);
20
+
21
+ return router;
22
+ }
23
+
24
+ init(Indiekit) {
25
+ Indiekit.addCollection("posts");
26
+ Indiekit.addEndpoint(this);
27
+
28
+ // Only mount if micropub endpoint not already configured
29
+ if (!Indiekit.config.application.micropubEndpoint) {
30
+ Indiekit.config.application.micropubEndpoint = this.mountPath;
31
+ }
32
+ }
33
+ }
package/lib/config.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Return queryable publication configuration
3
+ * @param {object} application - Application configuration
4
+ * @param {object} publication - Publication configuration
5
+ * @returns {object} Queryable configuration
6
+ */
7
+ export const getConfig = (application, publication) => {
8
+ const { mediaEndpoint, url } = application;
9
+ const { categories, channels, postTypes, syndicationTargets } = publication;
10
+
11
+ // Supported queries
12
+ const q = [
13
+ "category",
14
+ "channel",
15
+ "config",
16
+ "media-endpoint",
17
+ "post-types",
18
+ "source",
19
+ "syndicate-to",
20
+ ];
21
+
22
+ // Ensure syndication targets use absolute URLs
23
+ const syndicateTo = syndicationTargets.map((target) => target.info);
24
+ for (const info of syndicateTo) {
25
+ if (info.service && info.service.photo) {
26
+ info.service.photo = new URL(info.service.photo, url).href;
27
+ }
28
+ }
29
+
30
+ return {
31
+ categories,
32
+ channels: Object.entries(channels).map(([uid, channel]) => ({
33
+ uid,
34
+ name: channel.name,
35
+ })),
36
+ "media-endpoint": mediaEndpoint,
37
+ "post-types": Object.values(postTypes).map((postType) => ({
38
+ type: postType.type,
39
+ name: postType.name,
40
+ h: postType.h,
41
+ properties: postType.properties,
42
+ "required-properties": postType["required-properties"],
43
+ })),
44
+ "syndicate-to": syndicateTo,
45
+ q,
46
+ };
47
+ };
48
+
49
+ /**
50
+ * Query config value
51
+ * @param {Array} property - Property to query
52
+ * @param {object} options - List options (filter, limit, offset)
53
+ * @param {string} [options.filter] - Value to filter items by
54
+ * @param {number} [options.limit] - Limit of items to return
55
+ * @param {number} [options.offset] - Offset to start limit of items
56
+ * @returns {Array} Updated config property
57
+ */
58
+ export const queryConfig = (property, options) => {
59
+ const { filter, limit } = options;
60
+
61
+ if (!Array.isArray(property)) {
62
+ return property;
63
+ }
64
+
65
+ let properties = property || [];
66
+
67
+ if (filter) {
68
+ properties = properties.filter((item) => {
69
+ item = JSON.stringify(item);
70
+ item = item.toLowerCase();
71
+ return item.includes(filter);
72
+ });
73
+ }
74
+
75
+ if (limit) {
76
+ const offset = options.offset || 0;
77
+ properties = properties.slice(offset, offset + limit);
78
+ properties.length = Math.min(properties.length, limit);
79
+ }
80
+
81
+ return properties;
82
+ };
@@ -0,0 +1,141 @@
1
+ import { IndiekitError } from "@indiekit/error";
2
+
3
+ import { formEncodedToJf2, mf2ToJf2 } from "../jf2.js";
4
+ import { uploadMedia } from "../media.js";
5
+ import { postContent } from "../post-content.js";
6
+ import { postData } from "../post-data.js";
7
+ import { checkScope } from "../scope.js";
8
+
9
+ /**
10
+ * Perform requested post action
11
+ * @type {import("express").RequestHandler}
12
+ */
13
+ export const actionController = async (request, response, next) => {
14
+ const { app, body, files, query, session } = request;
15
+ const action = query.action || body?.action || "create";
16
+ const url = query.url || body?.url;
17
+ const { application, publication } = app.locals;
18
+
19
+ try {
20
+ // Check provided scope
21
+ const { scope, token } = session;
22
+ const hasScope = checkScope(scope, action);
23
+ if (!hasScope) {
24
+ throw IndiekitError.insufficientScope(
25
+ response.locals.__("ForbiddenError.insufficientScope"),
26
+ { scope: action },
27
+ );
28
+ }
29
+
30
+ // Toggle draft mode if `draft` scope
31
+ const draftMode = hasScope === "draft";
32
+
33
+ // Check for URL if not creating a new post
34
+ if (action !== "create" && !url) {
35
+ throw IndiekitError.badRequest(
36
+ response.locals.__("BadRequestError.missingParameter", "url"),
37
+ );
38
+ }
39
+
40
+ let data;
41
+ let jf2;
42
+ let content;
43
+ switch (action) {
44
+ case "create": {
45
+ // Create and normalise JF2 data
46
+ jf2 = request.is("json")
47
+ ? await mf2ToJf2(body, publication.enrichPostData)
48
+ : await formEncodedToJf2(body, publication.enrichPostData);
49
+
50
+ // Attach files
51
+ jf2 = files
52
+ ? await uploadMedia(application.mediaEndpoint, token, jf2, files)
53
+ : jf2;
54
+
55
+ data = await postData.create(application, publication, jf2, draftMode);
56
+ content = await postContent.create(publication, data);
57
+ break;
58
+ }
59
+
60
+ case "update": {
61
+ // Check for update operations
62
+ if (!(body.replace || body.add || body.remove)) {
63
+ throw IndiekitError.badRequest(
64
+ response.locals.__(
65
+ "BadRequestError.missingProperty",
66
+ "replace, add or remove operations",
67
+ ),
68
+ );
69
+ }
70
+
71
+ data = await postData.update(application, publication, url, body);
72
+
73
+ if (!data) {
74
+ content = {
75
+ status: 200,
76
+ location: url,
77
+ json: {
78
+ success: "update",
79
+ success_description: `Post at ${url} not updated as no properties changed`,
80
+ },
81
+ };
82
+ break;
83
+ }
84
+
85
+ // Draft mode: Only update posts that have `draft` post status
86
+ if (draftMode && data.properties["post-status"] !== "draft") {
87
+ throw IndiekitError.insufficientScope(
88
+ response.locals.__("ForbiddenError.insufficientScope"),
89
+ { scope: action },
90
+ );
91
+ }
92
+
93
+ content = await postContent.update(publication, data, url);
94
+ break;
95
+ }
96
+
97
+ case "delete": {
98
+ data = await postData.delete(application, publication, url);
99
+ content = await postContent.delete(publication, data);
100
+ break;
101
+ }
102
+
103
+ case "undelete": {
104
+ data = await postData.undelete(
105
+ application,
106
+ publication,
107
+ url,
108
+ draftMode,
109
+ );
110
+ content = await postContent.undelete(publication, data);
111
+ break;
112
+ }
113
+
114
+ default:
115
+ }
116
+
117
+ response
118
+ .status(content.status)
119
+ .location(content.location)
120
+ .json(content.json);
121
+ } catch (error) {
122
+ let nextError = error;
123
+
124
+ // Hoist not found error to controller to localise response
125
+ if (error.name === "NotFoundError") {
126
+ nextError = IndiekitError.notFound(
127
+ response.locals.__("NotFoundError.record", error.message),
128
+ );
129
+ }
130
+
131
+ // Hoist unsupported post type error to controller to localise response
132
+ if (error.name === "NotImplementedError") {
133
+ nextError = IndiekitError.notImplemented(
134
+ response.locals.__("NotImplementedError.postType", error.message),
135
+ { uri: "https://getindiekit.com/configuration/post-types" },
136
+ );
137
+ }
138
+
139
+ return next(nextError);
140
+ }
141
+ };
@@ -0,0 +1,117 @@
1
+ import { IndiekitError } from "@indiekit/error";
2
+ import { getCursor } from "@indiekit/util";
3
+
4
+ import { getConfig, queryConfig } from "../config.js";
5
+ import { getMf2Properties, jf2ToMf2 } from "../mf2.js";
6
+
7
+ /**
8
+ * Query published posts
9
+ * @type {import("express").RequestHandler}
10
+ */
11
+ export const queryController = async (request, response, next) => {
12
+ const { application, publication } = request.app.locals;
13
+ const postsCollection = application?.collections?.get("posts");
14
+
15
+ try {
16
+ const config = getConfig(application, publication);
17
+ const limit = Number(request.query.limit) || 0;
18
+ const offset = Number(request.query.offset) || 0;
19
+ let { after, before, filter, properties, q, url } = request.query;
20
+
21
+ if (!q) {
22
+ throw IndiekitError.badRequest(
23
+ response.locals.__("BadRequestError.missingParameter", "q"),
24
+ );
25
+ }
26
+
27
+ // `category` param is used to query `categories` configuration property
28
+ q = q === "category" ? "categories" : String(q);
29
+
30
+ // `channel` param is used to query `channels` configuration property
31
+ q = q === "channel" ? "channels" : String(q);
32
+
33
+ switch (q) {
34
+ case "config": {
35
+ response.json(config);
36
+
37
+ break;
38
+ }
39
+
40
+ case "source": {
41
+ if (url) {
42
+ // Return mf2 for a given URL (optionally filtered by properties)
43
+ let postData;
44
+
45
+ if (postsCollection) {
46
+ postData = await postsCollection.findOne({
47
+ "properties.url": url,
48
+ });
49
+ }
50
+
51
+ if (!postData) {
52
+ throw IndiekitError.badRequest(
53
+ response.locals.__("BadRequestError.missingResource", "post"),
54
+ );
55
+ }
56
+
57
+ const mf2 = jf2ToMf2(postData);
58
+ response.json(getMf2Properties(mf2, properties));
59
+ } else {
60
+ // Return mf2 for published posts
61
+ let cursor = {
62
+ items: [],
63
+ hasNext: false,
64
+ hasPrev: false,
65
+ };
66
+
67
+ if (postsCollection) {
68
+ cursor = await getCursor(postsCollection, after, before, limit);
69
+ }
70
+
71
+ const items = [];
72
+ for (let item of cursor.items) {
73
+ if (item.properties) {
74
+ items.push(jf2ToMf2(item));
75
+ } else {
76
+ /**
77
+ * @todo Consider better way to handle item with no properties
78
+ * - notify user and remove item from database?
79
+ * - notify user and don’t delete item from database?
80
+ * - fail silently?
81
+ */
82
+ console.warn(`Item ignored because it has no properties`, item);
83
+ }
84
+ }
85
+
86
+ response.json({
87
+ items,
88
+ paging: {
89
+ ...(cursor.hasNext && { after: cursor.lastItem }),
90
+ ...(cursor.hasPrev && { before: cursor.firstItem }),
91
+ },
92
+ });
93
+ }
94
+
95
+ break;
96
+ }
97
+
98
+ default: {
99
+ // Query configuration value (can be filtered, limited and offset)
100
+ if (config[q]) {
101
+ response.json({
102
+ [q]: queryConfig(config[q], { filter, limit, offset }),
103
+ });
104
+ } else {
105
+ throw IndiekitError.notImplemented(
106
+ response.locals.__("NotImplementedError.query", {
107
+ key: "q",
108
+ value: q,
109
+ }),
110
+ );
111
+ }
112
+ }
113
+ }
114
+ } catch (error) {
115
+ next(error);
116
+ }
117
+ };