UNPKG

markugen

Version:

Markdown to HTML/PDF static site generation tool

782 lines (781 loc) 32.2 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const node_path_1 = __importDefault(require("node:path")); const fs_extra_1 = __importDefault(require("fs-extra")); const colors_1 = __importDefault(require("colors")); const markugen_1 = __importDefault(require("./markugen")); const marked_1 = require("marked"); const marked_alert_1 = __importDefault(require("marked-alert")); const marked_directive_1 = require("marked-directive"); const marked_gfm_heading_id_1 = require("marked-gfm-heading-id"); const marked_highlight_1 = require("marked-highlight"); const highlight_js_1 = __importDefault(require("highlight.js")); const markeddocument_1 = __importDefault(require("./extensions/markeddocument")); const markedlinks_1 = __importDefault(require("./extensions/markedlinks")); const markedcommands_1 = __importDefault(require("./extensions/markedcommands")); const tabdirectives_1 = require("./extensions/tabdirectives"); const markedcopysavecode_1 = __importDefault(require("./extensions/markedcopysavecode")); const themes_1 = require("./themes"); const preprocessor_1 = require("./preprocessor"); const generator_1 = __importDefault(require("./generator")); __exportStar(require("./themes"), exports); __exportStar(require("./preprocessor"), exports); __exportStar(require("./page"), exports); __exportStar(require("./htmloptions"), exports); const EmptyAsset = { input: '', output: '' }; class HtmlGenerator extends generator_1.default { /** * The name of the markugen generated files */ static markugenFiles = { js: { out: 'markugen.js', template: 'markugen.template.js' }, css: { out: 'markugen.css', template: 'markugen.template.css' }, }; /** * Regular expression used for Markugen commands */ static cmdRegex = /\s*(?<esc>[\\]?)markugen\. *(?<cmd>[a-z_0-9]+) +(?<args>.+)/i; /** * Used to generate ids the same each time */ static globalId = 1; /** * Contains the options that were given on construction */ options; /** * Path to the templates */ templates; /** * Generated sitemap */ sitemap = { name: 'sitemap', title: 'Markugen v' + markugen_1.default.version, toc: 3, footer: '', home: '', }; /** * The html files that were generated */ generated = []; /** * JavaScript to embed in each page */ script = ''; /** * CSS to embed in each page */ style = ''; /** * Assets to copy over */ assets = { css: [], js: [], assets: [], favicon: EmptyAsset }; /** * The preprocessor to use for template expansion */ preprocessor; /** * Constructs a new generator with the given markugen options */ constructor(mark, options) { super(mark, options); this.options = { input: options.input, format: options.format ?? 'file', extensions: options.extensions ?? ['md'], outputFormat: options.outputFormat ?? 'file', output: node_path_1.default.resolve(options.output ?? './output'), outputName: options.outputName ?? '', pdf: options.pdf ?? false, pdfOnly: options.pdfOnly ?? false, exclude: options.exclude ?? [], title: options.title ?? 'Markugen v' + markugen_1.default.version, inheritTitle: options.inheritTitle ?? false, footer: options.footer ?? '', timestamp: options.timestamp ?? true, home: options.home ?? '', toc: options.toc ?? 3, embed: options.embed ?? false, favicon: options.favicon ?? '', assets: options.assets ?? [], keepAssets: options.keepAssets ?? false, script: options.script ?? '', js: options.js ?? [], style: options.style ?? '', css: options.css ?? [], theme: options.theme ?? themes_1.defaultThemes, vars: options.vars ?? {}, includeHidden: options.includeHidden ?? false, clearOutput: options.clearOutput ?? false, }; // unescape newlines provided in the string if (options.format === 'string' && options.cli === true) this.options.input = options.input.replace(/\\n/g, '\n'); this.templates = node_path_1.default.resolve(mark.root, 'templates'); if (!fs_extra_1.default.existsSync(this.templates)) throw Error(`Unable to locate templates directory [${this.templates}]`); // create the preprocessor this.preprocessor = new preprocessor_1.Preprocessor(this); // validate the options this.validate(); } /** * Generates the documentation. * @returns the paths to all generated pages, the html if format === 'string', or * undefined if an error occurred */ generate() { this.start(); // prepares for generation this.prepare(); let result = this.writeChildren(this.sitemap); // result should be the home file path if (this.options.outputFormat === 'file') result = node_path_1.default.resolve(this.output, this.sitemap.home); this.finish(); // output to the console if cli and output format of string if (this.options.outputFormat === 'string' && result) console.log(result); // return the result return this.options.outputFormat === 'string' ? result : this.generated; } /** * Clears all assets from the last run */ clearAssets() { // clear the assets const temp = []; for (const assets of Object.values(this.assets)) { if (Array.isArray(assets)) temp.push(...assets); else temp.push(assets); } for (const asset of temp) { // handle empty assets if (asset.output) { const full = node_path_1.default.resolve(this.output, asset.output); if (fs_extra_1.default.existsSync(full)) fs_extra_1.default.removeSync(full); } } } /** * @returns true if the input given is a single file */ get isInputFile() { return fs_extra_1.default.existsSync(this.input) && fs_extra_1.default.lstatSync(this.input).isFile(); } /** * @returns true if the input given is a string */ get isInputString() { return this.options.format === 'string'; } /** * @returns true if the input is a string or file */ get isInputSolo() { return this.isInputString || this.isInputFile; } /** * @returns the path to the input or the string input */ get input() { return this.options.input; } /** * @returns the path to the input directory */ get inputDir() { return this.isInputFile ? node_path_1.default.dirname(this.input) : this.input; } /** * @returns the path to the output directory */ get output() { return this.options.output; } /** * @returns true if hidden files and folders should be included */ get includeHidden() { return this.options.includeHidden; } /** * @returns true if the output should be cleared first */ get clearOutput() { return this.options.clearOutput; } /** * Checks to see if the path is excluded. * @param file the path to check for exclusion * @returns true if the path is excluded, false otherwise */ isExcluded(file) { for (const exclude of this.options.exclude) if (file === exclude) return true; // check the hidden files and folders return !this.options.includeHidden && node_path_1.default.basename(file).startsWith('.'); } /** * Validates the options and makes changes where necessary */ validate() { // must have at least one extension if (this.options.extensions.length < 1) this.options.extensions.push('md'); // pdfOnly implies pdf if (this.options.pdfOnly) this.options.pdf = true; // pdf implies output format of file if (this.options.pdf && this.options.outputFormat === 'string') { this.warning(`Output format changing to ${colors_1.default.green('file')} for PDF generation`); this.options.outputFormat = 'file'; } // validate the input and options based on input if (!this.isInputString) { this.options.input = node_path_1.default.resolve(this.options.input); if (!fs_extra_1.default.existsSync(this.options.input)) throw new Error(`Input does not exist [${colors_1.default.red(this.options.input)}]`); } // set the name of the output file if (this.isInputSolo && !this.options.outputName) { const name = this.isInputString ? 'index' : node_path_1.default.parse(this.options.input).name; this.options.outputName = name; } // handle pdf options if (this.options.pdf && !this.mark.options.browser) this.mark.options.browser = markugen_1.default.findChrome() ?? ''; if (this.options.pdf && (!this.mark.options.browser || !fs_extra_1.default.existsSync(this.mark.options.browser))) { throw new Error(`Unable to locate browser at [${this.mark.options.browser}], cannot generate PDFs`); } // output string only valid for input string if (this.options.outputFormat === 'string' && !this.isInputFile && this.options.format !== 'string') throw new Error('Output format can only be string if input is a string or a file'); // string format implies embed if (this.options.format === 'string' || this.options.outputFormat === 'string') this.options.embed = true; // solo input implies inherit title if (this.isInputSolo) this.options.inheritTitle = true; // check on protected directories if (this.options.clearOutput) { const nono = [ node_path_1.default.resolve('/'), this.inputDir, process.cwd(), ]; if (nono.includes(this.output)) { this.warning(`Output set to protected directory [${colors_1.default.red(this.output)}], skipping clear output`); this.options.clearOutput = false; } } this.setTheme(); this.checkAssets(); this.checkExcluded(); // quiet mode if string output if (this.options.outputFormat === 'string' && !this.options.pdf) this.mark.options = { ...this.mark.options, quiet: true }; } /** * Checks that the files are relative to the input directory and filters * out the ones that are not. Also resolves each path. * @param paths the file(s) to check for relativeness * @param file if true, checks that the input is a file * @returns the file(s) with non-existing removed and fully resolved paths */ filterInput(paths, file = false) { const isArray = Array.isArray(paths); const temp = isArray ? paths : [paths]; const filtered = []; for (const p of temp) { if (!p) continue; const resolved = node_path_1.default.isAbsolute(p) ? node_path_1.default.normalize(p) : node_path_1.default.resolve(this.inputDir, p); if (!fs_extra_1.default.existsSync(resolved)) { this.warning(`Given file or folder does not exist [${colors_1.default.red(p)}]`); } else if (file) { const stat = fs_extra_1.default.statSync(resolved); if (stat.isFile()) filtered.push(resolved); else this.warning(`Given input must be a file [${colors_1.default.red(p)}]`); } else filtered.push(resolved); } return filtered; } /** * Checks the validity of the excluded files */ checkExcluded() { // assets should be excluded for (const ass of this.options.assets) this.options.exclude.push(ass); this.options.exclude = this.filterInput(this.options.exclude); } /** * Checks the validity of the assets */ checkAssets() { this.assets = { css: [], js: [], assets: [], favicon: EmptyAsset }; this.resolveAssets(this.filterInput(this.options.js, true), this.assets.js, !this.options.embed, true); this.resolveAssets(this.filterInput(this.options.css, true), this.assets.css, !this.options.embed, true); this.resolveAssets(this.filterInput(this.options.favicon, true), this.assets.favicon); this.resolveAssets(this.filterInput(this.options.assets), this.assets.assets); } /** * Resolves the paths and stats on assets * @param assets the assets to filter * @param where where to place the resolved assets * @param copy whether these assets should be copied */ resolveAssets(assets, where, copy = true, expand = false) { const isArray = Array.isArray(assets); const temp = isArray ? assets : [assets]; for (const asset of temp) { const a = { input: asset, output: '', copy: copy, expand: expand }; const stat = fs_extra_1.default.statSync(asset); a.file = stat.isFile(); const rel = node_path_1.default.relative(this.inputDir, asset); // not relative to input dir if (rel.startsWith('..')) a.output = node_path_1.default.basename(asset); // relative to input directory else a.output = rel; if (Array.isArray(where)) where.push(a); else { where.input = a.input; where.output = a.output; where.copy = a.copy; where.expand = a.expand; where.file = a.file; } } } /** * Sets the appropriate themes based on the given values * @param themes the provided themes */ setTheme(themes) { if (!themes) this.options.theme = themes_1.defaultThemes; else { this.options.theme = { light: themes.light ? { ...themes_1.defaultThemes.light, ...themes.light } : themes_1.defaultThemes.light, dark: themes.dark ? { ...themes_1.defaultThemes.dark, ...themes.dark } : themes_1.defaultThemes.dark, }; } } /** * Prepares the generator */ prepare() { this.sitemap.title = this.options.title; this.sitemap.toc = this.options.toc; this.sitemap.home = this.options.home; this.sitemap.footer = this.options.footer; this.sitemap.children = {}; this.style = ''; this.script = ''; this.generated = []; // collect all of the children and build the sitemap if (!this.addChildren(this.inputDir, this.sitemap)) throw new Error(`No markdown files found in [${colors_1.default.red(this.inputDir)}]`); // set home to the first child with a page if (!this.sitemap.home && this.sitemap.children) { for (const child in this.sitemap.children) { if (this.sitemap.children[child].href) { this.sitemap.home = this.sitemap.children[child].href; break; } } } // clear and create the output directory if (this.clearOutput && fs_extra_1.default.existsSync(this.output)) { this.log('Clearing Output:', this.output); fs_extra_1.default.removeSync(this.output); } // create the directory if (!fs_extra_1.default.existsSync(this.output)) fs_extra_1.default.ensureDirSync(this.output); // copy the assets over this.writeAssets(); } /** * Copies the assets to the output directory */ writeAssets() { this.group(colors_1.default.green('Writing:'), 'assets'); for (const asset of this.assets.assets) this.writeAsset(asset); this.writeAsset(this.assets.favicon); this.writeScripts(); this.writeStyles(); this.groupEnd(); } /** * Copies a single asset to the output directory * @param asset the asset to copy */ writeAsset(asset) { if (asset.input && asset.output && asset.copy) { try { this.log(asset.expand ? 'Expand:' : 'Copy:', asset.input); const out = node_path_1.default.join(this.output, asset.output); const dir = node_path_1.default.join(this.output, asset.file ? node_path_1.default.dirname(asset.output) : asset.output); fs_extra_1.default.ensureDirSync(dir); if (!asset.expand) fs_extra_1.default.copySync(asset.input, out); else { const text = fs_extra_1.default.readFileSync(asset.input, { encoding: 'utf8' }); fs_extra_1.default.writeFileSync(out, this.preprocessor.process(text, asset.input)); } } catch (e) { this.warning(`Unable to access asset [${colors_1.default.red(asset.input)}]`, this.mark.options.debug ? `\n${e.stack}` : undefined); } } } /** * Writes and sets the styles */ writeScripts() { // write out the sitemap const temp = node_path_1.default.resolve(this.templates, HtmlGenerator.markugenFiles.js.template); this.preprocessor.vars.sitemap = this.removeInput(structuredClone(this.sitemap)); this.script = this.preprocessor.process(`${fs_extra_1.default.readFileSync(temp, { encoding: 'utf8' })}\n${this.options.script}`, temp); if (!this.options.embed) { const file = HtmlGenerator.markugenFiles.js.out; const full = node_path_1.default.resolve(this.output, file); fs_extra_1.default.writeFileSync(full, this.script); // expand the js assets for (const asset of this.assets.js) this.writeAsset(asset); // the markugen script must be first this.assets.js = [{ input: temp, output: file, file: true }, ...this.assets.js]; this.script = ''; } else { // embed js from files if (this.assets.js) { for (const asset of this.assets.js) { try { const text = fs_extra_1.default.readFileSync(asset.input, { encoding: 'utf8' }); this.script += '\n' + this.preprocessor.process(text, asset.input) + '\n'; } catch (e) { this.warning(`Given js file cannot be read [${colors_1.default.red(asset.input)}]`, this.mark.options.debug ? `\n${e.stack}` : undefined); } } this.assets.js = []; } } } /** * Writes and sets the styles */ writeStyles() { // write out the styles const temp = node_path_1.default.resolve(this.templates, HtmlGenerator.markugenFiles.css.template); this.preprocessor.vars.theme = { light: this.options.theme.light, dark: this.options.theme.dark }; this.style = this.preprocessor.process(`${fs_extra_1.default.readFileSync(temp, { encoding: 'utf8' })}\n${this.options.style}`, temp); if (!this.options.embed) { const file = HtmlGenerator.markugenFiles.css.out; const full = node_path_1.default.resolve(this.output, file); fs_extra_1.default.writeFileSync(full, this.style); // expand the css assets for (const asset of this.assets.css) this.writeAsset(asset); this.assets.css.push({ input: temp, output: file, file: true }); this.style = ''; } else { // embed styles from files if (this.assets.css) { for (const asset of this.assets.css) { try { const text = fs_extra_1.default.readFileSync(asset.input, { encoding: 'utf8' }); this.style += '\n' + this.preprocessor.process(text, asset.input) + '\n'; } catch (e) { this.warning(`Given css file cannot be read [${colors_1.default.red(asset.input)}]`, this.mark.options.debug ? `\n${e.stack}` : undefined); } } } this.assets.css = []; } } /** * Removes the input key from the given page * @param page the page to update * @returns the page with the key removed */ removeInput(page) { // remove the page's input if (page.input !== undefined) page.input = undefined; // remove the children's input if (page.children) { for (const child in page.children) this.removeInput(page.children[child]); } return page; } /** * Adds the children for the given page in the given directory * @param dir the directory to add children from * @param parent the parent page */ addChildren(dir, parent) { parent.children = {}; // handle single file if (this.isInputSolo) { const input = this.isInputFile ? this.options.input : this.options.outputName + '.md'; const entry = this.entry(this.inputDir, node_path_1.default.basename(input), true); if (entry) { this.addChild(parent, entry); return true; } return false; } let mark = []; // look for config file first const mfile = node_path_1.default.resolve(dir, 'markugen.json'); if (fs_extra_1.default.existsSync(mfile)) { try { mark = fs_extra_1.default.readJsonSync(mfile); } catch (e) { this.warning(`${e.message} [${mfile}]`); } if (!Array.isArray(mark)) { this.warning(`Configuration must be an array [${colors_1.default.red(mfile)}]`); mark = []; } } // populate the children from the config for (const child of mark) { const entry = this.entry(dir, child.name); if (!entry) this.warning(`Configuration [${colors_1.default.red(child.name)}] does not exist [${colors_1.default.red(mfile)}]`); else this.addChild(parent, entry, child); } // now get the rest of the files const subs = []; const files = fs_extra_1.default.readdirSync(dir, { withFileTypes: true }); for (const file of files) { const full = node_path_1.default.join(dir, file.name); if (this.isExcluded(full)) continue; // push directories for later if (file.isDirectory()) subs.push(file); // add missing children else if (file.isFile() && this.isMarkdown(file.name)) { const entry = this.entry(dir, file.name, true); if (entry) this.addChild(parent, entry); } } // now do the sub directories for (const sub of subs) { const md = this.entry(dir, sub.name); if (!md) continue; const child = this.addChild(parent, md); if (child) { const full = node_path_1.default.join(dir, sub.name); // if no children were added, delete the entry if (!this.addChildren(full, child)) delete parent.children[md.entry]; } } // return true if the parent has children return parent.children && Object.keys(parent.children).length > 0; } /** * Adds a child to the given parent * @param parent the parent to add the child to * @param entry the entry details for the child * @param config the config if it has one * @returns the page that was added or already there and the entry name */ addChild(parent, md, config) { // init the children if (!parent.children) parent.children = {}; const parts = node_path_1.default.parse(md.path); const parentDir = node_path_1.default.relative(this.inputDir, node_path_1.default.dirname(md.path)); // only add if it is not there if (!(md.entry in parent.children)) { const title = this.options.inheritTitle ? this.sitemap.title : this.title(md.path); const page = config ? { title: title, toc: parent.toc, ...config, } : { name: parts.name, title: title, toc: parent.toc, }; if (md.md) { const html = this.isInputSolo ? this.options.outputName : parts.name; page.input = md.md; page.href = node_path_1.default.join(parentDir, html + '.html').replace(/\\/g, '/'); } parent.children[md.entry] = page; } // return the child return parent.children[md.entry]; } /** * Returns true if the given file matches the provided extensions * @param file the file to check * @returns true if the given file matches the provided extensions */ isMarkdown(file) { const dot = file.lastIndexOf('.'); // check the extension return dot > 0 && dot + 1 < file.length && this.options.extensions.includes(file.slice(dot + 1)); } /** * Looks for a file matching the given name in the given directory * @param dir the parent directory to look in * @param name the name of the file to look for * @returns details about the file if found */ entry(dir, name, precheck) { const test = node_path_1.default.join(dir, name); const entry = node_path_1.default.relative(this.inputDir, test).replace(/\\/g, '/'); // just populate if already checked if (precheck) return { entry: entry, path: test, md: test }; // don't even bother if excluded if (this.isExcluded(test)) return undefined; const isDirectory = fs_extra_1.default.existsSync(test) && fs_extra_1.default.lstatSync(test).isDirectory(); // check all extensions first for (const ext of this.options.extensions) { const file = `${test}.${ext}`; if (fs_extra_1.default.existsSync(file)) { const entry = node_path_1.default.relative(this.inputDir, file).replace(/\\/g, '/'); return { entry: entry, path: isDirectory ? test : file, md: file }; } } // check for a directory if the file is not there return isDirectory ? { entry: entry, path: test } : undefined; } /** * Creates the html for the children of the given page * @param parent the parent page * @returns the html or file path for the first child */ writeChildren(parent) { let html = undefined; // only need to write out children here if (parent.children) { for (const child in parent.children) { // write out the first child if (parent.children[child].href) { const temp = this.writeHtml(parent.children[child]); // return the first page's html or file path if (!html) html = temp; } // write the children out if (parent.children[child].children) this.writeChildren(parent.children[child]); } } return html; } /** * Creates the html file for the given page * @param page the page for the markdown file * @returns the file path or the html as a string, undefined * if nothing was created */ writeHtml(page) { // only pages with hrefs have an html page if (!page.href) return undefined; let depth = ''; let dir = this.output; const subs = page.href.split(/\//g); for (let i = 0; i < subs.length - 1; i++) { dir = node_path_1.default.join(dir, subs[i]); fs_extra_1.default.ensureDirSync(dir); depth = '../' + depth; } if (!page.input) return undefined; const file = node_path_1.default.join(this.output, page.href); const md = this.isInputString ? file : page.input; const text = this.isInputString ? this.input : fs_extra_1.default.readFileSync(md, { encoding: 'utf8' }); this.group(colors_1.default.green('Generating:'), file); // create marked and extensions const marked = new marked_1.Marked((0, marked_alert_1.default)(), (0, marked_directive_1.createDirectives)([ ...marked_directive_1.presetDirectiveConfigs, tabdirectives_1.tabsDirective, ]), (0, marked_gfm_heading_id_1.gfmHeadingId)(), (0, marked_highlight_1.markedHighlight)({ langPrefix: 'hljs language-', highlight(code, lang) { const language = highlight_js_1.default.getLanguage(lang) ? lang : 'plaintext'; return highlight_js_1.default.highlight(code, { language }).value; } }), (0, markedcopysavecode_1.default)(), (0, markedcommands_1.default)({ file: md, generator: this, }), (0, markedlinks_1.default)(), (0, markeddocument_1.default)({ title: page.title, style: this.style, script: this.script, css: this.assets.css.map((asset) => depth + asset.output), js: this.assets.js.map((asset) => depth + asset.output), link: this.assets.favicon.output ? { href: depth + this.assets.favicon.output, rel: 'icon', sizes: 'any', type: 'image/x-icon', } : undefined, })); const html = marked.parse(this.preprocessor.process(text, md)); fs_extra_1.default.writeFileSync(file, html); this.generated.push(file); this.groupEnd(); // return the file path or html return this.options.outputFormat === 'file' ? file : html; } /** * @returns the title for the given file or directory */ title(file) { return node_path_1.default.parse(file).name.replace(/[-_.]/g, ' '); } } exports.default = HtmlGenerator;