@dinamomx/nuxtent
Version:
Seamlessly use content files in your Nuxt.js sites.
1,474 lines (1,464 loc) • 50.4 kB
JavaScript
/**
* 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());