├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .releaserc ├── LICENSE ├── README.md ├── package.json ├── rollup.config.ts ├── src ├── article.spec.ts ├── article.ts ├── dev-to-git.interface.ts ├── dev-to-git.ts ├── helpers.ts └── index.js ├── test ├── article.md ├── dev-to-git.json └── dev-to-git.test.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions docs 2 | # https://help.github.com/en/articles/about-github-actions 3 | # https://help.github.com/en/articles/workflow-syntax-for-github-actions 4 | name: CI 5 | 6 | on: [push] 7 | 8 | jobs: 9 | build: 10 | # Machine environment: 11 | # https://help.github.com/en/articles/software-in-virtual-environments-for-github-actions#ubuntu-1804-lts 12 | # We specify the Node.js version manually below 13 | runs-on: ubuntu-22.04 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js 12.8 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 12.8 21 | - name: Install dependencies 22 | run: yarn --frozen-lockfile --non-interactive --no-progress 23 | - name: Lint check 24 | run: yarn lint 25 | - name: Format check 26 | run: yarn prettier:check 27 | - name: Build lib 28 | run: yarn build 29 | - name: Copy necessary files into bin 30 | run: cp package.json README.md LICENSE bin 31 | - name: Release 32 | if: contains('refs/heads/master', github.ref) 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | run: yarn semantic-release 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | docs 3 | dist 4 | coverage 5 | bin 6 | .rpt2_cache 7 | .idea 8 | .history 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .history 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "htmlWhitespaceSensitivity": "ignore", 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["master"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | [ 7 | "@semantic-release/npm", 8 | { 9 | "npmPublish": true, 10 | "tarballDir": "." 11 | } 12 | ], 13 | [ 14 | "@semantic-release/github", 15 | { 16 | "assets": "*.tgz" 17 | } 18 | ] 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Maxime Robert 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dev.to git: One way publishing of your blog posts from a git repo to dev.to 2 | 3 | ## First, what is dev.to? 4 | 5 | https://dev.to is a free and open source blogging platform for developers. 6 | 7 | > dev.to (or just DEV) is a platform where software developers write articles, take part in discussions, and build their professional profiles. We value supportive and constructive dialogue in the pursuit of great code and career growth for all members. The ecosystem spans from beginner to advanced developers, and all are welcome to find their place within our community. 8 | 9 | ## Why would I want to put all my blog posts on a git repo? 10 | 11 | - Don't be afraid to mess up with one of your articles while editing it 12 | - Same good practices as when you're developing (format, commits, saving history, compare, etc) 13 | - Use prettier to format the markdown and all the code 14 | - Let people contribute to your article by creating a PR against it (tired of comments going sideways because of some typos? Just let people know they can make a PR at the end of your blog post) 15 | - Create code examples close to your blog post and make sure they're correct thanks to [Embedme](https://github.com/zakhenry/embedme) (_\*1_) 16 | 17 | _\*1: Embedme allows you to write code in actual files rather than your readme, and then from your Readme to make sure that your examples are matching those files._ 18 | 19 | If you prefer not to use Prettier or Embed me, you can do so by simply removing them but I think it's a nice thing to have! 20 | 21 | ## How do I choose which files I want to publish? 22 | 23 | There's a `dev-to-git.json` file where you can define an array of blog posts, e.g. 24 | 25 | ```json 26 | [ 27 | { 28 | "id": 12345, 29 | "relativePathToArticle": "./blog-posts/name-of-your-blog-post/name-of-your-blog-post.md" 30 | } 31 | ] 32 | ``` 33 | 34 | ## How can I find the ID of my blog post on dev.to? 35 | 36 | Whether it's published or just a draft, you **have to create it** on dev.to directly. Unfortunately, dev.to does not display the ID of the blog post on the page. So once it's created, you can open your browser console and paste the following code to retrieve the blog post ID: 37 | `+$('div[data-article-id]').getAttribute('data-article-id')` 38 | 39 | ## How do I configure every blog post individually? 40 | 41 | A blog post has to have a [**front matter**](https://dev.to/p/editor_guide) header. You can find an example in this repository here: https://github.com/maxime1992/dev-to-git/blob/master/test/article.md 42 | 43 | Simple and from there you have control over the following properties: `title`, `published`, `description`, `tags`, `series` and `canonical_url`. 44 | 45 | ## How do I add images to my blog posts? 46 | 47 | Instead of uploading them manually on dev.to, simply put them within your git repo and within the blog post use a relative link. Here's an example: `The following is an image: ![alt text](./assets/image.png 'Title image')`. 48 | 49 | If you've got some plugin to preview your markdown from your IDE, the images will be correctly displayed. Then, on CI, right before they're published, the link will be updated to match the raw file. 50 | 51 | ## How to setup CI for auto deploying the blog posts? 52 | 53 | If you want to use Github and Travis, a `.travis.yml` file has been already prepared for you. 54 | 55 | First, you have to activate the repository on Travis: https://travis-ci.org/account/repositories 56 | 57 | Then, you have to create a token on your dev.to account: https://dev.to/settings/account and set an environment variable on Travis called `DEV_TO_GIT_TOKEN` that will have the newly created token as value. 58 | 59 | # How can I manage my blog posts? Mono repo? One article per repo? 60 | 61 | It's totally up to you and you could even adopt both solutions at the same time. 62 | 63 | You can have a repo with a single blog post, for example if you're presenting a library it might make sense to have the article written within that repo. 64 | 65 | And if you prefer a mono repo approach with all your articles in the same repo, I've built a template repository to help you get started in a few minutes only: https://github.com/maxime1992/dev.to 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dev-to-git", 3 | "version": "0.0.0-development", 4 | "description": "A CLI to keep your dev.to posts in sync from a GIT project, using the CI provider of your choice", 5 | "keywords": [ 6 | "dev.to" 7 | ], 8 | "main": "bin/dev-to-git.umd.js", 9 | "module": "bin/dev-to-git.es5.js", 10 | "typings": "bin/types/dev-to-git.d.ts", 11 | "files": [ 12 | "bin", 13 | "README.md", 14 | "LICENSE", 15 | "package.json" 16 | ], 17 | "bin": { 18 | "dev-to-git": "bin/index.js" 19 | }, 20 | "author": "Maxime Robert ", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/maxime1992/dev-to-git.git" 24 | }, 25 | "license": "MIT", 26 | "engines": { 27 | "node": ">=6.0.0" 28 | }, 29 | "scripts": { 30 | "copy-index": "cp ./src/index.js ./bin", 31 | "prettier:base": "yarn run prettier \"./{src,test}/**/*.ts\" \"./**/*.{yml,md,json}\"", 32 | "prettier:fix": "yarn run prettier:base --write", 33 | "prettier:check": "yarn run prettier:base --check", 34 | "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 35 | "prebuild": "rimraf bin", 36 | "build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --out docs --target es6 --theme minimal --mode file src && yarn run copy-index", 37 | "start": "rollup -c rollup.config.ts -w", 38 | "test": "jest --coverage", 39 | "test:watch": "jest --coverage --watch", 40 | "test:prod": "npm run lint && npm run test -- --no-cache", 41 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 42 | "commit": "git-cz", 43 | "semantic-release": "semantic-release", 44 | "precommit": "lint-staged" 45 | }, 46 | "lint-staged": { 47 | "{src,test}/**/*.ts": [ 48 | "prettier --write", 49 | "git add" 50 | ] 51 | }, 52 | "config": { 53 | "commitizen": { 54 | "path": "node_modules/cz-conventional-changelog" 55 | } 56 | }, 57 | "jest": { 58 | "transform": { 59 | ".(ts|tsx)": "ts-jest" 60 | }, 61 | "testEnvironment": "node", 62 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 63 | "moduleFileExtensions": [ 64 | "ts", 65 | "tsx", 66 | "js" 67 | ], 68 | "coveragePathIgnorePatterns": [ 69 | "/node_modules/", 70 | "/test/" 71 | ], 72 | "coverageThreshold": { 73 | "global": { 74 | "branches": 90, 75 | "functions": 95, 76 | "lines": 95, 77 | "statements": 95 78 | } 79 | }, 80 | "collectCoverageFrom": [ 81 | "src/*.{js,ts}" 82 | ] 83 | }, 84 | "commitlint": { 85 | "extends": [ 86 | "@commitlint/config-conventional" 87 | ] 88 | }, 89 | "devDependencies": { 90 | "@commitlint/cli": "^7.1.2", 91 | "@commitlint/config-conventional": "^7.1.2", 92 | "@types/dotenv": "6.1.1", 93 | "@types/got": "9.6.0", 94 | "@types/jest": "^23.3.2", 95 | "@types/minimist": "1.2.0", 96 | "@types/node": "^10.11.0", 97 | "colors": "^1.3.2", 98 | "commitizen": "^3.0.0", 99 | "coveralls": "^3.0.2", 100 | "cross-env": "^5.2.0", 101 | "cz-conventional-changelog": "^2.1.0", 102 | "husky": "^1.0.1", 103 | "jest": "^23.6.0", 104 | "jest-config": "^23.6.0", 105 | "lint-staged": "^8.0.0", 106 | "lodash.camelcase": "^4.3.0", 107 | "prettier": "^1.14.3", 108 | "prompt": "^1.0.0", 109 | "replace-in-file": "^3.4.2", 110 | "rimraf": "^2.6.2", 111 | "rollup": "^0.67.0", 112 | "rollup-plugin-commonjs": "^9.1.8", 113 | "rollup-plugin-json": "^3.1.0", 114 | "rollup-plugin-node-resolve": "^3.4.0", 115 | "rollup-plugin-sourcemaps": "^0.4.2", 116 | "rollup-plugin-typescript2": "^0.18.0", 117 | "semantic-release": "17.4.4", 118 | "shelljs": "^0.8.3", 119 | "ts-jest": "^23.10.2", 120 | "ts-node": "^7.0.1", 121 | "tslint": "^5.11.0", 122 | "tslint-config-prettier": "^1.15.0", 123 | "tslint-config-standard": "^8.0.1", 124 | "typedoc": "^0.12.0", 125 | "typescript": "^3.0.3" 126 | }, 127 | "dependencies": { 128 | "chalk": "^2.4.2", 129 | "commander": "^2.20.0", 130 | "dotenv": "8.0.0", 131 | "front-matter": "3.0.2", 132 | "got": "9.6.0" 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import sourceMaps from 'rollup-plugin-sourcemaps' 4 | import camelCase from 'lodash.camelcase' 5 | import typescript from 'rollup-plugin-typescript2' 6 | import json from 'rollup-plugin-json' 7 | 8 | const pkg = require('./package.json') 9 | 10 | const libraryName = 'dev-to-git' 11 | 12 | export default { 13 | input: `src/${libraryName}.ts`, 14 | output: [ 15 | { file: pkg.main, name: camelCase(libraryName), format: 'umd', sourcemap: true }, 16 | { file: pkg.module, format: 'es', sourcemap: true }, 17 | ], 18 | // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') 19 | external: [], 20 | watch: { 21 | include: 'src/**', 22 | }, 23 | plugins: [ 24 | // Allow json resolution 25 | json(), 26 | // Compile TypeScript files 27 | typescript({ useTsconfigDeclarationDir: true }), 28 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 29 | commonjs(), 30 | // Allow node_modules resolution, so you can use 'external' to control 31 | // which external modules to include in the bundle 32 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 33 | resolve(), 34 | 35 | // Resolve source maps to the original source 36 | sourceMaps(), 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /src/article.spec.ts: -------------------------------------------------------------------------------- 1 | import { Article } from './article'; 2 | import { Repository } from './dev-to-git.interface'; 3 | 4 | describe(`Article`, () => { 5 | let article: Article; 6 | const repository: Repository = { username: `maxime1992`, name: 'dev-to-git' }; 7 | const relativePathToArticle = `./test/article.md`; 8 | 9 | beforeEach(() => { 10 | article = new Article( 11 | { 12 | id: 0, 13 | relativePathToArticle, 14 | repository, 15 | }, 16 | 'private-dev-to-token', 17 | ); 18 | }); 19 | 20 | describe(`Read`, () => { 21 | let articleRead: string; 22 | 23 | beforeEach(() => { 24 | articleRead = article.readArticleOnDisk(); 25 | }); 26 | 27 | it(`should read an article from the configuration`, () => { 28 | expect(articleRead).toContain(`This is my awesome article!`); 29 | expect(articleRead).toContain(`Hey, some text!`); 30 | }); 31 | 32 | it(`should rewrite the local images URLs to match the raw file on github`, () => { 33 | expect(articleRead).toContain( 34 | `Image 1: ![alt text 1](https://raw.githubusercontent.com/${repository.username}/${repository.name}/master/test/image-1.png 'Title image 1')`, 35 | ); 36 | 37 | expect(articleRead).toContain( 38 | `Image 2: ![alt text 2](https://raw.githubusercontent.com/${repository.username}/${repository.name}/master/test/image-2.png 'Title image 2')`, 39 | ); 40 | 41 | expect(articleRead).toContain( 42 | `Image 3: ![alt text 3](https://raw.githubusercontent.com/${repository.username}/${repository.name}/master/test/image-3.png)`, 43 | ); 44 | }); 45 | 46 | it(`should NOT rewrite absolute images URLs to match the raw file on github`, () => { 47 | expect(articleRead).toContain(`Absolute image: ![alt text](http://google.com/absolute-image.png)`); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/article.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArticleConfig, 3 | ArticleApi, 4 | ArticlePublishedStatus, 5 | UpdateStatus, 6 | ArticleApiResponse, 7 | } from './dev-to-git.interface'; 8 | import got from 'got'; 9 | import fs from 'fs'; 10 | import extractFrontMatter from 'front-matter'; 11 | 12 | interface ArticleFrontMatter { 13 | title: string; 14 | published: boolean; 15 | } 16 | 17 | const imagesRe: RegExp = /\!\[.*\]\(\.\/.*\)/g; 18 | const imageRe: RegExp = /\!\[(.*)\]\(([^ \)]*)(?: '(.*)')?\)/; 19 | 20 | const excludeArticleFromPath = (path: string): string => path.replace(/\/[^\/]+\.md$/, ''); 21 | 22 | interface ImageToReplace { 23 | localImage: string; 24 | remoteImage: string; 25 | } 26 | 27 | export class Article { 28 | // dev.to API returns a maximum of 1000 articles but would by default return only 30 29 | // https://docs.dev.to/api/#tag/articles/paths/~1articles~1me~1all/get 30 | // instead of having to manage the pagination I think it's safe to assume people using 31 | // dev-to-git won't have more than 1000 articles for now 32 | // also note that we're using a property instead of a method here so that the result is 33 | // shared/reused for all the different articles with only 1 HTTP call 34 | private articles: Promise> = got(`https://dev.to/api/articles/me/all?per_page=1000`, { 35 | json: true, 36 | method: 'GET', 37 | headers: { 'api-key': this.token }, 38 | }).then((res: got.Response) => 39 | res.body.reduce>((articlesMap, article) => { 40 | articlesMap[article.id] = article.body_markdown; 41 | return articlesMap; 42 | }, {}), 43 | ); 44 | 45 | constructor(private articleConfig: ArticleConfig, private token: string) {} 46 | 47 | private updateLocalImageLinks(article: string): string { 48 | let searchImageResult; 49 | let localImagesToReplace: ImageToReplace[] = []; 50 | 51 | // tslint:disable-next-line: no-conditional-assignment 52 | while ((searchImageResult = imagesRe.exec(article))) { 53 | const [image] = searchImageResult; 54 | 55 | const [_, alt = null, path, title = null] = imageRe.exec(image) || [null, null, null, null]; 56 | 57 | if (path) { 58 | const basePath: string = excludeArticleFromPath(this.articleConfig.relativePathToArticle.substr(2)); 59 | const assetPath = path.substr(2); 60 | 61 | localImagesToReplace.push({ 62 | localImage: image, 63 | remoteImage: `![${alt || ''}](https://raw.githubusercontent.com/${this.articleConfig.repository.username}\/${ 64 | this.articleConfig.repository.name 65 | }/master/${basePath}/${assetPath}${title ? ` '${title}'` : ``})`, 66 | }); 67 | } 68 | } 69 | 70 | return localImagesToReplace.reduce( 71 | (articleTemp, imageToReplace) => articleTemp.replace(imageToReplace.localImage, imageToReplace.remoteImage), 72 | article, 73 | ); 74 | } 75 | 76 | public readArticleOnDisk(): string { 77 | const article = fs.readFileSync(this.articleConfig.relativePathToArticle).toString(); 78 | return this.updateLocalImageLinks(article); 79 | } 80 | 81 | public async publishArticle(): Promise { 82 | const body: ArticleApi = { 83 | article: { body_markdown: this.readArticleOnDisk() }, 84 | }; 85 | 86 | let frontMatter: ArticleFrontMatter; 87 | 88 | try { 89 | frontMatter = this.extractDataFromFrontMatter(body.article.body_markdown); 90 | } catch { 91 | return Promise.resolve({ 92 | articleId: this.articleConfig.id, 93 | updateStatus: UpdateStatus.FAILED_TO_EXTRACT_FRONT_MATTER as UpdateStatus.FAILED_TO_EXTRACT_FRONT_MATTER, 94 | }); 95 | } 96 | 97 | let remoteArticleBodyMarkdown: string | null | undefined; 98 | 99 | try { 100 | const articles: Record = await this.articles; 101 | remoteArticleBodyMarkdown = articles[this.articleConfig.id] as string | null | undefined; 102 | 103 | if (remoteArticleBodyMarkdown === null || remoteArticleBodyMarkdown === undefined) { 104 | throw new Error(`Remote article body with id ${this.articleConfig.id} has not been found`); 105 | } 106 | } catch (error) { 107 | return { 108 | updateStatus: UpdateStatus.ERROR as UpdateStatus.ERROR, 109 | articleId: this.articleConfig.id, 110 | articleTitle: frontMatter.title, 111 | error, 112 | published: frontMatter.published, 113 | }; 114 | } 115 | 116 | if (remoteArticleBodyMarkdown && remoteArticleBodyMarkdown.trim() === body.article.body_markdown.trim()) { 117 | return { 118 | articleId: this.articleConfig.id, 119 | updateStatus: UpdateStatus.ALREADY_UP_TO_DATE as UpdateStatus.ALREADY_UP_TO_DATE, 120 | articleTitle: frontMatter.title, 121 | published: frontMatter.published, 122 | }; 123 | } 124 | 125 | return got(`https://dev.to/api/articles/${this.articleConfig.id}`, { 126 | json: true, 127 | method: 'PUT', 128 | headers: { 'api-key': this.token }, 129 | body, 130 | }) 131 | .then(() => ({ 132 | articleId: this.articleConfig.id, 133 | articleTitle: frontMatter.title, 134 | updateStatus: UpdateStatus.UPDATED as UpdateStatus.UPDATED, 135 | published: frontMatter.published, 136 | })) 137 | .catch(error => ({ 138 | articleId: this.articleConfig.id, 139 | articleTitle: frontMatter.title, 140 | updateStatus: UpdateStatus.ERROR as UpdateStatus.ERROR, 141 | error, 142 | published: frontMatter.published, 143 | })); 144 | } 145 | 146 | private extractDataFromFrontMatter(textArticle: string): ArticleFrontMatter { 147 | const frontMatter = extractFrontMatter(textArticle); 148 | 149 | if (!frontMatter || !frontMatter.attributes || !frontMatter.attributes.title) { 150 | throw new Error(`The article doesn't have a valid front matter`); 151 | } 152 | 153 | return { title: frontMatter.attributes.title, published: frontMatter.attributes.published || false }; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/dev-to-git.interface.ts: -------------------------------------------------------------------------------- 1 | export type Repository = { 2 | readonly username: string; 3 | readonly name: string; 4 | }; 5 | 6 | export interface ArticleConfigFile { 7 | id: number; 8 | relativePathToArticle: string; 9 | } 10 | 11 | export interface ArticleConfig extends ArticleConfigFile { 12 | repository: Repository; 13 | } 14 | 15 | // https://dev.to/api#available-json-parameters 16 | // new Dev.to update parameters https://docs.forem.com/api/#operation/updateArticle 17 | export interface ArticleApi { 18 | article: { 19 | body_markdown: string; 20 | }; 21 | } 22 | 23 | export interface ArticleApiResponse { 24 | id: number; 25 | body_markdown: string; 26 | } 27 | 28 | export enum UpdateStatus { 29 | UPDATED = 'Updated', 30 | ALREADY_UP_TO_DATE = 'AlreadyUpToDate', 31 | ERROR = 'Error', 32 | FAILED_TO_EXTRACT_FRONT_MATTER = 'FailedToExtractFrontMatter', 33 | } 34 | 35 | export interface ConfigurationOptions { 36 | silent: boolean; 37 | config: string; // the config file path 38 | devToToken: string; 39 | repository: Repository; 40 | } 41 | 42 | export type ArticlePublishedStatus = { 43 | articleId: number; 44 | } & ( 45 | | { 46 | updateStatus: UpdateStatus.FAILED_TO_EXTRACT_FRONT_MATTER; 47 | } 48 | | ({ articleTitle: string; published: boolean } & ( 49 | | { 50 | updateStatus: Exclude; 51 | } 52 | | { 53 | updateStatus: UpdateStatus.ERROR; 54 | error: Error; 55 | } 56 | ))); 57 | -------------------------------------------------------------------------------- /src/dev-to-git.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import program from 'commander'; 3 | import dotenv from 'dotenv'; 4 | import fs from 'fs'; 5 | import { Article } from './article'; 6 | import { 7 | ArticleConfig, 8 | ArticleConfigFile, 9 | ArticlePublishedStatus, 10 | ConfigurationOptions, 11 | Repository, 12 | UpdateStatus, 13 | } from './dev-to-git.interface'; 14 | import { Logger, formatArticlePublishedStatuses, logBuilder } from './helpers'; 15 | 16 | export const DEFAULT_CONFIG_PATH: string = './dev-to-git.json'; 17 | 18 | const repositoryRe: RegExp = /.*\/(.*)\/(.*)\.git/; 19 | 20 | export class DevToGit { 21 | private configuration: ConfigurationOptions; 22 | 23 | public logger: Logger; 24 | 25 | constructor() { 26 | dotenv.config(); 27 | 28 | const pkg = require('../package.json'); 29 | 30 | program 31 | .version(pkg.version) 32 | .arguments('[...files]') 33 | .option('--config ', `Pass custom path to .dev-to-git.json file`, DEFAULT_CONFIG_PATH) 34 | .option('--dev-to-token ', 'Token for publishing to dev.to', process.env.DEV_TO_GIT_TOKEN) 35 | .option('--repository-url ', 'Url of your repository you keep your articles in.') 36 | .option('--silent', `No console output`) 37 | .parse(process.argv); 38 | 39 | const configuration: ConfigurationOptions = (program as unknown) as ConfigurationOptions; 40 | this.configuration = configuration; 41 | 42 | this.logger = logBuilder(this.configuration); 43 | 44 | this.configuration.repository = this.parseRepository(program.repositoryUrl) || this.extractRepository(); 45 | 46 | if (!this.configuration.devToToken) { 47 | this.logger(chalk.red('DEV_TO_GIT_TOKEN environment variable, or --dev-to-token argument is required')); 48 | process.exit(1); 49 | } 50 | } 51 | 52 | private parseRepository(repo: string | null): Repository | null { 53 | if (!repo) { 54 | return null; 55 | } 56 | 57 | const match = repo.match(repositoryRe); 58 | 59 | if (!match) { 60 | return null; 61 | } 62 | 63 | return { 64 | username: match[1], 65 | name: match[2], 66 | }; 67 | } 68 | 69 | private extractRepository(): Repository { 70 | try { 71 | const packageJson = JSON.parse(fs.readFileSync('./package.json').toString()); 72 | 73 | const repo = this.parseRepository(packageJson.repository.url); 74 | 75 | if (!repo) { 76 | throw Error(); 77 | } 78 | 79 | return repo; 80 | } catch (error) { 81 | this.logger( 82 | chalk.red( 83 | 'If you do not specify --repository-url, you must have within your "package.json" a "repository" attribute which is an object and contains itself an attribute "url" like the following: https://github-gitlab-whatever.com/username/repository-name.git - this will be used to generate images links if necessary', 84 | ), 85 | ); 86 | throw new Error(); 87 | } 88 | } 89 | 90 | public getConfigPath(): string { 91 | return this.configuration.config; 92 | } 93 | 94 | public readConfigFile(): ArticleConfig[] { 95 | // @todo check structure of the object 96 | 97 | const articleConfigFiles: ArticleConfigFile[] = JSON.parse( 98 | fs.readFileSync(this.getConfigPath()).toString(), 99 | ) as ArticleConfigFile[]; 100 | 101 | return articleConfigFiles.map(articleConfigFile => ({ 102 | ...articleConfigFile, 103 | repository: this.configuration.repository, 104 | })); 105 | } 106 | 107 | public async publishArticles(): Promise { 108 | const articles = this.readConfigFile(); 109 | 110 | const articlePublishedStatuses = []; 111 | 112 | // instead of using Promise.all we use a for with await 113 | // to run the updates one by one to avoid hammering dev.to API 114 | // and have more risks of being rate limited 115 | for (const articleConf of articles) { 116 | const article = new Article(articleConf, this.configuration.devToToken); 117 | articlePublishedStatuses.push(await article.publishArticle()); 118 | } 119 | 120 | return articlePublishedStatuses; 121 | } 122 | } 123 | 124 | // @todo move to main file? 125 | const devToGit = new DevToGit(); 126 | devToGit 127 | .publishArticles() 128 | .then(articles => ({ articles, text: formatArticlePublishedStatuses(articles) })) 129 | .then(res => { 130 | devToGit.logger(res.text); 131 | 132 | res.articles.forEach(article => { 133 | if ( 134 | article.updateStatus === UpdateStatus.ERROR || 135 | article.updateStatus === UpdateStatus.FAILED_TO_EXTRACT_FRONT_MATTER 136 | ) { 137 | // if there's been at least one error, exit and fail 138 | process.exit(1); 139 | } 140 | }); 141 | }) 142 | .catch(error => { 143 | devToGit.logger(chalk.red(`An error occurred while publishing the articles`)); 144 | console.error(error); 145 | process.exit(1); 146 | }); 147 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ArticlePublishedStatus, ConfigurationOptions, UpdateStatus } from './dev-to-git.interface'; 2 | import chalk from 'chalk'; 3 | 4 | export const formatArticlePublishedStatuses = (articlePublishedStatuses: ArticlePublishedStatus[]): string => { 5 | return articlePublishedStatuses 6 | .map(articleStatus => { 7 | if (articleStatus.updateStatus === UpdateStatus.FAILED_TO_EXTRACT_FRONT_MATTER) { 8 | return chalk.red( 9 | `Article with ID "${articleStatus.articleId}" doesn't have a front matter correctly formatted`, 10 | ); 11 | } 12 | 13 | const baseText: string = `[${articleStatus.published ? 'PUBLISHED' : 'DRAFT'}] Article "${ 14 | articleStatus.articleTitle 15 | }" `; 16 | let text: string = ''; 17 | 18 | switch (articleStatus.updateStatus) { 19 | case UpdateStatus.ALREADY_UP_TO_DATE as UpdateStatus.ALREADY_UP_TO_DATE: 20 | text = chalk.blueBright(baseText + `is already up to date`); 21 | break; 22 | case UpdateStatus.ERROR as UpdateStatus.ERROR: 23 | text = chalk.redBright( 24 | baseText + 25 | `encountered an error:\n` + 26 | `Error name: "${articleStatus.error.name}"\n` + 27 | `Error message: "${articleStatus.error.message}"\n` + 28 | `Error stack: "${articleStatus.error.stack}"`, 29 | ); 30 | break; 31 | case UpdateStatus.UPDATED as UpdateStatus.UPDATED: 32 | if (articleStatus.published) { 33 | text = chalk.greenBright(baseText + `has been successfully updated`); 34 | } else { 35 | text = chalk.yellowBright(baseText + `has been successfully updated`); 36 | } 37 | break; 38 | 39 | default: 40 | throw new UnreachabelCase(articleStatus); 41 | } 42 | 43 | return text; 44 | }) 45 | .join(`\n`); 46 | }; 47 | 48 | class UnreachabelCase { 49 | // tslint:disable-next-line:no-empty 50 | constructor(payload: never) {} 51 | } 52 | 53 | export type Logger = (...messages: string[]) => void; 54 | 55 | export const logBuilder = (options: ConfigurationOptions): Logger => (...messages: string[]) => { 56 | if (!options.silent) { 57 | console.log(...messages); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('./dev-to-git.umd.js'); 4 | -------------------------------------------------------------------------------- /test/article.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Introducing 'Dev To Git': Publish your posts to dev.to from Github with continuous deployment" 3 | published: false 4 | description: "A new way of publishing to dev.to and keep your articles versioned" 5 | tags: devto, publication, continuousDeployment 6 | series: 7 | canonical_url: 8 | --- 9 | 10 | # This is my awesome article! 11 | 12 | ## Relative images 13 | 14 | Hey, some text! 15 | 16 | Image 1: ![alt text 1](./image-1.png 'Title image 1') 17 | 18 | Image 2: ![alt text 2](./image-2.png 'Title image 2') 19 | 20 | Image 3: ![alt text 3](./image-3.png) 21 | 22 | ## Absolute images 23 | 24 | Absolute image: ![alt text](http://google.com/absolute-image.png) 25 | -------------------------------------------------------------------------------- /test/dev-to-git.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 132750, 4 | "title": "Updated title", 5 | "description": "Great description", 6 | "published": "false", 7 | "urlToMainImage": null, 8 | "tags": ["typescript"], 9 | "relativePathToArticle": "./test/article.md", 10 | "series": null, 11 | "publishUnderOrg": false, 12 | "canonicalUrl": null 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /test/dev-to-git.test.ts: -------------------------------------------------------------------------------- 1 | import { DevToGit, DEFAULT_CONFIG_PATH } from '../src/dev-to-git'; 2 | 3 | describe(`DevToGit`, () => { 4 | beforeEach(() => { 5 | process.argv = ['don-t-care', 'don-t-care']; 6 | process.env.DEV_TO_GIT_TOKEN = 'token'; 7 | }); 8 | 9 | describe(`Config`, () => { 10 | describe(`Get config`, () => { 11 | it(`should have by default a path "./dev-to-git.json"`, () => { 12 | const devToGit = new DevToGit(); 13 | expect(devToGit.getConfigPath()).toBe(DEFAULT_CONFIG_PATH); 14 | }); 15 | 16 | it(`should accept a "config" argument to change the path to the config`, () => { 17 | const CUSTOM_CONFIG_PATH: string = './custom/dev-to-git.json'; 18 | process.argv = ['don-t-care', 'don-t-care', '--config', CUSTOM_CONFIG_PATH]; 19 | const devToGit = new DevToGit(); 20 | expect(devToGit.getConfigPath()).toBe(CUSTOM_CONFIG_PATH); 21 | }); 22 | 23 | it(`should use the default path if the "config" flag is passed without nothing`, () => { 24 | process.argv = ['don-t-care', 'don-t-care', '--config']; 25 | const devToGit = new DevToGit(); 26 | expect(devToGit.getConfigPath()).toBe(DEFAULT_CONFIG_PATH); 27 | }); 28 | }); 29 | 30 | describe(`Read config from file`, () => { 31 | it(`test`, () => { 32 | process.argv = ['don-t-care', 'don-t-care', '--config', './test/dev-to-git.json']; 33 | 34 | const devToGit = new DevToGit(); 35 | 36 | expect(devToGit.readConfigFile()).toEqual(require('./dev-to-git.json')); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "declarationDir": "bin/types", 14 | "outDir": "bin/lib", 15 | "typeRoots": ["node_modules/@types"] 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-standard", "tslint-config-prettier"] 3 | } 4 | --------------------------------------------------------------------------------