├── lib ├── markdown-regex.ts ├── assert-never.ts ├── markdown-url-to-html.ts ├── render-page.ts ├── sort-by-preferences.ts ├── path-to-text.ts ├── types.ts ├── group-by-path.ts ├── generate-index-info.ts ├── markdown-to-html.ts └── render-nav.ts ├── docs ├── nested │ ├── so_apple.md │ ├── vegetables │ │ └── others.md │ ├── index.md │ └── another-banana.md ├── a-folder │ ├── tenor.gif │ └── with-a-post.md ├── tests │ └── issue16.md ├── 1-banana.md └── template.html ├── .gitignore ├── .npmignore ├── .travis.yml ├── tsconfig.json ├── test ├── markdown-url-to-html.ts ├── markdown-regex.ts ├── sort-by-preferences.ts ├── path-to-text.ts ├── markdown-to-html.ts ├── render-page.ts ├── group-by-path.ts ├── render-nav.ts └── generate-index-info.ts ├── CHANGELOG.md ├── package.json ├── cli.ts └── README.md /lib/markdown-regex.ts: -------------------------------------------------------------------------------- 1 | export default /\.md$/; 2 | -------------------------------------------------------------------------------- /docs/nested/so_apple.md: -------------------------------------------------------------------------------- 1 | # Apple 2 | 3 | So delicious! 4 | 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | node_modules/ 3 | _docs/ 4 | docs/index.md 5 | -------------------------------------------------------------------------------- /docs/nested/vegetables/others.md: -------------------------------------------------------------------------------- 1 | This is getting out of hand... 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs/* 2 | !docs/template.html 3 | _docs/ 4 | test/ 5 | .travis.yml 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | script: 5 | - npm test 6 | -------------------------------------------------------------------------------- /docs/nested/index.md: -------------------------------------------------------------------------------- 1 | This is the index file inside a folder. 2 | 3 | The heading should link here. 4 | -------------------------------------------------------------------------------- /docs/a-folder/tenor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joakin/markdown-folder-to-html/HEAD/docs/a-folder/tenor.gif -------------------------------------------------------------------------------- /docs/tests/issue16.md: -------------------------------------------------------------------------------- 1 | Hello 2 | 3 | ``` 4 | '$KEY$' => $VALUE$, 5 | ``` 6 | 7 | This breaks down the rendering? 8 | -------------------------------------------------------------------------------- /lib/assert-never.ts: -------------------------------------------------------------------------------- 1 | export default function assertNever(x: never): never { 2 | throw new Error("Unexpected object: " + x); 3 | } 4 | -------------------------------------------------------------------------------- /lib/markdown-url-to-html.ts: -------------------------------------------------------------------------------- 1 | import mdR from "./markdown-regex"; 2 | 3 | export default function mdUrl(file: string) { 4 | return file.replace(mdR, ".html"); 5 | } 6 | -------------------------------------------------------------------------------- /docs/a-folder/with-a-post.md: -------------------------------------------------------------------------------- 1 | This is a test post!! 2 | 3 | | Hi| Ho| 4 | |---|---| 5 | |let's|go| 6 | 7 | This has a [code inside the `the link`](#woot). This is madness. 8 | 9 | ![be happy](./tenor.gif) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "rootDirs": ["./", "./lib", "./test"], 6 | "strict": true, 7 | 8 | "moduleResolution": "node", 9 | "esModuleInterop": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/markdown-url-to-html.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import mdUrl2Html from "../lib/markdown-url-to-html"; 4 | 5 | test("it changes the extension of a markdown url to reference an html file", t => { 6 | t.equal(mdUrl2Html("banana/split.md"), "banana/split.html"); 7 | t.end(); 8 | }); 9 | -------------------------------------------------------------------------------- /test/markdown-regex.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import mdR from "../lib/markdown-regex"; 4 | 5 | test("matches files with md extension", t => { 6 | t.ok(mdR.test("banana.md")); 7 | t.notOk(mdR.test("banana.md.other")); 8 | t.notOk(mdR.test("bananamd")); 9 | t.end(); 10 | }); 11 | -------------------------------------------------------------------------------- /docs/1-banana.md: -------------------------------------------------------------------------------- 1 | **Banana** 2 | 3 | You can have [nested folders](./nested/index.md) 4 | 5 | We also have [another banana](./nested/another-banana.md) 6 | 7 | And GFM features like tables: 8 | 9 | | Hello | World | 10 | | :-- | --: | 11 | | Yes | 1 | 12 | 13 | 14 | And automatic links https://example.com -------------------------------------------------------------------------------- /lib/render-page.ts: -------------------------------------------------------------------------------- 1 | const contentS = ""; 2 | const navS = ""; 3 | 4 | export default function renderPage( 5 | template: string, 6 | navmenu: string, 7 | content: string 8 | ) { 9 | return template 10 | .split(navS) 11 | .join(navmenu) 12 | .split(contentS) 13 | .join(content); 14 | } 15 | -------------------------------------------------------------------------------- /lib/sort-by-preferences.ts: -------------------------------------------------------------------------------- 1 | const isPreferent = (x: string, preferences: string[]) => 2 | preferences.indexOf(x) > -1; 3 | 4 | const strSort = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); 5 | 6 | export default function sortByPrecedence( 7 | preferences: string[], 8 | a: string, 9 | b: string 10 | ) { 11 | const aPref = isPreferent(a, preferences); 12 | const bPref = isPreferent(b, preferences); 13 | return aPref && bPref 14 | ? strSort(a, b) 15 | : aPref 16 | ? -1 17 | : bPref 18 | ? 1 19 | : strSort(a, b); 20 | } 21 | -------------------------------------------------------------------------------- /lib/path-to-text.ts: -------------------------------------------------------------------------------- 1 | import mdR from "./markdown-regex"; 2 | 3 | export default function pathToText(file: string, outputPath?: string) { 4 | // Remove output folder if there is one 5 | return ( 6 | (outputPath ? file.replace(outputPath + "/", "") : file) 7 | // Replace start of folder or file digits \d+ and dash out 8 | // 100-banana/10-apple/01-banana -> banana/apple/banana 9 | .replace(/(^|\/)(\d+-)/g, "$1") 10 | // Remove extension 11 | .replace(mdR, "") 12 | // Replace _ and - with spaces 13 | .replace(/_|-/g, " ") 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface MarkdownFile { 2 | path: string; 3 | url: string; 4 | content: string; 5 | html: string; 6 | } 7 | 8 | export type FileTree = FileNode[]; 9 | 10 | export type FileNode = FileFolder | File; 11 | 12 | export interface File { 13 | type: "file"; 14 | value: NodeType; 15 | } 16 | 17 | export interface FileFolder { 18 | type: "dir"; 19 | name: string; 20 | children: FileTree; 21 | } 22 | 23 | export type StringFile = string; 24 | 25 | export interface IndexFile { 26 | active: boolean; 27 | href: string; 28 | text: string; 29 | } 30 | -------------------------------------------------------------------------------- /test/sort-by-preferences.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import sortByPreferences from "../lib/sort-by-preferences"; 4 | 5 | const sort = sortByPreferences.bind(null, ["index.md", "README.md"]); 6 | 7 | test("sorts with normal string sort", t => { 8 | t.deepEqual(["a", "C", "A", "B", "~", "ab", "b"].sort(sort), [ 9 | "A", 10 | "B", 11 | "C", 12 | "a", 13 | "ab", 14 | "b", 15 | "~" 16 | ]); 17 | t.end(); 18 | }); 19 | 20 | test("sorts but puts first preferent strings", t => { 21 | t.deepEqual( 22 | ["a", "C", "README.md", "A", "index.md", "B", "~", "ab", "b"].sort(sort), 23 | ["README.md", "index.md", "A", "B", "C", "a", "ab", "b", "~"] 24 | ); 25 | t.end(); 26 | }); 27 | -------------------------------------------------------------------------------- /test/path-to-text.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import toText from "../lib/path-to-text"; 4 | 5 | test("gets rid of the markdown extension", t => { 6 | t.equal(toText("banana.md"), "banana"); 7 | t.end(); 8 | }); 9 | 10 | test("gets rid of the output dir", t => { 11 | t.equal(toText("_output/banana", "_output"), "banana"); 12 | t.end(); 13 | }); 14 | 15 | test("gets rid of numbers at the front of the name on each sub level", t => { 16 | t.equal( 17 | toText("1-banana/20-apple/005-tomato/banana"), 18 | "banana/apple/tomato/banana" 19 | ); 20 | t.end(); 21 | }); 22 | 23 | test("swaps - and _ for spaces", t => { 24 | t.equal(toText("banana-split/pina_colada"), "banana split/pina colada"); 25 | t.end(); 26 | }); 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.4.0 2 | 3 | - Don't show the menu and menu link on print mode 4 | 5 | # 2.3.0 6 | 7 | - Added anchors to headings 8 | - Upgraded dependencies 9 | 10 | # 2.2.0 11 | 12 | - Improve default menu link button color (made it white) 13 | - Make path handling more robust 14 | - Use less shelljs where possible 15 | 16 | # 2.1.1 17 | 18 | - Version shenanigans with git and npm, no meaningful changes 19 | - Removed prepublish script from package.json because npm is badly designed 20 | 21 | # 2.0.1 22 | 23 | - Fix #10 (Error when using custom name folder without a template file) 24 | 25 | # 2.0.0 26 | 27 | - Update dependencies 28 | - Project migrated to typescript 29 | - Default template JS error fixed 30 | - `contents.json` format changed, check the README for details 31 | -------------------------------------------------------------------------------- /test/markdown-to-html.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import md2html from "../lib/markdown-to-html"; 4 | 5 | test("transforms markdown to html", t => { 6 | t.equal( 7 | md2html("**banana** _split_ hey"), 8 | "

