Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c3dc63f
chore: ignore .worktrees directory
aciccarello Aug 16, 2026
f18dd3c
feat(endpoint-syndicate): make syndicate() optional on syndicator tar…
aciccarello Aug 16, 2026
886e668
feat(endpoint-micropub): call getSyndicationUrl hook on syndicators a…
aciccarello Aug 16, 2026
97d243a
test(endpoint-micropub): assert syndication property set in post-cont…
aciccarello Aug 16, 2026
4e82a58
feat: add syndicator-indienews package
aciccarello Aug 16, 2026
961859d
test: add error path coverage and verify failedTargets assertion
aciccarello Aug 17, 2026
1168577
docs(syndicator-indienews): add README and checked option support
aciccarello Aug 17, 2026
03a8981
docs(syndicator-indienews): remove unsupported multiple-language arra…
aciccarello Aug 17, 2026
921e4b5
feat(syndicator-indienews): support multiple languages via array config
aciccarello Aug 17, 2026
15f4c39
fix(syndicator-indienews): use PluginOptions typedef to fix JSDoc typ…
aciccarello Aug 17, 2026
02add51
fix(syndicator-indienews): cleanup naming and jsdoc
aciccarello Aug 17, 2026
d625e39
fix(syndicator-indienews): add name to target objects and rename shad…
aciccarello Aug 17, 2026
fd83ea3
docs(syndicator-indienews): note webmention and markup requirements
aciccarello Aug 17, 2026
0df000a
Merge branch 'main' into feat/syndication-preprocess-hook
aciccarello Aug 19, 2026
cdeaf73
fix(syndicator-indienews): remove trailing slash from IndieNews UIDs
aciccarello Aug 20, 2026
fd6f49a
feat(syndicator-indienews): use language names for multiple targets
aciccarello Sep 3, 2026
a27e08b
fix(syndicator-indienews): handle invalid language tags
aciccarello Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ docs/.cache

# Localisation keys
localazy.keys.json
.worktrees
24 changes: 24 additions & 0 deletions packages/endpoint-micropub/lib/post-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ export const postContent = {

const { postTemplate, store, storeMessageTemplate } = publication;
const { path, properties } = postData;

// Call getSyndicationUrl on any targeted syndicator that implements it
const syndicationTargets = publication.syndicationTargets ?? [];
const requestedUids = [properties["mp-syndicate-to"] ?? []].flat();
for (const target of syndicationTargets) {
if (
typeof target.getSyndicationUrl === "function" &&
requestedUids.includes(target.info.uid)
) {
try {
const urls = [await target.getSyndicationUrl(publication)]
.flat()
.filter(Boolean);
const existing = [properties.syndication ?? []].flat();
const newUrls = urls.filter((url) => !existing.includes(url));
if (newUrls.length > 0) {
properties.syndication = [...existing, ...newUrls];
}
} catch (error) {
debug(`getSyndicationUrl failed for ${target.info.uid}: %O`, error);
}
}
}

const metadata = {
action: "create",
result: "created",
Expand Down
77 changes: 76 additions & 1 deletion packages/endpoint-micropub/test/unit/post-content.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import { describe, it, mock } from "node:test";

import { mockAgent } from "@indiekit-test/mock-agent";
import { deletedPostData, postData } from "@indiekit-test/post-data";
Expand Down Expand Up @@ -86,4 +86,79 @@ describe("endpoint-micropub/lib/post-content", () => {
message: "postTemplate is not a function",
});
});

it("Calls getSyndicationUrl on targeted syndicator and sets syndication property", async () => {
const getSyndicationUrl = mock.fn(async () => "https://news.indieweb.org/en/");
const syndicator = {
info: { uid: "https://news.indieweb.org/en/" },
getSyndicationUrl,
};
const pub = { ...publication, syndicationTargets: [syndicator] };
const data = {
path: "foo.md",
properties: {
...postData.properties,
"mp-syndicate-to": ["https://news.indieweb.org/en/"],
},
};
await postContent.create(pub, data);
assert.equal(getSyndicationUrl.mock.calls.length, 1);
assert.deepEqual(data.properties.syndication, ["https://news.indieweb.org/en/"]);
});

