UNPKG

@dinamomx/nuxtent

Version:

Seamlessly use content files in your Nuxt.js sites.

1,474 lines (1,464 loc) 50.4 kB
/** * Nuxtent v3.2.0 * (c) 2019 César Valadez * @license MIT */ import micro, { send } from 'micro'; import { sep, join } from 'path'; import markdownItAnchor from 'markdown-it-anchor'; import { defaultsDeep } from 'lodash'; import diacritics from 'diacritics'; import consola from 'consola'; import { readFileSync, statSync, readdirSync } from 'fs'; import matter from 'gray-matter'; import dateFns from 'date-fns'; import pathToRegexp from 'path-to-regexp'; import yaml from 'js-yaml'; import markdownIt from 'markdown-it'; import markdownItTocDoneRight from 'markdown-it-toc-done-right'; import { get, router, withNamespace } from 'microrouter'; const name = "@dinamomx/nuxtent"; const version = "3.2.0"; const description = "Seamlessly use content files in your Nuxt.js sites."; const main = "index.js"; const module = "index.mjs"; const contributors = [ "Joost De Cock (@joostdecock)", "Alid Castano (@alidcastano)", "César Valadez (@cesasol)" ]; const repository = { type: "git", url: "https://github.com/nuxt-community/nuxtent-module.git" }; const keywords = [ "Nuxt.js", "Vue.js", "Content", "Blog", "Posts", "Collections", "Navigation", "Markdown", "Static" ]; const license = "MIT"; const scripts = { lint: "eslint --fix \"**/*.js\"", pretest: "npm run lint", debug: "cd docs; node --inspect node_modules/.bin/nuxt", e2e: "node_modules/cross-env/dist/bin/cross-env.js NODE_ENV=test jest --runInBand --forceExit", test: "jest", build: "node_modules/cross-env/dist/bin/cross-env.js NODE_ENV=production node_modules/rollup/bin/rollup -c rollup.config.js", watch: "npm run build -- -w", prepare: "npm run build", "build:docs": "cd docs && npm i && npm run generate", release: "standard-version && git push --follow-tags && npm publish", "semantic-release": "semantic-release", "eslint-check": "eslint --print-config . | eslint-config-prettier-check" }; const dependencies = { consola: "2.6.0", "date-fns": "1.30.1", diacritics: "1.3.0", "gray-matter": "4.0.2", "js-yaml": "3.13.1", "loader-utils": "1.2.3", lodash: "4.17.11", "markdown-it": "8.4.2", "markdown-it-anchor": "5.0.2", "markdown-it-toc-done-right": "3.0.1", micro: "9.3.3", microrouter: "3.1.3", "node-fetch": "2.3.0", "path-to-regexp": "3.0.0" }; const devDependencies = { "@babel/runtime": "7.4.3", "@nuxt/config": "2.6.2", "@nuxt/core": "2.6.2", "@nuxt/loading-screen": "0.5.0", "@nuxt/typescript": "2.6.2", "@nuxt/vue-app": "2.6.2", "@nuxt/vue-renderer": "2.6.2", "@nuxt/webpack": "2.6.2", "@types/anymatch": "1.3.1", "@types/body-parser": "1.17.0", "@types/clean-css": "4.2.1", "@types/compression": "0.0.36", "@types/connect": "3.4.32", "@types/diacritics": "1.3.1", "@types/etag": "1.8.0", "@types/express": "4.16.1", "@types/express-serve-static-core": "4.16.2", "@types/html-minifier": "3.5.3", "@types/js-yaml": "3.12.1", "@types/loader-utils": "^1.1.3", "@types/lodash": "4.14.123", "@types/loglevel": "1.5.4", "@types/markdown-it": "0.0.7", "@types/markdown-it-anchor": "4.0.3", "@types/memory-fs": "0.3.2", "@types/micro": "7.3.3", "@types/microrouter": "3.1.0", "@types/node": "11.13.7", "@types/node-fetch": "^2.3.2", "@types/optimize-css-assets-webpack-plugin": "1.3.4", "@types/range-parser": "^1.2.3", "@types/relateurl": "0.2.28", "@types/serve-static": "1.13.2", "@types/tapable": "1.0.4", "@types/terser-webpack-plugin": "1.2.1", "@types/uglify-js": "3.0.4", "@types/webpack": "4.4.27", "@types/webpack-bundle-analyzer": "2.13.1", "@types/webpack-dev-middleware": "2.0.2", "@types/webpack-hot-middleware": "2.16.5", chokidar: "2.1.5", "core-js": "2", "cross-env": "^5.2.0", cssnano: "4.1.10", "eslint-config-standard": "^14.1.0", "eventsource-polyfill": "0.9.6", nuxt: "2.6.2", "postcss-import": "12.0.1", "postcss-preset-env": "6.6.0", "postcss-url": "8.0.0", prettier: "1.17.0", "range-parser": "^1.2.0", "regenerator-runtime": "0.13.2", rollup: "1.10.1", "rollup-plugin-commonjs": "9.3.4", "rollup-plugin-copy": "^1.1.0", "rollup-plugin-json": "4.0.0", "rollup-plugin-node-resolve": "4.2.3", "rollup-plugin-typescript": "1.0.1", "source-map": "0.7.3", terser: "3.17.0", tslib: "1.9.3", tslint: "5.16.0", typescript: "3.4.5", "url-pattern": "1.0.3", vue: "2.6.10", "vue-router": "3.0.6", "vue-server-renderer": "2.6.10" }; const husky = { hooks: { "pre-commit": "lint-staged" } }; const bugs = { url: "https://github.com/nuxt-community/nuxtent-module/issues" }; const homepage = "https://github.com/nuxt-community/nuxtent-module#readme"; const directories = { doc: "docs", example: "examples", lib: "lib", test: "test" }; const files = [ "dist", "plugins" ]; const author = "Joost De Cock"; var _package = { name: name, version: version, description: description, main: main, module: module, contributors: contributors, repository: repository, keywords: keywords, license: license, scripts: scripts, dependencies: dependencies, devDependencies: devDependencies, husky: husky, "lint-staged": { "*.js": [ "eslint --fix --", "git add" ] }, bugs: bugs, homepage: homepage, directories: directories, files: files, author: author }; var _package$1 = /*#__PURE__*/Object.freeze({ name: name, version: version, description: description, main: main, module: module, contributors: contributors, repository: repository, keywords: keywords, license: license, scripts: scripts, dependencies: dependencies, devDependencies: devDependencies, husky: husky, bugs: bugs, homepage: homepage, directories: directories, files: files, author: author, default: _package }); /* eslint-disable no-useless-escape */ /** * Slugifies a string * Borrowed from vuepress, those guys are amazing * string.js slugify drops non ascii chars so we have to * use a custom implementation here */ const slugify = (str) => { // eslint-disable-next-line no-control-regex const rControl = /[\u0000-\u001f]/g; const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'<>,.?/]+/g; return (diacritics .remove(str) .normalize('NFD') // Remove control characters .replace(rControl, '') // Replace special characters .replace(rSpecial, '-') // Remove continous separators .replace(/\-{2,}/g, '-') // Remove prefixing and trailing separtors .replace(/^\-+|\-+$/g, '') // ensure it doesn't start with a number (#121) // .replace(/^(\d)/, '_$1') // lowercase .toLowerCase()); }; const logger = consola.withScope('nuxt:nuxtent'); /** * Converts a path route to a url like name * @param {string} routePath The route path in vue file format * // /pages/_category/_slug => pages-category-slug * @returns {string} The url like name */ const pathToName = (routePath) => { const firstSlash = /^\//; return routePath .replace(firstSlash, '') .replace(sep, '-') .replace('_', ''); }; /** * @description Genera objeto de componentes dinamicos * * @param assetMap El mapa de páginas */ function generatePluginMap(assetMap) { const webpackAlias = '~/content/'; const mdComps = []; for (const collections of assetMap.values()) { for (const page of collections.pagesMap.values()) { if (page.meta.fileName.endsWith('.comp.md')) { if (typeof page.body === 'string') { logger.error('Content component file should have a relativePath'); } else { const filePath = webpackAlias + page.body.relativePath.substring(1); mdComps.push([page.body.relativePath, filePath]); } } } } return mdComps; } const permalinkCompiler = pathToRegexp.compile; /** * Creates a slug * @param {string} fileName The file name * @returns {string} the slugified string */ const getSlug = (fileName) => { const onlyName = fileName .replace(/(\.comp)?(\.[0-9a-z]+)$/, '') // remove any ext .replace(/!?(\d{4}-\d{2}-\d{2}-)/, ''); // remove date and hypen return slugify(onlyName).toLowerCase(); }; /** * Converts the date of the post into object * @param {string} date The date * @returns {{year: string, month: string, day: string}} The date object */ const splitDate = (date) => { const [year, month, day] = date.split('-'); return { day, month, year, }; }; const isDev = process.env.NODE_ENV !== 'production'; class Page { /** * Creates an instance of Page. * @param meta The metadata for the page file * @param contentConfig The content configuration * * @memberOf Page */ constructor(meta, contentConfig) { this.cached = { attributes: {}, body: null, data: { attributes: {}, body: {}, }, date: null, path: null, permalink: null, }; this.propsSet = new Set([ 'meta', 'date', 'path', 'permalink', 'breadcrumbs', 'attributes', 'body', ]); this.__meta = meta; this.config = contentConfig; if (contentConfig.toc !== false) { this.propsSet.add('toc'); } } /** * Gets the meta but hides the file path */ get meta() { const cleanedMeta = Object.assign({}, this.__meta); // Never expose the filePath delete cleanedMeta.filePath; return cleanedMeta; } /** * Gets the path of the file */ get path() { // If there is no page defined in the configuration return the permalink if (!this.config.page) { return this.permalink; } // If is dev or isn't cached make it if (isDev || !this.cached.path) { const nestedPath = /([^_][a-zA-z]*?)\/[^a-z_]*/; const matchedPath = this.config.page.match(nestedPath); if (matchedPath && matchedPath[1] !== 'index') { this.cached.path = join(matchedPath[1], this.permalink).replace(/\\|\/\//, '/'); } else { this.cached.path = this.permalink.replace(/\\|\/\//, '/'); } } return this.cached.path; } /** * Gets the valid permalink for this page */ get permalink() { if (isDev || !this.cached.permalink) { const date = this.date.toString(); const { section, fileName } = this.meta; const slug = getSlug(fileName); const { year, month, day } = splitDate(date); const params = { section, slug, date, year, month, day }; const toPermalink = permalinkCompiler(this.config.permalink); let permalink = join('/', toPermalink(params).replace(/%2F/gi, '/') // make url encoded slash pretty ); // Handle permalinks for subdirectory indexes if (permalink.length > 6 && permalink.substr(-6) === '/index') { permalink = permalink.substr(0, permalink.length - 6); } this.cached.permalink = permalink.replace(/\\|\\\\/g, '/'); } return this.cached.permalink; } /** * Gets all the attributes */ get attributes() { if (typeof this.config.data === 'object') { return { ...this.config.data, ...this._rawData.attributes }; } return this._rawData.attributes; } /** * Gets the body contents for the object */ get body() { if (isDev || this.cached.body === null || (typeof this.cached.body === 'string' && this.cached.body.length === 0) || (typeof this.cached.body === 'object' && !this.cached.body.relativePath)) { const { dirName, section, fileName, filePath } = this.__meta; if (fileName.search(/\.comp\.md$/) > -1) { let relativePath = '.' + join(dirName, section, fileName); relativePath = relativePath.replace(/\\/, '/'); // normalize windows path if (!relativePath) { logger.error('Path not found for ' + this._rawData.fileName); } this.cached.body = { content: this._rawData.body.content, relativePath, }; } else if (fileName.search(/\.md$/) > -1) { if (this.config.markdown.plugins.toc) { // Inject callback in markdown-it-anchor plugin const tocPlugin = this.config.markdown.plugins .toc; tocPlugin[1].callback = this.tocParserCallback; } // markdown to html if (this.config.markdown.parser) { if (!this._rawData.body.content) { logger.warn(`Empty content on ${this.path}`); } this.cached.body = this.config.markdown.parser.render(this._rawData.body.content || ''); } else { logger.error(`The ${this.config.permalink} markdown config is wrong`); } } else if (fileName.endsWith('.html')) { this.cached.body = this._rawData.body.content || ''; } else if (fileName.search(/\.(yaml|yml)$/) > -1) { const source = readFileSync(filePath).toString(); const body = yaml.load(source); this.cached.body = body; } else { logger.error('This file is not supported ' + this.__meta.fileName); } } if (this.cached.body === null) { throw new Error('Unexpected result on get body'); } return this.cached.body; } get date() { if (isDev || !this.cached.date) { const { filePath, fileName, section } = this.__meta; if (this.config.isPost) { const fileDate = fileName.match(/!?(\d{4}-\d{2}-\d{2})/); // YYYY-MM-DD if (!fileDate) { throw new Error(`File "${fileName}" on ${section} Needs a date in YYYY-MM-DD-filename.md!`); } this.cached.date = fileDate[0]; } else { const stats = statSync(filePath); this.cached.date = dateFns.format(stats.ctime, 'YYYY-MM-DD'); } } return this.cached.date; } get _rawData() { if (isDev || !this.cached.data.fileName) { const source = readFileSync(this.__meta.filePath).toString(); const fileName = this.__meta.fileName; this.cached.data.fileName = fileName; if (fileName.search(/\.(md|html)$/) !== -1) { // { data: attributes, content: body } = matter(source) const result = matter(source, { excerpt: !!this.config.excerpt, }); this.cached.data.attributes = result.data; if (!!this.config.excerpt) { this.cached.data.attributes.excerpt = fileName.endsWith('md') && this.config.markdown.parser && result.excerpt ? this.config.markdown.parser.render(result.excerpt) : result.excerpt; } this.cached.data.body.content = result.content; } else if (fileName.search(/\.(yaml|yml)$/) !== -1) { this.cached.data.body.content = yaml.load(source); } else if (fileName.endsWith('.json')) { this.cached.data.body.content = JSON.parse(source); } else { logger.warn(`The file ${fileName} is not compatible with nuxtent`); } } return this.cached.data; } set toc(entry) { if (!this.config.toc || !entry) { return; } if (typeof this.cached.toc === 'undefined') { this.cached.toc = {}; } if (typeof this.cached.toc[this.permalink] === 'undefined') { this.cached.toc[this.permalink] = { items: {}, slug: entry.slug, topLevel: Infinity, }; } if (!entry.slug || typeof this.cached.toc[this.permalink].items[entry.slug] !== 'undefined') { return; } const tocEntry = { level: entry.tag ? parseInt(entry.tag.substr(1), 10) : 1, link: '#' + entry.slug, title: entry.title, }; if (tocEntry.level < this.cached.toc[this.permalink].topLevel) { this.cached.toc[this.permalink].topLevel = tocEntry.level; } if (typeof this.cached.toc[this.permalink].items[entry.slug] === 'undefined') { this.cached.toc[this.permalink].items[entry.slug] = tocEntry; } } get toc() { if (!this.config.toc || !this.cached.toc) { return null; } return this.cached.toc[this.permalink]; } get breadcrumbs() { if (Array.isArray(this.cached.breadcrumbs)) { return this.cached.breadcrumbs; } return []; } set breadcrumbs(crumbs) { this.cached.breadcrumbs = crumbs; } /** * @description Creates an instance of the page * * @param [params={}] Params * @param [params.exclude] The props exclution list * @returns {Object} The page content * * @memberOf Page */ create(params) { const excludes = params.exclude || []; const props = Array.from(this.propsSet); excludes.forEach(prop => { if (props.includes(prop)) { props.splice(props.indexOf(prop), 1); } }); const data = { attributes: {}, body: '', date: null, path: null, permalink: '', }; props.forEach(prop => { if (prop === 'attributes') { Object.assign(data, this[prop]); // @ts-ignore } else if (this[prop] !== undefined) { // @ts-ignore data[prop] = this[prop]; } }); return data; } /** * @description Callback for the toc * * @param token The token object from markdownIt * @param token.attrs The attributes for the token ej. class * @param token.tag The tag for the token * @param info Title and slug * @param info.title The title text of the anchor * @param info.slug The slug for the anchor * @returns {void} * * @memberOf Page */ tocParserCallback(token, info) { let addToToc = true; if (typeof token.attrs !== 'undefined') { const classValue = token.attrGet('class'); if (classValue && classValue.includes('notoc')) { addToToc = true; } } if (addToToc) { this.toc = { items: {}, slug: info.slug, tag: token.tag, title: info.title, topLevel: 0, }; } } } const { max, min } = Math; /** * @description The database for each content container * * @export * @class Database */ class Database { /** * Creates an instance of Database. * @param {Nuxtent.Config.Build} build The build config * @param {string} build.contentDir The directory where the content is located * @param {string} build.ignorePrefix The string prefix for ignored files * @param {string} dirName The name of the folder for the content * @param {Nuxtent.Config.Content} dirOpts The content container options * * @memberOf Database */ constructor(build, dirName, dirOpts) { this.dirPath = ''; this.dirPath = join(build.contentDir, dirName); this.permalink = dirOpts.permalink; const fileStore = new Map(); const createMap = ({ index, fileName, section, }) => { const filePath = join(build.contentDir, dirName, section, fileName); const meta = { index, fileName, section, dirName, filePath }; return new Page(meta, dirOpts); }; /** * Checks if the file has an allowed extension and if we should ignore it * @param name The name of the file. */ function canProcesFile(name) { const fileTest = new RegExp(`\.(${build.contentExtensions.join('|')}$)`); return (name.search(fileTest) !== -1 && !name.startsWith(build.ignorePrefix)); } const globAndApply = (dirPath, nestedPath = sep) => { const stats = readdirSync(dirPath, { withFileTypes: true, }).reverse(); // posts more useful in reverse order stats.forEach((stat, index) => { const statPath = join(dirPath, stat.name); if (stat.isFile() && canProcesFile(stat.name)) { const fileData = { dirName: dirPath, fileName: stat.name, filePath: statPath, index, section: nestedPath, }; const page = createMap(fileData); fileStore.set(page.permalink, page); } else if (stat.isDirectory()) { globAndApply(statPath, join(nestedPath, stat.name)); } }); return fileStore; }; this.pagesMap = globAndApply(this.dirPath); if (dirOpts.breadcrumbs === true) { this.loadBreadcrumbs(dirOpts.page); } this.pagesArr = [...this.pagesMap.values()]; } /** * @param {string} permalink The permalink for the page * @public * @returns {boolean} Weather or not exist this page */ exists(permalink) { return this.pagesMap.has(permalink); } /** * @param permalink The permalink for the page * @param query parameters that the page might need * @returns The page data */ find(permalink, query) { const page = this.pagesMap.get(permalink); if (page) { return page.create(query); } return null; } /** * @param onlyArg Arguments for the search * @param query The query parameters * @returns An array of pages that mathced the args */ findOnly(onlyArg, query) { if (typeof onlyArg === 'string') { onlyArg = onlyArg.split(','); } const [startIndex, endIndex] = onlyArg; let currIndex = typeof startIndex === 'number' ? startIndex : max(0, parseInt(startIndex, 10)); if (Number.isNaN(currIndex)) { currIndex = 0; } const finalIndex = endIndex !== undefined ? min(typeof endIndex === 'number' ? endIndex : parseInt(endIndex, 10), this.pagesArr.length - 1) : null; if (!finalIndex) { return [this.pagesArr[currIndex].create(query)]; } const pages = []; if (finalIndex) { while (currIndex <= finalIndex) { pages.push(this.pagesArr[currIndex]); currIndex++; } } return pages.map(page => page.create(query)); } /** * @param {string} betweenStr String of the start and end index * @param {any} query query parameters * @returns {NuxtentPageData[]} An array with the search results */ findBetween(betweenStr, query) { const [currPermalink, numStr1, numStr2] = betweenStr.split(','); if (!this.pagesMap.has(currPermalink)) { return []; } const page = this.pagesMap.get(currPermalink); if (!page) { return []; } const currPage = page.create(query); if (!currPage.meta) { logger.warn('You should not exclude meta when querying between'); return []; } const { index } = currPage.meta; const total = this.pagesArr.length - 1; const num1 = parseInt(numStr1 || '0', 10); const num2 = numStr2 !== undefined ? parseInt(numStr2, 10) : null; if (num1 === 0 && num2 === 0) { return [currPage]; } let beforeRange; if (num1 === 0) { beforeRange = []; } else { beforeRange = [max(0, index - num1), max(min(index - 1, total), 0)]; } let afterRange; if (num2 === 0 || (!num2 && num1 === 0)) { afterRange = []; } else { afterRange = [min(index + 1, total), min(index + (num2 || num1), total)]; } const beforePages = this.findOnly(beforeRange, query); const afterPages = this.findOnly(afterRange, query); return [currPage, ...beforePages, ...afterPages]; } /** * @param query The query parameters * @returns The page array with all the content */ findAll(query) { return this.pagesArr.map(page => page.create(query)); } /** * @description Loads the breadcrumbs * * @param {string} dirPage The page directory * @private * @returns {void} * @memberOf Database */ loadBreadcrumbs(dirPage) { const target = dirPage .split('/') .slice(0, -1) .join('/'); for (const page of this.pagesMap.values()) { const hops = page.permalink.substr(target.length + 1).split('/'); const breadcrumbs = []; for (let i = 0; i < hops.length; i++) { let crumb = target; for (let j = 0; j < i; j++) { crumb += '/' + hops[j]; } if (crumb !== target) { const crumbPage = this.pagesMap.get(crumb); if (crumbPage) { breadcrumbs.push({ frontMatter: crumbPage.attributes, permalink: crumb, }); } } } if (breadcrumbs.length > 0) { page.breadcrumbs = breadcrumbs; this.pagesMap.set(page.permalink, page); } } } } const createParser = (markdownConfig) => { const config = markdownConfig.settings; if (typeof markdownConfig.extend === 'function') { markdownConfig.extend(config); } const parser = markdownIt(config); const plugins = markdownConfig.plugins || {}; Object.keys(plugins).forEach(plugin => { Array.isArray(plugins[plugin]) ? parser.use.apply(parser, plugins[plugin]) : parser.use(plugins[plugin]); }); if (typeof markdownConfig.customize === 'function') { markdownConfig.customize(parser); } return parser; }; /** * @description Nuxtent Config Module * * @export * @class NuxtentConfig */ class NuxtentConfig { /** * Creates an instance of NuxtentConfig. * @param {Object} [moduleOptions={}] The module of the config found on nuxt.config.js * @param {Object} options The nuxt options found on ModuleContainer.options * * @memberOf NuxtentConfig */ constructor(moduleOptions, options) { /** * @description The hostname to use the server * @type {String} * @memberOf NuxtentConfig */ this.host = process.env.NUXTENT_HOST || process.env.HOST || 'localhost'; /** * @description The port to use * @type {String} * @memberOf NuxtentConfig */ this.port = process.env.NUXTENT_PORT || process.env.PORT || '3000'; /** * @description The nuxt publicPath * @const {String} * @private * * @memberOf NuxtentConfig */ this.publicPath = '/_nuxt/'; this.markdownSettings = { html: true, linkify: true, preset: 'default', typographer: true, }; this.defaultMarkdown = { customize: undefined, parser: undefined, plugins: {}, settings: { ...this.markdownSettings }, use: [], }; this.defaultToc = { level: 2, permalink: true, permalinkClass: 'nuxtent-anchor', permalinkSymbol: '🔗', slugify, }; /** * @description * @type {NuxtentConfigContentGenereate} * * @memberOf NuxtentConfig */ this.requestMethods = [ 'getOnly', 'get', ['getAll', { query: { exclude: ['body'] } }], ]; this.routePaths = new Map(); this.assetMap = new Map(); this.database = new Map(); /** * @description An array of the static pages to render during generate * */ this.staticRoutes = []; /** * @description Is a static (--generate) build * */ this.isStatic = false; this.defaultBuild = { buildDir: 'content', componentsDir: 'components', contentDir: 'content', contentDirWebpackAlias: '~/components', contentExtensions: ['json', 'md', 'yaml', 'yml'], ignorePrefix: '-', loaderComponentExtensions: ['.vue', '.js', '.mjs', '.tsx'], }; this.build = { ...this.defaultBuild }; this.markdown = { ...{ settigs: this.markdownSettings }, ...this.defaultMarkdown, }; this.toc = { ...this.defaultToc }; this.api = { ...this.defaultApi }; this.defaultContent = { breadcrumbs: false, data: undefined, isPost: false, markdown: { ...this.defaultMarkdown }, method: [], page: '', permalink: ':slug', toc: { ...this.defaultToc }, }; this.defaultContentContainer = [ ['/', this.defaultContent], ]; this.content = this.defaultContentContainer; this.userConfig = { api: { ...this.defaultApi }, build: { ...this.defaultBuild }, content: [...this.defaultContentContainer], markdown: { ...this.defaultMarkdown }, toc: { ...this.defaultToc }, }; this.userConfig = defaultsDeep({}, this.userConfig, moduleOptions, options.nuxtent); if (options.build) { this.publicPath = options.build.publicPath || this.publicPath; } const srcDir = options.srcDir || '~/'; this.build.contentDir = join(srcDir, 'content'); this.build.componentsDir = join(srcDir, 'components'); } get defaultApi() { return { apiBrowserPrefix: this.publicPath + this.defaultBuild.buildDir, apiServerPrefix: '/content-api', baseURL: `http://${this.host}:${this.port}`, browserBaseURL: '', host: this.host, port: this.port, }; } /** * @description The public config object * * @readonly * * @memberOf NuxtentConfig */ get config() { return { api: this.api, build: this.build, content: this.content, markdown: this.markdown, toc: this.toc, }; } setApi(options) { this.host = options.host || this.host; this.port = options.port || this.port; process.env.NUXTENT_HOST = this.host; process.env.NUXTENT_PORT = this.port; this.api = defaultsDeep({}, this.defaultApi, this.userConfig.api); } async init(rootDir = '~/') { const userConfig = await this.loadNuxtentConfig(rootDir); let content; if (!Array.isArray(userConfig.content)) { content = [ ['/', { ...this.defaultContent, ...userConfig.content }], ]; } else { content = userConfig.content.map(([container, options]) => { return [container, defaultsDeep(options, this.defaultContent)]; }); } delete userConfig.content; defaultsDeep(this.userConfig, userConfig); this.api = defaultsDeep({}, this.defaultApi, this.userConfig.api); this.build = defaultsDeep({}, this.defaultBuild, this.userConfig.build); this.markdown = defaultsDeep({}, this.defaultMarkdown, this.userConfig.markdown); this.toc = defaultsDeep({}, this.defaultToc, this.userConfig.toc); this.content = content; this.markdown.parser = createParser(this.markdown); for (const [, contentEntry] of this.content) { contentEntry.markdown = defaultsDeep({}, contentEntry.markdown, this.markdown); contentEntry.markdown.parser = createParser(contentEntry.markdown); } this.buildContent(); return Promise.resolve(this); } /** * Load the nuxtent config file * @param {String} rootDir The root of the proyect */ async loadNuxtentConfig(rootDir) { const rootConfig = join(rootDir, 'nuxtent.config.js'); try { const configModule = await import(rootConfig); return configModule.default ? configModule.default : configModule; } catch (error) { if (error.code === 'MODULE_NOT_FOUND' && error.message.includes('nuxtent.config.js')) { logger.warn('nuxtent.config.js not found, fallingback to defaults'); return this.userConfig; } throw new Error(`[Invalid nuxtent configuration] ${error}`); } } /** * Formats the toc options * @param dirOpts The content definition * @returns The content with the toc formatted and the plugin inserted */ setTocOptions(dirOpts = this.defaultContent) { // End early if is falsey if (!dirOpts.toc) { dirOpts.toc = false; return dirOpts; } // Local var to set the config const tocConfig = this.defaultToc; if (typeof dirOpts.toc === 'number') { defaultsDeep(tocConfig, { level: dirOpts.toc, }); } else if (typeof dirOpts.toc === 'object') { defaultsDeep(tocConfig, dirOpts.toc); } else { dirOpts.toc = tocConfig; } // Setting toc dirOpts.toc = tocConfig; dirOpts.markdown.plugins.toc = [markdownItAnchor, tocConfig]; dirOpts.markdown.plugins.markdownItTocDoneRight = [ markdownItTocDoneRight, { containerClass: 'nuxtent-toc', slugify, }, ]; return dirOpts; } buildContent() { this.content.forEach(([, content]) => { const { page, permalink } = content; if (page) { this.routePaths.set(pathToName(page), permalink); } }); } /** * Intercept the nuxt routes and map them to nuxtent, usefull for date routes * @param {*} moduleContianer - A map with all the routes * @returns {void} */ interceptRoutes(moduleContianer) { const renameRoutePath = (route) => { if (!route.name) { return route; } const overwritedPath = this.routePaths.get(route.name); if (overwritedPath !== undefined) { const isOptional = route.path.match(/\?$/); // QUESTION: Why did we had this? // const match = overwritedPath.match(/\/(.*)/) // if (match) { // overwritedPath = match[1] // } logger.debug(`Renamed ${route.name} path ${route.path} > ${overwritedPath}`); route.path = isOptional ? overwritedPath + '?' : overwritedPath; } // else if (route.children) { // route.children.forEach(renameRoutePath) // } return route; }; if (typeof moduleContianer.extendRoutes !== 'function') { throw new Error('There is no "extendRoutes"'); } moduleContianer.extendRoutes((routes, resolve) => routes.map(renameRoutePath)); } createContentDatabase() { this.content.forEach(([dirName, content]) => { const db = new Database(this.build, dirName, content); this.database.set(dirName, db); }); return this.database; } } function queryParse(query) { const { exclude = '', args = '' } = query; return { args: args.split(','), exclude: exclude.split(','), }; } /** * Sends a single response for a single item on a content group * @param db The database for the content group */ function itemResponse(db, prefix, path) { return async (req, res) => { if (!req.url) { logger.error('There is no url on the request'); return send(res, 500, 'No url'); } if (!Object.keys(req.params).length) { res.writeHead(301, { Location: prefix + req.url.replace(/\/$/, '') }); return res.end(); } const cleanRegex = new RegExp(`(^${prefix})|[/?]$`, 'g'); const permalink = req.url.replace(cleanRegex, '').replace(path, ''); if (!db.exists(permalink)) { logger.warn({ code: 404, requested: req.params, url: req.url }); return send(res, 404, { controller: 'itemResponse', links: db.pagesArr.map(page => page.permalink), message: 'Not Found in ' + db.dirPath, path, prefix, requested: permalink, url: req.url, }); } try { const page = await db.find(permalink, queryParse(req.query)); return send(res, 200, page); } catch (e) { return send(res, 500, { controller: 'itemResponse', error: e, message: 'There is a server error', path, requested: permalink, }); } }; } /** * The fallback routing * @param database The whole map for all the content groups */ function indexResponse(database) { // Cache the paths const basePaths = Array.from(database.keys()); function findDatabase(path) { const result = { db: null, key: basePaths.find(value => { return value.indexOf(path) !== -1; }), }; if (result.key) { result.db = database.get(result.key) || null; } return result; } return async (req, res) => { const { key, db } = findDatabase(req.url || '/'); if (key && db) { const result = await Promise.resolve({ index: key, pages: Array.from(db.pagesMap.keys()), }); return send(res, 200, result); } logger.warn('Page ' + req.url + ' not found.'); return send(res, 404, { controller: 'indexResponse', endpoints: basePaths, message: 'Not found', requested: req.url, }); }; } /** * Makes the string with a optional trailing slash * @param path The path to set the optional slash */ function trailingOptional(path) { const p = path.replace(/(\/:\w+)/g, (m, slug) => { if (slug) { return `(${slug})`; } return m; }); if (p.endsWith('/')) { // Make optional the trailing slash return p.replace(/\/$/, '(/)'); } return p; } function indexHandler(db) { return async (req, res) => { const { between, only } = req.query; if (between) { return send(res, 200, await db.findBetween(between, queryParse(req.query))); } else if (only) { return send(res, 200, await db.findOnly(only, queryParse(req.query))); } else { return send(res, 200, await db.findAll(queryParse(req.query))); } }; } /** * Instantiates the rotuter instance * @param nuxtentConfig The nuxtent config */ function createRouter(nuxtentConfig) { const routes = []; // for multiple content types, show the content configuration in the root request if (!nuxtentConfig.database.has('/')) { // Cache the result const contentEndpoints = Array.from(nuxtentConfig.database.keys()); routes.push(get('/', (req, res) => send(res, 200, { endpoints: contentEndpoints, message: 'Found', }))); } for (let [path, database] of nuxtentConfig.database) { if (!path.startsWith('/')) { path = '/' + path; } // Generate the route match for each item const item = path + database.permalink; const linkMatch = item.match(/:[\w]+/); const index = linkMatch ? item.substr(0, linkMatch.index) : path; // // Instantate just once const handler = indexHandler(database); // // The index route routes.push(get(trailingOptional(index), handler)); // // If permaink base differs from the base route on the config then set both if (index !== path) { routes.push(get(path + '(/)', handler)); } routes.push(get(trailingOptional(item), itemResponse(database, nuxtentConfig.api.apiServerPrefix, path))); } routes.push(get('*', indexResponse(nuxtentConfig.database))); function nuxtentRouter(req, res, next) { return router(...routes)(req, res); } nuxtentRouter.namespaced = () => { const api = withNamespace(nuxtentConfig.api.apiServerPrefix); // const prefixedRoutes = routes.map((fn) => api(fn)) return router(api(...routes)); }; return nuxtentRouter; } /** * Builds a path for browsers * @param {string} permalink The Permalink * @param {string} section The section aka folder * @param {string} buildDir The container folder * // /content/<folder> * @returns {string} The path for the static json */ const buildPath = (permalink, section, buildDir) => { // browser build path // convert the permalink's slashes to periods so that // generated content is not overly nested const allButFirstSlash = /(?!^\/)\//g; const filePath = permalink.replace(allButFirstSlash, '.'); return join(buildDir, section, filePath) + '.json'; }; const asset = (object) => { // webpack asset const content = JSON.stringify(object, null, process.env.NODE_ENV === 'production' ? 0 : 2); return { source: () => content, size: () => content.length }; }; function addAssets(nuxtOpts, assetMap) { logger.debug('Adding routes as assets for production'); nuxtOpts.build.plugins.push({ apply(compiler) { compiler.plugin('emit', (compilation, cb) => { assetMap.forEach((page, path) => { compilation.assets[path] = asset(page); }); cb(); }); }, }); } /** * Sets the static routes to generate * @param {NuxtentConfig} nuxtentConfig The nuxtent config * @param {Map<any, any>} contentDatabase The Map serving as database * @returns {void} nothing */ function createStaticRoutes(nuxtentConfig) { const contentDatabase = nuxtentConfig.database; const content = nuxtentConfig.content; const buildDir = nuxtentConfig.build.buildDir; for (let [dirName, { page, method }] of content) { const db = contentDatabase.get(dirName); if (!db) { throw new Error(`Database not found ${dirName}`); } if (!page) { throw new Error('You must specify a page path ' + dirName); } if (!Array.isArray(method)) { // Compatibility fix method = [method]; } method.forEach(reqType => { const req = { args: [], method: '', query: {}, }; if (typeof reqType === 'string') { req.method = reqType; } else if (Array.isArray(reqType)) { const [reqMethod, reqOptions] = reqType; // @ts-ignore req.args = reqOptions.args || []; req.method = typeof reqMethod === 'string' ? reqMethod : reqMethod[0]; req.query = reqOptions.query ? reqOptions.query : {}; } switch (req.method) { case 'get': db.findAll(req.query).forEach(publicPage => { nuxtentConfig.staticRoutes.push(publicPage.permalink); nuxtentConfig.assetMap.set(buildPath(publicPage.permalink, dirName, buildDir), publicPage); }); break; case 'getAll': nuxtentConfig.assetMap.set(buildPath('_all', dirName, buildDir), db.findAll(req.query)); break; case 'getOnly': nuxtentConfig.assetMap.set(buildPath('_only', dirName, buildDir), db.findOnly(req.args, req.query)); break; default: logger.error(Error(`The ${req.method} is not supported for static builds.`)); } }); } } /** * @description The Nuxtent Module * @export */ async function nuxtentModule(moduleOptions) { const self = this; // Adding nuxtent files to watcher prop self.options.watch.push('~/nuxtent.config.js'); const nuxtentConfig = new NuxtentConfig(moduleOptions, self.options); // This section starts as early as possible nuxtentConfig.setApi(self.options); await nuxtentConfig.init(self.options.rootDir); nuxtentConfig.createContentDatabase(); // Add content API when running `nuxt` & `nuxt build` (development and production) const nuxtentRouter = createRouter(nuxtentConfig); this.addServerMiddleware({ handler: nuxtentRouter, path: nuxtentConfig.api.apiServerPrefix, }); this.options.build.templates.push({ dst: 'nuxtent-config.js', options: nuxtentConfig.config, src: require.resolve('./plugins/nuxtent-config.template'), }); // Generate Vue templates from markdown with components (*.comp.md) this.extendBuild((config, loaders) => { if (config.module) { config.module.rules.push({ test: /\.comp\.md$/, use: [ 'vue-loader', { loader: require.resolve('./loader'), options: { componentsDir: nuxtentConfig.build.componentsDir, content: nuxtentConfig.content, database: nuxtentConfig.database, extensions: nuxtentConfig.build.loaderComponentExtensions, }, }, ], }); } }); this.nuxt.hook('listen', async () => { nuxtentConfig.setApi(self.options); }); // Execute this just before everyting starts building self.nuxt.hook('build:before', async (builder, buildOptions) => { // Sets the static mode const isStatic = ((builder.bundleBuilder || {}).buildContext || {}).isStatic || process.static; if (typeof isStatic === 'undefined') { logger.error("Can't define if this is a static build or not"); } nuxtentConfig.isStatic = !!isStatic; logger.info(`Nuxtent Initiated in ${nuxtentConfig.isStatic ? 'static' : 'dynamic'} mode`); nuxtentConfig.interceptRoutes(self); // Add `$content` helper this.addPlugin({ src: require.resolve('./plugins/nuxtent-request'), }); // // Add Vue templates generated from markdown with components (*.comp.md) to output build this.addPlugin({ options: { components: generatePluginMap(nuxtentConfig.database), }, src: require.resolve('./plugins/nuxtent-components.template'), }); }); this.nuxt.hook('generate:before', async (nuxt, generateOptions) => { createStaticRoutes(nuxtentConfig); // Adds routes as assets so it may be procesed addAssets(this.options, nuxtentConfig.assetMap); // add the routes to the routes array on the nuxt config generateOptions.routes = generateOptions.routes ? generateOptions.routes.concat(nuxtentConfig.staticRoutes) : nuxtentConfig.staticRoutes; }); // // Execute this after all is builder this.nuxt.hook('build:done', async () => { logger.info(`Generating: ${String(nuxtentConfig.isStatic)}`); if (nuxtentConfig.isStatic) { logger.info('opening server connection'); const app = await micro( // @ts-ignore nuxtentRouter.namespaced());