diff --git a/.gitignore b/.gitignore index c27da3df9..29cb72ad9 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ docs/.static/api/ db_data es_data gem_cache + +# Auto-generated Storybook stories +app/javascript/generated_stories/ diff --git a/.vscode/launch.json b/.vscode/launch.json index fdefffbfc..6b648590d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,13 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Attach to Node", + "port": 9229, + "request": "attach", + "skipFiles": ["node_modules/*", "/**/*.js"], + "type": "node" + }, { "type": "edge", "request": "attach", diff --git a/bin/__tests__/generate-css-utility-classes-docs.test.js b/bin/__tests__/generate-css-utility-classes-docs.test.js new file mode 100644 index 000000000..550992cc5 --- /dev/null +++ b/bin/__tests__/generate-css-utility-classes-docs.test.js @@ -0,0 +1,248 @@ +/* globals require beforeEach jest describe it expect */ +const path = require('path'); +const { + generateUtilityClassesDocumentation, + GENERATED_STORIES_FOLDER, +} = require('../generate-css-utility-classes-docs'); + +function createMockFileWriter() { + const files = {}; + async function fileWriter(file, content) { + files[file] = content; + } + + return { files, fileWriter }; +} + +function getStorybookFilePath(cssProperty) { + return path.join( + GENERATED_STORIES_FOLDER, + `${cssProperty}_utilityClasses.stories.jsx`, + ); +} + +describe('generateUtilityClassesDocumentation', () => { + beforeEach(() => { + console.log = jest.fn(); + }); + + it('should generate a Storybook story file', () => { + const expected = ` // This is an auto-generated file. DO NOT EDIT + import { h } from 'preact'; + import '../../crayons/storybook-utilities/designSystem.scss'; + + export default { + title: '5_CSS Utility classes/color', + }; + export const _color_some_utility_class = () =>
+

.color-some-utility-class utility class for the following CSS properties:

