Skip to content

Commit

Permalink
feat: add first setup
Browse files Browse the repository at this point in the history
  • Loading branch information
timseverien committed Jul 30, 2024
1 parent 59b41a8 commit cd48039
Show file tree
Hide file tree
Showing 25 changed files with 6,742 additions and 1 deletion.
13 changes: 13 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = tab
insert_final_newline = true
tab_width = 2
trim_trailing_whitespace = true

[*.{json,yaml,yml}]
indent_style = space
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ out

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
Expand Down
1 change: 1 addition & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx --no -- commitlint --edit $1
4 changes: 4 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
npm run-script lint
npm test
npm run build
git add dist
8 changes: 8 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"editorconfig": true,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"importOrderSeparation": false,
"importOrderSortSpecifiers": true
}
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Syndicate notes

This GitHub action lets you publish notes to various services.

## Usage

```yaml
on: push
name: 🚀 Deploy website on push
jobs:
web-deploy:
name: 🎉 Deploy
runs-on: ubuntu-latest
steps:
- name: 🚚 Get latest code
uses: actions/checkout@v4

- name: 📂 Sync files
uses: timseverien/syndicate-notes
with:
feedType: jsonfeed
feedUrl: https://tsev.dev/notes/feed.json

# Optional: format the message - allows you to add prefixes and suffixes
# contentFormat: '{{content}} {{url}}'

# Optional: change the cache directory used to track what’s published
# cacheDirectory: .cache/syndicate-notes

# Integration details
# These are all optional — omit the integrations you don’t want to publish to
discordWebhookId: '...'
discordWebhookToken: '...'
mastodonInstance: 'https://mastodon.social'
mastodonAccessToken: '...'
```
39 changes: 39 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: 'Syndicate notes'
description: 'Publish content elsewhere'
author: 'Tim Severien'

branding:
color: purple
icon: upload-cloud

inputs:
cacheDirectory:
description: 'Path to the directory where cache files are stored'
default: .cache/syndicate-notes

contentFormat:
description: ''
default: '{{content}} {{url}}'

feedType:
description: 'The feed type of feedUrl'
default: jsonfeed
feedUrl:
description: 'Feed URL where the notes are in'
required: true

# Discord
discordWebhookId:
description: 'The ID of the Discord Webhook'
discordWebhookToken:
description: 'The token of the Discord Webhook'

# Mastodon
mastodonInstance:
description: 'The root URL of the Mastodon instance where the toot should be created'
mastodonAccessToken:
description: 'Your access token for the Mastodon API, get it from /settings/applications/new'

