@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/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.
|
package/assets/icon.svg
ADDED
|
@@ -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
|
+
};
|