UNPKG

poststore

Version:

PostStore can be used with NEXT.JS to create markdown-based blogs.

1,514 lines (1,489 loc) 48 kB
import path, { extname } from 'path'; import { platform } from 'process'; import fs from 'fs'; import format from 'date-fns/format'; import matter from 'gray-matter'; import unified from 'unified'; import remark from 'remark-parse'; import remark2rehype from 'remark-rehype'; import rehypeSlug from 'rehype-slug'; import rehypeHtml from 'rehype-stringify'; import url from 'url'; import { selectAll } from 'unist-util-select'; import crypto from 'crypto'; import visit$1 from 'unist-util-visit'; import Prism from 'prismjs'; import rehypeParse from 'rehype-parse'; import chalk from 'chalk'; import loadLanguage from 'prismjs/components/'; import rehypeSanitize from 'rehype-sanitize'; import sharp from 'sharp'; import { watch } from 'chokidar'; const MODE_DEV = process.env['NODE_ENV'] === 'development' ? true : false; const MODE_TEST = process.env['NODE_ENV'] === 'test' ? true : false; const MODE_PRODUCTION = process.env['NODE_ENV'] === 'production' ? true : false; const PLATFROM_DARWIN = platform === 'darwin'; const HASH_ALGORITHM = 'sha1'; const NODE_TYPE_CATEGORY = 'category'; const NODE_TYPE_POST = 'post'; const DEFAULT_PARAM_VALUE = 'slug'; const DEFAULT_PERPAGE_VALUE = 10; const DEFAULT_BUILDINFO_PATH = './.poststore.buildInfo'; const DEFAULT_CONFIG_PATH = './poststore.config.js'; const DEFAULT_ASSET_DIRNAME = 'assets'; const DEFAULT_PUBLICDIR_PATH = './public'; const DEFAULT_PARAM_OPTION = { page: DEFAULT_PARAM_VALUE, category: DEFAULT_PARAM_VALUE, post: DEFAULT_PARAM_VALUE, tag: DEFAULT_PARAM_VALUE, }; const DEFAULT_PERPAGE_OPTION = { page: DEFAULT_PERPAGE_VALUE, category: DEFAULT_PERPAGE_VALUE, tag: DEFAULT_PERPAGE_VALUE, }; const CONFIG_EXAMPLE = `export default { storeOption: StoreOption | StoreOption[] } interface StoreOption { postDir: string; storeName?: string; perPage?: number | PerPageOption; pageParam?: string | PageParamOption; shouldUpdate?: boolean; watchMode?: boolean; incremental?: boolean; }`; const storeMap = new Map(); const watcherMap = new Map(); function* visit(rootNode, fn) { yield [fn(rootNode), rootNode]; if (rootNode.children) { for (const node of rootNode.children) { yield* visit(node, fn); } } } const findNode = (rootNode, fn) => { for (const [isTrue, node] of visit(rootNode, fn)) { if (isTrue) return node; } }; const findNodeAll = (rootNode, fn) => { const nodeList = []; for (const [isTrue, node] of visit(rootNode, fn)) { if (isTrue) nodeList.push(node); } return nodeList; }; const isCategoryNode = (node) => node.type === NODE_TYPE_CATEGORY; const isPostNode = (node) => node.type === NODE_TYPE_POST; const findCategory = (rootNode, categories) => { let now = rootNode; for (const category of categories) { const finded = findNode(now, (node) => isCategoryNode(node) && node.slug === category); if (!finded) break; now = finded; } return now; }; const getPostsAll = (rootNode) => findNodeAll(rootNode, isPostNode); const getCategoriesAll = (rootNode) => findNodeAll(rootNode, isCategoryNode); const getTagsAll = (rootNode) => { const posts = getPostsAll(rootNode); const tags = posts.reduce((tagList, now) => { now.postData.tags.forEach((tag) => tagList.add(tag)); return tagList; }, new Set()); return [...tags]; }; const getPostBy = (propName) => (rootNode, value) => { const finded = findNode(rootNode, (node) => isPostNode(node) && node[propName] === value); if (finded && isPostNode(finded)) return finded; }; const getPostBySlug = getPostBy('slug'); const getPostByPath = getPostBy('path'); const getPostsByCategories = (rootNode, categories) => { const categoryNode = findCategory(rootNode, categories); const posts = getPostsAll(categoryNode); return posts; }; const getPostsByTags = (rootNode, tags) => { return findNodeAll(rootNode, (node) => { if (!isPostNode(node)) return false; let isMatch = false; const postTags = node.postData.tags; isMatch = tags.some((tag) => postTags.includes(tag)); return isMatch; }); }; const getPostsByPage = (postList, page, perPage = 5) => { const start = (page - 1) * perPage; const end = start + perPage; return postList.slice(start, end); }; const getTotalPage = (total, perPage) => { return Math.ceil(total / perPage); }; const isPageSlug = (slug) => { const pagePointer = slug.length - 2; if (pagePointer < 0) return false; if (slug[pagePointer].toLowerCase() === 'page') return true; return false; }; const trimPagePath = (slug) => { if (!isPageSlug(slug)) return slug; return slug.slice(0, -2); }; const getPageNum = (slug) => { if (!isPageSlug(slug)) return -1; return +slug[slug.length - 1]; }; const pagePathFilter = (pathList, slug) => pathList .map((path) => path.params[slug]) .filter((slug) => !isPageSlug(slug)); const makeHash = (content, encoding = 'base64') => crypto.createHash(HASH_ALGORITHM).update(content, 'utf8').digest(encoding); const makeSetLike = (arr) => [...new Set(arr)]; const isSubDir = (parent, child, includeSelf = true) => { const [normalizedParentPath, normalizedChildPath] = [parent, child].map(path.normalize); const relativePath = path.relative(normalizedParentPath, normalizedChildPath); if (!relativePath) return includeSelf; const splittedPath = relativePath.split(path.sep); const isDotSegment = splittedPath[0] !== '..'; return isDotSegment ? true : false; }; const debounce = (fn, time = 300) => { let timerId; return (...args) => { if (timerId) clearTimeout(timerId); timerId = setTimeout(() => { fn(...args); }, time); }; }; const fillToOwnProperty = (obj, value) => { const coppied = { ...obj }; Object.getOwnPropertyNames(obj).forEach((key) => (coppied[key] = value)); return coppied; }; const sortRule = (a, b) => { const dateDiff = b.postData.date - a.postData.date; if (dateDiff === 0) { const aTitle = a.postData.title.toLowerCase(); const bTitle = b.postData.title.toLowerCase(); const firstCharDiff = aTitle[0].localeCompare(bTitle[0]); if (firstCharDiff === 0) return aTitle.localeCompare(bTitle); return firstCharDiff; } return dateDiff; }; const isURL = (input) => !!url.parse(input).protocol; var rehypeAsset = (filePath, info) => () => { return (root) => { info.assets = []; const imageNodes = selectAll('element[tagName="img"]', root); const imageNodeHandler = (node) => { const { src } = node.properties; const decodedPath = decodeURIComponent(src); const { isAbsolute } = path; if (isURL(decodedPath)) return; let sourcePath = decodedPath; let extName = ''; if (!isAbsolute(sourcePath)) { const fileDir = path.dirname(filePath); sourcePath = path.resolve(fileDir, decodedPath); extName = path.extname(sourcePath); } const hashedPath = makeHash(sourcePath, 'hex'); const targetPath = `/${DEFAULT_ASSET_DIRNAME}/${hashedPath}${extName}`; const assetInfo = { sourcePath, targetPath, }; node.properties.src = targetPath; if (node.properties.alt === 'thumbnail') { if (!node.properties.className) node.properties.className = []; node.properties.className.push('thumbnail-image'); info.thumbnail = targetPath; } info.assets.push(assetInfo); }; imageNodes.forEach(imageNodeHandler); if (!info.thumbnail) { info.thumbnail = ''; } return root; }; }; const styledInputValue = (inputValue) => inputValue ? chalk `{bold (${inputValue})}` : ''; const getStyledErrorMsg = (msg, inputValue) => chalk `${formatDate()} - 🍎 {red.bold ERROR:} {yellow ${msg}} {red ${styledInputValue(inputValue)}}`; const getStyledCautionMsg = (msg, inputValue) => chalk `${formatDate()} - 🍋 {yellow.bold Caution:} {magenta ${msg}} {yellow ${styledInputValue(inputValue)}}`; const getStyledInfoMsg = (msg, inputValue) => chalk `${formatDate()} - 🥬 {green.bold INFO} {yellow ${msg}} {green ${styledInputValue(inputValue)}}`; const getStyledLogMsg = (msg, inputValue) => chalk `${formatDate()} - 🥑 {cyan.bold LOG} {green ${msg}} {cyan ${styledInputValue(inputValue)}}`; const formatDate = () => format(Date.now(), 'yyyy-MM-dd HH:mm:ss'); loadLanguage(['jsx', 'tsx', 'typescript', 'scss', 'sql', 'json', 'bash']); const langPrefix = 'language-'; const getLanguage = (node) => { const className = node.properties && node.properties.className; if (!Array.isArray(className)) return ''; const languageClass = className.find((name) => name.startsWith(langPrefix)); if (!languageClass) return ''; const language = languageClass.substr(langPrefix.length); return language; }; const checkCode = (node) => { if (!node.children || !node.children.length) return false; if (node.children[0].type !== 'text' || !node.children[0].value) return false; return true; }; const getCode = (node) => { return checkCode(node) ? node.children[0].value : ''; }; const setCode = (node, code) => { const fragment = unified() .use(rehypeParse, { fragment: true }) .parse(code); if (fragment.children) { node.children = [...fragment.children]; } }; const nodeEditor = (node) => { if (node.tagName === 'code') { const language = getLanguage(node); const code = getCode(node); if (!language || !code) return; try { const highlightedCode = Prism.highlight(code, Prism.languages[language], language); setCode(node, highlightedCode); } catch { console.log(getStyledErrorMsg('Code Syntax highlighter error', `language: ${language}, code: ${code.replace('\n', '').substr(0, 32) + '...'}`)); return; } } }; var rehypePrism = () => { return (root) => { return visit$1(root, 'element', nodeEditor); }; }; const embedableLinkRegex = /https?:\/\/(www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_\-]+)(\?start=(\d+))?[-a-zA-Z0-9()@:%_\+.~#?&//=]*!!/i; const getIframeCode = ({ videoID, startTime }) => { const appendix = startTime ? `?start=${startTime}` : ''; const result = `<iframe src="https://www.youtube.com/embed/${videoID}${appendix}" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`; return result; }; const isLinkNode = (node) => { if (node.tagName === 'a' && node.properties && node.properties.href != null) return true; return false; }; const isEmbedable = (href) => embedableLinkRegex.test(href); const getVideoInfo = (href) => { const executed = embedableLinkRegex.exec(href); if (!executed) return; const videoID = executed[2]; const startTime = executed[4]; return { videoID, startTime, }; }; const setCode$1 = (node, code) => { const fragment = unified() .use(rehypeParse, { fragment: true }) .parse(code); if (!fragment.children) return; node.tagName = fragment.tagName; node.type = fragment.type; if (!node.properties.className) node.properties.className = []; node.properties.className.push('youtube-video-iframe'); node.children = [...fragment.children]; }; const nodeEditor$1 = (node) => { if (isLinkNode(node)) { const href = node.properties.href; if (!isEmbedable(href)) return; const videoInfo = getVideoInfo(href); if (!videoInfo) return; const iframeCode = getIframeCode(videoInfo); setCode$1(node, iframeCode); } return; }; var rehypeYoutube = () => { return (root) => { return visit$1(root, 'element', nodeEditor$1); }; }; var rehypeExcerpt = (info, length = 180) => () => { return (root) => { const nodes = selectAll('element[tagName="p"] text', root); const fullText = nodes.reduce((excerpt, { value }) => { const trimmedValue = value?.trim(); if (!trimmedValue) return excerpt; return `${excerpt} ${trimmedValue}`; }, ''); const trimmedFullText = fullText.trim(); if (!trimmedFullText) { info.excerpt = ''; return root; } const subText = [...trimmedFullText].slice(0, length).join(''); const ellipsis = trimmedFullText.length > length ? '...' : ''; info.excerpt = subText.trim() + ellipsis; return root; }; }; var strip = [ "script" ]; var clobberPrefix = "user-content-"; var clobber = [ "name", "id" ]; var ancestors = { tbody: [ "table" ], tfoot: [ "table" ], thead: [ "table" ], td: [ "table" ], th: [ "table" ], tr: [ "table" ] }; var protocols = { href: [ "http", "https", "mailto", "xmpp", "irc", "ircs" ], cite: [ "http", "https" ], src: [ "http", "https" ], longDesc: [ "http", "https" ] }; var tagNames = [ "h1", "h2", "h3", "h4", "h5", "h6", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", "details", "caption", "figure", "figcaption", "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "wbr", "input" ]; var attributes = { code: [ "className" ], a: [ "href" ], img: [ "src", "longDesc" ], input: [ [ "type", "checkbox" ], [ "disabled", true ] ], li: [ [ "className", "task-list-item" ] ], div: [ "itemScope", "itemType" ], blockquote: [ "cite" ], del: [ "cite" ], ins: [ "cite" ], q: [ "cite" ], "*": [ "abbr", "accept", "acceptCharset", "accessKey", "action", "align", "alt", "ariaDescribedBy", "ariaHidden", "ariaLabel", "ariaLabelledBy", "axis", "border", "cellPadding", "cellSpacing", "char", "charOff", "charSet", "checked", "clear", "cols", "colSpan", "color", "compact", "coords", "dateTime", "dir", "disabled", "encType", "htmlFor", "frame", "headers", "height", "hrefLang", "hSpace", "isMap", "id", "label", "lang", "maxLength", "media", "method", "multiple", "name", "noHref", "noShade", "noWrap", "open", "prompt", "readOnly", "rel", "rev", "rows", "rowSpan", "rules", "scope", "selected", "shape", "size", "span", "start", "summary", "tabIndex", "target", "title", "type", "useMap", "vAlign", "value", "vSpace", "width", "itemProp" ] }; var required = { input: { type: "checkbox", disabled: true } }; var sanitizeSchema = { strip: strip, clobberPrefix: clobberPrefix, clobber: clobber, ancestors: ancestors, protocols: protocols, tagNames: tagNames, attributes: attributes, required: required }; const spaceRegex = /\s+/g; const nonAllowedRegex = /[^가-힣a-z\d\s\.\-]/gi; const slugify = (name, delimeter = '-') => { const result = name .normalize() .replace(nonAllowedRegex, '') .trim() .replace(spaceRegex, delimeter); if (!result) throw new Error(getStyledErrorMsg(`The name cannot be slugified by the slugify module.`, `name : ${name}`)); return result; }; const buildInfoPath = DEFAULT_BUILDINFO_PATH; let buildInfo; const getCachedData = (filePath, newContent) => { if (MODE_TEST) return; const newHash = makeHash(newContent); const buildInfo = loadBuildInfo(buildInfoPath); const cachedInfo = buildInfo[filePath]; if (!cachedInfo) return; const { hash: oldHash, content } = cachedInfo; if (newHash === oldHash) return content; }; const saveCache = (filePath, content, data) => { if (MODE_TEST) return; const hash = makeHash(content); const deepCopied = JSON.parse(JSON.stringify(data)); const cacheData = { hash, content: deepCopied, }; buildInfo[filePath] = cacheData; }; const loadBuildInfo = (buildInfoPath) => { if (buildInfo) return buildInfo; try { const rawInfo = fs.readFileSync(buildInfoPath, 'utf8'); buildInfo = JSON.parse(rawInfo); } catch { buildInfo = {}; } return buildInfo; }; const buildInfoFileSave = () => { const stringified = JSON.stringify(buildInfo, null, 2); if (!stringified) return; const writeBuildInfo = () => { fs.promises.writeFile(buildInfoPath, stringified, 'utf8'); }; fs.promises.unlink(buildInfoPath).then(writeBuildInfo, writeBuildInfo); }; const fsPromise = fs.promises; const makePost = async ({ filePath, slugMap, useCache = true, }) => { const rawText = await fsPromise.readFile(filePath, 'utf8'); const cachedData = useCache ? getCachedData(filePath, rawText) : undefined; const { data: { title, date, tags = [], published = true }, content = '', } = matter(rawText); const refinedDate = await refineDate(filePath, date); const parsedSlug = parseSlug(filePath, refinedDate); const refinedSlug = !slugMap ? parsedSlug : makeUnique(parsedSlug, filePath, slugMap); const post = await createPostData(filePath, { title, tags, slug: refinedSlug, date: refinedDate, categories: [], isPublished: published, }, content, cachedData); if (!cachedData || !useCache) saveCache(filePath, rawText, { html: post.html, tags: post.tags, assets: post.assets, excerpt: post.excerpt, thumbnail: post.thumbnail, }); return post; }; const parseSlug = (filePath, timestamp) => { const basename = path.basename(filePath, '.md'); const datePrepended = prependDate(basename, timestamp); const slugified = slugify(datePrepended); return slugified; }; const refineDate = async (filePath, date) => { if (date == null) { if (MODE_TEST) return new Date('1990-04-10').valueOf(); const ctime = (await fsPromise.stat(filePath)).ctime; return ctime.valueOf(); } return date.valueOf(); }; const prependDate = (name, timestamp) => { return `${format(new Date(timestamp), 'yyyy-MM-dd')}-${name}`; }; const makeUnique = (slug, nodePath, slugMap, salt = 0, newSlug) => { const target = newSlug || slug; const isDupl = slugMap.get(target) === true ? true : false; if (isDupl) { const nowDir = path.dirname(nodePath); const parentDir = path.resolve(nowDir, '..'); const relativeDir = path.relative(parentDir, nowDir); const hash = salt === 0 ? '' : makeHash(relativeDir + `${salt}`, 'hex').substr(0, 5); const saltedSlug = slug + hash; return makeUnique(slug, nodePath, slugMap, salt + 1, saltedSlug); } slugMap.set(target, true); return target; }; const createPostData = async (filePath, { slug, title, date, tags, categories, isPublished }, rawContent, cachedData) => { const postData = { slug, title: !title ? slug : title, date, html: '', tags: makeSetLike(tags.map((tag) => slugify(tag))), categories: categories ? categories : [], isPublished, }; if (cachedData) { postData.html = cachedData.html; postData.tags = makeSetLike(cachedData.tags); postData.assets = cachedData.assets; postData.excerpt = cachedData.excerpt; postData.thumbnail = cachedData.thumbnail; return postData; } const { html, info } = await parseMarkdown(filePath, rawContent); postData.html = html; postData.assets = info.assets; postData.excerpt = info.excerpt; postData.thumbnail = info.thumbnail; return postData; }; const parseMarkdown = async (filePath, markdown) => { const info = {}; const parser = unified() .use(remark) .use(remark2rehype) .use(rehypeSanitize, sanitizeSchema) .use(rehypeYoutube) .use(rehypeExcerpt(info)) .use(rehypeAsset(filePath, info)) .use(rehypeSlug) .use(rehypePrism) .use(rehypeHtml, { collapseEmptyAttributes: true, }); const parsedData = await parser.process(markdown); const html = parsedData.contents.toString(); return { html, info, }; }; const fsPromise$1 = fs.promises; const markdownRegex = new RegExp(/\.mdx?$/); const isDirectory = (dirent) => dirent.isDirectory(); const isMarkdown = (dirent) => dirent.isFile() && markdownRegex.test(dirent.name); async function getNodeTree({ rootPath, slugMap = new Map(), }) { const node = await createCategoryNode({ slugMap, nodePath: rootPath, }); const subFileList = await fsPromise$1.readdir(rootPath, { withFileTypes: true, encoding: 'utf-8', }); if (!subFileList.length) return node; node.children = []; for (const currentFile of subFileList) { const currentFilePath = path.resolve(rootPath, currentFile.name); let childNode; if (isDirectory(currentFile)) { childNode = await getNodeTree({ slugMap, rootPath: currentFilePath, }); if (!childNode.children || childNode.children.length === 0) { continue; } } else if (isMarkdown(currentFile)) { try { childNode = await createPostNode({ slugMap, nodePath: currentFilePath, }); const { isPublished, html } = childNode.postData; if (!isPublished || !html) continue; } catch { console.log(getStyledCautionMsg(chalk `Failed to parse {bold [ ${currentFile.name} ]} file. Please check the file content.`)); continue; } } else { continue; } node.children.push(childNode); } node.children = sortChildren(node.children); return node; } const createNode = (type) => async ({ nodePath, slugMap, }) => { const name = path.basename(nodePath).normalize(); const newNode = { type, name, path: nodePath, }; if (type === NODE_TYPE_POST) { const postData = await makePost({ filePath: nodePath, slugMap }); const slug = postData.slug; newNode.slug = slug; newNode.postData = postData; return newNode; } newNode.slug = slugify(name); return newNode; }; const createCategoryNode = createNode(NODE_TYPE_CATEGORY); const createPostNode = createNode(NODE_TYPE_POST); const sortChildren = (nodeList) => { const categoryList = nodeList.filter(isCategoryNode); const postList = nodeList.filter(isPostNode); categoryList.sort(({ name: aName }, { name: bName }) => aName.localeCompare(bName)); postList.sort(sortRule); return [...categoryList, ...postList]; }; const convertToPath = (paramName) => (slug) => ({ params: { [paramName]: slug, }, }); const getCategoriesPaths = (rootNode, perPage = 10, parents = []) => { const result = []; if (!rootNode.children) return result; if (parents.length) { result.push(parents); const pagePaths = getPagePaths(rootNode, perPage); pagePaths.forEach((path) => result.push([...parents, ...path])); } for (const node of rootNode.children) { if (isCategoryNode(node)) { const category = [...parents, node.slug]; result.push(...getCategoriesPaths(node, perPage, category)); } } return result; }; const getTagsPaths = (rootNode, perPage = 10) => { const tagPaths = []; const tags = getTagsAll(rootNode); for (const tag of tags) { tagPaths.push([tag]); const pagePaths = getPagePaths(rootNode, perPage, (node) => isPostNode(node) && node.postData.tags.includes(tag)); pagePaths.forEach((path) => tagPaths.push([tag, ...path])); } return tagPaths; }; const getPagePaths = (rootNode, perPage = 5, nodeCondition = isPostNode) => { const pagePaths = []; const posts = findNodeAll(rootNode, nodeCondition); const length = posts.length; const pageCount = getTotalPage(length, perPage); const prefix = 'page'; for (let i = 1; i <= pageCount; i++) { pagePaths.push([prefix, `${i}`]); } return pagePaths; }; const getPathList = ({ rootNode, paramOption: { category: categoryParam, page: pageParam, post: postParam, tag: tagParam, }, perPageOption: { page: pagesPerMain, category: pagesPerCategoryPage, tag: pagesPerTagPage, }, }) => { const convertToPostPath = convertToPath(postParam); const convertToCategoryPath = convertToPath(categoryParam); const convertToPagePath = convertToPath(pageParam); const convertToTagPath = convertToPath(tagParam); const postList = getPostsAll(rootNode); const post = postList.map(({ slug }) => convertToPostPath(slug)); const category = getCategoriesPaths(rootNode, pagesPerCategoryPage).map(convertToCategoryPath); const page = getPagePaths(rootNode, pagesPerMain).map(convertToPagePath); const tag = getTagsPaths(rootNode, pagesPerTagPage).map(convertToTagPath); return { post, category, page, tag, }; }; const makePropList = ({ rootNode, pathList, paramOption, perPageOption, }) => { const global = makeGlobalProp(rootNode); const category = makeListPageProp({ rootNode, pathList: pathList.category, perPage: perPageOption.category, pageParam: paramOption.category, getPostsFn: getPostsByCategories, pageCategory: 'category', }); const tag = makeListPageProp({ rootNode, pathList: pathList.tag, perPage: perPageOption.tag, pageParam: paramOption.tag, getPostsFn: getPostsByTags, pageCategory: 'tag', }); const page = makeListPageProp({ rootNode, perPage: perPageOption.page, pathList: pathList.page, pageParam: paramOption.page, getPostsFn: getPostsAll, sort: true, pageCategory: 'page', }); const post = makePostPageProp(rootNode, pathList.post, paramOption.post); return { global, category, page, tag, post, }; }; const makeGlobalProp = (rootNode) => { const categoryCount = getCategoriesAll(rootNode).length - 1; const postCount = getPostsAll(rootNode).length; const tagCount = getTagsAll(rootNode).length; const buildTime = MODE_DEV || MODE_TEST ? 0 : Date.now(); const categoryTree = makeCategoryTree(rootNode); const tagList = makeTagList(rootNode); return { categoryCount, postCount, tagCount, categoryTree, tagList, buildTime, }; }; const makeCategoryTree = (rootNode, isRoot = true) => { const newNode = { name: rootNode.name, slug: rootNode.slug, postCount: getPostsAll(rootNode).length, }; if (!rootNode.children) return newNode; for (const child of rootNode.children) { if (isCategoryNode(child)) { if (!newNode.childList) newNode.childList = []; if (isRoot) { newNode.childList.push(makeCategoryTree(child, false)); continue; } const childNode = makeCategoryTree({ ...child, ...{ slug: `${newNode.slug}/${child.slug}` }, }, false); newNode.childList.push(childNode); } } return newNode; }; const makeTagList = (rootNode) => { const tags = getTagsAll(rootNode); const infoList = tags.map((tag) => ({ name: tag, slug: tag, postCount: getPostsByTags(rootNode, [tag]).length, })); return infoList; }; const makeListPageProp = ({ rootNode, pathList, pageParam, pageCategory, perPage = 10, getPostsFn = getPostsAll, sort = false, }) => { const propMap = {}; for (const path of pathList) { const slug = path.params[pageParam]; const isPage = isPageSlug(slug); const trimmedSlug = trimPagePath(slug); const posts = getPostsFn(rootNode, trimmedSlug); const totalPage = getTotalPage(posts.length, perPage); if (sort) posts.sort(sortRule); const currentPage = isPage ? getPageNum(slug) : 1; const postList = getPostsByPage(posts, currentPage, perPage).map((node) => node.postData); const count = postList.length; const joinedSlug = slug.join('/'); const prop = { slug: joinedSlug, pageCategory, count, currentPage, perPage, postList, totalPage, }; propMap[joinedSlug] = prop; } return propMap; }; const makePostPageProp = (rootNode, pathList, pageParam) => { const postMap = {}; for (const path of pathList) { const slug = path.params[pageParam]; const post = getPostBySlug(rootNode, slug); if (!post) continue; const postData = post.postData; const categories = postData.categories; let relatedPosts = []; if (categories.length > 0) { relatedPosts = getPostsByCategories(rootNode, categories) .filter((node) => node.slug !== slug) .map((node) => node.postData); } postMap[slug] = { slug, postData, relatedPosts, pageCategory: 'post' }; } return postMap; }; const fsPromise$2 = fs.promises; const copyAssetsTo = (publicDir, imageMaxWidth = 600) => async (assetList) => { const assetDir = path.resolve(publicDir, `./${DEFAULT_ASSET_DIRNAME}`); try { if (MODE_PRODUCTION) { await fsPromise$2.rmdir(assetDir, { recursive: true }); } await fsPromise$2.stat(assetDir); } catch (e) { if (e.code === 'ENOENT') fsPromise$2.mkdir(assetDir, { recursive: true }); } const operations = assetList.map(async ({ sourcePath, targetPath }) => { const writePath = path.join(publicDir, targetPath); try { await fsPromise$2.stat(writePath); } catch { const ext = extname(sourcePath); if (['.jpeg', '.jpg', '.png'].includes(ext)) { const image = sharp(sourcePath); const metadata = await image.metadata(); const width = metadata.width; if (width && width > imageMaxWidth) { image.resize({ width: imageMaxWidth }); } if (ext === '.png') { image.png(); } else if (['.jpeg', '.jpg'].includes(ext)) { image.jpeg(); } return image.trim().toFile(writePath); } return fsPromise$2.copyFile(sourcePath, path.join(publicDir, targetPath), fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE); } return Promise.reject(); }); const settledResult = (await Promise.allSettled(operations)).map((res) => res.status); const result = { fulfilled: settledResult.filter((status) => status === 'fulfilled'), rejected: settledResult.filter((status) => status === 'rejected'), }; const settledLength = result.fulfilled.length; if (settledLength > 0 && !MODE_PRODUCTION) { const resultMsg = `${result.fulfilled.length} image files have been created.`; console.log(getStyledLogMsg(resultMsg)); } return result; }; const getAssetList = (rootNode) => { const postList = getPostsAll(rootNode).map((node) => node.postData); const assetList = postList.reduce((acc, now) => { if (now.assets) acc = acc.concat(now.assets); return acc; }, []); return assetList; }; const makeStore = async ({ postDir, storeName, perPage, pageParam, incremental, imageMaxWidth, }) => { const [paramOption, perPageOption] = normalizeOption(pageParam, perPage); const rootNode = await getNodeTree({ rootPath: postDir, }); const pathList = getPathList({ paramOption, perPageOption, rootNode: rootNode, }); appendExtraToPost({ rootNode, categoryPathList: pathList.category, categoryParamName: paramOption.category, }); const propList = makePropList({ paramOption, perPageOption, rootNode: rootNode, pathList: pathList, }); const info = { name: storeName ? storeName : rootNode.name, postDir: postDir, }; const options = { postDir, perPage: perPageOption, pageParam: paramOption, }; const assetList = getAssetList(rootNode); await copyAssetsTo(DEFAULT_PUBLICDIR_PATH, imageMaxWidth)(assetList); const store = { rootNode, pathList, propList, info, options, }; if (incremental) buildInfoFileSave(); storeMap.set(postDir, store); return store; }; const makePathStore = async ({ postDir, perPage, pageParam, }) => { const [paramOption, perPageOption] = normalizeOption(pageParam, perPage); const rootNode = await getNodeTree({ rootPath: postDir, }); const pathList = getPathList({ paramOption, perPageOption, rootNode: rootNode, }); return { pathList, }; }; const appendExtraToPost = ({ rootNode, categoryPathList, categoryParamName, }) => { const trimmedCategories = pagePathFilter(categoryPathList, categoryParamName); for (const category of trimmedCategories) { const posts = getPostsByCategories(rootNode, category); let prev; for (const { postData: now } of posts) { if (prev) { now.prevPost = { slug: prev.slug, title: prev.title, }; prev.nextPost = { slug: now.slug, title: now.title, }; } now.categories = category; prev = now; } } }; const normalizeOption = (paramOption, perPageOption) => { let paramOptionResult = typeof paramOption === 'object' ? { ...DEFAULT_PARAM_OPTION, ...paramOption } : paramOption ? fillToOwnProperty(DEFAULT_PARAM_OPTION, paramOption) : DEFAULT_PARAM_OPTION; let countOptionResult = typeof perPageOption === 'object' ? { ...DEFAULT_PERPAGE_OPTION, ...perPageOption } : perPageOption ? fillToOwnProperty(DEFAULT_PERPAGE_OPTION, perPageOption) : DEFAULT_PERPAGE_OPTION; return [paramOptionResult, countOptionResult]; }; const startWatchMode = (props) => { console.log(getStyledInfoMsg('Start Watch mode..', props.postDir)); const parentWatcherKey = getParentWatcherKey(props.postDir); const watcher = parentWatcherKey ? watcherMap.get(parentWatcherKey) : watch(props.postDir, { persistent: true, ignoreInitial: true, interval: 100, binaryInterval: 3000, }); watcherMap.set(props.postDir, watcher); const updateStore = debounce(async (msg) => { await makeStore(props); console.log(msg); }, 200); const changeFileHandler = async (filePath) => { const store = storeMap.get(props.postDir); let newPostData; try { newPostData = await makePost({ filePath: filePath, useCache: false, }); } catch { console.log(getStyledCautionMsg(chalk `Failed to parse {bold [ ${path.basename(filePath)} ]} file. Please check the file content.`)); return; } if (!newPostData.html) return; const post = getPostByPath(store.rootNode, filePath); if (!post) { updateStore(getStyledLogMsg(chalk `{red.bold There is no stored post corresponding to the [ ${path.basename(filePath)} ] file.} The Store is created again.`, `store : ${store.info.name}`)); return; } updatePostData(post.postData, newPostData, [ 'title', 'html', 'tags', 'date', 'excerpt', 'thumbnail', ]); if (newPostData.assets) { await copyAssetsTo(DEFAULT_PUBLICDIR_PATH)(newPostData.assets); } console.log(getStyledLogMsg(chalk `{bold [ ${newPostData.title} ]} Post updated.`, `store : ${store.info.name}`)); if (props.incremental) buildInfoFileSave(); }; const rawHandlerForDarwin = async (eventName, filePath, detail) => { if (MODE_TEST) console.log({ event: detail.event, flags: detail.flags, type: detail.type, }); const { info: storeInfo } = storeMap.get(props.postDir); const fileDirPath = path.dirname(filePath); if (!isSubDir(storeInfo.postDir, fileDirPath)) return; if (eventName === 'modified' && detail.type === 'file' && extName(filePath) === '.md') { await changeFileHandler(filePath); return; } if (['error', 'ready'].includes(eventName)) return; if (['created', 'moved'].includes(eventName) && detail.type === 'file' && !['.md', '.jpg', '.png', '.jpeg', '.gif', '.svg'].includes(extName(filePath))) return; await restEventHandler(filePath, detail.type); }; const restEventHandler = async (filePath, type = 'file') => { const { info: storeInfo } = storeMap.get(props.postDir); updateStore(getStyledLogMsg(chalk `{bold [ ${path.basename(filePath)} ${type} ]} has changed. The Store is created again.`, `store : ${storeInfo.name}`)); }; if (!PLATFROM_DARWIN) { watcher.on('change', changeFileHandler); watcher.on('add', restEventHandler); watcher.on('addDir', restEventHandler); watcher.on('unlink', restEventHandler); watcher.on('unlinkDir', restEventHandler); watcher.on('all', async (eventName, path) => { if (eventName === 'change') return; await restEventHandler(path); }); return; } watcher.on('raw', rawHandlerForDarwin); }; const extName = (filePath) => path.extname(filePath).normalize().toLowerCase(); const getParentWatcherKey = (postDir) => [...watcherMap.keys()].find((watcherKey) => isSubDir(watcherKey, postDir)); const updatePostData = (targetPostData, newPostData, keys) => keys.forEach((key) => { targetPostData[key] = newPostData[key]; }); const getStore = async ({ postDir, storeName, perPage = DEFAULT_PERPAGE_VALUE, pageParam = DEFAULT_PARAM_VALUE, shouldUpdate = MODE_TEST, watchMode = MODE_DEV, incremental = true, imageMaxWidth, }) => { const cachedStore = storeMap.get(postDir); if (cachedStore && (!shouldUpdate || watchMode)) return cachedStore; const paramToMakeStore = { postDir, storeName, perPage, pageParam, incremental, imageMaxWidth, }; const store = await makeStore(paramToMakeStore); if (watchMode && !MODE_PRODUCTION) startWatchMode(paramToMakeStore); if (MODE_PRODUCTION || MODE_TEST) return store; console.log(getStyledInfoMsg('New store has been created.', `name : ${store.info.name}`)); return store; }; const loadConfig = async ({ storeName, configPath = DEFAULT_CONFIG_PATH, }) => { const { storeOption } = await getConfig(path.resolve(configPath)); if (Array.isArray(storeOption)) { if (!storeName) { throw new Error(getStyledErrorMsg('If the storeOption property in the config object is an array, storeName is required.')); } const findedOption = storeOption.find((option) => option.storeName === storeName); if (!findedOption) { throw new Error(getStyledErrorMsg('There is no option corresponding to storeName.', `name: ${storeName}`)); } return findedOption; } return storeOption; }; const getConfig = async (configPath) => { let config; try { fs.statSync(configPath); } catch { throw new Error(getStyledErrorMsg('Please ensure that the config file exists.', `path: ${configPath}`)); } try { const importedModule = await import(configPath); if (importedModule.default) { config = importedModule.default; } else { config = importedModule; } } catch { throw new Error(getStyledErrorMsg('This is not a module file.', `path: ${configPath}`)); } if (!validateConfig(config)) { throw new Error(getStyledErrorMsg('The configuration should look like below.') + chalk `\n{yellow.bold ${CONFIG_EXAMPLE}}`); } return config; }; const validateConfig = (config) => { if (!config || !config.storeOption) return false; const storeOption = config.storeOption; if (Array.isArray(storeOption)) { return storeOption.every((sOption) => validateStoreOption(sOption)); } return validateStoreOption(storeOption); }; const validateStoreOption = ({ postDir, shouldUpdate, watchMode, storeName, }) => { if (!postDir) return false; if (shouldUpdate && watchMode === true) { throw new Error(getStyledErrorMsg('Both "shouldUpdate" and "watchMode" cannot be true. Please check the configuration.', storeName && `store: ${storeName}`)); } return true; }; const makePageHandler = (pageCategory) => (option) => { const storeOption = normalizeOption$1(option); async function getPathsBySlug() { const { postDir, perPage, pageParam } = await storeOption; const store = await makePathStore({ postDir, perPage, pageParam, }); const pathList = store.pathList[pageCategory]; return pathList; } async function getPropsBySlug(param) { const store = await getStore(await storeOption); const propList = store.propList[pageCategory]; const key = Array.isArray(param) ? param.join('/') : param; const mainProp = propList[key]; if (!mainProp) { throw new Error(getStyledErrorMsg(`The data corresponding to the param does not exist.`, `input param : ${param}`)); } if (isListPage(mainProp)) { const refinedPostList = mainProp.postList.map(({ slug, title, date, thumbnail, excerpt, categories, tags }) => ({ slug, title, date, thumbnail, excerpt, categories, tags, })); const newMainProp = { ...mainProp }; newMainProp.postList = refinedPostList; return { param: key, global: store.propList.global, main: newMainProp, }; } return { param: key, global: store.propList.global, main: mainProp, }; } return { getPathsBySlug, getPropsBySlug, }; }; const getMainPageHandler = (option) => { const storeOption = normalizeOption$1(option); async function getMainProps() { const store = await getStore(await storeOption); const propList = store.propList.page; const mainKey = 'page/1'; const mainProp = propList[mainKey]; const emptyProp = { slug: mainKey, pageCategory: 'page', count: 0, currentPage: 0, postList: [], totalPage: 0, perPage: 0, }; if (!mainProp) { console.log(getStyledCautionMsg('Since There are no posts in the [postDir] path, the following default values is delivered.', `postDir: ${path.basename(store.info.postDir)}`)); console.log('\u001b[2m──────────────\u001b[22m'); console.log(chalk `{blue.bold Default Prop :}\n{yellow ${JSON.stringify({ global: store.propList.global, main: emptyProp, }, null, 2)}}`); } return { param: mainKey, global: store.propList.global, main: mainProp == null ? emptyProp : mainProp, }; } return { getMainProps }; }; const getGlobalPageHandler = (option) => { const storeOption = normalizeOption$1(option); async function getMainProps() { const store = await getStore(await storeOption); return { ...store.propList.global, }; } return { getMainProps }; }; const isConfigOption = (option) => { if (option.useConfig) return true; return false; }; const normalizeOption$1 = async (option) => { if (isConfigOption(option)) { const storeOption = await loadConfig(option); return storeOption; } if (!option.postDir) { throw new Error(getStyledErrorMsg('The postDir parameter is required.')); } const storeOption = option; return storeOption; }; const isListPage = (a) => { return a.pageCategory !== 'post'; }; const getCategoryPageHandler = makePageHandler('category'); const getPostPageHandler = makePageHandler('post'); const getTagPageHandler = makePageHandler('tag'); const getPageHandler = makePageHandler('page'); export { getCategoryPageHandler, getGlobalPageHandler, getMainPageHandler, getPageHandler, getPostPageHandler, getTagPageHandler };