UNPKG

@nuxt/press

Version:

Minimalist Markdown Publishing for Nuxt.js

1,820 lines (1,589 loc) 59.9 kB
'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 () => {