diff --git a/.eslintignore b/.eslintignore index 9cf3facc95c4..455ccfd84808 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,3 +15,6 @@ tests/coverage /html /docs/js node_modules + +# TODO: Upgrade to ESLint4 so we can apply a specific rule (one for CJS code) for below files +src/**/*.config.js diff --git a/.gitignore b/.gitignore index 891c48069f21..47700dbd4da7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ tests/coverage # a11y testing .aat.yml + +# Generated by npm@5, but project currently uses Yarn +package-lock.json diff --git a/demo/.babelrc b/demo/.babelrc index 0bcbdc77956a..f4f9840193db 100644 --- a/demo/.babelrc +++ b/demo/.babelrc @@ -11,5 +11,5 @@ ], "react" ], - "plugins": ["transform-class-properties", "dev-expression"] + "plugins": ["transform-class-properties", "transform-object-rest-spread", "dev-expression"] } diff --git a/demo/js/components/CodePage/CodePage.js b/demo/js/components/CodePage/CodePage.js index c4ba57eb618a..2400ae35d716 100644 --- a/demo/js/components/CodePage/CodePage.js +++ b/demo/js/components/CodePage/CodePage.js @@ -4,60 +4,36 @@ import Markdown from 'markdown-it'; import ComponentExample from '../ComponentExample/ComponentExample'; -/** - * @param {ComponentCollection|Component} metadata The component data. - * @returns {string} The HTML snippet for the component. - */ -const getContent = metadata => { - const { variants = {} } = metadata; - const { items = [] } = variants; - const variant = items[0]; - return metadata.content || (variant && variant.content) || ''; -}; - -/** - * @param {ComponentCollection|Component} metadata The component data. - * @returns {Component[]|Variant[]} The data of the component variants. - */ -const getSubItems = metadata => { - if (metadata.isCollection) { - return metadata.items; - } - if (!metadata.isCollated) { - return metadata.variants.items; - } - return []; -}; - /** * The page to show the component demo, its code as well as its README. */ const CodePage = ({ metadata, hideViewFullRender }) => { const md = new Markdown({ html: true }); - const subItems = getSubItems(metadata).filter(item => !item.isHidden); + const subItems = (metadata.items || []).filter(item => !item.isHidden); + /* eslint-disable react/no-danger */ const componentContent = !metadata.isCollection && subItems.length <= 1 ? ( ) : ( subItems.map(item => (

{item.label}

