├── .gitignore ├── README.md ├── eslintrc.json ├── index.js ├── package.json ├── rollup.config.js ├── source └── markdown.js └── test └── markdown.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | build/* 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | This [Rollup](https://rollupjs.org/) plugin lets you extract your JavaScript code from code blocks embedded in [Markdown](https://daringfireball.net/projects/markdown/syntax) files, which in turn promotes good written documentation. This technique is called [literate programming](https://en.wikipedia.org/wiki/Literate_programming). 4 | 5 | For a more detailed discussion about why you might want to do this, or to implement with other programming languages and other JavaScript build tools, please instead see [lit](https://github.com/vijithassar/lit), a shell script which provides the same functionality in a more agnostic fashion. 6 | 7 | # Example 8 | 9 | [GitHub Flavored Markdown](https://github.github.com/gfm/) represents code using [fenced code blocks](https://help.github.com/articles/creating-and-highlighting-code-blocks/), which are demarcated with three backticks in a row: 10 | 11 | ```javascript 12 | function greeting() { 13 | console.log('hello world'); 14 | } 15 | 16 | export { greeting }; 17 | ``` 18 | 19 | After you import and run this plugin in your `rollup.config.js` configuration file, the `greeting()` function above can be *imported directly from this Markdown document*! For example, you might use the following [ES6 module import statement](https://rollupjs.org/#importing): 20 | 21 | `import { greeting } from './path/to/README.md';` 22 | 23 | Sourcemaps will correctly point your debugging back to the original Markdown documents. 24 | 25 | You *must* include `js` or `javascript` as a language specifier after opening up a fenced code block in Markdown. Fenced code blocks that specify any other language and fenced code blocks that do not specify a language at all will be ignored. This makes it possible for you to include other code in your Markdown file without that code being executed. This is particularly useful for including Bash commands. -------------------------------------------------------------------------------- /eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": ["warn", 4], 13 | "no-unused-vars": ["error", { "argsIgnorePattern": "_" }], 14 | "linebreak-style": ["error", "unix"], 15 | "quotes": ["warn", "single"], 16 | "strict": ["error", "safe"], 17 | "no-dupe-keys": "error", 18 | "one-var": ["error", { 19 | "var": "always", 20 | "let": "never", 21 | "const": "never" 22 | }], 23 | "space-before-function-paren": ["error", "never"], 24 | "space-before-blocks": ["error", "always"], 25 | "space-infix-ops": "error", 26 | "key-spacing": ["error", { 27 | "afterColon": true, 28 | "mode": "strict" 29 | }], 30 | "func-style": ["error", "expression"], 31 | "eqeqeq": "error", 32 | "dot-notation": "error", 33 | "no-redeclare": "error", 34 | "no-undef": "error", 35 | "no-undefined": "error", 36 | "radix": "error", 37 | "vars-on-top": "error", 38 | "no-trailing-spaces": "error", 39 | "no-mixed-spaces-and-tabs": "error", 40 | "one-var-declaration-per-line": ["error", "always"], 41 | "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 3}], 42 | "no-console": ["error", { "allow": ["warn", "error"] }], 43 | "no-debugger": "error", 44 | "no-use-before-define": "error", 45 | "semi": ["error", "always"], 46 | "array-callback-return": "error", 47 | "curly": ["error", "all"], 48 | "no-eval": "error", 49 | "no-extend-native": "error", 50 | "no-multi-spaces": "error", 51 | "no-new-func": "error", 52 | "no-unused-expressions": "error", 53 | "no-useless-escape": "error", 54 | "no-cond-assign": ["error", "always"], 55 | "no-extra-boolean-cast": "error", 56 | "no-invalid-regexp": "error", 57 | "no-irregular-whitespace": "error", 58 | "valid-typeof": "error", 59 | "brace-style": ["error", "1tbs"], 60 | "no-global-assign": "error", 61 | "no-template-curly-in-string": "error", 62 | "func-call-spacing": ["error", "never"], 63 | "no-unsafe-negation": "off", 64 | "linebreak-style": ["error", "unix"], 65 | "new-parens": "error", 66 | "no-nested-ternary": "error", 67 | "no-new-object": "error", 68 | "no-unneeded-ternary": "error", 69 | "operator-assignment": ["error", "always"], 70 | "space-in-parens": ["error", "never"], 71 | "arrow-body-style": ["error", "as-needed"], 72 | "arrow-spacing": ["error", {"before": true, "after": true}], 73 | "no-confusing-arrow": ["error", {"allowParens": true}], 74 | "prefer-spread": "error", 75 | "require-yield": "error" 76 | }, 77 | "globals": { 78 | "it": true, 79 | "describe": true 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import markdown from './source/markdown.js'; 2 | 3 | export default markdown; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rollup-plugin-markdown", 3 | "version": "0.2.0", 4 | "description": "import JavaScript from Markdown code blocks", 5 | "main": "build/markdown.js", 6 | "module": "source/markdown.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/vijithassar/rollup-plugin-markdown.git" 10 | }, 11 | "keywords": [ 12 | "Markdown", 13 | "documentation", 14 | "literate", 15 | "programming", 16 | "writing" 17 | ], 18 | "scripts": { 19 | "lint": "eslint --config eslintrc.json ./source/*.js && eslint --config eslintrc.json ./test/*.js", 20 | "test": "mocha --require reify test/*.js", 21 | "build": "rollup --config rollup.config.js", 22 | "prepublish": "git checkout ./source/markdown.js && npm run build && npm run lint && npm run test" 23 | }, 24 | "author": "Vijith Assar", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/vijithassar/rollup-plugin-markdown/issues" 28 | }, 29 | "homepage": "https://github.com/vijithassar/rollup-plugin-markdown#readme", 30 | "dependencies": { 31 | "magic-string": "^0.22.4", 32 | "rollup-pluginutils": "^2.0.1" 33 | }, 34 | "devDependencies": { 35 | "eslint": "^4.10.0", 36 | "mocha": "^4.0.1", 37 | "reify": "^0.12.3", 38 | "rollup": "^0.51.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | let config = { 2 | name: 'rollup-plugin-markdown', 3 | exports: 'default', 4 | input: './index.js', 5 | output: { 6 | file: 'build/markdown.js', 7 | format: 'umd' 8 | } 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /source/markdown.js: -------------------------------------------------------------------------------- 1 | import MagicString from 'magic-string'; 2 | import { createFilter } from 'rollup-pluginutils'; 3 | 4 | // export a function that can take configuration options 5 | const markdown = (options = {}) => { 6 | 7 | // include or exclude files 8 | const filter = createFilter(options.include, options.exclude); 9 | 10 | // disable sourcemaps if you want for some reason 11 | const sourcemap = options.sourceMap !== false && options.sourcemap !== false; 12 | 13 | const plugin = { 14 | 15 | name: 'markdown', 16 | 17 | // transform source code 18 | transform: (code, id) => { 19 | 20 | // exit without transforming if the filter prohibits this id 21 | if (! filter(id)) { 22 | return null; 23 | } 24 | 25 | // load source code string into MagicString for transformation 26 | const magicstring = new MagicString(code); 27 | 28 | // spit input code string along newlines 29 | const lines = code.split('\n'); 30 | 31 | // if it doesn't look like a valid Markdown document containing 32 | // a reasonable number of code blocks, exit immediately and 33 | // return the input 34 | 35 | const fences = lines.filter(item => item.slice(0, 3) === '```'); 36 | const even_fences = fences.length % 2 === 0; 37 | const has_fences = fences.length > 0; 38 | 39 | if (! even_fences || ! has_fences) { 40 | const self = {code: code}; 41 | if (sourcemap) { 42 | self.map = magicstring.generateMap({hires: true}); 43 | } 44 | return self; 45 | } 46 | 47 | // track whether we're inside a code block 48 | let code_block = false; 49 | let js_code_block = false; 50 | let position = 0; 51 | // determine which lines to include in the output 52 | const line_data = lines 53 | .map(string => { 54 | const start = position; 55 | const end = position + string.length; 56 | position = end + 1; 57 | // every time a set of backticks is detected, 58 | // toggle the code block 59 | const backticks = string.slice(0, 3) === '```'; 60 | if (backticks) { 61 | code_block = ! code_block; 62 | } 63 | const js_backticks = string.slice(0, 5) === '```js' || string.slice(0, 13) === '```javascript'; 64 | if (js_backticks) { 65 | js_code_block = ! js_code_block; 66 | } 67 | const line = { 68 | line: string, 69 | start: start, 70 | end: end, 71 | include: code_block && js_code_block && ! backticks && ! js_backticks 72 | }; 73 | return line; 74 | }); 75 | 76 | // remove excluded lines 77 | line_data.forEach(line => { 78 | if (line.include === false) { 79 | magicstring.remove(line.start, line.end); 80 | } 81 | }); 82 | 83 | const result = { 84 | code: magicstring.toString() 85 | }; 86 | 87 | // attach sourcemap 88 | if (sourcemap) { 89 | result.map = magicstring.generateMap({hires: true}); 90 | } 91 | 92 | return result; 93 | 94 | } 95 | 96 | }; 97 | 98 | return plugin; 99 | 100 | }; 101 | 102 | export default markdown; -------------------------------------------------------------------------------- /test/markdown.js: -------------------------------------------------------------------------------- 1 | import factory from '../source/markdown.js'; 2 | import * as assert from 'assert'; 3 | 4 | const instance = factory(); 5 | 6 | const lines = (markdown) => { 7 | const transformation = instance.transform(markdown, 'test'); 8 | const clean = transformation.code.split('\n').filter(item => !! item); 9 | return clean; 10 | }; 11 | 12 | describe('rollup-plugin-markdown', () => { 13 | it('provides a factory', () => { 14 | assert.equal(typeof factory, 'function'); 15 | }); 16 | it('returns an instance', () => { 17 | assert.equal(typeof instance, 'object'); 18 | }); 19 | it('has the required methods', () => { 20 | assert.equal(typeof instance.transform, 'function'); 21 | }); 22 | it('extracts code blocks from Markdown', () => { 23 | const markdown = '# heading\ntext\n```js\ncode()\n```'; 24 | const expected = 'code()'; 25 | const processed = lines(markdown); 26 | assert.equal(processed, expected); 27 | }); 28 | it('ignores code with languages other than JavaScript specified', () => { 29 | const markdown = '# heading\ntext\n```bash\na()\n```\ntext\n```js\nb()\n```'; 30 | const expected = 'b()'; 31 | const processed = lines(markdown).join('\n'); 32 | assert.equal(processed, expected); 33 | }); 34 | it('ignores code without a language annotation after opening fence', () => { 35 | const markdown = '# heading\ntext\n```\na()\n```\ntext\n```javascript\nb()\n```'; 36 | const expected = 'b()'; 37 | const processed = lines(markdown).join('\n'); 38 | assert.equal(processed, expected); 39 | }); 40 | it('captures code with js as the language annotation', () => { 41 | const markdown = '# heading\ntext\n```js\nb()\n```'; 42 | const expected = 'b()'; 43 | const processed = lines(markdown).pop(); 44 | assert.equal(processed, expected); 45 | }); 46 | it('captures code with javascript as the language annotation', () => { 47 | const markdown = '# heading\ntext\n```javascript\nb()\n```'; 48 | const expected = 'b()'; 49 | const processed = lines(markdown).pop(); 50 | assert.equal(processed, expected); 51 | }); 52 | it('ignores code without a language annotation after opening fence', () => { 53 | const markdown = '# heading\ntext\n```\na()\n```\ntext\n```javascript\nb()\n```'; 54 | const expected = 'b()'; 55 | const processed = lines(markdown).pop(); 56 | assert.equal(processed, expected); 57 | }); 58 | it('captures multiple code blocks', () => { 59 | const markdown = '# heading\ntext\n```js\ncode()\n```\nmore text\n```\nmore_code()\n```'; 60 | const expected = ['code()', 'more_code()']; 61 | const processed = lines(markdown); 62 | expected.forEach((line, index) => { 63 | assert.equal(processed[index], line); 64 | }); 65 | }); 66 | it('preserves code comments', () => { 67 | const markdown = '# heading\ntext\n```js\n// comment\n\ncode()\n```'; 68 | const expected = ['// comment', 'code()']; 69 | const processed = lines(markdown); 70 | expected.forEach((line, index) => { 71 | assert.equal(processed[index], line); 72 | }); 73 | }); 74 | it('leaves non-Markdown code untouched', () => { 75 | const input = 'console.log("hello world");'; 76 | const expected = 'console.log("hello world");'; 77 | const processed = lines(input).pop(); 78 | assert.equal(processed, expected); 79 | }); 80 | }); --------------------------------------------------------------------------------