├── .editorconfig ├── .gitignore ├── LICENSE ├── NOTICE.md ├── jest.config.js ├── package-lock.json ├── package.json ├── parse └── package.json ├── readme.md ├── scripts ├── build.js ├── clean.js └── license.js ├── src ├── index.ts ├── parse-html.ts ├── parse-markdown-render.ts ├── parse-markdown.ts ├── parse-page-navigation.ts ├── parse-table-of-contents.ts ├── parse-utils.ts ├── parse.ts ├── prism.js ├── slugify.ts ├── test │ ├── fixtures │ │ └── pages │ │ │ ├── about.md │ │ │ ├── contact.md │ │ │ ├── docs │ │ │ ├── index.md │ │ │ └── installation.md │ │ │ ├── guides │ │ │ ├── ide.md │ │ │ └── workflow.md │ │ │ ├── index.md │ │ │ └── readme.md │ ├── parse.spec.ts │ ├── render-jsx-ast.spec.ts │ ├── slugify.spec.ts │ └── url.spec.ts └── types.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | *.log 7 | *.lock 8 | *.tmp 9 | *.tmp.* 10 | log.txt 11 | *.sublime-project 12 | *.sublime-workspace 13 | .vscode/ 14 | node_modules/ 15 | $RECYCLE.BIN/ 16 | .DS_Store 17 | Thumbs.db 18 | UserInterfaceState.xcuserstate 19 | .env 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 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 | -------------------------------------------------------------------------------- /NOTICE.md: -------------------------------------------------------------------------------- 1 | # Licenses of Bundled Dependencies 2 | 3 | The published distribution contains the following licenses: 4 | 5 | BSD-2-Clause 6 | BSD-3-Clause 7 | MIT 8 | 9 | The following distributions have been modified to be bundled within this distribution: 10 | 11 | -------- 12 | 13 | ## `argparse` 14 | 15 | License: MIT 16 | 17 | Contributors: Eugene Shkuropat, Paul Jacobson 18 | 19 | > (The MIT License) 20 | > 21 | > Copyright (C) 2012 by Vitaly Puzrin 22 | > 23 | > Permission is hereby granted, free of charge, to any person obtaining a copy 24 | > of this software and associated documentation files (the "Software"), to deal 25 | > in the Software without restriction, including without limitation the rights 26 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 27 | > copies of the Software, and to permit persons to whom the Software is 28 | > furnished to do so, subject to the following conditions: 29 | > 30 | > The above copyright notice and this permission notice shall be included in 31 | > all copies or substantial portions of the Software. 32 | > 33 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 34 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 35 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 36 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 37 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 38 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 39 | > THE SOFTWARE. 40 | 41 | -------- 42 | 43 | ## `esprima` 44 | 45 | License: BSD-2-Clause 46 | 47 | Author: Ariya Hidayat 48 | 49 | Homepage: http://esprima.org 50 | 51 | 52 | -------- 53 | 54 | ## `front-matter` 55 | 56 | License: MIT 57 | 58 | Author: Jason Campbell (http://twitter.com/jxson) 59 | 60 | Contributors: Jason Campbell (http://twitter.com/jxson), Jordan Santell (https://github.com/jsantell), Kai Davenport (https://github.com/binocarlos), Jean-Philippe Monette (https://github.com/jpmonette), Marc-André Arseneault (https://github.com/arsnl), Bret Comnes (http://bret.io), Peter Bengtsson (https://github.com/peterbe) 61 | 62 | Homepage: https://github.com/jxson/front-matter 63 | 64 | > # The MIT License (MIT) 65 | > 66 | > Copyright (c) Jason Campbell ("Author") 67 | > 68 | > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 69 | > 70 | > The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 71 | > 72 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 73 | 74 | -------- 75 | 76 | ## `js-yaml` 77 | 78 | License: MIT 79 | 80 | Author: Vladimir Zapparov 81 | 82 | Contributors: Aleksey V Zapparov (http://www.ixti.net/), Vitaly Puzrin (https://github.com/puzrin), Martin Grenfell (http://got-ravings.blogspot.com) 83 | 84 | Homepage: https://github.com/nodeca/js-yaml 85 | 86 | > (The MIT License) 87 | > 88 | > Copyright (C) 2011-2015 by Vitaly Puzrin 89 | > 90 | > Permission is hereby granted, free of charge, to any person obtaining a copy 91 | > of this software and associated documentation files (the "Software"), to deal 92 | > in the Software without restriction, including without limitation the rights 93 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 94 | > copies of the Software, and to permit persons to whom the Software is 95 | > furnished to do so, subject to the following conditions: 96 | > 97 | > The above copyright notice and this permission notice shall be included in 98 | > all copies or substantial portions of the Software. 99 | > 100 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 101 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 102 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 103 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 104 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 105 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 106 | > THE SOFTWARE. 107 | 108 | -------- 109 | 110 | ## `marked` 111 | 112 | License: MIT 113 | 114 | Author: Christopher Jeffrey 115 | 116 | Homepage: https://marked.js.org 117 | 118 | > # License information 119 | > 120 | > ## Contribution License Agreement 121 | > 122 | > If you contribute code to this project, you are implicitly allowing your code 123 | > to be distributed under the MIT license. You are also implicitly verifying that 124 | > all code is your original work. `` 125 | > 126 | > ## Marked 127 | > 128 | > Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) 129 | > 130 | > Permission is hereby granted, free of charge, to any person obtaining a copy 131 | > of this software and associated documentation files (the "Software"), to deal 132 | > in the Software without restriction, including without limitation the rights 133 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 134 | > copies of the Software, and to permit persons to whom the Software is 135 | > furnished to do so, subject to the following conditions: 136 | > 137 | > The above copyright notice and this permission notice shall be included in 138 | > all copies or substantial portions of the Software. 139 | > 140 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 141 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 142 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 143 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 144 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 145 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 146 | > THE SOFTWARE. 147 | > 148 | > ## Markdown 149 | > 150 | > Copyright © 2004, John Gruber 151 | > http://daringfireball.net/ 152 | > All rights reserved. 153 | > 154 | > Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 155 | > 156 | > * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 157 | > * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 158 | > * Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 159 | > 160 | > This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage. 161 | 162 | -------- 163 | 164 | ## `prismjs` 165 | 166 | License: MIT 167 | 168 | Author: Lea Verou 169 | 170 | > MIT LICENSE 171 | > 172 | > Copyright (c) 2012 Lea Verou 173 | > 174 | > Permission is hereby granted, free of charge, to any person obtaining a copy 175 | > of this software and associated documentation files (the "Software"), to deal 176 | > in the Software without restriction, including without limitation the rights 177 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 178 | > copies of the Software, and to permit persons to whom the Software is 179 | > furnished to do so, subject to the following conditions: 180 | > 181 | > The above copyright notice and this permission notice shall be included in 182 | > all copies or substantial portions of the Software. 183 | > 184 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 185 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 186 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 187 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 188 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 189 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 190 | > THE SOFTWARE. 191 | 192 | -------- 193 | 194 | ## `slugify` 195 | 196 | License: MIT 197 | 198 | Author: Simeon Velichkov (https://simov.github.io) 199 | 200 | Homepage: https://github.com/simov/slugify 201 | 202 | > The MIT License (MIT) 203 | > 204 | > Copyright (c) Simeon Velichkov 205 | > 206 | > Permission is hereby granted, free of charge, to any person obtaining a copy 207 | > of this software and associated documentation files (the "Software"), to deal 208 | > in the Software without restriction, including without limitation the rights 209 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 210 | > copies of the Software, and to permit persons to whom the Software is 211 | > furnished to do so, subject to the following conditions: 212 | > 213 | > The above copyright notice and this permission notice shall be included in all 214 | > copies or substantial portions of the Software. 215 | > 216 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 217 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 218 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 219 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 220 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 221 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 222 | > SOFTWARE. 223 | 224 | -------- 225 | 226 | ## `sprintf-js` 227 | 228 | License: BSD-3-Clause 229 | 230 | Author: Alexandru Marasteanu (http://alexei.ro/) 231 | 232 | > Copyright (c) 2007-2014, Alexandru Marasteanu 233 | > All rights reserved. 234 | > 235 | > Redistribution and use in source and binary forms, with or without 236 | > modification, are permitted provided that the following conditions are met: 237 | > * Redistributions of source code must retain the above copyright 238 | > notice, this list of conditions and the following disclaimer. 239 | > * Redistributions in binary form must reproduce the above copyright 240 | > notice, this list of conditions and the following disclaimer in the 241 | > documentation and/or other materials provided with the distribution. 242 | > * Neither the name of this software nor the names of its contributors may be 243 | > used to endorse or promote products derived from this software without 244 | > specific prior written permission. 245 | > 246 | > THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 247 | > ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 248 | > WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 249 | > DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 250 | > ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 251 | > (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 252 | > LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 253 | > ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 254 | > (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 255 | > SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 256 | 257 | -------- 258 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['ts', 'tsx', 'js', 'mjs', 'jsx', 'json', 'd.ts'], 3 | testPathIgnorePatterns: ['/.cache', '/.stencil', '/.vscode', '/dist', '/node_modules', '/www'], 4 | testRegex: '(/__tests__/.*|\\.?(test|spec))\\.(ts)$', 5 | transform: { 6 | '^.+\\.(ts)$': '@stencil/core/testing/jest-preprocessor.js', 7 | }, 8 | watchPathIgnorePatterns: ['^.+\\.d\\.ts$'], 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stencil/ssg", 3 | "version": "0.0.22", 4 | "description": "Stencil Static Site Generation Utilities", 5 | "keywords": [ 6 | "static site generation", 7 | "static site generator", 8 | "static site", 9 | "ssg", 10 | "stenciljs" 11 | ], 12 | "author": "Ionic Team", 13 | "homepage": "https://stenciljs.com/", 14 | "main": "dist/index.js", 15 | "types": "dist/types/index.d.ts", 16 | "scripts": { 17 | "build": "node scripts/clean.js && tsc -p ./tsconfig.json && node scripts/build.js && node scripts/license.js", 18 | "license": "node scripts/license.js", 19 | "release": "np --no-2fa", 20 | "version": "npm run build", 21 | "test": "jest", 22 | "test.watch": "jest --watchAll" 23 | }, 24 | "license": "MIT", 25 | "files": [ 26 | "dist/", 27 | "parse/" 28 | ], 29 | "dependencies": { 30 | "@stencil/core": ">=2.1.0" 31 | }, 32 | "devDependencies": { 33 | "@ionic/prettier-config": "^1.0.1", 34 | "@rollup/plugin-commonjs": "^15.1.0", 35 | "@rollup/plugin-node-resolve": "^9.0.0", 36 | "@types/jest": "^26.0.14", 37 | "@types/marked": "^1.1.0", 38 | "@types/prismjs": "^1.16.1", 39 | "front-matter": "^4.0.2", 40 | "jest": "^26.5.3", 41 | "marked": "^1.2.0", 42 | "np": "^6.5.0", 43 | "prettier": "^2.1.2", 44 | "prismjs": "^1.22.0", 45 | "rollup": "^2.31.0", 46 | "rollup-plugin-terser": "^7.0.2", 47 | "slugify": "^1.4.5", 48 | "typescript": "^4.0.3" 49 | }, 50 | "prettier": "@ionic/prettier-config", 51 | "engines": { 52 | "node": ">=14.5.0" 53 | }, 54 | "publishConfig": { 55 | "access": "public" 56 | }, 57 | "repository": { 58 | "type": "git", 59 | "url": "git+https://github.com/ionic-team/stencil-ssg.git" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /parse/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stencil/ssg/parse", 3 | "description": "Stencil Static Site Generation Parse Utilities (NodeJS/CommonJS)", 4 | "main": "../dist/parse.js", 5 | "types": "../dist/types/parse.d.ts", 6 | "private": true 7 | } 8 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Stencil Static Site Generation Utilities 🏎💨 2 | 3 | Utility functions and helpers for building static sites with [Stencil](https://stenciljs.com/): 4 | 5 | - Parse Markdown 6 | - Parse HTML 7 | - Parse Yaml Front Matter 8 | - Code Syntax Highlighting with [Prism](https://prismjs.com/) 9 | - Generate site table of contents 10 | - Convert Markdown/HTML into serializable JSX 11 | - Functional Component to render serialized JSX with minimal runtime 12 | - Slugify text 13 | 14 | ## Syntax Highlighting 15 | 16 | Uses [Prism](https://prismjs.com/) at build-time for code block syntax highlighting. 17 | Prism JavaScript is not needed at run-time, however the Prism CSS must be provided by the site. 18 | 19 | ### Setting Code Language 20 | 21 | ```typescript 22 | const mph: number = 88; 23 | ``` 24 | 25 | ### Adding Code Diffs 26 | 27 | ```diff-typescript 28 | - const mph: number = 88; 29 | + let year = 85; 30 | ``` 31 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const rollup = require('rollup'); 4 | const rollupCommonjs = require('@rollup/plugin-commonjs'); 5 | const { nodeResolve } = require('@rollup/plugin-node-resolve'); 6 | 7 | const rootDir = path.join(__dirname, '..',); 8 | const buildDir = path.join(rootDir, 'build'); 9 | const distDir = path.join(rootDir, 'dist'); 10 | const srcPrism = path.join(rootDir, 'src', 'prism.js'); 11 | 12 | async function build() { 13 | await bundleIndexEsm(); 14 | await bundleParseCjs(); 15 | 16 | console.log(`Build success 🏎💨\n`); 17 | } 18 | 19 | async function bundleIndexEsm() { 20 | const inputOpts = { 21 | input: path.join(buildDir, 'index.js'), 22 | external: ['@stencil/core'], 23 | plugins: [nodeResolve()], 24 | }; 25 | 26 | const rollupBuild = await rollup.rollup(inputOpts); 27 | 28 | await rollupBuild.write({ 29 | format: 'esm', 30 | file: path.join(distDir, 'index.js'), 31 | preferConst: true, 32 | }); 33 | } 34 | 35 | async function bundleParseCjs() { 36 | const inputOpts = { 37 | input: path.join(buildDir, 'parse.js'), 38 | external: [ 39 | '@stencil/core/mock-doc', 40 | 'crypto', 41 | 'path', 42 | 'fs', 43 | 'os', 44 | 'util', 45 | ], 46 | plugins: [ 47 | nodeResolve({ 48 | preferBuiltins: true, 49 | }), 50 | rollupCommonjs(), 51 | ], 52 | }; 53 | 54 | let prismCode = `(function() {\n${fs.readFileSync(srcPrism, 'utf8')}\n})();`; 55 | prismCode = prismCode.replace(`module.exports = Prism;`, ``); 56 | 57 | const rollupBuild = await rollup.rollup(inputOpts); 58 | 59 | await rollupBuild.write({ 60 | format: 'cjs', 61 | file: path.join(distDir, 'parse.js'), 62 | preferConst: true, 63 | intro: prismCode 64 | }); 65 | } 66 | 67 | build(); 68 | -------------------------------------------------------------------------------- /scripts/clean.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | fs.rmdirSync(path.join(__dirname, '..', 'dist'), { recursive: true }); 5 | -------------------------------------------------------------------------------- /scripts/license.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const entryDeps = ['front-matter', 'marked', 'prismjs', 'slugify']; 5 | const excludeDeps = ['clipboard']; 6 | 7 | function createLicense() { 8 | const thirdPartyLicensesRootPath = path.join(__dirname, '..', 'NOTICE.md'); 9 | 10 | const bundledDeps = []; 11 | 12 | createBundledDeps(bundledDeps, entryDeps); 13 | 14 | bundledDeps.sort((a, b) => { 15 | if (a.moduleId < b.moduleId) return -1; 16 | if (a.moduleId > b.moduleId) return 1; 17 | return 0; 18 | }); 19 | 20 | const licenses = bundledDeps 21 | .map(l => l.license) 22 | .reduce((arr, l) => { 23 | if (!arr.includes(l)) { 24 | arr.push(l); 25 | } 26 | return arr; 27 | }, []) 28 | .sort(); 29 | 30 | const output = 31 | ` 32 | # Licenses of Bundled Dependencies 33 | 34 | The published distribution contains the following licenses: 35 | 36 | ${licenses.map(l => ` ` + l).join('\n')} 37 | 38 | The following distributions have been modified to be bundled within this distribution: 39 | 40 | -------- 41 | 42 | ${bundledDeps.map(l => l.content).join('\n')} 43 | 44 | `.trim() + '\n'; 45 | 46 | fs.writeFileSync(thirdPartyLicensesRootPath, output); 47 | 48 | console.log(`NOTICE.md created 🐙\n`); 49 | } 50 | 51 | function createBundledDeps(bundledDeps, deps) { 52 | if (Array.isArray(deps)) { 53 | deps.forEach(moduleId => { 54 | if (includeDepLicense(bundledDeps, moduleId)) { 55 | const bundledDep = createBundledDepLicense(moduleId); 56 | bundledDeps.push(bundledDep); 57 | 58 | createBundledDeps(bundledDeps, bundledDep.dependencies); 59 | } 60 | }); 61 | } 62 | } 63 | 64 | function createBundledDepLicense(moduleId) { 65 | const moduleDir = path.join(__dirname, '..', 'node_modules', moduleId); 66 | const pkgJsonFile = path.join(moduleDir, 'package.json'); 67 | const pkgJson = require(pkgJsonFile); 68 | const output = []; 69 | let license = null; 70 | 71 | output.push(`## \`${moduleId}\``, ``); 72 | 73 | if (typeof pkgJson.license === 'string') { 74 | license = pkgJson.license; 75 | output.push(`License: ${pkgJson.license}`, ``); 76 | } 77 | 78 | if (Array.isArray(pkgJson.licenses)) { 79 | const bundledLicenses = []; 80 | pkgJson.licenses.forEach(l => { 81 | if (l.type) { 82 | license = l.type; 83 | bundledLicenses.push(l.type); 84 | } 85 | }); 86 | 87 | if (bundledLicenses.length > 0) { 88 | output.push(`License: ${bundledLicenses.join(', ')}`, ``); 89 | } 90 | } 91 | 92 | const author = getContributors(pkgJson.author); 93 | if (typeof author === 'string') { 94 | output.push(`Author: ${author}`, ``); 95 | } 96 | 97 | const contributors = getContributors(pkgJson.contributors); 98 | if (typeof contributors === 'string') { 99 | output.push(`Contributors: ${contributors}`, ``); 100 | } 101 | 102 | if (typeof pkgJson.homepage === 'string') { 103 | output.push(`Homepage: ${pkgJson.homepage}`, ``); 104 | } 105 | 106 | const depLicense = getBundledDepLicenseContent(moduleDir); 107 | if (typeof depLicense === 'string') { 108 | depLicense 109 | .trim() 110 | .split('\n') 111 | .forEach(ln => { 112 | output.push(`> ${ln}`); 113 | }); 114 | } 115 | 116 | output.push(``, `--------`, ``); 117 | 118 | const dependencies = (pkgJson.dependencies 119 | ? Object.keys(pkgJson.dependencies) 120 | : [] 121 | ).sort(); 122 | 123 | return { 124 | moduleId, 125 | content: output.join('\n'), 126 | license, 127 | dependencies, 128 | }; 129 | } 130 | 131 | function getContributors(prop) { 132 | if (typeof prop === 'string') { 133 | return prop; 134 | } 135 | 136 | if (Array.isArray(prop)) { 137 | return prop 138 | .map(getAuthor) 139 | .filter(c => !!c) 140 | .join(', '); 141 | } 142 | 143 | if (prop) { 144 | return getAuthor(prop); 145 | } 146 | } 147 | 148 | function getAuthor(c) { 149 | if (typeof c === 'string') { 150 | return c; 151 | } 152 | if (typeof c.name === 'string') { 153 | if (typeof c.url === 'string') { 154 | return `[${c.name}](${c.url})`; 155 | } else { 156 | return c.name; 157 | } 158 | } 159 | if (typeof c.url === 'string') { 160 | return c.url; 161 | } 162 | } 163 | 164 | function getBundledDepLicenseContent(moduleDir) { 165 | const licenseFiles = ['LICENSE', 'LICENSE.md', 'LICENSE-MIT', 'LICENSE.txt']; 166 | for (const licenseFile of licenseFiles) { 167 | try { 168 | const licensePath = path.join(moduleDir, licenseFile); 169 | return fs.readFileSync(licensePath, 'utf8'); 170 | } catch (e) {} 171 | } 172 | } 173 | 174 | function includeDepLicense(bundledDeps, moduleId) { 175 | if (moduleId.startsWith('@types/')) { 176 | return false; 177 | } 178 | if (bundledDeps.some(b => b.moduleId === moduleId)) { 179 | return false; 180 | } 181 | if (excludeDeps.includes(moduleId)) { 182 | return false; 183 | } 184 | return true; 185 | } 186 | 187 | createLicense(); 188 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { h } from '@stencil/core'; 2 | import type { RenderJsxProps, JsxAstNode, ElementPropsHook } from './types'; 3 | 4 | /** 5 | * Functional component that renders markdown and html content that 6 | * has already been converted into a serializable JSX AST format. 7 | */ 8 | export const RenderJsxAst = (props: RenderJsxProps) => { 9 | if (props && Array.isArray(props.ast)) { 10 | const elementProps = 11 | typeof props.elementProps === 'function' ? props.elementProps : undefined; 12 | 13 | return props.ast.map(node => toHypertext(elementProps, node)); 14 | } 15 | return null; 16 | }; 17 | 18 | /** 19 | * Converts an nested array shaped like hypertext 20 | * arguments and applies them on `h()`. 21 | * `['div', { id: 'my-id' }, 'text']` 22 | * becomes 23 | * `h('div', { id: 'my-id' }, 'text')` 24 | */ 25 | const toHypertext = ( 26 | elementProps: ElementPropsHook | undefined, 27 | node: JsxAstNode[], 28 | ) => { 29 | if (!Array.isArray(node) || node.length < 2) { 30 | return null; 31 | } 32 | 33 | const args = []; 34 | const tagName = typeof node[0] === 'string' ? node[0].toLowerCase() : ''; 35 | 36 | let i: number; 37 | let l: number; 38 | let arg: any; 39 | 40 | for (i = 0, l = node.length; i < l; i++) { 41 | arg = node[i]; 42 | 43 | if (i === 1) { 44 | if (elementProps && tagName) { 45 | arg = elementProps(tagName, arg); 46 | } 47 | } else if (i > 1) { 48 | if (Array.isArray(arg)) { 49 | arg = toHypertext(elementProps, arg); 50 | } 51 | } 52 | args.push(arg); 53 | } 54 | 55 | return (h as any).apply(null, args); 56 | }; 57 | 58 | export type { 59 | AnchorData, 60 | HeadingData, 61 | HtmlResults, 62 | ImgData, 63 | JsxAstNode, 64 | MarkdownResults, 65 | PageNavigation, 66 | PageNavigationData, 67 | PageNavigationOptions, 68 | RenderJsxProps, 69 | SlugifyOptions, 70 | TableOfContents, 71 | TableOfContentsNode, 72 | } from './types'; 73 | 74 | export { slugify } from './slugify'; 75 | -------------------------------------------------------------------------------- /src/parse-html.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnchorData, 3 | HeadingData, 4 | HtmlResults, 5 | ImgData, 6 | ParseHtmlOptions, 7 | } from './types'; 8 | import { createFragment, serializeNodeToHtml } from '@stencil/core/mock-doc'; 9 | import { readFile } from './parse-utils'; 10 | import { slugify } from './slugify'; 11 | 12 | export async function parseHtml(filePath: string, opts?: ParseHtmlOptions) { 13 | const content = await readFile(filePath, 'utf8'); 14 | return parseHtmlContent(content, opts); 15 | } 16 | 17 | export async function parseHtmlContent( 18 | html: string, 19 | opts?: ParseHtmlOptions, 20 | ): Promise { 21 | opts = getHtmlOptions(opts); 22 | 23 | if (typeof html !== 'string') { 24 | throw new Error(`html must be a string`); 25 | } 26 | 27 | if (typeof opts.beforeHtmlParse === 'function') { 28 | html = await opts.beforeHtmlParse(html); 29 | if (typeof html !== 'string') { 30 | throw new Error(`returned html from beforeHtmlParse() must be a string`); 31 | } 32 | } 33 | 34 | const frag = createFragment(html); 35 | const doc = frag.ownerDocument; 36 | 37 | if (typeof opts.beforeHtmlSerialize === 'function') { 38 | await opts.beforeHtmlSerialize(frag); 39 | } 40 | 41 | const headingElms = frag.querySelectorAll('h1,h2,h3,h4,h5,h6'); 42 | const headings = Array.from(headingElms).map(headingElm => { 43 | const headingData: HeadingData = { 44 | text: headingElm.textContent!, 45 | level: (headingLevels as any)[headingElm.tagName], 46 | id: null, 47 | }; 48 | 49 | if (opts?.headingIds) { 50 | headingData.id = (opts.headingIdPrefix || '') + slugify(headingData.text); 51 | headingElm.setAttribute('id', headingData.id); 52 | } else { 53 | headingData.id = headingElm.getAttribute('id'); 54 | } 55 | 56 | if ( 57 | opts?.headingAnchors && 58 | typeof headingData.id === 'string' && 59 | headingData.id.length > 0 && 60 | headingData.level > 1 61 | ) { 62 | //

