UNPKG

7.74 kBJavaScriptView Raw
1const fs = require('fs')
2const path = require('path')
3const { spawn } = require('child_process')
4const readdirp = require('readdirp')
5const mkdirp = require('mkdirp')
6const es = require('event-stream')
7const cheerio = require('cheerio')
8const markdownIt = require('markdown-it')
9
10const mdOpts = {
11 html: true,
12 linkify: true,
13 typographer: true
14}
15
16const markdownItSub = require('markdown-it-sub')
17const markdownItSup = require('markdown-it-sup')
18const markdownItFootnote = require('markdown-it-footnote')
19const markdownItDeflist = require('markdown-it-deflist')
20const markdownItEmoji = require('markdown-it-emoji')
21const markdownItIns = require('markdown-it-ins')
22const markdownItMark = require('markdown-it-mark')
23const markdownItAbbr = require('markdown-it-abbr')
24const markdownItHighlightjs = require('markdown-it-highlightjs')
25const markdownItGithubHeadings = require('markdown-it-github-headings')
26
27const defaultLayout = path.join(__dirname, 'layout.html')
28const encoding = { encoding: 'utf8' }
29
30function noop () {}
31
32/**
33 * Generate a static HTML site from a collection of markdown files.
34 *
35 * @param {Object} options - source, build, layout, silent
36 * @param {Function} callback
37 */
38function sitedown (options, callback) {
39 options = options || {}
40 options.source = options.source || path.resolve('.')
41 options.build = options.build || path.resolve('build')
42 options.layout = options.layout ? path.resolve(process.cwd(), options.layout) : defaultLayout
43 // pretty defaults to true unless explicitly set to false
44 options.pretty = options.pretty !== false
45 options.el = options.el || '.markdown-body'
46 options.files = []
47 options.assets = options.assets || 'assets'
48
49 if (typeof callback === 'undefined') callback = noop
50
51 if (!fs.existsSync(options.layout)) {
52 const error = new Error('layout file not found: ' + options.layout)
53 if (callback === noop) throw error
54 return callback(error)
55 }
56
57 readdirp(options.source, {
58 fileFilter: ['*.md', '*.markdown'],
59 directoryFilter: ['!.git', '!node_modules', '!' + options.build]
60 })
61 .on('warn', function (err) {
62 console.error('warning', err)
63 })
64 .on('error', function (err) {
65 callback(err)
66 })
67 .pipe(es.mapSync(function (entry) {
68 return entry.path
69 }))
70 .on('data', function (file) {
71 options.files.push(file)
72 })
73 .on('end', function () {
74 generateSite(options, _ => {
75 copyAssets(options.assets, options.build)
76 callback(null)
77 })
78 })
79}
80
81function copyAssets (source, dest) {
82 if (fs.existsSync(source)) {
83 spawn('cp', ['-R', '-v', source, dest], { stdio: 'inherit' })
84 } else {
85 console.log('❌ no assets found, skipping')
86 }
87}
88
89/**
90 * Turns markdown file into HTML.
91 *
92 * @param {String} filePath - full path to markdown file
93 @param {Object} options - hljsHighlights, githubHeadings
94 * @return {String} - md file converted to html
95 */
96function mdToHtml (filePath, opts) {
97 const body = fs.readFileSync(filePath, encoding)
98 if (!opts) opts = {}
99
100 let md = markdownIt(mdOpts)
101 .use(markdownItSub)
102 .use(markdownItSup)
103 .use(markdownItFootnote)
104 .use(markdownItDeflist)
105 .use(markdownItEmoji)
106 .use(markdownItIns)
107 .use(markdownItMark)
108 .use(markdownItAbbr)
109 .use(markdownItHighlightjs, { auto: false, code: !opts.noHljsClass })
110
111 if (opts.githubHeadings) {
112 md = md.use(markdownItGithubHeadings, { prefixHeadingIds: false })
113 }
114
115 // disable autolinking for filenames
116 md.linkify.tlds('.md', false) // markdown
117
118 return md.render(body)
119}
120
121/**
122 * Injects title and body into HTML layout.
123 * Title goes into `title` element, body goes into `.markdown-body` element.
124 *
125 * @param {String} title - page title
126 * @param {String} body - html content to inject into target
127 * @param {String} layout - html layout file
128 * @param {String?} el - CSS selector for target element
129 * @return {String}
130 */
131function buildPage (title, body, layout, el) {
132 const page = cheerio.load(layout)
133 const target = el || '.markdown-body'
134
135 page('title').text(title)
136 page(target).append(body)
137
138 return page.html()
139}
140
141/**
142 * Rewrites relative `$1.md` and `$1.markdown` links in body to `$1/index.html`.
143 * If pretty is false, rewrites `$1.md` to `$1.html`.
144 * `readme.md` is always rewritten to `index.html`.
145 *
146 * @param {String} body - html content to rewrite
147 * @param {Boolean} pretty - rewrite links for pretty URLs (directory indexes)
148 * @return {String}
149 */
150function rewriteLinks (body, pretty) {
151 body = body || ''
152
153 if (pretty !== false) pretty = true // default to true if omitted
154
155 const regex = /(href=")((?!http[s]*:).*)(\.md|\.markdown)"/g
156
157 return body.replace(regex, function (match, p1, p2, p3) {
158 const f = p2.toLowerCase()
159
160 // root readme
161 if (f === 'readme') return p1 + '/"'
162
163 // nested readme
164 if (f.match(/readme$/)) return p1 + f.replace(/readme$/, '') + '"'
165
166 // pretty url
167 if (pretty) return p1 + f + '/"'
168
169 // default
170 return p1 + f + '.html"'
171 })
172}
173
174/**
175 * Generates site from array of markdown file paths.
176 *
177 * @param {Object} options - source, layout, output, silent, files, pretty, el, githubHeadings
178 * @param {Function} callback
179 */
180function generateSite (options, callback) {
181 const layout = fs.readFileSync(options.layout, encoding)
182
183 options.files.forEach(function (file) {
184 const parsedFile = path.parse(file)
185 const name = parsedFile.name.toLowerCase()
186
187 parsedFile.ext = '.html'
188
189 if (name === 'readme') {
190 parsedFile.name = 'index'
191 parsedFile.base = 'index.html'
192 } else {
193 if (options.pretty) {
194 parsedFile.name = 'index'
195 parsedFile.base = 'index.html'
196 parsedFile.dir = path.join(parsedFile.dir, name)
197 } else {
198 parsedFile.name = name
199 parsedFile.base = name + '.html'
200 }
201 }
202
203 const dest = path.format(parsedFile)
204 const body = rewriteLinks(mdToHtml(path.join(options.source, file), {
205 githubHeadings: options.githubHeadings,
206 noHljsClass: options.noHljsClass
207 }), options.pretty)
208 const title = cheerio.load(body)('h1').first().text().trim()
209 const html = buildPage(title, body, layout, options.el)
210
211 mkdirp.sync(path.join(options.build, parsedFile.dir))
212 fs.writeFileSync(path.join(options.build, dest), html, encoding)
213 if (!options.silent) console.log('✅ built', dest)
214 })
215
216 callback(null)
217}
218
219/**
220 * Run sitedown and watch for changes.
221 *
222 * @param {Object} options - source, layout, output, silent, files, pretty
223 */
224function watch (options, callback) {
225 const gaze = require('gaze')
226 const source = path.resolve(options.source)
227 const layout = options.layout ? path.resolve(process.cwd(), options.layout) : defaultLayout
228
229 sitedown(options, function (err) {
230 if (err) return console.error(err.message)
231
232 callback(err)
233
234 gaze(['**/*.md', layout], { cwd: source }, function (err, watcher) {
235 if (err) console.error(err.message)
236
237 console.log('\nWatching ' + source + ' for changes...')
238
239 watcher.on('all', function (event, filepath) {
240 console.log('\n' + filepath + ' was ' + event + '\n')
241
242 sitedown(options, function (err) {
243 if (err) return console.error(err.message)
244 })
245 })
246 })
247 })
248}
249
250function dev (options) {
251 sitedown.watch(options, _ => {
252 const execPath = path.join(__dirname, './node_modules/.bin/serve')
253 const proc = spawn(execPath, [options.build], { stdio: 'inherit' })
254
255 proc.on('close', code => process.exit(code))
256 })
257}
258
259sitedown.mdToHtml = mdToHtml
260sitedown.buildPage = buildPage
261sitedown.rewriteLinks = rewriteLinks
262sitedown.generateSite = generateSite
263sitedown.watch = watch
264sitedown.dev = dev
265
266module.exports = sitedown