+ {item.notes && metadata.notes !== item.notes &&

{item.notes}

}
)) ); - /* eslint-disable react/no-danger */ return (
{componentContent} diff --git a/demo/js/components/ComponentExample/ComponentExample.js b/demo/js/components/ComponentExample/ComponentExample.js index 8838c47d8372..a5e37fbb96e2 100644 --- a/demo/js/components/ComponentExample/ComponentExample.js +++ b/demo/js/components/ComponentExample/ComponentExample.js @@ -134,7 +134,8 @@ class ComponentExample extends Component { }); const codepenLink = codepenSlug && `https://codepen.io/team/carbon/full/${codepenSlug}/`; - const componentLink = variant ? `/component/${component}/${variant}` : `/component/${component}`; + const variantSuffix = (component === variant && '--default') || ''; + const componentLink = variant ? `/component/${variant}${variantSuffix}` : `/component/${component}`; const viewFullRender = hideViewFullRender ? null : ( diff --git a/demo/js/components/RootPage.js b/demo/js/components/RootPage.js index ce47f3d02ed1..76e039e3899e 100644 --- a/demo/js/components/RootPage.js +++ b/demo/js/components/RootPage.js @@ -6,6 +6,67 @@ import SideNav from './SideNav'; import PageHeader from './PageHeader/PageHeader'; import SideNavToggle from './SideNavToggle/SideNavToggle'; +const checkStatus = response => { + if (response.status >= 200 && response.status < 400) { + return response; + } + + const error = new Error(response.statusText); + error.response = response; + throw error; +}; + +const load = (componentItems, selectedNavItemId) => { + const metadata = componentItems && componentItems.find(item => item.id === selectedNavItemId); + const subItems = metadata.items || []; + const hasRenderedContent = + !metadata.isCollection && subItems.length <= 1 ? metadata.renderedContent : subItems.every(item => item.renderedContent); + if (!hasRenderedContent) { + return fetch(`/code/${metadata.name}`) + .then(checkStatus) + .then(response => { + const contentType = response.headers.get('content-type'); + return contentType && contentType.includes('application/json') ? response.json() : response.text(); + }) + .then(responseContent => { + if (Object(responseContent) === responseContent) { + return componentItems.map(item => { + if (item.id !== selectedNavItemId) { + return item; + } + return !item.items + ? { + ...item, + renderedContent: responseContent[`${item.handle}--default`], + } + : { + ...item, + items: item.items.map( + subItem => + !responseContent[subItem.handle] + ? subItem + : { + ...subItem, + renderedContent: responseContent[subItem.handle], + } + ), + }; + }); + } + return componentItems.map( + item => + item.id !== selectedNavItemId + ? item + : { + ...item, + renderedContent: responseContent, + } + ); + }); + } + return Promise.resolve(null); +}; + /** * The top-most React component for dev env page. */ @@ -22,9 +83,19 @@ class RootPage extends Component { docItems: PropTypes.arrayOf(PropTypes.shape()).isRequired, // eslint-disable-line react/no-unused-prop-types }; - constructor() { + constructor(props) { super(); - this.state = {}; + + const { componentItems } = props; + + this.state = { + /** + * The array of component data. + * @type {Object[]} + */ + componentItems, + }; + window.addEventListener('popstate', evt => { this.switchTo(evt.state.name); }); @@ -42,6 +113,13 @@ class RootPage extends Component { } } + componentWillReceiveProps(props) { + const { componentItems } = props; + if (this.props.componentItems !== componentItems) { + this.setState({ componentItems }); + } + } + /** * The handler for changing in the state of side nav's toggle button. */ @@ -53,7 +131,7 @@ class RootPage extends Component { * The handler for the `click` event on the side nav for changing selection. */ onSideNavItemClick = evt => { - const { componentItems } = this.props; + const { componentItems } = this.state; const selectedNavItem = componentItems && componentItems.find(item => item.id === evt.target.dataset.navId); if (selectedNavItem) { this.switchTo(selectedNavItem.id); @@ -64,7 +142,7 @@ class RootPage extends Component { * @returns The component data that is currently selected. */ getCurrentComponentItem() { - const { componentItems } = this.props; + const { componentItems } = this.state; return componentItems && componentItems.find(item => item.id === this.state.selectedNavItemId); } @@ -74,17 +152,22 @@ class RootPage extends Component { */ switchTo(selectedNavItemId) { this.setState({ selectedNavItemId }, () => { - const { componentItems } = this.props; + const { componentItems } = this.state; const selectedNavItem = componentItems && componentItems.find(item => item.id === selectedNavItemId); const { name } = selectedNavItem || {}; if (name) { history.pushState({ name }, name, `/demo/${name}`); } + load(componentItems, selectedNavItemId).then(newComponentItems => { + if (newComponentItems) { + this.setState({ componentItems: newComponentItems }); + } + }); }); } render() { - const { componentItems } = this.props; + const { componentItems } = this.state; const metadata = this.getCurrentComponentItem(); const { name, label } = metadata || {}; const classNames = classnames({ diff --git a/demo/polyfills/devenv.js b/demo/polyfills/devenv.js index 6c1134ce080e..4511996f6c5e 100644 --- a/demo/polyfills/devenv.js +++ b/demo/polyfills/devenv.js @@ -1,4 +1,14 @@ // Polyfill for dev env UI based on `carbon-components-react` -/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable import/no-extraneous-dependencies, global-require */ +import 'core-js/modules/es6.string.includes'; import 'core-js/modules/es7.object.values'; -/* eslint-enable import/no-extraneous-dependencies */ +import 'whatwg-fetch'; + +if (typeof Promise === 'undefined') { + // Rejection tracking prevents a common issue where React gets into an + // inconsistent state due to an error, but it gets swallowed by a Promise, + // and the user has no idea what causes React's erratic future behavior. + require('promise/lib/rejection-tracking').enable(); + window.Promise = require('promise/lib/es6-extensions.js'); +} +/* eslint-enable import/no-extraneous-dependencies, global-require */ diff --git a/demo/scss/_page.scss b/demo/scss/_page.scss index e9aca5fb302c..3c4de6665625 100644 --- a/demo/scss/_page.scss +++ b/demo/scss/_page.scss @@ -75,12 +75,12 @@ td { } } - button { - border-radius: 0; - } - & > *:not(.component-example):not(.component-variation), - & > { + & > { + button { + border-radius: 0; + } + .page__divider-heading { @include typescale('zeta'); font-weight: 600; diff --git a/demo/views/demo-live.dust b/demo/views/demo-live.dust deleted file mode 100644 index 45a176744f7b..000000000000 --- a/demo/views/demo-live.dust +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - Carbon Components - - - - -
- {content|s} -
- - - - - - - - - - - - - - diff --git a/demo/views/demo-nav-data.hbs b/demo/views/demo-nav-data.hbs new file mode 100644 index 000000000000..29c2b10805e7 --- /dev/null +++ b/demo/views/demo-nav-data.hbs @@ -0,0 +1,15 @@ + diff --git a/demo/views/demo-nav.dust b/demo/views/layouts/demo-nav.hbs similarity index 61% rename from demo/views/demo-nav.dust rename to demo/views/layouts/demo-nav.hbs index f1c83565abdb..764c63e77974 100644 --- a/demo/views/demo-nav.dust +++ b/demo/views/layouts/demo-nav.hbs @@ -23,21 +23,7 @@
- + {{{body}}} diff --git a/demo/views/layouts/preview.hbs b/demo/views/layouts/preview.hbs new file mode 100644 index 000000000000..8ed6685d3af0 --- /dev/null +++ b/demo/views/layouts/preview.hbs @@ -0,0 +1,42 @@ + + + + + + + Carbon Components + + + + +
+ {{{body}}} +
+ + + + + + + + + + + + + diff --git a/gulpfile.js b/gulpfile.js index 62b5ad0418ba..c5a8d6f1710c 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,6 +1,7 @@ 'use strict'; // Node +const fs = require('fs'); const path = require('path'); // Styles @@ -41,10 +42,16 @@ const jsdocConfig = require('gulp-jsdoc3/dist/jsdocConfig.json'); // Generic utility const del = require('del'); +const writeFile = promisify(fs.writeFile); +const mkdirp = promisify(require('mkdirp')); + // Test environment const Server = require('karma').Server; const commander = require('commander'); +// Fractal templates +const templates = require('./tools/templates'); + const assign = v => v; const cloptions = commander .option('-k, --keepalive', 'Keeps browser open after first run of Karma test finishes') @@ -64,8 +71,8 @@ gulp.task('dev-server', cb => { let started; const options = { script: './server.js', - ext: 'dust js', - watch: ['demo/**/*.dust', 'server.js'], + ext: 'hbs js', + watch: ['demo/**/*.hbs', 'src/**/*.hbs', 'src/**/*.config.js', 'server.js'], env: { PORT: cloptions.port, }, @@ -246,11 +253,17 @@ gulp.task('sass:source', () => { return gulp.src(srcFiles).pipe(gulp.dest('scss')); }); -gulp.task('html:source', () => { - const srcFiles = './src/components/**/*.html'; - - return gulp.src(srcFiles).pipe(gulp.dest('html')); -}); +gulp.task('html:source', () => + templates.render().then(renderedItems => { + const promises = []; + renderedItems.forEach((rendered, item) => { + const dirname = path.dirname(path.resolve(__dirname, 'html', item.relViewPath)); + const filename = `${item.handle.replace(/--default$/, '')}.html`; + promises.push(mkdirp(dirname).then(() => writeFile(path.resolve(dirname, filename), rendered))); + }); + return Promise.all(promises); + }) +); /** * JSDoc @@ -294,7 +307,7 @@ gulp.task('jsdoc', cb => { gulp.task('test', ['test:unit', 'test:a11y']); -gulp.task('test:unit', done => { +gulp.task('test:unit', ['html:source'], done => { new Server( { configFile: path.resolve(__dirname, 'tests/karma.conf.js'), diff --git a/manifest.yml b/manifest.yml index 8921a817ec51..b9f79389b3c1 100644 --- a/manifest.yml +++ b/manifest.yml @@ -1,8 +1,8 @@ --- applications: - name: carbon-dev-environment - memory: 64M + memory: 1G buildpack: https://github.com/cloudfoundry/nodejs-buildpack.git#v1.5.22 random-route: true env: - NPM_CONFIG_PRODUCTION: false \ No newline at end of file + NPM_CONFIG_PRODUCTION: false diff --git a/package.json b/package.json index 5b21eab17db4..b8996698984e 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "eslint-plugin-prettier": "^2.3.1", "eslint-plugin-react": "^7.5.0", "express": "4.16.2", + "express-handlebars": "^3.0.0", "globby": "4.0.0", "gulp": "~3.9.0", "gulp-autoprefixer": "~3.0.1", @@ -93,6 +94,7 @@ "gulp-sourcemaps": "~1.6.0", "gulp-uglify": "^2.1.2", "gulp-util": "~3.0.7", + "handlebars-helpers": "^0.10.0", "html-loader": "^0.5.0", "husky": "^0.12.0", "jasmine-core": "^2.9.0", @@ -112,10 +114,12 @@ "markdown-it": "^8.4.0", "merge-stream": "^1.0.0", "minimatch": "^3.0.0", + "mkdirp": "^0.5.0", "mock-raf": "^1.0.0", "nodemon": "1.9.1", "postcss-loader": "^2.1.0", "prettier": "^1.7.0", + "promise": "^8.0.1", "prop-types": "^15.6.0", "pump": "^1.0.2", "react": "^16.2.0", @@ -137,7 +141,8 @@ "vinyl-named": "^1.1.0", "webpack": "^3.10.0", "webpack-dev-middleware": "^2.0.0", - "webpack-hot-middleware": "^2.21.0" + "webpack-hot-middleware": "^2.21.0", + "whatwg-fetch": "^2.0.4" }, "resolutions": { "freshy": ">= 1.0.3" diff --git a/server.js b/server.js index dd3d3562fbd6..8feb4ea84c56 100644 --- a/server.js +++ b/server.js @@ -1,36 +1,26 @@ 'use strict'; -const globby = require('globby'); -const { promisify } = require('bluebird'); -const fs = require('fs'); +/* eslint import/no-extraneous-dependencies: [2, {"devDependencies": true}] */ + const path = require('path'); const express = require('express'); -const Fractal = require('@frctl/fractal'); const webpack = require('webpack'); -const webpackDevConfig = require('./tools/webpack.dev.config'); const webpackDevMiddleware = require('webpack-dev-middleware'); const webpackHotMiddleware = require('webpack-hot-middleware'); -const compiler = webpack(webpackDevConfig); - -const readFile = promisify(fs.readFile); +const templates = require('./tools/templates'); const app = express(); -const adaro = require('adaro'); - const port = process.env.PORT || 8080; +const config = require('./tools/webpack.dev.config'); -app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: webpackDevConfig.output.publicPath })); +const compiler = webpack(config); +app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath })); app.use(webpackHotMiddleware(compiler)); -const fractal = Fractal.create(); -fractal.components.set('path', path.join(__dirname, 'src/components')); -fractal.components.set('ext', '.html'); -fractal.docs.set('path', path.join(__dirname, 'docs')); - -app.engine('dust', adaro.dust()); -app.set('view engine', 'dust'); +app.engine('hbs', templates.handlebars.engine); +app.set('view engine', 'hbs'); app.set('views', path.resolve(__dirname, 'demo/views')); app.use('/demo', express.static('demo')); app.use(express.static('src')); @@ -38,34 +28,44 @@ app.use(express.static('scripts')); app.use('/docs/js', express.static('docs/js')); /** - * @param {string} glob The glob. - * @returns {string} The file contents of files matching the given glob, concatenated. - */ -const getContent = glob => - globby(glob).then(filePaths => { - if (filePaths.length === 0) { - return undefined; - } - return Promise.all(filePaths.map(filePath => readFile(filePath, { encoding: 'utf8' }))).then(contents => - contents.reduce((a, b) => a.concat(b)) - ); - }); - -/** - * @param {ComponentCollection|Component} item The component data. + * @param {ComponentCollection|Component} metadata The component data. * @returns {Promise} - * The component data, with README.md content assigned to `.notes` property for component with variants (`ComponentCollection`). + * The normalized component data, + * esp. with README.md content assigned to `.notes` property for component with variants (`ComponentCollection`). * Fractal automatically populate `.notes` for component without variants (`Component`). */ -const ensureComponentItemNotes = item => { - if (!item.isCollection || !item.config.readme) { - return item; +const normalizeMetadata = metadata => { + const items = metadata.isCollection ? metadata : !metadata.isCollated && metadata.variants && metadata.variants(); + const visibleItems = items && items.filter(item => !item.isHidden); + const metadataJSON = typeof metadata.toJSON !== 'function' ? metadata : metadata.toJSON(); + if (!metadata.isCollection && visibleItems && visibleItems.size === 1) { + const firstVariant = visibleItems.first(); + return Object.assign(metadataJSON, { + context: firstVariant.context, + notes: firstVariant.notes, + preview: firstVariant.preview, + variants: undefined, + }); } - return item.config.readme - .getContent() - .then(notes => Object.assign(typeof item.toJSON !== 'function' ? item : item.toJSON(), { notes })); + return Object.assign(metadataJSON, { + items: !items || items.size <= 1 ? undefined : items.map(normalizeMetadata).toJSON().items, + variants: undefined, + }); }; +/** + * The promise resolved with the list of nav items. + * @type {Promise<(ComponentCollection|Component)[]>} + */ +const promiseNavItems = templates.promiseCache + .then(({ componentSource, docSource }) => + Promise.all([Promise.all(componentSource.items().map(normalizeMetadata)), docSource.items()]) + ) + .then(([componentItems, docItems]) => ({ + componentItems, + docItems, + })); + ['/', '/demo/:component'].forEach(route => { app.get(route, (req, res) => { const name = req.params.component; @@ -73,13 +73,9 @@ const ensureComponentItemNotes = item => { if (name && path.relative('src/components', `src/components/${name}`).substr(0, 2) === '..') { res.status(404).end(); } else { - fractal - .load() - .then(([componentSource, docSource]) => - Promise.all([Promise.all(componentSource.items().map(ensureComponentItemNotes)), docSource.items()]) - ) - .then(([componentItems, docItems]) => { - res.render('demo-nav', { + promiseNavItems + .then(({ componentItems, docItems }) => { + res.render('demo-nav-data', { componentItems, docItems, }); @@ -92,29 +88,48 @@ const ensureComponentItemNotes = item => { }); }); -['/component/:component', '/component/:component/:variant'].forEach(route => { - app.get(route, (req, res) => { - const glob = `src/components/${req.params.component}/**/${req.params.variant || '*'}.html`; +app.get('/component/:component', (req, res) => { + const name = req.params.component; + + if (path.relative('src/components', `src/components/${name}`).substr(0, 2) === '..') { + res.status(404).end(); + } else { + templates + .render({ layout: 'preview', concat: true }, name) + .then(rendered => { + // eslint-disable-next-line eqeqeq + if (rendered == null) { + res.status(404).end(); + } + res.send(rendered); + }) + .catch(error => { + console.error(error.stack); // eslint-disable-line no-console + res.status(500).end(); + }); + } +}); - if (path.relative('src/components', glob).substr(0, 2) === '..') { - res.status(404).end(); - } else { - getContent(glob) - .then(html => { - if (typeof html === 'undefined') { - res.status(404).end(); - } else { - res.render('demo-live', { - content: html, - }); - } - }) - .catch(error => { - console.error(error.stack); // eslint-disable-line no-console - res.status(500).end(); +app.get('/code/:component', (req, res) => { + const name = req.params.component; + + if (name && path.relative('src/components', `src/components/${name}`).substr(0, 2) === '..') { + res.status(404).end(); + } else { + templates + .render({}, name) + .then(renderedItems => { + const o = {}; + renderedItems.forEach((rendered, item) => { + o[item.handle] = rendered.trim(); }); - } - }); + res.json(o); + }) + .catch(error => { + console.error(error.stack); // eslint-disable-line no-console + res.status(500).end(); + }); + } }); app.listen(port, () => { diff --git a/src/components/accordion/_accordion.scss b/src/components/accordion/_accordion.scss index 0e7ac2ab7260..ece3d869d341 100644 --- a/src/components/accordion/_accordion.scss +++ b/src/components/accordion/_accordion.scss @@ -17,7 +17,7 @@ .#{$prefix}--accordion__item { transition: all $transition--base $carbon--standard-easing; - border-top: 1px solid $ui-04; + border-top: 1px solid $ui-03; overflow: hidden; &:focus { @@ -31,7 +31,7 @@ } &:last-child { - border-bottom: 1px solid $ui-04; + border-bottom: 1px solid $ui-03; } } diff --git a/src/components/accordion/legacyaccordion.html b/src/components/accordion/accordion--legacy.hbs similarity index 99% rename from src/components/accordion/legacyaccordion.html rename to src/components/accordion/accordion--legacy.hbs index 7c475b840d67..a69dcce3434c 100644 --- a/src/components/accordion/legacyaccordion.html +++ b/src/components/accordion/accordion--legacy.hbs @@ -47,4 +47,4 @@ aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

- \ No newline at end of file + diff --git a/src/components/accordion/accordion.html b/src/components/accordion/accordion.hbs similarity index 75% rename from src/components/accordion/accordion.html rename to src/components/accordion/accordion.hbs index 4539f1c06d81..ef82ceb46fb7 100644 --- a/src/components/accordion/accordion.html +++ b/src/components/accordion/accordion.hbs @@ -1,8 +1,8 @@