Text

63 | const anchor = doc.createElement('a'); 64 | anchor.setAttribute(`href`, `#${headingData.id}`); 65 | if (typeof opts.headingAnchorClassName === 'string') { 66 | anchor.className = opts.headingAnchorClassName; 67 | } 68 | anchor.setAttribute(`aria-hidden`, `true`); 69 | headingElm.insertBefore(anchor, headingElm.firstChild); 70 | } 71 | 72 | return headingData; 73 | }); 74 | 75 | if ( 76 | typeof opts.paragraphIntroClassName === 'string' && 77 | opts.paragraphIntroClassName.length > 0 78 | ) { 79 | const rootElements = Array.from(frag.children); 80 | const hasSubHeadings = rootElements.find(isSubHeading); 81 | if (hasSubHeadings) { 82 | // add paragraph intro class to every

until the first heading 83 | for (const elm of rootElements) { 84 | if (isSubHeading(elm)) { 85 | break; 86 | } 87 | if (elm.tagName === 'P') { 88 | elm.classList.add(opts.paragraphIntroClassName); 89 | } 90 | } 91 | } else { 92 | // no sub headings, so only add the class to the first paragraph 93 | for (const elm of rootElements) { 94 | if (elm.tagName === 'P') { 95 | elm.classList.add(opts.paragraphIntroClassName); 96 | break; 97 | } 98 | } 99 | } 100 | } 101 | const anchors: AnchorData[] = []; 102 | const imgs: ImgData[] = []; 103 | const tagNames: string[] = []; 104 | 105 | const ast = parsedNodeToJsxAst(frag, anchors, imgs, tagNames); 106 | 107 | return { 108 | ast, 109 | anchors, 110 | headings, 111 | imgs, 112 | tagNames, 113 | html: serializeNodeToHtml(frag), 114 | }; 115 | } 116 | 117 | const headingLevels = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 }; 118 | 119 | function isSubHeading(elm: Element) { 120 | if (elm) { 121 | const tagName = elm.tagName; 122 | return ( 123 | tagName === 'H2' || 124 | tagName === 'H3' || 125 | tagName === 'H4' || 126 | tagName === 'H5' || 127 | tagName === 'H6' 128 | ); 129 | } 130 | return false; 131 | } 132 | 133 | function getHtmlOptions(opts?: ParseHtmlOptions) { 134 | return { 135 | ...defaultParseHtmlOpts, 136 | ...opts, 137 | }; 138 | } 139 | 140 | const defaultParseHtmlOpts: ParseHtmlOptions = { 141 | headingAnchorClassName: `heading-anchor`, 142 | headingIds: true, 143 | headingAnchors: true, 144 | paragraphIntroClassName: `paragraph-intro`, 145 | }; 146 | 147 | /** 148 | * Converts parse5's html node format into a serialiable JSX AST format. 149 | *

bar
150 | * becomes 151 | * ['div', { id: "foo" }, "bar"] 152 | * This AST can then be quickly converted into JSX vdom at runtime 153 | */ 154 | function parsedNodeToJsxAst( 155 | node: Node, 156 | anchors: AnchorData[], 157 | imgs: ImgData[], 158 | tagNames: string[], 159 | ): any { 160 | if (node) { 161 | if (node.nodeName === '#text') { 162 | // text node 163 | return (node as Text).nodeValue; 164 | } 165 | 166 | if (node.nodeName === '#document-fragment') { 167 | // fragment 168 | const data: any[] = []; 169 | const childNodes = node.childNodes; 170 | for (let i = 0, l = childNodes.length; i < l; i++) { 171 | const n = parsedNodeToJsxAst(childNodes[i], anchors, imgs, tagNames); 172 | if (typeof n === 'string') { 173 | // fragment top level white space we can probably ignore 174 | if (n.trim() !== '') { 175 | const span = ['span', null, n]; 176 | data.push(span); 177 | } 178 | } else { 179 | data.push(n); 180 | } 181 | } 182 | return data; 183 | } 184 | 185 | const elm = node as HTMLElement; 186 | if (typeof elm.tagName === 'string') { 187 | // element 188 | const data: any[] = []; 189 | const attrs: { [tag: string]: any } = {}; 190 | let tag = elm.tagName.toLowerCase(); 191 | 192 | if (!tagNames.includes(tag)) { 193 | tagNames.push(tag); 194 | } 195 | 196 | data.push(tag); 197 | 198 | const elmAttributes = elm.attributes; 199 | const styleStr = elm.getAttribute('style'); 200 | 201 | if (elmAttributes.length > 0 || styleStr) { 202 | for (let j = 0, k = elmAttributes.length; j < k; j++) { 203 | const attr = elmAttributes[j]; 204 | if (attr) { 205 | attrs[attr.name] = attr.value; 206 | } 207 | } 208 | if (styleStr) { 209 | const style = convertStyleAttrToObj(styleStr); 210 | if (style && Object.keys(style).length > 0) { 211 | attrs.style = style; 212 | } 213 | } 214 | data.push(attrs); 215 | } else { 216 | data.push(null); 217 | } 218 | 219 | switch (tag) { 220 | case 'a': { 221 | const href = attrs.href; 222 | if (typeof href === 'string' && !href.startsWith('#')) { 223 | anchors.push({ 224 | text: elm.textContent!, 225 | href, 226 | }); 227 | } 228 | break; 229 | } 230 | case 'img': { 231 | imgs.push({ 232 | text: attrs.alt, 233 | src: attrs.src, 234 | }); 235 | break; 236 | } 237 | } 238 | 239 | const childNodes = elm.childNodes; 240 | for (let i = 0, l = childNodes.length; i < l; i++) { 241 | data.push(parsedNodeToJsxAst(childNodes[i], anchors, imgs, tagNames)); 242 | } 243 | 244 | return data; 245 | } 246 | } 247 | 248 | return ''; 249 | } 250 | 251 | function convertStyleAttrToObj(styleStr: string) { 252 | if (typeof styleStr === 'string' && styleStr.trim() !== '') { 253 | return styleStr.split(';').reduce((styleObj, style) => { 254 | const splt = style.split(':'); 255 | if (splt.length === 2) { 256 | const prop = splt[0].trim(); 257 | const value = splt[1].trim(); 258 | if (prop !== '') { 259 | styleObj[prop] = value; 260 | } 261 | } 262 | return styleObj; 263 | }, {} as any); 264 | } 265 | return null; 266 | } 267 | -------------------------------------------------------------------------------- /src/parse-markdown-render.ts: -------------------------------------------------------------------------------- 1 | import type { ParseMarkdownOptions } from './types'; 2 | import marked, { MarkedOptions, Renderer } from 'marked'; 3 | import type PrismGlobal from 'prismjs'; 4 | 5 | export function parseMarkdownRenderer( 6 | markdown: string, 7 | markedOpts: ParseMarkdownOptions, 8 | ) { 9 | return new Promise((resolve, reject) => { 10 | marked(markdown, markedOpts, (err, html) => { 11 | if (err) { 12 | reject(err); 13 | } else { 14 | resolve(html); 15 | } 16 | }); 17 | }); 18 | } 19 | 20 | class MarkedRenderer extends Renderer { 21 | constructor(private opts: ParseMarkdownOptions) { 22 | super(opts); 23 | } 24 | 25 | html(html: string): any { 26 | const regEx = /<(?[a-z]+(?:-[a-z]+)+)(?[\s\S]*)(?:\/>)/m; 27 | const match = html.match(regEx); 28 | 29 | type groupProps = { identifier: string, props?: string }; 30 | if (match) { 31 | const { identifier, props } = match.groups as groupProps; 32 | 33 | return `<${identifier}${props ? props : ''}>\n`; 34 | } 35 | 36 | return html; 37 | } 38 | 39 | paragraph(text: string) { 40 | const customComponentMatch = text.match(/<[a-z]+(-[a-z]+)+[^>]*>.*<\/[a-z]+(-[a-z]+)+>/m); 41 | if (customComponentMatch) return `${text}\n`; 42 | 43 | const imageMatch = text.match(/]*?\/>/m) 44 | if (imageMatch) return `${text}\n`; 45 | 46 | return `

${text}

\n`; 47 | } 48 | 49 | code(code: string, infostring: any, escaped: boolean) { 50 | if (this.opts.codeSyntaxHighlighting === false) { 51 | return super.code(code, infostring, escaped); 52 | } 53 | 54 | const info = getCodeBlockInfo(infostring, this.opts.langPrefix); 55 | if (info) { 56 | const grammar = Prism.languages[info.grammar]; 57 | if (grammar) { 58 | const prismCode = Prism.highlight(code, grammar, info.language); 59 | if (typeof prismCode === 'string') { 60 | return `
${prismCode}
\n`; 61 | } 62 | } 63 | } 64 | 65 | return `
${escaped ? code : escape(code, true)}
\n`; 66 | } 67 | 68 | heading(text: string, level: number) { 69 | return `${text}`; 70 | } 71 | 72 | image(href: string, title: string, text: string) { 73 | return `${text}`; 74 | } 75 | } 76 | 77 | function getCodeBlockInfo(infostring: string, langPrefix?: string) { 78 | if (typeof infostring === 'string') { 79 | infostring = infostring.trim().toLowerCase(); 80 | if (infostring.length > 0) { 81 | infostring = escape(infostring, true); 82 | langPrefix = (langPrefix || 'language-'); 83 | 84 | const info = { 85 | grammar: infostring, 86 | language: infostring, 87 | cssClass: langPrefix + infostring, 88 | }; 89 | 90 | if (infostring.startsWith('diff')) { 91 | // https://prismjs.com/plugins/diff-highlight/ 92 | info.grammar = 'diff'; 93 | info.cssClass += ` diff-highlight`; 94 | } 95 | 96 | return info; 97 | } 98 | } 99 | return null; 100 | } 101 | 102 | export function getMarkedOptions(opts: ParseMarkdownOptions) { 103 | opts = { 104 | ...defaultMarkedOpts, 105 | ...defaultParseMarkdownOpts, 106 | ...opts, 107 | }; 108 | 109 | const renderer = new MarkedRenderer(opts); 110 | (opts as MarkedOptions).renderer = renderer; 111 | return opts; 112 | } 113 | 114 | const defaultMarkedOpts: MarkedOptions = { 115 | silent: false, 116 | smartLists: true, 117 | smartypants: true, 118 | }; 119 | 120 | const defaultParseMarkdownOpts: ParseMarkdownOptions = { 121 | breaks: false, 122 | codeSyntaxHighlighting: true, 123 | gfm: true, 124 | }; 125 | 126 | const escapeTest = /[&<>"']/; 127 | const escapeReplace = /[&<>"']/g; 128 | const escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/; 129 | const escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g; 130 | const escapeReplacements: any = { 131 | '&': '&', 132 | '<': '<', 133 | '>': '>', 134 | '"': '"', 135 | "'": ''', 136 | }; 137 | const getEscapeReplacement = (ch: string) => escapeReplacements[ch]; 138 | 139 | function escape(html: string, encode: boolean) { 140 | if (encode) { 141 | if (escapeTest.test(html)) { 142 | return html.replace(escapeReplace, getEscapeReplacement); 143 | } 144 | } else { 145 | if (escapeTestNoEncode.test(html)) { 146 | return html.replace(escapeReplaceNoEncode, getEscapeReplacement); 147 | } 148 | } 149 | 150 | return html; 151 | } 152 | 153 | declare const Prism: typeof PrismGlobal; -------------------------------------------------------------------------------- /src/parse-markdown.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ParseMarkdownOptions, 3 | ParseMarkdownContentOptions, 4 | MarkdownResults, 5 | } from './types'; 6 | import frontMatter, { FrontMatterOptions } from 'front-matter'; 7 | import { parseHtmlContent } from './parse-html'; 8 | import { 9 | parseMarkdownRenderer, 10 | getMarkedOptions, 11 | } from './parse-markdown-render'; 12 | import { readFile, slugifyFilePath } from './parse-utils'; 13 | import path from 'path'; 14 | 15 | /** 16 | * Inputs markdown file path and reads the file and parses the yaml front matter data into 17 | * `attributes`, and the body of the markdown content is parsed into `html`. The 18 | * `html` is also parsed into a serializable `ast` format which can be used later 19 | * on the client-side togenerate JSX. Providing a `cache` argument will speed up builds. 20 | * @param id The mardown file path or id to be resolved. See the `resolveMarkdownPath` option for more info. 21 | * The file's content should include yaml front matter metadata and markdown in the body.. 22 | * @param opts Yaml, markdown, html and caching parsing options. 23 | */ 24 | export async function parseMarkdown(id: string, opts?: ParseMarkdownOptions) { 25 | opts = opts || {}; 26 | 27 | let content: string; 28 | let filePath: string; 29 | if (typeof opts.resolveMarkdownPath === 'function') { 30 | // user provided a custom markdown path resolver 31 | filePath = await opts.resolveMarkdownPath(id); 32 | content = await readFile(filePath, 'utf8'); 33 | } else { 34 | const readResults = await readMarkdownContent(id); 35 | content = readResults.content; 36 | filePath = readResults.filePath; 37 | } 38 | 39 | const results: MarkdownResults = await parseMarkdownContent(content, opts); 40 | 41 | if (typeof results.slug !== 'string') { 42 | results.slug = slugifyFilePath(filePath); 43 | } 44 | results.filePath = filePath; 45 | 46 | return results; 47 | } 48 | 49 | export async function readMarkdownContent(filePath: string) { 50 | const ext = path.extname(filePath).toLowerCase(); 51 | const results = { 52 | content: '', 53 | filePath, 54 | slug: '', 55 | }; 56 | 57 | if (ext === '.markdown') { 58 | throw new Error( 59 | `@stencil/markdown will only use ".md" markdown extensions: ${filePath}`, 60 | ); 61 | } 62 | 63 | if (ext === '.md') { 64 | // always do a direct read if it ends with .md or .markdown 65 | // don't try resolving if that doesn't work 66 | results.content = await readFile(filePath, 'utf8'); 67 | } else { 68 | let mdFilePath: string; 69 | let indexMdFilePath: string; 70 | 71 | if (filePath.endsWith('/')) { 72 | // with file path of `pages/my-file/`, will attempt: 73 | // 1. `pages/my-file.md` 74 | // 2. `pages/my-file/index.md` 75 | mdFilePath = filePath.substring(0, filePath.length - 1) + '.md'; 76 | indexMdFilePath = path.join( 77 | filePath.substring(0, filePath.length - 1), 78 | 'index.md', 79 | ); 80 | } else { 81 | // with file path of `pages/my-file`, will attempt: 82 | // 1. `pages/my-file.md` 83 | // 2. `pages/my-file/index.md` 84 | mdFilePath = filePath + '.md'; 85 | indexMdFilePath = path.join(filePath, 'index.md'); 86 | } 87 | 88 | try { 89 | results.content = await readFile(mdFilePath, 'utf8'); 90 | results.filePath = mdFilePath; 91 | } catch { 92 | try { 93 | results.content = await readFile(indexMdFilePath, 'utf8'); 94 | results.filePath = indexMdFilePath; 95 | } catch { 96 | throw new Error( 97 | `Unable to read: "${filePath}". Attempted: "${mdFilePath}", "${indexMdFilePath}"`, 98 | ); 99 | } 100 | } 101 | } 102 | 103 | results.slug = slugifyFilePath(results.filePath); 104 | return results; 105 | } 106 | 107 | /** 108 | * Inputs markdown content as a string and parses the yaml front matter data into 109 | * `attributes`, and the body of the markdown content is parsed into `html`. The 110 | * `html` is also parsed into a serializable `ast` format which can be used later 111 | * on the client-side togenerate JSX. Providing a `cache` argument will speed up builds. 112 | * @param content The mardown content, to include yaml front matter metadata. 113 | * @param opts Yaml, markdown, html and caching parsing options. 114 | */ 115 | export async function parseMarkdownContent( 116 | content: string, 117 | opts?: ParseMarkdownContentOptions, 118 | ) { 119 | if (typeof content !== 'string') { 120 | throw new Error(`content must be a string`); 121 | } 122 | 123 | opts = opts || {}; 124 | content = content.trim(); 125 | 126 | const fsOpts = getFrontMatterOptions(opts); 127 | const markedOpts = getMarkedOptions(opts); 128 | 129 | const fmResults = frontMatter(content, fsOpts); 130 | 131 | let markdownContent = fmResults.body; 132 | 133 | if (typeof opts.beforeMarkdownToHtml === 'function') { 134 | const fmAttrs = fmResults.attributes 135 | ? JSON.parse(JSON.stringify(fmResults.attributes)) 136 | : {}; 137 | markdownContent = await opts.beforeMarkdownToHtml(markdownContent, fmAttrs); 138 | if (typeof markdownContent !== 'string') { 139 | throw new Error( 140 | `returned markdown content from beforeMarkdownToHtml() must be a string`, 141 | ); 142 | } 143 | } 144 | 145 | const html = await parseMarkdownRenderer(markdownContent, markedOpts); 146 | 147 | const htmlResults = await parseHtmlContent(html, opts); 148 | 149 | const attributes = { ...(fmResults.attributes as any) }; 150 | 151 | const results: MarkdownResults = { 152 | attributes, 153 | html: htmlResults.html, 154 | ast: htmlResults.ast, 155 | anchors: htmlResults.anchors, 156 | headings: htmlResults.headings, 157 | imgs: htmlResults.imgs, 158 | tagNames: htmlResults.tagNames, 159 | }; 160 | 161 | if (typeof attributes.title === 'string') { 162 | results.title = attributes.title; 163 | } 164 | if (typeof attributes.description === 'string') { 165 | results.description = attributes.description; 166 | } 167 | if (typeof attributes.slug === 'string') { 168 | results.slug = attributes.slug; 169 | } 170 | 171 | return results; 172 | } 173 | 174 | function getFrontMatterOptions(opts: ParseMarkdownOptions) { 175 | const fsOpts: FrontMatterOptions = { 176 | allowUnsafe: opts.allowUnsafe !== false, 177 | }; 178 | return fsOpts; 179 | } 180 | -------------------------------------------------------------------------------- /src/parse-page-navigation.ts: -------------------------------------------------------------------------------- 1 | import type { PageNavigation, PageNavigationOptions } from './types'; 2 | import { 3 | findBestMatch, 4 | getTableOfContentsData, 5 | } from './parse-table-of-contents'; 6 | import path from 'path'; 7 | 8 | /** 9 | * Figure out the current url path relative from the root pages directory to the file path. 10 | * Will create a url path always starting with `/`. Any index file name, such as `index.md` will 11 | * be dropped. For example, `/info/about/index.md` will become `/info/about`. By passing the 12 | * `tocFilePath` table of contents file path option, it will use the table of contents to figure 13 | * out the previous page, next page, and parent page. 14 | * @param rootPagesDir The directory path representing the root of the website. 15 | * @param pageFilePath The absolute path of the file, which should be a descendant of the `rootPagesDir`. 16 | */ 17 | export async function getPageNavigation( 18 | rootPagesDir: string, 19 | pageFilePath: string, 20 | opts?: PageNavigationOptions, 21 | ) { 22 | opts = opts || {}; 23 | 24 | const results: PageNavigation = { 25 | current: { 26 | url: getUrl(rootPagesDir, pageFilePath, opts), 27 | title: null, 28 | }, 29 | parent: null, 30 | previous: null, 31 | next: null, 32 | }; 33 | 34 | if (opts.tableOfContents) { 35 | const r = getTableOfContentsData(opts.tableOfContents); 36 | 37 | let currentIndex = -1; 38 | for (let i = r.length - 1; i >= 0; i--) { 39 | if (r[i].file === pageFilePath) { 40 | currentIndex = i; 41 | break; 42 | } 43 | } 44 | 45 | if (currentIndex > -1) { 46 | const current = r[currentIndex]; 47 | results.current!.title = current.title; 48 | 49 | const prevTocs = r.slice(0, currentIndex).reverse(); 50 | const prev = findBestMatch(current.file, prevTocs); 51 | if (prev) { 52 | results.previous = { 53 | url: getUrl(rootPagesDir, prev.file, opts), 54 | title: prev.title, 55 | }; 56 | } 57 | 58 | const nextTocs = r.slice(currentIndex + 1); 59 | const next = findBestMatch(current.file, nextTocs); 60 | if (next) { 61 | results.next = { 62 | url: getUrl(rootPagesDir, next.file, opts), 63 | title: next.title, 64 | }; 65 | } 66 | 67 | const parent = findBestMatch( 68 | current.file, 69 | current.ancestorFiles?.reverse(), 70 | ); 71 | if (parent) { 72 | results.parent = { 73 | url: getUrl(rootPagesDir, parent.file, opts), 74 | title: parent.title, 75 | }; 76 | } 77 | } 78 | } 79 | 80 | return results; 81 | } 82 | 83 | export function getUrl( 84 | rootPagesDir: string, 85 | pageFilePath: string, 86 | opts: PageNavigationOptions, 87 | ) { 88 | if (typeof pageFilePath !== 'string' || pageFilePath === '') { 89 | return null; 90 | } 91 | 92 | rootPagesDir = path.normalize(rootPagesDir); 93 | pageFilePath = path.normalize(pageFilePath); 94 | 95 | if (!pageFilePath.startsWith(rootPagesDir)) { 96 | throw new Error( 97 | `page file "${pageFilePath}" must be a descendant of the root directory "${rootPagesDir}"`, 98 | ); 99 | } 100 | 101 | let url = path.relative(rootPagesDir, pageFilePath); 102 | 103 | const basename = path.basename(url).toLowerCase(); 104 | const ext = path.extname(basename); 105 | 106 | if (ext !== '.md') { 107 | throw new Error(`file must have a ".md" extension`); 108 | } 109 | 110 | if (basename === 'index.md') { 111 | url = path.dirname(url); 112 | } 113 | 114 | if (url === '.') { 115 | url = '/'; 116 | } else { 117 | if (url.endsWith('.md')) { 118 | url = url.substr(0, url.length - 3); 119 | } 120 | if (!url.startsWith('/')) { 121 | url = '/' + url; 122 | } 123 | if (opts.trailingSlash && !url.endsWith('/')) { 124 | url += '/'; 125 | } 126 | } 127 | 128 | return url; 129 | } 130 | 131 | // async function getTitle 132 | -------------------------------------------------------------------------------- /src/parse-table-of-contents.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TableOfContentsNode, 3 | ParseTableOfContentsOptions, 4 | TableOfContents, 5 | } from './types'; 6 | import { createFragment } from '@stencil/core/mock-doc'; 7 | import { 8 | getMarkedOptions, 9 | parseMarkdownRenderer, 10 | } from './parse-markdown-render'; 11 | import { getUrl } from './parse-page-navigation'; 12 | import { readFile } from './parse-utils'; 13 | import path from 'path'; 14 | 15 | const tocCache = new Map(); 16 | 17 | /** 18 | * Parses a markdown file with a list of markdown files and their title. This table of contents can be 19 | * used to create a site menu, such as a webpage's left menu, and it is used to figure out the 20 | * "next" and "previous" page links. 21 | * @param tocMarkdownFilePath The absolute file path of the table of contents markdown file to parse. 22 | * Each link's path to its markdown file will be relative to the table of contents file path. 23 | * @param rootPagesDir The directory path representing the root of the website. Each link's url will 24 | * be relative to the root pages directory. 25 | */ 26 | export async function parseTableOfContents( 27 | tocMarkdownFilePath: string, 28 | rootPagesDir: string, 29 | opts?: ParseTableOfContentsOptions, 30 | ) { 31 | opts = opts || {}; 32 | const content = await readFile(tocMarkdownFilePath, 'utf8'); 33 | 34 | const cacheKey = content + tocMarkdownFilePath + rootPagesDir; 35 | const cachedToc = tocCache.get(cacheKey); 36 | if (cachedToc) { 37 | if (tocCache.size > 500) { 38 | // not sure how this would happen, but just in case 39 | tocCache.clear(); 40 | } 41 | return cachedToc; 42 | } 43 | 44 | const html = await parseMarkdownRenderer(content, getMarkedOptions({})); 45 | 46 | const frag = createFragment(html); 47 | const ulElm = frag.querySelector('ul'); 48 | 49 | const tocs: TableOfContents = { 50 | tocFilePath: tocMarkdownFilePath, 51 | tocDirPath: path.dirname(tocMarkdownFilePath), 52 | rootPagesDir, 53 | root: [], 54 | }; 55 | parseTableOfContentsItem( 56 | 0, 57 | tocs.tocDirPath, 58 | rootPagesDir, 59 | ulElm, 60 | false, 61 | tocs.root, 62 | opts, 63 | ); 64 | 65 | tocCache.set(cacheKey, tocs); 66 | 67 | return tocs; 68 | } 69 | 70 | function parseTableOfContentsItem( 71 | depth: number, 72 | tocDirPath: string, 73 | rootPagesDir: string, 74 | ulElm: HTMLElement | null, 75 | hasParent: boolean, 76 | tocs: TableOfContentsNode[], 77 | opts: ParseTableOfContentsOptions, 78 | ) { 79 | if (ulElm) { 80 | const liElms = Array.from(ulElm.children).filter(n => n.nodeName === 'LI'); 81 | 82 | for (const liElm of liElms) { 83 | const tocNode: TableOfContentsNode = { depth }; 84 | const childNodes = Array.from(liElm.childNodes); 85 | 86 | let addNode = false; 87 | for (const n of childNodes) { 88 | if (n.nodeName === '#text') { 89 | const text = (n as Text).textContent; 90 | if (typeof text === 'string' && text.trim() !== '') { 91 | tocNode.text = text; 92 | addNode = true; 93 | } 94 | } else if (typeof (n as HTMLElement).tagName === 'string') { 95 | const elm = n as HTMLElement; 96 | if (elm.tagName === 'A') { 97 | const text = elm.textContent; 98 | if (typeof text === 'string' && text.trim() !== '') { 99 | tocNode.text = text; 100 | } 101 | 102 | const hrefValue = elm.getAttribute('href'); 103 | if (typeof hrefValue === 'string' && hrefValue.trim().length > 0) { 104 | const href = hrefValue.split('#')[0].split('?')[0]; 105 | tocNode.url = href; 106 | 107 | if (!href.toLowerCase().startsWith('http')) { 108 | const markdownFilePath = path.join(tocDirPath, href); 109 | const ext = path.extname(markdownFilePath).toLowerCase(); 110 | 111 | if (path.isAbsolute(markdownFilePath) && ext === '.md') { 112 | const url = getUrl(rootPagesDir, markdownFilePath, opts); 113 | if (url) { 114 | tocNode.url = url; 115 | } 116 | tocNode.file = path.relative(tocDirPath, markdownFilePath); 117 | } 118 | } 119 | } 120 | addNode = true; 121 | } else if (elm.tagName === 'UL') { 122 | const tocsChildren: TableOfContentsNode[] = []; 123 | parseTableOfContentsItem( 124 | depth + 1, 125 | tocDirPath, 126 | rootPagesDir, 127 | n as any, 128 | true, 129 | tocsChildren, 130 | opts, 131 | ); 132 | if (tocsChildren.length > 0) { 133 | addNode = true; 134 | tocNode.children = tocsChildren; 135 | } 136 | } 137 | } 138 | } 139 | 140 | if (addNode) { 141 | if (hasParent) { 142 | tocNode.hasParent = true; 143 | } 144 | tocs.push(tocNode); 145 | } 146 | } 147 | } 148 | } 149 | 150 | export function getTableOfContentsData(toc: TableOfContents) { 151 | const r: WalkResult[] = []; 152 | findPath([], toc.root, toc.tocDirPath, r); 153 | return r; 154 | } 155 | 156 | function findPath( 157 | ancestorFiles: WalkResult[], 158 | toc: TableOfContentsNode[], 159 | tocDir: string, 160 | r: WalkResult[], 161 | ) { 162 | if (Array.isArray(toc)) { 163 | for (const t of toc) { 164 | let filePath = t.file ? path.join(tocDir, t.file) : ''; 165 | 166 | r.push({ 167 | file: filePath, 168 | title: t.text!, 169 | ancestorFiles, 170 | depth: t.depth, 171 | }); 172 | 173 | const af = [ 174 | ...ancestorFiles.map(a => { 175 | return { 176 | file: a.file, 177 | title: a.title, 178 | depth: a.depth, 179 | }; 180 | }), 181 | { 182 | file: filePath, 183 | title: t.text!, 184 | depth: t.depth, 185 | }, 186 | ]; 187 | 188 | findPath(af, t.children!, tocDir, r); 189 | } 190 | } 191 | } 192 | 193 | export function findBestMatch( 194 | currentFile: string, 195 | r: WalkResult[] | undefined, 196 | ) { 197 | if (Array.isArray(r)) { 198 | let a = r.filter(t => t.file && t.file !== currentFile && t.title); 199 | if (a.length > 0) { 200 | const f = a[0].file; 201 | a = a.filter(b => b.file === f); 202 | return a[a.length - 1]; 203 | } 204 | const b = r.find(t => t.file && t.file !== currentFile); 205 | 206 | if (b) { 207 | return b; 208 | } 209 | return r[r.length - 1]; 210 | } 211 | return null; 212 | } 213 | 214 | interface WalkResult { 215 | title: string; 216 | file: string; 217 | depth: number; 218 | ancestorFiles?: WalkResult[]; 219 | } 220 | -------------------------------------------------------------------------------- /src/parse-utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { promisify } from 'util'; 4 | import { slugify } from './slugify'; 5 | 6 | export const readFile = promisify(fs.readFile); 7 | export const writeFile = promisify(fs.writeFile); 8 | 9 | export const slugifyFilePath = (filePath: string) => { 10 | if (typeof filePath === 'string') { 11 | let basename = path.basename(filePath); 12 | if (basename.toLowerCase() === 'index.md') { 13 | basename = path.basename(path.dirname(filePath)); 14 | } 15 | return slugify(basename!); 16 | } 17 | return ''; 18 | }; 19 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | AnchorData, 3 | HeadingData, 4 | HtmlResults, 5 | ImgData, 6 | JsxAstNode, 7 | MarkdownResults, 8 | PageNavigation, 9 | PageNavigationData, 10 | ParseHtmlOptions, 11 | ParseMarkdownContentOptions, 12 | ParseMarkdownOptions, 13 | PageNavigationOptions, 14 | RenderJsxProps, 15 | SlugifyOptions, 16 | TableOfContents, 17 | TableOfContentsNode, 18 | } from './types'; 19 | 20 | export { getPageNavigation } from './parse-page-navigation'; 21 | export { parseHtml, parseHtmlContent } from './parse-html'; 22 | export { 23 | parseMarkdown, 24 | parseMarkdownContent, 25 | readMarkdownContent, 26 | } from './parse-markdown'; 27 | export { parseTableOfContents } from './parse-table-of-contents'; 28 | export { slugify } from './slugify'; 29 | export { slugifyFilePath } from './parse-utils'; 30 | -------------------------------------------------------------------------------- /src/slugify.ts: -------------------------------------------------------------------------------- 1 | import type { SlugifyOptions } from './types'; 2 | 3 | /** 4 | * Utility function to transform the input into a url-friendly 5 | * "slug". For example, when given a file name such as 6 | * `My Blog Title.md` the slug becomes `my-blog-title`. 7 | * Ported from `https://github.com/simov/slugify` with 8 | * some added options. 9 | */ 10 | export const slugify = (str: string, opts: SlugifyOptions = {}) => { 11 | if (typeof str !== 'string') { 12 | throw new Error('slugify: string argument expected'); 13 | } 14 | 15 | if (opts.removeFileExtension !== false) { 16 | const dotSplit = str.split('.'); 17 | const lastPart = dotSplit.pop(); 18 | if (lastPart && removeSlugExts[lastPart.toLowerCase()]) { 19 | str = dotSplit.join('.'); 20 | } 21 | } 22 | 23 | const locale = slugifyLocales[opts.locale!] || {}; 24 | 25 | const replacement = opts.replacement === undefined ? '-' : opts.replacement; 26 | 27 | str = str 28 | .split('') 29 | // replace characters based on charMap 30 | .reduce( 31 | (result, ch) => 32 | result + 33 | (locale[ch] || slugifyCharMap[ch] || ch) 34 | // remove not allowed characters 35 | .replace(opts.remove || /[^\w\s$*_+~.()'"!\-:@]+/g, ''), 36 | '', 37 | ) 38 | // trim leading/trailing spaces 39 | .trim() 40 | // convert spaces to replacement character 41 | // also remove duplicates of the replacement character 42 | .replace(new RegExp('[\\s' + replacement + ']+', 'g'), replacement); 43 | 44 | if (opts.lower !== false) { 45 | str = str.toLowerCase(); 46 | } 47 | 48 | if (opts.strict !== false) { 49 | // remove anything besides letters, numbers, and the replacement char 50 | str = str 51 | .replace(new RegExp('[^a-zA-Z0-9' + replacement + ']', 'g'), '') 52 | // remove duplicates of the replacement character 53 | .replace(new RegExp('[\\s' + replacement + ']+', 'g'), replacement); 54 | } 55 | 56 | if (opts.trimReplacement !== false) { 57 | if (str.length > 2 && str.startsWith(replacement)) { 58 | str = str.substring(1); 59 | } 60 | if (str.length > 2 && str.endsWith(replacement)) { 61 | str = str.substring(0, str.length - 1); 62 | } 63 | } 64 | return str; 65 | }; 66 | 67 | const slugifyCharMap = JSON.parse( 68 | '{"$":"dollar","%":"percent","&":"and","<":"less",">":"greater","|":"or","¢":"cent","£":"pound","¤":"currency","¥":"yen","©":"(c)","ª":"a","®":"(r)","º":"o","À":"A","Á":"A","Â":"A","Ã":"A","Ä":"A","Å":"A","Æ":"AE","Ç":"C","È":"E","É":"E","Ê":"E","Ë":"E","Ì":"I","Í":"I","Î":"I","Ï":"I","Ð":"D","Ñ":"N","Ò":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","Ù":"U","Ú":"U","Û":"U","Ü":"U","Ý":"Y","Þ":"TH","ß":"ss","à":"a","á":"a","â":"a","ã":"a","ä":"a","å":"a","æ":"ae","ç":"c","è":"e","é":"e","ê":"e","ë":"e","ì":"i","í":"i","î":"i","ï":"i","ð":"d","ñ":"n","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","ù":"u","ú":"u","û":"u","ü":"u","ý":"y","þ":"th","ÿ":"y","Ā":"A","ā":"a","Ă":"A","ă":"a","Ą":"A","ą":"a","Ć":"C","ć":"c","Č":"C","č":"c","Ď":"D","ď":"d","Đ":"DJ","đ":"dj","Ē":"E","ē":"e","Ė":"E","ė":"e","Ę":"e","ę":"e","Ě":"E","ě":"e","Ğ":"G","ğ":"g","Ģ":"G","ģ":"g","Ĩ":"I","ĩ":"i","Ī":"i","ī":"i","Į":"I","į":"i","İ":"I","ı":"i","Ķ":"k","ķ":"k","Ļ":"L","ļ":"l","Ľ":"L","ľ":"l","Ł":"L","ł":"l","Ń":"N","ń":"n","Ņ":"N","ņ":"n","Ň":"N","ň":"n","Ō":"O","ō":"o","Ő":"O","ő":"o","Œ":"OE","œ":"oe","Ŕ":"R","ŕ":"r","Ř":"R","ř":"r","Ś":"S","ś":"s","Ş":"S","ş":"s","Š":"S","š":"s","Ţ":"T","ţ":"t","Ť":"T","ť":"t","Ũ":"U","ũ":"u","Ū":"u","ū":"u","Ů":"U","ů":"u","Ű":"U","ű":"u","Ų":"U","ų":"u","Ŵ":"W","ŵ":"w","Ŷ":"Y","ŷ":"y","Ÿ":"Y","Ź":"Z","ź":"z","Ż":"Z","ż":"z","Ž":"Z","ž":"z","ƒ":"f","Ơ":"O","ơ":"o","Ư":"U","ư":"u","Lj":"LJ","lj":"lj","Nj":"NJ","nj":"nj","Ș":"S","ș":"s","Ț":"T","ț":"t","˚":"o","Ά":"A","Έ":"E","Ή":"H","Ί":"I","Ό":"O","Ύ":"Y","Ώ":"W","ΐ":"i","Α":"A","Β":"B","Γ":"G","Δ":"D","Ε":"E","Ζ":"Z","Η":"H","Θ":"8","Ι":"I","Κ":"K","Λ":"L","Μ":"M","Ν":"N","Ξ":"3","Ο":"O","Π":"P","Ρ":"R","Σ":"S","Τ":"T","Υ":"Y","Φ":"F","Χ":"X","Ψ":"PS","Ω":"W","Ϊ":"I","Ϋ":"Y","ά":"a","έ":"e","ή":"h","ί":"i","ΰ":"y","α":"a","β":"b","γ":"g","δ":"d","ε":"e","ζ":"z","η":"h","θ":"8","ι":"i","κ":"k","λ":"l","μ":"m","ν":"n","ξ":"3","ο":"o","π":"p","ρ":"r","ς":"s","σ":"s","τ":"t","υ":"y","φ":"f","χ":"x","ψ":"ps","ω":"w","ϊ":"i","ϋ":"y","ό":"o","ύ":"y","ώ":"w","Ё":"Yo","Ђ":"DJ","Є":"Ye","І":"I","Ї":"Yi","Ј":"J","Љ":"LJ","Њ":"NJ","Ћ":"C","Џ":"DZ","А":"A","Б":"B","В":"V","Г":"G","Д":"D","Е":"E","Ж":"Zh","З":"Z","И":"I","Й":"J","К":"K","Л":"L","М":"M","Н":"N","О":"O","П":"P","Р":"R","С":"S","Т":"T","У":"U","Ф":"F","Х":"H","Ц":"C","Ч":"Ch","Ш":"Sh","Щ":"Sh","Ъ":"U","Ы":"Y","Ь":"","Э":"E","Ю":"Yu","Я":"Ya","а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ж":"zh","з":"z","и":"i","й":"j","к":"k","л":"l","м":"m","н":"n","о":"o","п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"c","ч":"ch","ш":"sh","щ":"sh","ъ":"u","ы":"y","ь":"","э":"e","ю":"yu","я":"ya","ё":"yo","ђ":"dj","є":"ye","і":"i","ї":"yi","ј":"j","љ":"lj","њ":"nj","ћ":"c","ѝ":"u","џ":"dz","Ґ":"G","ґ":"g","Ғ":"GH","ғ":"gh","Қ":"KH","қ":"kh","Ң":"NG","ң":"ng","Ү":"UE","ү":"ue","Ұ":"U","ұ":"u","Һ":"H","һ":"h","Ә":"AE","ә":"ae","Ө":"OE","ө":"oe","฿":"baht","ა":"a","ბ":"b","გ":"g","დ":"d","ე":"e","ვ":"v","ზ":"z","თ":"t","ი":"i","კ":"k","ლ":"l","მ":"m","ნ":"n","ო":"o","პ":"p","ჟ":"zh","რ":"r","ს":"s","ტ":"t","უ":"u","ფ":"f","ქ":"k","ღ":"gh","ყ":"q","შ":"sh","ჩ":"ch","ც":"ts","ძ":"dz","წ":"ts","ჭ":"ch","ხ":"kh","ჯ":"j","ჰ":"h","Ẁ":"W","ẁ":"w","Ẃ":"W","ẃ":"w","Ẅ":"W","ẅ":"w","ẞ":"SS","Ạ":"A","ạ":"a","Ả":"A","ả":"a","Ấ":"A","ấ":"a","Ầ":"A","ầ":"a","Ẩ":"A","ẩ":"a","Ẫ":"A","ẫ":"a","Ậ":"A","ậ":"a","Ắ":"A","ắ":"a","Ằ":"A","ằ":"a","Ẳ":"A","ẳ":"a","Ẵ":"A","ẵ":"a","Ặ":"A","ặ":"a","Ẹ":"E","ẹ":"e","Ẻ":"E","ẻ":"e","Ẽ":"E","ẽ":"e","Ế":"E","ế":"e","Ề":"E","ề":"e","Ể":"E","ể":"e","Ễ":"E","ễ":"e","Ệ":"E","ệ":"e","Ỉ":"I","ỉ":"i","Ị":"I","ị":"i","Ọ":"O","ọ":"o","Ỏ":"O","ỏ":"o","Ố":"O","ố":"o","Ồ":"O","ồ":"o","Ổ":"O","ổ":"o","Ỗ":"O","ỗ":"o","Ộ":"O","ộ":"o","Ớ":"O","ớ":"o","Ờ":"O","ờ":"o","Ở":"O","ở":"o","Ỡ":"O","ỡ":"o","Ợ":"O","ợ":"o","Ụ":"U","ụ":"u","Ủ":"U","ủ":"u","Ứ":"U","ứ":"u","Ừ":"U","ừ":"u","Ử":"U","ử":"u","Ữ":"U","ữ":"u","Ự":"U","ự":"u","Ỳ":"Y","ỳ":"y","Ỵ":"Y","ỵ":"y","Ỷ":"Y","ỷ":"y","Ỹ":"Y","ỹ":"y","‘":"\'","’":"\'","“":"\\"","”":"\\"","†":"+","•":"*","…":"...","₠":"ecu","₢":"cruzeiro","₣":"french franc","₤":"lira","₥":"mill","₦":"naira","₧":"peseta","₨":"rupee","₩":"won","₪":"new shequel","₫":"dong","€":"euro","₭":"kip","₮":"tugrik","₯":"drachma","₰":"penny","₱":"peso","₲":"guarani","₳":"austral","₴":"hryvnia","₵":"cedi","₸":"kazakhstani tenge","₹":"indian rupee","₺":"turkish lira","₽":"russian ruble","₿":"bitcoin","℠":"sm","™":"tm","∂":"d","∆":"delta","∑":"sum","∞":"infinity","♥":"love","元":"yuan","円":"yen","﷼":"rial"}', 69 | ); 70 | 71 | const slugifyLocales = JSON.parse( 72 | '{"de":{"Ä":"AE","ä":"ae","Ö":"OE","ö":"oe","Ü":"UE","ü":"ue"},"vi":{"Đ":"D","đ":"d"}}', 73 | ); 74 | 75 | const removeSlugExts: { [key: string]: true } = { 76 | md: true, 77 | markdown: true, 78 | html: true, 79 | htm: true, 80 | txt: true, 81 | png: true, 82 | jpeg: true, 83 | jpg: true, 84 | }; 85 | -------------------------------------------------------------------------------- /src/test/fixtures/pages/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: About Title 3 | --- 4 | 5 | # About Header 6 | 7 | About Paragraph 8 | -------------------------------------------------------------------------------- /src/test/fixtures/pages/contact.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contact Title 3 | --- 4 | 5 | # Contact Header 6 | 7 | Contact Paragraph 8 | -------------------------------------------------------------------------------- /src/test/fixtures/pages/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Docs Title 3 | --- 4 | 5 | # Docs Header 6 | 7 | Docs Paragraph 8 | -------------------------------------------------------------------------------- /src/test/fixtures/pages/docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started Title 3 | --- 4 | 5 | # Getting Started Header 6 | 7 | Getting Started Paragraph 8 | -------------------------------------------------------------------------------- /src/test/fixtures/pages/guides/ide.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started Title 3 | --- 4 | 5 | # Getting Started Header 6 | 7 | Getting Started Paragraph 8 | -------------------------------------------------------------------------------- /src/test/fixtures/pages/guides/workflow.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Workflow Title 3 | --- 4 | 5 | # Workflow Header 6 | 7 | Workflow Paragraph 8 | -------------------------------------------------------------------------------- /src/test/fixtures/pages/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Index Title 3 | --- 4 | 5 | # Index Header 6 | 7 | Index Paragraph 8 | -------------------------------------------------------------------------------- /src/test/fixtures/pages/readme.md: -------------------------------------------------------------------------------- 1 | # Root Table Of Contents 2 | 3 | * [Introduction](index.md) 4 | * [Documentation](docs/index.md) 5 | * [Getting Started](docs/index.md) 6 | * [Installation](docs/installation.md) 7 | * [About](about.md) 8 | * Guides 9 | * [Development Workflow](guides/workflow.md) 10 | * [IDE](guides/ide.md) 11 | * [Contact](contact.md) 12 | -------------------------------------------------------------------------------- /src/test/parse.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseMarkdownContent } from '../parse'; 2 | import type { ParseMarkdownOptions } from '../types'; 3 | 4 | describe(`parseMarkdownContent`, () => { 5 | require('../prism.js'); 6 | let opts: ParseMarkdownOptions; 7 | 8 | beforeEach(() => { 9 | opts = {}; 10 | }); 11 | 12 | it(`before html serialize`, async () => { 13 | opts.headingAnchors = false; 14 | opts.beforeHtmlSerialize = frag => { 15 | const h1 = frag.querySelector('h1'); 16 | h1.textContent = `Updated Heading`; 17 | const div = frag.ownerDocument.createElement('div'); 18 | div.innerHTML = ``; 19 | frag.appendChild(div); 20 | }; 21 | const r = await parseMarkdownContent( 22 | md(` 23 | # Heading 24 | 25 | Paragraph 26 | `), 27 | opts, 28 | ); 29 | 30 | expect(r.headings[0].level).toBe(1); 31 | expect(r.headings[0].text).toBe(`Updated Heading`); 32 | expect(r.headings[0].id).toBe(`updated-heading`); 33 | expect(r.html).toBe( 34 | `

Updated Heading

Paragraph

`, 35 | ); 36 | }); 37 | 38 | it(`heading id prefix`, async () => { 39 | opts.headingIdPrefix = 'some-prefix-'; 40 | const r = await parseMarkdownContent(md(`# Heading`), opts); 41 | 42 | expect(r.html).toBe( 43 | `

Heading

`, 44 | ); 45 | }); 46 | 47 | it(`heading anchor links by default`, async () => { 48 | const r = await parseMarkdownContent(md(`## Heading`), opts); 49 | 50 | expect(r.html).toBe( 51 | `

Heading

`, 52 | ); 53 | }); 54 | 55 | it(`no heading anchor links`, async () => { 56 | opts.headingAnchors = false; 57 | const r = await parseMarkdownContent(md(`# Heading`), opts); 58 | 59 | expect(r.html).toBe(`

Heading

`); 60 | }); 61 | 62 | it(`no heading anchor links if headingAnchors=true but headingIds=false`, async () => { 63 | opts.headingIds = false; 64 | opts.headingAnchors = true; 65 | const r = await parseMarkdownContent(md(`# Heading`), opts); 66 | 67 | expect(r.html).toBe(`

Heading

`); 68 | }); 69 | 70 | it(`paragraph intro with no sub headings`, async () => { 71 | opts.headingAnchors = false; 72 | const r = await parseMarkdownContent( 73 | md(` 74 | # Heading1 75 | 76 | Paragraph 1 77 | 78 | Paragraph 2 79 | `), 80 | opts, 81 | ); 82 | 83 | expect(r.html).toBe( 84 | `

Heading1

Paragraph 1

Paragraph 2

`, 85 | ); 86 | }); 87 | 88 | it(`paragraph intro with sub headings`, async () => { 89 | opts.headingAnchors = false; 90 | const r = await parseMarkdownContent( 91 | md(` 92 | # Heading1 93 | 94 | Paragraph 1 95 | 96 | Paragraph 2 97 | 98 | ## Header2 99 | 100 | Paragraph 3 101 | `), 102 | opts, 103 | ); 104 | 105 | expect(r.html).toBe( 106 | `

Heading1

Paragraph 1

Paragraph 2

Header2

Paragraph 3

`, 107 | ); 108 | }); 109 | 110 | it(`code block diff-typescript`, async () => { 111 | const c: string[] = []; 112 | c.push('```diff-typescript'); 113 | c.push('-let x = 88;'); 114 | c.push('+let x = 99;'); 115 | c.push('```'); 116 | const r = await parseMarkdownContent(c.join('\n'), opts); 117 | expect(r.html).toContain('
');
118 |     expect(r.html).toContain('-');
119 |     expect(r.html).toContain('+');
120 |     expect(r.html).toContain('let');
121 |     expect(r.html).toContain('=');
122 |   });
123 | 
124 |   it(`code block diff`, async () => {
125 |     const c: string[] = [];
126 |     c.push('```diff');
127 |     c.push('-let x = 88;');
128 |     c.push('+let x = 99;');
129 |     c.push('```');
130 |     const r = await parseMarkdownContent(c.join('\n'), opts);
131 |     expect(r.html).toContain('
');
132 |     expect(r.html).toContain('-let x = 88;');
133 |     expect(r.html).toContain('+let x = 99;');
134 |     expect(r.html).not.toContain('let');
135 |     expect(r.html).not.toContain('=');
136 |   });
137 | 
138 |   it(`code block typescript`, async () => {
139 |     const c: string[] = [];
140 |     c.push('```typescript');
141 |     c.push('function mph() {');
142 |     c.push('       return 88;');
143 |     c.push('}');
144 |     c.push('```');
145 |     const r = await parseMarkdownContent(c.join('\n'), opts);
146 |     expect(r.html).toContain('
');
147 |   });
148 | 
149 |   it(`images and inlined styles`, async () => {
150 |     const r = await parseMarkdownContent(
151 |       md(`
152 |         save the clock tower
153 |       `),
154 |       opts,
155 |     );
156 |     expect(r.imgs).toHaveLength(1);
157 |     expect(r.imgs[0].text).toBe(`save the clock tower`);
158 |     expect(r.imgs[0].src).toBe(`clock-tower.png`);
159 | 
160 |     const imgAst = r.ast[0];
161 |     expect(imgAst).toHaveLength(2);
162 |     expect(imgAst[0]).toBe('img');
163 |     expect(imgAst[1].alt).toBe('save the clock tower');
164 |     expect(imgAst[1].src).toBe('clock-tower.png');
165 |     expect(imgAst[1].style).toEqual({
166 |       'max-height': '360px',
167 |       'width': '240px',
168 |     });
169 |     expect(imgAst[1].class).toBe('marty mcfly');
170 |   });
171 | 
172 |   it(`anchors`, async () => {
173 |     const r = await parseMarkdownContent(
174 |       md(`
175 |         # Hill Valley
176 | 
177 |         Save the [clock](/clock) [tower](/tower)!
178 |       `),
179 |       opts,
180 |     );
181 |     expect(r.anchors).toHaveLength(2);
182 |     expect(r.anchors[0].text).toBe(`clock`);
183 |     expect(r.anchors[0].href).toBe(`/clock`);
184 |     expect(r.anchors[1].text).toBe(`tower`);
185 |     expect(r.anchors[1].href).toBe(`/tower`);
186 |   });
187 | 
188 |   it(`headings`, async () => {
189 |     const r = await parseMarkdownContent(
190 |       md(`
191 |         # Heading1
192 | 
193 |         Contenta
194 | 
195 |         ## Heading2a
196 | 
197 |         Contentb
198 | 
199 |         ### Heading3a
200 | 
201 |         Contentc
202 | 
203 |         ## Heading 2b [link](/link)
204 |       `),
205 |       opts,
206 |     );
207 |     expect(r.headings).toHaveLength(4);
208 |     expect(r.headings[0].text).toBe(`Heading1`);
209 |     expect(r.headings[0].id).toBe(`heading1`);
210 |     expect(r.headings[0].level).toBe(1);
211 |     expect(r.headings[1].text).toBe(`Heading2a`);
212 |     expect(r.headings[1].id).toBe(`heading2a`);
213 |     expect(r.headings[1].level).toBe(2);
214 |     expect(r.headings[2].text).toBe(`Heading3a`);
215 |     expect(r.headings[2].id).toBe(`heading3a`);
216 |     expect(r.headings[2].level).toBe(3);
217 |     expect(r.headings[3].text).toBe(`Heading 2b link`);
218 |     expect(r.headings[3].id).toBe(`heading-2b-link`);
219 |     expect(r.headings[3].level).toBe(2);
220 |   });
221 | 
222 |   it(`anchors`, async () => {
223 |     const r = await parseMarkdownContent(
224 |       md(`
225 |         # Hill Valley
226 | 
227 |         Save the ![clock tower](/clock-tower.png)
228 |       `),
229 |       opts,
230 |     );
231 |     expect(r.imgs).toHaveLength(1);
232 |     expect(r.imgs[0].text).toBe(`clock tower`);
233 |     expect(r.imgs[0].src).toBe(`/clock-tower.png`);
234 |   });
235 | 
236 |   it(`no br breaks default`, async () => {
237 |     const r = await parseMarkdownContent(
238 |       md(`
239 |         Save
240 |         the
241 |         clock
242 |         tower
243 |       `),
244 |       opts,
245 |     );
246 |     expect(r.html.replace(/\n/g, ' ')).toBe(`

Save the clock tower

`); 247 | }); 248 | 249 | it(`set br breaks`, async () => { 250 | opts.breaks = true; 251 | const r = await parseMarkdownContent( 252 | md(` 253 | Save 254 | the 255 | clock 256 | tower 257 | `), 258 | opts, 259 | ); 260 | expect(r.html.replace(/\n/g, ' ')).toBe(`

Save
the
clock
tower

`); 261 | }); 262 | 263 | it(`attributes`, async () => { 264 | const r = await parseMarkdownContent( 265 | md(` 266 | --- 267 | title: StencilJS 268 | description: Markdown parser 269 | --- 270 | 271 | # Heading1 272 | `), 273 | opts, 274 | ); 275 | expect(r.attributes.title).toBe(`StencilJS`); 276 | expect(r.attributes.description).toBe(`Markdown parser`); 277 | }); 278 | 279 | it(`Custom components in full format at top level. Includes one with multiple dashes.`, async () => { 280 | const r = await parseMarkdownContent( 281 | md(` 282 | This is the first paragraph. 283 | 284 | component text here 285 | 286 | 287 | 288 | This is the second paragraph. 289 | `), 290 | opts, 291 | ); 292 | 293 | const firstParagraphAst = r.ast[0]; 294 | const customComponentAst = r.ast[1]; 295 | const veryCustomComponentAst = r.ast[2]; 296 | const secondParagraphAst = r.ast[3]; 297 | 298 | expect(firstParagraphAst[0]).toBe(`p`); 299 | expect(firstParagraphAst[2]).toBe(`This is the first paragraph.`); 300 | 301 | expect(customComponentAst[0]).toBe(`custom-component`); 302 | expect(customComponentAst[2]).toBe(`component text here`); 303 | 304 | expect(veryCustomComponentAst[0]).toBe(`very-custom-component`); 305 | expect(veryCustomComponentAst[1].class).toBe(`ui-component`); 306 | 307 | expect(secondParagraphAst[0]).toBe(`p`); 308 | expect(secondParagraphAst[2]).toBe(`This is the second paragraph.`); 309 | }); 310 | 311 | it(`custom components in abbreviated format at top level`, async () => { 312 | const r = await parseMarkdownContent( 313 | md(` 314 | This is the first paragraph. 315 | 316 | 324 | 325 | This is the second paragraph. 326 | `), 327 | opts, 328 | ); 329 | const firstParagraphAst = r.ast[0]; 330 | const customComponentAst = r.ast[1]; 331 | const secondParagraphAst = r.ast[2]; 332 | 333 | expect(firstParagraphAst[0]).toBe(`p`); 334 | expect(firstParagraphAst[2]).toBe(`This is the first paragraph.`); 335 | 336 | expect(customComponentAst[0]).toBe(`ui-button`); 337 | expect(customComponentAst[1].id).toBe(`myComponent`); 338 | expect(customComponentAst[1].class).toBe(`ui-button ui-button--round`); 339 | expect(customComponentAst[1].text).toBe(`submit`); 340 | expect(customComponentAst[1].type).toBe(`round`); 341 | expect(customComponentAst[1].back).toBe(`blue`); 342 | expect(customComponentAst[1].style['max-height']).toBe(`360px`); 343 | expect(customComponentAst[1].style.width).toBe(`240px`); 344 | 345 | expect(secondParagraphAst[0]).toBe(`p`); 346 | expect(secondParagraphAst[2]).toBe(`This is the second paragraph.`); 347 | }); 348 | 349 | it(`render images at top level`, async () => { 350 | const r = await parseMarkdownContent( 351 | md(` 352 | You can install these easily by opening 353 | 354 | ![SDK Platforms](/assets/img/docs/android/sdk-platforms.png) 355 | ![SDK Tools](/assets/img/docs/android/sdk-tools.png) 356 | 357 | # Creating Android Project 358 | `), 359 | opts, 360 | ); 361 | 362 | const firstParagraphAst = r.ast[0]; 363 | const firstImageAst = r.ast[1]; 364 | const secondImageAst = r.ast[2]; 365 | const firstHeadingAst = r.ast[3]; 366 | 367 | expect(firstParagraphAst[0]).toBe(`p`); 368 | expect(firstParagraphAst[2]).toBe(`You can install these easily by opening`); 369 | 370 | expect(firstImageAst[0]).toBe(`img`); 371 | expect(secondImageAst[0]).toBe(`img`); 372 | expect(secondImageAst[1].alt).toBe(`SDK Tools`); 373 | 374 | expect(firstHeadingAst[0]).toBe(`h1`); 375 | expect(firstHeadingAst[2]).toBe(`Creating Android Project`); 376 | }); 377 | }); 378 | 379 | function md(txt: string) { 380 | const lines = txt.split('\n'); 381 | return lines 382 | .map(l => l.trimLeft()) 383 | .join('\n') 384 | .trim(); 385 | } 386 | -------------------------------------------------------------------------------- /src/test/render-jsx-ast.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseMarkdownContent } from '../parse'; 2 | import { RenderJsxAst } from '../index'; 3 | import type { RenderJsxProps, ParseMarkdownOptions } from '../types'; 4 | 5 | describe(`RenderJsxAst`, () => { 6 | let opts: ParseMarkdownOptions; 7 | 8 | beforeEach(() => { 9 | opts = {}; 10 | }); 11 | 12 | it(`parse/serialize, elementProps`, async () => { 13 | const r = await parseMarkdownContent( 14 | md(` 15 | Save the [clock](/clock) [tower](/tower)! 16 | `), 17 | opts, 18 | ); 19 | 20 | const href = (url: string) => { 21 | return { 22 | href: url, 23 | onClick: () => console.log('clicked href'), 24 | }; 25 | }; 26 | 27 | const props: RenderJsxProps = { 28 | ast: r.ast, 29 | elementProps: (tagName, orgProps) => { 30 | const newProps = { ...orgProps }; 31 | if (tagName === 'p') { 32 | return { 33 | ...orgProps, 34 | class: 'paragraph', 35 | }; 36 | } else if (tagName === 'a') { 37 | return { 38 | ...orgProps, 39 | ...href(orgProps.href + '?custom'), 40 | }; 41 | } 42 | return orgProps; 43 | }, 44 | }; 45 | const vdom = RenderJsxAst(props); 46 | 47 | const p = vdom[0]; 48 | expect(p.$tag$).toBe('p'); 49 | expect(p.$attrs$.class).toBe('paragraph'); 50 | expect(p.$children$[0].$text$).toBe('Save the '); 51 | 52 | expect(p.$children$[1].$tag$).toBe('a'); 53 | expect(p.$children$[1].$attrs$.href).toBe('/clock?custom'); 54 | expect(p.$children$[1].$attrs$.onClick).toBeDefined(); 55 | }); 56 | 57 | it(`parse/serialize`, async () => { 58 | opts.headingAnchors = false; 59 | const r = await parseMarkdownContent( 60 | md(` 61 | # Hill Valley 62 | 63 | Save the [clock](/clock) [tower](/tower)! 64 | `), 65 | opts, 66 | ); 67 | const props: RenderJsxProps = { ast: r.ast }; 68 | const vdom = RenderJsxAst(props); 69 | expect(vdom).toHaveLength(2); 70 | const h1 = vdom[0]; 71 | expect(h1.$tag$).toBe('h1'); 72 | expect(h1.$text$).toBe(null); 73 | expect(h1.$children$[0].$text$).toBe('Hill Valley'); 74 | expect(h1.$children$[0].$children$).toBe(null); 75 | 76 | const p = vdom[1]; 77 | expect(p.$tag$).toBe('p'); 78 | expect(p.$text$).toBe(null); 79 | expect(p.$children$[0].$text$).toBe('Save the '); 80 | 81 | expect(p.$children$[1].$tag$).toBe('a'); 82 | expect(p.$children$[1].$text$).toBe(null); 83 | expect(p.$children$[1].$children$[0].$text$).toBe('clock'); 84 | 85 | expect(p.$children$[2].$text$).toBe(' '); 86 | 87 | expect(p.$children$[3].$tag$).toBe('a'); 88 | expect(p.$children$[3].$text$).toBe(null); 89 | expect(p.$children$[3].$children$[0].$text$).toBe('tower'); 90 | }); 91 | }); 92 | 93 | function md(txt: string) { 94 | const lines = txt.split('\n'); 95 | return lines 96 | .map(l => l.trimLeft()) 97 | .join('\n') 98 | .trim(); 99 | } 100 | -------------------------------------------------------------------------------- /src/test/slugify.spec.ts: -------------------------------------------------------------------------------- 1 | import { slugify } from '../slugify'; 2 | 3 | it(`slugify`, () => { 4 | expect(slugify(`My Slugify`)).toBe(`my-slugify`); 5 | expect(slugify(`My Slugify`, { replacement: `_` })).toBe(`my_slugify`); 6 | expect(slugify(`My Slugify`, { lower: false })).toBe(`My-Slugify`); 7 | expect(slugify(`My Slugify!!`)).toBe(`my-slugify`); 8 | expect(slugify(`My Slugify!!`, { strict: false })).toBe(`my-slugify!!`); 9 | expect(slugify(`My Slugify.markdown`)).toBe(`my-slugify`); 10 | expect(slugify(`My Slugify.md`, { removeFileExtension: false })).toBe( 11 | `my-slugifymd`, 12 | ); 13 | expect(slugify(`---My Slugify---`)).toBe(`my-slugify`); 14 | expect(slugify(`---My Slugify---`, { replacement: `_` })).toBe(`my_slugify`); 15 | expect(slugify(`---My Slugify---`, { trimReplacement: false })).toBe( 16 | `-my-slugify-`, 17 | ); 18 | expect(slugify(`-`)).toBe(`-`); 19 | expect(slugify(`--`)).toBe(`-`); 20 | expect(slugify(`---`)).toBe(`-`); 21 | expect(slugify(`--#--This is Some Example of A_ Heading?!--`)).toBe( 22 | `this-is-some-example-of-a-heading`, 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /src/test/url.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPageNavigation, 3 | parseTableOfContents, 4 | PageNavigationOptions, 5 | } from '../parse'; 6 | import path from 'path'; 7 | import os from 'os'; 8 | 9 | describe(`getPagination`, () => { 10 | const rootPagesDir = path.join(__dirname, 'fixtures', 'pages'); 11 | const tocFilePath = path.join(rootPagesDir, 'readme.md'); 12 | let opts: PageNavigationOptions; 13 | 14 | beforeEach(async () => { 15 | opts = { 16 | tableOfContents: await parseTableOfContents(tocFilePath, rootPagesDir), 17 | }; 18 | }); 19 | 20 | it(`toc last`, async () => { 21 | const pageFilePath = path.join(rootPagesDir, 'contact.md'); 22 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 23 | expect(r.current.url).toBe('/contact'); 24 | expect(r.current.title).toBe('Contact'); 25 | expect(r.parent).toBe(null); 26 | expect(r.previous.url).toBe('/guides/ide'); 27 | expect(r.previous.title).toBe('IDE'); 28 | expect(r.next).toBe(null); 29 | }); 30 | 31 | it(`toc 2nd level w/ non-link parent`, async () => { 32 | const pageFilePath = path.join(rootPagesDir, 'guides', 'ide.md'); 33 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 34 | expect(r.current.url).toBe('/guides/ide'); 35 | expect(r.current.title).toBe('IDE'); 36 | expect(r.parent.url).toBe(null); 37 | expect(r.parent.title).toBe('Guides'); 38 | expect(r.previous.url).toBe('/guides/workflow'); 39 | expect(r.previous.title).toBe('Development Workflow'); 40 | expect(r.next.url).toBe('/contact'); 41 | expect(r.next.title).toBe('Contact'); 42 | }); 43 | 44 | it(`toc 2nd level w/ same url as top level`, async () => { 45 | const pageFilePath = path.join(rootPagesDir, 'docs', 'index.md'); 46 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 47 | expect(r.current.url).toBe('/docs'); 48 | expect(r.current.title).toBe('Getting Started'); 49 | expect(r.parent.url).toBe('/docs'); 50 | expect(r.parent.title).toBe('Documentation'); 51 | expect(r.previous.url).toBe('/'); 52 | expect(r.previous.title).toBe('Introduction'); 53 | expect(r.next.url).toBe('/docs/installation'); 54 | expect(r.next.title).toBe('Installation'); 55 | }); 56 | 57 | it(`toc root, third link, skip non-next toc link`, async () => { 58 | const pageFilePath = path.join(rootPagesDir, 'about.md'); 59 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 60 | expect(r.current.url).toBe('/about'); 61 | expect(r.current.title).toBe('About'); 62 | expect(r.parent).toBe(null); 63 | expect(r.previous.url).toBe('/docs/installation'); 64 | expect(r.previous.title).toBe('Installation'); 65 | expect(r.next.url).toBe('/guides/workflow'); 66 | expect(r.next.title).toBe('Development Workflow'); 67 | }); 68 | 69 | it(`toc root`, async () => { 70 | const pageFilePath = path.join(rootPagesDir, 'index.md'); 71 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 72 | expect(r.current.url).toBe('/'); 73 | expect(r.current.title).toBe('Introduction'); 74 | expect(r.parent).toBe(null); 75 | expect(r.previous).toBe(null); 76 | expect(r.next.url).toBe('/docs'); 77 | expect(r.next.title).toBe('Getting Started'); 78 | }); 79 | 80 | it(`directory w/ index.md trailing slash`, async () => { 81 | opts.trailingSlash = true; 82 | const pageFilePath = path.join(rootPagesDir, 'docs', 'index.md'); 83 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 84 | expect(r.current.url).toBe('/docs/'); 85 | }); 86 | 87 | it(`directory w/ index.md`, async () => { 88 | const pageFilePath = path.join(rootPagesDir, 'docs', 'index.md'); 89 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 90 | expect(r.current.url).toBe('/docs'); 91 | }); 92 | 93 | it(`directory w/ filename.md`, async () => { 94 | const pageFilePath = path.join(rootPagesDir, 'docs', 'getting-started.md'); 95 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 96 | expect(r.current.url).toBe('/docs/getting-started'); 97 | }); 98 | 99 | it(`root w/ filename.md`, async () => { 100 | const pageFilePath = path.join(rootPagesDir, 'about-us.md'); 101 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 102 | expect(r.current.url).toBe('/about-us'); 103 | }); 104 | 105 | it(`root index w/ trailing slash`, async () => { 106 | opts.trailingSlash = true; 107 | const pageFilePath = path.join(rootPagesDir, 'index.md'); 108 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 109 | expect(r.current.url).toBe('/'); 110 | }); 111 | 112 | it(`root index`, async () => { 113 | const pageFilePath = path.join(rootPagesDir, 'index.md'); 114 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 115 | expect(r.current.url).toBe('/'); 116 | }); 117 | 118 | it(`must be descendant`, async () => { 119 | try { 120 | const r = await getPageNavigation(rootPagesDir, os.tmpdir(), opts); 121 | } catch (e) { 122 | return; 123 | } 124 | throw new Error('must be descendant'); 125 | }); 126 | 127 | it(`must be a markdown file`, async () => { 128 | try { 129 | const pageFilePath = path.join(rootPagesDir, 'index.html'); 130 | const r = await getPageNavigation(rootPagesDir, pageFilePath, opts); 131 | } catch (e) { 132 | return; 133 | } 134 | throw new Error('must be a markdown file'); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ParseHtmlOptions { 2 | /** 3 | * Hook that can be used to modify the HTML content before it is parsed 4 | * into a `document`. Should return the updated HTML as a string. 5 | */ 6 | beforeHtmlParse?(htmlContent: string): string | Promise; 7 | /** 8 | * Hook that can be used to modify the document fragment before it is 9 | * serialized into an HTML string. The fragment is a standards based 10 | * DOM object and common web APIs such as setAttribute and querySelector 11 | * can be used. The `document` can be accessed at `frag.ownerDocument`. 12 | * To create an element, use `frag.ownerDocument.createElement('div')`. 13 | * The frag is updated in place. 14 | * @param frag DOM document fragment of the parsed content. 15 | */ 16 | beforeHtmlSerialize?(frag: DocumentFragment): void | Promise; 17 | /** 18 | * Include an id attribute in h1-h6 heading tags. 19 | * @default true 20 | */ 21 | headingIds?: boolean; 22 | /** 23 | * Set the prefix for heading tag ids. 24 | */ 25 | headingIdPrefix?: string; 26 | /** 27 | * Include anchors within h2-h6 tags using their heading id as the href hash. 28 | * This is useful so h2-h6 headings can be linked to using a hash in the url. 29 | * Additionally, `headingIds` must be `true` for heading anchors. It's recommended 30 | * to also set the anchor's CSS on a heading hover. 31 | * 32 | * `

Text

` 33 | * @default true 34 | */ 35 | headingAnchors?: boolean; 36 | /** 37 | * The CSS classname to add to heading anchor elements. 38 | * @default "heading-anchor" 39 | */ 40 | headingAnchorClassName?: string; 41 | /** 42 | * CSS classname to be added to the first paragraphs found within the content. 43 | * Intro paragraphs are the first paragraphs before the first subheading. For 44 | * example, all the paragraphs between the `h1` and `h2` headings will get the 45 | * paragraph intro CSS classname, but all paragraphs after the `h2` will 46 | * not receive the classname. If there are no subheadings, then only the first 47 | * paragraph will receive the classname. 48 | * @default "paragraph-intro" 49 | */ 50 | paragraphIntroClassName?: string; 51 | } 52 | 53 | export interface ParseMarkdownContentOptions extends ParseHtmlOptions { 54 | /** 55 | * Whether to use [safeload](https://github.com/nodeca/js-yaml#safeload-string---options-) 56 | * @default true 57 | */ 58 | allowUnsafe?: boolean; 59 | /** 60 | * A prefix URL for any relative link. 61 | */ 62 | baseUrl?: string; 63 | /** 64 | * Hook that can be used to modify the markdown content before it is converted 65 | * into HTML. This happens after the markdown file's front matter has been parsed 66 | * and the front matter attributes are available as the second param. Should return 67 | * the updated markdown content. 68 | * @param markdownContent The markdown content. 69 | * @param frontMatterAttributes The front matter attributes already parsed from the markdown content 70 | */ 71 | beforeMarkdownToHtml?( 72 | markdownContent: string, 73 | frontMatterAttributes: any, 74 | ): string | Promise; 75 | /** 76 | * Enable GFM line breaks. This option requires the gfm option to be true. 77 | * @default false 78 | */ 79 | breaks?: boolean; 80 | /** 81 | * Add syntax highlighting to code blocks. Uses [PrismJS](https://prismjs.com/) 82 | * at build time. The correct prism language css styles should also be added 83 | * to the pages rendering the code blocks. 84 | * @default true 85 | */ 86 | codeSyntaxHighlighting?: boolean; 87 | /** 88 | * Enable GitHub flavored markdown. 89 | * @default true 90 | */ 91 | gfm?: boolean; 92 | /** 93 | * Set the prefix for code block classes. 94 | * @default "language-" 95 | */ 96 | langPrefix?: string; 97 | } 98 | 99 | export interface ParseMarkdownOptions extends ParseMarkdownContentOptions { 100 | /** 101 | * Will resolve the markdown file path given an id, much like how nodejs would 102 | * resolve a `.js` file. By default, if the given id does not have an `.md` 103 | * file extension, it will first check for the markdown file by adding `.md`. 104 | * If that file does not exist, it will check if the id is a directory, and look 105 | * for `index.md` inside of the directory. Unlike nodejs resolving, the default markdown 106 | * file path resolving will not move up a directory and try again. The default 107 | * will also only look using the `.md` extension. 108 | * 109 | * Given the id `pages/my-file`, without an `.md` extension, the default resolution 110 | * will attempt to find the markdown file in this order: 111 | * 112 | * 1. `pages/my-file.md` 113 | * 2. `pages/my-file/index.md` 114 | * 115 | * If the given id is `pages/my-file.md`, which has the `.md` extension, it will 116 | * attempt find this exact file path. 117 | * 118 | * @default true 119 | */ 120 | resolveMarkdownPath?: (id: string) => Promise; 121 | } 122 | 123 | export interface HtmlResults { 124 | /** 125 | * Anchor (link) data and in order found in the document. Does not include 126 | * anchors without `href` attributes or href's that start with `#`. 127 | */ 128 | anchors: AnchorData[]; 129 | /** 130 | * Results of parsing the html into a JSON serializable format which can 131 | * be later used to generate JSX/hypertext, both serverside and clientside. 132 | */ 133 | ast: JsxAstNode[]; 134 | /** 135 | * Heading data and in order found in the document. 136 | */ 137 | headings: HeadingData[]; 138 | /** 139 | * Images in order found in the document. 140 | */ 141 | imgs: ImgData[]; 142 | /** 143 | * All the HTML tags found, but no duplicates. 144 | */ 145 | tagNames: string[]; 146 | /** 147 | * The resulting HTML, which may be different from the passed in 148 | * HTML due to any changes that could have happened within the 149 | * `beforeHtmlSerialize(frag)` option. 150 | */ 151 | html: string; 152 | } 153 | 154 | export interface MarkdownResults 155 | extends HtmlResults { 156 | /** 157 | * Contains extracted yaml attributes. 158 | */ 159 | attributes: T; 160 | /** 161 | * The description from the front matter attributes. 162 | */ 163 | description?: string; 164 | /** 165 | * The resolved file path of the markdown file. 166 | */ 167 | filePath?: string; 168 | /** 169 | * Results from parsing the markdown into html. 170 | */ 171 | html: string; 172 | /** 173 | * Slugified title from the filename provided in the parse options. This can be 174 | * overridden by adding a `slug` front matter attribute. 175 | */ 176 | slug?: string; 177 | /** 178 | * The title from the front matter attributes. 179 | */ 180 | title?: string; 181 | } 182 | 183 | export interface PageUrlOptions { 184 | /** 185 | * If the url should always end with a `/` or not. Default is to not end with a trailing `/`. 186 | * @default false 187 | */ 188 | trailingSlash?: boolean; 189 | } 190 | 191 | export interface PageNavigationOptions extends PageUrlOptions { 192 | /** 193 | * The table of contents which can be used to calculate the previous and next pages. 194 | */ 195 | tableOfContents?: TableOfContents; 196 | } 197 | 198 | export interface PageNavigation { 199 | current: PageNavigationData | null; 200 | parent: PageNavigationData | null; 201 | next: PageNavigationData | null; 202 | previous: PageNavigationData | null; 203 | } 204 | 205 | export interface PageNavigationData { 206 | title: string | null; 207 | url: string | null; 208 | } 209 | 210 | export interface AnchorData { 211 | href: string | null; 212 | text: string; 213 | } 214 | 215 | export interface HeadingData { 216 | text: string; 217 | level: number; 218 | id: string | null; 219 | } 220 | 221 | export interface ImgData { 222 | text: string | null; 223 | src: string | null; 224 | } 225 | 226 | export type JsxAstNode = any; 227 | 228 | export interface RenderJsxProps { 229 | ast: JsxAstNode[]; 230 | /** 231 | * A hook called for every element, passing the tag name 232 | * and its props to the function. The returned props is 233 | * what will get used when rendering. This is useful for adding 234 | * listeners which cannot be serialized, and/or updating element 235 | * props. 236 | */ 237 | elementProps?: ElementPropsHook; 238 | } 239 | 240 | export type ElementPropsHook = ( 241 | tagName: string, 242 | props: RenderAstJsxProps, 243 | ) => RenderAstJsxProps; 244 | 245 | export type RenderAstJsxProps = { [key: string]: any } | null; 246 | 247 | export interface SlugifyOptions { 248 | /** 249 | * Replace spaces with replacement character. 250 | * @default `-` 251 | */ 252 | replacement?: string; 253 | /** 254 | * Remove characters that match regex. 255 | * @default undefined 256 | */ 257 | remove?: RegExp; 258 | /** 259 | * Convert to lower case. 260 | * @default `true` 261 | */ 262 | lower?: boolean; 263 | /** 264 | * Strip special characters except replacement. 265 | * @default `true` 266 | */ 267 | strict?: boolean; 268 | /** 269 | * Language code of the locale to use. 270 | */ 271 | locale?: string; 272 | /** 273 | * Remove common file extensions if the string ends with one of these: 274 | * `.md`, `.markdown`, `.txt`, `.html`, `.htm`, `.jpeg`, `.jpg`, `.png` 275 | * @default `true` 276 | */ 277 | removeFileExtension?: boolean; 278 | /** 279 | * Trim the replacement character from the start and end of the slug. 280 | * For example, if the slug ends up being `--my-slug---` the output 281 | * will be `my-slug` 282 | * @default `true` 283 | */ 284 | trimReplacement?: boolean; 285 | } 286 | 287 | export interface ParseTableOfContentsOptions extends PageUrlOptions {} 288 | 289 | export interface TableOfContents { 290 | tocFilePath: string; 291 | tocDirPath: string; 292 | rootPagesDir: string; 293 | root: TableOfContentsNode[]; 294 | } 295 | 296 | export interface TableOfContentsNode { 297 | text?: string; 298 | url?: string; 299 | file?: string; 300 | hasParent?: boolean; 301 | children?: TableOfContentsNode[]; 302 | depth: number; 303 | } 304 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "allowUnreachableCode": false, 5 | "strict": true, 6 | "declaration": true, 7 | "declarationDir": "./dist/types", 8 | "experimentalDecorators": true, 9 | "lib": ["dom", "es2020"], 10 | "moduleResolution": "node", 11 | "module": "esnext", 12 | "target": "es2018", 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "outDir": "./build" 16 | }, 17 | "files": ["./src/index.ts", "src/parse.ts"] 18 | } 19 | --------------------------------------------------------------------------------