Skip to content

Commit

Permalink
feat: Add strategy to post to dev.to (#55)
Browse files Browse the repository at this point in the history
  • Loading branch information
nzakas authored Mar 3, 2025
1 parent 511bad2 commit 0b30203
Show file tree
Hide file tree
Showing 5 changed files with 290 additions and 1 deletion.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Usage: crosspost [options] ["Message to post."]
--linkedin, -l Post to LinkedIn.
--discord, -d Post to Discord via bot.
--discord-webhook Post to Discord via webhook.
--devto Post to dev.to.
--file, -f The file to read the message from.
--help, -h Show this message.
```
Expand Down Expand Up @@ -147,6 +148,8 @@ Each strategy requires a set of environment variables in order to execute:
- `DISCORD_CHANNEL_ID`
- Discord Webhook
- `DISCORD_WEBHOOK_URL`
- dev.to
- `DEVTO_API_KEY`

Tip: You can also load environment variables from a `.env` file in the current working directory by setting the environment variable `CROSSPOST_DOTENV` to `1`.

Expand Down Expand Up @@ -257,6 +260,22 @@ To enable posting to Discord using a webhook, you'll need to create a webhook an

Use the copied webhook URL as the `webhookUrl` parameter in the `DiscordWebhookStrategy` configuration.

### Dev.to

To enable posting to Dev.to:

1. Log in to your [Dev.to](https://dev.to) account.
2. Click on your profile picture in the top right.
3. Click "Settings".
4. Click "Extensions" in the left sidebar.
5. Scroll down to "DEV Community API Keys".
6. Enter a description for your API key and click "Generate API Key".
7. Copy the generated API key.

Use this API key as the value for the `DEVTO_API_KEY` environment variable when using the CLI.

The first line of your post will be used as the article title on Dev.to.

## License

Copyright 2024-2025 Nicholas C. Zakas
Expand Down
14 changes: 13 additions & 1 deletion src/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
LinkedInStrategy,
DiscordStrategy,
DiscordWebhookStrategy,
DevtoStrategy,
} from "./index.js";
import fs from "node:fs";

Expand Down Expand Up @@ -56,6 +57,7 @@ const options = {
linkedin: { type: booleanType, short: "l" },
discord: { type: booleanType, short: "d" },
"discord-webhook": { type: booleanType },
devto: { type: booleanType },
file: { type: stringType },
help: { type: booleanType, short: "h" },
};
Expand All @@ -73,7 +75,8 @@ if (
!flags.bluesky &&
!flags.linkedin &&
!flags.discord &&
!flags["discord-webhook"])
!flags["discord-webhook"] &&
!flags.devto)
) {
console.log('Usage: crosspost [options] ["Message to post."]');
console.log("--twitter, -t Post to Twitter.");
Expand All @@ -82,6 +85,7 @@ if (
console.log("--linkedin, -l Post to LinkedIn.");
console.log("--discord, -d Post to Discord via bot.");
console.log("--discord-webhook Post to Discord via webhook.");
console.log("--devto Post to Dev.to.");
console.log("--file The file to read the message from.");
console.log("--help, -h Show this message.");
process.exit(1);
Expand Down Expand Up @@ -168,6 +172,14 @@ if (flags["discord-webhook"]) {
);
}

if (flags.devto) {
strategies.push(
new DevtoStrategy({
apiKey: env.require("DEVTO_API_KEY"),
}),
);
}

//-----------------------------------------------------------------------------
// Main
//-----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export * from "./strategies/twitter.js";
export * from "./strategies/linkedin.js";
export * from "./strategies/discord.js";
export * from "./strategies/discord-webhook.js";
export * from "./strategies/devto.js";
export * from "./client.js";
123 changes: 123 additions & 0 deletions src/strategies/devto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* @fileoverview Dev.to strategy for posting articles.
* @author Nicholas C. Zakas
*/

/* global fetch */

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/**
* @typedef {Object} DevtoOptions
* @property {string} apiKey The Dev.to API key.
*/

/**
* @typedef {Object} DevtoArticle
* @property {string} title The title of the article.
* @property {string} body_markdown The markdown content of the article.
* @property {boolean} published Whether the article is published.
* @property {string[]} tags The tags for the article.
*/

/**
* @typedef {Object} DevtoErrorResponse
* @property {string} error The error message.
* @property {string} status The error status.
*/

//-----------------------------------------------------------------------------
// Constants
//-----------------------------------------------------------------------------

const API_URL = "https://dev.to/api";

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

/**
* Posts an article to Dev.to.
* @param {string} apiKey The Dev.to API key.
* @param {string} content The content to post.
* @returns {Promise<DevtoArticle>} A promise that resolves with the article data.
*/
async function postArticle(apiKey, content) {
const response = await fetch(`${API_URL}/articles`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"api-key": apiKey,
"User-Agent": "Crosspost v0.7.0", // x-release-please-version
},
body: JSON.stringify({
article: {
title: content.split(/\r?\n/g)[0],
body_markdown: content,
published: true,
},
}),
});

if (response.ok) {
return /** @type {Promise<DevtoArticle>} */ (response.json());
}

const errorBody = /** @type {DevtoErrorResponse} */ (await response.json());

throw new Error(
`${response.status} ${response.statusText}: Failed to post article:\n${errorBody.status} - ${errorBody.error}`,
);
}

//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------

/**
* A strategy for posting articles to Dev.to.
*/
export class DevtoStrategy {
/**
* The name of the strategy.
* @type {string}
* @readonly
*/
name = "devto";

/**
* The API key for Dev.to.
* @type {string}
*/
#apiKey;

/**
* Creates a new instance.
* @param {DevtoOptions} options Options for the instance.
* @throws {Error} When options are missing.
*/
constructor(options) {
const { apiKey } = options;

if (!apiKey) {
throw new TypeError("Missing apiKey.");
}

this.#apiKey = apiKey;
}

/**
* Posts an article to Dev.to.
* @param {string} message The message to post.
* @returns {Promise<DevtoArticle>} A promise that resolves with the article data.
*/
async post(message) {
if (!message) {
throw new TypeError("Missing message to post.");
}

return postArticle(this.#apiKey, message);
}
}
134 changes: 134 additions & 0 deletions tests/strategies/devto.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @fileoverview Tests for the DevtoStrategy class.
* @author Nicholas C. Zakas
*/

//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------

import assert from "node:assert";
import { DevtoStrategy } from "../../src/strategies/devto.js";
import { MockServer, FetchMocker } from "mentoss";

//-----------------------------------------------------------------------------
// Data
//-----------------------------------------------------------------------------

const API_URL = "https://dev.to/api";
const API_KEY = "abc123";

const CREATE_ARTICLE_RESPONSE = {
title: "Hello World",
body_markdown: "Hello World\n\nThis is a test post.",
published: true,
tags: [],
url: "https://dev.to/test/hello-world-123",
canonical_url: "https://dev.to/test/hello-world-123",
id: 123456,
};

const server = new MockServer(API_URL);
const fetchMocker = new FetchMocker({
servers: [server],
});

//-----------------------------------------------------------------------------
// Tests
//-----------------------------------------------------------------------------

describe("DevtoStrategy", () => {
let options;

beforeEach(() => {
options = {
apiKey: API_KEY,
};
});

describe("constructor", () => {
it("should throw a TypeError if apiKey is missing", () => {
assert.throws(
() => {
new DevtoStrategy({ ...options, apiKey: undefined });
},
TypeError,
"Missing apiKey.",
);
});

it("should create an instance if all options are provided", () => {
const strategy = new DevtoStrategy(options);
assert.strictEqual(strategy.name, "devto");
});
});

describe("post", () => {
let strategy;

beforeEach(() => {
strategy = new DevtoStrategy(options);
fetchMocker.mockGlobal();
});

afterEach(() => {
fetchMocker.unmockGlobal();
server.clear();
});

it("should throw an Error if message is missing", async () => {
await assert.rejects(
async () => {
await strategy.post();
},
TypeError,
"Missing message to post.",
);
});

it("should successfully post an article", async () => {
const content = "Hello World\n\nThis is a test post.";

server.post(
{
url: "/api/articles",
headers: {
"content-type": "application/json",
"api-key": API_KEY,
},
body: {
article: {
title: "Hello World",
body_markdown: content,
published: true,
},
},
},
{
status: 201,
headers: {
"content-type": "application/json",
},
body: CREATE_ARTICLE_RESPONSE,
},
);

const response = await strategy.post(content);
assert.deepStrictEqual(response, CREATE_ARTICLE_RESPONSE);
});

it("should handle post failure", async () => {
server.post("/api/articles", {
status: 422,
body: {
error: "Validation error",
status: "422",
},
});

await assert.rejects(async () => {
await strategy.post("Hello World");
}, /422 Unprocessable Entity: Failed to post article/);
});
});
});

0 comments on commit 0b30203

Please sign in to comment.