UNPKG

hikarujs

Version:

A static site generator that generates routes based on directories naturally.

1,441 lines (1,370 loc) 42.4 kB
/** * @module utils */ import * as path from "node:path"; import fse from "fs-extra"; import YAML from "yaml"; import * as parse5 from "parse5"; import {readdirpPromise} from "readdirp"; import picomatch from "picomatch"; import nunjucks from "nunjucks"; import {Site, File, Category, Tag, TOC} from "./types.js"; /** * @description Hikaru's package dir. */ const hikaruDir = path.resolve( path.dirname(new URL(import.meta.url).pathname), "../" ); /** * @param {String} path * @return {Promise<Object>} */ const loadJSON = async (path) => { return JSON.parse(await fse.readFile(path, "utf8")); }; /** * @param {String} path * @return {Object} */ const loadJSONSync = (path) => { return JSON.parse(fse.readFileSync(path, "utf8")); }; /** * @private * @description This is Hikaru's package.json, it is used internally. */ const pkgJSON = loadJSONSync(path.join(hikaruDir, "package.json")); /** * @param {String} path * @return {Promise<Object>} */ const loadYAML = async (path) => { return YAML.parse(await fse.readFile(path, "utf8")); }; /** * @param {String} path * @return {Object} */ const loadYAMLSync = (path) => { return YAML.parse(fse.readFileSync(path, "utf8")); }; /** * @param {*} o * @return {Boolean} */ const isNumber = (o) => { return typeof o === "number" || o instanceof Number; }; /** * @param {*} o * @return {Boolean} */ const isString = (o) => { return typeof o === "string" || o instanceof String; }; /** * @param {*} o * @return {Boolean} */ const isArray = (o) => { return Array.isArray(o); }; /** * @param {*} o * @return {Boolean} */ const isFunction = (o) => { return o instanceof Function; }; /** * @param {*} o * @return {Boolean} Return `false` when `o == null`. */ const isObject = (o) => { return typeof o === "object" && o != null; }; /** * @param {*} o * @return {Boolean} */ const isBuffer = (o) => { return Buffer.isBuffer(o); }; /** * @private */ const typeCheckers = { "Number": isNumber, "String": isString, "Array": isArray, "Function": isFunction, "Buffer": isBuffer, "Object": isObject, "null": (o) => { return o == null; } }; /** * @description Check whether a variable is one of give types. * @param {*} var * @param {String} name Variable name. * @param {...String} types */ const checkType = (variable, name, ...types) => { // Previously we accept String or Array of String, keep compatible. types = types.flat(); for (const type of types) { if (typeCheckers[type] == null) { throw new TypeError(`\`types\` should only contain following types: ${ Object.keys(typeCheckers).join(", ") }.`); } } const ok = types.some((type) => { return typeCheckers[type](variable); }); if (!ok) { throw new TypeError(`\`${name}\` should only be one of following types: ${ types.join(", ") }.`); } }; /** * @description Check whether a buffer is a binary. You should use * `isBinaryPath(p)` because this is costly and mostly a false positive is * harmless so we don't need an exact check. For non-costly exact check, add * `isbinaryfile` dependency for your project. * @param {Buffer} b * @return {Boolean} */ const isBinary = (b) => { return isBuffer(b) && !b.equals(Buffer.from(b.toString("utf8"), "utf8")); }; /** * @private */ const binExt = loadJSONSync(path.join(hikaruDir, "hikaru", "bin-ext.json")); /** * @description Check whether a path is a binary via ext name. For non-costly * exact check, add `isbinaryfile` dependency for your project. * @param {String} p * @return {Boolean} */ const isBinaryPath = (p) => { return binExt.includes(path.extname(p).toLowerCase()); }; /** * @deprecated * @description You should use `isBinaryPath(p)` mostly. This is only for * compatibility because `isbinaryfile` dependency is dropped. * @param {String|Buffer} o * @return {Boolean} */ const isBinaryFile = (o) => { return isBuffer(o) ? isBinary(o) : isBinaryPath(o); }; /** * @deprecated * @description You should use `isBinaryPath(p)` mostly. This is only for * compatibility because `isbinaryfile` dependency is dropped. * @param {String|Buffer} o * @return {Boolean} */ const isBinaryFileSync = isBinaryFile; /** * @description Node.js marks `fs.exists()` deprecated and suggest to use * `fs.access()`, but it throws error instead of return a boolean, this is a * wrapper for it. * @see https://nodejs.org/api/fs.html#fsaccesssyncpath-mode * @param {String} p * @return {Boolean} */ const isReadableSync = (p) => { try { fse.accessSync(p, fse.constants.R_OK); return true; } catch (error) { return false; } }; /** * @description Escape HTML chars. * @param {String} str * @return {String} Escaped HTML string. */ const escapeHTML = (str) => { return str.replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); }; /** * @description Remove all HTML tags. * @param {String} str * @return {String} String without HTML tags. */ const removeHTMLTags = (str) => { return str.replace(/<\/?[^>]+>/gi, ""); }; /** * @callback compareCallback * @description Comparing function accepted by `Array.sort()`. * @param {*} a * @param {*} b * @return {Number} */ /** * @description Sort an array with a fallback list, call comparing function * from first to last until there is a difference. * @param {Array} arr * @param {...compareCallback} fns */ const fallbackSort = (arr, ...fns) => { fns = fns.filter((fn) => { return isFunction(fn); }); if (!isArray(arr) || fns.length === 0) { return; } arr.sort((a, b) => { for (const fn of fns) { const result = fn(a, b); if (result !== 0) { return result; } } return 0; }); }; /** * @description A Promised glob. * @param {String} pattern * @param {Objects} [opts] Optional match opts. * @param {String} [opts.workDir=.] Working dir for this match. * @param {Boolean} [opts.ignoreDir=true] Ignore directories or not. * @param {Boolean} [opts.ignoreHidden=true] Ignore hidden files or not. * @param {Boolean} [opts.recursive=true] Set to false if you don't care about * subdirs to get better performance. * @return {Promise<String[]>} */ const matchFiles = (pattern, opts = {}) => { if (opts["ignoreDir"] == null) { opts["ignoreDir"] = true; } if (opts["ignoreHidden"] == null) { opts["ignoreHidden"] = true; } if (opts["recursive"] == null) { opts["recursive"] = true; } opts["workDir"] = opts["workDir"] || "."; const isMatch = picomatch(pattern, {"dot": !opts["ignoreHidden"]}); const matchWithHidden = (entry) => { return isMatch(entry["path"]); }; // We don't use picomatch for hidden files, name is more reliable. const matchWithoutHidden = (entry) => { return !entry["path"].split(path.sep).some((name) => { return name.startsWith("."); }) && matchWithHidden(entry); }; const readdirpOpts = { "fileFilter": opts["ignoreHidden"] ? matchWithoutHidden : matchWithHidden, "type": opts["ignoreDir"] ? "files" : "files_directories" }; if (!opts["recursive"]) { readdirpOpts["depth"] = 1; } return readdirpPromise(opts["workDir"], readdirpOpts).then((entries) => { return entries.map((entry) => { return entry["path"]; }); }); }; /** * @description Remove XML control chars. * @param {String} str * @return {String} XML string. */ const removeControlChars = (str) => { /* eslint-disable-next-line no-control-regex */ return str.replace(/[\x00-\x1F\x7F]/g, ""); }; /** * @typedef {Object} FrontMatter * @property {Object} attributes Parsed front-matter properties. * @property {String} body String after front-matter. * @property {String} frontMatter Front-matter string. */ /** * @description Get front-matter from string, front-matter here should be at the * beginning of string (so UTF-8 BOM is not supported), and should begin with * `---` in the first line, and should have valid YAML in lines after that, and * should end with `---` in the next line. * @param {String} str * @return {FrontMatter} */ const getFrontMatter = (str) => { // Return if no front matter. Only check once so `g` is not required. if (!/^---+\r?\n/.test(str)) { return {"attributes": {}, "body": str}; } // We split string manually instead of using `str.split(regexp, 3)`, this // function will split the whole string first and then return the first 3 // results, but we want to only split twice. // // Flag `m` can be used for per line test, and `exec()`, `matchAll()` requires // flag `g`. // // We need to use /\r?\n/ here instead of /$/, so `\r` and `\n` will be part // of the match results so we could exclude them from front-matter and body. const regexp = /^---+\r?\n/gm; // RegExp is stateful so `exec()` will start after last matched result. const fmBegin = regexp.exec(str); const fmEnd = regexp.exec(str); // `null` is returned if not match, which means no front-matter at all. if (fmBegin == null || fmEnd == null) { return {"attributes": {}, "body": str}; } // Matched result looks like // `{"---\n", "index": 0, "input": "---\n", "groups": undefined}`. // It is a mix of Array and Object. We use it to split string manually. const result = { "body": str.substring(fmEnd.index + fmEnd[0].length), "frontMatter": str.substring(fmBegin.index + fmBegin[0].length, fmEnd.index) }; try { result["attributes"] = YAML.parse(result["frontMatter"]); } catch (error) { result["attributes"] = {}; } return result; }; /** * @description Parse front-matter and set properties to file. * @param {File} file * @return {File} */ const parseFrontMatter = (file) => { if (file["text"] == null) { return file; } const parsed = getFrontMatter(file["text"]); file["text"] = parsed["body"]; file["frontMatter"] = parsed["attributes"]; file = Object.assign(file, parsed["attributes"]); // See <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse>. // // ISO 8601 date time format is expected, and by default a string with date // and time will be parsed as local time. file["updated"] = file["updated"] || file["updatedDate"]; if (file["updated"] != null) { file["updated"] = new Date(file["updated"]); } file["updatedDate"] = file["updated"]; file["created"] = file["created"] || file["createdDate"]; if (file["created"] != null) { file["created"] = new Date(file["created"]); } file["createdDate"] = file["created"]; return file; }; /** * @private * @description This is only used for serving, so we lazy load it to save time * on building. */ let extMIME = null; /** * @description Detect Content-Type via filename. * @param {String} docPath * @return {String} Content-Type value. */ const getContentType = (docPath) => { if (extMIME == null) { extMIME = loadJSONSync(path.join(hikaruDir, "hikaru", "ext-mime.json")); } const ext = path.extname(docPath).toLowerCase(); return extMIME[ext] || "application/octet-stream"; }; /** * @description Paginate page's posts. * @param {File} p Original page. * @param {File[]} posts Page related posts. * @param {Number} [perPage=10] How many posts per page. * @return {File[]} Paginated pages, original page's index is 0. */ const paginate = (p, posts = [], perPage = 10) => { const results = []; let perPagePosts = []; for (const post of posts) { if (perPagePosts.length === perPage) { results.push(new File(p, {"posts": perPagePosts})); perPagePosts = []; } perPagePosts.push(post); } results.push(new File(p, {"posts": perPagePosts})); results[0]["pages"] = results; results[0]["index"] = 0; // Keep compatible and don't break themes. results[0]["pageIndex"] = results[0]["index"]; results[0]["pageArray"] = results[0]["pages"]; results[0]["docPath"] = p["docPath"]; for (let i = 1; i < results.length; ++i) { results[i]["pages"] = results; results[i]["index"] = i; // Keep compatible and don't break themes. results[i]["pageArray"] = results[i]["pages"]; results[i]["pageIndex"] = results[i]["index"]; results[i]["docPath"] = path.join( path.dirname(p["docPath"]), `${path.basename( p["docPath"], path.extname(p["docPath"]) )}-${i + 1}.html` ); } return results; }; /** * @description Generate pages and paginate posts of categories. * @param {Category[]} categories * @param {String} parentPath Parent category's dir. * @param {docDir} docDir * @param {Number} [perPage=10] How many posts per page. * @return {File[]} All category and it's subs pages. */ const paginateCategoriesPosts = ( categories, parentPath, docDir, perPage = 10 ) => { const results = []; for (const category of categories) { category["docPath"] = path.join(parentPath, category["name"], "index.html"); const sp = new File({ "layout": "category", "docDir": docDir, "docPath": category["docPath"], "title": "category", "category": category, "name": category["name"], "comment": false, "reward": false }); results.push(...paginate(sp, category["posts"], perPage)); results.push(...paginateCategoriesPosts(category["subs"], path.join( parentPath, category["name"] ), docDir, perPage)); } return results; }; /** * @callback getPath * @description Get full website path starts from `/` after domain. This * function can only handle site internal path. * @param {String} [docPath] * @param {Boolean} [skipEncode=false] If true, skip `encodeURI()`. * @return {String} Full path that starts with site rootDir. */ /** * @description Get a function to handle full website path. * @param {String} [rootDir] Site rootDir. * @return {getPath} */ const getPathFn = (rootDir = path.posix.sep) => { // Anyway, we need to escape backslash literally using RegExp. const winSepRegExp = new RegExp(`\\${path.win32.sep}`, "g"); rootDir = rootDir.replace(winSepRegExp, path.posix.sep); if (!rootDir.endsWith(path.posix.sep)) { rootDir = path.posix.join(rootDir, path.posix.sep); } if (!path.posix.isAbsolute(rootDir)) { rootDir = path.posix.join(path.posix.sep, rootDir); } return (docPath = "", skipEncode = false) => { // Handle link with query string or hash. // Use assertion to prevent `?` and `#` to be removed. const array = docPath.split(/(?=[?#])/); array[0] = array[0].replace(winSepRegExp, path.posix.sep); const baseName = path.posix.basename(array[0]); const dirName = path.posix.dirname(array[0]); if (baseName === "index.html" || baseName === "index.htm") { array[0] = path.posix.join(dirName, path.posix.sep); } // marked.js and CommonMark tends to do URL encode by themselves. Maybe I // should not do `encodeURL()` here. // // See <https://github.com/markedjs/marked/issues/1285>. return skipEncode ? path.posix.join(rootDir, ...array) : encodeURI(path.posix.join(rootDir, ...array)); }; }; /** * @callback getURL * @description Get full website URL including the domain. This function can * only handle site internal path. * @param {String} [docPath] * @return {URL} Full website URL. */ /** * @description Get a function to handle full website URL. * @param {String} [baseURL] Site baseURL. * @param {String} [rootDir] Site rootDir. * @return {getURL} */ const getURLFn = (baseURL, rootDir = path.posix.sep) => { const getPath = getPathFn(rootDir); return (docPath = "") => { return new URL(getPath(docPath), baseURL); }; }; /** * @callback isCurrentHost * @description Test if given URL is on current host. This function does not * care about query string and hash. * @param {String} [testURL] URL needed to test. * @return {Boolean} */ /** * @description Get a function to handle if parameter is on current host. * @param {String} baseURL Site baseURL. * @param {String} [rootDir] Site rootDir. * @return {isCurrentHost} */ const isCurrentHostFn = (baseURL, rootDir = path.posix.sep) => { const getURL = getURLFn(baseURL, rootDir); const currentHost = getURL().host; return (testURL) => { // If `testURL` is a valid URL, `baseURL` will be ignored, so we can compare // host for all links here. const url = new URL(testURL, baseURL); // It returns `""` for data URL! return url.host === currentHost || url.host === ""; }; }; /** * @callback isCurrentPath * @description Test if given path is current path. This function does not care * about query string and hash. This function can only handle site internal * path. * @param {String} [testPath] Path needed to test. * @param {Boolean} [strict=false] If not strict, true is also returned if given * path is parent path of current path. * @return {Boolean} */ /** * @description Get a function to handle if parameter is current path. * @param {String} [rootDir] Site rootDir. * @param {String} [currentPath] current page's path. * @return {isCurrentPath} */ const isCurrentPathFn = (rootDir = path.posix.sep, currentPath = "") => { const getPath = getPathFn(rootDir); currentPath = getPath(currentPath).split(/[?#]/)[0]; const currentToken = currentPath.split(path.posix.sep); // `"/a/b/"` will be `["", "a", "b", ""]`, and `"/a/b/c"` will be // `["", "a", "b", "c"]`, so we always ignore the last empty string. if (currentToken[currentToken.length - 1].length === 0) { currentToken.pop(); } return (testPath = "", strict = false) => { if (!isString(testPath)) { strict = testPath; testPath = ""; } testPath = getPath(testPath).split(/[?#]/)[0]; if (currentPath === testPath) { return true; } const testToken = testPath.split(path.posix.sep); if (testToken[testToken.length - 1].length === 0) { testToken.pop(); } if (strict && testToken.length !== currentToken.length) { return false; } // `testPath` is shorter and usually be a menu link. for (let i = 0; i < testToken.length; ++i) { if (testToken[i] !== currentToken[i]) { return false; } } return true; }; }; /** * @description Compare strings. * @param {String} a * @param {String} b * @return {Number} */ const localeCompareSimple = (a, b) => { if (!(isString(a) && isString(b))) { return 0; } return a.localeCompare(b); }; /** * @callback localeCompare * @description Compare strings, with locale support if `Intl` is available. * @param {String} a * @param {String} b * @return {Number} */ /** * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator * @description Get a function to compare strings, with locale support if `Intl` * is available. * @param {String} locales * @return {localCompare} */ const localeCompareFn = (locales) => { if (!isObject(Intl)) { return localeCompareSimple; } const collator = new Intl.Collator(locales, { "usage": "sort", "localeMatcher": "best fit", "numeric": true, // `R` < `r` but `re` < `RI`, why? "caseFirst": "upper", "sensitivity": "variant", "ignorePunctuation": false }); return collator.compare.bind(collator); }; /** * @description Format date and time to `YYYY-MM-DD HH:mm:ss`. * @param {*} [dt=new Date()] * @return {String} */ const formatDateTimeSimple = (dt = new Date()) => { if (!(dt instanceof Date)) { dt = new Date(dt); } const year = dt.getFullYear().toString(); const month = (dt.getMonth() + 1).toString().padStart(2, "0"); const date = dt.getDate().toString().padStart(2, "0"); const hour = dt.getHours().toString().padStart(2, "0"); const minute = dt.getMinutes().toString().padStart(2, "0"); const second = dt.getSeconds().toString().padStart(2, "0"); return `${year}-${month}-${date} ${hour}:${minute}:${second}`; }; /** * @callback formatDateTime * @description Format date and time, with locale support if `Intl` is * available. * @param {*} [dt=new Date()] * @return {String} */ /** * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts * @description Get a function to format date and time, with locale support if * `Intl` is available. * @param {String} locales * @return {formatDateTime} */ const formatDateTimeFn = (locales) => { if (!isObject(Intl)) { return formatDateTimeSimple; } const formatter = new Intl.DateTimeFormat(locales, { "year": "numeric", "month": "2-digit", "day": "2-digit", "weekday": "short", "hour": "2-digit", "minute": "2-digit", "second": "2-digit", "timeZoneName": "short", "hour12": false }); return (dt = new Date()) => { if (!(dt instanceof Date)) { dt = new Date(dt); } const parts = formatter.formatToParts(dt); const obj = {}; for (const {type, value} of parts) { obj[type] = value; } return `${obj["year"]}-${obj["month"]}-${obj["day"]} ${obj["weekday"]} ${obj["hour"]}:${obj["minute"]}:${obj["second"]} ${obj["timeZoneName"]}`; }; }; /** * @typedef {Object} CategoriesData * @property {Category[]} categories * @property {Number} categoriesLength */ /** * @description Generate categories from posts. * @param {File[]} posts * @return {CategoriesData} */ const genCategories = (posts) => { const categories = []; let categoriesLength = 0; for (const post of posts) { if (post["frontMatter"]["categories"] == null) { continue; } const postCategories = []; let subCategories = categories; for (const c of post["frontMatter"]["categories"]) { // Maybe Numbers? I don't know. const cateName = c.toString(); let category = subCategories.find((category) => { return category["name"] === cateName; }); if (category == null) { category = new Category(cateName); ++categoriesLength; subCategories.push(category); } postCategories.push(category); category["posts"].push(post); subCategories = category["subs"]; } post["categories"] = postCategories; } return {categories, categoriesLength}; }; /** * @typedef {Object} TagsData * @property {Tag[]} tags * @property {Number} tagsLength */ /** * @description Generate tags from posts. * @param {File[]} posts * @return {TagsData} */ const genTags = (posts) => { const tags = []; let tagsLength = 0; for (const post of posts) { if (post["frontMatter"]["tags"] == null) { continue; } const postTags = []; for (const t of post["frontMatter"]["tags"]) { // Maybe Numbers? I don't know. const tagName = t.toString(); let tag = tags.find((tag) => { return tag["name"] === tagName; }); if (tag == null) { tag = new Tag(tagName); ++tagsLength; tags.push(tag); } postTags.push(tag); tag["posts"].push(post); } post["tags"] = postTags; } return {tags, tagsLength}; }; /** * @description Put file into an array in site, will replace file with the same * destination. * @param {Site} site * @param {String} key * @param {File} file */ const putSite = (site, key, file) => { if (file == null || !Site.arrayKeys.includes(key)) { return; } const i = site[key].findIndex((element) => { return ( element["docPath"] === file["docPath"] && element["docDir"] === file["docDir"] ); }); if (i !== -1) { site[key][i] = file; } else { site[key].push(file); } }; /** * @description Delete file from an array in site which have the same source. * @param {Site} site * @param {String} key * @param {File} file */ const delSite = (site, key, file) => { if (file == null || !Site.arrayKeys.includes(key)) { return; } for (let i = 0; i < site[key].length; ++i) { if ( site[key][i]["srcPath"] === file["srcPath"] && site[key][i]["srcDir"] === file["srcDir"] ) { // Don't use break here because we may have many files created with the // same source. site[key].splice(i, 1); // Don't forget this because we removed one element from array! --i; } } }; /** * @description Get File's full src path. * @param {File} file * @return {String} */ const getFullSrcPath = (file) => { if (file == null || file["srcDir"] == null || file["srcPath"] == null) { return null; } return path.join(file["srcDir"], file["srcPath"]); }; /** * @description Get File's full document path. * @param {File} file * @return {String} */ const getFullDocPath = (file) => { if (file == null || file["docDir"] == null || file["docPath"] == null) { return null; } return path.join(file["docDir"], file["docPath"]); }; /** * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/index.md#parsefragment * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/tree-adapter/default/document-fragment.md * @description Parse HTML string into parse5 Node. * @param {Object} [node] If specified, given fragment will be parsed as it was * set to the context element's `innerHTML` property. * @param {String} html HTML string to parse. * @param {Object} [options] parse5 options. * @return {Object} */ const parseNode = (node, html, options) => { return parse5.parseFragment(node, html, options); }; /** * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/index.md#serialize * @description Serialize parse5 Node into HTML string. * @param {Object} node parse5 Node to serialize. * @param {Object} [options] parse5 options. * @return {String} */ const serializeNode = (node, options) => { return parse5.serialize(node, options); }; /** * @description Quick and not so dirty way to replace a Node with given HTML * string. * @param {Object} node parse5 Node to replace. * @param {String} html */ const replaceNode = (node, html) => { const parentNode = node["parentNode"]; if (parentNode != null && html != null) { const newNode = parseNode(html); if (newNode["childNodes"] != null && newNode["childNodes"].length > 0) { const index = parentNode["childNodes"].indexOf(node); parentNode["childNodes"].splice( index, 1, ...newNode["childNodes"].map((childNode) => { childNode["parentNode"] = parentNode; return childNode; }) ); } } }; /** * @callback traversalCallback * @param {Object} node parse5 Node. */ /** * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/tree-adapter/default/element.md * @description Recursively Pre-Order Traversal of parse5 Node. * @param {Object} node Root parse5 Node of a tree. * @param {traversalCallback} callback */ const nodesEach = (node, callback) => { if (isFunction(callback)) { callback(node); if (node["childNodes"] != null) { for (const childNode of node["childNodes"]) { nodesEach(childNode, callback); } } } }; /** * @callback filterCallback * @param {Object} node parse5 Node. * @return {Boolean} True to collect a node into an Array. */ /** * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/tree-adapter/default/element.md * @description Recursively Pre-Order Traversal of parse5 Node. * @param {Object} node Root parse5 Node of a tree. * @param {filterCallback} callback * @return {Object[]} An Array of filtered parse5 Nodes. */ const nodesFilter = (node, callback) => { const results = []; if (isFunction(callback)) { nodesEach(node, (node) => { if (callback(node)) { results.push(node); } }); } return results; }; /** * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/tree-adapter/default/element.md * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/tree-adapter/default/text-node.md * @description Get text content of a parse5 Node. * @param {Object} node parse5 Node. * @return {String} */ const getNodeText = (node) => { if (node["childNodes"] != null) { for (const childNode of node["childNodes"]) { if (childNode["nodeName"] === "#text") { return childNode["value"]; } } } return null; }; /** * @description Set text content (or innerHTML) of a parse5 Node. * @param {Object} node parse5 Node. * @param {String} html */ const setNodeText = (node, html) => { // Add HTML to childNodes via parsing and replacing to keep tree reference, // and skip the parse5-generated `#document-fragment` node. // Text nodes have no childNode. // Only append to nodes that already have childNodes. if (node["childNodes"] != null) { // Don't forget to replace childNode's parentNode. node["childNodes"] = parseNode(html)["childNodes"].map((childNode) => { childNode["parentNode"] = node; return childNode; }); } }; /** * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/tree-adapter/default/element.md * @see https://github.com/inikulin/parse5/blob/master/packages/parse5/docs/tree-adapter/default/attribute.md * @description Get an attribute value from parse5 Node. * @param {Object} node parse5 Node. * @param {String} attrName * @return {String} Value of the attribute, `null` if not available. */ const getNodeAttr = (node, attrName) => { if (node["attrs"] != null) { for (const attr of node["attrs"]) { if (attr["name"] === attrName) { return attr["value"]; } } } return null; }; /** * @description Set an attribute value to parse5 Node. * @param {Object} node parse5 Node. * @param {String} attrName * @param {String} attrValue */ const setNodeAttr = (node, attrName, attrValue) => { // Do not add attr to nodes without attrs array, // for example text node. if (node["attrs"] != null) { for (const attr of node["attrs"]) { // Already have this attr, then replace. if (attr["name"] === attrName) { attr["value"] = attrValue; return; } } // Have other attrs but not this, so append. node["attrs"].push({"name": attrName, "value": attrValue}); } }; /** * @description Update headings' IDs for bootstrap scrollspy. * @param {Object} node parse5 Node. * @param {Object} [opts] * @param {String} [opts.safeChar="-"] Char used to replace unsupported chars. */ const resolveHeadingIDs = (node, opts = {}) => { const headingNames = ["h1", "h2", "h3", "h4", "h5", "h6"]; const headingIDs = {}; const headingNodes = nodesFilter(node, (node) => { return headingNames.includes(node["tagName"]); }); for (const node of headingNodes) { const text = getNodeText(node); if (text != null) { // Replace some chars in escaped ID because scrollspy cannot support it. const encoded = encodeURI(text.trim().replace( /[\s()[\]{}<>.,!@#$%^&*=|`'/?~]/g, opts["safeChar"] || "-" )); const id = headingIDs[encoded] == null ? encoded : `${encoded}-${headingIDs[encoded]++}`; // If we have `abc`, `abc` and `abc-1`, we must save the `abc-1` generated // by the second `abc`, to prevent 2 `abc-1` for the last `abc-1`. headingIDs[id] = 1; setNodeAttr(node, "id", id); setNodeText( node, `<a class="heading-link header-link" href="#${id}"></a>${text}` ); } } }; /** * @deprecated * @description Use `resolveHeadingIDs(node)` instead. */ const resolveHeaderIDs = resolveHeadingIDs; /** * @description Generate TOC from HTML headings. * @param {Object} node parse5 Node. * @param {Object} [opts] */ const genTOC = (node, opts = {}) => { const headingNames = ["h1", "h2", "h3", "h4", "h5", "h6"]; const toc = []; const headingNodes = nodesFilter(node, (node) => { return headingNames.includes(node["tagName"]); }); for (const node of headingNodes) { let level = toc; while ( level.length > 0 && headingNames.indexOf( level[level.length - 1]["name"] ) < headingNames.indexOf(node["tagName"]) ) { level = level[level.length - 1]["subs"]; } const id = getNodeAttr(node, "id"); const text = getNodeText(node); if (id != null && text != null) { // Don't set anchor to absolute path, // because it's hard to write selector for scrollspy. level.push(new TOC(node["tagName"], `#${id}`, text.trim())); } } return toc; }; /** * @description Get protocol of a URL. * @param {String} url * @return {String} If no protocol return `null`. */ const getURLProtocol = (url) => { try { // If no protocol in url, it will throw an error. return new URL(url).protocol; } catch (error) { return null; } }; /** * @description Update site's internal link to absolute path, and add attributes * for external link. * @param {Object} node parse5 Node. * @param {String} baseURL Site baseURL. * @param {String} rootDir Site rootDir. * @param {String} docPath * @param {Object} [opts] */ const resolveAnchors = (node, baseURL, rootDir, docPath, opts = {}) => { const isCurrentHost = isCurrentHostFn(baseURL, rootDir); const getPath = getPathFn(rootDir); // Replace relative path to absolute path. const anchorNodes = nodesFilter(node, (node) => { return node["tagName"] === "a"; }); for (const node of anchorNodes) { const href = getNodeAttr(node, "href"); if (href != null) { if (!isCurrentHost(href)) { setNodeAttr(node, "target", "_blank"); setNodeAttr(node, "rel", "external nofollow noreferrer noopener"); } // `path.posix.isAbsolute()` detects `/` or `//`. if (!(path.posix.isAbsolute(href) || getURLProtocol(href) != null)) { /** * marked.js and CommonMark tends to do URL encode by themselves. * I should skip `encodeURI()` here and do it for heading ID only. * See <https://github.com/markedjs/marked/issues/1285>. */ setNodeAttr( node, "href", getPath(path.join(path.dirname(docPath), href), true) ); } } } }; /** * @description Update site's internal image src to absolute path. * @param {Object} node parse5 Node. * @param {String} rootDir Site rootDir. * @param {String} docPath * @param {Object} [opts] */ const resolveImages = (node, rootDir, docPath, opts = {}) => { const getPath = getPathFn(rootDir); // Replace relative path to absolute path. const imageNodes = nodesFilter(node, (node) => { return node["tagName"] === "img"; }); for (const node of imageNodes) { const src = getNodeAttr(node, "src"); if (src != null) { // `path.posix.isAbsolute()` detects `/` or `//`. if (!(path.posix.isAbsolute(src) || getURLProtocol(src) != null)) { setNodeAttr( node, "src", getPath(path.join(path.dirname(docPath), src)) ); } } } }; /** * @description Update code blocks. * @param {Object} node parse5 Node. * @param {Object} [opts] * @param {Boolean} [opts.lineNumbers=false] Generate line numbers. */ const resolveCodeBlocks = (node, opts = {}) => { const codeBlockNodes = nodesFilter(node, (node) => { return node["tagName"] === "pre" && // Prevent re-resolving code blocks when re-process. node["parentNode"]["tagName"] !== "figure" && node["childNodes"].length === 1 && node["childNodes"][0]["tagName"] === "code"; }); for (const node of codeBlockNodes) { const code = getNodeText(node["childNodes"][0]); if (code != null) { const info = getNodeAttr(node["childNodes"][0], "class"); // Many Markdown renderer add `language-` prefix to code block's info. // Better to remove it while processing to keep consistent. let lang = info; const langPrefix = "language-"; if (info != null && info.startsWith(langPrefix)) { lang = info.substring(langPrefix.length); } const escapedCode = escapeHTML(code); const results = [`<figure data-raw="${escapedCode}"`]; if (info != null) { results.push(` data-info="${info}" data-lang="${lang}"`); } results.push(" class=\"code-block\">"); if (opts["lineNumbers"]) { results.push("<pre class=\"line-numbers gutter\">"); // Highlight should not change lines, but may replace `\n` with `<br>`, // so use original code here. const codeLines = escapedCode.split(/\r?\n/g); // It seems marked.js starts to keep the last `\n`, which will leave an // empty line after splitting, and we should not add line number for the // last empty line. Don't do trim here, we only ignore! if (codeLines[codeLines.length - 1].length === 0) { codeLines.pop(); } for (let i = 0; i < codeLines.length; ++i) { results.push(`<span class="line-number">${i + 1}</span>`); if (i !== codeLines.length - 1) { results.push("\n"); } } results.push("</pre>"); } results.push("<pre class=\"code\">"); if (info != null) { results.push(`<code class="${info}">`); } else { results.push("<code>"); } results.push(escapedCode); results.push("</code></pre>"); results.push("</figure>"); replaceNode(node, results.join("")); } } }; /** * @description Get Hikaru version. * @return {String} */ const getVersion = () => { return pkgJSON["version"]; }; /** * @description Hikaru's default 404 page content for server. * @type {String} */ const default404 = [ "<!DOCTYPE html>", "<html>", " <head>", " <meta charset=\"utf-8\">", " <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">", " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1\">", " <title>404 Not Found</title>", " </head>", " <body>", " <h1>404 Not Found</h1>", ` <p>Hikaru v${getVersion()}</p>`, " </body>", "</html>", "" ].join("\n"); // Nunjucks uses runtime including (we treat extending as another kind if // including), which means it will try to call included template everytime it // renders instead of call included template once during compiling. So if a // included template is updated, all templates that include it will be updated. // That means we don't need to handle file dependencies of nunjucks templates. // // We already load and compile toplevel templates by ourselves, but for included // templates, they are loaded and compiled by nunjucks loader. By default, the // FileSystemLoader of nunjucks could watch and reload files, so we only need to // watch the toplevel layout files (because we load and compile them, not // nunjucks). However, FileSystemLoader refuses to load files out of search // paths, which is needed for plugins. And users may use other templating engine // that does not support watching and reloading, so it is useful to control // watching and reloading by ourselves, then we could also bypass nunjucks to // load file contents from Hikaru via custom loader. /** * @private */ class NjkLoader extends nunjucks.Loader { constructor(hikaru) { super(); this.watcher = hikaru.watcher; this.layouts = hikaru.site["layouts"]; this.layoutDir = hikaru.site["siteConfig"]["themeLayoutDir"]; if (this.watcher != null) { // This is mainly for non-toplevel templates that will be included by // other templates, because they are loaded by nunjucks, we need to tell // nunjucks to update them. this.watcher.register( this.layoutDir, (srcDir, srcPaths) => { const {added, changed, removed} = srcPaths; const all = added.concat(changed).concat(removed); for (const srcPath of all) { // Mark that template as dirty in the internal cache of nunjucks, // so it will re-fetch content from this loader when including. this.emit("update", srcPath); } } ); } } getSource(srcPath) { // If template is not in theme's layout dir, for example plugin's templates, // fallback to read from disk. let result = null; if (!this.layouts.has(srcPath)) { // Ignore non-existing files. if (!isReadableSync(srcPath)) { return null; } // Async including in nunjucks for loops is hard to handle, so always read // file sync. result = { "src": fse.readFileSync(srcPath, "utf8"), "path": srcPath, // We have no way in plugins to tell nunjucks to update included // templates which will be loaded and compiled by loader, so if we are // serving, we never cache those templates, this is not good for // performance, but plugins should not use too complex templates. "noCache": this.watcher !== null }; } else { result = { "src": this.layouts.get(srcPath), "path": srcPath, "noCache": false }; } this.emit("load", srcPath, result); return result; } } export { hikaruDir, loadJSON, loadJSONSync, loadYAML, loadYAMLSync, pkgJSON, isNumber, isString, isArray, isFunction, isObject, isBuffer, checkType, isBinary, isBinaryPath, isBinaryFile, isBinaryFileSync, isReadableSync, escapeHTML, removeHTMLTags, fallbackSort, matchFiles, removeControlChars, getFrontMatter, parseFrontMatter, getContentType, paginate, paginateCategoriesPosts, getPathFn, getURLFn, isCurrentHostFn, isCurrentPathFn, localeCompareSimple, localeCompareFn, formatDateTimeSimple, formatDateTimeFn, genCategories, genTags, putSite, delSite, getFullSrcPath, getFullDocPath, parseNode, serializeNode, replaceNode, nodesEach, nodesFilter, getNodeText, setNodeText, getNodeAttr, setNodeAttr, resolveHeadingIDs, resolveHeaderIDs, genTOC, getURLProtocol, resolveAnchors, resolveImages, resolveCodeBlocks, getVersion, default404, NjkLoader };