banana split hey

\n" 9 | ); 10 | t.end(); 11 | }); 12 | 13 | test("transforms local markdown links to html links", t => { 14 | t.equal( 15 | md2html("[banana](./banana/split.md)"), 16 | '

banana

\n' 17 | ); 18 | t.end(); 19 | }); 20 | 21 | test("transforms heading text to ids for anchor links", t => { 22 | t.equal( 23 | md2html("# Heading 1"), 24 | '

Heading 1

\n' 25 | ); 26 | t.end(); 27 | }); 28 | -------------------------------------------------------------------------------- /test/render-page.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import page from "../lib/render-page"; 4 | 5 | test("Changes instances of with the navigation html", t => { 6 | t.equal( 7 | page("banana apple", "split", "tini"), 8 | "banana split apple" 9 | ); 10 | t.end(); 11 | }); 12 | 13 | test("Changes instances of with the page content", t => { 14 | t.equal( 15 | page("banana apple", "nav", "tini"), 16 | "banana tini apple" 17 | ); 18 | t.end(); 19 | }); 20 | 21 | test("Changes instances of with the page content", t => { 22 | const content = 23 | "

Hello

\n
'$KEY$' => $VALUE$,\n
\n

This breaks down the rendering?

\n"; 24 | t.equal( 25 | page(" banana apple", "nav", content), 26 | `nav banana ${content} apple` 27 | ); 28 | t.end(); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/group-by-path.ts: -------------------------------------------------------------------------------- 1 | import { FileTree, FileFolder, FileNode, StringFile } from "./types"; 2 | import { assert } from "console"; 3 | 4 | export default function groupByPath( 5 | tree: FileTree, 6 | value: string 7 | ): FileTree { 8 | const paths = value.split("/"); 9 | let currentTree = tree; 10 | paths.forEach((path, i) => { 11 | // If we're on the last path segment, we just add it 12 | if (i === paths.length - 1) { 13 | currentTree.push({ type: "file", value }); 14 | } else { 15 | let folder: FileNode | undefined = currentTree.find( 16 | f => f.type === "dir" && f.name === path 17 | ); 18 | // Check again for type file, but we filtered above... typescript stuff 19 | if (!folder || folder.type === "file") { 20 | folder = { type: "dir", name: path, children: [] }; 21 | currentTree.push(folder); 22 | } 23 | currentTree = folder.children; 24 | } 25 | }); 26 | return tree; 27 | } 28 | -------------------------------------------------------------------------------- /test/group-by-path.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import group from "../lib/group-by-path"; 4 | 5 | test("given a group of paths, it groups them in arrays when reducing over them", t => { 6 | const paths = [ 7 | "index", 8 | "banana", 9 | "nested/banana", 10 | "nested/apple", 11 | "nested/super nested/thing" 12 | ]; 13 | 14 | const groupedPaths = [ 15 | { type: "file", value: "index" }, 16 | { type: "file", value: "banana" }, 17 | { 18 | type: "dir", 19 | name: "nested", 20 | children: [ 21 | { type: "file", value: "nested/banana" }, 22 | { type: "file", value: "nested/apple" }, 23 | { 24 | type: "dir", 25 | name: "super nested", 26 | children: [ 27 | { 28 | type: "file", 29 | value: "nested/super nested/thing" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | ]; 36 | 37 | t.deepEqual(paths.reduce(group, []), groupedPaths); 38 | t.end(); 39 | }); 40 | -------------------------------------------------------------------------------- /lib/generate-index-info.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import pathToText from "./path-to-text"; 3 | import mdUrl from "./markdown-url-to-html"; 4 | import { FileTree, StringFile, IndexFile, FileNode } from "./types"; 5 | import assertNever from "./assert-never"; 6 | 7 | export default function generateIndexInfo( 8 | currentFile: string, 9 | groupedFiles: FileTree 10 | ): FileTree { 11 | const currentDir = path.dirname(currentFile); 12 | 13 | return groupedFiles.map( 14 | (f: FileNode): FileNode => { 15 | if (f.type === "dir") { 16 | return { 17 | type: "dir", 18 | name: pathToText(f.name), 19 | children: generateIndexInfo(currentFile, f.children) 20 | }; 21 | } else if (f.type === "file") { 22 | const active = f.value === currentFile; 23 | const href = mdUrl(path.relative(currentDir, f.value)); 24 | const parts = f.value.split("/"); 25 | const text = pathToText(parts[parts.length - 1]); 26 | return { type: "file", value: { active, href, text } }; 27 | } 28 | return assertNever(f); // Error out if we're not handling all FileNode cases 29 | } 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /lib/markdown-to-html.ts: -------------------------------------------------------------------------------- 1 | import url from "url"; 2 | import markdownIt from "markdown-it"; 3 | import markdownItAnchor from "markdown-it-anchor"; 4 | import mdUrl from "./markdown-url-to-html"; 5 | 6 | const markdown = markdownIt({ 7 | html: true, 8 | linkify: true, 9 | typographer: true 10 | }) 11 | .use(transformLocalMdLinksToHTML) 12 | .use(markdownItAnchor, { 13 | permalink: true, 14 | permalinkClass: "heading-anchor-permalink", 15 | permalinkSymbol: "#" 16 | }); 17 | 18 | function transformLocalMdLinksToHTML(md: any) { 19 | const defaultLinkOpenRender = 20 | md.renderer.rules.link_open || 21 | function(tokens: any, idx: any, options: any, env: any, self: any) { 22 | return self.renderToken(tokens, idx, options); 23 | }; 24 | md.renderer.rules.link_open = function( 25 | tokens: any, 26 | idx: any, 27 | options: any, 28 | env: any, 29 | self: any 30 | ) { 31 | const a = tokens[idx]; 32 | const href = a.attrGet("href"); 33 | if (href) { 34 | a.attrSet("href", localMarkdownLinkToHtmlLink(href)); 35 | } 36 | return defaultLinkOpenRender(tokens, idx, options, env, self); 37 | }; 38 | } 39 | 40 | export default function md2html(contents: string) { 41 | return markdown.render(contents); 42 | } 43 | 44 | function localMarkdownLinkToHtmlLink(hrefAttr: string) { 45 | const href = url.parse(hrefAttr); 46 | if (!href.protocol && !href.host) { 47 | // Local link 48 | return mdUrl(hrefAttr); 49 | } 50 | return hrefAttr; 51 | } 52 | -------------------------------------------------------------------------------- /test/render-nav.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import nav from "../lib/render-nav"; 4 | 5 | test("renders a ul with plain index item as a li", t => { 6 | t.equal( 7 | nav([ 8 | { 9 | type: "file", 10 | value: { text: "banana", active: false, href: "bananalink" } 11 | } 12 | ]), 13 | '' 14 | ); 15 | t.end(); 16 | }); 17 | 18 | test("renders a active class when index item is active", t => { 19 | t.equal( 20 | nav([ 21 | { 22 | type: "file", 23 | value: { text: "banana", active: true, href: "bananalink" } 24 | } 25 | ]), 26 | '' 27 | ); 28 | t.end(); 29 | }); 30 | 31 | test("renders a heading and nested items when index item is array", t => { 32 | t.equal( 33 | nav([ 34 | { 35 | type: "file", 36 | value: { text: "banana", active: true, href: "bananalink" } 37 | }, 38 | { 39 | type: "dir", 40 | name: "headingtext", 41 | children: [ 42 | { 43 | type: "file", 44 | value: { 45 | text: "apple", 46 | active: false, 47 | href: "applelink" 48 | } 49 | } 50 | ] 51 | } 52 | ]), 53 | `
    54 |
  • banana
  • 55 |
  • headingtext
  • 56 | 59 |
