@nuxt/press
Version:
Minimalist Markdown Publishing for Nuxt.js
1,820 lines (1,589 loc) • 59.9 kB
JavaScript
'use strict';
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
function _interopNamespace(e) {
if (e && e.__esModule) { return e; } else {
var n = {};
if (e) {
Object.keys(e).forEach(function (k) {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () {
return e[k];
}
});
});
}
n['default'] = e;
return n;
}
}
const defu = _interopDefault(require('defu'));
const os = _interopDefault(require('os'));
const consola = _interopDefault(require('consola'));
const path = require('path');
const path__default = _interopDefault(path);
const util = require('util');
const fs = require('fs-extra');
const fs__default = _interopDefault(fs);
const klaw = _interopDefault(require('klaw'));
const slug = _interopDefault(require('slug'));
const chokidar = _interopDefault(require('chokidar'));
const Markdown = _interopDefault(require('@nuxt/markdown'));
const graymatter = _interopDefault(require('gray-matter'));
const lodashTemplate = _interopDefault(require('lodash/template'));
const webpack = require('webpack');
const customContainer = _interopDefault(require('remark-container'));
const nodeRes = require('node-res');
const maxRetries = 0; // use 0 for debugging
const pool = new Array(os.cpus().length).fill(null);
class PromisePool {
constructor (jobs, handler) {
this.handler = handler;
this.jobs = jobs.map(payload => ({ payload }));
}
async done (before) {
if (before) {
await before();
}
await Promise.all(pool.map(() => {
return new Promise(async (resolve) => {
while (this.jobs.length) {
let job;
try {
job = this.jobs.pop();
await this.handler(job.payload);
} catch (err) {
if (job.retries && job.retries === maxRetries) {
consola.warn('Job exceeded retry limit: ', job);
} else {
consola.warn('Job failed: ', job, err);
}
}
}
resolve();
})
}));
}
}
function interopDefault (m) {
return m.default || m
}
// export async function _import(modulePath) {
// const sliceAt = resolve(this.options.rootDir).length
// return interopDefault(await import(`.${modulePath.slice(sliceAt)}`))
// }
async function importModule (modulePath) {
return interopDefault(await new Promise(function (resolve) { resolve(_interopNamespace(require(modulePath))); }))
}
const readFileAsync = util.promisify(fs__default.readFile);
const writeFileAsync = util.promisify(fs__default.writeFile);
const appendFileAsync = util.promisify(fs__default.appendFile);
const stat = util.promisify(fs__default.stat);
function join (...paths) {
return path__default.join(...paths.map(p => p.replace(/\//g, path__default.sep)))
}
function exists (...paths) {
return fs__default.existsSync(join(...paths))
}
function readFile (...paths) {
return readFileAsync(join(...paths), 'utf-8')
}
function writeFile (path, contents) {
return writeFileAsync(path, contents, 'utf-8')
}
function readJsonSync (...paths) {
return JSON.parse(fs__default.readFileSync(join(...paths)).toString())
}
function ensureDir (...paths) {
return fs__default.ensureDir(join(...paths))
}
function walk (root, validate, sliceAtRoot = false) {
const matches = [];
const sliceAt = (sliceAtRoot ? root : this.options.srcDir).length + 1;
if (validate instanceof RegExp) {
const pattern = validate;
validate = path => pattern.test(path);
}
return new Promise((resolve) => {
klaw(root)
.on('data', (match) => {
const path = match.path.slice(sliceAt);
if (validate(path) && !path.includes('node_modules')) {
matches.push(path);
}
})
.on('end', () => resolve(matches));
})
}
function removePrivateKeys (source, target = null) {
if (target === null) {
target = {};
}
for (const prop in source) {
if (prop === '__proto__' || prop === 'constructor') {
continue
}
const value = source[prop];
if ((!prop.startsWith('$')) && prop !== 'source') {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
target[prop] = {};
removePrivateKeys(value, target[prop]);
continue
}
target[prop] = value;
}
}
return target
}
async function loadConfig (rootId, config = {}) {
// Detect standalone mode
if (typeof config === 'string') {
config = { $standalone: config };
}
const jsConfigPath = join(this.options.rootDir, `nuxt.${rootId}.js`);
// JavaScript config has precedence over JSON config
if (exists(jsConfigPath)) {
config = defu(await importModule(jsConfigPath), config);
} else if (exists(`${jsConfigPath}on`)) {
config = defu(await importModule(`${jsConfigPath}on`), config);
}
this.options[rootId] = defu(config, this.options[rootId] || {});
this[`$${rootId}`] = this.options[rootId];
this[`$${rootId}`].$buildDir = this.options.buildDir;
return this[`$${rootId}`]
}
async function updateConfig (rootId, obj) {
// Copy object and remove props that start with $
// (These can be used for internal template pre-processing)
obj = removePrivateKeys(obj);
// If .js config found, do nothing
// we only update JSON files, not JavaScript
if (exists(join(this.options.rootDir, `nuxt.${rootId}.js`))) {
const config = await importModule(join(this.options.rootDir, `nuxt.${rootId}.js`));
await ensureDir(join(this.options.buildDir, 'press'));
await writeFile(join(this.options.buildDir, 'press', 'config.json'), JSON.stringify(config, null, 2));
return
}
const path = join(this.options.rootDir, `nuxt.${rootId}.json`);
if (!exists(path)) {
await fs.writeJson(path, obj, { spaces: 2 });
return
}
let json = {};
try {
const jsonFile = await readFile(path);
if (!jsonFile) {
consola.warn(`Config file ${path} was empty`);
} else {
json = JSON.parse(jsonFile);
}
} catch (err) {
consola.error('An error occurred updating NuxtPress config:');
consola.fatal(err);
process.exit();
}
const updated = defu(json, obj);
await writeFile(path, JSON.stringify(updated, null, 2));
await ensureDir(join(this.options.buildDir, 'press'));
await writeFile(
join(this.options.buildDir, 'press', 'config.json'),
JSON.stringify(updated, null, 2)
);
}
function routePath (routePath, prefix) {
if (prefix && routePath.startsWith(prefix)) {
routePath = routePath.substr(prefix.length);
}
if (routePath.endsWith('/index')) {
return routePath.slice(0, routePath.indexOf('/index'))
}
if (routePath === 'index') {
return ''
}
return routePath
}
function stripP (str) {
str = str.replace(/^<p>/, '');
return str.replace(/<\/p>$/, '')
}
function trimEnd (str, chr = '') {
if (!chr) {
return str.trimEnd()
}
return str.replace(new RegExp(`${chr}+$`), '')
}
const trimSlash = str => trimEnd(str, '/');
const escapeREs = {};
function escapeChars (str, chars = '"') {
if (Array.isArray(chars)) {
chars = chars.join();
}
if (!escapeREs[chars]) {
escapeREs[chars] = new RegExp(`([${chars}])`, 'g');
}
const escapeRE = escapeREs[chars];
return str.replace(escapeRE, '\\$1')
}
function slugify (str) {
return slug(str, { lower: true })
}
function markdownToText (markdown) {
// fully strip code blocks
markdown = markdown.replace(/<code[^>]*>[\s\S]*?<\/code>/gmi, '');
// strip other html tags
markdown = markdown.replace(/<\/?[^>]+(>|$)/g, '');
return markdown
}
function resolve (...paths) {
return path.resolve(__dirname, join(...paths))
}
// BLOG MODE
// Markdown files are loaded from the blog/ directory.
// Configurable via press.blog.dir
async function parseEntry (sourcePath, processor) {
// TODO just completely rewrite this function, please
const parse = this.$press.blog.source;
const fileName = path.parse(sourcePath).name;
const raw = await readFile(this.options.srcDir, sourcePath);
const metadata = parse.metadata.call(this, fileName, raw);
if (metadata instanceof Error) {
consola.warn(metadata.message);
return
}
const title = metadata.title || parse.title.call(this, raw);
const slug = metadata.slug;
const body = await parse.markdown.call(this, metadata.content || raw.substr(raw.indexOf('#')), processor);
const published = metadata.published;
delete metadata.content;
const source = { ...metadata, body, title, published };
if (slug) {
source.path = `${this.$press.blog.prefix}${slug}`;
} else {
source.path = `${this.$press.blog.prefix}${this.$press.blog.source.path.call(this, fileName, source)}`;
}
source.type = 'entry';
source.id = this.$press.blog.source.id.call(this, source);
if (this.options.dev) {
source.src = sourcePath;
}
return source
}
function addArchiveEntry (archive, entry) {
const year = entry.published.getFullYear();
const month = (entry.published.getMonth() + 1)
.toString()
.padStart(2, '0');
if (!archive[year]) {
archive[year] = {};
}
if (!archive[year][month]) {
archive[year][month] = [];
}
archive[year][month].push(entry);
}
async function generateFeed (options, entries) {
let srcPath = join(this.options.srcDir, 'press', 'blog', 'static', 'rss.xml');
if (!exists(srcPath)) {
srcPath = resolve('blueprints', 'blog', 'templates', 'static', 'rss.xml');
}
const template = lodashTemplate(await readFile(srcPath));
return template({ blog: options, entries })
}
function sortEntries (entries) {
return entries
.map(e => ({ ...e, $published: new Date(e.published) }))
.sort((a, b) => (b.$published - a.$published))
.map(({ $published, ...e }) => e)
}
async function data () {
const srcRoot = join(
this.options.srcDir,
this.$press.blog.dir
);
const sources = {};
const archive = {};
const jobs = await walk.call(this, srcRoot, (path) => {
if (path.startsWith('pages')) {
return false
}
return /\.md$/.test(path)
});
const mdProcessor = await this.$press.blog.source.processor();
const handler = async (path) => {
const entry = await parseEntry.call(this, path, mdProcessor);
if (!entry) {
return
}
addArchiveEntry(archive, entry);
sources[entry.path] = entry;
};
const queue = new PromisePool(jobs, handler);
await queue.done();
const index = sortEntries(Object.values(sources)).slice(0, 10)
.map((entry, i) => {
// Remove body from all but the latest entry
if (i === 0) {
return entry
}
return (({ body, ...rest }) => rest)(entry)
});
for (const year in archive) {
for (const month in archive[year]) {
archive[year][month] = sortEntries(archive[year][month])
.map(({ body, id, ...entry }) => entry);
}
}
if (typeof this.$press.blog.feed.path === 'function') {
this.$press.blog.feed.path = this.$press.blog.feed.path(this.$press.blog);
}
return {
static: {
[this.$press.blog.feed.path]: (
await generateFeed.call(this, this.$press.blog, index)
)
},
topLevel: {
index,
archive
},
sources
}
}
const blog = {
// Include data loader
data,
// Enable blog if srcDir/blog/ exists
enabled (options) {
if (options.$standalone === 'blog') {
options.blog.dir = '';
options.blog.prefix = '/';
if (exists(this.options.srcDir, 'entries')) {
options.blog.dir = 'entries';
}
if (exists(this.options.srcDir, 'posts')) {
options.blog.dir = 'posts';
}
return true
}
return exists(this.options.srcDir, options.blog.dir)
},
templates: {
'archive': 'pages/archive.vue',
'entry': 'components/entry.vue',
'index': 'pages/index.vue',
'layout': 'layouts/blog.vue',
'sidebar': 'components/sidebar.vue',
'head': 'head.js',
'feed': 'static/rss.xml'
},
routes (templates) {
return [
{
name: 'blog_index',
path: this.$press.blog.prefix,
component: templates.index
},
{
name: 'blog_archive',
path: `${this.$press.blog.prefix}archive`,
component: templates.archive
}
]
},
generateRoutes (data, prefix, staticRoot) {
return [
...Object.keys(data.topLevel).map(async route => ({
route: prefix(routePath(route)),
payload: await importModule(join(staticRoot, 'blog', `${trimSlash(route)}.json`))
})),
...Object.keys(data.sources).map(async route => ({
route: routePath(route),
payload: await importModule(join(staticRoot, 'sources', route))
}))
]
},
serverMiddleware ({ options, rootId, id }) {
const { index, archive } = typeof options.blog.api === 'function'
? options.blog.api.call(this, { rootId, id })
: options.blog.api;
return [
(req, res, next) => {
if (req.url.startsWith('/api/blog/index')) {
index.call(this, req, res, next);
} else if (req.url.startsWith('/api/blog/archive')) {
archive.call(this, req, res, next);
} else {
next();
}
}
]
},
build: {
before () {
this.$addPressTheme('blueprints/blog/theme.css');
},
async compile ({ rootId }) {
await updateConfig.call(this, rootId, { blog: this.$press.blog });
},
async done () {
if (this.nuxt.options.dev) {
let updatedEntry;
const mdProcessor = await this.$press.blog.source.processor();
const watchDir = this.$press.blog.dir
? `${this.$press.blog.dir}/`
: this.$press.blog.dir;
chokidar.watch([
`${watchDir}*.md`,
`${watchDir}**/*.md`
], {
cwd: this.options.srcDir,
ignoreInitial: true,
ignored: 'node_modules/**/*'
})
.on('change', async (path) => {
updatedEntry = await parseEntry.call(this, path, mdProcessor);
this.$pressSourceEvent('change', 'blog', updatedEntry);
})
.on('add', async (path) => {
updatedEntry = await parseEntry.call(this, path, mdProcessor);
this.$pressSourceEvent('add', 'blog', updatedEntry);
})
.on('unlink', path => this.$pressSourceEvent('unlink', 'blog', { path }));
}
}
},
options: {
dir: 'blog',
prefix: '/blog/',
// Blog metadata
title: 'A NuxtPress Blog',
links: [],
icons: [],
feed: {
// Replace with final link to your feed
link: 'https://nuxt.press',
// The <description> RSS tag
description: 'A NuxtPress Blog Description',
// Used in RFC4151-based RSS feed entry tags
tagDomain: 'nuxt.press',
// Final RSS path
path: options => `${options.prefix}rss.xml`
},
// If in Nuxt's SPA mode, setting custom API
// handlers also disables bundling of index.json
// and source/*.json files into the static/ folder
api ({ rootId }) {
const cache = {};
const rootDir = join(this.options.buildDir, rootId, 'static');
return {
index: (req, res, next) => {
if (this.options.dev || !cache.index) {
cache.index = readJsonSync(rootDir, 'blog', 'index.json');
}
res.json(cache.index);
},
archive: (req, res, next) => {
if (this.options.dev || !cache.archive) {
cache.archive = readJsonSync(rootDir, 'blog', 'archive.json');
}
res.json(cache.archive);
}
}
},
source: {
processor () {
return new Markdown({
toc: false,
sanitize: false
})
},
markdown (source, processor) {
return processor.toMarkup(source).then(({ html }) => html)
},
// metadata() parses the starting block of text in a Markdown source,
// considering the first and (optionally) second lines as
// publishing date and summary respectively
metadata (fileName, source) {
if (source.trimLeft().startsWith('---')) {
const { content, data } = graymatter(source);
if (data.date) {
data.published = new Date(Date.parse(data.date));
}
delete data.date;
return { ...data, content }
}
let published;
published = source.substr(0, source.indexOf('#')).trim();
published = Date.parse(published);
if (isNaN(published)) {
return new Error(`Missing or invalid publication date in ${fileName} -- see documentation at https://nuxt.press`)
}
return {
published: new Date(published)
}
},
// path() determines the final URL path of a Markdown source
// In `blog` mode, the default format is /YYYY/MM/DD/<slug>
path (fileName, { title, published }) {
const slug = slugify(title || fileName);
const date = published.toString().split(/\s+/).slice(1, 4).reverse();
return `${date[0]}/${date[2].toLowerCase()}/${date[1]}/${slug}/`
},
// id() determines the unique RSS ID of a Markdown source
// Default RFC4151-based format is used. See https://tools.ietf.org/html/rfc4151
id ({ published, path }) {
const tagDomain = this.$press.blog.feed.tagDomain;
const year = published.getFullYear();
return `tag:${tagDomain},${year}:${path}`
},
// title() determines the title of a Markdown source
title (body) {
const titleMatch = body.substr(body.indexOf('#')).match(/^#\s+(.*)/);
return titleMatch ? titleMatch[1] : ''
}
}
}
};
// PAGES
// Markdown files under pages/ are treated as individual
// Nuxt routes using the ejectable page template
// Custom pages can be added by ensuring there's
// a .vue file matching the .md file. The processed
// contents of the .md file become available as $page
// in the custom Vue component for the page
async function loadPage (pagePath, mdProcessor) {
const sliceAt = this.options.dir.pages.length;
const { name, dir } = path.parse(pagePath);
const path$1 = `${dir.slice(sliceAt)}/${name}/`;
let body = await readFile(this.options.srcDir, pagePath);
const metadata = await this.$press.common.source.metadata.call(this, body);
const titleMatch = body.match(/^#\s+(.*)/);
let title = titleMatch ? titleMatch[1] : '';
// Overwrite body if given as metadata
if (metadata.body) {
body = metadata.body;
}
// Overwrite title if given as metadata
if (metadata.title) {
title = metadata.title;
}
body = await this.$press.common.source.markdown.call(this, body, mdProcessor);
title = stripP(await this.$press.common.source.markdown.call(this, title, mdProcessor));
const src = pagePath.slice(this.options.srcDir.length + 1);
return {
...metadata,
body,
title,
path: path$1,
src: this.options.dev ? src : undefined
}
}
async function data$1 () {
const pagesRoot = join(
this.options.srcDir,
this.options.dir.pages
);
if (!exists(pagesRoot)) {
return {}
}
const pages = {};
const mdProcessor = await this.$press.common.source.processor();
const queue = new PromisePool(
await walk.call(this, pagesRoot, /\.md$/),
async (path) => {
// Somehow eslint doesn't detect func.call(), so:
// eslint-disable-next-line no-use-before-define
const page = await loadPage.call(this, path, mdProcessor);
pages[page.path] = page;
}
);
await queue.done();
return { sources: pages }
}
const common = {
// Include data loader
data: data$1,
// Main blueprint, enabled by default
enabled: () => true,
templates: {
// [type?:eject_key]: 'path in templates/'
'middleware': 'middleware/press.js',
'nuxt-static': 'components/nuxt-static.js',
'press-link': 'components/press-link.js',
'nuxt-template': 'components/nuxt-template.js',
'observer': 'components/observer.js',
'plugin': 'plugins/press.js',
'plugin:scroll': 'plugins/scroll.client.js',
'source': 'pages/source.vue'
// 'utils': 'utils.js'
},
routes (templates) {
const $press = this.$press;
// always add '/' to support pages
const prefixes = ['/'];
for (const blueprint of ['blog', 'docs', 'slides']) {
if ($press[blueprint]) {
const prefix = $press[blueprint].prefix || '/';
if (!prefixes.includes(prefix)) {
prefixes.push(prefix);
}
}
}
const routes = [];
for (let prefix of prefixes) {
prefix = trimEnd(prefix, '/');
let prefixName = '';
if (prefix) {
prefixName = `-${prefix.replace('/', '')}`;
if (prefix[0] !== '/') {
prefix = `/${prefix}`;
}
}
const hasLocales = !!$press.i18n;
if (hasLocales) {
routes.push({
name: `source-locale${prefixName}`,
path: `${prefix}/:locale/:source(.*)`,
component: templates.source
});
routes.push({
name: `source${prefixName}`,
path: `${prefix}/`,
meta: { sourceParam: true },
component: templates.source
});
continue
}
routes.push({
name: `source${prefixName}`,
path: `${prefix}/:source(.*)`,
component: templates.source
});
}
return routes
},
generateRoutes (data, _, staticRoot) {
if (!data || !data.sources) {
return []
}
return Object.keys(data.sources).map(async (route) => {
let routePath = route;
if (routePath.endsWith('/index')) {
routePath = routePath.slice(0, route.indexOf('/index'));
}
if (routePath === '') {
routePath = '/';
}
return {
route: routePath,
payload: await importModule(`${staticRoot}/sources${route}`)
}
})
},
serverMiddleware ({ options, rootId, id }) {
const { source } = typeof options.common.api === 'function'
? options.common.api.call(this, { rootId, id })
: options.common.api;
return [
(req, res, next) => {
if (req.url.startsWith('/api/source/')) {
const sourcePath = trimEnd(req.url.slice(12), '/');
source.call(this, sourcePath, req, res, next);
} else {
next();
}
}
]
},
build: {
async before ({ options }) {
this.options.build.plugins.unshift(new webpack.IgnorePlugin(/\.md$/));
const pagesDir = join(this.options.srcDir, this.options.dir.pages);
if (!exists(pagesDir)) {
this.$press.$placeholderPagesDir = pagesDir;
await ensureDir(pagesDir);
}
},
async done () {
if (this.nuxt.options.dev) {
chokidar.watch(['pages/*.md'], {
cwd: this.options.srcDir,
ignoreInitial: true,
ignored: 'node_modules/**/*'
})
.on('change', async path => this.$pressSourceEvent('change', await loadPage.call(this, path)))
.on('add', async path => this.$pressSourceEvent('add', await loadPage.call(this, path)))
.on('unlink', path => this.$pressSourceEvent('unlink', { path }));
}
if (this.$press.$placeholderPagesDir) {
await fs.remove(this.$press.$placeholderPagesDir);
}
}
},
options: {
api ({ rootId }) {
const rootDir = join(this.options.buildDir, rootId, 'static');
const sourceCache = {};
return {
source (source, _, res, next) {
if (this.options.dev || !sourceCache[source]) {
let sourceFile = join(rootDir, 'sources', `${source}/index.json`);
if (!exists(sourceFile)) {
sourceFile = join(rootDir, 'sources', `${source}.json`);
if (!exists(sourceFile)) {
const err = new Error('NuxtPress: source not found');
err.statusCode = 404;
next(err);
return
}
}
sourceCache[source] = readJsonSync(sourceFile);
}
res.json(sourceCache[source]);
}
}
},
source: {
processor () {
return new Markdown({
toc: false,
sanitize: false
})
},
markdown (source, processor) {
return processor.toMarkup(source).then(({ html }) => html)
},
metadata (source) {
if (source.trimLeft().startsWith('---')) {
const { content: body, data } = graymatter(source);
return { ...data, body }
}
return {}
},
title (body) {
return body.substr(body.indexOf('#')).match(/^#\s+(.*)/)[1]
}
}
}
};
const indexKeys = ['index', 'readme'];
const templates = {
header: 'components/header.vue',
home: 'components/home.vue',
layout: 'layouts/docs.vue',
mixin: 'mixins/docs.js',
'nav-link': 'components/nav-link.vue',
'outbound-link-icon': 'components/outbound-link-icon.vue',
plugin: 'plugins/press.docs.js',
sidebar: 'components/sidebar.vue',
'sidebar-section': 'components/sidebar-section.vue',
'sidebar-sections': 'components/sidebar-sections.vue',
topic: 'components/topic.vue',
utils: 'utils.js'
};
const defaultDir = 'docs';
const defaultPrefix = '/docs/';
const maxSidebarDepth = 2;
const defaultMetaSettings = {
sidebarDepth: 1
};
const normalizePath = str => str.endsWith('/') || str.includes('/#') ? str : `${str}/`;
function normalizePaths (paths) {
if (Array.isArray(paths)) {
for (const key in paths) {
paths[key] = normalizePaths(paths[key]);
}
return paths
}
if (typeof paths === 'object') {
if (paths.children) {
paths.children = normalizePaths(paths.children);
return paths
}
for (const key in paths) {
const normalizedKey = normalizePath(key);
paths[normalizedKey] = normalizePaths(paths[key]);
if (key !== normalizedKey) {
delete paths[key];
}
}
return paths
}
return normalizePath(paths)
}
function tocToTree (toc) {
const sections = [undefined, [], [], [], [], [], []];
let prevLevel = 0;
for (const [level, name, url] of toc) {
if (level < prevLevel) {
for (;prevLevel > level; prevLevel--) {
const currentLevel = prevLevel - 1;
const lastIndex = sections[currentLevel].length - 1;
if (lastIndex > -1) {
sections[currentLevel][lastIndex][3] = sections[prevLevel];
} else {
sections[currentLevel] = sections[prevLevel];
}
sections[prevLevel] = [];
}
}
sections[level].push([level, name, url]);
prevLevel = level;
}
for (let level = sections.length - 1; level > 1; level--) {
if (!sections[level].length) {
continue
}
let lowerLevel = level;
let lastIndex = -1;
while (lastIndex < 0 && lowerLevel > 1) {
lowerLevel = lowerLevel - 1;
if (sections[lowerLevel]) {
lastIndex = sections[lowerLevel].length - 1;
}
}
if (lastIndex > -1) {
sections[lowerLevel][lastIndex][3] = sections[level];
} else {
sections[lowerLevel] = sections[level];
}
sections[level] = [];
}
return sections[1]
}
function createSidebarFromToc (path, title, page, startDepth = 0) {
const sidebar = [];
if (!page) {
return sidebar
}
// eslint-disable-next-line prefer-const
let { meta, toc = [] } = page;
if (meta.title) {
title = meta.title;
} else if (meta.home) {
title = 'Home';
}
// If the page has no toc, add an item
let sidebarAddPage = !toc.length;
if (!sidebarAddPage && title) {
const firstToc = toc[0];
// if the first item in the toc is not a level 1
// and a title has been set, add an item for the page
if (firstToc[0] !== 1) {
sidebarAddPage = true;
}
// always (re-)set the title, this is so meta.title
// can overwrite the page title in the sidebar
if (firstToc[0] === 1) {
toc[0][1] = title;
}
}
if (sidebarAddPage) {
sidebar.push([1, title || path, normalizePath(path)]);
}
// normalize skip levels to array
let sidebarSkipLevels = meta.sidebarSkipLevels;
if (!sidebarSkipLevels && meta.sidebarSkipLevel) {
sidebarSkipLevels = [meta.sidebarSkipLevel];
}
if (sidebarSkipLevels) {
const skipCount = meta.sidebarSkipCount || Infinity;
let skipCounter = 0;
toc = toc.filter(([level]) => {
if (!sidebarSkipLevels.includes(level)) {
return true
}
if (skipCounter < skipCount) {
skipCounter++;
return false
}
return true
});
}
sidebar.push(...toc.map(([level, name, url]) => [level + startDepth, name, normalizePath(url)]));
return tocToTree(sidebar)
}
function createSidebar (sidebarConfig, pages, routePrefix) {
const sidebar = [];
for (let sourcePath of sidebarConfig) {
let title;
if (Array.isArray(sourcePath)) {
[sourcePath, title] = sourcePath;
}
if (typeof sourcePath === 'object') {
const title = sourcePath.title;
const children = [];
if (sourcePath.children) {
for (sourcePath of sourcePath.children) {
sourcePath = normalizePath(sourcePath.replace(/.md$/i, ''));
const pagePath = `${routePrefix}${sourcePath}`;
children.push(...createSidebarFromToc(sourcePath, undefined, pages[pagePath], 1));
}
}
sidebar.push([1, title, '', children]);
continue
}
const pagePath = `${routePrefix}${sourcePath}`;
sidebar.push(...createSidebarFromToc(sourcePath, title, pages[pagePath]));
}
return sidebar
}
// DOCS MODE
// Markdown files can be placed in
// Nuxt's srcDir or the docs/ directory.
// Directory configurable via press.docs.dir
const isIndexRE = new RegExp(`(^|/)(${indexKeys.join('|')})$`, 'i');
async function parsePage (sourcePath, mdProcessor) {
const src = sourcePath;
let raw = await readFile(this.options.srcDir, sourcePath);
const { name: fileName } = path__default.parse(sourcePath);
let meta;
if (raw.trimLeft().startsWith('---')) {
const { content, data } = graymatter(raw);
raw = content;
meta = defu(data, defaultMetaSettings);
if (meta.sidebar === 'auto') {
meta.sidebarDepth = maxSidebarDepth;
}
} else {
meta = defu({}, defaultMetaSettings);
}
const { toc, html: body } = await this.$press.docs.source.markdown.call(this, raw, mdProcessor);
const title = await this.$press.docs.source.title.call(this, fileName, raw, toc);
sourcePath = sourcePath.substr(0, sourcePath.lastIndexOf('.')).replace(isIndexRE, '') || 'index';
const urlPath = sourcePath === 'index' ? '/' : `/${sourcePath.replace(/\/index$/, '')}/`;
let locale = '';
const locales = this.$press.i18n && this.$press.i18n.locales;
if (locales) {
({ code: locale } = locales.find(l => l.code === sourcePath || sourcePath.startsWith(`${l.code}/`)) || {});
}
const source = {
type: 'topic',
locale,
title,
body,
path: `${trimSlash(this.$press.docs.prefix)}${urlPath}`,
...this.options.dev && { src }
};
return {
toc: toc.map((h) => {
if (h[2].substr(0, 1) === '#') {
h[2] = `${urlPath}${h[2]}`;
}
return h
}),
meta,
source
}
}
async function data$2 ({ options: { docs: docOptions } }) {
let srcRoot = join(
this.options.srcDir,
this.$press.docs.dir
);
if (!exists(srcRoot)) {
srcRoot = this.options.srcDir;
}
const jobs = await walk.call(this, srcRoot, (path) => {
if (path.startsWith('pages')) {
return false
}
return path.endsWith('.md')
});
const sources = {};
const $pages = {};
const mdProcessor = await this.$press.docs.source.processor();
const prefix = trimSlash(this.$press.docs.prefix);
const handler = async (path) => {
const { toc, meta, source } = await parsePage.call(this, path, mdProcessor);
const sourcePath = routePath(source.path, prefix) || '/';
this.nuxt.callHook('press:docs:page', {
toc,
meta,
sourcePath,
source
});
$pages[sourcePath] = {
meta,
toc
};
sources[sourcePath] = source;
};
const queue = new PromisePool(jobs, handler);
await queue.done();
const options = {
$pages,
$prefix: trimSlash(this.$press.docs.prefix || '')
};
const press = this.$press;
// TODO: should this logic need to be moved somewhere else
options.$asJsonTemplate = new Proxy({}, {
get (_, prop) {
let val = options[prop] || options[`$${prop}`] || docOptions[prop];
if (prop === 'nav') {
val = val.map((link) => {
const keys = Object.keys(link);
if (keys.length > 1) {
return link
} else {
return {
text: keys[0],
link: Object.values(link)[0]
}
}
});
} else if (prop === 'pages') {
val = {};
// only export the minimum of props we need
for (const path in options.$pages) {
const page = options.$pages[path];
const [toc = []] = page.toc || [];
val[path] = {
title: page.meta.title || toc[1] || '',
hash: (toc[2] && toc[2].substr(path.length)) || '',
meta: page.meta
};
}
} else if (prop === 'sidebars') {
let createSidebarForEachLocale = false;
const hasLocales = !!(press.i18n && press.i18n.locales);
let sidebarConfig = press.docs.sidebar;
if (typeof sidebarConfig === 'string') {
sidebarConfig = [sidebarConfig];
}
if (Array.isArray(sidebarConfig)) {
createSidebarForEachLocale = hasLocales;
sidebarConfig = {
'/': sidebarConfig
};
}
let routePrefixes = [''];
if (createSidebarForEachLocale) {
routePrefixes = press.i18n.locales.map(locale => `/${typeof locale === 'object' ? locale.code : locale}`);
}
const sidebars = {};
for (const routePrefix of routePrefixes) {
for (const path in sidebarConfig) {
const normalizedPath = normalizePaths(path);
const sidebarPath = `${routePrefix}${normalizedPath}`;
sidebars[sidebarPath] = createSidebar(
sidebarConfig[path].map(normalizePaths),
options.$pages,
routePrefix
);
}
}
for (const path in options.$pages) {
const page = options.$pages[path];
if (page.meta && page.meta.sidebar === 'auto') {
sidebars[path] = tocToTree(page.toc);
}
}
val = sidebars;
}
if (val) {
const jsonStr = JSON.stringify(val, null, 2);
return escapeChars(jsonStr, '`')
}
return val
}
});
return {
options,
sources
}
}
const docs = {
data: data$2,
templates,
enabled (options) {
if (options.$standalone === 'docs') {
options.docs.dir = options.docs.dir || '';
options.docs.prefix = options.docs.prefix || '/';
return true
}
if (options.docs.dir === undefined) {
options.docs.dir = defaultDir;
}
if (!options.docs.prefix) {
options.docs.prefix = defaultPrefix;
}
return exists(this.options.srcDir, options.docs.dir)
},
async generateRoutes (data, prefix, staticRoot) {
let home = '/';
if (this.$press.i18n) {
home = `/${this.$press.i18n.locales[0].code}`;
}
return [
{
route: prefix(''),
payload: await importModule(`${staticRoot}/sources${this.$press.docs.prefix}${home}`)
},
...Object.values(data.sources).map(async ({ path }) => ({
route: routePath(path),
payload: await importModule(`${staticRoot}/sources/${path}`)
}))
]
},
async ready () {
if (this.$press.docs.search) {
let languages = [];
if (this.$press.i18n && this.$press.i18n.locales) {
languages = this.$press.i18n.locales.map(l => l.code);
}
await this.requireModule({
src: '@nuxtjs/lunr-module',
options: {
globalComponent: false,
languages
}
});
let documentIndex = 1;
this.nuxt.hook('press:docs:page', ({ toc, source }) => {
this.nuxt.callHook('lunr:document', {
locale: source.locale,
document: {
id: documentIndex,
title: source.title,
body: markdownToText(source.body)
},
meta: {
to: source.path,
title: source.title
}
});
documentIndex++;
});
}
},
build: {
before () {
this.$addPressTheme('blueprints/docs/theme.css');
},
async compile ({ rootId }) {
await updateConfig.call(this, rootId, { docs: this.$press.docs });
},
done ({ rootId }) {
if (this.nuxt.options.dev) {
const watchDir = this.$press.docs.dir
? `${this.$press.docs.dir}/`
: this.$press.docs.dir;
const updateDocs = async (path) => {
const docsData = await data$2.call(this, { options: this.$press });
if (docsData.options) {
Object.assign(this.$press.docs, docsData.options);
}
await updateConfig.call(this, rootId, { docs: docsData.options });
const source = Object.values(docsData.sources).find(s => s.src === path) || {};
this.$pressSourceEvent('reload', 'docs', { data: docsData, source });
};
chokidar.watch([
`${watchDir}*.md`,
`${watchDir}**/*.md`
], {
cwd: this.options.srcDir,
ignoreInitial: true,
ignored: 'node_modules/**/*'
})
.on('change', updateDocs)
.on('add', updateDocs)
.on('unlink', updateDocs);
}
}
},
options: {
dir: undefined,
prefix: undefined,
title: 'My Documentation',
search: true,
nav: [],
source: {
processor () {
return new Markdown({
toc: true,
sanitize: false,
extend ({ layers }) {
layers['remark-container'] = customContainer;
}
})
},
markdown (source, processor) {
return processor.toMarkup(source)
},
title (fileName, body, toc) {
if (toc && toc[0]) {
return toc[0][1]
}
const titleMatch = body.substr(body.indexOf('#')).match(/^#+\s+(.*)/);
if (titleMatch) {
return titleMatch[1]
}
return fileName
}
}
}
};
// SLIDES MODE
// Markdown files are loaded from the slides/ directory.
// Configurable via press.slides.dir
async function parseSlides (sourcePath, mdProcessor) {
const raw = await readFile(this.options.srcDir, sourcePath);
let slides = [];
let c;
let i = 0;
let s = 0;
let escaped = false;
for (i = 0; i < raw.length; i++) {
c = raw.charAt(i);
if (c === '\n') {
if (raw.charAt(i + 1) === '`' && raw.slice(i + 1, i + 4) === '```') {
escaped = !escaped;
i = i + 3;
continue
}
if (escaped) {
continue
}
if (raw.charAt(i + 1) === '#') {
if (raw.slice(i + 2, i + 3) !== '#') {
slides.push(raw.slice(s, i).trimStart());
s = i;
}
}
}
}
slides.push(slides.length > 0
? raw.slice(s, i).trimStart()
: raw
);
slides = await Promise.all(
slides.filter(Boolean).map((slide) => {
return this.$press.slides.source.markdown.call(this, slide, mdProcessor)
})
);
const source = { slides, type: 'slides', ...this.options.dev && { src: sourcePath } };
source.path = this.$press.slides.source.path
.call(this, path.parse(sourcePath).name.toLowerCase());
if (this.options.dev) {
source.src = sourcePath;
}
return source
}
async function data$3 () {
const sources = {};
const srcRoot = join(
this.options.srcDir,
this.$press.slides.dir
);
const jobs = await walk.call(this, srcRoot, (path) => {
if (path.startsWith('pages')) {
return false
}
return /\.md$/.test(path)
});
const mdProcessor = await this.$press.slides.source.processor();
const handler = async (path) => {
const slides = await parseSlides.call(this, path, mdProcessor);
sources[slides.path] = slides;
};
const pool = new PromisePool(jobs, handler);
await pool.done();
const index = Object.values(sources);
return {
topLevel: {
index
},
sources
}
}
const slides = {
// Include data loader
data: data$3,
// Enable slides blueprint if srcDir/slides/*.md files exist
enabled (options) {
if (options.$standalone === 'slides') {
options.slides.prefix = '/';
if (!exists(join(this.options.srcDir, options.slides.dir))) {
options.slides.dir = '';
}
return true
}
return exists(join(this.options.srcDir, options.slides.dir))
},
templates: {
index: 'pages/index.vue',
layout: 'layouts/slides.vue',
plugin: 'plugins/slides.client.js',
slides: 'components/slides.vue',
arrowLeft: 'assets/arrow-left.svg',
arrowRight: 'assets/arrow-right.svg'
},
// Register routes once templates have been added
routes (templates) {
return [
{
name: 'slides_index',
path: this.$press.slides.prefix,
component: templates.index
}
]
},
generateRoutes (data, prefix, staticRoot) {
return Object.keys(data.sources).map(async route => ({
route: prefix(route),
payload: await importModule(`${staticRoot}/sources${route}`)
}))
},
// Register serverMiddleware
serverMiddleware ({ options, rootId, id }) {
const { index } = typeof options.slides.api === 'function'
? options.slides.api.call(this, { rootId, id })
: options.slides.api;
return [
(req, res, next) => {
if (req.url.startsWith('/api/slides/index')) {
index(req, res, next);
} else {
next();
}
}
]
},
build: {
before () {
this.$addPressTheme('blueprints/slides/theme.css');
},
async done () {
if (this.nuxt.options.dev) {
let updatedSlides;
const mdProcessor = await this.$press.slides.source.processor();
const watchDir = this.$press.slides.dir
? `${this.$press.slides.dir}/`
: this.$press.slides.dir;
chokidar.watch([`${watchDir}*.md`, `${watchDir}**/*.md`], {
cwd: this.options.srcDir,
ignoreInitial: true,
ignored: 'node_modules/**/*'
})
.on('change', async (path) => {
updatedSlides = await parseSlides.call(this, path, mdProcessor);
this.$pressSourceEvent('change', 'slides', updatedSlides);
})
.on('add', async (path) => {
updatedSlides = await parseSlides.call(this, path, mdProcessor);
this.$pressSourceEvent('add', 'slides', updatedSlides);
})
.on('unlink', path => this.$pressSourceEvent('unlink', 'slides', { path }));
}
}
},
// Options are merged into the parent module default options
options: {
dir: 'slides',
prefix: '/slides/',
api ({ rootId }) {
const cache = {};
const rootDir = join(this.options.buildDir, rootId, 'static');
return {
index: (req, res, next) => {
if (this.options.dev || !cache.index) {
cache.index = readJsonSync(rootDir, 'slides', 'index.json');
}
res.json(cache.index);
}
}
},
source: {
processor () {
return new Markdown({ toc: false, sanitize: false })
},
markdown (source, processor) {
return processor.toMarkup(source).then(({ html }) => html)
},
metadata (source) {
if (source.trimLeft().startsWith('---')) {
const { content: body, data } = graymatter(source);
return { ...data, body }
}
return {}
},
path (fileName) {
return `${this.$press.slides.prefix}${fileName.toLowerCase()}/`
}
}
}
};
const blueprints = {
blog,
common,
docs,
slides
};
if (!Object.fromEntries) {
Object.fromEntries = (iterable) => {
return [ ...iterable ].reduce((obj, [key, val]) => {
obj[key] = val;
return obj
}, {})
};
}
async function registerBlueprints (rootId, options, blueprintIds) {
// this: Nuxt ModuleContainer instance
// rootId: root id (used to define directory and config key)
// options: module options (as captured by the module function)
// blueprints: blueprint loading order
// Future-compatible flag
this.$isGenerate = this.nuxt.options._generate || this.nuxt.options.target === 'static';
// Sets this.options[rootId] ensuring
// external config files have precendence
options = await loadConfig.call(this, rootId, options);
if (options.i18n) {
const locales = options.i18n.locales;
this.options.i18n = {
locales,
defaultLocale: locales[0].code,
vueI18n: {
fallbackLocale: locales[0].code,
messages: options.i18n.messages || {}
}
};
this.requireModule('nuxt-i18n');
}
if (this.nuxt.options.dev) {
const devStaticRoot = join(this.options.buildDir, rootId, 'static');
this.saveDevDataSources = (...args) => {
return new Promise(async (resolve) => {
await saveDataSources.call(this, devStaticRoot, ...args);
resolve();
})
};
}
this.$addPressTheme = (path) => {
if (options.naked) {
return
}
let addIndex = this.options.css
.findIndex(css => typeof css === 'string' && css.match(/nuxt\.press\.css$/));
if (addIndex === -1) {
addIndex = this.options.css
.findIndex(css => typeof css === 'string' && css.match(/prism\.css$/));
}
this.options.css.splice(addIndex + 1, 0, resolve(path));
};
for (const id of blueprintIds) {
await _registerBlueprint.call(this, id, rootId, options);
}
}
async function _registerBlueprint (id, rootId, options) {
// Load blueprint specification
const blueprint = blueprints[id];
// Populate mode default options
const blueprintOptions = defu(options[id] || {}, blueprint.options);
// Determine if mode is enabled
if (!blueprint.enabled.call(this, { ...options, [id]: blueprintOptions })) {
// Return if blueprint is not enabled
return
}
// Set flag to indicate blueprint was enabled (ie: options.$common = true)
options[`$${id}`] = true;
if (this.options.dev) {
options.dev = true;
}
// Populate options with defaults
options[id] = blueprintOptions;
// Register server middleware
if (blueprint.serverMiddleware) {
for (let serverMiddleware of await blueprint.serverMiddleware.call(this, { options, rootId, id })) {
serverMiddleware = serverMiddleware.bind(this);
this.addServerMiddleware(async (req, res, next) => {
try {
await serverMiddleware(req, res, next);
} catch (err) {
next(err);
}
});
}
}
if (blueprint.ready) {
await blueprint.ready.call(this);
}
const context = { options, rootId, id, data: undefined };
let compileHookRan = false;
const {
before: buildBefore,
compile: buildCompile,
done: buildDone
} = blueprint.build || {};
this.nuxt.addHooks({
build: {
// build:before hook
before: async () => {
const data = await blueprint.data.call(this, context);
context.data = data;
if (data.options) {
Object.assign(options[id], data.options);
}
if (data.static) {
if (typeof options[id].extendStaticFiles === 'function') {
await options[id].extendStaticFiles.call(this, data.static, context);
}
await saveStaticFiles.call(this, data.static);
}
const templates = await addTemplates.call(this, context, blueprint.templates);
await updateConfig.call(this, rootId, { [id]: data.options });
if (blueprint.routes) {
const routes = await blueprint.routes.call(this, templates);
this.extendRoutes((nuxtRoutes) => {
for (const route of routes) {
if (exists(route.component)) {
// this is a fix for hmr, it already has full path set
continue
}
const path = join(this.options.srcDir, route.component);
if (exists(path)) {
route.component = path;
} else {
route.component = join(this.options.buildDir, route.component);
}
}
nuxtRoutes.push(...routes);
});
}
if (!buildBefore) {
return
}
await buildBefore.call(this, context);
},
// build:compile hook
compile: async ({ name }) => {
// compile hook should only run once for a blueprint
if (compileHookRan) {
return
}
compileHookRan = true;
const staticRoot = join(this.options.buildDir, rootId, 'static');
await saveDataSources.call(this, staticRoot, id, context.data);
if (!buildCompile) {
return
}
await buildCompile.call(this, context);
},
// build:done hook
done: async () => {
if (!buildDone) {
return
}
await buildDone.call(this, context);
}
}
});
if (!this.$isGenerate) {
return
}
let staticRootGenerate;
this.nuxt.addHooks({
generate: {
// generate:distCopied hook
distCopied: async () => {