diff --git a/package.json b/package.json index 7760dbbf33..a5f56fce90 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "scripts": { "bootstrap": "lerna bootstrap && yarn tsc", "boot": "node scripts/bootstrap.js", + "newpage": "yarn workspace docs newpage", "dev": "yarn tsc && yarn workspace docs dev", "build": "yarn tsc && yarn workspace docs build", "show-help": "yarn workspace docs show-help", + "newpage:blog": "yarn workspace blog newpage", "dev:blog": "yarn tsc && yarn workspace blog dev", "build:blog": "yarn tsc && yarn workspace blog build", "register-vuepress": "lerna exec --scope vuepress -- yarn link", diff --git a/packages/@vuepress/core/README.md b/packages/@vuepress/core/README.md index 7829eb5850..fa95115ede 100644 --- a/packages/@vuepress/core/README.md +++ b/packages/@vuepress/core/README.md @@ -2,6 +2,8 @@ ## APIs +### newpage(targetDir, options) + ### dev(sourceDir, options) ### build(sourceDir, options) diff --git a/packages/@vuepress/core/lib/index.js b/packages/@vuepress/core/lib/index.js index 43a6d0124b..4fc320f432 100644 --- a/packages/@vuepress/core/lib/index.js +++ b/packages/@vuepress/core/lib/index.js @@ -3,3 +3,4 @@ exports.dev = require('./dev') exports.build = require('./build') exports.eject = require('./eject') +exports.newpage = require('./newpage') diff --git a/packages/@vuepress/core/lib/newpage.js b/packages/@vuepress/core/lib/newpage.js new file mode 100644 index 0000000000..37554b449e --- /dev/null +++ b/packages/@vuepress/core/lib/newpage.js @@ -0,0 +1,329 @@ +'use strict' + +const { + slugify: SLUGIFY, + path: PATH, + fs: FSE, + chalk: CHALK, + logger: LOGGER +} = require('@vuepress/shared-utils') + +// ----------------------------------------------------------------------------- + +const CMD_NAME = PATH.basename(__filename).replace('.js', '') + +// ----------------------------------------------------------------------------- + +// promisify https used for fetching lorem markdown + +const HTTPS = require('https') +const { promisify: PROMISIFY } = require('util') + +HTTPS.get[PROMISIFY.custom] = (options) => { + return new Promise((resolve, reject) => { + HTTPS.get(options, (response) => { + response.end = new Promise((resolve) => response.on('end', resolve)) + resolve(response) + }).on('error', reject) + }) +} + +const HTTP_GET = PROMISIFY(HTTPS.get) + +// ----------------------------------------------------------------------------- + +/** + * Class for creating a new page + */ +class Page { + /** + * constructor + * + * @param {string} dir - the root target directory + * @param {object} options - cli command options + */ + constructor (dir, options = {}) { + this.dir = dir + this.options = options + + // ------------------------------------------------------------------------- + + this.frontmatter = this.options.frontmatter || this.options.fm || {} + this.frontmatter.title = this.options.title || this.frontmatter.title || '' + this.frontmatter.date = this.frontmatter.date || new Date() + + // ------------------------------------------------------------------------- + + // as a minimum, we need either the title or the path + + if (!this.frontmatter.title && !this.options.path) { + throw new Error('title or path required') + } + } + + /** + * page title + * + * @returns {string} + */ + get title () { + return this.frontmatter.title + } + + /** + * file name slug based on slug option or slugified title + * + * @returns {string} + */ + get slug () { + return this.options.slug ? this.options.slug : SLUGIFY(this.title, { lower: true }) + } + + /** + * if page is not created in it's own directory + * + * @returns {bool} + */ + get nodir () { + return (this.options.nodir || this.options.notInOwnDirectory) + } + + /** + * filename when page is created in it's own directory + * + * @returns {string} + */ + get filename () { + return this.options.filename ? this.options.filename : 'README.md' + } + + /** + * resolve page path + * + * @returns {string} + */ + get path () { + if (this.options.path) { + // @notes: this is left up to the user; no checks done + return PATH.resolve(this.dir, this.options.path) + } + + // ------------------------------------------------------------------------- + + const slug = this.slug + let path + + if (this.nodir) { + const filename = PATH.extname(slug) ? slug : `${slug}.md` + path = [this.dir, filename] + } else { + path = [this.dir, slug, this.filename] + } + + // ------------------------------------------------------------------------- + + return PATH.join(...path.filter(e => e)) + } + + /** + * default template based on frontmatter options + * + * @returns {string} + */ + getDefaultTemplate () { + try { + // template frontmatter + + const template = [] + + template.push('---') + template.push('') + + for (const key of Object.keys(this.frontmatter)) { + const fm = this.frontmatter[ key ] + const value = Array.isArray(fm) ? `\n - ${fm.join('\n - ')}` : fm + + template.push(`${key}: ${value}`) + } + + template.push('') + template.push('---') + + // ----------------------------------------------------------------------- + + // template content + + template.push('') + template.push('%%content%%') + template.push('') + + // ----------------------------------------------------------------------- + + return template.join('\n') + } catch (err) { + throw err + } + } + + /** + * user defined template file + * + * @returns {string} + */ + async getTemplateFromFile () { + try { + const file = this.options.template || '' + + if (!file) { + return + } + + // ----------------------------------------------------------------------- + + const exists = await FSE.pathExists(file) + + if (exists) { + const content = await FSE.readFile(file, 'utf8') + return content + } else { + LOGGER.warn(`[vuepress ${CMD_NAME}] template file '${CHALK.cyan(file)}' does not exist; falling back to default template.\n`) + } + } catch (err) { + throw err + } + } + + /** + * fetch lorem markdown using https://github.com/jaspervdj/lorem-markdownum + * + * @returns {string} + */ + async fetchLorem () { + try { + const res = await HTTP_GET('https://jaspervdj.be/lorem-markdownum/markdown.txt') + + let body = '' + + res.on('data', (chunk) => (body += chunk)) + + await res.end + + // ----------------------------------------------------------------------- + + // make it more interesting by replacing first header + // with page title, lorem image, and toc + + const headerReplacement = [ + `# ${this.title}`, + '![random image](http://lorempixel.com/1024/576)', + '[[toc]]' + ].join('\n\n') + + return body.replace(/^#+\s*.*/i, headerReplacement) + } catch (err) { + throw err + } + } + + /** + * replaces supported tokens in template + * + * @returns {string} + */ + async replaceTokens (template) { + try { + const tokens = { + title: this.frontmatter.title, + date: this.frontmatter.date, + content: this.options.content || '# {{ $page.title }}' + } + + // ----------------------------------------------------------------------- + + for (const key of Object.keys(tokens)) { + let replacement = tokens[ key ] + + if (key === 'content' && replacement === '%%lorem%%') { + replacement = await this.fetchLorem() + } + + // --------------------------------------------------------------------- + + const regex = new RegExp(`%%${key}%%`, 'gmi') + + template = template.replace(regex, replacement) + } + + // ----------------------------------------------------------------------- + + return template + } catch (err) { + throw err + } + } + + /** + * get template either from a user defined file or fallback to the default one + * + * @returns {string} + */ + async getTemplate () { + try { + let template = await this.getTemplateFromFile() + + if (!template) { + template = this.getDefaultTemplate() + } + + // ----------------------------------------------------------------------- + + template = await this.replaceTokens(template) + + // ----------------------------------------------------------------------- + + return template + } catch (err) { + throw err + } + } + + /** + * creates the page file + * + * @returns {string} + */ + async create () { + try { + const file = this.path + const exists = await FSE.pathExists(file) + const relative = PATH.relative(process.cwd(), file) + + if (exists) { + LOGGER.error(`[vuepress ${CMD_NAME}] file already exists: '${CHALK.cyan(relative)}'\n`) + process.exit(1) + } + + // ----------------------------------------------------------------------- + + const template = await this.getTemplate() + + // ----------------------------------------------------------------------- + + await FSE.outputFile(file, template) + + LOGGER.success(`[vuepress ${CMD_NAME}] created new page in '${CHALK.cyan(relative)}'.\n`) + } catch (err) { + throw err + } + } +} + +// ----------------------------------------------------------------------------- + +module.exports = async (dir, options = {}) => { + try { + await new Page(dir, options).create() + } catch (err) { + LOGGER.error(CHALK.red(`\n[vuepress ${CMD_NAME}] ${err.message}\n`)) + } +} diff --git a/packages/blog/package.json b/packages/blog/package.json index dfd7df746f..e916c738ba 100644 --- a/packages/blog/package.json +++ b/packages/blog/package.json @@ -5,7 +5,8 @@ "version": "1.0.0-alpha.32", "scripts": { "dev": "vuepress dev source --temp .temp", - "build": "vuepress build source --temp .temp" + "build": "vuepress build source --temp .temp", + "newpage": "vuepress newpage source" }, "dependencies": { "@vuepress/theme-blog": "^1.0.0-alpha.32", diff --git a/packages/docs/docs/README.md b/packages/docs/docs/README.md index f87e157342..ecda5921dc 100644 --- a/packages/docs/docs/README.md +++ b/packages/docs/docs/README.md @@ -29,11 +29,11 @@ footer: MIT Licensed | Copyright © 2018-present Evan You ``` bash # install -yarn global add vuepress@next +yarn global add vuepress@next # OR npm install -g vuepress@next -# create a markdown file -echo '# Hello VuePress' > README.md +# create a new page +vuepress newpage --title "Hello VuePress" --path "README.md" # start writing vuepress dev diff --git a/packages/docs/docs/guide/getting-started.md b/packages/docs/docs/guide/getting-started.md index 9432ce2d15..54cd01c76a 100644 --- a/packages/docs/docs/guide/getting-started.md +++ b/packages/docs/docs/guide/getting-started.md @@ -12,8 +12,8 @@ If you just want to play around with VuePress, you can install it globally: # install globally yarn global add vuepress # OR npm install -g vuepress -# create a markdown file -echo '# Hello VuePress' > README.md +# create a new page +vuepress newpage --title "Hello VuePress" --path "README.md" # start writing vuepress dev @@ -30,10 +30,8 @@ If you have an existing project and would like to keep documentation inside the # install as a local dependency yarn add -D vuepress # OR npm install -D vuepress -# create a docs directory -mkdir docs -# create a markdown file -echo '# Hello VuePress' > docs/README.md +# create default theme home page +vuepress newpage --path "docs/README.md" --frontmatter.home true ``` ::: warning @@ -45,12 +43,19 @@ Then, add some scripts to `package.json`: ``` json { "scripts": { + "docs:newpage": "vuepress newpage docs", "docs:dev": "vuepress dev docs", "docs:build": "vuepress build docs" } } ``` +You can now start creating pages with: + +``` bash +yarn docs:newpage # OR npm run docs:newpage +``` + You can now start writing with: ``` bash diff --git a/packages/docs/package.json b/packages/docs/package.json index e2d22e762a..b7e1a52b15 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vuepress dev docs --temp .temp", "build": "vuepress build docs --temp .temp", - "show-help": "vuepress --help" + "show-help": "vuepress --help", + "newpage": "vuepress newpage docs" }, "repository": { "type": "git", diff --git a/packages/vuepress/lib/registerCoreCommands.js b/packages/vuepress/lib/registerCoreCommands.js index 2705cbf690..829fb24cab 100644 --- a/packages/vuepress/lib/registerCoreCommands.js +++ b/packages/vuepress/lib/registerCoreCommands.js @@ -4,7 +4,7 @@ * Module dependencies. */ -const { dev, build, eject } = require('@vuepress/core') +const { dev, build, eject, newpage } = require('@vuepress/core') const { path, logger, env } = require('@vuepress/shared-utils') const { wrapCommand } = require('./util') @@ -65,4 +65,24 @@ module.exports = function (cli, options) { .action((dir = '.') => { wrapCommand(eject)(path.resolve(dir)) }) + + cli + .command('newpage [targetDir]', 'create new page') + .alias('new-page') + .alias('new_page') + .alias('newPage') + .option('-t, --title ', 'page title - required if path is empty; it can be set here or using `--frontmatter.title`') + .option('-c, --content [content]', 'page content (default: `# {{ page.title }})') + .option('--fm, --frontmatter [frontmatter]', 'set page frontmatter as dot-nested options, such as --fm.lang "en-US"') + .option('--nodir, --not-in-own-directory', 'disable page in it\'s own directory (default: false)') + .option('-f, --filename [filename]', 'page filename, such as \'index.md\', applicable if page is in it\'s own directory (default: README.md)') + .option('-s, --slug [slug]', 'set page slug (default: `slugify( title ))`') + .option('-p, --path [path]', 'manually set page path (it will be resolved based on targetDir) - this would ignore --nodir, --filename, and --slug') + .option('-T, --template [template]', 'path to template file (default: auto generated template based on --title and --frontmatter options)') + .action((dir = '.', commandOptions) => { + wrapCommand(newpage)(path.resolve(dir), { + ...options, + ...commandOptions + }) + }) } diff --git a/packages/vuepress/lib/util.js b/packages/vuepress/lib/util.js index 6c14e277ea..4b30ca4a82 100644 --- a/packages/vuepress/lib/util.js +++ b/packages/vuepress/lib/util.js @@ -46,7 +46,7 @@ function wrapCommand (fn) { */ function isKnownCommand (argv) { - return ['dev', 'build', 'eject'].includes(argv[0]) + return ['dev', 'build', 'eject', 'newpage'].includes(argv[0]) } module.exports = {