` 60 | ); 61 | t.end(); 62 | }); 63 | -------------------------------------------------------------------------------- /lib/render-nav.ts: -------------------------------------------------------------------------------- 1 | import { FileTree, IndexFile, FileFolder, File } from "./types"; 2 | import assertNever from "./assert-never"; 3 | 4 | export default function renderNav( 5 | groupedFiles: FileTree, 6 | level = 0 7 | ): string { 8 | return `
    9 | ${groupedFiles 10 | .map(f => { 11 | if (f.type === "dir") { 12 | const childrenNav = renderNav(f.children, level + 1); 13 | const indexFile = getIndexFile(f.children); 14 | // Heading with link if there is an index file in the folder 15 | if (indexFile) { 16 | const link = renderActive( 17 | f.name, 18 | indexFile.value.href, 19 | indexFile.value.active 20 | ); 21 | return `
  • ${link}
  • \n${childrenNav}`; 22 | } 23 | // Heading without link 24 | return `
  • ${f.name}
  • \n${childrenNav}`; 25 | } else if (f.type === "file") { 26 | // Leaf 27 | const { text, href, active } = f.value; 28 | 29 | // Skip index files on nested levels since the Heading links to them. 30 | if (level > 0 && text && text.toLowerCase() === "index") return; 31 | 32 | return `
  • ${renderActive(text, href, active)}
  • `; 33 | } 34 | return assertNever(f); 35 | }) 36 | .join("\n")} 37 |
`; 38 | } 39 | 40 | function renderActive(text: string, href: string, active: boolean) { 41 | return `${text}`; 42 | } 43 | 44 | function getIndexFile(files: FileTree): File | undefined { 45 | return files.find(e => e.type === "file" && e.value.text === "index") as File< 46 | IndexFile 47 | >; // Stupid TS 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-folder-to-html", 3 | "version": "2.4.0", 4 | "description": "Convert a folder with files and markdown documents to an HTML site", 5 | "main": "cli.js", 6 | "bin": { 7 | "markdown-folder-to-html": "./cli.js" 8 | }, 9 | "scripts": { 10 | "start": "npm run compile && node cli.js", 11 | "compile": "tsc", 12 | "build": "cp README.md docs/index.md && npm start", 13 | "deploy": "npm run build && gh-pages -d _docs", 14 | "prepare-to-publish": "npm run test && npm run deploy", 15 | "format": "prettier --write '{lib,test}/**/*.ts' ./cli.ts", 16 | "test": "npm run compile && tape test/*.js | tap-dot", 17 | "docs": "markdown-folder-to-html" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/joakin/markdown-folder-to-html.git" 22 | }, 23 | "keywords": [ 24 | "markdown", 25 | "md", 26 | "html", 27 | "static", 28 | "site", 29 | "folder", 30 | "cli", 31 | "simple" 32 | ], 33 | "author": "Joaquin Oltra (http://chimeces.com)", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/joakin/markdown-folder-to-html/issues" 37 | }, 38 | "homepage": "https://github.com/joakin/markdown-folder-to-html#readme", 39 | "dependencies": { 40 | "markdown-it": "^8.4.2", 41 | "markdown-it-anchor": "^5.3.0", 42 | "shelljs": "^0.8.4" 43 | }, 44 | "devDependencies": { 45 | "@types/markdown-it": "0.0.8", 46 | "@types/markdown-it-anchor": "^4.0.4", 47 | "@types/node": "^10.17.35", 48 | "@types/shelljs": "^0.8.8", 49 | "@types/tape": "^4.13.0", 50 | "gh-pages": "^2.2.0", 51 | "prettier": "^1.19.1", 52 | "tap-dot": "^2.0.0", 53 | "tape": "^4.13.3", 54 | "typescript": "^3.9.7" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/nested/another-banana.md: -------------------------------------------------------------------------------- 1 | # Nested banana 2 | 3 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus non luctus 4 | justo. Praesent a molestie quam. Quisque pulvinar arcu nec sem tincidunt, in 5 | dictum lectus eleifend. Quisque tincidunt tristique tortor a imperdiet. Proin 6 | pharetra scelerisque venenatis. Donec tortor ante, aliquam eu lacinia eu, 7 | gravida sed neque. Sed vel felis facilisis, eleifend nisl sit amet, convallis 8 | turpis. Donec ultrices leo ac eros tristique, vel condimentum lorem interdum. 9 | 10 | Aenean vel aliquet orci. Duis et risus ut metus accumsan consectetur. Nullam 11 | quis nisi facilisis, consectetur metus vitae, tempus purus. Fusce iaculis 12 | tincidunt nibh, in ornare quam congue vitae. Nulla commodo nulla eget ornare 13 | faucibus. Sed a est eget sapien lobortis sollicitudin. Aliquam aliquam nisl id 14 | gravida dignissim. Sed placerat leo vitae eleifend aliquam. Sed facilisis 15 | ultrices condimentum. 16 | 17 | Pellentesque sed mollis eros. Pellentesque mollis ac ex ut tincidunt. Cras quis 18 | suscipit tellus. Praesent vehicula vehicula sagittis. Ut id libero dui. Nunc 19 | egestas ac urna vitae cursus. Nulla dictum sed est ut bibendum. Nam in magna 20 | pulvinar, tincidunt felis ut, pulvinar erat. Cras velit leo, molestie eu ex 21 | vitae, interdum iaculis nisi. Cras congue felis vitae interdum fermentum. Donec 22 | pulvinar nunc metus, a vulputate lorem suscipit in. Praesent semper felis ut 23 | enim suscipit, in pretium lectus consectetur. Curabitur sed leo suscipit augue 24 | consequat finibus sit amet id lorem. Aliquam erat volutpat. Integer quis nulla 25 | tortor. Proin et aliquet sapien. 26 | 27 | Nunc ac imperdiet tortor. Nunc justo magna, consequat sed dignissim vel, 28 | fermentum a nulla. Pellentesque sed fringilla velit, eu varius risus. Nam 29 | elementum justo finibus auctor tristique. Ut bibendum enim vitae elit aliquam 30 | vestibulum. Pellentesque varius eget sem quis fringilla. Nam vulputate purus 31 | vel rhoncus laoreet. Nam fermentum elementum diam, sed molestie purus. 32 | -------------------------------------------------------------------------------- /test/generate-index-info.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | 3 | import groupByPath from "../lib/group-by-path"; 4 | import generateIndexInfo from "../lib/generate-index-info"; 5 | import { File, IndexFile } from "../lib/types"; 6 | 7 | const files = [ 8 | "index.md", 9 | "1-banana.md", 10 | "nested/another-banana.md", 11 | "nested/so_apple.md" 12 | ]; 13 | 14 | const grouped = files.reduce(groupByPath, []); 15 | 16 | // Generate info with the first file as the current one 17 | const treeInfoFirstIsCurrent = generateIndexInfo(files[0], grouped); 18 | 19 | test("current file has active property to true", t => { 20 | const first = treeInfoFirstIsCurrent[0]; 21 | t.ok(first.type === "file" && first.value.active); 22 | t.end(); 23 | }); 24 | 25 | test("leaf file has text property that has been parsed", t => { 26 | const second = treeInfoFirstIsCurrent[1]; 27 | t.equal(second.type === "file" && second.value.text, "banana"); 28 | t.end(); 29 | }); 30 | 31 | test("nested files are kept in a hierarchy", t => { 32 | const third = treeInfoFirstIsCurrent[2]; 33 | t.equal(third.type, "dir", "The nested tree is a dir"); 34 | t.equal( 35 | third.type === "dir" && third.name, 36 | "nested", 37 | "Heading is in the first position" 38 | ); 39 | t.equal( 40 | third.type === "dir" && third.children.length, 41 | 2, 42 | "Second position has array of children" 43 | ); 44 | t.end(); 45 | }); 46 | 47 | test("whole structure matches (for reference)", t => { 48 | // Verify whole structure 49 | t.deepEqual(treeInfoFirstIsCurrent, [ 50 | { 51 | type: "file", 52 | value: { 53 | active: true, 54 | href: "index.html", 55 | text: "index" 56 | } 57 | }, 58 | { 59 | type: "file", 60 | value: { 61 | active: false, 62 | href: "1-banana.html", 63 | text: "banana" 64 | } 65 | }, 66 | { 67 | type: "dir", 68 | name: "nested", 69 | children: [ 70 | { 71 | type: "file", 72 | value: { 73 | active: false, 74 | href: "nested/another-banana.html", 75 | text: "another banana" 76 | } 77 | }, 78 | { 79 | type: "file", 80 | value: { 81 | active: false, 82 | href: "nested/so_apple.html", 83 | text: "so apple" 84 | } 85 | } 86 | ] 87 | } 88 | ]); 89 | t.end(); 90 | }); 91 | 92 | test("properly generates the hrefs as relative paths when current file is a nested one", t => { 93 | const treeInfoNestedIsCurrent = generateIndexInfo(files[2], grouped); 94 | const first = treeInfoNestedIsCurrent[0]; 95 | const second = treeInfoNestedIsCurrent[1]; 96 | const third = treeInfoNestedIsCurrent[2]; 97 | t.equal(first.type === "file" && first.value.href, "../index.html"); 98 | t.equal(second.type === "file" && second.value.href, "../1-banana.html"); 99 | t.equal( 100 | third.type === "dir" && (third.children[1] as File).value.href, 101 | "so_apple.html" 102 | ); 103 | t.end(); 104 | }); 105 | -------------------------------------------------------------------------------- /cli.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | import sh from "shelljs"; 6 | 7 | import groupByPath from "./lib/group-by-path"; 8 | import sortByPreferences from "./lib/sort-by-preferences"; 9 | import mdUrl from "./lib/markdown-url-to-html"; 10 | import md2html from "./lib/markdown-to-html"; 11 | import renderNav from "./lib/render-nav"; 12 | import generateIndexInfo from "./lib/generate-index-info"; 13 | import page from "./lib/render-page"; 14 | import mdR from "./lib/markdown-regex"; 15 | import { FileTree, StringFile } from "./lib/types"; 16 | 17 | const [docsFolder, ...argsRest] = process.argv.slice(2); 18 | 19 | // Default parameters 20 | const defaultFolder = "docs"; 21 | const folder = path.resolve(docsFolder || defaultFolder); 22 | const output = path.resolve(folder, "..", `_${path.basename(folder)}`); 23 | const templateFilename = "template.html"; 24 | const contentsFilename = "contents.json"; 25 | const preferences = ["index.md", "README.md"]; 26 | 27 | // Guards 28 | // Bail out if more than 1 args 29 | if (argsRest && argsRest.length > 0) { 30 | console.error("Too may arguments"); 31 | usage(true); 32 | } 33 | 34 | // Bail out if the folder doesn't exist 35 | if (!fs.existsSync(folder)) { 36 | console.error(`Folder ${folder} not found.`); 37 | usage(true); 38 | } 39 | 40 | // Define template html, user's first, otherwise default 41 | let template = path.join(folder, templateFilename); 42 | if (!fs.existsSync(template)) { 43 | template = path.join(__dirname, defaultFolder, templateFilename); 44 | } 45 | const tpl = fs.readFileSync(template, "utf8"); 46 | 47 | // Prepare output folder (create, clean, copy sources) 48 | fs.mkdirSync(output, { recursive: true }); 49 | sh.rm("-rf", path.join(output, "*")); 50 | sh.cp("-R", path.join(folder, "*"), output); 51 | 52 | // Start processing. Outline: 53 | // 54 | // 1. Get all files 55 | // 2. Sort them 56 | // 3. Group them hierachically 57 | // 4. Parse files and generate output html files 58 | 59 | sh.cd(output); 60 | const all = sh.find("*"); 61 | 62 | const mds = all 63 | .filter(file => file.match(mdR)) 64 | .sort(sortByPreferences.bind(null, preferences)) 65 | .map(file => { 66 | const content = sh.cat(file).toString(); // The result is a weird not-string 67 | return { 68 | path: file, 69 | url: mdUrl(file), 70 | content, 71 | html: md2html(content) 72 | }; 73 | }); 74 | 75 | const groupedMds: FileTree = mds.reduce( 76 | (grouped: FileTree, value) => groupByPath(grouped, value.path), 77 | [] 78 | ); 79 | 80 | mds.forEach(({ path, url, html }) => { 81 | const navHtml = renderNav(generateIndexInfo(path, groupedMds)); 82 | const pageHtml = page(tpl, navHtml, html); 83 | fs.writeFileSync(url, pageHtml); 84 | }); 85 | 86 | const contentsJSON = { 87 | paths: groupedMds, 88 | contents: mds.map((md, i) => ({ ...md, id: i })) 89 | }; 90 | fs.writeFileSync(contentsFilename, JSON.stringify(contentsJSON, null, 2)); 91 | 92 | sh.rm("-r", "**/*.md"); 93 | 94 | function usage(error: boolean) { 95 | console.log( 96 | ` 97 | Usage: 98 | 99 | markdown-folder-to-html [input-folder] 100 | 101 | input-folder [optional] defaults to \`docs\` 102 | ` 103 | ); 104 | process.exit(error ? 1 : 0); 105 | } 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markdown-folder-to-html 2 | 3 | Simplest zero-config way to generate html docs from markdown files. 4 | 5 | Copies `docs` to `_docs` and compiles markdown files to html using 6 | `docs/template.html`. 7 | 8 | [Live example at chimeces.com/markdown-folder-to-html](http://chimeces.com/markdown-folder-to-html/) 9 | 10 | ## Usage 11 | 12 | Requires node.js >= 6 13 | 14 | Given we have some docs: 15 | 16 | 1. `mkdir -p docs` 17 | 2. Add some docs `echo "**Banana**" > docs/banana.md` 18 | 3. Add some docs `echo "**Apple**" > docs/index.md` 19 | 20 | ### In a project 21 | 22 | 1. Install `npm install -D markdown-folder-to-html` 23 | 2. Add `docs` to npm scripts `{"scripts": {"docs": "markdown-folder-to-html"}}` 24 | 3. 🎉 `npm run docs` and `open _docs/index.html` 25 | 26 | ### Globally 27 | 28 | 1. Install `npm install -g markdown-folder-to-html` 29 | 2. 🎉 `markdown-folder-to-html` and `open _docs/index.html` 30 | 31 | ## Conventions 32 | 33 | ### Input/Output folder 34 | 35 | You can pass an argument to the cli to change the input folder (by default 36 | `docs`). That will change the output folder too to `_FOLDERNAME` (by default 37 | `_docs`). 38 | 39 | ```bash 40 | markdown-folder-to-html documentation 41 | # Outputs site to _documentation 42 | ``` 43 | 44 | If you want to change the output folder name, just `mv` it to something else. 45 | 46 | ### Custom HTML 47 | 48 | The default HTML is extremely basic, but 49 | [simple and pretty](https://github.com/joakin/markdown-folder-to-html/blob/master/docs/template.html), 50 | and is the one used in the docs. 51 | 52 | This is the basic template that would work: 53 | 54 | ```html 55 | 56 | 57 | 58 | 61 |
62 | 63 |
64 | 65 | 66 | ``` 67 | 68 | Create your own in your docs folder `docs/template.html` to use that one 69 | instead. Feel free to include styles inline or CSS files (since all will be 70 | copied to output). 71 | 72 | ### Order 73 | 74 | You may have noticed that files are sorted alphabetically. There's a little 75 | trick where if you name your folders/files with XX-folder/XX-file (XX being a 76 | number of 1+ digits) those numbers won't show up on the index of the pages, 77 | giving you the ability to organize files both in the filesystem and in the 78 | generated HTML site. 79 | 80 | Also, the root `index.md` file will always show up at the beginning of the 81 | index. 82 | 83 | ### Site contents and information for custom templates 84 | 85 | If you want to do things with a custom template HTML you need the information of 86 | the site. This will allow you to do things in the front-end UI, like adding 87 | search to the static site with lunrjs or other things like adding buttons for 88 | the next/previous article. 89 | 90 | For this use cases, you will see a `contents.json` generated in your output 91 | folder. It contains the hierarchical paths of the files, and the contents with 92 | the original markup, the HTML, the original path and the transformed URL: 93 | 94 | ```json 95 | { 96 | "paths": [ 97 | { 98 | "type": "file", 99 | "value": "index.md" 100 | }, 101 | { 102 | "type": "file", 103 | "value": "1-banana.md" 104 | }, 105 | { 106 | "type": "dir", 107 | "name": "a-folder", 108 | "children": [ 109 | { 110 | "type": "file", 111 | "value": "a-folder/with-a-post.md" 112 | } 113 | ] 114 | } 115 | //... 116 | ], 117 | "contents": [ 118 | { 119 | "path": "index.md", 120 | "url": "index.html", 121 | "content": "# markdown-folder-to-html\n\nSimplest zero-config ...", 122 | "html": "

markdown-folder-to-html

\n

Simplest zero-config ...", 123 | "id": 0 124 | }, 125 | { 126 | "path": "1-banana.md", 127 | "url": "1-banana.html", 128 | "content": "**Banana**\n\nYou can have [nested folders](./n...", 129 | "html": "

Banana

\n

You can have 2 | 3 | 4 | 467 | 468 | 469 | 470 | 471 | 472 |

473 | 474 | 475 | 476 | 477 | 478 | 481 |
482 | 483 |
484 |
485 | 488 | 524 | 525 | 526 | --------------------------------------------------------------------------------