diff --git a/packages/cli/config/webpack.config.common.js b/packages/cli/config/webpack.config.common.js index 03017da18..e0f1d2303 100644 --- a/packages/cli/config/webpack.config.common.js +++ b/packages/cli/config/webpack.config.common.js @@ -13,11 +13,14 @@ const mapUserWorkspaceDirectory = (userPath) => { return new webpack.NormalModuleReplacementPlugin( new RegExp(`${directory}`), (resource) => { - resource.request = resource.request.replace(new RegExp(`\.\.\/${directory}`), userPath); + // workaround to ignore cli/templates default imports when rewriting + if (!new RegExp('\/cli\/templates').test(resource.request)) { + resource.request = resource.request.replace(new RegExp(`\.\.\/${directory}`), userPath); + } // remove any additional nests, after replacement with absolute path of user workspace + directory const additionalNestedPathIndex = resource.request.lastIndexOf('..'); - + if (additionalNestedPathIndex > -1) { resource.request = resource.request.substring(additionalNestedPathIndex + 2, resource.request.length); } @@ -25,7 +28,7 @@ const mapUserWorkspaceDirectory = (userPath) => { ); }; -module.exports = (config, context) => { +module.exports = ({ config, context }) => { // dynamically map all the user's workspace directories for resolution by webpack // this essentially helps us keep watch over changes from the user, and greenwood's build pipeline const mappedUserDirectoriesForWebpack = getUserWorkspaceDirectories(context.userWorkspace).map(mapUserWorkspaceDirectory); diff --git a/packages/cli/config/webpack.config.develop.js b/packages/cli/config/webpack.config.develop.js index 833585fea..50b9ad8c9 100644 --- a/packages/cli/config/webpack.config.develop.js +++ b/packages/cli/config/webpack.config.develop.js @@ -23,7 +23,7 @@ const rebuild = async() => { }; module.exports = ({ config, context, graph }) => { - const configWithContext = commonConfig(config, context, graph); + const configWithContext = commonConfig({ config, context, graph }); const { devServer, publicPath } = config; const { host, port } = devServer; diff --git a/packages/cli/config/webpack.config.prod.js b/packages/cli/config/webpack.config.prod.js index cb065dea8..971664fb6 100644 --- a/packages/cli/config/webpack.config.prod.js +++ b/packages/cli/config/webpack.config.prod.js @@ -4,7 +4,7 @@ const webpackMerge = require('webpack-merge'); const commonConfig = require(path.join(__dirname, '..', './config/webpack.config.common.js')); module.exports = ({ config, context, graph }) => { - const configWithContext = commonConfig(config, context, graph); + const configWithContext = commonConfig({ config, context, graph }); return webpackMerge(configWithContext, { diff --git a/packages/cli/lib/compile.js b/packages/cli/lib/compile.js index 31c949303..d999086b8 100644 --- a/packages/cli/lib/compile.js +++ b/packages/cli/lib/compile.js @@ -24,7 +24,7 @@ module.exports = generateCompilation = () => { // generate a graph of all pages / components to build console.log('Generating graph of workspace files...'); - compilation.graph = await generateGraph(compilation); + compilation = await generateGraph(compilation); // generate scaffolding console.log('Scaffolding out project files...'); diff --git a/packages/cli/lib/config.js b/packages/cli/lib/config.js index ea5c014a7..0a73f91b1 100644 --- a/packages/cli/lib/config.js +++ b/packages/cli/lib/config.js @@ -8,10 +8,13 @@ let defaultConfig = { port: 1984, host: 'http://localhost' }, - publicPath: '/' + publicPath: '/', + title: 'Greenwood App', + meta: [] }; module.exports = readAndMergeConfig = async() => { + // eslint-disable-next-line complexity return new Promise((resolve, reject) => { try { // deep clone of default config @@ -19,7 +22,8 @@ module.exports = readAndMergeConfig = async() => { if (fs.existsSync(path.join(process.cwd(), 'greenwood.config.js'))) { const userCfgFile = require(path.join(process.cwd(), 'greenwood.config.js')); - const { workspace, devServer, publicPath } = userCfgFile; + + const { workspace, devServer, publicPath, title, meta } = userCfgFile; // workspace validation if (workspace) { @@ -41,6 +45,13 @@ module.exports = readAndMergeConfig = async() => { } } + if (title) { + if (typeof title !== 'string') { + reject('Error: greenwood.config.js title must be a string'); + } + customConfig.title = title; + } + if (publicPath) { if (typeof publicPath !== 'string') { reject('Error: greenwood.config.js publicPath must be a string'); @@ -50,7 +61,10 @@ module.exports = readAndMergeConfig = async() => { } } - // devServer checks + if (meta && meta.length > 0) { + customConfig.meta = meta; + } + if (devServer && Object.keys(devServer).length > 0) { if (devServer.host) { @@ -72,6 +86,7 @@ module.exports = readAndMergeConfig = async() => { // console.log(`custom port provided => ${customConfig.devServer.port}`); } } + } } diff --git a/packages/cli/lib/graph.js b/packages/cli/lib/graph.js index d0e1c0b8c..6262b7c67 100644 --- a/packages/cli/lib/graph.js +++ b/packages/cli/lib/graph.js @@ -5,7 +5,7 @@ const fm = require('front-matter'); const path = require('path'); const util = require('util'); -const createGraphFromPages = async (pagesDir) => { +const createGraphFromPages = async (pagesDir, config) => { let pages = []; const readdir = util.promisify(fs.readdir); const readFile = util.promisify(fs.readFile); @@ -26,7 +26,8 @@ const createGraphFromPages = async (pagesDir) => { if (isMdFile && !stats.isDirectory()) { const fileContents = await readFile(filePath, 'utf8'); const { attributes } = fm(fileContents); - let { label, template } = attributes; + let { label, template, title } = attributes; + let { meta } = config; let mdFile = ''; // if template not set, use default @@ -54,8 +55,8 @@ const createGraphFromPages = async (pagesDir) => { // set route to the nested pages path and file name(without extension) route = completeNestedPath + route; - mdFile = `./${completeNestedPath}${fileRoute}.md`; - relativeExpectedPath = `'../${completeNestedPath}/${fileName}/${fileName}.js'`; + mdFile = `.${completeNestedPath}${fileRoute}.md`; + relativeExpectedPath = `'..${completeNestedPath}/${fileName}/${fileName}.js'`; } else { mdFile = `.${fileRoute}.md`; relativeExpectedPath = `'../${fileName}/${fileName}.js'`; @@ -64,6 +65,11 @@ const createGraphFromPages = async (pagesDir) => { // generate a random element name label = label || generateLabelHash(filePath); + // set element text, override with markdown title + title = title || config.title; + + // TODO: Allow for other, per page, dynamic, meta data, merge meta array + /* * Variable Definitions *---------------------- @@ -75,10 +81,11 @@ const createGraphFromPages = async (pagesDir) => { * fileName: file name without extension/path, so that it can be copied to scratch dir with same name * relativeExpectedPath: relative import path for generated component within a list.js file to later be * imported into app.js root component - * elementLabel: the element name for the generated md page e.g. + * title: the head text + * meta: og graph meta array of objects { property/name, content } */ - pages.push({ mdFile, label, route, template, filePath, fileName, relativeExpectedPath }); + pages.push({ mdFile, label, route, template, filePath, fileName, relativeExpectedPath, title, meta }); } if (stats.isDirectory()) { await walkDirectory(filePath); @@ -116,9 +123,11 @@ module.exports = generateGraph = async (compilation) => { return new Promise(async (resolve, reject) => { try { - const graph = await createGraphFromPages(compilation.context.pagesDir); + const { context, config } = compilation; + + compilation.graph = await createGraphFromPages(context.pagesDir, config); - resolve(graph); + resolve(compilation); } catch (err) { reject(err); } diff --git a/packages/cli/lib/init.js b/packages/cli/lib/init.js index 411f99c4f..eb86abaa9 100644 --- a/packages/cli/lib/init.js +++ b/packages/cli/lib/init.js @@ -3,6 +3,7 @@ const path = require('path'); const defaultTemplatesDir = path.join(__dirname, '../templates/'); const scratchDir = path.join(process.cwd(), './.greenwood/'); const publicDir = path.join(process.cwd(), './public'); +const metaComponent = path.join(__dirname, '..', 'templates', './components/meta'); module.exports = initContexts = async({ config }) => { @@ -44,7 +45,8 @@ module.exports = initContexts = async({ config }) => { ? path.join(userTemplatesDir, notFoundPageTemplate) : path.join(defaultTemplatesDir, notFoundPageTemplate), indexPageTemplate, - notFoundPageTemplate + notFoundPageTemplate, + metaComponent }; if (!fs.existsSync(scratchDir)) { diff --git a/packages/cli/lib/scaffold.js b/packages/cli/lib/scaffold.js index 5f35218e1..896a05a94 100644 --- a/packages/cli/lib/scaffold.js +++ b/packages/cli/lib/scaffold.js @@ -23,6 +23,29 @@ const writePageComponentsFromTemplate = async (compilation) => { }); }; + const loadPageMeta = async (file, result, { metaComponent }) => { + return new Promise((resolve, reject) => { + try { + const { title, meta, route } = file; + const metadata = { + title, + meta + }; + + metadata.meta.push({ property: 'og:title', content: title }); + metadata.meta.push({ property: 'og:url', content: route }); + + result = result.replace(/METAIMPORT/, `import '${metaComponent}'`); + result = result.replace(/METADATA/, `const metadata = ${JSON.stringify(metadata)}`); + result = result.replace(/METAELEMENT/, ''); + + resolve(result); + } catch (err) { + reject(err); + } + }); + }; + return Promise.all(compilation.graph.map(file => { const context = compilation.context; @@ -30,6 +53,7 @@ const writePageComponentsFromTemplate = async (compilation) => { try { let result = await createPageComponent(file, context); + result = await loadPageMeta(file, result, context); let relPageDir = file.filePath.substring(context.pagesDir.length, file.filePath.length); const pathLastBackslash = relPageDir.lastIndexOf('/'); diff --git a/packages/cli/templates/components/meta.js b/packages/cli/templates/components/meta.js new file mode 100644 index 000000000..d9885ce66 --- /dev/null +++ b/packages/cli/templates/components/meta.js @@ -0,0 +1,72 @@ +import { html, LitElement } from 'lit-element'; + +/* +* Take an attributes object with an array of meta objects, add them to an element and replace/add the element to DOM +* { +* title: 'my title', +* meta: [ +* { property: 'og:site', content: 'greenwood' }, +* { name: 'twitter:site', content: '@PrjEvergreen ' } +* ] +* } +*/ + +class meta extends LitElement { + + static get properties() { + return { + attributes: { + type: Object + } + }; + } + + firstUpdated() { + let header = document.head; + let meta; + + if (this.attributes) { + this.attributes.meta.map(attr => { + meta = document.createElement('meta'); + + const metaPropertyOrName = Object.keys(attr)[0]; + const metaPropValue = Object.values(attr)[0]; + let metaContentVal = Object.values(attr)[1]; + + // insert origin domain into url + if (metaPropValue === 'og:url') { + metaContentVal = window.location.origin + metaContentVal; + } + + meta.setAttribute(metaPropertyOrName, metaPropValue); + meta.setAttribute('content', metaContentVal); + + const oldmeta = header.querySelector(`[${metaPropertyOrName}="${metaPropValue}"]`); + + // rehydration + if (oldmeta) { + header.replaceChild(meta, oldmeta); + } else { + header.appendChild(meta); + } + }); + let title = document.createElement('title'); + + title.innerText = this.attributes.title; + const oldTitle = document.head.querySelector('title'); + + header.replaceChild(title, oldTitle); + } + + } + + render() { + return html` +
+ +
+ `; + } +} + +customElements.define('eve-meta', meta); \ No newline at end of file diff --git a/packages/cli/templates/page-template.js b/packages/cli/templates/page-template.js index 16ff0fe5a..4355f1987 100644 --- a/packages/cli/templates/page-template.js +++ b/packages/cli/templates/page-template.js @@ -1,9 +1,12 @@ import { html, LitElement } from 'lit-element'; MDIMPORT; +METAIMPORT; +METADATA; class PageTemplate extends LitElement { render() { return html` + METAELEMENT
diff --git a/test/cli/cases/build.config.default/build.config.default.spec.js b/test/cli/cases/build.config.default/build.config.default.spec.js index 0c4fa90a3..4f056bd51 100644 --- a/test/cli/cases/build.config.default/build.config.default.spec.js +++ b/test/cli/cases/build.config.default/build.config.default.spec.js @@ -30,7 +30,7 @@ describe('Build Greenwood With: ', async function() { before(async function() { await setup.runGreenwoodCommand('build'); }); - runSmokeTest(['public', 'index', 'not-found', 'hello'], LABEL); + runSmokeTest(['public', 'index', 'not-found', 'hello', 'meta'], LABEL); }); after(function() { diff --git a/test/cli/cases/build.config.error-title/build.config.error-title.spec.js b/test/cli/cases/build.config.error-title/build.config.error-title.spec.js new file mode 100644 index 000000000..9ac5a145f --- /dev/null +++ b/test/cli/cases/build.config.error-title/build.config.error-title.spec.js @@ -0,0 +1,44 @@ +/* + * Use Case + * Run Greenwood build command with a bad value for title in a custom config. + * + * User Result + * Should throw an error. + * + * User Command + * greenwood build + * + * User Config + * { + * title: {} + * } + * + * User Workspace + * Greenwood default + */ +const expect = require('chai').expect; +const TestBed = require('../../test-bed'); + +describe('Build Greenwood With: ', () => { + let setup; + + before(async () => { + setup = new TestBed(); + setup.setupTestBed(__dirname); + }); + + describe('Custom Configuration with a bad value for Title', () => { + it('should throw an error that title must be a string', async () => { + try { + await setup.runGreenwoodCommand('build'); + } catch (err) { + expect(err).to.contain('greenwood.config.js title must be a string'); + } + }); + }); + + after(function() { + setup.teardownTestBed(); + }); + +}); \ No newline at end of file diff --git a/test/cli/cases/build.config.error-title/greenwood.config.js b/test/cli/cases/build.config.error-title/greenwood.config.js new file mode 100644 index 000000000..e76c49235 --- /dev/null +++ b/test/cli/cases/build.config.error-title/greenwood.config.js @@ -0,0 +1,3 @@ +module.exports = { + title: {} +}; \ No newline at end of file diff --git a/test/cli/cases/build.config.meta/build.config.meta.spec.js b/test/cli/cases/build.config.meta/build.config.meta.spec.js new file mode 100644 index 000000000..ba3a341b0 --- /dev/null +++ b/test/cli/cases/build.config.meta/build.config.meta.spec.js @@ -0,0 +1,76 @@ +/* + * Use Case + * Run Greenwood with meta config object and default workspace. + * + * User Result + * Should generate a bare bones Greenwood build. (same as build.default.spec.js) with meta data + * + * User Command + * greenwood build + * + * User Config + * { + * title: 'My Custom Greenwood App', + * meta: [ + * { property: 'og:site', content: 'greenwood' }, + * { name: 'twitter:site', content: '@PrjEvergreen' } + * ] + * } + * + * User Workspace + * Greenwood default + * src/ + * pages/ + * index.md + * hello.md + */ +const fs = require('fs'); +const { JSDOM } = require('jsdom'); +const path = require('path'); +const expect = require('chai').expect; +const runSmokeTest = require('../../smoke-test'); +const TestBed = require('../../test-bed'); + +describe('Build Greenwood With: ', async function() { + const LABEL = 'Custom Meta Configuration and Default Workspace'; + let setup; + + before(async function() { + setup = new TestBed(); + this.context = setup.setupTestBed(__dirname); + }); + + describe(LABEL, function() { + before(async function() { + await setup.runGreenwoodCommand('build'); + }); + runSmokeTest(['public', 'index', 'not-found', 'hello', 'meta'], LABEL); + + describe('Custom Meta Index Page', function() { + let dom; + + beforeEach(async function() { + dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html')); + }); + + it('should output an index.html file within the default hello page directory', function() { + expect(fs.existsSync(path.join(this.context.publicDir, './index.html'))).to.be.true; + }); + + it('should have our custom config tag with og:site property in the ', function() { + const metaElement = dom.window.document.querySelector('head meta[property="og:site"]'); + + expect(metaElement.getAttribute('content')).to.be.equal('greenwood'); + }); + + it('should have our custom config tag with twitter:site name in the ', function() { + const metaElement = dom.window.document.querySelector('head meta[name="twitter:site"]'); + + expect(metaElement.getAttribute('content')).to.be.equal('@PrjEvergreen'); + }); + }); + }); + after(function() { + setup.teardownTestBed(); + }); +}); \ No newline at end of file diff --git a/test/cli/cases/build.config.meta/greenwood.config.js b/test/cli/cases/build.config.meta/greenwood.config.js new file mode 100644 index 000000000..9fa0da1bb --- /dev/null +++ b/test/cli/cases/build.config.meta/greenwood.config.js @@ -0,0 +1,6 @@ +module.exports = { + meta: [ + { property: 'og:site', content: 'greenwood' }, + { name: 'twitter:site', content: '@PrjEvergreen' } + ] +}; \ No newline at end of file diff --git a/test/cli/cases/build.config.meta/src/pages/hello.md b/test/cli/cases/build.config.meta/src/pages/hello.md new file mode 100644 index 000000000..fb8cf32d5 --- /dev/null +++ b/test/cli/cases/build.config.meta/src/pages/hello.md @@ -0,0 +1,7 @@ +--- +label: 'hello' +title: 'Hello Page' +--- +### Hello World + +This is an example page built by Greenwood. Make your own in _src/pages_! \ No newline at end of file diff --git a/test/cli/cases/build.config.meta/src/pages/index.md b/test/cli/cases/build.config.meta/src/pages/index.md new file mode 100644 index 000000000..1c1a50fbb --- /dev/null +++ b/test/cli/cases/build.config.meta/src/pages/index.md @@ -0,0 +1,3 @@ +### Greenwood + +This is the home page built by Greenwood. Make your own pages in src/pages/index.js! \ No newline at end of file diff --git a/test/cli/cases/build.config.title/build.config.title.spec.js b/test/cli/cases/build.config.title/build.config.title.spec.js new file mode 100644 index 000000000..1e288f611 --- /dev/null +++ b/test/cli/cases/build.config.title/build.config.title.spec.js @@ -0,0 +1,120 @@ +/* + * Use Case + * Run Greenwood with string title in config and default workspace. + * + * User Result + * Should generate a bare bones Greenwood build. (same as build.default.spec.js) with custom title in header + * + * User Command + * greenwood build + * + * User Config + * { + * title: 'My Custom Greenwood App' + * } + * + * User Workspace + * Greenwood default + * src/ + * pages/ + * index.md + * hello.md + */ +const fs = require('fs'); +const { JSDOM } = require('jsdom'); +const path = require('path'); +const expect = require('chai').expect; +const runSmokeTest = require('../../smoke-test'); +const TestBed = require('../../test-bed'); + +describe('Build Greenwood With: ', async function() { + const LABEL = 'Custom Title Configuration and Default Workspace'; + let setup; + + before(async function() { + setup = new TestBed(); + this.context = setup.setupTestBed(__dirname); + }); + + describe(LABEL, function() { + before(async function() { + await setup.runGreenwoodCommand('build'); + }); + runSmokeTest(['public', 'not-found', 'hello', 'meta'], LABEL); + + describe('Custom Title', function() { + const indexPageTitle = 'My Custom Greenwood App'; + const indexPageHeading = 'Greenwood'; + const indexPageBody = 'This is the home page built by Greenwood. Make your own pages in src/pages/index.js!'; + let dom; + + beforeEach(async function() { + dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html')); + }); + + it('should output an index.html file within the default public directory', function() { + expect(fs.existsSync(path.join(this.context.publicDir, './index.html'))).to.be.true; + }); + + it('should have our custom config meta tag in the <head>', function() { + const title = dom.window.document.querySelector('head title').textContent; + + expect(title).to.be.equal(indexPageTitle); + }); + + // rest of index smoke-test because <title> is changed for this case + it('should have a