diff --git a/example/package.json b/example/package.json index 34b696b..e6c2851 100644 --- a/example/package.json +++ b/example/package.json @@ -4,7 +4,8 @@ "main": "index.js", "license": "MIT", "scripts": { - "start": "webpack-dev-server" + "start": "webpack-dev-server", + "build": "webpack --mode development" }, "dependencies": { "react": "^16.13.1", diff --git a/example/src/App.js b/example/src/App.js index faf1065..5b39b1b 100644 --- a/example/src/App.js +++ b/example/src/App.js @@ -3,13 +3,23 @@ import stylemug from 'stylemug'; import { globalStyles } from './globals'; const styles = stylemug.create({ + foo: 'bar', title: { fontSize: '31px', fontFamily: 'courier', - color: '#444', + color: 'green', }, titleRed: { color: 'red', + '&:hoverr': { + color: 'red', + }, + }, +}); + +const secondaryStyles = stylemug.create({ + color: { + color: '#444', }, }); @@ -22,7 +32,11 @@ export default function App() { return (
-

Hello World

+

+ Hello World +

); diff --git a/packages/babel-stylemug-plugin/src/__test__/__snapshots__/babel.test.js.snap b/packages/babel-stylemug-plugin/src/__test__/__snapshots__/babel.test.js.snap index 25c692f..b4e37d3 100644 --- a/packages/babel-stylemug-plugin/src/__test__/__snapshots__/babel.test.js.snap +++ b/packages/babel-stylemug-plugin/src/__test__/__snapshots__/babel.test.js.snap @@ -1,5 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`babel plugin - reports should replace create argument 1`] = ` +"\\"use strict\\"; + +var _stylemug = _interopRequireDefault(require(\\"stylemug\\")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \\"default\\": obj }; } + +var styles = _stylemug[\\"default\\"].create({}, { + sourceLinesRange: \\"In lines 4 to 4\\", + messages: [\\"mockError1\\", \\"mockError2\\"] +});" +`; + exports[`babel plugin should error when failed to resolve 1`] = ` "\\"use strict\\"; @@ -13,7 +26,10 @@ function _defineProperty(obj, key, value) { if (key in obj) { Object.definePrope var styles = _stylemug[\\"default\\"].create(_defineProperty({}, _mock.selector, { color: 'red' -}), \\"Failed to evaluate the following stylesheet: \\\\n\\\\nstylemug.create({\\\\n [selector]: {\\\\n color: 'red'\\\\n }\\\\n})\\\\n\\\\nMake sure your stylesheet is statically defined.\\");" +}), { + sourceLinesRange: \\"In lines 5 to 9\\", + messages: [\\"Failed to evaluate the following stylesheet: \\\\n\\\\nstylemug.create({\\\\n [selector]: {\\\\n color: 'red'\\\\n }\\\\n})\\\\n\\\\nMake sure your stylesheet is statically defined.\\"] +});" `; exports[`babel plugin should replace create argument 1`] = ` diff --git a/packages/babel-stylemug-plugin/src/__test__/babel.test.js b/packages/babel-stylemug-plugin/src/__test__/babel.test.js index 320457a..cfe6b16 100644 --- a/packages/babel-stylemug-plugin/src/__test__/babel.test.js +++ b/packages/babel-stylemug-plugin/src/__test__/babel.test.js @@ -1,17 +1,25 @@ import { transform } from '@babel/core'; +import { compileSchema } from 'stylemug-compiler'; import { babelPlugin as plugin } from '../babel'; jest.mock('stylemug-compiler', () => ({ - compileSchema: () => ({ - className: { - hash: { - keyId: 'id', - }, - }, - }), + compileSchema: jest.fn(), })); describe('babel plugin', () => { + beforeEach(() => { + compileSchema.mockImplementationOnce(() => ({ + result: { + className: { + hash: { + keyId: 'id', + }, + }, + }, + reports: [], + })); + }); + it('should replace create argument', () => { const example = ` import stylemug from 'stylemug'; @@ -73,3 +81,24 @@ describe('babel plugin', () => { expect(code).toMatchSnapshot(); }); }); + +describe('babel plugin - reports', () => { + it('should replace create argument', () => { + compileSchema.mockImplementationOnce(() => ({ + result: {}, + reports: [{ message: 'mockError1' }, { message: 'mockError2' }], + })); + + const example = ` + import stylemug from 'stylemug'; + + const styles = stylemug.create({}); + `; + + const { code } = transform(example, { + plugins: [plugin], + }); + + expect(code).toMatchSnapshot(); + }); +}); diff --git a/packages/babel-stylemug-plugin/src/babel.js b/packages/babel-stylemug-plugin/src/babel.js index 46722c6..a284ff9 100644 --- a/packages/babel-stylemug-plugin/src/babel.js +++ b/packages/babel-stylemug-plugin/src/babel.js @@ -5,9 +5,39 @@ import { compileSchema } from 'stylemug-compiler'; export function babelPlugin(babel) { const t = babel.types; - function defineError(path, msg) { + function wrapReports(path, reports) { + if (!Array.isArray(reports)) { + reports = [reports]; + } + if (!reports.length) { + return; + } + const node = t.cloneDeep(path.node); - node.arguments[1] = t.stringLiteral(msg); + const objectChilds = [ + t.objectProperty( + t.identifier('sourceLinesRange'), + t.stringLiteral( + 'In lines ' + node.loc.start.line + ' to ' + node.loc.end.line + ) + ), + t.objectProperty( + t.identifier('messages'), + t.arrayExpression( + reports.map((report) => t.stringLiteral(report.message)) + ) + ), + ]; + if (path.hub.file.opts.filename) { + objectChilds.push( + t.objectProperty( + t.identifier('fileName'), + t.stringLiteral(path.hub.file.opts.filename || 'unknown') + ) + ); + } + + node.arguments[1] = t.objectExpression(objectChilds); path.replaceWith(node); } @@ -34,22 +64,24 @@ export function babelPlugin(babel) { references.forEach((reference) => { const local = reference.parentPath.parentPath; - let sheet = evaluateSimple(local.get('arguments')[0]); + const sheet = evaluateSimple(local.get('arguments')[0]); if (!sheet.confident) { - defineError( - local, - 'Failed to evaluate the following stylesheet: \n\n' + + wrapReports(local, { + message: + 'Failed to evaluate the following stylesheet: \n\n' + local.toString() + '\n\n' + - 'Make sure your stylesheet is statically defined.' - ); + 'Make sure your stylesheet is statically defined.', + }); return; } - sheet = compileSchema(sheet.value); + + const { result, reports } = compileSchema(sheet.value); + wrapReports(local, reports); const nextLocal = t.cloneDeep(local.node); nextLocal.arguments[0] = t.objectExpression( - Object.entries(sheet).map(([name, rules]) => { + Object.entries(result).map(([name, rules]) => { return t.objectProperty( t.identifier(name), t.objectExpression( diff --git a/packages/stylemug-compiler/src/__test__/__snapshots__/compile-context.test.js.snap b/packages/stylemug-compiler/src/__test__/__snapshots__/compile-context.test.js.snap new file mode 100644 index 0000000..ee132ae --- /dev/null +++ b/packages/stylemug-compiler/src/__test__/__snapshots__/compile-context.test.js.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`compile should assert an invalid rule value 1`] = ` +Array [ + Object { + "message": "Invalid type for color", + }, +] +`; + +exports[`compile should assert an invalid sheet 1`] = ` +Object { + "default": Object { + "b765dfa68": Object { + "children": "", + "key": "backgroundColor", + "keyId": "bb7c5a6d0", + "media": undefined, + "value": "yellow", + }, + "cb34cd11f": Object { + "children": "", + "key": "color", + "keyId": "c3d7e6258", + "media": undefined, + "value": "red", + }, + }, +} +`; + +exports[`compile should assert an invalid sheet 2`] = ` +Array [ + Object { + "message": "Classname with key foo is not an object", + }, +] +`; + +exports[`compile should assert invalid pseudo classes 1`] = ` +Array [ + Object { + "message": "&:iAmAnInvalidPseudoClass is an invalid CSS pseudo class / element", + }, + Object { + "message": "Invalid type for color", + }, +] +`; + +exports[`compile should report multiple errors 1`] = ` +Array [ + Object { + "message": "Classname with key foo is not an object", + }, + Object { + "message": "&:iAmAnInvalidPseudoClass is an invalid CSS pseudo class / element", + }, + Object { + "message": "Invalid type for color", + }, +] +`; diff --git a/packages/stylemug-compiler/src/__test__/compile-context.test.js b/packages/stylemug-compiler/src/__test__/compile-context.test.js new file mode 100644 index 0000000..ceddd11 --- /dev/null +++ b/packages/stylemug-compiler/src/__test__/compile-context.test.js @@ -0,0 +1,52 @@ +import { compileSchema } from '../compile'; + +describe('compile', () => { + it('should assert an invalid sheet', () => { + const { result, reports } = compileSchema({ + default: { + color: 'red', + backgroundColor: 'yellow', + }, + // The rule below fails. + foo: 'bar', + }); + + expect(result).toMatchSnapshot(); + expect(reports).toMatchSnapshot(); + }); + + it('should assert an invalid rule value', () => { + const { reports } = compileSchema({ + default: { + color: true, + }, + }); + + expect(reports).toMatchSnapshot(); + }); + + it('should assert invalid pseudo classes', () => { + const { reports } = compileSchema({ + default: { + '&:iAmAnInvalidPseudoClass': { + color: true, + }, + }, + }); + + expect(reports).toMatchSnapshot(); + }); + + it('should report multiple errors', () => { + const { reports } = compileSchema({ + foo: 'bar', + default: { + '&:iAmAnInvalidPseudoClass': { + color: true, + }, + }, + }); + + expect(reports).toMatchSnapshot(); + }); +}); diff --git a/packages/stylemug-compiler/src/__test__/compile.test.js b/packages/stylemug-compiler/src/__test__/compile.test.js index 4c5c775..e001048 100644 --- a/packages/stylemug-compiler/src/__test__/compile.test.js +++ b/packages/stylemug-compiler/src/__test__/compile.test.js @@ -2,7 +2,7 @@ import { compileSchema, compileSelectors } from '../compile'; describe('compile', () => { it('should compile schema', () => { - const result = compileSchema({ + const { result } = compileSchema({ default: { color: 'red', backgroundColor: 'yellow', @@ -18,16 +18,8 @@ describe('compile', () => { expect(result).toMatchSnapshot(); }); - it('should compile selectors with number as value', () => { - const result = compileSelectors({ - fontSize: 12, - }); - - expect(result).toMatchSnapshot(); - }); - it('should compile nested selectors', () => { - const result = compileSchema({ + const { result } = compileSchema({ default: { color: 'red', @@ -41,7 +33,7 @@ describe('compile', () => { }); it('should compile nested media query', () => { - const result = compileSchema({ + const { result } = compileSchema({ default: { color: 'red', @@ -53,4 +45,11 @@ describe('compile', () => { expect(result).toMatchSnapshot(); }); + + it('should compile selectors with number as value', () => { + const result = compileSelectors({ + fontSize: 12, + }); + expect(result).toMatchSnapshot(); + }); }); diff --git a/packages/stylemug-compiler/src/compile.js b/packages/stylemug-compiler/src/compile.js index 869f88c..542cf4b 100644 --- a/packages/stylemug-compiler/src/compile.js +++ b/packages/stylemug-compiler/src/compile.js @@ -1,6 +1,8 @@ import fnv1a from 'fnv1a'; import { save as extractorSave } from './extractor'; import { extractShorthands } from './shorthands'; +import { createContext } from './context'; +import { PseudoClasses, PseudoElements } from './static'; function hash(str) { let hash = fnv1a(str).toString(16); @@ -9,54 +11,70 @@ function hash(str) { return `${prepend}${hash}`; } -export function compileSelectors(selectors, children, media) { +export function compileSelectors(selectors, children, media, context) { let result = {}; selectors = extractShorthands(selectors); for (let key in selectors) { const value = selectors[key]; + const type = typeof value; - switch (typeof value) { - case 'object': - // @media - if (/^@/.test(key)) { - result = Object.assign( - result, - compileSelectors(value, children, key) - ); - } - // &:focus - else { - const child = key.replace(/&/g, ''); - result = Object.assign( - result, - compileSelectors(value, children + child, media) - ); - } + // @media + if (/^@/.test(key)) { + if (type !== 'object') { + context.report('Rule with key ' + key + ' is not an object'); continue; + } + result = Object.assign( + result, + compileSelectors(value, children, key, context) + ); + continue; + } + + // &:focus + // &::after + if (/^&/.test(key)) { + const child = key.replace(/&/g, ''); - case 'number': - case 'string': - const mediaKey = media || ''; - const className = hash(key + value + children + mediaKey); - - const entry = { - // Provide a key id to match selectors setting the same key, - // for example, when for a given key, two classes apply with both - // backgroundColor as propery, we can filter out the first one by specificity. - keyId: hash(key + children + mediaKey), - key, - value, - children, - media, - }; - - extractorSave(className, entry); - - result[className] = entry; + if (!PseudoClasses.includes(child) && !PseudoElements.includes(child)) { + context.report(key + ' is an invalid CSS pseudo class / element'); + } + if (type !== 'object') { + context.report('Rule with key ' + key + ' is not an object'); continue; + } + + result = Object.assign( + result, + compileSelectors(value, children + child, media, context) + ); + continue; + } + + if (type === 'number' || type === 'string') { + const mediaKey = media || ''; + const className = hash(key + value + children + mediaKey); + + const entry = { + // Provide a key id to match selectors setting the same key, + // for example, when for a given key, two classes apply with both + // backgroundColor as propery, we can filter out the first one by specificity. + keyId: hash(key + children + mediaKey), + key, + value, + children, + media, + }; + + extractorSave(className, entry); + + result[className] = entry; + continue; } + + context.report('Invalid type for ' + key); } return result; @@ -64,10 +82,16 @@ export function compileSelectors(selectors, children, media) { export function compileSchema(schema) { const result = {}; + const context = createContext(); for (let key in schema) { - result[key] = compileSelectors(schema[key], ''); + if (typeof schema[key] !== 'object') { + context.report('Classname with key ' + key + ' is not an object'); + continue; + } + + result[key] = compileSelectors(schema[key], '', undefined, context); } - return result; + return { result, reports: context.getReports() }; } diff --git a/packages/stylemug-compiler/src/context.js b/packages/stylemug-compiler/src/context.js new file mode 100644 index 0000000..c813de5 --- /dev/null +++ b/packages/stylemug-compiler/src/context.js @@ -0,0 +1,18 @@ +export function createContext() { + const reports = []; + + function report(message) { + reports.push({ + message, + }); + } + + function getReports() { + return reports; + } + + return { + report, + getReports, + }; +} diff --git a/packages/stylemug-compiler/src/static.js b/packages/stylemug-compiler/src/static.js new file mode 100644 index 0000000..93c8d6d --- /dev/null +++ b/packages/stylemug-compiler/src/static.js @@ -0,0 +1,41 @@ +export const PseudoClasses = [ + ':active', + ':checked', + ':disabled', + ':empty', + ':enabled', + ':first-child', + ':first-of-type', + ':focus', + ':hover', + ':in-range', + ':invalid', + ':lang(language)', + ':last-child', + ':last-of-type', + ':link', + ':not(selector)', + ':nth-child(n)', + ':nth-last-child(n)', + ':nth-last-of-type(n)', + ':nth-of-type(n)', + ':only-of-type', + ':only-child', + ':optional', + ':out-of-range', + ':read-only', + ':read-write', + ':required', + ':root', + ':target', + ':valid', + ':visited', +]; + +export const PseudoElements = [ + '::after', + '::before', + '::first-letter', + '::first-line', + '::selection', +]; diff --git a/packages/stylemug/src/__test__/runtime.test.js b/packages/stylemug/src/__test__/runtime.test.js index a101e40..b3fd21b 100644 --- a/packages/stylemug/src/__test__/runtime.test.js +++ b/packages/stylemug/src/__test__/runtime.test.js @@ -43,21 +43,6 @@ describe('runtime', () => { }); }); - describe('resolver warnings', () => { - it('should warn when a lookup failed', () => { - const styles = runtime.create({ - foo: { - color: 'red', - }, - }); - styles('unknown'); - - expect(warn).toHaveBeenCalledWith( - 'The class name "unknown" does not exist in your stylesheet. Check your stylemug.create({}) definition.' - ); - }); - }); - describe('compiler warnings', () => { it('should warn if a compiler error is thrown', () => { runtime.create( diff --git a/packages/stylemug/src/__test__/warn.test.js b/packages/stylemug/src/__test__/warn.test.js deleted file mode 100644 index 3a6fb74..0000000 --- a/packages/stylemug/src/__test__/warn.test.js +++ /dev/null @@ -1,10 +0,0 @@ -import warn from '../warn'; - -global.console.warn = jest.fn(); - -it('should log a warning message', () => { - warn('Something bad happened'); - expect(global.console.warn).toHaveBeenCalledWith( - '[stylemug] Something bad happened' - ); -}); diff --git a/packages/stylemug/src/index.js b/packages/stylemug/src/index.js index 935ec74..daa7c9d 100644 --- a/packages/stylemug/src/index.js +++ b/packages/stylemug/src/index.js @@ -1,14 +1,9 @@ import warn from './warn'; -const noop = () => {}; - export default { create(schema, error) { - if (error) { - if (__DEV__) { - warn(error); - } - return noop; + if (__DEV__ && error) { + warn(error); } const resolver = (...classNames) => { @@ -24,14 +19,6 @@ export default { maps.push(className); } if (typeof className === 'string') { - if (__DEV__ && !schema[className]) { - warn( - 'The class name "' + - className + - '" does not exist in your stylesheet. Check your ' + - 'stylemug.create({}) definition.' - ); - } maps.push(schema[className]); } } diff --git a/packages/stylemug/src/warn.js b/packages/stylemug/src/warn.js index ec6e9eb..69ce069 100644 --- a/packages/stylemug/src/warn.js +++ b/packages/stylemug/src/warn.js @@ -1,9 +1,15 @@ -const warnings = {}; +export default function warn(error) { + let message = 'There are warnings in your stylesheet:\n\n'; -export default function warn(str) { - if (warnings[warn]) { - return; + if (error.fileName) { + message += error.fileName + '\n'; + message += ' ' + error.sourceLinesRange; + message += '\n\n'; } - warnings[warn] = true; - console.warn('[stylemug] ' + str); + + error.messages.forEach((msg, i) => { + message += i + 1 + ') ' + msg + '\n'; + }); + + console.warn('[stylemug]', message); } diff --git a/scripts/dev b/scripts/dev index 120c50a..6b36ed0 100755 --- a/scripts/dev +++ b/scripts/dev @@ -7,7 +7,7 @@ spawn('scripts/build', { stdio: 'inherit', }); -spawn('yarn', ['start'], { +spawn('yarn', ['build'], { stdio: 'inherit', cwd: path.resolve('./example'), });