+ +${' '} +
{\`.color-some-utility-class {
+  color: red;
+}
+\`}
+
+ + _color_some_utility_class.story = { name: 'color-some-utility-class' }; + `; + const styleSheet = { + cssRules: [ + { + style: { + 0: 'color', + length: 1, + _importants: {}, + color: 'red', + }, + selectorText: '.color-some-utility-class', + cssText: '.color-some-utility-class{color: red;}', + }, + ], + }; + + const { files, fileWriter } = createMockFileWriter(); + const filePath = getStorybookFilePath('color'); + + generateUtilityClassesDocumentation(styleSheet, fileWriter); + + expect(files[filePath]).toEqual(expected); + }); + + it('should generate a Storybook story file when a utility class has more than one property set', () => { + const expected = ` // This is an auto-generated file. DO NOT EDIT + import { h } from 'preact'; + import '../../crayons/storybook-utilities/designSystem.scss'; + + export default { + title: '5_CSS Utility classes/color', + }; + export const _color_some_utility_class = () =>
+

.color-some-utility-class utility class for the following CSS properties:

+ +${' '} +
{\`.color-some-utility-class {
+  color: red;
+  opacity: 0.5;
+}
+\`}
+
+ + _color_some_utility_class.story = { name: 'color-some-utility-class' }; + `; + const styleSheet = { + cssRules: [ + { + style: { + 0: 'color', + 1: 'opacity', + length: 2, + _importants: {}, + color: 'red', + opacity: '0.5', + }, + selectorText: '.color-some-utility-class', + cssText: '.color-some-utility-class{color: red;opacity: 0.5}', + }, + ], + }; + + const { files, fileWriter } = createMockFileWriter(); + const filePath = getStorybookFilePath('color'); + + generateUtilityClassesDocumentation(styleSheet, fileWriter); + + expect(files[filePath]).toEqual(expected); + }); + + it('should generate a Storybook story file when CSS utility classes have !important in values', () => { + const expected = ` // This is an auto-generated file. DO NOT EDIT + import { h } from 'preact'; + import '../../crayons/storybook-utilities/designSystem.scss'; + + export default { + title: '5_CSS Utility classes/color', + }; + export const _color_some_utility_class = () =>
+

.color-some-utility-class utility class for the following CSS properties:

+ +

Note that !important is being used to override pre-design system CSS.

+
{\`.color-some-utility-class {
+  color: red;
+}
+\`}
+
+ + _color_some_utility_class.story = { name: 'color-some-utility-class' }; + `; + const styleSheet = { + cssRules: [ + { + style: { + 0: 'color', + length: 1, + _importants: { color: 'important' }, + color: 'red', + }, + selectorText: '.color-some-utility-class', + cssText: '.color-some-utility-class{color: red;}', + }, + ], + }; + + const { files, fileWriter } = createMockFileWriter(); + const filePath = getStorybookFilePath('color'); + + generateUtilityClassesDocumentation(styleSheet, fileWriter); + + expect(files[filePath]).toEqual(expected); + }); + + it('should generate a Storybook story file for only non-@media CSS rules', () => { + const expected = ` // This is an auto-generated file. DO NOT EDIT + import { h } from 'preact'; + import '../../crayons/storybook-utilities/designSystem.scss'; + + export default { + title: '5_CSS Utility classes/color', + }; + export const _color_some_utility_class = () =>
+

.color-some-utility-class utility class for the following CSS properties:

+ +${' '} +
{\`.color-some-utility-class {
+  color: red;
+}
+\`}
+
+ + _color_some_utility_class.story = { name: 'color-some-utility-class' }; + `; + const styleSheet = { + cssRules: [ + { + style: { + 0: 'color', + length: 1, + _importants: {}, + color: 'red', + }, + selectorText: '.color-some-utility-class', + cssText: '.color-some-utility-class{color: red;}', + }, + { + style: { + 0: 'width', + length: 1, + _importants: {}, + }, + media: 'some-media-rule', + }, + ], + }; + + const { files, fileWriter } = createMockFileWriter(); + + generateUtilityClassesDocumentation(styleSheet, fileWriter); + const filePath = getStorybookFilePath('color'); + expect(files[filePath]).toEqual(expected); + }); +}); diff --git a/bin/generate-css-utility-classes-docs.js b/bin/generate-css-utility-classes-docs.js new file mode 100755 index 000000000..b4ec30736 --- /dev/null +++ b/bin/generate-css-utility-classes-docs.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +/* globals module process require __dirname */ + +const fs = require('fs'); +const path = require('path'); +const util = require('util'); +const folderExists = util.promisify(fs.exists); +const mkdir = util.promisify(fs.mkdir); +const sass = require('node-sass'); +const CSSOM = require('cssom'); +const prettier = require('prettier'); +const renderCss = util.promisify(sass.render); +const file = fs.promises; + +const stylesheetsDirectory = path.resolve( + __dirname, + '../app/assets/stylesheets', +); +const GENERATED_STORIES_FOLDER = path.join( + __dirname, + '../app/javascript/generated_stories/__stories__', +); + +/** + * Generates a style sheet object for the given SASS/CSS file. + * + * @param {string} file The file to load as a style sheet. + * + * @returns {CSSStyleSheet} The stylesheet for the given file. + */ +async function getStyleSheet(file) { + const { css: bytes } = await renderCss({ + file, + }); + const utilityClassesContent = new TextDecoder('utf-8').decode(bytes); + const styleSheet = CSSOM.parse(utilityClassesContent); + + return styleSheet; +} + +/** + * Groups CSS rules by CSS property. + * + * @param {CSSRule} rules A set of CSS rules + * + * @returns {object} A lookup whose keys are CSS properties + * and the values are a lookup whose keys are CSS utility class names + * and the values are the associated CSS rule. + */ +function groupCssRulesByCssProperty(rules) { + const groupedRules = rules.reduce((acc, rule) => { + if (rule.media) { + return acc; + } + + // Utility classes can modify more than one property, so we classify the CSS rule under + // more than one CSS property potentially. + // It means things will be repeated in Storybook, but it's all auto-generated, so no biggie. + for (let i = 0; i < rule.style.length; i++) { + const cssProperty = rule.style[i]; + + acc[cssProperty] = acc[cssProperty] || {}; + acc[cssProperty][rule.selectorText] = rule; + } + + return acc; + }, {}); + + return groupedRules; +} + +/** + * Generates the content for Storybook stories for all the CSS utility + * classes associated to the given CSS property. + * + * @param {string} cssProperty A CSS property + * @param {object} cssRules A lookup whose keys are CSS utility class + * names and the values are CSS rules.action-space + * + * @returns {string} The content for Storybook stories for all the CSS + * utility classes associated to the given CSS property + */ +function generateUtilityClassStories(cssProperty, cssRules) { + const storybookStories = [ + ` // This is an auto-generated file. DO NOT EDIT + import { h } from 'preact'; + import '../../crayons/storybook-utilities/designSystem.scss'; + + export default { + title: '5_CSS Utility classes/${cssProperty}', + };`, + ]; + + for (const [className, cssRule] of Object.entries(cssRules)) { + const sanitizedCssClassName = className.replace(/[.-]/g, '_'); + const propertiesAndValues = []; + let isImportant = false; + + for (let i = 0; i < cssRule.style.length; i++) { + const styleProperty = cssRule.style[i]; + const value = cssRule.style[styleProperty]; + + if (!isImportant) { + isImportant = cssRule.style._importants[styleProperty] === 'important'; + } + + propertiesAndValues.push(`
  • + ${styleProperty} set to ${value} +
  • `); + } + + storybookStories.push(` + export const ${sanitizedCssClassName} = () =>
    +

    ${className} utility class for the following CSS properties:

    + + ${ + isImportant + ? '

    Note that !important is being used to override pre-design system CSS.

    ' + : '' + } +
    {\`${prettier.format(cssRule.cssText, {
    +        parser: 'css',
    +      })}\`}
    +
    + + ${sanitizedCssClassName}.story = { name: '${className.replace( + /^\./, + '', + )}' }; + `); + } + + return storybookStories.join(''); +} + +async function generateUtilityClassesDocumentation( + styleSheet, + fileWriter = file.writeFile, +) { + console.log('Grouping stylesheet rules by CSS property'); + const rulesForStorybook = groupCssRulesByCssProperty(styleSheet.cssRules); + + for (const [cssProperty, cssRules] of Object.entries(rulesForStorybook)) { + const storybookContent = generateUtilityClassStories(cssProperty, cssRules); + + console.log( + `Persisting Storybook stories for CSS utility classes related to the ${cssProperty} property.`, + ); + await fileWriter( + path.join( + GENERATED_STORIES_FOLDER, + `${cssProperty}_utilityClasses.stories.jsx`, + ), + storybookContent, + ); + } +} + +async function generateDocumentation() { + console.log('Ensuring the auto-generated Storybook folder exists.'); + + if (!(await folderExists(GENERATED_STORIES_FOLDER))) { + console.log( + 'The auto-generated Storybook folder does not exist. Creating it.', + ); + await mkdir(GENERATED_STORIES_FOLDER, { recursive: true }); + } + + const utilityClassesFilename = path.join( + stylesheetsDirectory, + 'config/_generator.scss', + ); + + console.log(`Generating the style sheet for ${utilityClassesFilename}`); + + try { + const styleSheet = await getStyleSheet(utilityClassesFilename); + + await generateUtilityClassesDocumentation(styleSheet); + } catch (error) { + throw new Error('Error generating the CSS utilty class Storybook stories'); + } +} + +if (process.env.NODE_ENV !== 'test') { + // we're running unit tests so do not run the function to generate docs. + generateDocumentation(); +} + +/* + These variables and functions are only used within this script, but they are being exported + so that the core of the doc generation tool can be tested. Normally you would not export + things deemed private. +*/ +module.exports = { + GENERATED_STORIES_FOLDER, + getStyleSheet, + generateUtilityClassesDocumentation, +}; diff --git a/jest.config.js b/jest.config.js index e22e1f272..24bc78a82 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,7 @@ process.env.TZ = 'UTC'; module.exports = { setupFilesAfterEnv: ['./testSetup.js'], collectCoverageFrom: [ + 'bin/*.js', 'app/javascript/**/*.{js,jsx}', // This exclusion avoids running coverage on Barrel files, https://twitter.com/housecor/status/981558704708472832 '!app/javascript/**/index.js', diff --git a/package.json b/package.json index dd828a92a..df64c16b0 100644 --- a/package.json +++ b/package.json @@ -15,15 +15,15 @@ "scripts": { "api-docs:lint": "spectral lint -F hint -v docs/api_v0.yml && lint-openapi -e docs/api_v0.yml", "api-docs:serve": "redoc-cli serve docs/api_v0.yml --options.pathInMiddlePanel --options.jsonSampleExpandLevel=all --options.menuToggle -t docs/api_template.hbs --watch", - "generate-themes-storybook": "node-sass app/assets/stylesheets/themes -o app/javascript/storybook-static/themes", + "storybook-prerequisites": "node-sass app/assets/stylesheets/themes -o app/javascript/storybook-static/themes && bin/generate-css-utility-classes-docs.js", + "prebuild-storybook": "npm run storybook-prerequisites", "build-storybook": "build-storybook -c app/javascript/.storybook -s app/assets -o app/javascript/storybook-static --quiet", - "postbuild-storybook": "npm run generate-themes-storybook", - "prestorybook": "npm run generate-themes-storybook", + "prestorybook": "npm run storybook-prerequisites", "storybook": "start-storybook -p 6006 -c app/javascript/.storybook -s app/assets,app/javascript/storybook-static", "lint:frontend": "eslint app/assets/javascripts/**/*.js app/javascript/**/*.jsx app/javascript/**/*.js", "pretest": "npm run lint:frontend", - "test": "jest app/javascript/ --coverage", - "test:watch": "jest app/javascript/ --watch" + "test": "jest app/javascript/ bin/ --coverage", + "test:watch": "jest app/javascript/ bin/ --watch" }, "husky": { "hooks": { @@ -101,6 +101,7 @@ "babel-jest": "^26.2.2", "babel-loader": "^8.1.0", "css-loader": "^4.2.1", + "cssom": "^0.4.4", "eslint": "^7.6.0", "eslint-config-preact": "^1.1.1", "eslint-config-prettier": "^6.11.0",