UNPKG

13.9 kBJavaScriptView Raw
1'use strict'
2
3const http = require('http')
4const path = require('path')
5const fs = require('fs')
6const chalk = require('chalk')
7
8const open = require('open')
9const Promise = require('bluebird')
10const connect = require('connect')
11const less = require('less')
12const send = require('send')
13const liveReload = require('livereload')
14const connectLiveReload = require('connect-livereload')
15const implant = require('implant')
16const deepmerge = require('deepmerge')
17const handlebars = require('handlebars')
18const termImg = require('term-img')
19const MarkdownIt = require('markdown-it')
20const mdItAnchor = require('markdown-it-anchor')
21const mdItTaskLists = require('markdown-it-task-lists')
22const mdItHLJS = require('markdown-it-highlightjs')
23
24const style = {
25 link: chalk.blueBright.underline.italic,
26 patreon: chalk.rgb(249, 104, 84).underline.italic,
27 github: chalk.blue.underline.italic,
28 address: chalk.greenBright.underline.italic,
29 port: chalk.reset.cyanBright,
30 pid: chalk.reset.cyanBright
31}
32
33const md = new MarkdownIt({
34 linkify: false,
35 html: true
36})
37 .use(mdItAnchor)
38 .use(mdItTaskLists)
39 .use(mdItHLJS)
40
41// Markdown Extension Types
42const fileTypes = {
43 markdown: [
44 '.markdown',
45 '.mdown',
46 '.mkdn',
47 '.md',
48 '.mkd',
49 '.mdwn',
50 '.mdtxt',
51 '.mdtext',
52 '.text'
53 ],
54
55 html: [
56 '.html',
57 '.htm'
58 ],
59
60 watch: [
61 '.sass',
62 '.less',
63 '.js',
64 '.css',
65 '.json',
66 '.gif',
67 '.png',
68 '.jpg',
69 '.jpeg'
70 ],
71
72 exclusions: [
73 'node_modules/'
74 ]
75}
76
77fileTypes.watch = fileTypes.watch
78 .concat(fileTypes.markdown)
79 .concat(fileTypes.html)
80
81const materialIcons = require(path.join(__dirname, 'icons', 'material-icons.json'))
82
83const faviconPath = path.join(__dirname, 'icons', 'markserv.svg')
84const faviconData = fs.readFileSync(faviconPath)
85
86const log = (str, flags, err) => {
87 if (flags.silent) {
88 return
89 }
90 if (str) {
91 // eslint-disable-next-line no-console
92 console.log(str)
93 }
94
95 if (err) {
96 // eslint-disable-next-line no-console
97 console.error(err)
98 }
99}
100
101const msg = (type, msg, flags) => {
102 if (type === 'patreon') {
103 return log(chalk`{bgRgb(249, 104, 84).white.bold {black ▕}● PATREON } ` + msg, flags)
104 }
105
106 if (type === 'github') {
107 return log(chalk`{bgYellow.black  GitHub } ` + msg, flags)
108 }
109
110 log(chalk`{bgGreen.black Markserv }{white ${type}: }` + msg, flags)
111}
112
113const errormsg = (type, msg, flags, err) =>
114 log(chalk`{bgRed.black Markserv }{red ${type}: }` + msg, flags, err)
115
116const warnmsg = (type, msg, flags) =>
117 log(chalk`{bgYellow.black Markserv }{yellow ${type}: }` + msg, flags)
118
119const isType = (exts, filePath) => {
120 const fileExt = path.parse(filePath).ext
121 return exts.includes(fileExt)
122}
123
124// MarkdownToHTML: turns a Markdown file into HTML content
125const markdownToHTML = markdownText => new Promise((resolve, reject) => {
126 let result
127
128 try {
129 result = md.render(markdownText)
130 } catch (err) {
131 return reject(err)
132 }
133
134 resolve(result)
135})
136
137// GetFile: reads utf8 content from a file
138const getFile = path => new Promise((resolve, reject) => {
139 fs.readFile(path, 'utf8', (err, data) => {
140 if (err) {
141 return reject(err)
142 }
143 resolve(data)
144 })
145})
146
147// Get Custom Less CSS to use in all Markdown files
148const buildLessStyleSheet = cssPath =>
149 new Promise(resolve =>
150 getFile(cssPath).then(data =>
151 less.render(data).then(data =>
152 resolve(data.css)
153 )
154 )
155 )
156
157const baseTemplate = (templateUrl, handebarData) => new Promise((resolve, reject) => {
158 getFile(templateUrl).then(source => {
159 const template = handlebars.compile(source)
160 const output = template(handebarData)
161 resolve(output)
162 }).catch(reject)
163})
164
165const lookUpIconClass = (path, type) => {
166 let iconDef
167
168 if (type === 'folder') {
169 iconDef = materialIcons.folderNames[path]
170
171 if (!iconDef) {
172 iconDef = 'folder'
173 }
174 }
175
176 if (type === 'file') {
177 // Try extensions first
178 const ext = path.slice(path.lastIndexOf('.') + 1)
179 iconDef = materialIcons.fileExtensions[ext]
180
181 // Then try applying the filename
182 if (!iconDef) {
183 iconDef = materialIcons.fileNames[path]
184 }
185
186 if (!iconDef) {
187 iconDef = 'file'
188 }
189 }
190
191 return iconDef
192}
193
194const dirToHtml = filePath => {
195 const urls = fs.readdirSync(filePath)
196
197 let list = '<ul>\n'
198
199 let prettyPath = '/' + path.relative(process.cwd(), filePath)
200 if (prettyPath[prettyPath.length] !== '/') {
201 prettyPath += '/'
202 }
203
204 if (prettyPath.substr(prettyPath.length - 2, 2) === '//') {
205 prettyPath = prettyPath.substr(0, prettyPath.length - 1)
206 }
207
208 urls.forEach(subPath => {
209 const dir = fs.statSync(filePath + subPath).isDirectory()
210 let href
211 if (dir) {
212 const iconClass = lookUpIconClass(subPath, 'folder')
213 href = subPath + '/'
214 list += `\t<li class="icon ${iconClass} isfolder"><a href="${href}">${href}</a></li> \n`
215 } else {
216 href = subPath
217 const iconClass = lookUpIconClass(href, 'file')
218 list += `\t<li class="icon ${iconClass} isfile"><a href="${href}">${href}</a></li> \n`
219 }
220 })
221
222 list += '</ul>\n'
223
224 return list
225}
226
227// Remove URL params from file being fetched
228const getPathFromUrl = url => {
229 return url.split(/[?#]/)[0]
230}
231
232const markservPageObject = {
233 lib: (dir, opts) => {
234 const relPath = path.join('lib', opts.rootRelUrl)
235 return relPath
236 }
237}
238
239// Http_request_handler: handles all the browser requests
240const createRequestHandler = flags => {
241 let dir = flags.dir
242 const isDir = fs.statSync(dir).isDirectory()
243 if (!isDir) {
244 dir = path.parse(flags.dir).dir
245 }
246 flags.$openLocation = path.relative(dir, flags.dir)
247
248 const implantOpts = {
249 maxDepth: 10
250 }
251
252 const implantHandlers = {
253 markserv: prop => new Promise(resolve => {
254 if (Reflect.has(markservPageObject, prop)) {
255 const value = path.relative(dir, __dirname)
256 return resolve(value)
257 }
258
259 resolve(false)
260 }),
261
262 file: (url, opts) => new Promise(resolve => {
263 const absUrl = path.join(opts.baseDir, url)
264 getFile(absUrl)
265 .then(data => {
266 msg('implant', style.link(absUrl), flags)
267 resolve(data)
268 })
269 .catch(err => {
270 warnmsg('implant 404', style.link(absUrl), flags, err)
271 resolve(false)
272 })
273 }),
274
275 less: (url, opts) => new Promise(resolve => {
276 const absUrl = path.join(opts.baseDir, url)
277 buildLessStyleSheet(absUrl)
278 .then(data => {
279 msg('implant', style.link(absUrl), flags)
280 resolve(data)
281 })
282 .catch(err => {
283 warnmsg('implant 404', style.link(absUrl), flags, err)
284 resolve(false)
285 })
286 }),
287
288 markdown: (url, opts) => new Promise(resolve => {
289 const absUrl = path.join(opts.baseDir, url)
290 getFile(absUrl).then(markdownToHTML)
291 .then(data => {
292 msg('implant', style.link(absUrl), flags)
293 resolve(data)
294 })
295 .catch(err => {
296 warnmsg('implant 404', style.link(absUrl), flags, err)
297 resolve(false)
298 })
299 }),
300
301 html: (url, opts) => new Promise(resolve => {
302 const absUrl = path.join(opts.baseDir, url)
303 getFile(absUrl)
304 .then(data => {
305 msg('implant', style.link(absUrl), flags)
306 resolve(data)
307 })
308 .catch(err => {
309 warnmsg('implant 404', style.link(absUrl), flags, err)
310 resolve(false)
311 })
312 })
313 }
314
315 const markservUrlLead = '%7Bmarkserv%7D'
316
317 return (req, res) => {
318 const decodedUrl = getPathFromUrl(decodeURIComponent(req.originalUrl))
319 const filePath = path.normalize(unescape(dir) + unescape(decodedUrl))
320 const baseDir = path.parse(filePath).dir
321 implantOpts.baseDir = baseDir
322
323 if (flags.verbose) {
324 msg('request', filePath, flags)
325 }
326
327 const isMarkservUrl = req.url.includes(markservUrlLead)
328 if (isMarkservUrl) {
329 const markservFilePath = req.url.split(markservUrlLead)[1]
330 const markservRelFilePath = path.join(__dirname, markservFilePath)
331 if (flags.verbose) {
332 msg('{markserv url}', style.link(markservRelFilePath), flags)
333 }
334 send(req, markservRelFilePath).pipe(res)
335 return
336 }
337
338 const prettyPath = filePath
339
340 let stat
341 let isDir
342 let isMarkdown
343 let isHtml
344
345 try {
346 stat = fs.statSync(filePath)
347 isDir = stat.isDirectory()
348 if (!isDir) {
349 isMarkdown = isType(fileTypes.markdown, filePath)
350 isHtml = isType(fileTypes.html, filePath)
351 }
352 } catch (err) {
353 const fileName = path.parse(filePath).base
354 if (fileName === 'favicon.ico') {
355 res.writeHead(200, {'Content-Type': 'image/x-icon'})
356 res.write(faviconData)
357 res.end()
358 return
359 }
360
361 res.writeHead(200, {'Content-Type': 'text/html'})
362 errormsg('404', filePath, flags, err)
363 res.write(`404 :'( for ${prettyPath}`)
364 res.end()
365 return
366 }
367
368 // Markdown: Browser is requesting a Markdown file
369 if (isMarkdown) {
370 msg('markdown', style.link(prettyPath), flags)
371 getFile(filePath).then(markdownToHTML).then(filePath).then(html => {
372 return implant(html, implantHandlers, implantOpts).then(output => {
373 const templateUrl = path.join(__dirname, 'templates/markdown.html')
374
375 const handlebarData = {
376 title: path.parse(filePath).base,
377 content: output,
378 pid: process.pid | 'N/A'
379 }
380
381 return baseTemplate(templateUrl, handlebarData).then(final => {
382 const lvl2Dir = path.parse(templateUrl).dir
383 const lvl2Opts = deepmerge(implantOpts, {baseDir: lvl2Dir})
384
385 return implant(final, implantHandlers, lvl2Opts)
386 .then(output => {
387 res.writeHead(200, {
388 'content-type': 'text/html'
389 })
390 res.end(output)
391 })
392 })
393 })
394 }).catch(err => {
395 // eslint-disable-next-line no-console
396 console.error(err)
397 })
398 } else if (isHtml) {
399 msg('html', style.link(prettyPath), flags)
400 getFile(filePath).then(html => {
401 return implant(html, implantHandlers, implantOpts).then(output => {
402 res.writeHead(200, {
403 'content-type': 'text/html'
404 })
405 res.end(output)
406 })
407 }).catch(err => {
408 // eslint-disable-next-line no-console
409 console.error(err)
410 })
411 } else if (isDir) {
412 // Index: Browser is requesting a Directory Index
413 msg('dir', style.link(prettyPath), flags)
414 const templateUrl = path.join(__dirname, 'templates/directory.html')
415
416 const dirs = path.relative(dir, filePath).split('/')
417 const shortPath = path.relative(dir, filePath)
418 const thisPath = shortPath.slice(shortPath.lastIndexOf('/') + 1)
419 const folderIcon = lookUpIconClass(thisPath, 'folder')
420
421 const crumbs = dirs.map((dir, i) => {
422 const href = '/' + (dirs.slice(0, i + 1).join('/')) + '/'
423 if (href === '//') {
424 dir = ''
425 }
426 const crumb = {href, text: dir}
427 return crumb
428 })
429
430 const handlebarData = {
431 dirname: path.parse(filePath).dir,
432 content: dirToHtml(filePath),
433 pid: process.pid | 'N/A',
434 path: shortPath + '/',
435 dir: dirs[dirs.length - 1] + '/',
436 folderIcon,
437 crumbs
438 }
439
440 return baseTemplate(templateUrl, handlebarData).then(final => {
441 const lvl2Dir = path.parse(templateUrl).dir
442 const lvl2Opts = deepmerge(implantOpts, {baseDir: lvl2Dir})
443 return implant(final, implantHandlers, lvl2Opts).then(output => {
444 res.writeHead(200, {
445 'content-type': 'text/html'
446 })
447 res.end(output)
448 })
449 }).catch(err => {
450 // eslint-disable-next-line no-console
451 console.error(err)
452 })
453 } else {
454 // Other: Browser requests other MIME typed file (handled by 'send')
455 msg('file', style.link(prettyPath), flags)
456 send(req, filePath, {dotfiles: 'allow'}).pipe(res)
457 }
458 }
459}
460
461const startConnectApp = (liveReloadPort, httpRequestHandler) => {
462 const connectApp = connect().use('/', httpRequestHandler)
463 connectApp.use(connectLiveReload({
464 port: liveReloadPort
465 }))
466
467 return connectApp
468}
469
470const startHTTPServer = (connectApp, port, flags) => {
471 let httpServer
472
473 if (connectApp) {
474 httpServer = http.createServer(connectApp)
475 } else {
476 httpServer = http.createServer()
477 }
478
479 httpServer.listen(port, flags.address)
480 return httpServer
481}
482
483const startLiveReloadServer = (liveReloadPort, flags) => {
484 let dir = flags.dir
485 const isDir = fs.statSync(dir).isDirectory()
486 if (!isDir) {
487 dir = path.parse(flags.dir).dir
488 }
489
490 const exts = fileTypes.watch.map(type => type.substr(1))
491 const exclusions = fileTypes.exclusions.map(exPath => {
492 return path.join(dir, exPath)
493 })
494
495 return liveReload.createServer({
496 exts,
497 exclusions,
498 port: liveReloadPort
499 }).watch(path.resolve(dir))
500}
501
502const logActiveServerInfo = (httpPort, liveReloadPort, flags) => {
503 const serveURL = 'http://' + flags.address + ':' + httpPort
504 const dir = path.resolve(flags.dir)
505
506 if (!flags.silent) {
507 const logoPath = path.join(__dirname, '..', 'media', 'markserv-logo-term.png')
508 termImg(logoPath, {
509 width: 12,
510 fallback: () => {}
511 })
512 }
513
514 const patreonLink = `patreon.com/f1lt3r`
515 const githubLink = 'github.com/f1lt3r/markserv'
516
517 msg('address', style.address(serveURL), flags)
518 msg('path', chalk`{grey ${style.address(dir)}}`, flags)
519 msg('livereload', chalk`{grey communicating on port: ${style.port(liveReloadPort)}}`, flags)
520
521 if (process.pid) {
522 msg('process', chalk`{grey your pid is: ${style.pid(process.pid)}}`, flags)
523 msg('stop', chalk`{grey press {magenta [Ctrl + C]} or type {magenta "sudo kill -9 ${process.pid}"}}`, flags)
524 }
525
526 msg('github', chalk`Contribute on Github - {yellow.underline ${githubLink}}`, flags)
527 msg('patreon', chalk`{whiteBright.bold Help support Markserv - Become a Patreon! ${style.patreon(patreonLink)}}`, flags)
528
529 if (flags.$openLocation || flags.$pathProvided) {
530 open(serveURL + '/' + flags.$openLocation)
531 }
532}
533
534const init = async flags => {
535 const liveReloadPort = flags.livereloadport
536 const httpPort = flags.port
537
538 const httpRequestHandler = createRequestHandler(flags)
539 const connectApp = startConnectApp(liveReloadPort, httpRequestHandler)
540 const httpServer = await startHTTPServer(connectApp, httpPort, flags)
541
542 let liveReloadServer
543 if (liveReloadPort) {
544 liveReloadServer = await startLiveReloadServer(liveReloadPort, flags)
545 }
546
547 // Log server info to CLI
548 logActiveServerInfo(httpPort, liveReloadPort, flags)
549
550 const service = {
551 pid: process.pid,
552 httpServer,
553 liveReloadServer,
554 connectApp
555 }
556
557 return service
558}
559
560module.exports = {
561 getFile,
562 markdownToHTML,
563 init
564}