it("Does not call getSyndicationUrl on syndicator not in mp-syndicate-to", async () => {
const getSyndicationUrl = mock.fn(async () => "https://news.indieweb.org/en/");
const syndicator = {
info: { uid: "https://news.indieweb.org/en/" },
getSyndicationUrl,
};
const pub = { ...publication, syndicationTargets: [syndicator] };
const data = {
path: "foo.md",
properties: {
...postData.properties,
"mp-syndicate-to": ["https://other.example/"],
},
};
await postContent.create(pub, data);
assert.equal(getSyndicationUrl.mock.calls.length, 0);
});

it("Appends to existing syndication without duplicates", async () => {
const getSyndicationUrl = mock.fn(async () => "https://news.indieweb.org/en/");
const syndicator = {
info: { uid: "https://news.indieweb.org/en/" },
getSyndicationUrl,
};
const pub = { ...publication, syndicationTargets: [syndicator] };
const data = {
path: "foo.md",
properties: {
...postData.properties,
syndication: ["https://news.indieweb.org/en/"],
"mp-syndicate-to": ["https://news.indieweb.org/en/"],
},
};
await postContent.create(pub, data);
// getSyndicationUrl was called but returned a duplicate — syndication stays length 1
assert.equal(getSyndicationUrl.mock.calls.length, 1);
assert.deepEqual(data.properties.syndication, ["https://news.indieweb.org/en/"]);
});

it("Silently skips syndicator when getSyndicationUrl throws", async () => {
const syndicator = {
info: { uid: "https://news.indieweb.org/en/" },
getSyndicationUrl: async () => { throw new Error("network error"); },
};
const pub = { ...publication, syndicationTargets: [syndicator] };
const data = {
path: "foo.md",
properties: {
...postData.properties,
"mp-syndicate-to": ["https://news.indieweb.org/en/"],
},
};
await assert.doesNotReject(() => postContent.create(pub, data));
assert.equal(data.properties.syndication, undefined);
});
});
4 changes: 4 additions & 0 deletions packages/endpoint-syndicate/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export const syndicateToTargets = async (publication, properties) => {
const alreadySyndicated = hasSyndicationUrl(syndicatedUrls, url);

if (target && !alreadySyndicated) {
if (typeof target.syndicate !== "function") {
continue;
}

try {
const syndicatedUrl = await target.syndicate(properties, publication);

Expand Down
20 changes: 20 additions & 0 deletions packages/endpoint-syndicate/test/unit/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getPostData,
getSyndicationTarget,
hasSyndicationUrl,
syndicateToTargets,
} from "../../lib/utils.js";

const { client, database, mongoServer } = await testDatabase();
Expand Down Expand Up @@ -87,4 +88,23 @@ describe("endpoint-syndicate/lib/token", () => {
false,
);
});

it("Skips syndicator target without syndicate method", async () => {
const targetWithoutSyndicate = {
info: { uid: "https://example.com/" },
// no syndicate method
};
const publication = {
syndicationTargets: [targetWithoutSyndicate],
};
const properties = {
"mp-syndicate-to": ["https://example.com/"],
url: "https://me.example/post/1",
};

const result = await syndicateToTargets(publication, properties);

assert.deepEqual(result.syndicatedUrls, []);
assert.equal(result.failedTargets, undefined);
});
});
57 changes: 57 additions & 0 deletions packages/syndicator-indienews/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# @indiekit/syndicator-indienews

