├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode └── launch.json ├── README.md ├── bin └── index.js ├── package-lock.json ├── package.json └── src ├── convertHTMLtoPortableText.js ├── convertMDtoVFile.js ├── convertToSanityDocument.js ├── defaultSchema.js ├── extractMDfromFile.js ├── globMDFiles.js ├── migrateFiles.js └── writeToFile.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["sanity", "prettier"], 3 | "env": {"node": true, "browser": false}, 4 | "parserOptions": { 5 | "ecmaVersion": 2018 6 | } 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | build 12 | 13 | # misc 14 | .DS_Store 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | # Logs 25 | logs 26 | *.log 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | 37 | # Directory for instrumented libs generated by jscoverage/JSCover 38 | lib-cov 39 | 40 | # Coverage directory used by tools like istanbul 41 | coverage 42 | 43 | # nyc test coverage 44 | .nyc_output 45 | 46 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 47 | .grunt 48 | 49 | # Bower dependency directory (https://bower.io/) 50 | bower_components 51 | 52 | # node-waf configuration 53 | .lock-wscript 54 | 55 | # Compiled binary addons (https://nodejs.org/api/addons.html) 56 | build/Release 57 | 58 | # Dependency directories 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # TypeScript v1 declaration files 63 | typings/ 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variables file 81 | .env 82 | 83 | # next.js build output 84 | .next 85 | # Logs 86 | logs 87 | *.log 88 | npm-debug.log* 89 | yarn-debug.log* 90 | yarn-error.log* 91 | 92 | # Runtime data 93 | pids 94 | *.pid 95 | *.seed 96 | *.pid.lock 97 | 98 | # Directory for instrumented libs generated by jscoverage/JSCover 99 | lib-cov 100 | 101 | # Coverage directory used by tools like istanbul 102 | coverage 103 | 104 | # nyc test coverage 105 | .nyc_output 106 | 107 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 108 | .grunt 109 | 110 | # Bower dependency directory (https://bower.io/) 111 | bower_components 112 | 113 | # node-waf configuration 114 | .lock-wscript 115 | 116 | # Compiled binary addons (https://nodejs.org/api/addons.html) 117 | build/Release 118 | 119 | # Dependency directories 120 | node_modules/ 121 | jspm_packages/ 122 | 123 | # TypeScript v1 declaration files 124 | typings/ 125 | 126 | # Optional npm cache directory 127 | .npm 128 | 129 | # Optional eslint cache 130 | .eslintcache 131 | 132 | # Optional REPL history 133 | .node_repl_history 134 | 135 | # Output of 'npm pack' 136 | *.tgz 137 | 138 | # Yarn Integrity file 139 | .yarn-integrity 140 | 141 | # dotenv environment variables file 142 | .env 143 | 144 | # next.js build output 145 | .next 146 | build 147 | 148 | .serverless 149 | .webpack 150 | storybook-static 151 | 152 | sanity/dist 153 | 154 | test/tmp/*.mp4.vscode 155 | *.ndjson -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .editorconfig 3 | .eslintignore 4 | .eslintrc 5 | test 6 | .vscode 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/bin/index.js", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown to Sanity 2 | 3 | This tool can glob a folder of markdown files with yaml frontmatter (typically found in Gatsby blogs) and ouput a Sanity.io `.ndjson`-import file. 4 | 5 | ## Installation 6 | 7 | As a global CLI tool: 8 | 9 | ```sh 10 | npm i -g markdown-to-sanity 11 | 12 | # or 13 | 14 | yarn add --global markdown-to-sanity 15 | ``` 16 | 17 | As a project dependency: 18 | 19 | ```sh 20 | npm i markdown-to-sanity 21 | 22 | # or 23 | 24 | yarn add markdown-to-sanity 25 | ``` 26 | 27 | ## Usage 28 | 29 | As CLI: 30 | ```sh 31 | > markdown-to-sanity # follow the instructions 32 | ``` 33 | 34 | The CLI will write a `ndjson`-file you can use with `sanity dataset import`. [Learn more about importing data to Sanity](https://www.sanity.io/docs/data-store/importing-data). 35 | 36 | In a project: 37 | ```js 38 | const migrateFiles = require('markdown-to-sanity') 39 | 40 | migrateFiles({ 41 | inputPath: '~/Sites/gatsby-blog/src/pages', 42 | filename: 'production', 43 | outputPath: './' 44 | }) 45 | ``` -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const inquirer = require('inquirer') 4 | const { PathPrompt } = require('inquirer-path') 5 | inquirer.registerPrompt('fuzzypath', require('inquirer-fuzzy-path')) 6 | inquirer.prompt.registerPrompt('path', PathPrompt) 7 | 8 | const writeToFile = require('../src/writeToFile') 9 | const migrateFiles = require('../src/migrateFiles') 10 | 11 | async function run () { 12 | const answers = await inquirer.prompt([ 13 | /* Pass your questions in here */ 14 | { 15 | type: 'path', 16 | name: 'inputPath', 17 | default: process.cwd(), 18 | message: 'The absolute path to your folder with markdown files', 19 | excludePath: nodePath => nodePath.startsWith('node_modules'), 20 | }, 21 | { 22 | type: 'string', 23 | name: 'filename', 24 | default: 'production', 25 | message: `Filename for the import file` 26 | }, 27 | { 28 | type: 'path', 29 | name: 'outputPath', 30 | default: process.cwd(), 31 | directoryOnly: true, 32 | message: `Output path for the import file` 33 | } 34 | ]) 35 | const { inputPath, filename, outputPath } = answers 36 | const sanityDocuments = await migrateFiles(inputPath, filename, outputPath) 37 | writeToFile({ filename, sanityDocuments, outputPath }) 38 | } 39 | 40 | run() 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-to-sanity", 3 | "version": "0.1.1", 4 | "description": "Migrate a folder of markdown files to Sanity", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "eslint .", 8 | "start": "node ./bin" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "bin": { 14 | "markdown-to-sanity": "./bin/index.js" 15 | }, 16 | "devDependencies": { 17 | "eslint": "^6.0.1", 18 | "eslint-config-prettier": "^6.0.0", 19 | "eslint-config-sanity": "^0.141.5", 20 | "jest": "^24.8.0", 21 | "nodemon": "^1.19.1", 22 | "prettier": "^1.18.2" 23 | }, 24 | "dependencies": { 25 | "@sanity/block-tools": "^0.141.5", 26 | "@sanity/schema": "^0.141.5", 27 | "date-fns": "^1.30.1", 28 | "glob": "^7.1.4", 29 | "globby": "^10.0.1", 30 | "inquirer": "^6.5.0", 31 | "inquirer-fuzzy-path": "^2.0.3", 32 | "inquirer-path": "^1.0.0-beta5", 33 | "jsdom": "^15.1.1", 34 | "remark-extract-frontmatter": "^2.0.0", 35 | "remark-frontmatter": "^1.3.2", 36 | "remark-html": "^9.0.1", 37 | "remark-parse": "^6.0.3", 38 | "showdown": "^1.9.1", 39 | "showdown-highlightjs-extension": "^0.1.2", 40 | "unified": "^8.1.0", 41 | "unified-stream": "^1.0.5", 42 | "yaml": "^1.6.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/convertHTMLtoPortableText.js: -------------------------------------------------------------------------------- 1 | const blockTools = require('@sanity/block-tools').default 2 | const jsdom = require('jsdom') 3 | const { JSDOM } = jsdom 4 | const HTMLpattern = /<[a-z][\s\S]*>/ 5 | 6 | /** 7 | * block tools needs a schema definition to now what 8 | * types are available 9 | * */ 10 | const defaultSchema = require('./defaultSchema') 11 | const blockContentType = defaultSchema 12 | .get('blogPost') 13 | .fields.find(field => field.name === 'body').type 14 | 15 | function convertHTMLtoPortableText (HTMLDoc) { 16 | if (!HTMLpattern.test(HTMLDoc)) { 17 | return [] 18 | } 19 | const rules = [ 20 | { 21 | deserialize(el, next, block) { 22 | if (el.tagName.toLowerCase() !== "figure") { 23 | return undefined; 24 | } 25 | const img = Array.from(el.children).find( 26 | child => child.tagName.toLowerCase() === "img" 27 | ); 28 | const caption = Array.from(el.children).find( 29 | child => child.tagName.toLowerCase() === "figcaption" 30 | ); 31 | 32 | return block({ 33 | _type: "figure", 34 | image: { 35 | // using the format for importing assets via the CLI 36 | // https://www.sanity.io/docs/data-store/importing-data#import-using-the-cli 37 | _sanityAsset: `image@${img.getAttribute("src")}` 38 | }, 39 | alt: img.getAttribute("alt"), 40 | caption: caption ? caption.textContent : '' 41 | }); 42 | } 43 | }, 44 | { 45 | // Special case for code blocks (wrapped in pre and code tag) 46 | deserialize (el, next, block) { 47 | if (el.tagName.toLowerCase() !== 'pre') { 48 | return undefined 49 | } 50 | const codeElement = el.children[0] 51 | const codeElementNodes = 52 | codeElement && codeElement.tagName.toLowerCase() === 'code' 53 | ? codeElement.childNodes 54 | : el.childNodes 55 | let code = '' 56 | codeElementNodes.forEach(node => { 57 | code += node.textContent 58 | }) 59 | 60 | let language = 'text'; 61 | if(codeElement.className){ 62 | language = codeElement.className.split("-")[1]; 63 | } 64 | return block({ 65 | _type: 'code', 66 | code: code, 67 | language: language 68 | }) 69 | } 70 | }, 71 | { 72 | deserialize (el, next, block) { 73 | if (el.tagName === 'IMG') { 74 | return { 75 | _type: 'img', 76 | asset: { 77 | src: `${el.getAttribute('src').replace(/^\/\//, 'https://')}`, 78 | alt: `${el.getAttribute('alt')}` 79 | } 80 | } 81 | } 82 | 83 | if ( 84 | el.tagName.toLowerCase() === 'p' && 85 | el.childNodes.length === 1 && 86 | el.childNodes.tagName && 87 | el.childNodes[0].tagName.toLowerCase() === 'img' 88 | ) { 89 | return { 90 | _type: 'img', 91 | asset: { 92 | src: `${el.getAttribute('src').replace(/^\/\//, 'https://')}`, 93 | alt: `${el.getAttribute('alt')}` 94 | } 95 | } 96 | } 97 | // Only convert block-level images, for now 98 | return undefined 99 | } 100 | } 101 | ] 102 | /** 103 | * Since we're in a node context, we need 104 | * to give block-tools JSDOM in order to 105 | * parse the HTML DOM elements 106 | */ 107 | return blockTools.htmlToBlocks(HTMLDoc, blockContentType, { 108 | rules, 109 | parseHtml: html => new JSDOM(html).window.document 110 | }) 111 | } 112 | module.exports = convertHTMLtoPortableText 113 | -------------------------------------------------------------------------------- /src/convertMDtoVFile.js: -------------------------------------------------------------------------------- 1 | const unified = require('unified') 2 | const frontmatter = require('remark-frontmatter') 3 | const extract = require('remark-extract-frontmatter') 4 | const markdown = require('remark-parse') 5 | const html = require('remark-html') 6 | const yaml = require('yaml').parse 7 | 8 | async function convertMDtoVFile (markdownContent) { 9 | const VFile = await unified() 10 | .use(markdown) 11 | .use(frontmatter) 12 | .use(extract, { name: 'frontmatter', yaml: yaml }) 13 | .use(html) 14 | .process(markdownContent) 15 | return VFile 16 | } 17 | 18 | module.exports = convertMDtoVFile 19 | -------------------------------------------------------------------------------- /src/convertToSanityDocument.js: -------------------------------------------------------------------------------- 1 | const convertHTMLtoPortableText = require('./convertHTMLtoPortableText') 2 | const {format} = require('date-fns') 3 | function convertToSanityDocument({data = {}, contents}) { 4 | const { title, date } = data.frontmatter || {} 5 | const portableText = convertHTMLtoPortableText(contents) 6 | 7 | const doc = { 8 | _type: 'post', 9 | _createdAt: format(new Date(date)), 10 | publishedAt: format(new Date(date)), 11 | title, 12 | body: portableText 13 | } 14 | return doc 15 | } 16 | 17 | module.exports = convertToSanityDocument 18 | -------------------------------------------------------------------------------- /src/defaultSchema.js: -------------------------------------------------------------------------------- 1 | const Schema = require('@sanity/schema').default 2 | 3 | module.exports = Schema.compile({ 4 | name: 'myBlog', 5 | types: [ 6 | { 7 | type: 'object', 8 | name: 'blogPost', 9 | fields: [ 10 | { 11 | title: 'Title', 12 | type: 'string', 13 | name: 'title' 14 | }, 15 | { 16 | title: 'Body', 17 | name: 'body', 18 | type: 'array', 19 | of: [ 20 | { type: 'block' }, 21 | { 22 | name: 'code', 23 | type: 'object', 24 | title: 'Code', 25 | fields: [ 26 | { 27 | title: 'Code', 28 | name: 'code', 29 | type: 'code' 30 | }, 31 | { 32 | name: 'language', 33 | title: 'Language', 34 | type: 'string' 35 | }, 36 | { 37 | title: 'Highlighted lines', 38 | name: 'highlightedLines', 39 | type: 'array', 40 | of: [ 41 | { 42 | type: 'number', 43 | title: 'Highlighted line' 44 | } 45 | ] 46 | } 47 | ] 48 | }, 49 | { 50 | name: 'image', 51 | type: 'object', 52 | title: 'image', 53 | fields: [ 54 | { 55 | title: 'src', 56 | name: 'src', 57 | type: 'string' 58 | }, 59 | { 60 | title: 'alt', 61 | name: 'alt', 62 | type: 'string' 63 | } 64 | ] 65 | } 66 | ] 67 | } 68 | ] 69 | } 70 | ] 71 | }) -------------------------------------------------------------------------------- /src/extractMDfromFile.js: -------------------------------------------------------------------------------- 1 | const { readFile } = require('fs') 2 | 3 | async function extractMDfromFile(filePath) { 4 | const mdContent = await new Promise((resolve, reject) => readFile(filePath, 'utf-8', (err, data) => { 5 | if (err) { 6 | throw Error(err) 7 | } 8 | return resolve(data) 9 | })) 10 | return mdContent 11 | } 12 | 13 | module.exports = extractMDfromFile -------------------------------------------------------------------------------- /src/globMDFiles.js: -------------------------------------------------------------------------------- 1 | const globby = require('globby') 2 | 3 | async function globMDFiles (path) { 4 | const options = { 5 | cwd: path 6 | } 7 | const files = await globby(`**/*.md`, options) 8 | // return an array of absolute paths 9 | return files.map(file => `${path}/${file}`) 10 | } 11 | 12 | module.exports = globMDFiles 13 | -------------------------------------------------------------------------------- /src/migrateFiles.js: -------------------------------------------------------------------------------- 1 | const globMDFiles = require('./globMDFiles') 2 | const extractMDfromFile = require('./extractMDfromFile') 3 | const convertMDtoVFile = require('./convertMDtoVFile') 4 | const convertToSanityDocument = require('./convertToSanityDocument') 5 | 6 | async function migrateFiles (inputPath, filename, outputPath) { 7 | const files = await globMDFiles(inputPath) 8 | const mdDocuments = await Promise.all(files.map(extractMDfromFile)) 9 | const VFiles = await Promise.all(mdDocuments.map(convertMDtoVFile)) 10 | const sanityDocuments = await Promise.all(VFiles.map(convertToSanityDocument)) 11 | return sanityDocuments 12 | } 13 | 14 | module.exports = migrateFiles 15 | -------------------------------------------------------------------------------- /src/writeToFile.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const fs = require('fs') 3 | 4 | function writeToFile ({ filename, sanityDocuments, outputPath }) { 5 | const path = `${outputPath}/${filename.split('.ndjson')[0]}` 6 | 7 | const preparedDocument = sanityDocuments.reduce( 8 | (acc, doc) => `${acc }${JSON.stringify(doc)}\n` 9 | , '') 10 | 11 | return fs.writeFile(`${path}.ndjson`, preparedDocument, (err, data) => { 12 | if (err) { 13 | throw new Error(err) 14 | } 15 | console.log( 16 | `Wrote ${sanityDocuments.length} documents to ${filename}.ndjson` 17 | ) 18 | }) 19 | } 20 | 21 | module.exports = writeToFile 22 | --------------------------------------------------------------------------------