Auto-generate CSS utility classes documentation in Storybook (#9633)

Co-authored-by: Ben Halpern <bendhalpern@gmail.com>
This commit is contained in:
Nick Taylor 2020-08-11 23:32:14 -04:00 committed by GitHub
parent 5bd29877d8
commit b733a7c753
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 471 additions and 5 deletions

3
.gitignore vendored
View file

@ -79,3 +79,6 @@ docs/.static/api/
db_data
es_data
gem_cache
# Auto-generated Storybook stories
app/javascript/generated_stories/

7
.vscode/launch.json vendored
View file

@ -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/*", "<node_internals>/**/*.js"],
"type": "node"
},
{
"type": "edge",
"request": "attach",

View file

@ -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 = () => <div class="container">
<p><code>.color-some-utility-class</code> utility class for the following CSS properties:</p>
<ul>
<li>
<a
href="https://developer.mozilla.org/en-US/docs/Web/CSS/color"
target="_blank"
rel="noopener noreferrer"
>color</a> set to <code>red</code>
</li>
</ul>
${' '}
<pre><code>{\`.color-some-utility-class {
color: red;
}
\`}</code></pre>
</div>
_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 = () => <div class="container">
<p><code>.color-some-utility-class</code> utility class for the following CSS properties:</p>
<ul>
<li>
<a
href="https://developer.mozilla.org/en-US/docs/Web/CSS/color"
target="_blank"
rel="noopener noreferrer"
>color</a> set to <code>red</code>
</li><li>
<a
href="https://developer.mozilla.org/en-US/docs/Web/CSS/opacity"
target="_blank"
rel="noopener noreferrer"
>opacity</a> set to <code>0.5</code>
</li>
</ul>
${' '}
<pre><code>{\`.color-some-utility-class {
color: red;
opacity: 0.5;
}
\`}</code></pre>
</div>
_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 = () => <div class="container">
<p><code>.color-some-utility-class</code> utility class for the following CSS properties:</p>
<ul>
<li>
<a
href="https://developer.mozilla.org/en-US/docs/Web/CSS/color"
target="_blank"
rel="noopener noreferrer"
>color</a> set to <code>red</code>
</li>
</ul>
<p>Note that <code>!important</code> is being used to override pre-design system CSS.</p>
<pre><code>{\`.color-some-utility-class {
color: red;
}
\`}</code></pre>
</div>
_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 = () => <div class="container">
<p><code>.color-some-utility-class</code> utility class for the following CSS properties:</p>
<ul>
<li>
<a
href="https://developer.mozilla.org/en-US/docs/Web/CSS/color"
target="_blank"
rel="noopener noreferrer"
>color</a> set to <code>red</code>
</li>
</ul>
${' '}
<pre><code>{\`.color-some-utility-class {
color: red;
}
\`}</code></pre>
</div>
_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);
});
});

View file

@ -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(`<li>
<a
href="https://developer.mozilla.org/en-US/docs/Web/CSS/${styleProperty}"
target="_blank"
rel="noopener noreferrer"
>${styleProperty}</a> set to <code>${value}</code>
</li>`);
}
storybookStories.push(`
export const ${sanitizedCssClassName} = () => <div class="container">
<p><code>${className}</code> utility class for the following CSS properties:</p>
<ul>
${propertiesAndValues.join('')}
</ul>
${
isImportant
? '<p>Note that <code>!important</code> is being used to override pre-design system CSS.</p>'
: ''
}
<pre><code>{\`${prettier.format(cssRule.cssText, {
parser: 'css',
})}\`}</code></pre>
</div>
${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,
};

View file

@ -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',

View file

@ -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",