[IndieNews](https://news.indieweb.org) syndicator for Indiekit.

IndieNews is a community aggregator for posts about the IndieWeb. Unlike API-based syndicators, IndieNews discovers content via [Webmention](https://indieweb.org/Webmention) — syndication is triggered when your post links to an IndieNews channel URL and sends a Webmention to it.

This syndicator writes the IndieNews channel URL into the `syndication` property of your post at creation time, so that it appears in your post's markup and can be picked up by your Webmention sending setup.

## Installation

`npm install @indiekit/syndicator-indienews`

## Usage

Add `@indiekit/syndicator-indienews` to your list of plug-ins, specifying options as required:

```json
{
"plugins": ["@indiekit/syndicator-indienews"],
"@indiekit/syndicator-indienews": {
"language": "en"
}
}
```

When you create a post with `mp-syndicate-to: https://news.indieweb.org/en`, Indiekit will write `https://news.indieweb.org/en` into the `syndication` property of your post.

For IndieNews to discover your post, two additional things must happen:

1. **Your post template must include the `syndication` value** as a `u-syndication` link in your post's HTML markup, so that the URL is visible to Webmention senders.
2. **Your site must send a Webmention** to the IndieNews channel URL after publishing. This can be done with a Webmention sending service or tool such as [Telegraph](https://telegraph.p3k.io) or [webmention.app](https://webmention.app).

### Multiple languages

To offer syndication to multiple IndieNews language channels, pass an array of options:

```json
{
"plugins": ["@indiekit/syndicator-indienews"],
"@indiekit/syndicator-indienews": [
{ "language": "en" },
{ "language": "es" }
]
}
```

Each entry appears as a separate syndication target in your Micropub client.

## Options

Each options object (or the single options object) supports:

| Option | Type | Description |
| :--------- | :------- | :------------------------------------------------------------------------------------------------------------- |
| `language` | `string` | IndieNews language channel to syndicate to. _Optional_, defaults to `"en"`. See [IndieNews](https://news.indieweb.org) for available languages. |
| `checked` | `boolean`| Tell a Micropub client whether this syndicator should be enabled by default. _Optional_, defaults to `false`. |

76 changes: 76 additions & 0 deletions packages/syndicator-indienews/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @import { Indiekit } from "@indiekit/indiekit";
*/

/**
* @typedef {object} IndieNewsPluginOptions
* @property {string} [language] - IndieNews language code (default: "en")
* @property {boolean} [checked] - Pre-check in Micropub clients (default: false)
*/

/**
* Create a single IndieNews syndicator target for a given language.
* @param {IndieNewsPluginOptions} options - Options object (per language)
* @param {boolean} includeLanguage - Whether to include the language in the target name
* @returns {object} Syndicator target
*/
function createTarget(options = {}, includeLanguage) {
const language = options.language ?? "en";
let languageName = language;

try {
languageName = new Intl.DisplayNames([language], {
type: "language",
}).of(language) ?? language;
} catch {
// Use the configured code when it is not a valid language tag.
}

const name = includeLanguage
? `IndieNews (${languageName})`
: "IndieNews";
const checked = options.checked ?? false;
const uid = `https://news.indieweb.org/${language}`;

return {
name,

get info() {
return {
checked,
name,
uid,
service: {
name: "IndieNews",
photo: "https://news.indieweb.org/favicon.ico",
url: "https://news.indieweb.org",
},
};
},

async getSyndicationUrl() {
return uid;
},
};
}

export default class SyndicatorIndieNews {
name = "IndieNews Syndicator";

/**
* @param {IndieNewsPluginOptions | IndieNewsPluginOptions[]} [options] - Plug-in options, or array of options for multiple languages
*/
constructor(options = {}) {
const targetOptions = [options].flat();
this.targets = targetOptions.map((options) =>
createTarget(options, targetOptions.length > 1),
);
}

/**
* @param {Indiekit} Indiekit - Indiekit instance
*/
init(Indiekit) {
Indiekit.addSyndicator(this.targets);
}
}
37 changes: 37 additions & 0 deletions packages/syndicator-indienews/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@indiekit/syndicator-indienews",
"version": "1.0.0",
"description": "IndieNews syndicator for Indiekit",
"keywords": [
"indiekit",
"indiekit-plugin",
"indieweb",
"syndication",
"indienews"
],
"homepage": "https://getindiekit.com",
"author": {
"name": "Paul Robert Lloyd",
"url": "https://paulrobertlloyd.com"
},
"license": "MIT",
"engines": {
"node": ">=24.17"
},
"type": "module",
"main": "index.js",
"files": [
"index.js"
],
"bugs": {
"url": "https://github.com/getindiekit/indiekit/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/getindiekit/indiekit.git",
"directory": "packages/syndicator-indienews"
},
"publishConfig": {
"access": "public"
}
}
Loading