├── .vscode └── settings.json ├── db └── secrets.db ├── assets ├── dev-1.png ├── dev-2.png ├── dev-3.png ├── Medium-1.png ├── Medium-2.png ├── Medium-3.png ├── Hashnode-1.png ├── Hashnode-2.png └── Hashnode-3.png ├── src ├── commands │ ├── config │ │ ├── reset │ │ │ ├── reset-dev.js │ │ │ ├── reset-medium.js │ │ │ ├── reset-cloudinary.js │ │ │ ├── reset-hashnode.js │ │ │ └── reset-all.js │ │ ├── config-reset.js │ │ ├── config-dev.js │ │ ├── config-selector.js │ │ ├── config-imageSelector.js │ │ ├── config-titleSelector.js │ │ ├── config-cloudinary.js │ │ ├── config-medium.js │ │ └── config-hashnode.js │ ├── platforms │ │ ├── cloudinary │ │ │ └── index.js │ │ ├── medium │ │ │ └── index.js │ │ ├── dev │ │ │ └── index.js │ │ └── hashnode │ │ │ └── index.js │ └── run.js ├── utils.js └── config-store.js ├── .eslintrc.js ├── .github └── FUNDING.yml ├── LICENSE ├── package.json ├── .gitignore ├── index.js └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Cloudinary" 4 | ] 5 | } -------------------------------------------------------------------------------- /db/secrets.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/db/secrets.db -------------------------------------------------------------------------------- /assets/dev-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/dev-1.png -------------------------------------------------------------------------------- /assets/dev-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/dev-2.png -------------------------------------------------------------------------------- /assets/dev-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/dev-3.png -------------------------------------------------------------------------------- /assets/Medium-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/Medium-1.png -------------------------------------------------------------------------------- /assets/Medium-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/Medium-2.png -------------------------------------------------------------------------------- /assets/Medium-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/Medium-3.png -------------------------------------------------------------------------------- /assets/Hashnode-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/Hashnode-1.png -------------------------------------------------------------------------------- /assets/Hashnode-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/Hashnode-2.png -------------------------------------------------------------------------------- /assets/Hashnode-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahednasser/cross-post/HEAD/assets/Hashnode-3.png -------------------------------------------------------------------------------- /src/commands/config/reset/reset-dev.js: -------------------------------------------------------------------------------- 1 | const configstore = require('../../../config-store'); 2 | const { displaySuccess } = require('../../../utils'); 3 | 4 | configstore.reset('dev'); 5 | console.log(displaySuccess('dev.to configuration has been reset successfully')); 6 | -------------------------------------------------------------------------------- /src/commands/config/reset/reset-medium.js: -------------------------------------------------------------------------------- 1 | const configstore = require('../../../config-store'); 2 | const { displaySuccess } = require('../../../utils'); 3 | 4 | configstore.reset('medium'); 5 | console.log(displaySuccess('medium.com configuration has been reset successfully')); 6 | -------------------------------------------------------------------------------- /src/commands/config/reset/reset-cloudinary.js: -------------------------------------------------------------------------------- 1 | const configstore = require('../../../config-store'); 2 | const { displaySuccess } = require('../../../utils'); 3 | 4 | configstore.reset('cloudinary'); 5 | console.log(displaySuccess('cloudinary configuration has been reset successfully')); 6 | -------------------------------------------------------------------------------- /src/commands/config/reset/reset-hashnode.js: -------------------------------------------------------------------------------- 1 | const configstore = require('../../../config-store'); 2 | const { displaySuccess } = require('../../../utils'); 3 | 4 | configstore.reset('hashnode'); 5 | console.log(displaySuccess('hashnode.com configuration has been reset successfully')); 6 | -------------------------------------------------------------------------------- /src/commands/config/reset/reset-all.js: -------------------------------------------------------------------------------- 1 | const configstore = require('../../../config-store'); 2 | const { displaySuccess } = require('../../../utils'); 3 | 4 | configstore.reset('imageSelector'); 5 | configstore.reset('titleSelector'); 6 | configstore.reset('selector'); 7 | console.log(displaySuccess('all non-platform configurations have been reset successfully')); 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 'latest', 12 | }, 13 | rules: { 14 | 'no-console': 'off', 15 | 'default-case': 'off', 16 | 'no-prototype-builtins': 'off', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.buymeacoffee.com/shahednasser'] 14 | -------------------------------------------------------------------------------- /src/commands/config/config-reset.js: -------------------------------------------------------------------------------- 1 | const { Command } = require('commander'); 2 | 3 | const reset = new Command(); 4 | 5 | reset.name('reset') 6 | .description('reset configuration for a given platform(s)') 7 | .addHelpText('after', ` 8 | Example: 9 | # to reset dev.to configuration 10 | $ cross-post config reset dev 11 | 12 | # to reset all non-platform configuration 13 | $ cross-post config reset all 14 | (or just) 15 | $ cross-post config reset 16 | `) 17 | .executableDir('./reset') 18 | .command('dev', 'reset configuration for dev.to') 19 | .command('medium', 'reset configuration for medium.com') 20 | .command('hashnode', 'reset configuration for hashnode.com') 21 | .command('cloudinary', 'reset configuration for cloudinary') 22 | .command('all', 'reset all *non-platform* configuration', { isDefault: true }); 23 | 24 | reset.parse(); 25 | -------------------------------------------------------------------------------- /src/commands/config/config-dev.js: -------------------------------------------------------------------------------- 1 | // /* eslint-disable no-param-reassign */ 2 | // /* eslint-disable no-underscore-dangle */ 3 | const inquirer = require('inquirer'); 4 | const configstore = require('../../config-store'); 5 | const { displayInfo, displayError, displaySuccess } = require('../../utils'); 6 | 7 | inquirer 8 | .prompt([ 9 | { 10 | name: 'apiKey', 11 | message: displayInfo('Enter dev.to API key'), 12 | }, 13 | ]) 14 | .then((value) => { 15 | if (!value.hasOwnProperty('apiKey')) { 16 | console.error(displayError('API key is required')); 17 | } 18 | 19 | // store api key 20 | configstore.set('dev', value); 21 | 22 | console.log(displaySuccess('Configuration saved successfully')); 23 | }) 24 | .catch((err) => { 25 | console.error(err); 26 | console.error(displayError('An error occurred, please try again later.')); 27 | }); 28 | -------------------------------------------------------------------------------- /src/commands/platforms/cloudinary/index.js: -------------------------------------------------------------------------------- 1 | const cloudinary = require('cloudinary').v2; 2 | const Conf = require('conf'); 3 | const { imagePlatform } = require('../../../utils'); 4 | 5 | const configstore = new Conf(); 6 | const keys = configstore.get(imagePlatform); 7 | 8 | async function uploadToCloudinary(image) { 9 | if (!keys) { 10 | throw new Error('In order to process Data URI images, the image needs to be uploaded to Cloudinary' 11 | + ' to obtain a valid URL, then the image is deleted after the upload. If you wish to do that, please' 12 | + ' create a Cloudinary account and obtain the keys necessary. You can also skip uploading the image with the' 13 | + ' article by passing the option --ignore-image'); 14 | } 15 | cloudinary.config(keys); 16 | return cloudinary.uploader.upload(image, { return_delete_token: true }); 17 | } 18 | 19 | module.exports = uploadToCloudinary; 20 | -------------------------------------------------------------------------------- /src/commands/config/config-selector.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-underscore-dangle */ 3 | const inquirer = require('inquirer'); 4 | const configstore = require('../../config-store'); 5 | const { displayError, displayInfo, displaySuccess } = require('../../utils'); 6 | 7 | inquirer 8 | .prompt([ 9 | { 10 | name: 'selector', 11 | message: displayInfo('Enter default selector'), 12 | }, 13 | ]) 14 | .then((value) => { 15 | if (!value.hasOwnProperty('selector')) { 16 | console.error(displayError('Selector is required')); 17 | } 18 | 19 | // store api key 20 | configstore.set('selector', value.selector); 21 | 22 | console.log(displaySuccess('Configuration saved successfully')); 23 | }) 24 | .catch((err) => { 25 | console.error(err); 26 | console.error(displayError('An error occurred, please try again later.')); 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | const allowedPlatforms = ['dev', 'hashnode', 'medium']; 4 | 5 | module.exports = { 6 | allowedPlatforms, 7 | displayError: chalk.bold.red, 8 | displaySuccess: chalk.bold.green, 9 | displayInfo: chalk.bold.blue, 10 | isPlatformAllowed(platform, config = false) { 11 | if (config) { 12 | return allowedPlatforms.includes(platform) || module.exports.imagePlatform === platform 13 | || platform === 'imageSelector' || platform === 'selector'; 14 | } 15 | return allowedPlatforms.includes(platform); 16 | }, 17 | platformNotAllowedMessage: `Platforms specified are not all allowed. Allowed platform values are: ${allowedPlatforms.join(', ')}`, 18 | isDataURL(s) { 19 | const regex = /^data:((?:\w+\/(?:(?!;).)+)?)((?:;[\w\W]*?[^;])*),(.+)$/i; 20 | return !!s.match(regex); 21 | }, 22 | imagePlatform: 'cloudinary', 23 | }; 24 | -------------------------------------------------------------------------------- /src/commands/config/config-imageSelector.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-underscore-dangle */ 3 | const inquirer = require('inquirer'); 4 | const configstore = require('../../config-store'); 5 | const { displayError, displayInfo, displaySuccess } = require('../../utils'); 6 | 7 | inquirer 8 | .prompt([ 9 | { 10 | name: 'imageSelector', 11 | message: displayInfo('Enter default image selector'), 12 | }, 13 | ]) 14 | .then((value) => { 15 | if (!value.hasOwnProperty('imageSelector')) { 16 | console.error(displayError('Image selector is required')); 17 | } 18 | 19 | // store api key 20 | configstore.set('imageSelector', value.imageSelector); 21 | 22 | console.log(displaySuccess('Configuration saved successfully')); 23 | }) 24 | .catch((err) => { 25 | console.error(err); 26 | console.error(displayError('An error occurred, please try again later.')); 27 | }); 28 | -------------------------------------------------------------------------------- /src/commands/config/config-titleSelector.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-underscore-dangle */ 3 | const inquirer = require('inquirer'); 4 | const configstore = require('../../config-store'); 5 | const { displayInfo, displayError, displaySuccess } = require('../../utils'); 6 | 7 | inquirer 8 | .prompt([ 9 | { 10 | name: 'titleSelector', 11 | message: displayInfo('Enter default title selector'), 12 | }, 13 | ]) 14 | .then((value) => { 15 | if (!value.hasOwnProperty('titleSelector')) { 16 | console.error(displayError('Title selector is required')); 17 | } 18 | 19 | // store api key 20 | configstore.set('titleSelector', value.titleSelector); 21 | 22 | console.log(displaySuccess('Configuration saved successfully')); 23 | }) 24 | .catch((err) => { 25 | console.error(err); 26 | console.error(displayError('An error occurred, please try again later.')); 27 | }); 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shahed Nasser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cross-post-blog", 3 | "version": "1.5.4", 4 | "description": "Cross post a blog to multiple websites", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/shahednasser/cross-post.git" 12 | }, 13 | "author": "Shahed Nasser", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/shahednasser/cross-post/issues" 17 | }, 18 | "homepage": "https://github.com/shahednasser/cross-post#readme", 19 | "dependencies": { 20 | "axios": "^0.21.4", 21 | "canvas": "^2.9.0", 22 | "chalk": "^4.1.0", 23 | "cloudinary": "^1.28.1", 24 | "clui": "^0.3.6", 25 | "commander": "^9.0.0", 26 | "conf": "^9.0.2", 27 | "form-data": "^4.0.0", 28 | "got": "^11.8.3", 29 | "htmlparser2": "^8.0.1", 30 | "image-data-uri": "^2.0.1", 31 | "inquirer": "^8.2.0", 32 | "jsdom": "^16.5.1", 33 | "marked": "^4.0.12", 34 | "turndown": "^7.1.1", 35 | "url": "^0.11.0" 36 | }, 37 | "bin": { 38 | "cross-post": "index.js" 39 | }, 40 | "files": [ 41 | "index.js", 42 | "src" 43 | ], 44 | "devDependencies": { 45 | "eslint": "^8.9.0", 46 | "eslint-config-airbnb-base": "^15.0.0", 47 | "eslint-plugin-import": "^2.25.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/commands/config/config-cloudinary.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-underscore-dangle */ 3 | const inquirer = require('inquirer'); 4 | const { displayError, displayInfo, displaySuccess } = require('../../utils'); 5 | const configstore = require('../../config-store'); 6 | 7 | inquirer 8 | .prompt([ 9 | { 10 | name: 'cloud_name', 11 | message: displayInfo('Enter cloud name'), 12 | }, 13 | { 14 | name: 'api_key', 15 | message: displayInfo('Enter API key'), 16 | }, 17 | { 18 | name: 'api_secret', 19 | message: displayInfo('Enter API secret'), 20 | }, 21 | ]) 22 | .then((value) => { 23 | if (!value.hasOwnProperty('cloud_name')) { 24 | console.error(displayError('Cloud name is required')); 25 | } 26 | 27 | if (!value.hasOwnProperty('api_key')) { 28 | console.error(displayError('API key is required')); 29 | } 30 | 31 | if (!value.hasOwnProperty('api_secret')) { 32 | console.error(displayError('API secret is required')); 33 | } 34 | 35 | // store keys 36 | configstore.set('cloudinary', value); 37 | 38 | console.log(displaySuccess('Configuration saved successfully')); 39 | }) 40 | .catch((err) => { 41 | console.error(err); 42 | console.error(displayError('An error occurred, please try again later.')); 43 | }); 44 | -------------------------------------------------------------------------------- /src/commands/config/config-medium.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-underscore-dangle */ 3 | const inquirer = require('inquirer'); 4 | const { default: axios } = require('axios'); 5 | const { displayError, displayInfo, displaySuccess } = require('../../utils'); 6 | const configstore = require('../../config-store'); 7 | 8 | inquirer 9 | .prompt([ 10 | { 11 | name: 'integrationToken', 12 | message: displayInfo('Enter medium.com Integration Token'), 13 | }, 14 | ]) 15 | .then((value) => { 16 | if (!value.hasOwnProperty('integrationToken')) { 17 | console.error(displayError('Integration token is required')); 18 | } 19 | 20 | // get user informations to get authorId 21 | axios 22 | .get('https://api.medium.com/v1/me', { 23 | headers: { 24 | Authorization: `Bearer ${value.integrationToken}`, 25 | }, 26 | }) 27 | .then((res) => { 28 | if (res.data.data.id) { 29 | value.authorId = res.data.data.id; 30 | } 31 | 32 | configstore.set('medium', value); 33 | console.log(displaySuccess('Configuration saved successfully')); 34 | }) 35 | .catch(() => { 36 | console.error( 37 | displayError('An error occurred, please try again later.'), 38 | ); 39 | }); 40 | }) 41 | .catch((err) => { 42 | console.error(err); 43 | console.error(displayError('An error occurred, please try again later.')); 44 | }); 45 | -------------------------------------------------------------------------------- /src/commands/platforms/medium/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const Conf = require('conf'); 3 | 4 | const configstore = new Conf(); 5 | 6 | /** 7 | * Post article to Medium 8 | * 9 | * @param {string} title Title of article 10 | * @param {string} content Content of article in markdown 11 | * @param {string} canonicalUrl URL of original article 12 | * @param {boolean} p Whether to publish article publicly or not 13 | * @param {null|Function} cb callback function to run after posting is finished 14 | */ 15 | function postToMedium(title, content, canonicalUrl, p, cb = null) { 16 | const mediumConfig = configstore.get('medium'); 17 | return axios.post(`https://api.medium.com/v1/users/${mediumConfig.authorId}/posts`, { 18 | title, 19 | contentFormat: 'markdown', 20 | content, 21 | canonicalUrl, 22 | publishStatus: p ? 'public' : 'draft', 23 | }, { 24 | headers: { 25 | Authorization: `Bearer ${mediumConfig.integrationToken}`, 26 | }, 27 | }) 28 | .then((res) => { 29 | if (cb) { 30 | cb({ 31 | success: true, 32 | url: res.data.data.url, 33 | platform: 'Medium', 34 | public: p, 35 | }); 36 | } 37 | }) 38 | .catch((res) => { 39 | if (cb) { 40 | cb({ 41 | success: false, 42 | }); 43 | } 44 | 45 | if (res.data) { 46 | throw new Error(`Error occured while cross posting to Medium: ${res.data}`); 47 | } else { 48 | throw new Error('An error occurred, please try again later.'); 49 | } 50 | }); 51 | } 52 | 53 | module.exports = postToMedium; 54 | -------------------------------------------------------------------------------- /src/commands/platforms/dev/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const Conf = require('conf'); 3 | 4 | const configstore = new Conf(); 5 | 6 | /** 7 | * Post article to dev.to 8 | * 9 | * @param {string} title Title of article 10 | * @param {string} bodyMarkdown Content of the article in markdown 11 | * @param {string} canonicalUrl URL of original article 12 | * @param {string} mainImage Cover image URL 13 | * @param {boolean} published whether to publish as draft or public 14 | * @param {null|Function} cb callback function to run after posting is finished 15 | */ 16 | function postToDev(title, bodyMarkdown, canonicalUrl, mainImage, published, cb = null) { 17 | const article = { 18 | title, 19 | published, 20 | body_markdown: bodyMarkdown, 21 | canonical_url: canonicalUrl, 22 | }; 23 | if (mainImage) { 24 | article.main_image = mainImage; 25 | } 26 | // send article to DEV.to 27 | return axios.post( 28 | 'https://dev.to/api/articles', 29 | { 30 | article, 31 | }, 32 | { 33 | headers: { 34 | 'api-key': configstore.get('dev').apiKey, 35 | }, 36 | }, 37 | ).then((devReponse) => { 38 | if (cb) { 39 | cb({ 40 | success: true, url: devReponse.data.url + (published ? '' : '/edit'), platform: 'DEV', public: published, 41 | }); 42 | } 43 | }).catch((err) => { 44 | if (cb) { 45 | cb({ success: false }); 46 | } 47 | throw new Error(err.response ? `Error occured while cross posting to DEV: ${err.response.data.error}` 48 | : 'An error occurred, please try again later'); 49 | }); 50 | } 51 | 52 | module.exports = postToDev; 53 | -------------------------------------------------------------------------------- /src/config-store.js: -------------------------------------------------------------------------------- 1 | const Conf = require('conf'); 2 | 3 | const CONFIG_SCHEMA = { 4 | dev: { 5 | type: 'object', 6 | properties: { 7 | apiKey: { 8 | type: 'string', 9 | default: '', 10 | }, 11 | }, 12 | }, 13 | hashnode: { 14 | type: 'object', 15 | properties: { 16 | apiKey: { 17 | type: 'string', 18 | default: '', 19 | }, 20 | username: { 21 | type: 'string', 22 | default: '', 23 | }, 24 | }, 25 | }, 26 | medium: { 27 | type: 'object', 28 | properties: { 29 | integrationToken: { 30 | type: 'string', 31 | default: '', 32 | }, 33 | authorId: { 34 | type: 'string', 35 | default: '', 36 | }, 37 | }, 38 | }, 39 | cloudinary: { 40 | type: 'object', 41 | properties: { 42 | cloud_name: { 43 | type: 'string', 44 | default: '', 45 | }, 46 | api_key: { 47 | type: 'string', 48 | default: '', 49 | }, 50 | api_secret: { 51 | type: 'string', 52 | default: '', 53 | }, 54 | }, 55 | }, 56 | imageSelector: { 57 | type: 'string', 58 | default: '', 59 | }, 60 | titleSelector: { 61 | type: 'string', 62 | default: '', 63 | }, 64 | selector: { 65 | type: 'string', 66 | default: '', 67 | }, 68 | }; 69 | const CONFIG_DEFAULTS = { 70 | dev: { 71 | apiKey: '', 72 | }, 73 | hashnode: { 74 | apiKey: '', 75 | username: '', 76 | }, 77 | medium: { 78 | integrationToken: '', 79 | authorId: '', 80 | }, 81 | cloudinary: { 82 | cloud_name: '', 83 | api_key: '', 84 | api_secret: '', 85 | }, 86 | imageSelector: '', 87 | titleSelector: '', 88 | selector: '', 89 | }; 90 | const configstore = new Conf({ schema: CONFIG_SCHEMA, defaults: CONFIG_DEFAULTS }); 91 | module.exports = configstore; 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | const path = require('path'); 3 | const { program } = require('commander'); 4 | const run = require('./src/commands/run'); 5 | const { allowedPlatforms } = require('./src/utils'); 6 | 7 | program.usage('[command] [options]'); 8 | 9 | program 10 | .command('run ') 11 | .description('Cross post a blog post') 12 | .option('-l, --local', 'Use if the you want to directly post a local Markdown file. in this case should be the path to the file') 13 | .option('-t, --title [title]', 'Title for the article') 14 | .option('-p, --platforms [platforms...]', `Platforms to post articles to. Allowed values are: ${allowedPlatforms.join(', ')}`) 15 | .option('-s, --selector [selector]', 'The selector to look for in the document in the URL supplied. By default, it will be article. ' 16 | + 'This will override the selector set in the config.') 17 | .option('-pu, --public', 'Publish it publically instead of to drafts by default.') 18 | .option('-i, --ignore-image', 'Ignore uploading image with the article. This helps mitigate errors when uploading images') 19 | .option('-is, --image-selector [imageSelector]', 'By default, article images will be the first image detected in the article. This ' 20 | + 'allows you to specify the selector of the image to be used instead. This will override the selector set in the config.') 21 | .option('-ts, --title-selector [titleSelector]', 'By default, the article title is the first heading detected. This will allow ' 22 | + 'you to change the default selector. This will override the selector set in the config.') 23 | .option('-iu, --image-url [imageUrl]', 'URL of image to use for the article\'s main image.') 24 | .action(run); 25 | 26 | program 27 | .command('config') 28 | .description(`Add configuration for a platform or other options. Allowed values are: ${allowedPlatforms.join(', ')}`) 29 | .executableDir(path.join(__dirname, './src/commands/config')) 30 | .command('dev', 'configure for dev.to platform') 31 | .command('medium', 'configure for medium.com platform') 32 | .command('hashnode', 'configure for hashnode.com platform') 33 | .command('cloudinary', 'configure for cloudinary platform') 34 | .command('imageSelector', 'set article hero image selector CSS rule') 35 | .command('titleSelector', 'set article title selector CSS rule') 36 | .command('selector', 'set article selector CSS rule (for articles retrieved from URL)') 37 | .command('reset', 'reset configuration for a given platform(s)'); 38 | 39 | program.parse(); 40 | -------------------------------------------------------------------------------- /src/commands/config/config-hashnode.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-underscore-dangle */ 3 | const inquirer = require('inquirer'); 4 | const { default: axios } = require('axios'); 5 | const { displayError, displayInfo, displaySuccess } = require('../../utils'); 6 | const configstore = require('../../config-store'); 7 | 8 | inquirer 9 | .prompt([ 10 | { 11 | name: 'apiKey', 12 | message: displayInfo('Enter hashnode.com API key'), 13 | }, 14 | { 15 | name: 'username', 16 | message: displayInfo('Enter username to get publication ID'), 17 | }, 18 | ]) 19 | .then((value) => { 20 | if (!value.hasOwnProperty('apiKey')) { 21 | console.error(displayError('API key is required')); 22 | } 23 | 24 | if (value.username) { 25 | // get the publication id of the user to use it for creating publications 26 | axios 27 | .post( 28 | 'https://api.hashnode.com', 29 | { 30 | query: ` 31 | query user($username: String!) { 32 | user(username: $username) { 33 | publication { 34 | _id 35 | } 36 | } 37 | } 38 | `, 39 | variables: { 40 | username: value.username, 41 | }, 42 | }, 43 | { 44 | headers: { 45 | Authorization: value.apiKey, 46 | }, 47 | }, 48 | ) 49 | .then((res) => { 50 | if (res.data.errors) { 51 | console.error( 52 | displayError( 53 | `An error occured while fetching publication Id: ${res.data.errors[0].message}`, 54 | ), 55 | ); 56 | } else { 57 | value.publicationId = res.data.data.user.publication._id; 58 | delete value.username; 59 | configstore.set('hashnode', value); 60 | console.log(displaySuccess('Configuration saved successfully')); 61 | } 62 | }) 63 | .catch((err) => { 64 | console.error( 65 | displayError( 66 | `An error occured while fetching publication Id: ${err.response.data.errors[0].message}`, 67 | ), 68 | ); 69 | }); 70 | } else { 71 | console.error(displayError('Username is required')); 72 | } 73 | }) 74 | .catch(() => { 75 | console.error(displayError('An error occurred, please try again later.')); 76 | }); 77 | -------------------------------------------------------------------------------- /src/commands/platforms/hashnode/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const Conf = require('conf'); 3 | 4 | const configstore = new Conf(); 5 | 6 | /** 7 | * Post article to Hashnode 8 | * 9 | * @param {string} title Title of article 10 | * @param {string} contentMarkdown Content of article in Markdown 11 | * @param {string} originalArticleURL URL of original article 12 | * @param {string} coverImageURL URL of cover image 13 | * @param {boolean} hideFromHashnodeFeed Whether to post it publically or not 14 | * @param {null|Function} cb callback function to run after posting is finished 15 | */ 16 | function postToHashnode( 17 | title, 18 | contentMarkdown, 19 | originalArticleURL, 20 | coverImageURL, 21 | hideFromHashnodeFeed, 22 | cb = null, 23 | ) { 24 | const configData = configstore.get('hashnode'); 25 | const data = { 26 | input: { 27 | title, 28 | contentMarkdown, 29 | isPartOfPublication: { publicationId: configData.publicationId }, 30 | tags: [], 31 | }, 32 | publicationId: configData.publicationId, 33 | hideFromHashnodeFeed, 34 | }; 35 | if (originalArticleURL) { 36 | data.isRepublished = { 37 | originalArticleURL, 38 | }; 39 | } 40 | if (coverImageURL) { 41 | data.input.coverImageURL = coverImageURL; 42 | } 43 | return axios.post('https://api.hashnode.com', { 44 | query: 'mutation createPublicationStory($input: CreateStoryInput!, $publicationId: String!){ createPublicationStory(input: $input, publicationId: $publicationId){ post { slug, publication { domain } } } }', 45 | variables: data, 46 | }, { 47 | headers: { 48 | Authorization: configData.apiKey, 49 | }, 50 | }) 51 | .then((res) => { 52 | if (res.data.errors) { 53 | throw new Error(`Error occured while cross posting to Hashnode: ${res.data.errors[0].message}`); 54 | } else { 55 | const { post } = res.data.data.createPublicationStory; 56 | const postUrl = `${post.publication.domain ? post.publication.domain : ''}/${post.slug}`; 57 | 58 | if (cb) { 59 | cb({ 60 | success: true, 61 | url: postUrl, 62 | platform: 'Hashnode', 63 | public: !hideFromHashnodeFeed, 64 | }); 65 | } 66 | } 67 | }) 68 | .catch((err) => { 69 | if (cb) { 70 | cb({ success: false }); 71 | } 72 | 73 | if (err.response) { 74 | throw new Error(`Error occured while cross posting to Hashnode: ${err.response.data.errors[0].message}`); 75 | } else { 76 | throw new Error(err); 77 | } 78 | }); 79 | } 80 | 81 | module.exports = postToHashnode; 82 | -------------------------------------------------------------------------------- /src/commands/run.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const process = require('process'); 4 | const Conf = require('conf'); 5 | const got = require('got'); 6 | const jsdom = require('jsdom'); 7 | const htmlparser2 = require('htmlparser2'); 8 | const { URLSearchParams } = require('url'); 9 | const { marked } = require('marked'); 10 | 11 | const { JSDOM } = jsdom; 12 | 13 | const TurndownService = require('turndown'); 14 | const CLI = require('clui'); 15 | 16 | const turndownService = new TurndownService({ 17 | codeBlockStyle: 'fenced', 18 | }); 19 | const { Spinner } = CLI; 20 | const { 21 | allowedPlatforms, 22 | displayError, 23 | displaySuccess, 24 | isPlatformAllowed, 25 | platformNotAllowedMessage, 26 | isDataURL, 27 | } = require('../utils'); 28 | const postToDev = require('./platforms/dev'); 29 | const postToHashnode = require('./platforms/hashnode'); 30 | const postToMedium = require('./platforms/medium'); 31 | const uploadToCloudinary = require('./platforms/cloudinary'); 32 | 33 | const configstore = new Conf(); 34 | const loading = new Spinner('Processing URL...'); 35 | 36 | let platformsPosted = 0; // incremental count of platforms the article is posted on 37 | let chosenPlatforms = allowedPlatforms; // the platforms chosen, defaults to all platforms 38 | 39 | /** 40 | * 41 | * @param {string} type Type to search for. Can be either title or 42 | * @param {HTMLElement} node element to search in 43 | * @returns 44 | */ 45 | function search(type, node) { 46 | if ((type === 'title' && (node.tagName === 'H1' || node.tagName === 'H2' || node.tagName === 'H3' 47 | || node.tagName === 'H4' || node.tagName === 'H5' || node.tagName === 'H6')) 48 | || (type === 'image' && node.tagName === 'IMG')) { 49 | return type === 'title' ? node.textContent : node.getAttribute('src'); 50 | } 51 | 52 | if (node.childNodes && node.childNodes.length) { 53 | const { childNodes } = node; 54 | for (let i = 0; i < childNodes.length; i += 1) { 55 | const title = search(type, childNodes.item(i)); 56 | if (title) { 57 | return title; 58 | } 59 | } 60 | } 61 | 62 | return null; 63 | } 64 | 65 | /** 66 | * 67 | * @param {*} url the string that has provided by the user 68 | * @returns 69 | */ 70 | async function getImageForHashnode(url) { 71 | try { 72 | const response = await got(url); 73 | let count = 0, imageUrl; 74 | const parser = new htmlparser2.Parser({ 75 | onopentag: function(name, attribs) { 76 | if (name === 'img' && attribs.src && attribs.src.includes('/_next/image')) { 77 | count += 1; 78 | if (count === 2) { 79 | imageUrl = attribs.src; 80 | } 81 | } 82 | }, 83 | }); 84 | parser.write(response.body); 85 | parser.end(); 86 | return imageUrl; 87 | } catch (error) { 88 | //pass 89 | } 90 | } 91 | 92 | /** 93 | * 94 | * @param {string} err Error message to display 95 | */ 96 | function handleError(err) { 97 | loading.stop(); 98 | console.error(displayError(err)); 99 | } 100 | 101 | /** 102 | * If the number of platforms posted on is complete stop the loading 103 | * @param {boolean} success whether it was successful or not 104 | */ 105 | function checkIfShouldStopLoading(success) { 106 | if (platformsPosted === chosenPlatforms.length) { 107 | loading.stop(); 108 | if (success) { 109 | process.exit(); 110 | } 111 | } 112 | } 113 | 114 | /** 115 | * Function to run after posting on a platform is done 116 | */ 117 | function afterPost({ 118 | success, url = '', platform = '', p = false, 119 | }) { 120 | if (success) { 121 | console.log( 122 | displaySuccess(`Article ${p ? 'published' : 'added to drafts'} on ${platform} at ${url}`), 123 | ); 124 | } 125 | platformsPosted += 1; 126 | checkIfShouldStopLoading(success); 127 | } 128 | 129 | function postToPlatforms(title, markdown, url, image, p) { 130 | chosenPlatforms.forEach((platform) => { 131 | switch (platform) { 132 | case 'dev': 133 | loading.message('Posting article to dev.to...'); 134 | postToDev(title, markdown, url, image, p, afterPost) 135 | .catch(handleError); 136 | break; 137 | case 'hashnode': 138 | loading.message('Posting article to Hashnode...'); 139 | postToHashnode(title, markdown, url, image, p, afterPost) 140 | .catch(handleError); 141 | break; 142 | case 'medium': 143 | loading.message('Posting article to Medium...'); 144 | postToMedium(title, markdown, url, p, afterPost) 145 | .catch(handleError); 146 | break; 147 | default: 148 | break; 149 | } 150 | }); 151 | } 152 | 153 | /** 154 | * 155 | * @param {string} url URL of the blog post 156 | * @param {object} param1 The parameters from the command line 157 | */ 158 | async function run(url, options) { 159 | let { 160 | title, selector, imageSelector, titleSelector, 161 | } = options; 162 | 163 | const { 164 | platforms, 165 | ignoreImage, 166 | imageUrl, 167 | local, 168 | public: p = false, 169 | } = options; 170 | 171 | // check if all platforms chosen are correct 172 | if (platforms) { 173 | const error = platforms.some((platform) => !isPlatformAllowed(platform)); 174 | if (error) { 175 | // if some of the platforms are not correct, return 176 | console.error( 177 | displayError(platformNotAllowedMessage), 178 | ); 179 | return; 180 | } 181 | // set chosen platforms to platforms chosen 182 | chosenPlatforms = platforms; 183 | } 184 | 185 | // check if configurations exist for the platforms 186 | const errorPlatform = chosenPlatforms.find((platform) => { 187 | if (!configstore.get(platform)) { 188 | return true; 189 | } 190 | return false; 191 | }); 192 | 193 | if (errorPlatform) { 194 | console.error( 195 | displayError(`Please set the configurations required for ${errorPlatform}`), 196 | ); 197 | return; 198 | } 199 | 200 | if (!selector) { 201 | // check if a default selector is set in the configurations 202 | selector = configstore.get('selector'); 203 | if (!selector) { 204 | selector = 'article'; // default value if no selector is supplied 205 | } 206 | } 207 | 208 | // start loading 209 | loading.start(); 210 | let articleContent; 211 | let markdown = ''; 212 | try { 213 | if (local) { 214 | // publish from a local file 215 | const filePath = path.resolve(process.cwd(), url); 216 | if (path.extname(filePath).toLowerCase().indexOf('md') === -1) { 217 | handleError('File extension not allowed. Only markdown files are accepted'); 218 | return; 219 | } 220 | markdown = fs.readFileSync(filePath, 'utf-8'); 221 | articleContent = marked.parse(markdown); 222 | } else { 223 | // publish from the web 224 | articleContent = (await got(url, { 225 | https: { 226 | rejectUnauthorized: false, 227 | }, 228 | })).body; 229 | } 230 | } catch (e) { 231 | handleError(e); 232 | return; 233 | } 234 | 235 | const dom = new JSDOM(articleContent, { 236 | resources: 'usable', 237 | includeNodeLocations: true, 238 | }); 239 | const articleNode = local ? dom.window.document.querySelector('body') : dom.window.document.querySelector(selector); 240 | if (articleNode) { 241 | // if article element found, get its HTML content 242 | const html = articleNode.innerHTML; 243 | if (!markdown.length && html) { 244 | markdown = turndownService.remove('style').turndown(html); 245 | } 246 | // check if title is set in the command line arguments 247 | if (!title) { 248 | if (!titleSelector) { 249 | titleSelector = configstore.get('titleSelector'); 250 | } 251 | 252 | if (titleSelector) { 253 | title = dom.window.document.querySelector(titleSelector).textContent; 254 | } 255 | 256 | if (!title) { 257 | // get title of article 258 | title = search('title', articleNode); 259 | if (!title) { 260 | title = ''; 261 | } 262 | } 263 | } 264 | let image = null; 265 | if (!ignoreImage) { 266 | if (imageUrl) { 267 | // use image url that is provided 268 | image = imageUrl; 269 | } else { 270 | // Get cover image of the article 271 | if (imageSelector) { 272 | // get image using selector specified 273 | image = dom.window.document.querySelector(imageSelector); 274 | if (image) { 275 | image = image.getAttribute('src'); 276 | } 277 | } else { 278 | // check if there's a default image selector in config 279 | imageSelector = configstore.get('imageSelector'); 280 | if (imageSelector) { 281 | image = dom.window.document.querySelector(imageSelector); 282 | if (image) { 283 | image = image.getAttribute('src'); 284 | } 285 | } else { 286 | if (url.includes("hashnode")) { 287 | await getImageForHashnode(url).then((img) => { 288 | const params = new URLSearchParams(img.split('?')[1]); 289 | image = params.get('url'); 290 | }); 291 | }else{ 292 | image = search('image') 293 | } 294 | } 295 | } 296 | // check if image is dataurl 297 | if (image && isDataURL(image)) { 298 | const res = await uploadToCloudinary(image); 299 | image = res.url; 300 | } else if (image.indexOf('/') === 0 && !local) { 301 | // get domain name of url and prepend it to the image URL 302 | const urlObject = new URL(url); 303 | image = `${urlObject.protocol}//${urlObject.hostname}${image}`; 304 | } 305 | } 306 | } 307 | 308 | postToPlatforms(title, markdown, local ? '' : url, image, p); 309 | } else { 310 | handleError('No articles found in the URL.'); 311 | } 312 | } 313 | 314 | module.exports = run; 315 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cross Post 2 | 3 | [![GitHub license](https://img.shields.io/github/license/Naereen/StrapDown.js.svg)](./LICENSE) [![npm version](https://badge.fury.io/js/cross-post-blog.svg)](https://badge.fury.io/js/cross-post-blog) 4 | 5 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/shahednasser) 6 | 7 | Easily cross post your article on dev.to, Hashnode and Medium from your terminal. 8 | 9 | - [Installation](#installation) 10 | - [Installation of MacOS with M1 chip](#installation-of-macos-with-m1-chip) 11 | - [Method 1: Rosetta Terminal](#method-1-rosetta-terminal) 12 | - [Method 2](#method-2) 13 | - [Usage](#usage) 14 | - [Set Configuration](#set-configuration) 15 | - [dev.to](#devto) 16 | - [Hashnode](#hashnode) 17 | - [Medium](#medium) 18 | - [Cross Posting Your Articles](#cross-posting-your-articles) 19 | - [Cross Posting Local Markdown Files](#cross-posting-local-markdown-files) 20 | - [Selector Configuration](#selector-configuration) 21 | - [Image Selector Configuration](#image-selector-configuration) 22 | - [Title Selector Configuration](#title-selector-configuration) 23 | - [Uploading Data URI Article Images](#uploading-data-uri-article-images) 24 | - [Using a Cloudinary account](#using-a-cloudinary-account) 25 | - [Pass Image URL](#pass-image-url) 26 | - [Post Article Without Image](#post-article-without-image) 27 | - [Reset Configuration](#reset-configuration-values) 28 | - [License](#license) 29 | 30 | ## Installation 31 | 32 | In your terminal: 33 | 34 | ```bash 35 | npm i -g cross-post-blog 36 | ``` 37 | 38 | ### Installation of MacOS with M1 chip 39 | 40 | For Apple M1, it's best to have Node v14. 41 | 42 | There are two ways to install this package on a MacOS with M1 chip: 43 | 44 | #### Method 1: Rosetta Terminal 45 | 46 | 1. If you don't have a Rosetta Terminal, go to Finder, then in the menu bar go to Go > Utilities. Duplicate "Terminal" and rename it to "Rosetta Terminal" or anything you want. Then click on the duplicate you create it and press "command + I" and choose "Open using Rosetta". 47 | 2. Open the Rosetta Terminal you created, uninstall and then install Node again. 48 | 3. Install this package again. 49 | 50 | #### Method 2 51 | 52 | 1. In the terminal run: arch -arm64 brew install pkg-config cairo pango libpng jpeg giflib librsvg 53 | 2. Try installing this package again. 54 | 55 | You might also need to add the following to `~/.zshrc`: 56 | 57 | ```bash 58 | export PKG_CONFIG_PATH="/opt/homebrew/Cellar:/opt/homebrew/lib/pkgconfig:/opt/homebrew/share/pkgconfig" 59 | ``` 60 | 61 | ## Usage 62 | 63 | ### Set Configuration 64 | 65 | For the simplicity of the CLI, and considering most of the APIs of each of the platforms do not allow or provide endpoints for user authentication, you will need to get your access tokens, api keys or integration tokens from your own profile before using cross post. This will just need to be done the first time or if you want to change the tokens. 66 | 67 | **The tokens are all stored on your local machine.** 68 | 69 | Here's a guide on how to do this for each of the platforms: 70 | 71 | ### dev.to 72 | 73 | 1. After logging into your account on dev.to, click on your profile image and then click on Settings 74 | 75 | ![Settings](./assets/dev-1.png) 76 | 77 | 2. Then, click on the Accounts tab in the sidebar 78 | 79 | ![Accounts](./assets/dev-2.png) 80 | 81 | 3. Scroll down to the "DEV Community API Keys" section. You need to generate a new key. Enter "Cross Post" in the description text box or any name you want then click "Generate API key" 82 | 83 | ![Generate API Key](./assets/dev-3.png) 84 | 85 | Copy the generated API key, then in your terminal: 86 | 87 | ```bash 88 | cross-post config dev 89 | ``` 90 | 91 | You'll be prompted to enter the API key. Paste the API key you copied earlier and hit enter. The API key will be saved. 92 | 93 | ### Hashnode 94 | 95 | 1. After logging into your account on Hashnode, click on your profile image and then click on "Account Settings" 96 | 97 | ![Settings](./assets/Hashnode-1.png) 98 | 99 | 2. In the sidebar click on "Developer" 100 | 101 | ![Developer](./assets/Hashnode-2.png) 102 | 103 | 3. Click the "Generate" button and then copy the generated access token. 104 | 105 | ![Generate](./assets/Hashnode-3.png) 106 | 107 | 4. Run the following in your terminal: 108 | 109 | ```bash 110 | cross-post config hashnode 111 | ``` 112 | 113 | First you'll be prompted to enter your access token. Then, you need to enter your Hashnode username. The reason behind that is that when later posting on hashnode your publication id is required, so your username will be used here to retreive the publication id. Once you do and everything goes well, the configuration will be saved successfully. 114 | 115 | ### Medium 116 | 117 | 1. After logging into Medium, click on your profile image and then click on "Settings" 118 | 119 | ![Settings](./assets/Medium-1.png) 120 | 121 | 2. Then click on "Integration Tokens" in the sidebar 122 | 123 | ![Integration Tokens](./assets/Medium-2.png) 124 | 125 | 3. You have to enter description of the token then click "Get integration token" and copy the generated token. 126 | 127 | ![Generate token](./assets/Medium-3.png) 128 | 129 | 4. In your terminal run: 130 | 131 | ```bash 132 | cross-post config medium 133 | ``` 134 | 135 | Then enter the integration token you copied. A request will also be sent to Medium to get your authorId as it will be used later to post your article on Medium. Once that is done successfully, your configuration will be saved. 136 | 137 | ### Cross Posting Your Articles 138 | 139 | To cross post your articles, you will use the following command: 140 | 141 | ```bash 142 | cross-post run [options] 143 | ``` 144 | 145 | Where `url` is the URL of your article that you want to cross post. `options` can be: 146 | 147 | 1. `-p, --platforms [platforms...]` The platform(s) you want to post the article on. By default if this option is not included, it will be posted on all the platforms. An example of its usage: 148 | 149 | ```bash 150 | cross-post run -p dev hashnode 151 | ``` 152 | 153 | 2. `-t, --title [title]` The title by default will be taken from the URL you supplied, however, if you want to use a different title you can supply it in this option. 154 | 3. `-s, --selector [selector]` by default, the `selector` config value or the `article` selector will be used to find your article in the URL you pass as an argument. However, if you need a different selector to be used to find the article, you can pass it here. 155 | 4. `-pu, --public` by default, the article will be posted as a draft (or hidden for hashnode due to the limitations of the Hashnode API). You can pass this option to post it publicly. 156 | 5. `-i, --ignore-image` this will ignore uploading an image with the article. This helps avoid errors when an image cannot be fetched. 157 | 6. `-is, --image-selector [imageSelector]` this will select the image from the page based on the selector you provide, instead of the first image inside the article. This option overrides the default image selector in the configurations. 158 | 7. `-iu, --image-url [imageUrl]` this will use the image URL you provide as a value of the option and will not look for any image inside the article. 159 | 8. `-ts, --title-selector [titleSelector]` this will select the title from the page based on the selector you provide, instead of the first heading inside the article. This option overrides the default title selector in the configurations. 160 | 161 | This command will find the HTML element in the URL page you pass as an argument and if found, it will extract the title (if no title is passed in the arguments) and cover image. 162 | 163 | #### Cross Posting Local Markdown Files 164 | 165 | Starting from version 1.2.3, you can now post local markdown files to the platforms. Instead of passing a URL, pass the path to the file with the option `-l` or `--local`. 166 | 167 | For example: 168 | 169 | ```bash 170 | cross-post run /path/to/test.md -l 171 | ``` 172 | 173 | You can also use any of the previous options mentioned. 174 | 175 | #### Selector Configuration 176 | 177 | If you need this tool to always use the same selector for the article, you can set the default selector in the configuration using the following command: 178 | 179 | ```bash 180 | cross-post config selector 181 | ``` 182 | 183 | Then, you'll be prompted to enter the selector you want. After you set the default selector, all subsequent `run` commands will use the same selector unless you override it using the option `--selector`. 184 | 185 | #### Image Selector Configuration 186 | 187 | If you need this tool to always use the same selector for the image, you can set the default image selector in the configuration using the following command: 188 | 189 | ```bash 190 | cross-post config imageSelector 191 | ``` 192 | 193 | Then, you'll be prompted to enter the image selector you want. After you set the default image selector, all subsequent `run` commands will use the same selector unless you override it using the option `--image-selector`. 194 | 195 | #### Title Selector Configuration 196 | 197 | If you need this tool to always use the same selector for the title, you can set the default title selector in the configuration using the following command: 198 | 199 | ```bash 200 | cross-post config titleSelector 201 | ``` 202 | 203 | Then, you'll be prompted to enter the title selector you want. After you set the default title selector, all subsequent `run` commands will use the same selector unless you override it using the option `--title-selector`. 204 | 205 | #### Uploading Data URI Article Images 206 | 207 | If your website's main article image is a [Data URL image](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs), uploading it as it is would lead to an error on most platforms. There are 3 ways to avoid that: 208 | 209 | ##### Using a Cloudinary account 210 | 211 | In this method, you'll need to create or use an already created Cloudinary account and the tool will use the account to upload the image and get a URL. 212 | 213 | Follow the steps below: 214 | 215 | 1. Create a [Cloudinary](https://cloudinary.com) account. 216 | 2. Get the `cloud_name`, `api_key`, and `api_secret` from your account. 217 | 3. Run `cross-post config cloudinary` and enter the information as prompted. **Remember that all keys are stored on your local machine**. 218 | 219 | That's it. Next time you run the `cross-post run` command, if the image is a Data URL image, it will be uploaded to Cloudinary to get a URL for it. You can delete the image once the article has been published publicly on the platforms. 220 | 221 | ##### Pass Image URL 222 | 223 | You can pass an image URL as an option to `cross-post run` using `--image-url`. 224 | 225 | ##### Post Article Without Image 226 | 227 | You can pass the option `--ignore-image` to `cross-post run` and the article will be published without an image. 228 | 229 | #### Reset Configuration Values 230 | 231 | you can reset configuration values for each platform like this 232 | 233 | ```bash 234 | cross-post config reset 235 | ``` 236 | 237 | for example, 238 | 239 | ```bash 240 | cross-post config reset dev 241 | ``` 242 | 243 | will reset all configuration values for dev.to platform 244 | 245 | All available reset commands are 246 | 247 | ```markdown 248 | Commands: 249 | dev reset configuration for dev.to 250 | medium reset configuration for medium.com 251 | hashnode reset configuration for hashnode.com 252 | cloudinary reset configuration for cloudinary 253 | all reset all *non-platform* configuration 254 | ``` 255 | 256 | The command `cross-post config reset all` or simply, `cross-post config reset` will reset every configuration value except the platform configuration values. 257 | 258 | --- 259 | 260 | ## License 261 | 262 | MIT 263 | --------------------------------------------------------------------------------