├── .eslintrc ├── .gitignore ├── .npmignore ├── README.md ├── bin ├── chapter.js ├── cmd.js ├── count.js └── server.js ├── chapters.yml ├── commitlint.config.js ├── dist ├── assets │ ├── css │ │ ├── app.css │ │ ├── bulma.min.css │ │ ├── collapse.svg │ │ ├── expand.svg │ │ ├── next-disabled.svg │ │ ├── next.svg │ │ ├── previous-disabled.svg │ │ ├── previous.svg │ │ ├── size.svg │ │ ├── toc.svg │ │ └── up.svg │ └── js │ │ └── app.js ├── cli-options.html ├── favicon.ico ├── index.html ├── sub-commands.html └── template-variables.html ├── docs ├── cli-options.md ├── sub-commands.md └── template-variables.md ├── lib ├── chapters.js ├── config.js ├── copy.js ├── index.js ├── log4js.config.json ├── page.js ├── theme.js └── utils.js ├── loppo.yml ├── loppo.yml.default ├── package.json ├── test ├── chapters.test.js ├── config.test.js ├── copy.test.js ├── fixture │ ├── chapters │ │ ├── chapters-file-already-exists │ │ │ └── chapters.yml │ │ ├── complex-docs-directory-structure │ │ │ └── docs │ │ │ │ ├── a.md │ │ │ │ └── b │ │ │ │ └── c │ │ │ │ └── d.md │ │ ├── empty-chapters-file │ │ │ └── chapters.yml │ │ ├── exclude-complex-empty-directory │ │ │ └── docs │ │ │ │ ├── a.md │ │ │ │ └── images │ │ │ │ └── dir3 │ │ │ │ └── b.md │ │ ├── exclude-double-empty-directory │ │ │ └── docs │ │ │ │ └── a.md │ │ ├── exclude-empty-directory │ │ │ └── docs │ │ │ │ └── a.md │ │ ├── generating-chapters-file-complex-format │ │ │ └── docs │ │ │ │ ├── a.md │ │ │ │ └── b.md │ │ ├── generating-chapters-file-filter │ │ │ └── docs │ │ │ │ ├── _a.txt │ │ │ │ └── a.txt │ │ ├── no-docs-directory-exists │ │ │ └── readme.md │ │ └── simple-generating-chapters-file │ │ │ └── docs │ │ │ ├── a.md │ │ │ └── b.md │ ├── config │ │ ├── no-options-no-default │ │ │ └── .gitkeep │ │ ├── no-options-with-config │ │ │ └── loppo.yml │ │ ├── options-no-config │ │ │ └── .gitkeep │ │ └── options-with-config │ │ │ └── loppo.yml │ ├── copy │ │ ├── copy-to-output-directory │ │ │ └── themes │ │ │ │ └── oceandeep │ │ │ │ └── index.html │ │ ├── customization-false │ │ │ └── .gitkeep │ │ ├── customization-true │ │ │ └── loppo-theme │ │ │ │ └── assets │ │ │ │ └── js │ │ │ │ └── app.js │ │ └── excludes-template-files │ │ │ └── themes │ │ │ └── oceandeep │ │ │ └── index.template │ ├── makeContent │ │ ├── non-root-index-with-md-files │ │ │ └── docs │ │ │ │ └── dir1 │ │ │ │ ├── a.md │ │ │ │ └── b.md │ │ ├── regular-markdown-file │ │ │ └── docs │ │ │ │ └── first.md │ │ ├── root-index-with-readme │ │ │ └── README.md │ │ └── root-index-without-readme │ │ │ └── .gitkeep │ ├── theme │ │ ├── customization-true-no-themedir-exists │ │ │ └── .gitkeep │ │ ├── customization-true-old-themedir-exists │ │ │ └── themes │ │ │ │ └── oceandeep │ │ │ │ └── page.template │ │ ├── customization-true-themedir-exists │ │ │ └── loppo-theme │ │ │ │ └── page.template │ │ ├── includes │ │ │ └── themes │ │ │ │ └── oceandeep │ │ │ │ ├── author.template │ │ │ │ ├── date.template │ │ │ │ ├── page.template │ │ │ │ └── title.template │ │ ├── theme-exists │ │ │ └── themes │ │ │ │ └── oceandeep │ │ │ │ └── page.template │ │ └── theme-not-exists │ │ │ └── themes │ │ │ └── .gitkeep │ └── writePage │ │ └── simple-write │ │ └── .gitkeep ├── makeBreadcrumb.test.js ├── makeBuildTime.test.js ├── makeContent.test.js ├── makeIndex.test.js ├── makeNextPageObject.test.js ├── makePageTitle.test.js ├── makePreviousPageObject.test.js ├── makeRelativeRootPath.test.js ├── markdownRender.test.js ├── theme.test.js ├── utils │ ├── isHomepage.test.js │ ├── makeChapterList.test.js │ ├── makeChaptersOrigin.test.js │ └── siteId.test.js └── writePage.test.js └── utils ├── escapeTitle.js ├── findRootPosition.js ├── isHomepage.js ├── makeBreadcrumb.js ├── makeBreadcrumbOrigin.js ├── makeChapterList.js ├── makeChaptersOrigin.js ├── makeNextPageObject.js ├── makeNextPageOrigin.js ├── makePreviousPageObject.js ├── makePreviousPageOrigin.js ├── readmeCheck.js ├── siteId.js └── themePath.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "globals": { 6 | }, 7 | "plugins": [ 8 | ], 9 | "extends": "airbnb", 10 | "rules": { 11 | "camelcase": 0, 12 | "comma-dangle": 0, 13 | "func-names": 0, 14 | "global-require": 0, 15 | "prefer-arrow-callback": 0, 16 | "prefer-template": 0, 17 | "no-console": 0, 18 | "no-constant-condition": 0, 19 | "no-param-reassign": 0, 20 | "no-shadow": 0, 21 | "no-unused-expressions": 0, 22 | "id-length": 0, 23 | "prefer-rest-params": 0, 24 | "react/prop-types": 0, 25 | "strict": 0, 26 | "vars-on-top": 0, 27 | "max-len": 0, 28 | "import/newline-after-import": 0, 29 | "import/no-dynamic-require": 0, 30 | "no-path-concat": 0, 31 | "prefer-spread": 0, 32 | "prefer-destructuring": 0 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build/ 3 | npm-debug.log 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Loppo is an extremely easy static site generator of markdown documents. You get your site with only one command. Please visit [demo](http://redux.ruanyifeng.com/). 2 | 3 | ## Features 4 | 5 | - easy config ([example](https://raw.githubusercontent.com/ruanyf/loppo/master/loppo.yml.default)) 6 | - simple site structure ([example](https://raw.githubusercontent.com/ruanyf/redux-docs/master/chapters.yml)) 7 | - friendly template syntax([example](https://raw.githubusercontent.com/ruanyf/redux-docs/master/themes/oceandeep/page.template)) 8 | - built-in [utility commands](docs/sub-commands.md) 9 | 10 | ## How to use 11 | 12 | **Attention: Loppo is still in its very early stages. Use it in production at your own risk.** 13 | 14 | First of all, arrange your documents into the following structure. 15 | 16 | ``` 17 | |- myProject 18 | |- README.md 19 | |- docs 20 | |- page1.md 21 | |- page2.md 22 | |- ... 23 | ``` 24 | 25 | Now, install Loppo. 26 | 27 | ```bash 28 | $ npm install loppo -g 29 | ``` 30 | 31 | Enter your project directory. 32 | 33 | ```bash 34 | $ cd myProject 35 | ``` 36 | 37 | Run the command. 38 | 39 | ```bash 40 | $ loppo 41 | ``` 42 | 43 | Now, Loppo will build the document site under `dist` sub-directory. After the building process, you could open the site in your browser. 44 | 45 | ```bash 46 | $ open dist/index.html 47 | ``` 48 | 49 | ## License 50 | 51 | GPL v3 52 | 53 | -------------------------------------------------------------------------------- /bin/chapter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const promptly = require('promptly'); 4 | const fs = require('fs-extra'); 5 | const path = require('path'); 6 | const { log } = require('../lib/utils'); 7 | const readmeCheck = require('../utils/readmeCheck'); 8 | const chaptersLib = require('../lib/chapters'); 9 | 10 | function chapterHandler(option) { 11 | const chaptersFile = path.resolve(process.cwd(), 'chapters.yml'); 12 | if (fs.existsSync(chaptersFile)) { 13 | fs.removeSync(chaptersFile); 14 | } 15 | chaptersLib(option); 16 | } 17 | 18 | module.exports = { 19 | command: 'chapter', 20 | desc: 'create new chapters.yml file', 21 | builder: yargs => yargs 22 | .option('force', { 23 | alias: 'f', 24 | describe: 'chapters.yml will be deleted without confirmation', 25 | type: 'boolean', 26 | default: 'false', 27 | }), 28 | handler: (argv) => { 29 | const str = 'This command will delete your chapters.yml if existed.\nAre you sure to continue? (Y/N)'; 30 | if (!argv.force) { 31 | promptly.prompt(str, function (err, value) { 32 | const input = value.trim().toLowerCase(); 33 | if (input !== 'y' && input !== 'yes') return; 34 | chapterHandler(argv); 35 | }); 36 | } else { 37 | chapterHandler(argv); 38 | } 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /bin/cmd.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path'); 4 | 5 | const { argv } = require('yargs') 6 | .usage('Usage: loppo [Options], loppo [Commands] [Options]') 7 | .option('dir', { 8 | alias: 'd', 9 | default: 'docs', 10 | describe: 'document directory', 11 | type: 'string' 12 | }) 13 | .option('output', { 14 | alias: 'o', 15 | default: 'dist', 16 | describe: 'output directory', 17 | type: 'string' 18 | }) 19 | .option('site', { 20 | alias: 's', 21 | default: 'Documents', 22 | describe: 'site name', 23 | type: 'string' 24 | }) 25 | .option('id', { 26 | default: require('../utils/siteId')(), 27 | describe: 'site ID', 28 | type: 'string' 29 | }) 30 | .option('direction', { 31 | default: 'ltr', 32 | describe: 'document character direction', 33 | type: 'string' 34 | }) 35 | .option('theme', { 36 | alias: 't', 37 | describe: 'theme name', 38 | type: 'string' 39 | }) 40 | .option('debug', { 41 | default: false, 42 | describe: 'debug mode', 43 | type: 'boolean' 44 | }) 45 | .option('help', { 46 | alias: 'h', 47 | describe: 'help information', 48 | type: 'boolean' 49 | }) 50 | .command(require('./server')) 51 | .command(require('./count')) 52 | .command(require('./chapter')) 53 | .help('help') 54 | .version() 55 | .example('loppo --dir docs --output dist') 56 | .example('loppo server'); 57 | 58 | if (argv.debug) { 59 | process.env.DEBUG = '*'; 60 | } 61 | 62 | if ( 63 | argv._.indexOf('server') === -1 && 64 | argv._.indexOf('count') === -1 && 65 | argv._.indexOf('chapter') === -1 66 | ) { 67 | require('../lib')(argv); 68 | } 69 | 70 | -------------------------------------------------------------------------------- /bin/count.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs-extra'); 5 | const yaml = require('js-yaml'); 6 | const md = require('turpan'); 7 | const htmlToText = require('html-to-text'); 8 | const wordCount = require('wordcount'); 9 | 10 | function print(filesArr, option) { 11 | if (option.file) { 12 | const fileList = []; 13 | filesArr.forEach(f => fileList.push(f.file)); 14 | const position = fileList.indexOf(option.file); 15 | if (position === -1) { 16 | console.log('Error: cannot find ' + option.file); 17 | return; 18 | } 19 | console.log('[File] ' + option.file); 20 | console.log('[Lines] ' + filesArr[position].line); 21 | console.log('[Words] ' + filesArr[position].word); 22 | console.log('[Chars] ' + filesArr[position].char); 23 | return; 24 | } 25 | 26 | let totalLine = 0; 27 | let totalWord = 0; 28 | let totalChar = 0; 29 | filesArr.forEach((f) => { 30 | totalLine += f.line; 31 | totalWord += f.word; 32 | totalChar += f.char; 33 | 34 | if (option.detail) { 35 | console.log('[File] ' + f.file); 36 | console.log('[Lines] ' + f.line); 37 | console.log('[Words] ' + f.word); 38 | console.log('[Chars] ' + f.char); 39 | console.log(); 40 | } 41 | }); 42 | 43 | if (option.detail) { 44 | console.log('## Total'); 45 | } 46 | 47 | console.log('[Files] ' + filesArr.length); 48 | console.log('[Lines] ' + totalLine); 49 | console.log('[Words] ' + totalWord); 50 | console.log('[Chars] ' + totalChar); 51 | } 52 | 53 | module.exports = { 54 | command: 'count', 55 | desc: 'word counts for each markdown file', 56 | builder: yargs => yargs 57 | .option('file', { 58 | alias: 'f', 59 | describe: 'the file name to count', 60 | type: 'string' 61 | }) 62 | .option('detail', { 63 | alias: 'd', 64 | describe: 'detail mode', 65 | default: 'false', 66 | type: 'boolean' 67 | }), 68 | handler: (argv) => { 69 | const chaptersPath = path.resolve(process.cwd(), 'chapters.yml'); 70 | const chaptersContent = fs.readFileSync(chaptersPath, 'utf8'); 71 | const chaptersArr = yaml.safeLoad(chaptersContent); 72 | 73 | const cfgPath = path.resolve(process.cwd(), 'loppo.yml'); 74 | const cfgContent = fs.readFileSync(cfgPath, 'utf8'); 75 | const cfgObj = yaml.safeLoad(cfgContent); 76 | const docDir = cfgObj.dir; 77 | 78 | const filesArr = []; 79 | chaptersArr 80 | .filter(c => Object.keys(c)[0].substr(-3) === '.md') 81 | .forEach((c) => { 82 | const fileName = Object.keys(c)[0]; 83 | const filePath = path.resolve(process.cwd(), docDir, fileName); 84 | let fileContent = fs.readFileSync(filePath, 'utf8').trim(); 85 | const fileContentArr = fileContent.split('\n'); 86 | if (/^\s*#\s*([^#].*?)\s*$/.test(fileContentArr[0])) fileContentArr.shift(); 87 | fileContent = fileContentArr.join('\n'); 88 | 89 | const HTMLContent = md.render(fileContent); 90 | const TEXTContent = htmlToText.fromString(HTMLContent, { 91 | wordwrap: false, 92 | ignoreImage: true, 93 | ignoreHref: true 94 | }); 95 | const result = {}; 96 | result.file = fileName; 97 | result.line = TEXTContent.split('\n').length; 98 | result.word = wordCount(TEXTContent); 99 | result.char = TEXTContent.length; 100 | filesArr.push(result); 101 | }); 102 | 103 | print(filesArr, argv); 104 | } 105 | }; 106 | 107 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const connect = require('connect'); 4 | const serveStatic = require('serve-static'); 5 | const path = require('path'); 6 | 7 | module.exports = { 8 | command: 'server', 9 | desc: 'build the docs and run a web server', 10 | handler: (argv) => { 11 | const option = require('../lib')(argv); 12 | connect() 13 | .use(serveStatic(path.resolve(process.cwd(), option.output))) 14 | .listen(8080, function () { 15 | console.log('Server running on 8080...'); 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /chapters.yml: -------------------------------------------------------------------------------- 1 | - cli-options.md: Command line options 2 | - sub-commands.md: Sub-commands 3 | - template-variables.md: Template variables 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /dist/assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* 2 | * general 3 | */ 4 | 5 | :root { 6 | font-size: 16px; 7 | font-family: "FZLanTingHei", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Hiragino Kaku Gothic Pro", Meiryo, "Malgun Gothic", sans-serif; 8 | box-sizing: border-box; 9 | } 10 | 11 | body { 12 | font-size: 1.25rem; 13 | } 14 | 15 | code { 16 | font-size: 1.25rem; 17 | margin-left: 0.25rem; 18 | margin-right: 0.25rem; 19 | } 20 | 21 | .link-disabled { 22 | pointer-events: none; 23 | } 24 | 25 | .progress-indicator { 26 | position: fixed; 27 | top: 0; 28 | left: 0; 29 | height: 3px; 30 | background-color: #ffdd57; 31 | } 32 | 33 | 34 | /* 35 | * navbar 36 | */ 37 | 38 | .navbar { 39 | background-color: #3273dc; 40 | color: #FFF; 41 | position: fixed; 42 | top: 0; 43 | right: 0; 44 | width: 100%; 45 | height: 3.3125rem; 46 | padding-bottom: 0; 47 | margin-right: 0; 48 | margin-top: 0; 49 | margin-bottom:0; 50 | } 51 | 52 | .logo { 53 | margin-top: 0.3rem; 54 | margin-left: 1rem; 55 | } 56 | 57 | .logo a { 58 | color: #FFF; 59 | } 60 | 61 | .logo a:hover { 62 | color: rgba(255, 255, 255, 0.8); 63 | } 64 | 65 | .site-title { 66 | text-align: center; 67 | } 68 | 69 | 70 | /* 71 | * book-toc 72 | */ 73 | 74 | .book-toc { 75 | position: fixed; 76 | top: 3.3125rem; 77 | right: 0; 78 | opacity: 0.9; 79 | transition: all 1s; 80 | color: rgba(0, 0, 0, 0.4); 81 | line-height: 2; 82 | height: 75vh; 83 | width: 20rem; 84 | overflow: scroll; 85 | z-index: 9; 86 | } 87 | 88 | .book-toc h3 { 89 | margin-bottom: 1.5rem; 90 | } 91 | 92 | .book-toc a { 93 | color: rgba(0, 0, 0, 0.7); 94 | } 95 | 96 | .book-toc a:hover { 97 | color: rgba(0, 0, 0, 0.9); 98 | text-decoration: underline; 99 | } 100 | 101 | .book-toc .chapter-item-current a { 102 | color: #ff3860; 103 | font-weight: 700; 104 | } 105 | 106 | .book-toc ul { 107 | margin-top: 0; 108 | margin-left: 0.5rem; 109 | margin-right: 0.5rem; 110 | } 111 | 112 | .book-toc ul.chapter-area { 113 | counter-reset: chapter; 114 | } 115 | 116 | .book-toc ul.chapter-area > li { 117 | list-style-type: none; 118 | } 119 | 120 | .book-toc ul.chapter-area > li::before { 121 | counter-increment: chapter; 122 | content: counter(chapter) ". "; 123 | } 124 | 125 | .book-toc ul.chapter-level-1 { 126 | counter-reset: article; 127 | } 128 | 129 | .book-toc ul.chapter-level-1 > li { 130 | list-style-type: none; 131 | } 132 | 133 | .book-toc ul.chapter-level-1 > li::before { 134 | counter-increment: article; 135 | content: counter(chapter) "." counter(article) " "; 136 | } 137 | 138 | .book-toc .icon-expand { 139 | width: 18px; 140 | height: 18px; 141 | content: url(expand.svg); 142 | } 143 | 144 | .book-toc .icon-collapse { 145 | width: 18px; 146 | height: 18px; 147 | content: url(collapse.svg); 148 | } 149 | 150 | 151 | /* 152 | * content 153 | */ 154 | 155 | .content { 156 | background-color: #FFF; 157 | color: #333; 158 | margin-top: 3.3125rem; 159 | padding-left: 1rem; 160 | padding-right: 1rem; 161 | line-height: 1.6; 162 | } 163 | 164 | .breadcrumb-area { 165 | padding-top: 1rem; 166 | } 167 | 168 | h1.article-title { 169 | margin-bottom: 3rem; 170 | } 171 | 172 | .sticky { 173 | bottom: 0px; 174 | position: fixed; 175 | margin-left: 0; 176 | margin-right: 0; 177 | padding-left: 0; 178 | padding-right: 0; 179 | padding-bottom: 0px; 180 | margin-bottom: 0px; 181 | } 182 | 183 | .article-bar { 184 | margin-top: 3rem; 185 | padding-top: 1rem; 186 | margin-left: -10px; 187 | margin-right: -10px; 188 | border-top: 1px solid #bdc3c7; 189 | position: -webkit-sticky; 190 | position: sticky; 191 | bottom: 0; 192 | background: white; 193 | } 194 | 195 | .article-bar .icon-next { 196 | content: url(next.svg); 197 | } 198 | 199 | .article-bar .icon-previous { 200 | content: url(previous.svg); 201 | } 202 | 203 | .article-bar .icon-up { 204 | content: url(up.svg); 205 | } 206 | 207 | .article-bar .icon-size { 208 | content: url(size.svg); 209 | } 210 | 211 | .article-bar .icon-toc { 212 | content: url(toc.svg); 213 | } 214 | 215 | .article-bar .icon-previous-disabled { 216 | content: url(previous-disabled.svg); 217 | } 218 | 219 | .article-bar .icon-next-disabled { 220 | content: url(next-disabled.svg); 221 | } 222 | 223 | .article-bar .level-item .link-content { 224 | display: none; 225 | } 226 | 227 | 228 | /* 229 | * article 230 | */ 231 | 232 | .article { 233 | counter-reset: firstLevelTitle; 234 | } 235 | 236 | .article > h2::before { 237 | counter-increment: firstLevelTitle; 238 | content: counter(firstLevelTitle) '. '; 239 | } 240 | 241 | .article > h2:only-of-type::before { 242 | content: ''; 243 | } 244 | 245 | .article > h2 { 246 | counter-reset: secondLevelTitle; 247 | } 248 | 249 | .article > h3::before { 250 | counter-increment: secondLevelTitle; 251 | content: counter(firstLevelTitle) '.' counter(secondLevelTitle) ' '; 252 | } 253 | 254 | .article .first-level-collapse { 255 | display: none; 256 | } 257 | 258 | /* 259 | * article-toc 260 | */ 261 | 262 | .article-toc { 263 | border: 1px solid #aaa; 264 | background-color: #f9f9f9; 265 | display: table; 266 | padding: 1rem; 267 | } 268 | 269 | .article-toc ul { 270 | margin-left: 0.5rem; 271 | margin-right: 0.5rem; 272 | } 273 | 274 | .article-toc > ul { 275 | counter-reset: articleTOCFirstLevel; 276 | } 277 | 278 | .article-toc > ul > li { 279 | list-style-type: none; 280 | counter-reset: articleTOCSecondLevel; 281 | } 282 | 283 | .article-toc > ul > li::before { 284 | counter-increment: articleTOCFirstLevel; 285 | content: counter(articleTOCFirstLevel) '. '; 286 | } 287 | 288 | .article-toc > ul > li > ul > li { 289 | list-style-type: none; 290 | } 291 | 292 | .article-toc > ul > li > ul > li::before { 293 | counter-increment: articleTOCSecondLevel; 294 | content: counter(articleTOCFirstLevel) '.' counter(articleTOCSecondLevel) ' '; 295 | } 296 | 297 | 298 | /* 299 | * foot 300 | */ 301 | 302 | .foot { 303 | background-color: #FFF; 304 | border-top: 1px solid #bdc3c7; 305 | color: #b86bff; 306 | font-size: 0.9rem; 307 | } 308 | 309 | .columns.foot { 310 | margin-bottom: 0; 311 | } 312 | 313 | .build-by { 314 | margin-left: 1rem; 315 | } 316 | 317 | 318 | /* 319 | * highlight 320 | */ 321 | 322 | .hljs { 323 | margin-bottom: 1rem; 324 | border-radius: 0.5rem; 325 | overflow: auto; 326 | } 327 | 328 | .hljs code { 329 | background: #23241f; 330 | color: #f8f8f2; 331 | direction: ltr; 332 | } 333 | 334 | /* 335 | 336 | Monokai Sublime style. Derived from Monokai by noformnocontent http://nn.mit-license.org/ 337 | 338 | */ 339 | 340 | .hljs { 341 | display: block; 342 | overflow-x: auto; 343 | padding: 0.5em; 344 | background: #23241f; 345 | } 346 | 347 | .hljs, 348 | .hljs-tag, 349 | .hljs-subst { 350 | color: #f8f8f2; 351 | } 352 | 353 | .hljs-strong, 354 | .hljs-emphasis { 355 | color: #a8a8a2; 356 | } 357 | 358 | .hljs-bullet, 359 | .hljs-quote, 360 | .hljs-number, 361 | .hljs-regexp, 362 | .hljs-literal, 363 | .hljs-link { 364 | color: #ae81ff; 365 | } 366 | 367 | .hljs-code, 368 | .hljs-title, 369 | .hljs-section, 370 | .hljs-selector-class { 371 | color: #a6e22e; 372 | } 373 | 374 | .hljs-strong { 375 | font-weight: bold; 376 | } 377 | 378 | .hljs-emphasis { 379 | font-style: italic; 380 | } 381 | 382 | .hljs-keyword, 383 | .hljs-selector-tag, 384 | .hljs-name, 385 | .hljs-attr { 386 | color: #f92672; 387 | } 388 | 389 | .hljs-symbol, 390 | .hljs-attribute { 391 | color: #66d9ef; 392 | } 393 | 394 | .hljs-params, 395 | .hljs-class .hljs-title { 396 | color: #f8f8f2; 397 | } 398 | 399 | .hljs-string, 400 | .hljs-type, 401 | .hljs-built_in, 402 | .hljs-builtin-name, 403 | .hljs-selector-id, 404 | .hljs-selector-attr, 405 | .hljs-selector-pseudo, 406 | .hljs-addition, 407 | .hljs-variable, 408 | .hljs-template-variable { 409 | color: #e6db74; 410 | } 411 | 412 | .hljs-comment, 413 | .hljs-deletion, 414 | .hljs-meta { 415 | color: #75715e; 416 | } 417 | 418 | -------------------------------------------------------------------------------- /dist/assets/css/collapse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 39 | -------------------------------------------------------------------------------- /dist/assets/css/expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 39 | -------------------------------------------------------------------------------- /dist/assets/css/next-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /dist/assets/css/next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /dist/assets/css/previous-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /dist/assets/css/previous.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /dist/assets/css/size.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | -------------------------------------------------------------------------------- /dist/assets/css/toc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65 | -------------------------------------------------------------------------------- /dist/assets/css/up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /dist/assets/js/app.js: -------------------------------------------------------------------------------- 1 | /* set content's min-height */ 2 | (function () { 3 | var content = document.querySelector('.content'); 4 | var navbarHeight = 5 | window.getComputedStyle(document.querySelector('.navbar')) 6 | .getPropertyValue('height'); 7 | var footHeight = 8 | window.getComputedStyle(document.querySelector('.foot')) 9 | .getPropertyValue('height'); 10 | var minHeight = document.documentElement.clientHeight 11 | - parseInt(navbarHeight) 12 | - parseInt(footHeight); 13 | content.style.minHeight = minHeight + 'px'; 14 | })(); 15 | 16 | 17 | /* insert article's toc */ 18 | (function () { 19 | var article = document.querySelector('.article'); 20 | var firstH2Title = article.querySelector('h2'); 21 | var h2TitleNumber = article.querySelectorAll('h2'); 22 | 23 | if (firstH2Title && LOPPO.article_toc && h2TitleNumber.length >= 2) { 24 | var toc_div = document.createElement('div'); 25 | toc_div.setAttribute('class', 'article-toc'); 26 | toc_div.innerHTML = '
Loppo has some command line options.
--dir
or -d
sets the document directory which keeps the original Markdown files. docs
is the default directory.
$ loppo --dir my_docs
5 |
--output
or -o
sets the output directory which keeps the generated documents. dist
is the default directory.
$ loppo --output my_site
6 |
--site
or -s
sets the site's name. Documents
is the default.
$ loppo --site "My Documents"
7 |
--theme
or -t
sets a site's theme. loppo-theme-oceandeep
is the default.
$ loppo --theme oceandeep
8 |
--id
sets a site's ID (default is the dir name of the project).
--direction
sets the document's character direction. ltr
is the default. It also could be setted as rtl
.
The option needs the support of the site theme.
--debug
opens Loppo's debug mode.
--version
or -v
shows Loppo's version information.
--help
gives Loppo's commandline usage.
示例:
loppo --dir docs --output dist
Loppo is an extremely easy static site generator of markdown documents. You get your site with only one command. Please visit demo.
Attention: Loppo is still in its very early stages. Use it in production at your own risk.
First of all, arrange your documents into the following structure.
|- myProject
5 | |- README.md
6 | |- docs
7 | |- page1.md
8 | |- page2.md
9 | |- ...
10 |
Now, install Loppo.
$ npm install loppo -g
11 |
Enter your project directory.
$ cd myProject
12 |
Run the command.
$ loppo
13 |
Now, Loppo will build the document site under dist
sub-directory. After the building process, you could open the site in your browser.
$ open dist/index.html
14 |
GPL v3
Loppo supports two git-style subcommands.
loppo server
builds the document site at first, and thereafter launch a web server on 8080 port for dist
sub-directory.
$ loppo server
5 |
After running the comand, you could visit http://127.0.0.1:8080 in your browser.
This command is helpful for preview when you develop your documents.
loppo count
will output statistic information of your documents.
$ loppo count
6 |
7 | [Files] 56
8 | [Lines] 8885
9 | [Words] 51555
10 | [Chars] 362931
11 |
Attention, before using this command, chapters.yml
must already be existed. Otherwise you will get an error.
loppo count
has two options.
# output every markdown file's statistic information
12 | $ loppo count --detail
13 |
14 | # output a specified markdown file's statistic information
15 | # the file path should be same as the corresponding item in chapters.yml
16 | $ loppo count -f some.md
17 |
loppo chapter
will re-create chapters.yml
and add new Markdown files into it.
Attention, the old chapters.yml
will be deleted after running the command.
$ loppo chapter
18 |
19 | This command will delete your chapters.yml if existed.
20 | Are you sure to continue? (Y/N)
21 |
If you want to skip the confirmation, use --force
option.
$ loppo chapter --force
22 |
Loppo provides some template variables used in templates.
They could be divided into two categories: site variables and page variables.
The site variables are the same within the whole site.
option.site
is the site name (default is Documents
).
option.id
is the site id (default is the dir name of the project).
option.dir
is the document directory of the repo.
option.chapters
is an array which includes all .md
files and directories in the docs
directory.
[
5 | {'a.md': 'Title A'},
6 | {'dir1/': 'dir1'},
7 | {'dir1/b.md': 'Title B'}
8 | ]
9 |
If the doc
directory has nothing, option.chapters
will be an empty array.
option.chaptersOrigin
is just yet another form of option.chapters
with different data structure.
[
10 | { origin: 'a.md', path: 'a.html', text: 'Title A', nextLevelBegins: false, currentLevelEnds: false },
11 | { origin: 'dir1/', path: 'dir1/index.html', text: 'dir1', nextLevelBegins: true, currentLevelEnds: false },
12 | { origin: 'dir1/b.md', path: 'dir1/b.html', text: 'Title B', nextLevelBegins: false, currentLevelEnds: true, currentLevelEndNum: 2}
13 | ]
14 |
Fields
currentLevelEnds
is true
option.chapterList
is a HTML string converted from option.chapters
.
option.loppo_version
is the version number of Loppo.
Page variables are different for every document page.
option.current_path
is the relative path of current page, such like dir1/example.md
.
For the index page of document directory, option.current_path
is /
.
option.content
is the HTML markup of current page converted from markdown. It has three posibilities.
.md
file, option.content
is its markdown content.docs
directory), option.content
is the content of README.md
under the project root directory (not docs
directory)。If no README.md
,option.content
is an empty string.option.content
is all .md
files and sub-directories directly under it.option.isHomepage
is a boolean value to indicate whether on not the current page is the homepage of the site.
option.page_title
is the page name of a document page.
option.site
.<h1>
title of README.md
. If not, it is option.site
..md
file, it is the <h1>
title of the file. If not, it is the title in chapters.yml
.option.previous_page_object
is an object which represents the previous page of current page.
null
.chapters.yml
.For example, current page is b.md
as following. Then option.previous_page
is { 'b.md': 'Title B' }
.
- a.md: Title A
15 | - b.md: Title B
16 |
Attention, if current page is the first item of chapters.yml
and is not index.md
,option.previous_page
will be { 'index.md': 'Home' }
.
option.previousPageOrigin
is yet another form of option.previous_page_object
with different data structure.
{ origin: 'b.md', path: 'b.html', text: 'Title B' }
17 |
option.previous_page
is a HTML string converted from option.previous_page_object
.
option.next_page_object
is object which represents the next page of current page.
null
.chapters.yml
.For example, current page is a.md
as following. Then option.next_page_object
is { 'a.md': 'Title A' }
.
- a.md: Title A
18 | - b.md: Title B
19 |
option.nextPageOrigin
is yet another form of option.next_page_object
with different data structure.
{ origin: 'b.md', path: 'b.html', text: 'Title B' }
20 |
option.next_page
is a HTML string converted from option.next_page_object
.
option.relative_root_path
is the relative path to the root path for the current page.
For example, if the root path is /
and the current page is dir1/a.md
, option.relative_root_path
is ../
.
option.build_time
is the time of building the current page, which is a JavaScript Date instance.
option.breadcrumbOrigin
is an array containing the location information of current page.
For example, if current page is dir1/dir2/a.md
, option.breadcrumb
is the following.
[
21 | { path: 'index.html', text: 'Home' },
22 | { path: 'dir1/index.html', text: 'dir1' },
23 | { path: 'dir1/dir2/index.html', text: 'dir2' },
24 | { path: 'dir1/dir2/a.html', text: 'Title A' }
25 | ]
26 |
option.breadcrumb
is a HTML string which containing the location information of current page.
<div class="breadcrumb-area">
27 | <a href="index.html" class="breadcrumb-item" target="_blank">Home</a>
28 | <span class="breadcrumb-delimitor"> > </span>
29 | <a href="dir1/" class="breadcrumb-item" target="_blank">dir1</a>
30 | <!-- ... -->
31 | </div>
32 |
option.toc
is the table of content of current page.
This is an example.
\n'); 31 | t.end(); 32 | } 33 | ); 34 | 35 | test( 36 | test_title + 37 | 'special title', 38 | function (t) { 39 | const opt = { 40 | dir: 'docs', 41 | site: 'My Site', 42 | chapters: [ 43 | { 'dir1/': 'dir1' }, 44 | { 'dir1/a.md': 'Title A' }, 45 | { 'dir2/': 'dir2' }, 46 | { 'dir2/b.md': 'Title B' } 47 | ], 48 | content: '## HTML 文档的”标题“\n' 49 | }; 50 | const optionObj = markdownRender('dir1/a.md', opt); 51 | t.equal(optionObj.toc, 'console.log("hello world");\n
\n');
119 | t.end();
120 | }
121 | );
122 |
--------------------------------------------------------------------------------
/test/theme.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const test = require('tape');
5 | const fs = require('fs-extra');
6 | const theme = require('../lib/theme');
7 | // const walkSync = require('walk-sync');
8 |
9 | const log = require('../lib/utils').log;
10 | log.setLevel('error');
11 |
12 | const test_title = '[theme.js] ';
13 | const THEME = 'oceandeep';
14 | const THEMEDIR = 'loppo-theme';
15 |
16 | test(
17 | test_title +
18 | 'if the theme directory already exists, the directory will have no changes',
19 | function (t) {
20 | const TEST_PATH = path.resolve(__dirname, './fixture/theme/theme-exists');
21 | process.chdir(TEST_PATH);
22 | const option = theme({ theme: THEME });
23 | // const result = walkSync(path.resolve(process.cwd(), 'themes', THEME));
24 | let result = '';
25 | if (path.sep !== '/') {
26 | result = 'hello world\r\n';
27 | } else {
28 | result = 'hello world\n';
29 | }
30 | t.equal(option.templates.page(), result);
31 | t.end();
32 | }
33 | );
34 |
35 | test(
36 | test_title +
37 | 'if the theme directory does not exist, the directory will be created',
38 | function (t) {
39 | const TEST_PATH = path.resolve(__dirname, './fixture/theme/theme-not-exists');
40 | process.chdir(TEST_PATH);
41 | const option = theme({ theme: THEME });
42 | // const result = walkSync(path.resolve(process.cwd(), 'themes', THEME));
43 |
44 | t.equal(typeof option.templates.page, 'function');
45 | t.end();
46 | fs.removeSync(path.resolve(process.cwd(), 'themes', THEME));
47 | }
48 | );
49 |
50 | test(
51 | test_title +
52 | '<% includes %> syntax',
53 | function (t) {
54 | const TEST_PATH = path.resolve(__dirname, './fixture/theme/includes');
55 | process.chdir(TEST_PATH);
56 | const option = theme({ theme: THEME });
57 | // const result = walkSync(path.resolve(process.cwd(), 'themes', THEME));
58 | let result = '';
59 | if (path.sep !== '/') {
60 | result = 'Hello World
', 24 | templates: {} 25 | }; 26 | 27 | opt.templates.page = _.template('<%= content %>'); 28 | 29 | writePage('/', opt); 30 | 31 | t.ok(fs.existsSync(path.join(TEST_PATH, opt.output, 'index.html'))); 32 | t.equal( 33 | fs.readFileSync(path.join(TEST_PATH, opt.output, 'index.html'), 'utf8'), 34 | 'Hello World
' 35 | ); 36 | t.end(); 37 | fs.removeSync(path.join(TEST_PATH, opt.output)); 38 | } 39 | ); 40 | -------------------------------------------------------------------------------- /utils/escapeTitle.js: -------------------------------------------------------------------------------- 1 | const { escape } = require('lodash'); 2 | 3 | function escapeTitle(chapters) { 4 | if (!chapters) return undefined; 5 | chapters.map(c => { 6 | for (let key in c) { 7 | c[key] = escape(c[key]); 8 | } 9 | return c; 10 | }); 11 | return chapters; 12 | } 13 | 14 | module.exports = escapeTitle; 15 | -------------------------------------------------------------------------------- /utils/findRootPosition.js: -------------------------------------------------------------------------------- 1 | function findRootPosition(root, optionObj) { 2 | for (let i = 0; i < optionObj.chapters.length; i += 1) { 3 | if (optionObj.chapters[i][root]) { 4 | return i; 5 | } 6 | } 7 | throw new Error('Cannot find the path in Chapters!'); 8 | } 9 | 10 | module.exports = findRootPosition; 11 | -------------------------------------------------------------------------------- /utils/isHomepage.js: -------------------------------------------------------------------------------- 1 | function isHomepage(root) { 2 | if (root === '/' || root === 'index.md') return true; 3 | return false; 4 | } 5 | 6 | module.exports = isHomepage; 7 | -------------------------------------------------------------------------------- /utils/makeBreadcrumb.js: -------------------------------------------------------------------------------- 1 | function makeBreadcrumb(root, optionObj) { 2 | const bcHTMLArr = []; 3 | optionObj.breadcrumbOrigin.forEach(function (c) { 4 | let bcPath = c.path; 5 | /* 6 | if (bcPath.substr(-3) === '.md') { 7 | bcPath = bcPath.substr(0, bcPath.length - 3) + '.html'; 8 | } else if (bcPath.substr(-1) === '/') { 9 | bcPath += 'index.html'; 10 | } 11 | */ 12 | const str = '' 16 | + c.text 17 | + ''; 18 | bcHTMLArr.push(str); 19 | }); 20 | let bcHTML = ' '; 23 | optionObj.breadcrumb = bcHTML; 24 | return optionObj; 25 | } 26 | 27 | module.exports = makeBreadcrumb; 28 | -------------------------------------------------------------------------------- /utils/makeBreadcrumbOrigin.js: -------------------------------------------------------------------------------- 1 | const findRootPosition = require('./findRootPosition'); 2 | 3 | function transform(chapterObj) { 4 | let key = Object.keys(chapterObj)[0]; 5 | let value = chapterObj[key]; 6 | 7 | if (key.substr(-3).toLowerCase() === '.md') { 8 | key = key.substr(0, key.length - 3) + '.html'; 9 | } 10 | 11 | if (key.substr(-1) === '/') { 12 | key += 'index.html'; 13 | } 14 | 15 | return { 16 | path: key, 17 | text: value 18 | }; 19 | } 20 | 21 | function makeBreadcrumbOrigin(root, optionObj) { 22 | const breadcrumbArr = [transform({ 'index.md': 'Home' })]; 23 | if (root !== '/') { 24 | const pathArr = root.split('/'); 25 | let currentPath = ''; 26 | for (let i = 0; i < pathArr.length - 1; i += 1) { 27 | currentPath += pathArr[i] + '/'; 28 | for (let m = 0; m < optionObj.chapters.length; m += 1) { 29 | if (optionObj.chapters[m][currentPath]) { 30 | breadcrumbArr.push(transform(optionObj.chapters[m])); 31 | break; 32 | } 33 | } 34 | } 35 | } 36 | 37 | if (root[root.length - 1] !== '/') { 38 | if (root.substr(-8) === 'index.md') { 39 | breadcrumbArr.pop(); 40 | } 41 | const position = findRootPosition(root, optionObj); 42 | breadcrumbArr.push(transform(optionObj.chapters[position])); 43 | } 44 | optionObj.breadcrumbOrigin = breadcrumbArr; 45 | return optionObj; 46 | } 47 | 48 | module.exports = makeBreadcrumbOrigin; 49 | -------------------------------------------------------------------------------- /utils/makeChapterList.js: -------------------------------------------------------------------------------- 1 | function makeChapterList(root, optionObj) { 2 | let chapterArr = optionObj.chapters; 3 | if (!optionObj.chapters) { 4 | chapterArr = []; 5 | } 6 | let str = '