├── posts └── .keep ├── uploads └── .keep ├── .nvmrc ├── .env.sample ├── .gitignore ├── .github └── FUNDING.yml ├── scripts ├── perform-migration.js ├── src │ ├── upload.mutation.gql │ └── publish-post.js └── perform-cleanse.js ├── package.json ├── README.md └── hashnode-overrides.css /posts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /uploads/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.18.2 2 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | HASHNODE_PUBLICATION_ID=myblogid 2 | HASHNODE_API_KEY=abcdefg1234iamaKey 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | posts.json 3 | posts/cleaned.json 4 | uploads/* 5 | .env 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: tinacious 4 | -------------------------------------------------------------------------------- /scripts/perform-migration.js: -------------------------------------------------------------------------------- 1 | const publishPost = require('./src/publish-post'); 2 | 3 | const POSTS_TO_BATCH_UPLOAD = require('../posts/cleaned.json'); 4 | 5 | POSTS_TO_BATCH_UPLOAD 6 | .forEach((post) => { 7 | publishPost(post) 8 | }) 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "migrate-wordpress", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "cleanse": "node scripts/perform-cleanse.js", 6 | "migrate": "node scripts/perform-migration.js" 7 | }, 8 | "dependencies": { 9 | "dotenv": "^8.2.0", 10 | "graphql-request": "1.8.2", 11 | "html-attributes-remover": "^1.0.4", 12 | "turndown": "^6.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /scripts/src/upload.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation createPublicationStory( 2 | $input: CreateStoryInput!, 3 | $hideFromHashnodeFeed: Boolean, 4 | $publicationId: String! 5 | ) { 6 | createPublicationStory( 7 | input: $input, 8 | hideFromHashnodeFeed: $hideFromHashnodeFeed, 9 | publicationId: $publicationId 10 | ) { 11 | success 12 | code 13 | message 14 | post { 15 | cuid 16 | slug 17 | title 18 | contentMarkdown 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WordPress-Hashnode Migrator 2 | 3 | This is a set of scripts and instructions to facilitate migrating a blog from WordPress to Hashnode. 4 | 5 | ## Usage 6 | 7 | Use the following guide as a walkthrough for this process: 8 | 9 | [Migrating my blog from WordPress to Hashnode](https://blog.tinaciousdesign.com/migrating-my-blog-from-wordpress-to-hashnode-ckdgzbasn00zcdns1bmm2dj76) 10 | 11 | 12 | 13 | ## Scripts 14 | 15 | Install dependencies: 16 | 17 | ``` 18 | npm install 19 | ``` 20 | 21 | ### Set up your environment variables 22 | 23 | Set the required Hashnode secrets as environment variables in the `.env` file. See `.env.sample` for an example. 24 | 25 | ``` 26 | mv .env.sample .env 27 | ``` 28 | 29 | Open the `.env` file and edit it. 30 | 31 | 32 | ### Clean the posts 33 | 34 | This operation is like drinking a kale and cucumber smoothie. It processes the posts in `./posts.json` and performs the following actions: 35 | 36 | - removes dirty attributes from content HTML 37 | - converts post content to Markdown 38 | - writes a new file in `./posts/cleaned.json` 39 | 40 | 41 | ``` 42 | npm run cleanse 43 | ``` 44 | 45 | ### Publish new posts 46 | 47 | Once your posts are ready to be published, run the following script. 48 | 49 | ``` 50 | npm run migrate 51 | ``` 52 | -------------------------------------------------------------------------------- /scripts/src/publish-post.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | const Fs = require('fs') 4 | const Path = require('path') 5 | const { GraphQLClient } = require('graphql-request') 6 | 7 | const client = new GraphQLClient('https://api.hashnode.com', { 8 | headers: { 9 | Authorization: process.env.HASHNODE_API_KEY 10 | } 11 | }) 12 | 13 | const query = Fs.readFileSync( 14 | Path.join(__dirname, 'upload.mutation.gql'), 15 | 'utf-8' 16 | ) 17 | 18 | const publishPost = (post) => { 19 | const variables = { 20 | hideFromHashnodeFeed: true, 21 | publicationId: process.env.HASHNODE_PUBLICATION_ID, 22 | input: { 23 | title: post.title, 24 | contentMarkdown: post.content, 25 | isRepublished: { 26 | originalArticleURL: post.url, 27 | }, 28 | tags: [] 29 | } 30 | } 31 | 32 | 33 | return client.request(query, variables) 34 | .then((data) => { 35 | console.log('✅ Success!') 36 | console.log(data) 37 | return post.title 38 | }) 39 | .catch((err) => { 40 | console.error('🚨 Error!') 41 | console.error(err) 42 | }); 43 | }; 44 | 45 | 46 | module.exports = publishPost; 47 | 48 | 49 | if (process.env.TEST_PUBLISH) { 50 | const testPost = { 51 | } 52 | 53 | publishPost(testPost) 54 | } 55 | -------------------------------------------------------------------------------- /hashnode-overrides.css: -------------------------------------------------------------------------------- 1 | /* blue: #00bfff */ 2 | /* turquoise: #00CED1 */ 3 | /* green: #00d364 */ 4 | /* purple: #c6f */ 5 | /* yellow: #fc6 */ 6 | 7 | .mode-dark .dark\:bg-brand-dark-grey-900 { 8 | background-color: #1c1c26; 9 | } 10 | 11 | ::selection { 12 | background-color: rgba(255, 51, 153, 0.3); 13 | } 14 | 15 | /* Header */ 16 | .blog-header { 17 | border-bottom: 4px solid rgb(255, 51, 153); 18 | } 19 | 20 | .blog-sub-header { 21 | padding-bottom: 6px; 22 | } 23 | 24 | .blog-settings, 25 | .blog-theme-switcher { 26 | } 27 | 28 | .blog-sub-header-nav a { 29 | background: #f39; 30 | border-radius: 4px; 31 | margin-bottom: 12px; 32 | padding: 0; 33 | color: #f39; 34 | position: relative; 35 | height: 2em; 36 | width: 60px; 37 | } 38 | 39 | .blog-sub-header-nav a:hover { 40 | background: #f39; 41 | } 42 | 43 | .blog-sub-header-nav a:after { 44 | content: "Blog"; 45 | color: #fff; 46 | position: absolute; 47 | top: 50%; 48 | left: 50%; 49 | transform: translate(-50%, -50%); 50 | } 51 | 52 | .blog-social-media-section a { 53 | padding: 8px; 54 | margin: 8px 3px; 55 | border-radius: 50%; 56 | background: #f39; 57 | opacity: 1; 58 | } 59 | 60 | .blog-social-media-section a:hover { 61 | background: #f39; 62 | } 63 | 64 | /* Body links */ 65 | .prose a { 66 | color: #f39; 67 | } 68 | .prose a:hover { 69 | color: #00d364; 70 | } 71 | .prose pre { 72 | background-color: #1c1c26; 73 | } 74 | 75 | .button-primary { 76 | color: #f39; 77 | border-color: #f39; 78 | } 79 | .button-primary:hover { 80 | background-color: #fff; 81 | } 82 | 83 | 84 | /* Highlight.js */ 85 | /* Comment */ 86 | .hljs-comment, 87 | .hljs-quote { 88 | color: #686889; 89 | } 90 | 91 | /* Red */ 92 | .hljs-variable, 93 | .hljs-template-variable, 94 | .hljs-tag, 95 | .hljs-name, 96 | .hljs-selector-id, 97 | .hljs-deletion { 98 | color: #f39; 99 | } 100 | 101 | /* Magenta */ 102 | .hljs-number, 103 | .hljs-literal { 104 | color: #CC66FF; 105 | } 106 | 107 | /* Orange */ 108 | .hljs-params, 109 | .hljs-selector-class, 110 | .hljs-string { 111 | color: #FFCC66; 112 | } 113 | 114 | /* Turquoise */ 115 | .hljs-subst, 116 | .hljs-regexp, 117 | .hljs-built_in, 118 | .hljs-builtin-name, 119 | .hljs-function { 120 | color: #00ced1; 121 | } 122 | 123 | /* Green */ 124 | .hljs-class, 125 | .hljs-symbol, 126 | .hljs-title, 127 | .hljs-bullet, 128 | .hljs-attr, 129 | .hljs-attribute, 130 | .hljs-addition { 131 | color: #00D364; 132 | } 133 | 134 | /* Blue */ 135 | .hljs-doctag, 136 | .hljs-type, 137 | .hljs-link, 138 | .hljs-meta, 139 | .hljs-section { 140 | color: #00BFFF; 141 | /* color: #00D364; */ 142 | } 143 | 144 | /* Pink */ 145 | .hljs-keyword, 146 | .hljs-selector-tag { 147 | color: #f39; 148 | } 149 | 150 | .hljs { 151 | display: block; 152 | overflow-x: auto; 153 | background: #1c1c26; 154 | color: #B3B3D4; 155 | padding: 0.5em; 156 | } 157 | 158 | .hljs-emphasis { 159 | font-style: italic; 160 | } 161 | 162 | .hljs-strong { 163 | font-weight: bold; 164 | } 165 | 166 | @media screen and (-ms-high-contrast: active) { 167 | .hljs-addition, 168 | .hljs-attr, 169 | .hljs-attribute, 170 | .hljs-built_in, 171 | .hljs-bullet, 172 | .hljs-comment, 173 | .hljs-link, 174 | .hljs-literal, 175 | .hljs-meta, 176 | .hljs-number, 177 | .hljs-params, 178 | .hljs-string, 179 | .hljs-symbol, 180 | .hljs-type, 181 | .hljs-quote { 182 | color: highlight; 183 | } 184 | 185 | .hljs-keyword, 186 | .hljs-selector-tag { 187 | font-weight: bold; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /scripts/perform-cleanse.js: -------------------------------------------------------------------------------- 1 | const Path = require('path') 2 | const Fs = require('fs') 3 | 4 | const AttributeRemover = require('html-attributes-remover').default 5 | const imgAttributeRemover = new AttributeRemover({ 6 | 'htmlTags': ['img'], 7 | 'attributes': ['style', 'class'] 8 | }) 9 | 10 | const Turndown = require('turndown') 11 | const turndownService = new Turndown({ 12 | codeBlockStyle: 'fenced', 13 | headingStyle: 'atx', 14 | hr: '---', 15 | bulletListMarker: '-', 16 | fence: '```', 17 | emDelimiter: '_', 18 | strongDelimiter: '**', 19 | linkStyle: 'inlined' 20 | }) 21 | 22 | 23 | 24 | /** 25 | * Some constants you'll need to change 26 | */ 27 | // You need a posts.json file at the root 28 | const EXISTING_POSTS = require('../posts.json') 29 | const OLD_MEDIA_PATH = 'http://tinaciousdesign.com/wp-content/uploads/' 30 | const NEW_MEDIA_PATH = 'https://tinaciousdesign.imfast.io/' 31 | const NEW_POSTS_PATH = '../posts/cleaned.json' 32 | 33 | 34 | const publishedPosts = EXISTING_POSTS.filter((post) => !!post.Date && post.Permalink.indexOf('?p=') === -1) 35 | 36 | /** 37 | * Hashmap of { filename: metadata } 38 | * Data contains: 39 | * - title 40 | * - excerpt 41 | * - content 42 | * - slug 43 | * - date (ISO format) 44 | */ 45 | const postData = [] 46 | 47 | console.log({ 48 | posts: EXISTING_POSTS.length, 49 | publishedPosts: publishedPosts.length, 50 | drafts: EXISTING_POSTS.length - publishedPosts.length 51 | }) 52 | 53 | /** 54 | * Does what it says on the box. 55 | */ 56 | const replaceAllOldUrlsWithNewUrls = (post) => { 57 | const postCopy = JSON.parse(JSON.stringify(post)) 58 | postCopy.Content = postCopy.Content.replace(OLD_MEDIA_PATH, NEW_MEDIA_PATH, 'g') 59 | return postCopy 60 | } 61 | 62 | /** 63 | * Leverages a third-party library to clean up unneeded WordPress attributes like JSON.parse(JSON.stringify(o)) 103 | 104 | 105 | const sortTransformedPost = (postA, postB) => { 106 | if (postA.date < postB.date) { 107 | return -1 108 | } 109 | 110 | if (postA.date > postB.date) { 111 | return 1; 112 | } 113 | 114 | return 0; 115 | } 116 | 117 | 118 | /** 119 | * Main 120 | */ 121 | function main () { 122 | publishedPosts.forEach((oldPost) => { 123 | // Transformations 124 | let post = clone(oldPost) 125 | post = replaceAllOldUrlsWithNewUrls(post) 126 | post = cleanPostHtml(post) 127 | post = convertPostHtmlToMarkdown(post) 128 | 129 | // Persistence 130 | const filename = post.Permalink.split('/').reverse()[1]; 131 | addPostToPostData(post, filename) 132 | }) 133 | 134 | const sortedPostData = postData.sort(sortTransformedPost) 135 | persistPostData(sortedPostData) 136 | } 137 | 138 | 139 | main(); 140 | --------------------------------------------------------------------------------