1 | 'use strict'
|
2 |
|
3 | const http = require('http')
|
4 | const path = require('path')
|
5 | const fs = require('fs')
|
6 | const chalk = require('chalk')
|
7 |
|
8 | const open = require('open')
|
9 | const Promise = require('bluebird')
|
10 | const connect = require('connect')
|
11 | const less = require('less')
|
12 | const send = require('send')
|
13 | const liveReload = require('livereload')
|
14 | const connectLiveReload = require('connect-livereload')
|
15 | const implant = require('implant')
|
16 | const deepmerge = require('deepmerge')
|
17 | const handlebars = require('handlebars')
|
18 | const termImg = require('term-img')
|
19 | const MarkdownIt = require('markdown-it')
|
20 | const mdItAnchor = require('markdown-it-anchor')
|
21 | const mdItTaskLists = require('markdown-it-task-lists')
|
22 | const mdItHLJS = require('markdown-it-highlightjs')
|
23 |
|
24 | const 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 |
|
33 | const md = new MarkdownIt({
|
34 | linkify: false,
|
35 | html: true
|
36 | })
|
37 | .use(mdItAnchor)
|
38 | .use(mdItTaskLists)
|
39 | .use(mdItHLJS)
|
40 |
|
41 |
|
42 | const 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 |
|
77 | fileTypes.watch = fileTypes.watch
|
78 | .concat(fileTypes.markdown)
|
79 | .concat(fileTypes.html)
|
80 |
|
81 | const materialIcons = require(path.join(__dirname, 'icons', 'material-icons.json'))
|
82 |
|
83 | const faviconPath = path.join(__dirname, 'icons', 'markserv.svg')
|
84 | const faviconData = fs.readFileSync(faviconPath)
|
85 |
|
86 | const log = (str, flags, err) => {
|
87 | if (flags.silent) {
|
88 | return
|
89 | }
|
90 | if (str) {
|
91 |
|
92 | console.log(str)
|
93 | }
|
94 |
|
95 | if (err) {
|
96 |
|
97 | console.error(err)
|
98 | }
|
99 | }
|
100 |
|
101 | const 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 |
|
113 | const errormsg = (type, msg, flags, err) =>
|
114 | log(chalk`{bgRed.black Markserv }{red ${type}: }` + msg, flags, err)
|
115 |
|
116 | const warnmsg = (type, msg, flags) =>
|
117 | log(chalk`{bgYellow.black Markserv }{yellow ${type}: }` + msg, flags)
|
118 |
|
119 | const isType = (exts, filePath) => {
|
120 | const fileExt = path.parse(filePath).ext
|
121 | return exts.includes(fileExt)
|
122 | }
|
123 |
|
124 |
|
125 | const 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 |
|
138 | const 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 |
|
148 | const 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 |
|
157 | const 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 |
|
165 | const 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 |
|
178 | const ext = path.slice(path.lastIndexOf('.') + 1)
|
179 | iconDef = materialIcons.fileExtensions[ext]
|
180 |
|
181 |
|
182 | if (!iconDef) {
|
183 | iconDef = materialIcons.fileNames[path]
|
184 | }
|
185 |
|
186 | if (!iconDef) {
|
187 | iconDef = 'file'
|
188 | }
|
189 | }
|
190 |
|
191 | return iconDef
|
192 | }
|
193 |
|
194 | const 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 |
|
228 | const getPathFromUrl = url => {
|
229 | return url.split(/[?#]/)[0]
|
230 | }
|
231 |
|
232 | const markservPageObject = {
|
233 | lib: (dir, opts) => {
|
234 | const relPath = path.join('lib', opts.rootRelUrl)
|
235 | return relPath
|
236 | }
|
237 | }
|
238 |
|
239 |
|
240 | const 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 |
|
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 |
|
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 |
|
409 | console.error(err)
|
410 | })
|
411 | } else if (isDir) {
|
412 |
|
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 |
|
451 | console.error(err)
|
452 | })
|
453 | } else {
|
454 |
|
455 | msg('file', style.link(prettyPath), flags)
|
456 | send(req, filePath, {dotfiles: 'allow'}).pipe(res)
|
457 | }
|
458 | }
|
459 | }
|
460 |
|
461 | const startConnectApp = (liveReloadPort, httpRequestHandler) => {
|
462 | const connectApp = connect().use('/', httpRequestHandler)
|
463 | connectApp.use(connectLiveReload({
|
464 | port: liveReloadPort
|
465 | }))
|
466 |
|
467 | return connectApp
|
468 | }
|
469 |
|
470 | const 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 |
|
483 | const 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 |
|
502 | const 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 |
|
534 | const 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 |
|
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 |
|
560 | module.exports = {
|
561 | getFile,
|
562 | markdownToHTML,
|
563 | init
|
564 | }
|