runs:
using: 'node20'
main: 'dist/index.js'
3 changes: 3 additions & 0 deletions commitlint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
extends: ['@commitlint/config-conventional'],
};
74 changes: 74 additions & 0 deletions dist/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { addMessageToCache, createMessageFilter, getCache, persistCache, } from '@/lib/cache';
import { getMessages as getMessagesFromJsonFeed } from '@/lib/sources/jsonfeed';
import * as core from '@actions/core';
import { publish as syndicate, } from '@tsev/social-gateway';
import { createDiscordIntegration } from '@tsev/social-gateway/integrations/discord/index';
import { createMastodonIntegration } from '@tsev/social-gateway/integrations/mastodon/index';
import { z } from 'zod';
export function createIntegrationOrNull(inputKeys, factory) {
const inputs = Object.fromEntries(inputKeys.map((input) => [input, core.getInput(input)]));
if (!Object.values(inputs).every((a) => Boolean(a))) {
return null;
}
return factory(inputs);
}
const FEED_KEYS = ['jsonfeed'];
const FEED_PARSE_MAP = {
jsonfeed: getMessagesFromJsonFeed,
};
const integrations = [
createIntegrationOrNull(['discordWebhookId', 'discordWebhookToken'], (inputs) => createDiscordIntegration({
webhookId: inputs.discordWebhookId,
webhookToken: inputs.discordWebhookToken,
})),
createIntegrationOrNull(['mastodonInstance', 'mastodonAccessToken'], (inputs) => createMastodonIntegration({
accessToken: inputs.mastodonAccessToken,
instanceUrl: inputs.mastodonInstance,
})),
];
const cacheDirectorySchema = z.string().min(1);
const contentFormatSchema = z.string().min(1);
const feedTypeSchema = z.enum(FEED_KEYS);
const feedUrlSchema = z.string().url();
try {
const cacheDirectory = cacheDirectorySchema.parse(core.getInput('cacheDirectory'));
const contentFormat = contentFormatSchema.parse(core.getInput('contentFormat'));
const feedType = feedTypeSchema.parse(core.getInput('feedType'));
const feedUrl = new URL(feedUrlSchema.parse(core.getInput('feedUrl')));
const formatMessage = (content, url) => contentFormat
.replace(/{{content}}/g, content)
.replace(/{{url}}/g, url)
.trim();
const cache = await getCache(cacheDirectory);
const filterMessage = createMessageFilter(cache);
const messages = await FEED_PARSE_MAP[feedType](feedUrl, {
filter: filterMessage,
format: formatMessage,
});
const options = {
integrations: integrations.filter((i) => i !== null),
};
let isMessageFailed = false;
for (const message of messages) {
try {
await syndicate(message, options);
}
catch (error) {
core.error(`Unable to syndicate message ${message.id}`);
isMessageFailed = true;
}
try {
addMessageToCache(cache, message);
await persistCache(cacheDirectory, cache);
}
catch (error) {
core.error('Unable to save to cache');
}
}
if (isMessageFailed) {
throw new Error('Not all messages were syndicated');
}
}
catch (error) {
core.setFailed(error.message);
}
57 changes: 57 additions & 0 deletions dist/lib/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { isBefore } from 'date-fns';
import { parse, stringify } from 'devalue';
import * as fs from 'fs-extra';
import * as path from 'node:path';
import { z } from 'zod';
const CACHE_FILE_NAME = 'syndicate-notes.json';
const cacheDataSchema = z.object({
lastSyndicated: z.date(),
syndicatedItems: z.set(z.string().min(1)),
});
function getCacheFilePath(directory) {
return path.relative(directory, CACHE_FILE_NAME);
}
export function addMessageToCache(cache, message) {
const items = new Set(cache.syndicatedItems);
items.add(message.id);
return {
lastSyndicated: new Date(),
syndicatedItems: items,
};
}
export function createMessageFilter(cache) {
return (message) => {
// Message is explicitly included in syndicated items
if (!cache.syndicatedItems.has(message.id))
return false;
// Message was published before last syndication
if (message.publishDate &&
isBefore(message.publishDate, cache.lastSyndicated))
return false;
return true;
};
}
export async function getCache(directory) {
const cacheFilePath = getCacheFilePath(directory);
try {
const buffer = await fs.readFile(cacheFilePath);
const data = parse(buffer.toString());
return cacheDataSchema.parse(data);
}
catch {
return {
lastSyndicated: new Date(),
syndicatedItems: new Set(),
};
}
}
export async function persistCache(directory, cache) {
const cacheFilePath = getCacheFilePath(directory);
try {
const data = await fs.writeFile(cacheFilePath, stringify(cache));
return cacheDataSchema.parse(data);
}
catch {
throw new Error('Unable to write cache');
}
}
33 changes: 33 additions & 0 deletions dist/lib/cache.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { parseISO } from 'date-fns';
import { describe, expect, test } from 'vitest';
import { addMessageToCache } from './cache';
describe(addMessageToCache.name, () => {
test('given message, returns cache with message added to syndicatedItems list', () => {
const cache = {
lastSyndicated: parseISO('2024-01-01'),
syndicatedItems: new Set(),
};
const message = {
id: 'foobar',
content: 'Foobar!',
};
const result = addMessageToCache(cache, message);
expect(result).toStrictEqual(expect.objectContaining({
syndicatedItems: new Set(['foobar']),
}));
});
test('given message, returns cache with updated lastSyndicated ', () => {
const cache = {
lastSyndicated: parseISO('2024-01-01'),
syndicatedItems: new Set(),
};
const message = {
id: 'foobar',
content: 'Foobar!',
};
const result = addMessageToCache(cache, message);
expect(result).not.toStrictEqual(expect.objectContaining({
lastSyndicated: parseISO('2024-01-01'),
}));
});
});
1 change: 1 addition & 0 deletions dist/lib/message.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
1 change: 1 addition & 0 deletions dist/lib/sources/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
69 changes: 69 additions & 0 deletions dist/lib/sources/jsonfeed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { z } from 'zod';
const jsonFeedAttachmentSchema = z.object({
mime_type: z.string().min(3).includes('/'),
url: z.string().url(),
title: z.string().optional(),
size_in_bytes: z.number().optional(),
duration_in_seconds: z.number().optional(),
});
const jsonFeedAuthorSchema = z.object({
avatar: z.string().url().optional(),
name: z.string().optional(),
url: z.string().url().optional(),
});
const jsonFeedItemSchema = z.object({
id: z.string().min(1),
// One of these should be present
content_html: z.string().optional(),
content_text: z.string().optional(),
authors: jsonFeedAuthorSchema.array().optional(),
banner_image: z.string().url().optional(),
date_modified: z.string().datetime().optional(),
date_published: z.string().datetime().optional(),
external_url: z.string().url().optional(),
image: z.string().url().optional(),
language: z.string().optional(),
summary: z.string().optional(),
tags: z.string().array().optional(),
title: z.string().optional(),
url: z.string().url().optional(),
attachments: jsonFeedAttachmentSchema.array().optional(),
});
const jsonFeedSchema = z.object({
version: z.string().startsWith('https://jsonfeed.org/version/').url(),
title: z.string(),
items: jsonFeedItemSchema.array(),
authors: jsonFeedAuthorSchema.array().optional(),
description: z.string().optional(),
expired: z.boolean().optional(),
favicon: z.string().url().optional(),
feed_url: z.string().url().optional(),
home_page_url: z.string().url().optional(),
icon: z.string().url().optional(),
language: z.string().optional(),
next_url: z.string().optional(),
user_comment: z.string().optional(),
// Deprecated: JSON feed 1.0
author: jsonFeedAuthorSchema.optional(),
});
export const getMessages = async (url, { format, filter }) => {
try {
const response = await fetch(url);
const json = await response.json();
const feed = jsonFeedSchema.parse(json);
return feed.items
.map((item) => {
const contentMessage = item.content_text ?? item.content_html ?? '';
const contentUrl = item.url ?? '';
return {
id: item.id,
language: item.language ?? feed.language ?? undefined,
content: format(contentMessage, contentUrl ?? ''),
};
})
.filter(filter);
}
catch (error) {
throw new Error(`Unable to read feed ${url}`);
}
};
11 changes: 11 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import pluginJs from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';

export default [
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
eslintPluginPrettierRecommended,
];
Loading

0 comments on commit cd48039

Please sign in to comment.