├── .githooks ├── pre-commit └── commit-msg ├── src ├── util │ ├── postcss.js │ ├── postcss.d.ts │ ├── wait.ts │ ├── normalizePath.ts │ ├── ignoreNoEntryError.ts │ ├── isRecordLike.ts │ ├── ensureArray.ts │ ├── getBase64UrlHash.test.ts │ ├── serialize.ts │ ├── getBase64UrlHash.ts │ ├── createTemporaryDirectory.ts │ ├── createExposedPromise.test.ts │ ├── tokenizeString.test.ts │ ├── ensureArray.test.ts │ ├── createTemporaryDirectory.test.ts │ ├── encodeString.ts │ ├── createExposedPromise.ts │ ├── createIdGenerator.test.ts │ ├── deleteFile.ts │ ├── updateFile.ts │ ├── tokenizeString.ts │ ├── createIdGenerator.ts │ ├── runCode.for-test.ts │ ├── encodeString.test.ts │ ├── updateFile.test.ts │ ├── deleteFile.test.ts │ └── createSandbox.for-test.ts ├── helper │ ├── noop.js │ ├── noop.ts │ ├── default.js │ └── default.ts ├── minifier │ ├── walker.js │ ├── ast.ts │ ├── createOptimizedIdGenerator.ts │ ├── types.ts │ ├── setDictionary.ts │ ├── minifyCSSInScript.ts │ ├── minifyScripts.ts │ ├── extractCSSFromArrayExpression.ts │ ├── minifyScriptsForCSS.ts │ ├── parseCSSModuleScript.ts │ ├── findAddStyleImport.ts │ ├── parseScripts.ts │ ├── walker.d.ts │ └── parseCSSModuleScript.test.ts ├── runner │ ├── waitForInitialScanCompletion.ts │ ├── parseCSS.ts │ ├── getExtensionOption.ts │ ├── getCSSParserConfiguration.ts │ ├── getIncludePatterns.ts │ ├── getOutputOption.ts │ ├── getChokidarOptions.ts │ ├── extractPluginResult.ts │ ├── getExtensionOption.test.ts │ ├── getIncludePatterns.test.ts │ ├── getOutputOption.test.ts │ ├── getSessionConfiguration.ts │ ├── generateScript.ts │ ├── types.ts │ ├── Session.ts │ └── Session.test.ts ├── postcssPlugin │ ├── getMatchedImport.ts │ ├── getImports.ts │ ├── parseImport.ts │ ├── getPluginConfiguration.ts │ ├── getDependencies.ts │ ├── minify.test.ts │ ├── removeImportsAndRaws.ts │ ├── plugin.ts │ ├── createTransformer.ts │ ├── mangleKeyFrames.ts │ ├── parseImport.test.ts │ ├── minify.ts │ ├── transformDeclarations.ts │ ├── mangleIdentifiers.ts │ ├── types.ts │ └── plugin.test.ts ├── bin │ ├── loadParameters.ts │ └── esifycss.ts └── index.ts ├── @types ├── postcss-scss │ ├── package.json │ └── index.d.ts └── css.d.ts ├── ava.config.cjs ├── ava.config.client.cjs ├── .gitignore ├── tsconfig.build.json ├── test-client ├── parcel-javascript │ ├── src │ │ ├── index.html │ │ ├── page.css │ │ └── page.js │ └── package.json ├── typescript-rollup │ ├── src │ │ ├── index.html │ │ ├── page.css │ │ └── page.ts │ ├── tsconfig.json │ └── package.json ├── util │ ├── constants.ts │ ├── spawn.ts │ ├── markResult.ts │ ├── createBrowserStackLocal.ts │ ├── createRequestHandler.ts │ └── capabilities.ts └── run.ts ├── tsconfig.helper.json ├── sample ├── 00-src │ ├── style1.css │ ├── style2.css │ ├── style1.css.js │ └── style2.css.js ├── 01-mangle │ ├── style1.css │ ├── style2.css │ ├── style1.css.js │ ├── style2.css.js │ └── helper.js ├── 02-no-mangle │ ├── style1.css │ ├── style2.css │ ├── style1.css.js │ ├── style2.css.js │ └── helper.js └── plugin.js ├── .github ├── workflows │ ├── test-client.yml │ ├── release.yml │ └── test.yml └── FUNDING.yml ├── tsconfig.json ├── scripts ├── chmodScripts.ts └── copy.ts ├── package.json ├── LICENSE.txt └── README.md /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx lint-staged 3 | -------------------------------------------------------------------------------- /src/util/postcss.js: -------------------------------------------------------------------------------- 1 | exports.postcss = require('postcss'); 2 | -------------------------------------------------------------------------------- /src/helper/noop.js: -------------------------------------------------------------------------------- 1 | export const addStyle = (_rules) => { 2 | }; 3 | -------------------------------------------------------------------------------- /src/minifier/walker.js: -------------------------------------------------------------------------------- 1 | module.exports = require('acorn-walk'); 2 | -------------------------------------------------------------------------------- /.githooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx nlib-lint-commit --input $1 3 | -------------------------------------------------------------------------------- /src/util/postcss.d.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | 3 | export {postcss}; 4 | -------------------------------------------------------------------------------- /src/helper/noop.ts: -------------------------------------------------------------------------------- 1 | export const addStyle = (_rules: Array): void => { 2 | // noop 3 | }; 4 | -------------------------------------------------------------------------------- /@types/postcss-scss/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@types/postcss-scss", 3 | "version": "0.0.0" 4 | } 5 | -------------------------------------------------------------------------------- /ava.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extensions: ['ts'], 3 | require: ['ts-node/register'], 4 | timeout: '2m', 5 | }; 6 | -------------------------------------------------------------------------------- /src/util/wait.ts: -------------------------------------------------------------------------------- 1 | export const wait = async (duration: number) => { 2 | await new Promise((resolve) => { 3 | setTimeout(resolve, duration); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /ava.config.client.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | ...require('./ava.config.cjs'), 4 | files: [path.join('test-client', 'run.ts')], 5 | }; 6 | -------------------------------------------------------------------------------- /src/util/normalizePath.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Normalizes the input path to identify files on Windows. 3 | */ 4 | export const normalizePath = ( 5 | input: string, 6 | ): string => input.split('\\').join('/'); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | lib 4 | temp 5 | output 6 | .cache 7 | .nyc_output 8 | !sample 9 | test-client/**/*.css.js 10 | test-client/**/*.css.ts 11 | test-client/**/*.css.d.ts 12 | test-client/**/package-lock.json 13 | -------------------------------------------------------------------------------- /@types/css.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/unambiguous 2 | declare module '*.css' { 3 | export const className: Record; 4 | export const id: Record; 5 | export const keyframes: Record; 6 | } 7 | -------------------------------------------------------------------------------- /@types/postcss-scss/index.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/unambiguous 2 | declare module 'postcss-scss' { 3 | import type {Parser, Stringifier} from 'postcss'; 4 | 5 | export const parse: Parser; 6 | export const stringify: Stringifier; 7 | } 8 | -------------------------------------------------------------------------------- /src/util/ignoreNoEntryError.ts: -------------------------------------------------------------------------------- 1 | import {isRecordLike} from './isRecordLike'; 2 | 3 | export const ignoreNoEntryError = (error: unknown) => { 4 | if (isRecordLike(error) && error.code === 'ENOENT') { 5 | return null; 6 | } 7 | throw error; 8 | }; 9 | -------------------------------------------------------------------------------- /src/util/isRecordLike.ts: -------------------------------------------------------------------------------- 1 | export const isRecordLike = (input: unknown): input is Record => { 2 | if (input) { 3 | const type = typeof input; 4 | return type === 'object' || type === 'function'; 5 | } 6 | return false; 7 | }; 8 | -------------------------------------------------------------------------------- /src/util/ensureArray.ts: -------------------------------------------------------------------------------- 1 | export const ensureArray = ( 2 | arg: Array | TType | null | undefined, 3 | ): Array => { 4 | if (Array.isArray(arg)) { 5 | return arg.slice(); 6 | } 7 | return typeof arg === 'undefined' || arg === null ? [] : [arg]; 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | }, 6 | "include": [ 7 | "src/**/*" 8 | ], 9 | "exclude": [ 10 | "src/helper/*.ts", 11 | "**/*.test.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/util/getBase64UrlHash.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {getBase64UrlHash} from './getBase64UrlHash'; 3 | 4 | test('get a hash string', (t) => { 5 | const hash = getBase64UrlHash('foo', Buffer.from('bar')); 6 | t.is(hash, 'w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI'); 7 | }); 8 | -------------------------------------------------------------------------------- /test-client/parcel-javascript/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | parcel-javascript 4 | 5 | 6 | -------------------------------------------------------------------------------- /test-client/typescript-rollup/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | typescript-rollup 4 | 5 | 6 | -------------------------------------------------------------------------------- /tsconfig.helper.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2017", 5 | "module": "esnext", 6 | "declaration": false, 7 | "sourceMap": false, 8 | "outDir": "./lib/helper" 9 | }, 10 | "include": [ 11 | "src/helper/*.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/util/serialize.ts: -------------------------------------------------------------------------------- 1 | import type {InspectOptions} from 'util'; 2 | import * as util from 'util'; 3 | 4 | export const serialize = (value: unknown, inspectOptions: InspectOptions = {}): string => { 5 | switch (typeof value) { 6 | case 'string': 7 | return value; 8 | default: 9 | return util.inspect(value, inspectOptions); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /test-client/parcel-javascript/src/page.css: -------------------------------------------------------------------------------- 1 | @keyframes foo { 2 | 0% { 3 | transform: rotate(0deg); 4 | } 5 | 100% { 6 | transform: rotate(720deg); 7 | } 8 | } 9 | 10 | #foo { 11 | text-align: center; 12 | animation: 2s infinite foo; 13 | } 14 | 15 | .foo { 16 | width: 400px; 17 | max-width: 90%; 18 | margin: 0 auto; 19 | font-size: 30px; 20 | } 21 | 22 | .bar {} 23 | -------------------------------------------------------------------------------- /src/util/getBase64UrlHash.ts: -------------------------------------------------------------------------------- 1 | import type {BinaryLike} from 'crypto'; 2 | import {createHash} from 'crypto'; 3 | 4 | export const getBase64UrlHash = (...dataList: Array): Buffer | string => { 5 | const hash = createHash('sha256'); 6 | for (const data of dataList) { 7 | hash.update(data); 8 | } 9 | return hash.digest('base64').split('+').join('-').split('/').join('_').replace(/[=]+$/, ''); 10 | }; 11 | -------------------------------------------------------------------------------- /src/minifier/ast.ts: -------------------------------------------------------------------------------- 1 | import type {Node} from 'acorn'; 2 | import type {Program, ObjectExpression, ArrayExpression} from './walker'; 3 | 4 | export const isProgramNode = (node: Node): node is Program => node.type === 'Program'; 5 | export const isObjectExpression = (node: Node): node is ObjectExpression => node.type === 'ObjectExpression'; 6 | export const isArrayExpression = (node: Node): node is ArrayExpression => node.type === 'ArrayExpression'; 7 | -------------------------------------------------------------------------------- /test-client/typescript-rollup/src/page.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | @keyframes foo { 4 | 0% { 5 | transform: rotate(0deg); 6 | } 7 | 100% { 8 | transform: rotate(720deg); 9 | } 10 | } 11 | 12 | #foo { 13 | text-align: center; 14 | animation: 2s infinite foo; 15 | } 16 | 17 | .foo { 18 | width: 400px; 19 | max-width: 90%; 20 | margin: 0 auto; 21 | font-size: 30px; 22 | } 23 | 24 | .bar {} 25 | -------------------------------------------------------------------------------- /src/minifier/createOptimizedIdGenerator.ts: -------------------------------------------------------------------------------- 1 | import type {IdGenerator} from '../util/createIdGenerator'; 2 | import {createIdentifier} from '../util/createIdGenerator'; 3 | 4 | export const createOptimizedIdGenerator = (tokens: Map): IdGenerator => { 5 | const identifier = createIdentifier(); 6 | for (const [token] of [...tokens].sort((a, b) => b[1] - a[1])) { 7 | identifier(token); 8 | } 9 | return identifier; 10 | }; 11 | -------------------------------------------------------------------------------- /test-client/parcel-javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build:esifycss": "esifycss src/**/*.css", 5 | "build:parcel": "parcel build src/index.html --no-minify --out-dir output", 6 | "build": "run-s build:esifycss build:parcel" 7 | }, 8 | "devDependencies": { 9 | "esifycss": "file:../..", 10 | "npm-run-all": "4.1.5", 11 | "parcel-bundler": "1.12.5" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/util/createTemporaryDirectory.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as os from 'os'; 3 | import * as fs from 'fs'; 4 | 5 | const {mkdtemp} = fs.promises; 6 | 7 | export const createTemporaryDirectory = async ( 8 | prefix = '', 9 | ): Promise => { 10 | const pathPrefix = path.join(os.tmpdir(), prefix || 'node-tmp-'); 11 | const createdTemporaryDirectory = await mkdtemp(pathPrefix); 12 | return createdTemporaryDirectory; 13 | }; 14 | -------------------------------------------------------------------------------- /src/runner/waitForInitialScanCompletion.ts: -------------------------------------------------------------------------------- 1 | import type * as chokidar from 'chokidar'; 2 | 3 | export const waitForInitialScanCompletion = async ( 4 | watcher: chokidar.FSWatcher, 5 | ): Promise => { 6 | await new Promise((resolve, reject): void => { 7 | watcher 8 | .once('error', reject) 9 | .once('ready', () => { 10 | watcher.removeListener('error', reject); 11 | resolve(); 12 | }); 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /src/runner/parseCSS.ts: -------------------------------------------------------------------------------- 1 | import type {Result} from 'postcss'; 2 | import {postcss} from '../util/postcss'; 3 | import type {CSSParserParameters} from './types'; 4 | import {getCSSParserConfiguration} from './getCSSParserConfiguration'; 5 | 6 | export const parseCSS = async ( 7 | parameters: CSSParserParameters, 8 | ): Promise => { 9 | const config = await getCSSParserConfiguration(parameters); 10 | return await postcss(config.plugins).process(config.css, config.options); 11 | }; 12 | -------------------------------------------------------------------------------- /sample/00-src/style1.css: -------------------------------------------------------------------------------- 1 | @keyframes FadeIn { 2 | 0% {opacity: 0} 3 | 100% {opacity: 1} 4 | } 5 | @keyframes Rotate { 6 | 0% {transform: rotate( 0deg)} 7 | 100% {transform: rotate(360deg)} 8 | } 9 | #container { 10 | display: flex; 11 | animation: 0.2s linear FadeIn; 12 | } 13 | .icon { 14 | animation-duration: 1s; 15 | animation-iteration-count: infinite; 16 | animation-timing-function: linear; 17 | } 18 | .icon.rotate { 19 | animation-name: Rotate; 20 | } 21 | -------------------------------------------------------------------------------- /sample/00-src/style2.css: -------------------------------------------------------------------------------- 1 | @keyframes FadeIn { 2 | 0% {opacity: 0} 3 | 100% {opacity: 1} 4 | } 5 | @keyframes Rotate { 6 | 0% {transform: rotate( 0deg)} 7 | 100% {transform: rotate(360deg)} 8 | } 9 | #container { 10 | display: flex; 11 | animation: 0.2s linear FadeIn; 12 | } 13 | .icon { 14 | animation-duration: 1s; 15 | animation-iteration-count: infinite; 16 | animation-timing-function: linear; 17 | } 18 | .icon.rotate { 19 | animation-name: Rotate; 20 | } 21 | -------------------------------------------------------------------------------- /sample/01-mangle/style1.css: -------------------------------------------------------------------------------- 1 | @keyframes FadeIn { 2 | 0% {opacity: 0} 3 | 100% {opacity: 1} 4 | } 5 | @keyframes Rotate { 6 | 0% {transform: rotate( 0deg)} 7 | 100% {transform: rotate(360deg)} 8 | } 9 | #container { 10 | display: flex; 11 | animation: 0.2s linear FadeIn; 12 | } 13 | .icon { 14 | animation-duration: 1s; 15 | animation-iteration-count: infinite; 16 | animation-timing-function: linear; 17 | } 18 | .icon.rotate { 19 | animation-name: Rotate; 20 | } 21 | -------------------------------------------------------------------------------- /sample/01-mangle/style2.css: -------------------------------------------------------------------------------- 1 | @keyframes FadeIn { 2 | 0% {opacity: 0} 3 | 100% {opacity: 1} 4 | } 5 | @keyframes Rotate { 6 | 0% {transform: rotate( 0deg)} 7 | 100% {transform: rotate(360deg)} 8 | } 9 | #container { 10 | display: flex; 11 | animation: 0.2s linear FadeIn; 12 | } 13 | .icon { 14 | animation-duration: 1s; 15 | animation-iteration-count: infinite; 16 | animation-timing-function: linear; 17 | } 18 | .icon.rotate { 19 | animation-name: Rotate; 20 | } 21 | -------------------------------------------------------------------------------- /sample/02-no-mangle/style1.css: -------------------------------------------------------------------------------- 1 | @keyframes FadeIn { 2 | 0% {opacity: 0} 3 | 100% {opacity: 1} 4 | } 5 | @keyframes Rotate { 6 | 0% {transform: rotate( 0deg)} 7 | 100% {transform: rotate(360deg)} 8 | } 9 | #container { 10 | display: flex; 11 | animation: 0.2s linear FadeIn; 12 | } 13 | .icon { 14 | animation-duration: 1s; 15 | animation-iteration-count: infinite; 16 | animation-timing-function: linear; 17 | } 18 | .icon.rotate { 19 | animation-name: Rotate; 20 | } 21 | -------------------------------------------------------------------------------- /sample/02-no-mangle/style2.css: -------------------------------------------------------------------------------- 1 | @keyframes FadeIn { 2 | 0% {opacity: 0} 3 | 100% {opacity: 1} 4 | } 5 | @keyframes Rotate { 6 | 0% {transform: rotate( 0deg)} 7 | 100% {transform: rotate(360deg)} 8 | } 9 | #container { 10 | display: flex; 11 | animation: 0.2s linear FadeIn; 12 | } 13 | .icon { 14 | animation-duration: 1s; 15 | animation-iteration-count: infinite; 16 | animation-timing-function: linear; 17 | } 18 | .icon.rotate { 19 | animation-name: Rotate; 20 | } 21 | -------------------------------------------------------------------------------- /test-client/util/constants.ts: -------------------------------------------------------------------------------- 1 | import {name as projectName} from '../../package.json'; 2 | 3 | export {projectName}; 4 | export const buildName = `${projectName}#${process.env.GITHUB_RUN_ID || new Date().toISOString()}`; 5 | 6 | const userName = process.env.BROWSERSTACK_USERNAME; 7 | const accessKey = process.env.BROWSERSTACK_ACCESS_KEY; 8 | export const browserStack = userName && accessKey ? { 9 | userName, 10 | accessKey, 11 | server: 'http://hub-cloud.browserstack.com/wd/hub', 12 | } : null; 13 | -------------------------------------------------------------------------------- /src/postcssPlugin/getMatchedImport.ts: -------------------------------------------------------------------------------- 1 | import {normalizePath} from '../util/normalizePath'; 2 | import type {Imports} from './types'; 3 | 4 | export const getMatchedImport = ( 5 | value: string, 6 | imports: Imports, 7 | ): {key: string, from: string} | null => { 8 | const normalized = normalizePath(value); 9 | for (const [name, from] of imports) { 10 | if (normalized.startsWith(name)) { 11 | return {key: normalized.slice(name.length), from}; 12 | } 13 | } 14 | return null; 15 | }; 16 | -------------------------------------------------------------------------------- /src/runner/getExtensionOption.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import type {SessionConfiguration} from './types'; 3 | 4 | export const getExtensionOption = ( 5 | parameters: {ext?: string}, 6 | output: SessionConfiguration['output'], 7 | ): string => { 8 | let {ext} = parameters; 9 | if (!ext) { 10 | if (output.type === 'script') { 11 | ext = path.extname(output.path); 12 | } 13 | if (!ext) { 14 | ext = '.js'; 15 | } 16 | } 17 | return ext; 18 | }; 19 | -------------------------------------------------------------------------------- /sample/01-mangle/style1.css.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // This file is generated by esifycss. 3 | import {addStyle} from './helper.js'; 4 | addStyle(["WYIGuBCOQCaAOEcQCaAUEE","WYIGwBCOQCeAgBiBIIOkBmBEcQCeAgBiByBkBmBEE","0BGOC2BA4BSMA6BoBIqBIGuBE","sBGUCMK8BAUoBSMK+BKgCAiCSMKkCKmCAqBE","sBGuCGwCCMKoCAGwBE"]); 5 | export const className = { 6 | "icon": "_1", 7 | "rotate": "_2" 8 | }; 9 | export const id = { 10 | "container": "_0" 11 | }; 12 | export const keyframes = { 13 | "FadeIn": "_3", 14 | "Rotate": "_4" 15 | }; 16 | -------------------------------------------------------------------------------- /sample/01-mangle/style2.css.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // This file is generated by esifycss. 3 | import {addStyle} from './helper.js'; 4 | addStyle(["WYIGqCCOQCaAOEcQCaAUEE","WYIGsCCOQCeAgBiBIIOkBmBEcQCeAgBiByBkBmBEE","0BGyCC2BA4BSMA6BoBIqBIGqCE","sBG0CCMK8BAUoBSMK+BKgCAiCSMKkCKmCAqBE","sBG2CG4CCMKoCAGsCE"]); 5 | export const className = { 6 | "icon": "_6", 7 | "rotate": "_7" 8 | }; 9 | export const id = { 10 | "container": "_5" 11 | }; 12 | export const keyframes = { 13 | "FadeIn": "_8", 14 | "Rotate": "_9" 15 | }; 16 | -------------------------------------------------------------------------------- /test-client/typescript-rollup/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitReturns": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "outDir": "./temp" 14 | }, 15 | "include": [ 16 | "src/**/*.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/minifier/types.ts: -------------------------------------------------------------------------------- 1 | export interface Range { 2 | start: number, 3 | end: number, 4 | } 5 | 6 | export interface CSSRange extends Range { 7 | css: string, 8 | } 9 | 10 | export interface ParseResult { 11 | ranges: Array, 12 | expressionStatements: Array, 13 | importDeclarations: Array, 14 | } 15 | 16 | export interface ScriptData extends ParseResult { 17 | script: string, 18 | } 19 | 20 | export interface ParseScriptsResult { 21 | scripts: Map, 22 | tokens: Map, 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/test-client.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - pull_request 3 | jobs: 4 | TestClient: 5 | runs-on: ubuntu-latest 6 | concurrency: test-client 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-node@v3 10 | with: 11 | node-version: 18.x 12 | cache: npm 13 | - run: npm ci 14 | - run: npm run build 15 | - run: npm run test-client 16 | env: 17 | BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} 18 | BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 19 | -------------------------------------------------------------------------------- /sample/02-no-mangle/style1.css.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // This file is generated by esifycss. 3 | import {addStyle} from './helper.js'; 4 | addStyle(["YaIcG0BCOQCeAOEgBQCeAWEE","YaIiBG2BCOQCkBASmBIIOoBqBEgBQCkBASmB4BoBqBEE","6B8BGOC+BAgCUMAiCsBIuBIcG0BE","wByBGWCMKkCAWsBUMKmCKoCAqCUMKsCKuCAuBE","wByBG2CSG4CCMKwCAiBG2BE"]); 5 | export const className = { 6 | "icon": "icon_1", 7 | "rotate": "rotate_2" 8 | }; 9 | export const id = { 10 | "container": "container_0" 11 | }; 12 | export const keyframes = { 13 | "FadeIn": "FadeIn_3", 14 | "Rotate": "Rotate_4" 15 | }; 16 | -------------------------------------------------------------------------------- /sample/02-no-mangle/style2.css.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // This file is generated by esifycss. 3 | import {addStyle} from './helper.js'; 4 | addStyle(["YaIcGyCCOQCeAOEgBQCeAWEE","YaIiBG0CCOQCkBASmBIIOoBqBEgBQCkBASmB4BoBqBEE","6B8BG6CC+BAgCUMAiCsBIuBIcGyCE","wByBG8CCMKkCAWsBUMKmCKoCAqCUMKsCKuCAuBE","wByBG+CSGgDCMKwCAiBG0CE"]); 5 | export const className = { 6 | "icon": "icon_6", 7 | "rotate": "rotate_7" 8 | }; 9 | export const id = { 10 | "container": "container_5" 11 | }; 12 | export const keyframes = { 13 | "FadeIn": "FadeIn_8", 14 | "Rotate": "Rotate_9" 15 | }; 16 | -------------------------------------------------------------------------------- /src/runner/getCSSParserConfiguration.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import type {CSSParserParameters, CSSParserConfigurations} from './types'; 3 | 4 | const {readFile} = fs.promises; 5 | 6 | export const getCSSParserConfiguration = async ( 7 | parameters: CSSParserParameters, 8 | ): Promise => ({ 9 | css: `${parameters.css || await readFile(parameters.file)}`, 10 | plugins: parameters.plugins, 11 | options: { 12 | ...parameters.options, 13 | from: parameters.file, 14 | map: parameters.map || {}, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/util/createExposedPromise.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {createExposedPromise} from './createExposedPromise'; 3 | 4 | test('#0 resolve', async (t) => { 5 | const exposed = createExposedPromise(); 6 | exposed.resolve(); 7 | await exposed.promise; 8 | t.pass(); 9 | }); 10 | 11 | test('#1 reject', async (t) => { 12 | const exposed = createExposedPromise(); 13 | const message = 'Expected'; 14 | exposed.reject(new Error(message)); 15 | const error = await t.throwsAsync(exposed.promise); 16 | t.is(error && error.message, message); 17 | }); 18 | -------------------------------------------------------------------------------- /src/util/tokenizeString.test.ts: -------------------------------------------------------------------------------- 1 | import ava from 'ava'; 2 | import {tokenizeString} from './tokenizeString'; 3 | 4 | interface Test { 5 | input: string, 6 | expected: Array, 7 | } 8 | 9 | ([ 10 | {input: 'foo', expected: ['foo']}, 11 | {input: 'width:100px', expected: ['width', ':', '100', 'px']}, 12 | ] as Array).forEach(({input, expected}, index) => { 13 | ava(`#${index} ${JSON.stringify(input)} → ${expected.join('|')}`, (t) => { 14 | t.deepEqual( 15 | [...tokenizeString(input)], 16 | expected, 17 | ); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | jobs: 5 | Publish: 6 | runs-on: ubuntu-latest 7 | environment: deployment 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 18.x 13 | cache: npm 14 | registry-url: https://registry.npmjs.org 15 | - run: npm ci 16 | - run: npm run build 17 | - run: npx @nlib/cleanup-package-json --file package.json 18 | - run: npm publish 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "allowJs": true, 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "removeComments": true, 10 | "declaration": true, 11 | "sourceMap": false, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true 16 | }, 17 | "exclude": [ 18 | "./lib/**/*", 19 | "./sample/**/*" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/util/ensureArray.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {ensureArray} from './ensureArray'; 3 | 4 | interface Test { 5 | input: Array | number, 6 | expected: Array, 7 | } 8 | 9 | ([ 10 | {input: 0, expected: [0]}, 11 | {input: [0], expected: [0]}, 12 | {input: [0, 1], expected: [0, 1]}, 13 | ] as Array).forEach(({input, expected}, index) => { 14 | test(`#${index} ensureArray(${JSON.stringify(input)}) → ${JSON.stringify(expected)}`, (t) => { 15 | t.deepEqual( 16 | ensureArray(input), 17 | expected, 18 | ); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/util/createTemporaryDirectory.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {createTemporaryDirectory} from './createTemporaryDirectory'; 3 | 4 | interface Test { 5 | prefix?: string, 6 | expected: RegExp, 7 | } 8 | 9 | ([ 10 | {expected: /node-tmp-/}, 11 | {prefix: 'foo-temporary-', expected: /foo-temporary-/}, 12 | ] as Array).forEach(({prefix, expected}, index) => { 13 | test(`#${index} createTemporaryDirectory(${JSON.stringify(prefix)}) → ${JSON.stringify(expected)}`, async (t) => { 14 | const actual = await createTemporaryDirectory(prefix); 15 | t.true(expected.test(actual)); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/util/encodeString.ts: -------------------------------------------------------------------------------- 1 | import * as vlq from 'vlq'; 2 | import type {IdGenerator} from './createIdGenerator'; 3 | import {tokenizeString} from './tokenizeString'; 4 | 5 | export const encodeString = ( 6 | string: string, 7 | idGenerator: IdGenerator, 8 | ): string => { 9 | const encoded: Array = []; 10 | for (const token of tokenizeString(string)) { 11 | encoded.push(idGenerator(token)); 12 | } 13 | return vlq.encode(encoded); 14 | }; 15 | 16 | export const decodeString = ( 17 | encoded: string, 18 | words: Array, 19 | ): string => vlq.decode(encoded).map((index) => words[index]).join(''); 20 | -------------------------------------------------------------------------------- /test-client/typescript-rollup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build:esifycss": "esifycss --helper src/helper.css.ts src/**/*.css", 5 | "build:tsc": "tsc", 6 | "build:rollup": "rollup temp/page.js --format iife --file output/page.js", 7 | "build:html": "cpy --cwd=src index.html ../output", 8 | "build": "run-s build:esifycss build:tsc build:rollup build:html" 9 | }, 10 | "devDependencies": { 11 | "cpy-cli": "4.2.0", 12 | "esifycss": "file:../..", 13 | "npm-run-all": "4.1.5", 14 | "rollup": "3.29.4", 15 | "typescript": "4.9.5" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sample/plugin.js: -------------------------------------------------------------------------------- 1 | const postcss = require('postcss'); 2 | const esifycss = require('..'); 3 | const css = ` 4 | @keyframes aaa { 5 | 0% { 6 | color: red; 7 | } 8 | 100% { 9 | color: green; 10 | } 11 | } 12 | #foo>.bar { 13 | animation-name: aaa; 14 | } 15 | `; 16 | postcss([ 17 | esifycss.plugin(), 18 | ]) 19 | .process(css, {from: '/foo/bar.css'}) 20 | .then((result) => { 21 | const pluginResult = esifycss.extractPluginResult(result); 22 | console.log(pluginResult); 23 | // → { 24 | // className: {bar: '_1'}, 25 | // id: {foo: '_0'}, 26 | // keyframes: {aaa: '_2'}, 27 | // } 28 | }); 29 | -------------------------------------------------------------------------------- /sample/00-src/style1.css.js: -------------------------------------------------------------------------------- 1 | import {addStyle} from '../01-mangle/helper.js'; 2 | addStyle([{$$esifycss: "@keyframes _v{0%{opacity:0}100%{opacity:1}}"},{$$esifycss: "@keyframes _w{0%{transform:rotate( 0deg)}100%{transform:rotate(360deg)}}"},{$$esifycss: "#_s{display:flex;animation:.2s linear _v}"},{$$esifycss: "._t{animation-duration:1s;animation-iteration-count:infinite;animation-timing-function:linear}"},{$$esifycss: "._t._u{animation-name:_w}"}]); 3 | export const className = { 4 | "icon": "_t", 5 | "rotate": "_u" 6 | }; 7 | export const id = { 8 | "container": "_s" 9 | }; 10 | export const keyframes = { 11 | "FadeIn": "_v", 12 | "Rotate": "_w" 13 | }; 14 | -------------------------------------------------------------------------------- /sample/00-src/style2.css.js: -------------------------------------------------------------------------------- 1 | import {addStyle} from '../01-mangle/helper.js'; 2 | addStyle([{$$esifycss: "@keyframes _q{0%{opacity:0}100%{opacity:1}}"},{$$esifycss: "@keyframes _r{0%{transform:rotate( 0deg)}100%{transform:rotate(360deg)}}"},{$$esifycss: "#_n{display:flex;animation:.2s linear _q}"},{$$esifycss: "._o{animation-duration:1s;animation-iteration-count:infinite;animation-timing-function:linear}"},{$$esifycss: "._o._p{animation-name:_r}"}]); 3 | export const className = { 4 | "icon": "_o", 5 | "rotate": "_p" 6 | }; 7 | export const id = { 8 | "container": "_n" 9 | }; 10 | export const keyframes = { 11 | "FadeIn": "_q", 12 | "Rotate": "_r" 13 | }; 14 | -------------------------------------------------------------------------------- /src/minifier/setDictionary.ts: -------------------------------------------------------------------------------- 1 | export const setDictionary = ( 2 | code: string, 3 | words: Array, 4 | ): string => { 5 | const longestWordLength = 2 + words.reduce((longest, {length}) => longest < length ? length : longest, 0); 6 | const dictionarySize = words.length; 7 | const step = 10 <= 120 / longestWordLength ? 10 : 5; 8 | const lines: Array = []; 9 | for (let offset = 0; offset < dictionarySize; offset += step) { 10 | lines.push(`${words.slice(offset, offset + step).map((word) => JSON.stringify(word).padStart(longestWordLength)).join(', ')},`); 11 | } 12 | return code.replace(/\[\s*(['"])ESIFYCSS DICTIONARY\1\s*\]/, `[\n${lines.join('\n')}\n]`); 13 | }; 14 | -------------------------------------------------------------------------------- /src/minifier/minifyCSSInScript.ts: -------------------------------------------------------------------------------- 1 | import type {IdGenerator} from '../util/createIdGenerator'; 2 | import {encodeString} from '../util/encodeString'; 3 | import type {CSSRange} from './types'; 4 | 5 | export const minifyCSSInScript = ( 6 | script: string, 7 | cssRanges: Array, 8 | idGenerator: IdGenerator, 9 | ): string => { 10 | let minified = script; 11 | for (let index = cssRanges.length; index--;) { 12 | const range = cssRanges[index]; 13 | minified = [ 14 | minified.slice(0, range.start), 15 | JSON.stringify(encodeString(range.css, idGenerator)), 16 | minified.slice(range.end), 17 | ].join(''); 18 | } 19 | return minified; 20 | }; 21 | -------------------------------------------------------------------------------- /src/util/createExposedPromise.ts: -------------------------------------------------------------------------------- 1 | type Resolve = () => void; 2 | type Reject = (error: unknown) => void; 3 | 4 | export interface ExposedPromise { 5 | readonly promise: Promise, 6 | readonly resolve: Resolve, 7 | readonly reject: Reject, 8 | } 9 | 10 | export const createExposedPromise = (): ExposedPromise => { 11 | let resolve: Resolve | null = null; 12 | let reject: Reject | null = null; 13 | const promise = new Promise((res, rej) => { 14 | resolve = res; 15 | reject = rej; 16 | }); 17 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 18 | if (resolve && reject) { 19 | return {promise, resolve, reject}; 20 | } 21 | throw new Error('NoResolver'); 22 | }; 23 | -------------------------------------------------------------------------------- /src/postcssPlugin/getImports.ts: -------------------------------------------------------------------------------- 1 | import type * as postcss from 'postcss'; 2 | import {parseImport} from './parseImport'; 3 | import type {Imports} from './types'; 4 | 5 | export const getImports = ( 6 | root: postcss.Root, 7 | id: string, 8 | ): Imports => { 9 | const imports: Imports = new Map(); 10 | root.walkAtRules((rule) => { 11 | const {name} = rule; 12 | if (name === 'import') { 13 | const parsed = parseImport(rule.params, id); 14 | if (parsed) { 15 | imports.set(parsed.localName, parsed.from); 16 | rule.remove(); 17 | } 18 | } 19 | Object.assign(rule.raws, {before: '', between: '', after: ''}); 20 | }); 21 | return imports; 22 | }; 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: gjbkz # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /src/runner/getIncludePatterns.ts: -------------------------------------------------------------------------------- 1 | export const getIncludePatterns = ( 2 | {include, extensions}: { 3 | include: Array, 4 | extensions: Array, 5 | }, 6 | ) => { 7 | const includePatterns = new Set(); 8 | for (const includePattern of include) { 9 | for (const ext of extensions) { 10 | if (includePattern.endsWith(ext)) { 11 | includePatterns.add(includePattern); 12 | } else { 13 | includePatterns.add( 14 | includePattern 15 | .replace(/[/*]*$/, `/**/*${ext}`) 16 | .replace(/^\/\*/, '*'), 17 | ); 18 | } 19 | } 20 | } 21 | return [...includePatterns]; 22 | }; 23 | -------------------------------------------------------------------------------- /src/runner/getOutputOption.ts: -------------------------------------------------------------------------------- 1 | import {getBase64UrlHash} from '../util/getBase64UrlHash'; 2 | import type {SessionConfiguration, SessionOutput} from './types'; 3 | 4 | export const getOutputOption = ( 5 | {helper, css}: {helper?: string, css?: string}, 6 | include: Array, 7 | ): SessionConfiguration['output'] => { 8 | let output: SessionOutput | undefined; 9 | if (css) { 10 | if (helper) { 11 | throw new Error(`You can't use options.helper (${helper}) with options.css (${css})`); 12 | } 13 | output = {type: 'css', path: css}; 14 | } else { 15 | const scriptPath = helper || `helper.${getBase64UrlHash(...include)}.css.js`; 16 | output = {type: 'script', path: scriptPath}; 17 | } 18 | return output; 19 | }; 20 | -------------------------------------------------------------------------------- /src/postcssPlugin/parseImport.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import {normalizePath} from '../util/normalizePath'; 3 | 4 | export const parseImport = ( 5 | parameter: string, 6 | id: string, 7 | ): {from: string, localName: string} | null => { 8 | const matched = (/^(?:url\(\s*)?(['"])(.+)\1\s*\)?\s*/).exec(parameter); 9 | if (matched) { 10 | const localName = parameter.slice(matched[0].length); 11 | if ((/^[\w-]+$/).exec(localName)) { 12 | const from = matched[2]; 13 | if (from.startsWith('.')) { 14 | return { 15 | from: normalizePath(path.join(path.dirname(id), ...from.split(/\//))), 16 | localName, 17 | }; 18 | } 19 | } 20 | } 21 | return null; 22 | }; 23 | -------------------------------------------------------------------------------- /src/runner/getChokidarOptions.ts: -------------------------------------------------------------------------------- 1 | import type {WatchOptions} from 'chokidar'; 2 | import {ensureArray} from '../util/ensureArray'; 3 | import type {SessionOptions, ReadonlyWatchOptions} from './types'; 4 | 5 | export const getChokidarOptions = ( 6 | {chokidar: chokidarOptions = {}, exclude, css}: SessionOptions, 7 | ): ReadonlyWatchOptions => { 8 | const options: WatchOptions = { 9 | ...chokidarOptions, 10 | ignoreInitial: false, 11 | alwaysStat: true, 12 | }; 13 | const ignored = [ 14 | ...ensureArray(chokidarOptions.ignored as Array), 15 | ...ensureArray(exclude), 16 | ]; 17 | if (css) { 18 | ignored.push(css); 19 | } 20 | if (0 < ignored.length) { 21 | options.ignored = ignored; 22 | } 23 | return options; 24 | }; 25 | -------------------------------------------------------------------------------- /src/util/createIdGenerator.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {createIdentifier} from './createIdGenerator'; 3 | 4 | test('#0 without listener', (t) => { 5 | const identifier = createIdentifier(); 6 | const id1 = identifier('foo'); 7 | const id2 = identifier('bar'); 8 | t.true(id1 !== id2); 9 | const id3 = identifier('foo'); 10 | t.is(id3, id1); 11 | }); 12 | 13 | test('#1 with listener', (t) => { 14 | const log: Record = {}; 15 | const identifier = createIdentifier((key, id) => { 16 | log[key] = id; 17 | }); 18 | const id1 = identifier('foo'); 19 | const id2 = identifier('bar'); 20 | t.true(id1 !== id2); 21 | const id3 = identifier('foo'); 22 | t.is(id3, id1); 23 | t.deepEqual(log, { 24 | foo: id1, 25 | bar: id2, 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/util/deleteFile.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | 4 | const {unlink, readdir, rmdir} = fs.promises; 5 | 6 | export const deleteFile = async ( 7 | filePath: string, 8 | ): Promise => { 9 | try { 10 | await unlink(filePath); 11 | } catch (error: unknown) { 12 | switch ((error as {code?: string}).code) { 13 | case 'ENOENT': 14 | return; 15 | case 'EISDIR': 16 | case 'EPERM': { 17 | const files = (await readdir(filePath)).map((name) => path.join(filePath, name)); 18 | await Promise.all(files.map(async (file) => { 19 | await deleteFile(file); 20 | })); 21 | await rmdir(filePath); 22 | break; 23 | } 24 | default: 25 | throw error; 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/runner/extractPluginResult.ts: -------------------------------------------------------------------------------- 1 | import type * as postcss from 'postcss'; 2 | import type {EsifyCSSResult} from '../postcssPlugin/types'; 3 | import {PluginName} from '../postcssPlugin/plugin'; 4 | 5 | export const extractPluginResult = ( 6 | postcssResult: postcss.Result, 7 | ): EsifyCSSResult => { 8 | const pluginResult: EsifyCSSResult = { 9 | className: {}, 10 | id: {}, 11 | keyframes: {}, 12 | }; 13 | for (const warning of postcssResult.warnings()) { 14 | if (warning.plugin === PluginName) { 15 | const result = JSON.parse(warning.text) as EsifyCSSResult; 16 | Object.assign(pluginResult.className, result.className); 17 | Object.assign(pluginResult.id, result.id); 18 | Object.assign(pluginResult.keyframes, result.keyframes); 19 | } 20 | } 21 | return pluginResult; 22 | }; 23 | -------------------------------------------------------------------------------- /src/util/updateFile.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import {ignoreNoEntryError} from './ignoreNoEntryError'; 4 | 5 | export const updateFile = async (filePath: string, source: Uint8Array | string): Promise => { 6 | await ensureDirectory(path.dirname(filePath)); 7 | const buffer = Buffer.from(source); 8 | const currentBuffer = await fs.promises.readFile(filePath).catch(ignoreNoEntryError); 9 | if (currentBuffer && buffer.equals(currentBuffer)) { 10 | return; 11 | } 12 | await fs.promises.writeFile(filePath, buffer); 13 | }; 14 | 15 | const ensureDirectory = async (directory: string) => { 16 | const directoryStats = await fs.promises.stat(directory).catch(ignoreNoEntryError); 17 | if (!directoryStats || !directoryStats.isDirectory()) { 18 | await fs.promises.mkdir(directory, {recursive: true}); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/postcssPlugin/getPluginConfiguration.ts: -------------------------------------------------------------------------------- 1 | import {createIdentifier} from '../util/createIdGenerator'; 2 | import type { 3 | PluginOptions, 4 | PluginConfiguration, 5 | PluginMangler, 6 | } from './types'; 7 | 8 | export const getPluginMangler = ( 9 | { 10 | mangle = true, 11 | identifier = createIdentifier(), 12 | }: PluginOptions, 13 | ): PluginMangler => { 14 | if (mangle) { 15 | return (id, type, name) => `_${identifier(`${id}-${type}-${name}`).toString(36)}`; 16 | } else { 17 | return (id, type, name) => `${name}_${identifier(`${id}-${type}-${name}`).toString(36)}`; 18 | } 19 | }; 20 | 21 | export const getPluginConfiguration = ( 22 | parameters: PluginOptions = {}, 23 | ): PluginConfiguration => ({ 24 | mangler: parameters.mangler || getPluginMangler(parameters), 25 | rawPrefix: parameters.rawPrefix || 'raw-', 26 | }); 27 | -------------------------------------------------------------------------------- /src/runner/getExtensionOption.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {getExtensionOption} from './getExtensionOption'; 3 | 4 | interface Test { 5 | input: Parameters, 6 | expected: ReturnType, 7 | } 8 | 9 | ([ 10 | {input: [{ext: undefined}, {type: 'script', path: 'test.js'}], expected: '.js'}, 11 | {input: [{ext: undefined}, {type: 'script', path: 'test.ts'}], expected: '.ts'}, 12 | {input: [{ext: '.js'}, {type: 'script', path: 'test.ts'}], expected: '.js'}, 13 | {input: [{ext: undefined}, {type: 'css', path: 'test.css'}], expected: '.js'}, 14 | {input: [{ext: '.ts'}, {type: 'css', path: 'test.css'}], expected: '.ts'}, 15 | ] as Array).forEach(({input, expected}, index) => { 16 | test(`#${index} ${JSON.stringify(input)} → ${expected}`, (t) => { 17 | t.is(getExtensionOption(...input), expected); 18 | }); 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /src/util/tokenizeString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Splits the input string for minifying CSS strings. 3 | * If the input is "width:100px", the output will be "width" ":" "100" "px" 4 | */ 5 | export const tokenizeString = function* (string: string): Generator { 6 | let pos = 0; 7 | const REGEXP = /[.0-9]+|[^a-zA-Z]/g; 8 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 9 | while (1) { 10 | const match = REGEXP.exec(string); 11 | if (match) { 12 | const {0: matched, index} = match; 13 | if (pos < index) { 14 | yield string.slice(pos, index); 15 | } 16 | yield matched; 17 | } else { 18 | const rest = string.slice(pos).trim(); 19 | if (rest) { 20 | yield rest; 21 | } 22 | break; 23 | } 24 | pos = REGEXP.lastIndex; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /scripts/chmodScripts.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-import-module-exports */ 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | 5 | const afs = fs.promises; 6 | 7 | export const permitExecution = async ( 8 | file: string, 9 | ): Promise => { 10 | const stats = await afs.stat(file); 11 | await afs.chmod(file, stats.mode | fs.constants.S_IXUSR); 12 | }; 13 | 14 | export const chmodScripts = async (): Promise => { 15 | const binDirectory = path.join(__dirname, '../lib/bin'); 16 | const files = (await afs.readdir(binDirectory)) 17 | .filter((name) => path.extname(name) === '.js') 18 | .map((name) => path.join(binDirectory, name)); 19 | await Promise.all(files.map(permitExecution)); 20 | }; 21 | 22 | if (!module.parent) { 23 | chmodScripts() 24 | .catch((error) => { 25 | process.stderr.write(`${error}`); 26 | process.exit(1); 27 | }); 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/runner/getIncludePatterns.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {getIncludePatterns} from './getIncludePatterns'; 3 | 4 | interface Test { 5 | input: Parameters, 6 | expected: ReturnType, 7 | } 8 | 9 | ([ 10 | {input: [{include: ['*'], extensions: ['.css']}], expected: ['**/*.css']}, 11 | {input: [{include: ['aaa.css'], extensions: ['.css']}], expected: ['aaa.css']}, 12 | {input: [{include: ['aaa.test'], extensions: ['.css']}], expected: ['aaa.test/**/*.css']}, 13 | { 14 | input: [{include: ['aaa.test'], extensions: ['.css', '.scss']}], 15 | expected: ['aaa.test/**/*.css', 'aaa.test/**/*.scss'], 16 | }, 17 | ] as Array).forEach(({input, expected}, index) => { 18 | test(`#${index} ${JSON.stringify(input)} → ${expected}`, (t) => { 19 | t.deepEqual(getIncludePatterns(...input), expected); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/postcssPlugin/getDependencies.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import type * as postcss from 'postcss'; 3 | 4 | export const REGEX_IMPORT = /^(['"])([^\s'"]+)\1\s*([^\s]*)$/; 5 | 6 | export const getDependencies = ( 7 | root: postcss.Root, 8 | id: string, 9 | ): Map => { 10 | const dependencies = new Map(); 11 | const directory = path.dirname(id); 12 | let count = 0; 13 | root.walkAtRules((node): void => { 14 | if (node.name === 'import') { 15 | const match = REGEX_IMPORT.exec(node.params); 16 | if (match) { 17 | count++; 18 | const [,, target, name = `$${count}`] = match; 19 | dependencies.set( 20 | name, 21 | path.resolve(target, directory), 22 | ); 23 | node.remove(); 24 | } 25 | } 26 | }); 27 | return dependencies; 28 | }; 29 | -------------------------------------------------------------------------------- /src/postcssPlugin/minify.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as postcss from 'postcss'; 3 | import {minify} from './minify'; 4 | 5 | interface Test { 6 | css: string, 7 | expected: string, 8 | } 9 | 10 | ([ 11 | { 12 | css: '\n\n\n', 13 | expected: '', 14 | }, 15 | { 16 | css: [ 17 | ' /* comment */', 18 | ' @import url("foo.css") print ; ;', 19 | ' /* comment */', 20 | '\n.foo #bar > div { ; ; foo : bar ; ; bar : "foo" ; ;; } ;\n\n', 21 | ' /* comment */', 22 | ].join('\n;;\n'), 23 | expected: [ 24 | '@import url("foo.css") print;', 25 | '.foo #bar>div{foo:bar;bar:"foo"}', 26 | ].join(''), 27 | }, 28 | ] as Array).forEach(({css, expected}, index) => { 29 | test(`#${index + 1} ${expected}`, (t) => { 30 | const root = minify(postcss.parse(css)); 31 | t.is(root.toString(), expected); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | jobs: 5 | Lint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-node@v3 10 | with: 11 | node-version: 18.x 12 | cache: npm 13 | - run: npm ci 14 | - run: npm run lint 15 | Test: 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ubuntu-latest, macos-latest, windows-latest] 20 | node: [18.x, 16.x, 14.x] 21 | runs-on: ${{ matrix.os }} 22 | steps: 23 | - run: git config --global core.autocrlf false 24 | - run: git config --global core.eol lf 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node }} 29 | cache: npm 30 | - run: npm ci 31 | - run: npx c8 npm test 32 | - run: npx c8 report --reporter=text-lcov > coverage.lcov 33 | - uses: codecov/codecov-action@v3 34 | -------------------------------------------------------------------------------- /src/postcssPlugin/removeImportsAndRaws.ts: -------------------------------------------------------------------------------- 1 | import type {Imports, EsifyCSSResult, IdentifierMap} from './types'; 2 | import {getMatchedImport} from './getMatchedImport'; 3 | 4 | export const removeImportsAndRaws = ( 5 | {result: maps, imports, rawPrefix}: { 6 | result: EsifyCSSResult, 7 | imports: Imports, 8 | rawPrefix: string, 9 | }, 10 | ): EsifyCSSResult => { 11 | const isLocalTransformedIdentifier = (key: string): boolean => { 12 | return !key.startsWith(rawPrefix) && !getMatchedImport(key, imports); 13 | }; 14 | const filter = (map: IdentifierMap): IdentifierMap => Object.keys(map) 15 | .filter(isLocalTransformedIdentifier) 16 | .reduce( 17 | (result, key) => { 18 | result[key] = map[key]; 19 | return result; 20 | }, 21 | {}, 22 | ); 23 | return { 24 | className: filter(maps.className), 25 | id: filter(maps.id), 26 | keyframes: filter(maps.keyframes), 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/postcssPlugin/plugin.ts: -------------------------------------------------------------------------------- 1 | import type {Plugin, Root} from 'postcss'; 2 | import type {PluginOptions} from './types'; 3 | import {getPluginConfiguration} from './getPluginConfiguration'; 4 | import {createTransformer} from './createTransformer'; 5 | 6 | export const PluginName = 'esifycss'; 7 | 8 | /** 9 | * PostCSS plugin for EsifyCSS 10 | */ 11 | export const plugin = Object.assign( 12 | (props?: PluginOptions): Plugin => { 13 | const transform = createTransformer(getPluginConfiguration(props)); 14 | const cache = new WeakMap(); 15 | return { 16 | postcssPlugin: PluginName, 17 | Root: async (root, {result}) => { 18 | let cached = cache.get(root); 19 | if (!cached) { 20 | cached = JSON.stringify(await transform(root, result)); 21 | cache.set(root, cached); 22 | } 23 | result.warn(cached); 24 | }, 25 | }; 26 | }, 27 | {postcss: true}, 28 | ); 29 | -------------------------------------------------------------------------------- /src/minifier/minifyScripts.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import {updateFile} from '../util/updateFile'; 3 | import {createOptimizedIdGenerator} from './createOptimizedIdGenerator'; 4 | import {parseScripts} from './parseScripts'; 5 | import {minifyCSSInScript} from './minifyCSSInScript'; 6 | import {setDictionary} from './setDictionary'; 7 | 8 | const {readFile} = fs.promises; 9 | 10 | export const minifyScripts = async ( 11 | props: { 12 | files: Map, 13 | cssKey: string, 14 | dest: string, 15 | }, 16 | ): Promise => { 17 | const parseResult = await parseScripts(props); 18 | const identifier = createOptimizedIdGenerator(parseResult.tokens); 19 | await Promise.all([...parseResult.scripts].map(async ([file, {script, ranges}]) => { 20 | const minified = minifyCSSInScript(script, ranges, identifier); 21 | await updateFile(file, minified); 22 | })); 23 | const helperCode = setDictionary(await readFile(props.dest, 'utf8'), identifier.idList); 24 | await updateFile(props.dest, helperCode); 25 | }; 26 | -------------------------------------------------------------------------------- /src/util/createIdGenerator.ts: -------------------------------------------------------------------------------- 1 | export interface IdGenerator { 2 | readonly idList: Array, 3 | (key: string): number, 4 | } 5 | 6 | interface IdListener { 7 | (key: string, id: number): void, 8 | } 9 | 10 | export const createIdentifier = ( 11 | listener: IdListener = () => { 12 | // noop 13 | }, 14 | ): IdGenerator => { 15 | const knownIdList = new Map(); 16 | let count = 0; 17 | return Object.defineProperty( 18 | (key: string): number => { 19 | let id = knownIdList.get(key); 20 | if (typeof id === 'undefined') { 21 | id = count++; 22 | knownIdList.set(key, id); 23 | listener(key, id); 24 | } 25 | return id; 26 | }, 27 | 'idList', 28 | { 29 | get: () => { 30 | const result: Array = []; 31 | for (const [id, index] of knownIdList) { 32 | result[index] = id; 33 | } 34 | return result; 35 | }, 36 | }, 37 | ) as IdGenerator; 38 | }; 39 | -------------------------------------------------------------------------------- /scripts/copy.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-import-module-exports */ 2 | import * as fs from 'fs/promises'; 3 | import * as path from 'path'; 4 | 5 | /** 6 | * @param {string} src 7 | * @param {string} dest 8 | */ 9 | export const copy = async ( 10 | src: string, 11 | dest: string, 12 | ): Promise => { 13 | src = path.normalize(src); 14 | dest = path.normalize(dest); 15 | if ((await fs.stat(src)).isDirectory()) { 16 | await fs.mkdir(dest, {recursive: true}); 17 | await Promise.all((await fs.readdir(src)).map(async (name) => { 18 | const srcFile = path.join(src, name); 19 | const destFile = path.join(dest, name); 20 | await fs.copyFile(srcFile, destFile); 21 | console.log(`Copied: ${srcFile} → ${destFile}`); 22 | })); 23 | } else { 24 | await fs.copyFile(src, dest); 25 | console.log(`Copied: ${src} → ${dest}`); 26 | } 27 | }; 28 | 29 | if (!module.parent) { 30 | const [src, dest] = process.argv.slice(-2); 31 | copy(src, dest) 32 | .catch((error) => { 33 | console.error(error); 34 | process.exit(1); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /test-client/util/spawn.ts: -------------------------------------------------------------------------------- 1 | import * as childProcess from 'child_process'; 2 | import type * as stream from 'stream'; 3 | 4 | interface SpawnParameters { 5 | command: string, 6 | args?: Array, 7 | options?: childProcess.SpawnOptionsWithoutStdio, 8 | stdout?: stream.Writable, 9 | stderr?: stream.Writable, 10 | } 11 | 12 | export const spawn = async (parameters: SpawnParameters): Promise => { 13 | await new Promise((resolve, reject) => { 14 | const subProcess = childProcess.spawn( 15 | parameters.command, 16 | parameters.args || [], 17 | parameters.options || {}, 18 | ) 19 | .once('error', reject) 20 | .once('exit', (code) => { 21 | if (code === 0) { 22 | resolve(); 23 | } else { 24 | reject(new Error(`The command "${parameters.command}" exited with code ${code}.`)); 25 | } 26 | }); 27 | if (subProcess.stdout) { 28 | subProcess.stdout.pipe(parameters.stdout || process.stdout); 29 | } 30 | if (subProcess.stderr) { 31 | subProcess.stderr.pipe(parameters.stderr || process.stderr); 32 | } 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /test-client/util/markResult.ts: -------------------------------------------------------------------------------- 1 | import type * as http from 'http'; 2 | import * as https from 'https'; 3 | import type * as selenium from 'selenium-webdriver'; 4 | import {browserStack} from './constants'; 5 | 6 | export const markResult = async ( 7 | session: selenium.Session, 8 | passed: boolean, 9 | ): Promise => { 10 | if (browserStack) { 11 | const sessionId = session.getId(); 12 | const endpoint = `https://${browserStack.userName}:${browserStack.accessKey}@api.browserstack.com/automate/sessions/${sessionId}.json`; 13 | const res = await new Promise((resolve, reject) => { 14 | const req = https.request(endpoint, { 15 | method: 'PUT', 16 | headers: {'content-type': 'application/json'}, 17 | }); 18 | req.once('error', reject); 19 | req.once('response', resolve); 20 | req.end(JSON.stringify({status: passed ? 'passed' : 'failed'})); 21 | }); 22 | console.info(`${res.statusCode} ${res.statusMessage}`); 23 | for await (const data of res) { 24 | console.info(`${data}`); 25 | } 26 | } else { 27 | console.info('markResult:Skipped'); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/bin/loadParameters.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import type {SessionOptions} from '../runner/types.js'; 4 | 5 | const {readFile} = fs.promises; 6 | 7 | interface EsifyCSSCommandOptions { 8 | exclude: Array, 9 | helper: string, 10 | config: string, 11 | noMangle: boolean, 12 | watch: boolean, 13 | css: string, 14 | ext: string, 15 | } 16 | 17 | export const loadParameters = async ( 18 | include: Array, 19 | args: EsifyCSSCommandOptions, 20 | directory: string = process.cwd(), 21 | ): Promise => { 22 | const parameters: Partial = { 23 | include, 24 | helper: args.helper, 25 | css: args.css, 26 | ext: args.ext, 27 | exclude: args.exclude, 28 | esifycssPluginParameter: { 29 | mangle: !args.noMangle, 30 | }, 31 | watch: args.watch, 32 | }; 33 | if (args.config) { 34 | const configPath = path.isAbsolute(args.config) ? args.config : path.join(directory, args.config); 35 | const configJSON = await readFile(configPath, 'utf8'); 36 | Object.assign(parameters, JSON.parse(configJSON) as SessionOptions); 37 | } 38 | return parameters; 39 | }; 40 | -------------------------------------------------------------------------------- /src/runner/getOutputOption.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {getOutputOption} from './getOutputOption'; 3 | 4 | interface Test { 5 | input: Parameters, 6 | expected: ReturnType | null, 7 | } 8 | 9 | ([ 10 | { 11 | input: [{}, ['a']], 12 | expected: {type: 'script', path: 'helper.ypeBEsobvcr6wjGzmiPcTaeG7_gUfE5yuYB3ha_uSLs.css.js'}, 13 | }, 14 | { 15 | input: [{helper: 'output.js'}, ['a']], 16 | expected: {type: 'script', path: 'output.js'}, 17 | }, 18 | { 19 | input: [{helper: 'output.ts'}, ['a']], 20 | expected: {type: 'script', path: 'output.ts'}, 21 | }, 22 | { 23 | input: [{css: 'output.css'}, ['a']], 24 | expected: {type: 'css', path: 'output.css'}, 25 | }, 26 | { 27 | input: [{css: 'output.css', helper: 'output.js'}, ['a']], 28 | expected: null, 29 | }, 30 | ] as Array).forEach(({input, expected}, index) => { 31 | test(`#${index} ${JSON.stringify(input)} → ${expected ? JSON.stringify(expected) : 'Error'}`, (t) => { 32 | if (expected) { 33 | t.deepEqual(getOutputOption(...input), expected); 34 | } else { 35 | t.throws(() => getOutputOption(...input)); 36 | } 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/postcssPlugin/createTransformer.ts: -------------------------------------------------------------------------------- 1 | import type * as postcss from 'postcss'; 2 | import {normalizePath} from '../util/normalizePath'; 3 | import type {PluginConfiguration, EsifyCSSResult} from './types'; 4 | import {transformDeclarations} from './transformDeclarations'; 5 | import {getImports} from './getImports'; 6 | import {minify} from './minify'; 7 | import {mangleIdentifiers} from './mangleIdentifiers'; 8 | import {mangleKeyFrames} from './mangleKeyFrames'; 9 | import {removeImportsAndRaws} from './removeImportsAndRaws'; 10 | 11 | export const createTransformer = ( 12 | {mangler, rawPrefix}: PluginConfiguration, 13 | ) => async ( 14 | root: postcss.Root, 15 | result: postcss.Result, 16 | ): Promise => { 17 | const id = normalizePath(result.opts.from || Date.now().toString(36)); 18 | const imports = getImports(root, id); 19 | const transformResult: EsifyCSSResult = { 20 | ...(await mangleIdentifiers({id, root, mangler, imports, rawPrefix})), 21 | keyframes: mangleKeyFrames({id, root, mangler, imports, rawPrefix}), 22 | }; 23 | transformDeclarations({root, mangler, imports, transformResult, rawPrefix}); 24 | minify(root); 25 | return removeImportsAndRaws({ 26 | result: transformResult, 27 | imports, 28 | rawPrefix, 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/util/runCode.for-test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vm from 'vm'; 3 | import * as rollup from 'rollup'; 4 | import * as postcss from 'postcss'; 5 | import type {EsifyCSSResult} from '../postcssPlugin/types'; 6 | import {createSandbox} from './createSandbox.for-test'; 7 | import {updateFile} from './updateFile'; 8 | 9 | export interface RunCodeResult extends EsifyCSSResult { 10 | root: postcss.Root, 11 | } 12 | 13 | export const runCode = async (file: string): Promise => { 14 | const testCodePath = `${file}-import.js`; 15 | await updateFile(testCodePath, `import * as imported from './${path.basename(file)}';exports = imported;`); 16 | const bundle = await rollup.rollup({input: testCodePath}); 17 | const {output: [output, undef]} = await bundle.generate({format: 'es'}); 18 | if (undef) { 19 | if ('code' in undef) { 20 | throw new Error(`Unexpected multiple outputs: ${undef.code}`); 21 | } else { 22 | throw new Error(`Unexpected multiple outputs: ${JSON.stringify(undef, null, 2)}`); 23 | } 24 | } 25 | const sandbox = createSandbox(); 26 | vm.runInNewContext(output.code, sandbox); 27 | const {exports: {className = {}, id = {}, keyframes = {}}} = sandbox; 28 | return {className, id, keyframes, root: postcss.parse(sandbox.document.css)}; 29 | }; 30 | -------------------------------------------------------------------------------- /test-client/util/createBrowserStackLocal.ts: -------------------------------------------------------------------------------- 1 | import * as BrowserStack from 'browserstack-local'; 2 | 3 | export const createBrowserStackLocal = async ( 4 | parameters: { 5 | accessKey: string, 6 | port: number, 7 | localIdentifier: string, 8 | }, 9 | ): Promise => { 10 | const bsLocal = new BrowserStack.Local(); 11 | await new Promise((resolve, reject) => { 12 | bsLocal.start({ 13 | key: parameters.accessKey, 14 | verbose: true, 15 | forceLocal: true, 16 | onlyAutomate: true, 17 | only: `localhost,${parameters.port},0`, 18 | localIdentifier: parameters.localIdentifier, 19 | }, (error) => { 20 | if (error) { 21 | reject(error); 22 | } else { 23 | resolve(bsLocal); 24 | } 25 | }); 26 | }); 27 | await new Promise((resolve, reject) => { 28 | let count = 0; 29 | const check = function () { 30 | if (bsLocal.isRunning()) { 31 | resolve(); 32 | } else if (count++ < 30) { 33 | setTimeout(check, 1000); 34 | } else { 35 | reject(new Error('Failed to start browserstack-local')); 36 | } 37 | }; 38 | check(); 39 | }); 40 | return bsLocal; 41 | }; 42 | -------------------------------------------------------------------------------- /src/postcssPlugin/mangleKeyFrames.ts: -------------------------------------------------------------------------------- 1 | import type * as postcss from 'postcss'; 2 | import type {EsifyCSSResult, Imports, PluginMangler} from './types'; 3 | import {getMatchedImport} from './getMatchedImport'; 4 | 5 | export const mangleKeyFrames = ( 6 | {id, root, mangler, imports, rawPrefix}: { 7 | id: string, 8 | root: postcss.Root, 9 | mangler: PluginMangler, 10 | imports: Imports, 11 | rawPrefix: string, 12 | }, 13 | ): EsifyCSSResult['keyframes'] => { 14 | const keyframes: EsifyCSSResult['keyframes'] = {}; 15 | root.walkAtRules((rule) => { 16 | const {name} = rule; 17 | if (name === 'keyframes') { 18 | const {params: before} = rule; 19 | if (before) { 20 | let after = before; 21 | if (before.startsWith(rawPrefix)) { 22 | after = before.slice(rawPrefix.length); 23 | } else { 24 | const imported = getMatchedImport(before, imports); 25 | if (imported) { 26 | after = mangler(imported.from, name, imported.key); 27 | } else { 28 | after = mangler(id, name, before); 29 | } 30 | } 31 | rule.params = keyframes[before] = after; 32 | } 33 | } 34 | }); 35 | return keyframes; 36 | }; 37 | -------------------------------------------------------------------------------- /src/runner/getSessionConfiguration.ts: -------------------------------------------------------------------------------- 1 | import {plugin} from '../postcssPlugin/plugin'; 2 | import {ensureArray} from '../util/ensureArray'; 3 | import type {SessionConfiguration, SessionOptions} from './types'; 4 | import {getChokidarOptions} from './getChokidarOptions'; 5 | import {getOutputOption} from './getOutputOption'; 6 | import {getExtensionOption} from './getExtensionOption'; 7 | import {getIncludePatterns} from './getIncludePatterns'; 8 | 9 | export const getSessionConfiguration = ( 10 | parameters: SessionOptions, 11 | ): SessionConfiguration => { 12 | const include = getIncludePatterns({ 13 | include: ensureArray(parameters.include || '*'), 14 | extensions: parameters.extensions || ['.css'], 15 | }); 16 | const output = getOutputOption(parameters, include); 17 | return { 18 | watch: Boolean(parameters.watch), 19 | output, 20 | path: include, 21 | ext: getExtensionOption(parameters, output), 22 | chokidar: getChokidarOptions(parameters), 23 | stdout: parameters.stdout || process.stdout, 24 | stderr: parameters.stderr || process.stderr, 25 | postcssPlugins: [ 26 | ...ensureArray(parameters.postcssPlugins), 27 | plugin(parameters.esifycssPluginParameter || {}), 28 | ], 29 | postcssOptions: parameters.postcssOptions || {}, 30 | cssKey: '$$esifycss', 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/postcssPlugin/parseImport.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {parseImport} from './parseImport'; 3 | 4 | interface Test { 5 | input: Parameters, 6 | expected: ReturnType, 7 | } 8 | 9 | ([ 10 | { 11 | input: ['"./foo.css" x', 'bar/input.css'], 12 | expected: { 13 | localName: 'x', 14 | from: 'bar/foo.css', 15 | }, 16 | }, 17 | { 18 | input: ['url("./foo.css") x', 'bar/input.css'], 19 | expected: { 20 | localName: 'x', 21 | from: 'bar/foo.css', 22 | }, 23 | }, 24 | { 25 | input: ['url( "./foo.css" ) x', 'bar/input.css'], 26 | expected: { 27 | localName: 'x', 28 | from: 'bar/foo.css', 29 | }, 30 | }, 31 | { 32 | input: ['"foo.css" x', 'bar/input.css'], 33 | expected: null, 34 | }, 35 | { 36 | input: ['"./foo.css" x x', 'bar/input.css'], 37 | expected: null, 38 | }, 39 | { 40 | input: ['"./foo.css" x!', 'bar/input.css'], 41 | expected: null, 42 | }, 43 | { 44 | input: ['""./foo.css" x', 'bar/input.css'], 45 | expected: null, 46 | }, 47 | ] as Array).forEach(({input, expected}, index) => { 48 | test(`#${index + 1} ${input[0]}`, (t) => { 49 | t.deepEqual(parseImport(...input), expected); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/util/encodeString.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {encodeString, decodeString} from './encodeString'; 3 | import {createIdentifier} from './createIdGenerator'; 4 | 5 | interface Test { 6 | input: string, 7 | preset?: Array, 8 | expected: string, 9 | expectedWords: Array, 10 | } 11 | 12 | ([ 13 | { 14 | input: 'foo', 15 | expected: 'A', 16 | expectedWords: ['foo'], 17 | }, 18 | { 19 | input: 'bar foo foo foo foo', 20 | expected: 'ACECECECE', 21 | expectedWords: ['bar', ' ', 'foo'], 22 | }, 23 | { 24 | input: 'bar foo foo foo foo', 25 | preset: ['foo', ' '], 26 | expected: 'ECACACACA', 27 | expectedWords: ['foo', ' ', 'bar'], 28 | }, 29 | ] as Array).forEach(({input, preset, expected, expectedWords}, index) => { 30 | test(`#${index} ${JSON.stringify(input)} ${JSON.stringify(preset || [])} → ${expected}`, (t) => { 31 | const identifier = createIdentifier(); 32 | if (preset) { 33 | for (const token of preset) { 34 | identifier(token); 35 | } 36 | } 37 | const actual = encodeString(input, identifier); 38 | t.is(actual, expected); 39 | t.deepEqual( 40 | identifier.idList, 41 | expectedWords, 42 | ); 43 | t.is(decodeString(actual, identifier.idList), input); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/minifier/extractCSSFromArrayExpression.ts: -------------------------------------------------------------------------------- 1 | import type {Node} from './walker'; 2 | import {isArrayExpression, isObjectExpression} from './ast'; 3 | import type {CSSRange} from './types'; 4 | 5 | export const extractCSSFromArrayExpression = ( 6 | node: Node, 7 | cssKey: string, 8 | ): Array => { 9 | const result: Array = []; 10 | if (isArrayExpression(node)) { 11 | for (const item of node.elements) { 12 | if (isObjectExpression(item)) { 13 | for (const {key, value} of item.properties) { 14 | if (key.name === cssKey) { 15 | if (value.type === 'Literal') { 16 | const css = value.value; 17 | if (typeof css === 'string') { 18 | result.push({ 19 | css, 20 | start: item.start, 21 | end: item.end, 22 | }); 23 | } else { 24 | throw new Error(`InvalidCSSType: ${JSON.stringify(css)}`); 25 | } 26 | } else { 27 | throw new Error(`NonLiteral: ${JSON.stringify(value)}`); 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | return result; 35 | }; 36 | -------------------------------------------------------------------------------- /src/bin/esifycss.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | import * as console from 'console'; 5 | import * as commander from 'commander'; 6 | import {Session} from '../runner/Session.js'; 7 | import {loadParameters} from './loadParameters'; 8 | 9 | const packageData = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')) as { 10 | version: string, 11 | }; 12 | 13 | export const program = new commander.Command() 14 | .version(packageData.version) 15 | .arguments('') 16 | .option('--helper ', 'A path where the helper script will be output. You can\'t use --helper with --css.') 17 | .option('--css ', 'A path where the css will be output. You can\'t use --css with --helper.') 18 | .option('--ext ', 'An extension of scripts generated from css.') 19 | .option('--config ', 'A path to configuration files.') 20 | .option('--exclude ', 'Paths or patterns to be excluded.') 21 | .option('--noMangle', 'Keep the original name for debugging.') 22 | .option('--watch', 'Watch files and update the modules automatically.') 23 | .action(async (include: Array, options) => { 24 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 25 | await new Session(await loadParameters(include, options)).start(); 26 | }); 27 | 28 | if (require.main === module) { 29 | program.parseAsync().catch((error: unknown) => { 30 | console.error(error); 31 | process.exit(1); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/util/updateFile.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import ava from 'ava'; 4 | import {createTemporaryDirectory} from './createTemporaryDirectory'; 5 | import {wait} from './wait'; 6 | import {updateFile} from './updateFile'; 7 | 8 | ava('create a file', async (t) => { 9 | const testDirectory = await createTemporaryDirectory(); 10 | const filePath = path.join(testDirectory, 'file'); 11 | await updateFile(filePath, filePath); 12 | t.is(await fs.promises.readFile(filePath, 'utf8'), filePath); 13 | }); 14 | 15 | ava('create a directory and a file', async (t) => { 16 | const testDirectory = await createTemporaryDirectory(); 17 | const filePath = path.join(testDirectory, 'dir', 'file'); 18 | await updateFile(filePath, filePath); 19 | t.is(await fs.promises.readFile(filePath, 'utf8'), filePath); 20 | }); 21 | 22 | ava('do nothing if the source is same', async (t) => { 23 | const testDirectory = await createTemporaryDirectory(); 24 | const filePath = path.join(testDirectory, 'dir', 'file'); 25 | await updateFile(filePath, filePath.repeat(2)); 26 | t.is(await fs.promises.readFile(filePath, 'utf8'), filePath.repeat(2)); 27 | const stats1 = await fs.promises.stat(filePath); 28 | await wait(50); 29 | await updateFile(filePath, filePath); 30 | const stats2 = await fs.promises.stat(filePath); 31 | t.true(stats1.mtimeMs < stats2.mtimeMs); 32 | await wait(50); 33 | await updateFile(filePath, filePath); 34 | const stats3 = await fs.promises.stat(filePath); 35 | t.is(stats3.mtimeMs, stats2.mtimeMs); 36 | }); 37 | -------------------------------------------------------------------------------- /src/minifier/minifyScriptsForCSS.ts: -------------------------------------------------------------------------------- 1 | import {updateFile} from '../util/updateFile'; 2 | import {parseScripts} from './parseScripts'; 3 | import type {ScriptData} from './types'; 4 | 5 | const cache = new WeakMap(); 6 | 7 | export const minifyScriptForCSS = async ( 8 | [file, data]: [string, ScriptData], 9 | ): Promise => { 10 | let cached = cache.get(data); 11 | if (!cached) { 12 | const cssList: Array = []; 13 | let code = data.script; 14 | for (let index = data.ranges.length; index--;) { 15 | cssList[index] = data.ranges[index].css; 16 | } 17 | code = [...data.expressionStatements, ...data.importDeclarations] 18 | .sort((range1, range2) => range1.start < range2.start ? 1 : -1) 19 | .reduce((node, range) => `${node.slice(0, range.start)}${node.slice(range.end)}`, code) 20 | .replace(/\n\s*\n/g, '\n'); 21 | const css = cssList.join('\n'); 22 | cached = {code, css}; 23 | cache.set(data, cached); 24 | } 25 | await updateFile(file, cached.code); 26 | return cached.css; 27 | }; 28 | 29 | /** 30 | * Removes statements for importing and executing addStyle(). 31 | * @param props 32 | */ 33 | export const minifyScriptsForCSS = async ( 34 | props: { 35 | files: Map, 36 | cssKey: string, 37 | dest: string, 38 | }, 39 | ): Promise => { 40 | const parseResult = await parseScripts(props); 41 | const cssList = await Promise.all([...parseResult.scripts].map(minifyScriptForCSS)); 42 | await updateFile(props.dest, cssList.join('\n')); 43 | }; 44 | -------------------------------------------------------------------------------- /src/minifier/parseCSSModuleScript.ts: -------------------------------------------------------------------------------- 1 | import * as acorn from 'acorn'; 2 | import * as acornWalk from './walker'; 3 | import type {ParseResult, CSSRange, Range} from './types'; 4 | import {extractCSSFromArrayExpression} from './extractCSSFromArrayExpression'; 5 | 6 | export const parseCSSModuleScript = ( 7 | props: { 8 | code: string, 9 | cssKey: string, 10 | ecmaVersion?: 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020, 11 | }, 12 | ): ParseResult => { 13 | const ranges: Array = []; 14 | const importDeclarations: Array = []; 15 | const expressionStatements: Array = []; 16 | const ast = acorn.parse( 17 | props.code, 18 | { 19 | sourceType: 'module', 20 | ecmaVersion: props.ecmaVersion || 11, 21 | }, 22 | ); 23 | acornWalk.simple(ast, { 24 | ImportDeclaration: ({start, end}) => { 25 | importDeclarations.push({start, end}); 26 | }, 27 | ExpressionStatement: (statement) => { 28 | const {expression, start, end} = statement; 29 | if (expression.callee) { 30 | const {length} = ranges; 31 | for (const argument of expression.arguments) { 32 | ranges.push(...extractCSSFromArrayExpression(argument, props.cssKey)); 33 | } 34 | if (length < ranges.length) { 35 | expressionStatements.push({start, end}); 36 | } 37 | } 38 | }, 39 | }); 40 | return { 41 | ranges, 42 | expressionStatements, 43 | importDeclarations, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/postcssPlugin/minify.ts: -------------------------------------------------------------------------------- 1 | import type {Root} from 'postcss'; 2 | 3 | export const minify = ( 4 | root: Root, 5 | ): Root => { 6 | Object.assign(root.raws, {semicolon: false, after: ''}); 7 | root.walk((node) => { 8 | switch (node.type) { 9 | case 'comment': 10 | node.remove(); 11 | break; 12 | case 'atrule': 13 | if (node.name === 'charset') { 14 | /** 15 | * https://www.w3.org/TR/CSS2/syndata.html#x57 16 | * > User agents must ignore any @charset rule not at the beginning of the style sheet. 17 | * https://www.w3.org/TR/css-syntax-3/#charset-rule 18 | * > However, there is no actual at-rule named @charset. 19 | */ 20 | node.remove(); 21 | } else { 22 | Object.assign(node.raws, {before: '', between: '', afterName: ' '}); 23 | } 24 | break; 25 | case 'rule': 26 | node.selector = node.selector 27 | .replace(/\s+/g, ' ') 28 | .replace(/\s*([,>~+])\s*/g, '$1') 29 | .trim(); 30 | Object.assign(node.raws, {before: '', between: '', semicolon: false, after: '', ownSemicolon: ''}); 31 | break; 32 | case 'decl': { 33 | Object.assign(node.raws, {before: '', between: ':', semicolon: true, after: '', ownSemicolon: ''}); 34 | const value = node.raws.value as {raw: string, value: string} | undefined; 35 | if (value) { 36 | value.raw = value.value; 37 | } 38 | break; 39 | } 40 | default: 41 | } 42 | }); 43 | return root; 44 | }; 45 | -------------------------------------------------------------------------------- /test-client/parcel-javascript/src/page.js: -------------------------------------------------------------------------------- 1 | import * as css from './page.css.js'; 2 | 3 | const outputElement = document.body.appendChild(document.createElement('div')); 4 | outputElement.id = 'output'; 5 | outputElement.style.fontFamily = 'Consolas, Courier, monospace'; 6 | outputElement.style.whiteSpace = 'pre-wrap'; 7 | 8 | const log = (message, color = null) => { 9 | const element = outputElement.appendChild(document.createElement('div')); 10 | element.appendChild(document.createTextNode(message)); 11 | if (color) { 12 | element.style.color = color; 13 | } 14 | }; 15 | 16 | const test = () => { 17 | log(JSON.stringify(css, null, 2)); 18 | 19 | const fooElement = document.body.appendChild(document.createElement('div')); 20 | fooElement.id = css.id && css.id.foo; 21 | fooElement.classList.add(css.className && css.className.foo); 22 | fooElement.textContent = 'FOO'; 23 | 24 | const computedStyle = getComputedStyle(fooElement); 25 | const passed = [ 26 | {name: 'text-align', expected: 'center'}, 27 | {name: 'animation-duration', expected: '2s'}, 28 | {name: 'animation-iteration-count', expected: 'infinite'}, 29 | {name: 'animation-name', expected: css.keyframes.foo}, 30 | {name: 'font-size', expected: '30px'}, 31 | ].reduce((result, {name, expected}) => { 32 | const actual = (computedStyle.getPropertyValue(name) || '').trim(); 33 | log(JSON.stringify({name, actual, expected})); 34 | return result && actual === expected; 35 | }, true); 36 | const summary = passed ? 'passed' : 'failed'; 37 | document.title += ` → ${summary}`; 38 | log(summary); 39 | }; 40 | 41 | try { 42 | test(); 43 | } catch (error) { 44 | log(`${error.stack || error}`); 45 | } 46 | -------------------------------------------------------------------------------- /test-client/typescript-rollup/src/page.ts: -------------------------------------------------------------------------------- 1 | import * as css from './page.css'; 2 | 3 | const outputElement = document.body.appendChild(document.createElement('div')); 4 | outputElement.id = 'output'; 5 | outputElement.style.fontFamily = 'Consolas, Courier, monospace'; 6 | outputElement.style.whiteSpace = 'pre-wrap'; 7 | 8 | const log = (message: string, color: string | null = null) => { 9 | const element = outputElement.appendChild(document.createElement('div')); 10 | element.appendChild(document.createTextNode(message)); 11 | if (color) { 12 | element.style.color = color; 13 | } 14 | }; 15 | 16 | const test = () => { 17 | log(JSON.stringify(css, null, 2)); 18 | 19 | const fooElement = document.body.appendChild(document.createElement('div')); 20 | fooElement.id = css.id && css.id.foo; 21 | fooElement.classList.add(css.className && css.className.foo); 22 | fooElement.textContent = 'FOO'; 23 | 24 | const computedStyle = getComputedStyle(fooElement); 25 | const status = [ 26 | {name: 'text-align', expected: 'center'}, 27 | {name: 'animation-duration', expected: '2s'}, 28 | {name: 'animation-iteration-count', expected: 'infinite'}, 29 | {name: 'animation-name', expected: css.keyframes.foo}, 30 | {name: 'font-size', expected: '30px'}, 31 | ].reduce((result, {name, expected}) => { 32 | const actual = (computedStyle.getPropertyValue(name) || '').trim(); 33 | log(JSON.stringify({name, actual, expected})); 34 | return result && actual === expected; 35 | }, true); 36 | const summary = status ? 'passed' : 'failed'; 37 | document.title += ` → ${summary}`; 38 | log(summary); 39 | }; 40 | 41 | try { 42 | test(); 43 | } catch (error: unknown) { 44 | log(`${error}`); 45 | } 46 | -------------------------------------------------------------------------------- /src/minifier/findAddStyleImport.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import type * as acorn from 'acorn'; 3 | import type {ImportDeclaration, VariableDeclaration, FunctionDeclaration} from './walker'; 4 | import {isProgramNode} from './ast'; 5 | 6 | export const normalizeHelperId = ( 7 | id: string, 8 | ) => path.normalize(id).replace(/\.ts$/, ''); 9 | 10 | export const findAddStyleImport = ( 11 | ast: acorn.Node, 12 | helperId: string, 13 | localName?: string, 14 | ): {name: string, node: FunctionDeclaration | ImportDeclaration | VariableDeclaration} => { 15 | if (!isProgramNode(ast)) { 16 | throw new Error('InvalidNode'); 17 | } 18 | for (const node of ast.body) { 19 | if (node.type === 'ImportDeclaration') { 20 | if (normalizeHelperId(node.source.value) === normalizeHelperId(helperId)) { 21 | const {specifiers = []} = node; 22 | if (specifiers.length === 1) { 23 | return {name: specifiers[0].local.name, node}; 24 | } 25 | } 26 | } 27 | } 28 | if (localName) { 29 | for (const node of ast.body) { 30 | switch (node.type) { 31 | case 'VariableDeclaration': { 32 | const {declarations} = node; 33 | if (declarations.length === 1 && declarations[0].id.name === localName) { 34 | return {name: localName, node}; 35 | } 36 | break; 37 | } 38 | case 'FunctionDeclaration': 39 | if (node.id.name === localName) { 40 | return {name: localName, node}; 41 | } 42 | break; 43 | default: 44 | } 45 | } 46 | } 47 | throw new Error('NoAddStyle'); 48 | }; 49 | -------------------------------------------------------------------------------- /test-client/util/createRequestHandler.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import type * as http from 'http'; 3 | import * as path from 'path'; 4 | import {URL} from 'url'; 5 | 6 | const afs = fs.promises; 7 | 8 | export const createRequestHandler = ( 9 | directory: string, 10 | callback: (message: string) => void, 11 | ): http.RequestListener & {contentTypes: Map} => { 12 | const contentTypes = new Map(); 13 | contentTypes.set('.html', 'text/html'); 14 | contentTypes.set('.css', 'text/css'); 15 | contentTypes.set('.js', 'text/javascript'); 16 | const listener: http.RequestListener = (req, res) => { 17 | const url = new URL(req.url || '/', 'https://example.com'); 18 | url.pathname = url.pathname.replace(/\/$/, '/index.html'); 19 | const filePath = path.join(directory, url.pathname); 20 | Promise.resolve() 21 | .then(async () => { 22 | const stats = await afs.stat(filePath); 23 | res.writeHead(200, { 24 | 'content-type': contentTypes.get(path.extname(filePath)) || 'text/plain', 25 | 'content-length': stats.size, 26 | }); 27 | if (req.method === 'HEAD') { 28 | res.end(); 29 | } else { 30 | fs.createReadStream(filePath).pipe(res); 31 | } 32 | }) 33 | .catch((error) => { 34 | if ((error as {code?: string}).code === 'ENOENT') { 35 | res.statusCode = 404; 36 | } else { 37 | res.statusCode = 500; 38 | } 39 | res.end(`${error}`); 40 | }) 41 | .finally(() => { 42 | callback(`${req.method} ${url.pathname} → ${res.statusCode}`); 43 | }); 44 | }; 45 | return Object.assign(listener, {contentTypes}); 46 | }; 47 | -------------------------------------------------------------------------------- /src/helper/default.js: -------------------------------------------------------------------------------- 1 | const dictionary = ['ESIFYCSS DICTIONARY']; 2 | const charToInteger = JSON.parse('{"0":52,"1":53,"2":54,"3":55,"4":56,"5":57,"6":58,"7":59,"8":60,"9":61,"A":0,"B":1,"C":2,"D":3,"E":4,"F":5,"G":6,"H":7,"I":8,"J":9,"K":10,"L":11,"M":12,"N":13,"O":14,"P":15,"Q":16,"R":17,"S":18,"T":19,"U":20,"V":21,"W":22,"X":23,"Y":24,"Z":25,"a":26,"b":27,"c":28,"d":29,"e":30,"f":31,"g":32,"h":33,"i":34,"j":35,"k":36,"l":37,"m":38,"n":39,"o":40,"p":41,"q":42,"r":43,"s":44,"t":45,"u":46,"v":47,"w":48,"x":49,"y":50,"z":51,"+":62,"/":63,"=":64}'); 3 | const decode = (encoded) => { 4 | if (typeof encoded === 'object') { 5 | return encoded.$$esifycss; 6 | } 7 | const result = []; 8 | let value = 0; 9 | let shift = 0; 10 | const end = encoded.length; 11 | for (let index = 0; index < end; index++) { 12 | let integer = charToInteger[encoded[index]]; 13 | if (0 <= integer) { 14 | const hasContinuationBit = integer & 32; 15 | integer &= 31; 16 | value += integer << shift; 17 | if (hasContinuationBit) { 18 | shift += 5; 19 | } 20 | else { 21 | value >>= 1; 22 | result.push(dictionary[value]); 23 | value = shift = 0; 24 | } 25 | } 26 | else { 27 | throw new Error(`EsifyCSS:UnexpectedToken:${encoded[index]}:'${encoded}'[${index}]`); 28 | } 29 | } 30 | return result.join(''); 31 | }; 32 | const style = document.createElement('style'); 33 | export const addStyle = (rules) => { 34 | if (!style.parentNode) { 35 | document.head.appendChild(style); 36 | } 37 | const cssStyleSheet = style.sheet; 38 | rules.forEach((words) => { 39 | cssStyleSheet.insertRule(decode(words), cssStyleSheet.cssRules.length); 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/minifier/parseScripts.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import {tokenizeString} from '../util/tokenizeString'; 3 | import type {ParseScriptsResult, ScriptData} from './types'; 4 | import {parseCSSModuleScript} from './parseCSSModuleScript'; 5 | 6 | const cache = new Map, 9 | scriptData: ScriptData, 10 | }>(); 11 | 12 | export const parseScripts = async ( 13 | {files, cssKey}: { 14 | files: Map, 15 | cssKey: string, 16 | }, 17 | ): Promise => { 18 | const scripts = new Map(); 19 | const allTokens = new Map(); 20 | const tasks: Array> = []; 21 | for (const [source, file] of files) { 22 | const {mtimeMs} = await fs.promises.stat(source); 23 | let cached = cache.get(source); 24 | if (!cached || mtimeMs !== cached.mtimeMs) { 25 | const code = await fs.promises.readFile(file, 'utf8'); 26 | const data = parseCSSModuleScript({code, cssKey}); 27 | const tokens = new Map(); 28 | for (const {css} of data.ranges) { 29 | for (const token of tokenizeString(css)) { 30 | tokens.set(token, (tokens.get(token) || 0) + 1); 31 | allTokens.set(token, (allTokens.get(token) || 0) + 1); 32 | } 33 | } 34 | cached = {mtimeMs, tokens, scriptData: {...data, script: code}}; 35 | cache.set(source, cached); 36 | } else { 37 | for (const [token, count] of cached.tokens) { 38 | allTokens.set(token, (allTokens.get(token) || 0) + count); 39 | } 40 | } 41 | scripts.set(file, cached.scriptData); 42 | } 43 | await Promise.all(tasks); 44 | return {scripts, tokens: allTokens}; 45 | }; 46 | -------------------------------------------------------------------------------- /src/util/deleteFile.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import ava from 'ava'; 4 | import {createTemporaryDirectory} from './createTemporaryDirectory'; 5 | import {deleteFile} from './deleteFile'; 6 | import {updateFile} from './updateFile'; 7 | 8 | const {stat, mkdir} = fs.promises; 9 | 10 | ava('delete a file', async (t) => { 11 | const testDirectory = await createTemporaryDirectory(); 12 | const filePath = path.join(testDirectory, 'file'); 13 | await updateFile(filePath, filePath); 14 | t.true((await stat(filePath)).isFile()); 15 | await deleteFile(filePath); 16 | await t.throwsAsync(async () => { 17 | await stat(filePath); 18 | }, {code: 'ENOENT'}); 19 | }); 20 | 21 | ava('delete an empty directory', async (t) => { 22 | const testDirectory = await createTemporaryDirectory(); 23 | const directory = path.join(testDirectory, 'dir'); 24 | await mkdir(directory); 25 | t.true((await stat(directory)).isDirectory()); 26 | await deleteFile(directory); 27 | await t.throwsAsync(async () => { 28 | await stat(directory); 29 | }, {code: 'ENOENT'}); 30 | }); 31 | 32 | ava('delete a directory', async (t) => { 33 | const testDirectory = await createTemporaryDirectory(); 34 | const rootDirectory = path.join(testDirectory, 'dir1'); 35 | const directory = path.join(rootDirectory, 'dir2'); 36 | await mkdir(directory, {recursive: true}); 37 | const filePath = path.join(directory, 'file'); 38 | await updateFile(path.join(directory, 'file'), directory); 39 | t.true((await stat(filePath)).isFile()); 40 | await deleteFile(rootDirectory); 41 | await t.throwsAsync(async () => { 42 | await stat(filePath); 43 | }, {code: 'ENOENT'}); 44 | await t.throwsAsync(async () => { 45 | await stat(directory); 46 | }, {code: 'ENOENT'}); 47 | await t.throwsAsync(async () => { 48 | await stat(rootDirectory); 49 | }, {code: 'ENOENT'}); 50 | }); 51 | -------------------------------------------------------------------------------- /src/runner/generateScript.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import type * as postcss from 'postcss'; 3 | import type {EsifyCSSResult} from '../postcssPlugin/types'; 4 | 5 | export const generateScript = function* ( 6 | /** 7 | * Returns a (TypeScript-compatible) JavaScript code that exports className, 8 | * id, keyframes. The script contains addStyle(...) lines that insert CSS rules 9 | * to the document. 10 | */ 11 | {output, helper, result, root, cssKey}: { 12 | /** The destination of the output. The relative path to the helper script is calculated from this value. */ 13 | output: string, 14 | /** Path to the helperScript which is required to get a relative path to the helper script. */ 15 | helper: string, 16 | /** The main contents of the output script. */ 17 | result: EsifyCSSResult, 18 | /** The root node will be splitted into rules that can be passed to insertRule. */ 19 | root: postcss.Document | postcss.Root, 20 | cssKey: string, 21 | }, 22 | ): Generator { 23 | let helperPath = path.relative(path.dirname(output), helper); 24 | helperPath = helperPath && helperPath.replace(/\.ts$/, ''); 25 | if (!path.isAbsolute(helperPath)) { 26 | helperPath = `./${path.normalize(helperPath)}`.replace(/^\.\/\.\./, '..'); 27 | } 28 | yield '/* eslint-disable */'; 29 | yield '// This file is generated by esifycss.'; 30 | yield `import {addStyle} from '${helperPath.split(path.sep).join('/')}';`; 31 | yield `addStyle([${root.nodes.map((node) => { 32 | let css = node.toString(); 33 | css = css.endsWith('}') ? css : `${css};`; 34 | return `{${cssKey}: ${JSON.stringify(css)}}`; 35 | }).join(',')}]);`; 36 | yield `export const className = ${JSON.stringify(result.className, null, 4)};`; 37 | yield `export const id = ${JSON.stringify(result.id, null, 4)};`; 38 | yield `export const keyframes = ${JSON.stringify(result.keyframes, null, 4)};`; 39 | }; 40 | -------------------------------------------------------------------------------- /src/postcssPlugin/transformDeclarations.ts: -------------------------------------------------------------------------------- 1 | import * as console from 'console'; 2 | import type * as postcss from 'postcss'; 3 | import * as animationParser from '@hookun/parse-animation-shorthand'; 4 | import type {EsifyCSSResult, Imports, PluginMangler} from './types'; 5 | import {getMatchedImport} from './getMatchedImport'; 6 | 7 | export const transformDeclarations = ( 8 | {root, transformResult, mangler, imports, rawPrefix}: { 9 | root: postcss.Root, 10 | transformResult: EsifyCSSResult, 11 | mangler: PluginMangler, 12 | imports: Imports, 13 | rawPrefix: string, 14 | }, 15 | ): void => { 16 | const getRenamed = (name: string): string | undefined => { 17 | if (name.startsWith(rawPrefix)) { 18 | return name.slice(rawPrefix.length); 19 | } 20 | const imported = getMatchedImport(name, imports); 21 | if (imported) { 22 | return mangler(imported.from, 'keyframes', imported.key); 23 | } 24 | return transformResult.keyframes[name]; 25 | }; 26 | root.walkDecls((declaration) => { 27 | const {prop} = declaration; 28 | if (prop === 'animation-name') { 29 | const renamed = getRenamed(declaration.value); 30 | if (renamed) { 31 | declaration.value = renamed; 32 | } 33 | } else if (prop === 'animation') { 34 | const animations: Array = []; 35 | try { 36 | for (const animation of animationParser.parse(declaration.value)) { 37 | const renamed = getRenamed(animation.name); 38 | if (renamed) { 39 | animation.name = renamed; 40 | } 41 | animations.push(animationParser.serialize(animation)); 42 | } 43 | } catch (error: unknown) { 44 | console.error(error); 45 | } 46 | declaration.value = animations.join(','); 47 | } 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /src/postcssPlugin/mangleIdentifiers.ts: -------------------------------------------------------------------------------- 1 | import type * as postcss from 'postcss'; 2 | import * as selectorParser from 'postcss-selector-parser'; 3 | import type {EsifyCSSResult, Imports, PluginMangler} from './types'; 4 | import {getMatchedImport} from './getMatchedImport'; 5 | 6 | export const mangleIdentifiers = async ( 7 | {id, root, mangler, imports, rawPrefix}: { 8 | id: string, 9 | root: postcss.Root, 10 | mangler: PluginMangler, 11 | imports: Imports, 12 | rawPrefix: string, 13 | }, 14 | ): Promise<{id: EsifyCSSResult['id'], className: EsifyCSSResult['className']}> => { 15 | const result: {id: EsifyCSSResult['id'], className: EsifyCSSResult['className']} = { 16 | id: {}, 17 | className: {}, 18 | }; 19 | const parser = selectorParser((selectors) => { 20 | selectors.walk((selector) => { 21 | if (selectorParser.isClassName(selector) || selectorParser.isIdentifier(selector)) { 22 | const {value: before} = selector; 23 | let after = before; 24 | if (before.startsWith(rawPrefix)) { 25 | after = before.slice(rawPrefix.length); 26 | } else { 27 | const imported = getMatchedImport(before, imports); 28 | if (imported) { 29 | after = mangler(imported.from, selector.type, imported.key); 30 | } else { 31 | after = mangler(id, selector.type, before); 32 | } 33 | } 34 | const type = selector.type === 'id' ? 'id' : 'className'; 35 | selector.value = result[type][before] = after; 36 | } 37 | }); 38 | }); 39 | const processes: Array> = []; 40 | root.walkRules((rule) => { 41 | processes.push(parser.process(rule.selector).then((newSelector) => { 42 | rule.selector = newSelector; 43 | })); 44 | }); 45 | await Promise.all(processes); 46 | return result; 47 | }; 48 | -------------------------------------------------------------------------------- /src/postcssPlugin/types.ts: -------------------------------------------------------------------------------- 1 | import type {IdGenerator} from '../util/createIdGenerator'; 2 | 3 | export interface PluginOptions { 4 | /** 5 | * When it is true, this plugin minifies classnames. 6 | * @default true 7 | */ 8 | mangle?: boolean, 9 | /** 10 | * A function returns an unique number from a given file id. If you process 11 | * CSS files in multiple postcss processes, you should create an identifier 12 | * outside the processes and pass it as this value to keep the uniqueness 13 | * of mangled outputs. 14 | * @default esifycss.createIdentifier() 15 | */ 16 | identifier?: IdGenerator, 17 | /** 18 | * Names starts with this value are not passed to mangler but replaced with 19 | * unprefixed names. 20 | * @default "raw-" 21 | */ 22 | rawPrefix?: string, 23 | /** 24 | * A custom mangler: (*id*, *type*, *name*) => string. 25 | * - *id*: string. A filepath to the CSS. 26 | * - *type*: 'id' | 'class' | 'keyframes'. The type of *name* 27 | * - *name*: string. An identifier in the style. 28 | * 29 | * If mangler is set, `mangle` and `identifier` options are ignored. 30 | * 31 | * For example, If the plugin processes `.foo{color:green}` in `/a.css`, 32 | * The mangler is called with `("/a.css", "class", "foo")`. A mangler should 33 | * return an unique string for each input pattern or the styles will be 34 | * overwritten unexpectedly. 35 | * @default undefined 36 | */ 37 | mangler?: PluginMangler, 38 | } 39 | 40 | export interface PluginMangler { 41 | ( 42 | id: string, 43 | type: string, 44 | name: string, 45 | ): string, 46 | } 47 | 48 | export interface PluginConfiguration { 49 | readonly mangler: PluginMangler, 50 | readonly rawPrefix: string, 51 | } 52 | 53 | export type IdentifierMap = Record; 54 | 55 | export interface EsifyCSSResult { 56 | className: IdentifierMap, 57 | id: IdentifierMap, 58 | keyframes: IdentifierMap, 59 | } 60 | 61 | export interface Imports extends Map {} 62 | -------------------------------------------------------------------------------- /src/helper/default.ts: -------------------------------------------------------------------------------- 1 | const dictionary: Array = ['ESIFYCSS DICTIONARY']; 2 | // const charToInteger = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split('') 3 | // .reduce<{[char: string]: number}>((map, char, index) => { 4 | // map[char] = index; 5 | // return map; 6 | // }, {}); 7 | const charToInteger = JSON.parse('{"0":52,"1":53,"2":54,"3":55,"4":56,"5":57,"6":58,"7":59,"8":60,"9":61,"A":0,"B":1,"C":2,"D":3,"E":4,"F":5,"G":6,"H":7,"I":8,"J":9,"K":10,"L":11,"M":12,"N":13,"O":14,"P":15,"Q":16,"R":17,"S":18,"T":19,"U":20,"V":21,"W":22,"X":23,"Y":24,"Z":25,"a":26,"b":27,"c":28,"d":29,"e":30,"f":31,"g":32,"h":33,"i":34,"j":35,"k":36,"l":37,"m":38,"n":39,"o":40,"p":41,"q":42,"r":43,"s":44,"t":45,"u":46,"v":47,"w":48,"x":49,"y":50,"z":51,"+":62,"/":63,"=":64}') as Record; 8 | const decode = (encoded: string | {$$esifycss: string}): string => { 9 | if (typeof encoded === 'object') { 10 | return encoded.$$esifycss; 11 | } 12 | const result: Array = []; 13 | let value = 0; 14 | let shift = 0; 15 | const end = encoded.length; 16 | for (let index = 0; index < end; index++) { 17 | let integer = charToInteger[encoded[index]]; 18 | if (0 <= integer) { 19 | const hasContinuationBit = integer & 32; 20 | integer &= 31; 21 | value += integer << shift; 22 | if (hasContinuationBit) { 23 | shift += 5; 24 | } else { 25 | // const shouldNegate = value & 1; 26 | value >>= 1; 27 | result.push(dictionary[value]); 28 | value = shift = 0; 29 | } 30 | } else { 31 | throw new Error(`EsifyCSS:UnexpectedToken:${encoded[index]}:'${encoded}'[${index}]`); 32 | } 33 | } 34 | return result.join(''); 35 | }; 36 | 37 | const style = document.createElement('style'); 38 | 39 | export const addStyle = (rules: Array): void => { 40 | if (!style.parentNode) { 41 | document.head.appendChild(style); 42 | } 43 | const cssStyleSheet = style.sheet as CSSStyleSheet; 44 | rules.forEach((words) => { 45 | cssStyleSheet.insertRule(decode(words), cssStyleSheet.cssRules.length); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /test-client/util/capabilities.ts: -------------------------------------------------------------------------------- 1 | import { 2 | projectName, 3 | buildName, 4 | browserStack, 5 | } from './constants'; 6 | 7 | interface BrowsetStackOptions { 8 | os?: 'OS X' | 'Windows', 9 | osVersion?: string, 10 | deviceName?: string, 11 | realMobile?: 'false' | 'true', 12 | projectName?: string, 13 | buildName?: string, 14 | sessionName: string, 15 | local?: 'false' | 'true', 16 | localIdentifier: string, 17 | seleniumVersion?: string, 18 | userName?: string, 19 | accessKey?: string, 20 | } 21 | 22 | interface Capability { 23 | browserName: string, 24 | browserVersion?: string, 25 | 'bstack:options': BrowsetStackOptions, 26 | } 27 | 28 | const timeStamp = Date.now().toString(36); 29 | const sessionName = 'ClientTest'; 30 | const generateLocalIdentifier = () => `${sessionName}-${timeStamp}-${capabilities.length}`; 31 | export const capabilities: Array = []; 32 | if (browserStack) { 33 | const baseOptions = { 34 | projectName, 35 | buildName, 36 | sessionName, 37 | local: 'true' as const, 38 | userName: browserStack.userName, 39 | accessKey: browserStack.accessKey, 40 | }; 41 | const generateOptions = ( 42 | options: Partial, 43 | ): BrowsetStackOptions => ({ 44 | ...baseOptions, 45 | ...options, 46 | localIdentifier: generateLocalIdentifier(), 47 | }); 48 | for (const browserName of ['Chrome', 'Firefox', 'Edge']) { 49 | capabilities.push({ 50 | browserName, 51 | 'bstack:options': generateOptions({os: 'Windows', osVersion: '10'}), 52 | }); 53 | } 54 | for (const browserName of ['Chrome', 'Firefox', 'Safari']) { 55 | capabilities.push({ 56 | browserName, 57 | 'bstack:options': generateOptions({os: 'OS X', osVersion: 'Monterey'}), 58 | }); 59 | } 60 | capabilities.push({ 61 | 'browserName': 'Safari', 62 | 'bstack:options': generateOptions({osVersion: '15', deviceName: 'iPhone 13', realMobile: 'true'}), 63 | }); 64 | } else { 65 | capabilities.push({ 66 | 'browserName': 'Chrome', 67 | 'bstack:options': { 68 | sessionName, 69 | localIdentifier: generateLocalIdentifier(), 70 | }, 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /sample/01-mangle/helper.js: -------------------------------------------------------------------------------- 1 | const dictionary = [ 2 | ":", "{", "}", "_", " ", "-", "animation", "0", "%", ";", 3 | "1", "@", "keyframes", "opacity", "100", "transform", "rotate", "(", "deg", ")", 4 | "s", "linear", ".", "3", "4", "360", "#", "display", "flex", ".2", 5 | "duration", "iteration", "count", "infinite", "timing", "function", "name", "8", "9", "1.", 6 | "2", "5", "6", "6.", "7", 7 | ]; 8 | const charToInteger = JSON.parse('{"0":52,"1":53,"2":54,"3":55,"4":56,"5":57,"6":58,"7":59,"8":60,"9":61,"A":0,"B":1,"C":2,"D":3,"E":4,"F":5,"G":6,"H":7,"I":8,"J":9,"K":10,"L":11,"M":12,"N":13,"O":14,"P":15,"Q":16,"R":17,"S":18,"T":19,"U":20,"V":21,"W":22,"X":23,"Y":24,"Z":25,"a":26,"b":27,"c":28,"d":29,"e":30,"f":31,"g":32,"h":33,"i":34,"j":35,"k":36,"l":37,"m":38,"n":39,"o":40,"p":41,"q":42,"r":43,"s":44,"t":45,"u":46,"v":47,"w":48,"x":49,"y":50,"z":51,"+":62,"/":63,"=":64}'); 9 | const decode = (encoded) => { 10 | if (typeof encoded === 'object') { 11 | return encoded.$$esifycss; 12 | } 13 | const result = []; 14 | let value = 0; 15 | let shift = 0; 16 | const end = encoded.length; 17 | for (let index = 0; index < end; index++) { 18 | let integer = charToInteger[encoded[index]]; 19 | if (0 <= integer) { 20 | const hasContinuationBit = integer & 32; 21 | integer &= 31; 22 | value += integer << shift; 23 | if (hasContinuationBit) { 24 | shift += 5; 25 | } 26 | else { 27 | value >>= 1; 28 | result.push(dictionary[value]); 29 | value = shift = 0; 30 | } 31 | } 32 | else { 33 | throw new Error(`EsifyCSS:UnexpectedToken:${encoded[index]}:'${encoded}'[${index}]`); 34 | } 35 | } 36 | return result.join(''); 37 | }; 38 | const style = document.createElement('style'); 39 | export const addStyle = (rules) => { 40 | if (!style.parentNode) { 41 | document.head.appendChild(style); 42 | } 43 | const cssStyleSheet = style.sheet; 44 | rules.forEach((words) => { 45 | cssStyleSheet.insertRule(decode(words), cssStyleSheet.cssRules.length); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /sample/02-no-mangle/helper.js: -------------------------------------------------------------------------------- 1 | const dictionary = [ 2 | ":", "{", "}", "_", " ", "-", "animation", "0", "%", "rotate", 3 | ";", "1", "@", "keyframes", "FadeIn", "opacity", "100", "Rotate", "transform", "(", 4 | "deg", ")", "s", "linear", ".", "icon", "3", "4", "360", "#", 5 | "container", "display", "flex", ".2", "duration", "iteration", "count", "infinite", "timing", "function", 6 | "name", "8", "9", "1.", "2", "5", "6", "6.", "7", 7 | ]; 8 | const charToInteger = JSON.parse('{"0":52,"1":53,"2":54,"3":55,"4":56,"5":57,"6":58,"7":59,"8":60,"9":61,"A":0,"B":1,"C":2,"D":3,"E":4,"F":5,"G":6,"H":7,"I":8,"J":9,"K":10,"L":11,"M":12,"N":13,"O":14,"P":15,"Q":16,"R":17,"S":18,"T":19,"U":20,"V":21,"W":22,"X":23,"Y":24,"Z":25,"a":26,"b":27,"c":28,"d":29,"e":30,"f":31,"g":32,"h":33,"i":34,"j":35,"k":36,"l":37,"m":38,"n":39,"o":40,"p":41,"q":42,"r":43,"s":44,"t":45,"u":46,"v":47,"w":48,"x":49,"y":50,"z":51,"+":62,"/":63,"=":64}'); 9 | const decode = (encoded) => { 10 | if (typeof encoded === 'object') { 11 | return encoded.$$esifycss; 12 | } 13 | const result = []; 14 | let value = 0; 15 | let shift = 0; 16 | const end = encoded.length; 17 | for (let index = 0; index < end; index++) { 18 | let integer = charToInteger[encoded[index]]; 19 | if (0 <= integer) { 20 | const hasContinuationBit = integer & 32; 21 | integer &= 31; 22 | value += integer << shift; 23 | if (hasContinuationBit) { 24 | shift += 5; 25 | } 26 | else { 27 | value >>= 1; 28 | result.push(dictionary[value]); 29 | value = shift = 0; 30 | } 31 | } 32 | else { 33 | throw new Error(`EsifyCSS:UnexpectedToken:${encoded[index]}:'${encoded}'[${index}]`); 34 | } 35 | } 36 | return result.join(''); 37 | }; 38 | const style = document.createElement('style'); 39 | export const addStyle = (rules) => { 40 | if (!style.parentNode) { 41 | document.head.appendChild(style); 42 | } 43 | const cssStyleSheet = style.sheet; 44 | rules.forEach((words) => { 45 | cssStyleSheet.insertRule(decode(words), cssStyleSheet.cssRules.length); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Generated by @nlib/indexen 2 | export * from './minifier/ast'; 3 | export * from './minifier/createOptimizedIdGenerator'; 4 | export * from './minifier/extractCSSFromArrayExpression'; 5 | export * from './minifier/findAddStyleImport'; 6 | export * from './minifier/minifyCSSInScript'; 7 | export * from './minifier/minifyScripts'; 8 | export * from './minifier/minifyScriptsForCSS'; 9 | export * from './minifier/parseCSSModuleScript'; 10 | export * from './minifier/parseScripts'; 11 | export * from './minifier/setDictionary'; 12 | export * from './minifier/types'; 13 | export * from './postcssPlugin/createTransformer'; 14 | export * from './postcssPlugin/getDependencies'; 15 | export * from './postcssPlugin/getImports'; 16 | export * from './postcssPlugin/getMatchedImport'; 17 | export * from './postcssPlugin/getPluginConfiguration'; 18 | export * from './postcssPlugin/mangleIdentifiers'; 19 | export * from './postcssPlugin/mangleKeyFrames'; 20 | export * from './postcssPlugin/minify'; 21 | export * from './postcssPlugin/parseImport'; 22 | export * from './postcssPlugin/plugin'; 23 | export * from './postcssPlugin/removeImportsAndRaws'; 24 | export * from './postcssPlugin/transformDeclarations'; 25 | export * from './postcssPlugin/types'; 26 | export * from './util/createExposedPromise'; 27 | export * from './util/createIdGenerator'; 28 | export * from './util/createSandbox.for-test'; 29 | export * from './util/createTemporaryDirectory'; 30 | export * from './util/deleteFile'; 31 | export * from './util/encodeString'; 32 | export * from './util/ensureArray'; 33 | export * from './util/getBase64UrlHash'; 34 | export * from './util/ignoreNoEntryError'; 35 | export * from './util/isRecordLike'; 36 | export * from './util/normalizePath'; 37 | export * from './util/runCode.for-test'; 38 | export * from './util/serialize'; 39 | export * from './util/tokenizeString'; 40 | export * from './util/updateFile'; 41 | export * from './util/wait'; 42 | export * from './runner/Session'; 43 | export * from './runner/extractPluginResult'; 44 | export * from './runner/generateScript'; 45 | export * from './runner/getCSSParserConfiguration'; 46 | export * from './runner/getChokidarOptions'; 47 | export * from './runner/getExtensionOption'; 48 | export * from './runner/getIncludePatterns'; 49 | export * from './runner/getOutputOption'; 50 | export * from './runner/getSessionConfiguration'; 51 | export * from './runner/parseCSS'; 52 | export * from './runner/types'; 53 | export * from './runner/waitForInitialScanCompletion'; 54 | -------------------------------------------------------------------------------- /src/util/createSandbox.for-test.ts: -------------------------------------------------------------------------------- 1 | class Element { 2 | 3 | public readonly tagName: string; 4 | 5 | public readonly children: Array; 6 | 7 | public constructor(tagName: string) { 8 | this.tagName = tagName; 9 | this.children = []; 10 | } 11 | 12 | public appendChild(element: Element): Element { 13 | this.children.push(element); 14 | return element; 15 | } 16 | 17 | } 18 | 19 | class CSSRule { 20 | 21 | public cssText: string; 22 | 23 | public constructor(cssText: string) { 24 | this.cssText = cssText; 25 | } 26 | 27 | } 28 | 29 | class StyleSheet { 30 | 31 | public cssRules: Array; 32 | 33 | public constructor() { 34 | this.cssRules = []; 35 | } 36 | 37 | public insertRule(cssText: string, index = 0): void { 38 | this.cssRules.splice(index, 0, new CSSRule(cssText)); 39 | } 40 | 41 | } 42 | 43 | class StyleElement extends Element { 44 | 45 | public sheet: StyleSheet; 46 | 47 | public constructor() { 48 | super('style'); 49 | this.sheet = new StyleSheet(); 50 | } 51 | 52 | } 53 | 54 | export const isStyleElement = ( 55 | element: Element, 56 | ): element is StyleElement => element.tagName === 'style'; 57 | 58 | export const walkElements = function* ( 59 | element: Element, 60 | ): IterableIterator { 61 | yield element; 62 | for (const childElement of element.children) { 63 | yield* walkElements(childElement); 64 | } 65 | }; 66 | 67 | class Document { 68 | 69 | public readonly head: Element; 70 | 71 | public readonly body: Element; 72 | 73 | public constructor() { 74 | this.head = new Element('head'); 75 | this.body = new Element('body'); 76 | } 77 | 78 | public createElement(tagName: string): Element { 79 | switch (tagName) { 80 | case 'style': 81 | return new StyleElement(); 82 | default: 83 | return new Element(tagName); 84 | } 85 | } 86 | 87 | public *walkElements(): IterableIterator { 88 | yield* walkElements(this.head); 89 | yield* walkElements(this.body); 90 | } 91 | 92 | public get stylesheets(): Array { 93 | const result: Array = []; 94 | for (const element of this.walkElements()) { 95 | if (isStyleElement(element)) { 96 | result.push(element.sheet); 97 | } 98 | } 99 | return result; 100 | } 101 | 102 | public get css(): string { 103 | return this.stylesheets.map( 104 | (sheet) => sheet.cssRules.map((rule) => rule.cssText).join(''), 105 | ).join(''); 106 | } 107 | 108 | } 109 | 110 | interface Sandbox { 111 | document: Document, 112 | exports: Partial, 113 | } 114 | 115 | export const createSandbox = (): Sandbox => ({ 116 | document: new Document(), 117 | exports: {}, 118 | }); 119 | -------------------------------------------------------------------------------- /src/minifier/walker.d.ts: -------------------------------------------------------------------------------- 1 | import type * as acorn from 'acorn'; 2 | 3 | export type NodeType = 'ArrayExpression' | 'ArrayPattern' | 'ArrowFunctionExpression' | 'AssignmentExpression' | 'AssignmentPattern' | 'AwaitExpression' | 'BinaryExpression' | 'BlockStatement' | 'BreakStatement' | 'CallExpression' | 'CatchClause' | 'ClassBody' | 'ClassDeclaration' | 'ClassExpression' | 'ConditionalExpression' | 'ContinueStatement' | 'DebuggerStatement' | 'DoWhileStatement' | 'EmptyStatement' | 'ExportAllDeclaration' | 'ExportDefaultDeclaration' | 'ExportNamedDeclaration' | 'ExportSpecifier' | 'ExpressionStatement' | 'ForInStatement' | 'ForOfStatement' | 'ForStatement' | 'FunctionDeclaration' | 'FunctionExpression' | 'Identifier' | 'IfStatement' | 'ImportDeclaration' | 'ImportDefaultSpecifier' | 'ImportNamespaceSpecifier' | 'ImportSpecifier' | 'LabeledStatement' | 'Literal' | 'LogicalExpression' | 'MemberExpression' | 'MetaProperty' | 'MethodDefinition' | 'NewExpression' | 'ObjectExpression' | 'ObjectPattern' | 'ParenthesizedExpression' | 'Program' | 'Property' | 'RestElement' | 'ReturnStatement' | 'SequenceExpression' | 'SpreadElement' | 'Super' | 'SwitchCase' | 'SwitchStatement' | 'TaggedTemplateExpression' | 'TemplateElement' | 'TemplateLiteral' | 'ThisExpression' | 'ThrowStatement' | 'TryStatement' | 'UnaryExpression' | 'UpdateExpression' | 'VariableDeclaration' | 'VariableDeclarator' | 'WhileStatement' | 'WithStatement' | 'YieldExpression'; 4 | 5 | interface Node extends acorn.Node { 6 | type: NodeType, 7 | key?: Identifier, 8 | value?: Node | boolean | number | string | null, 9 | } 10 | 11 | interface Literal extends Node { 12 | type: 'Literal', 13 | raw: string, 14 | value: boolean | number | string | null, 15 | } 16 | 17 | interface StringLiteral extends Literal { 18 | value: string, 19 | } 20 | 21 | interface Identifier extends Node { 22 | type: 'Identifier', 23 | name: string, 24 | } 25 | 26 | interface CallExpression extends Node { 27 | type: 'CallExpression', 28 | callee?: Identifier, 29 | arguments: Array, 30 | } 31 | 32 | interface ExpressionStatement extends Node { 33 | type: 'ExpressionStatement', 34 | expression: CallExpression, 35 | } 36 | 37 | interface ImportSpecifier extends Node { 38 | type: 'ImportSpecifier', 39 | imported: Identifier, 40 | local: Identifier, 41 | } 42 | 43 | interface ImportDeclaration extends Node { 44 | type: 'ImportDeclaration', 45 | specifiers: Array, 46 | source: StringLiteral, 47 | } 48 | 49 | interface VariableDeclarator extends Node { 50 | type: 'VariableDeclaration', 51 | id: Identifier, 52 | } 53 | 54 | interface VariableDeclaration extends Node { 55 | type: 'VariableDeclaration', 56 | declarations: Array, 57 | } 58 | 59 | interface FunctionDeclaration extends Node { 60 | type: 'FunctionDeclaration', 61 | id: Identifier, 62 | } 63 | 64 | interface MemberExpression extends Node { 65 | type: 'MemberExpression', 66 | key: Identifier, 67 | value: Node, 68 | } 69 | 70 | interface ObjectExpression extends Node { 71 | type: 'ObjectExpression', 72 | properties: Array, 73 | } 74 | 75 | interface ArrayExpression extends Node { 76 | type: 'ArrayExpression', 77 | elements: Array, 78 | } 79 | 80 | interface Program extends Node { 81 | type: 'Program', 82 | body: Array, 83 | } 84 | 85 | interface Visitors { 86 | ImportDeclaration?: (node: ImportDeclaration) => void, 87 | ExpressionStatement?: (node: ExpressionStatement) => void, 88 | ObjectExpression?: (node: ObjectExpression) => void, 89 | } 90 | 91 | export const simple: ( 92 | ast: acorn.Node, 93 | visitors: Visitors, 94 | ) => void; 95 | 96 | export const base: Record void>; 97 | -------------------------------------------------------------------------------- /src/runner/types.ts: -------------------------------------------------------------------------------- 1 | import type {Writable} from 'stream'; 2 | import type {Matcher} from 'anymatch'; 3 | import type {AwaitWriteFinishOptions, WatchOptions} from 'chokidar'; 4 | import type {AcceptedPlugin, ProcessOptions, SourceMapOptions} from 'postcss'; 5 | import type {PluginOptions} from '../postcssPlugin/types'; 6 | 7 | export interface SessionOptions { 8 | /** 9 | * Pattern(s) to be included 10 | * @default "*" 11 | */ 12 | include?: Array | string, 13 | /** 14 | * Pattern(s) to be excluded. 15 | * @default ['node_modules'] 16 | */ 17 | exclude?: Matcher, 18 | /** 19 | * File extension(s) to be included. 20 | * @default ['.css'] 21 | */ 22 | extensions?: Array, 23 | /** 24 | * Where this plugin outputs the helper script. 25 | * If you use TypeScript, set a value like '*.ts'. 26 | * You can't use this option with the css option. 27 | * The {hash} in the default value is calculated from the include option. 28 | * @default "helper.{hash}.css.js" 29 | */ 30 | helper?: string, 31 | /** 32 | * File extension of generated script. 33 | * @default options.helper ? path.extname(options.helper) : '.js' 34 | */ 35 | ext?: string, 36 | /** 37 | * Where this plugin outputs the css. 38 | * You can't use this option with the helper option. 39 | * @default undefined 40 | */ 41 | css?: string, 42 | /** 43 | * It it is true, a watcher is enabled. 44 | * @default false 45 | */ 46 | watch?: boolean, 47 | /** 48 | * Options passed to chokidar. 49 | * You can't set ignoreInitial to true. 50 | * @default { 51 | * ignore: exclude, 52 | * ignoreInitial: false, 53 | * useFsEvents: false, 54 | * } 55 | */ 56 | chokidar?: WatchOptions, 57 | /** 58 | * An array of postcss plugins. 59 | * esifycss.plugin is appended to this array. 60 | * @default [] 61 | */ 62 | postcssPlugins?: Array, 63 | /** 64 | * https://github.com/postcss/postcss#options 65 | * @default undefined 66 | */ 67 | postcssOptions?: ProcessOptions, 68 | /** 69 | * Parameters for esifycss.plugin. 70 | */ 71 | esifycssPluginParameter?: PluginOptions, 72 | /** 73 | * A stream where the runner outputs logs. 74 | * @default process.stdout 75 | */ 76 | stdout?: Writable, 77 | /** 78 | * A stream where the runner outputs errorlogs. 79 | * @default process.stderr 80 | */ 81 | stderr?: Writable, 82 | } 83 | 84 | export interface ReadonlyWatchOptions extends Readonly { 85 | awaitWriteFinish?: Readonly | boolean, 86 | } 87 | 88 | export interface SessionOutput { 89 | type: 'css' | 'script', 90 | path: string, 91 | } 92 | 93 | export interface SessionConfiguration { 94 | readonly watch: boolean, 95 | readonly output: SessionOutput, 96 | readonly ext: string, 97 | readonly path: ReadonlyArray, 98 | readonly chokidar: ReadonlyWatchOptions, 99 | readonly stdout: Writable, 100 | readonly stderr: Writable, 101 | readonly postcssPlugins: Array, 102 | readonly postcssOptions: ProcessOptions, 103 | readonly cssKey: string, 104 | } 105 | 106 | export interface CSSParserParameters { 107 | file: string, 108 | css?: Buffer | string, 109 | options?: ProcessOptions, 110 | plugins: Array, 111 | map?: SourceMapOptions, 112 | } 113 | 114 | export interface CSSParserConfigurations { 115 | readonly css: string, 116 | readonly plugins: Array, 117 | readonly options: { 118 | readonly from: string, 119 | readonly map: SourceMapOptions, 120 | }, 121 | } 122 | -------------------------------------------------------------------------------- /src/postcssPlugin/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import * as parser from '@hookun/parse-animation-shorthand'; 2 | import test from 'ava'; 3 | import type {AtRule, Declaration, Rule} from 'postcss'; 4 | import {extractPluginResult} from '../runner/extractPluginResult'; 5 | import {postcss} from '../util/postcss'; 6 | import {plugin} from './plugin'; 7 | 8 | const getFirstDeclaration = ( 9 | rule: Rule, 10 | ): Declaration => { 11 | if (rule.nodes) { 12 | if (rule.nodes[1]) { 13 | throw new Error(`The given rule has multiple declarations: ${rule}`); 14 | } 15 | return rule.nodes[0] as Declaration; 16 | } 17 | throw new Error(`The given rule has no nodes: ${rule}`); 18 | }; 19 | 20 | test('plugin', async (t): Promise => { 21 | const transformer = plugin(); 22 | const resultA = await postcss([transformer]).process([ 23 | '@import "./b.css" c-;', 24 | '@keyframes aaa {0%{opacity:0}100%{opacity:1}}', 25 | '.foo:first-child[data-hello=abc]{animation-name: aaa}', 26 | '#bar1,#bar2:nth-of-type(2):not([data-hello=abc]){animation: linear aaa 1s 123ms}', 27 | '.c-foo>.raw-foo{animation-name: c-aaa}', 28 | '#c-bar>#raw-bar{animation-name: raw-ccc}', 29 | ].join('\n'), {from: '/a/a.css'}); 30 | const mapA = extractPluginResult(resultA); 31 | const resultB = await postcss([transformer]).process([ 32 | '@import "./a.css" c-;', 33 | '@keyframes aaa {0%{opacity:0}100%{opacity:1}}', 34 | '.foo{animation-name: c-aaa}', 35 | '#bar{animation-name: raw-ccc}', 36 | ].join('\n'), {from: '/a/b.css'}); 37 | const mapB = extractPluginResult(resultB); 38 | { 39 | const identifiers = [ 40 | mapA.className.foo, 41 | mapA.id.bar1, 42 | mapA.id.bar2, 43 | mapA.keyframes.aaa, 44 | mapB.className.foo, 45 | mapB.id.bar, 46 | mapB.keyframes.aaa, 47 | ]; 48 | t.true(identifiers.every((identifier) => Boolean(identifier))); 49 | t.is(identifiers.length, new Set(identifiers).size); 50 | } 51 | const rootA = postcss.parse(resultA.css); 52 | const rootB = postcss.parse(resultB.css); 53 | const nodes = [...(rootA.nodes || []), ...(rootB.nodes || [])]; 54 | let index = 0; 55 | { 56 | const node = nodes[index++] as AtRule; 57 | t.is(node.type, 'atrule'); 58 | t.is(node.name, 'keyframes'); 59 | t.is(node.params, `${mapA.keyframes.aaa}`); 60 | } 61 | { 62 | const node = nodes[index++] as Rule; 63 | t.is(node.type, 'rule'); 64 | t.is(node.selector, `.${mapA.className.foo}:first-child[data-hello=abc]`); 65 | { 66 | const declaration = getFirstDeclaration(node); 67 | t.is(declaration.prop, 'animation-name'); 68 | t.is(declaration.value, `${mapA.keyframes.aaa}`); 69 | } 70 | } 71 | { 72 | const node = nodes[index++] as Rule; 73 | t.is(node.type, 'rule'); 74 | t.is(node.selector, `#${mapA.id.bar1},#${mapA.id.bar2}:nth-of-type(2):not([data-hello=abc])`); 75 | { 76 | const declaration = getFirstDeclaration(node); 77 | t.is(declaration.prop, 'animation'); 78 | t.deepEqual( 79 | parser.parse(declaration.value), 80 | parser.parse(`linear ${mapA.keyframes.aaa} 1s 123ms`), 81 | ); 82 | } 83 | } 84 | { 85 | const node = nodes[index++] as Rule; 86 | t.is(node.type, 'rule'); 87 | t.is(node.selector, `.${mapB.className.foo}>.foo`); 88 | { 89 | const declaration = getFirstDeclaration(node); 90 | t.is(declaration.prop, 'animation-name'); 91 | t.is(declaration.value, `${mapB.keyframes.aaa}`); 92 | } 93 | } 94 | { 95 | const node = nodes[index++] as Rule; 96 | t.is(node.type, 'rule'); 97 | t.is(node.selector, `#${mapB.id.bar}>#bar`); 98 | { 99 | const declaration = getFirstDeclaration(node); 100 | t.is(declaration.prop, 'animation-name'); 101 | t.is(declaration.value, 'ccc'); 102 | } 103 | } 104 | { 105 | const node = nodes[index++] as AtRule; 106 | t.is(node.type, 'atrule'); 107 | t.is(node.name, 'keyframes'); 108 | t.is(node.params, `${mapB.keyframes.aaa}`); 109 | } 110 | { 111 | const node = nodes[index++] as Rule; 112 | t.is(node.type, 'rule'); 113 | t.is(node.selector, `.${mapB.className.foo}`); 114 | { 115 | const declaration = getFirstDeclaration(node); 116 | t.is(declaration.prop, 'animation-name'); 117 | t.is(declaration.value, `${mapA.keyframes.aaa}`); 118 | } 119 | } 120 | { 121 | const node = nodes[index++] as Rule; 122 | t.is(node.type, 'rule'); 123 | t.is(node.selector, `#${mapB.id.bar}`); 124 | { 125 | const declaration = getFirstDeclaration(node); 126 | t.is(declaration.prop, 'animation-name'); 127 | t.is(declaration.value, 'ccc'); 128 | } 129 | } 130 | }); 131 | -------------------------------------------------------------------------------- /test-client/run.ts: -------------------------------------------------------------------------------- 1 | import type * as childProcess from 'child_process'; 2 | import * as fs from 'fs'; 3 | import * as http from 'http'; 4 | import * as path from 'path'; 5 | import {URL} from 'url'; 6 | import * as selenium from 'selenium-webdriver'; 7 | import type * as BrowserStack from 'browserstack-local'; 8 | import anyTest from 'ava'; 9 | import type {TestFn} from 'ava'; 10 | import {capabilities} from './util/capabilities'; 11 | import {browserStack} from './util/constants'; 12 | import {createBrowserStackLocal} from './util/createBrowserStackLocal'; 13 | import {createRequestHandler} from './util/createRequestHandler'; 14 | import {markResult} from './util/markResult'; 15 | import {spawn} from './util/spawn'; 16 | 17 | const {writeFile} = fs.promises; 18 | 19 | const test = anyTest as TestFn<{ 20 | session?: selenium.Session, 21 | builder?: selenium.Builder, 22 | driver?: selenium.ThenableWebDriver, 23 | bsLocal: BrowserStack.Local, 24 | server: http.Server, 25 | port: number, 26 | baseURL: URL, 27 | passed: boolean, 28 | }>; 29 | 30 | /** 31 | * https://www.browserstack.com/question/664 32 | * Question: What ports can I use to test development environments or private 33 | * servers using BrowserStack? 34 | * → We support all ports for all browsers other than Safari. 35 | */ 36 | let port = 9200; 37 | test.beforeEach(async (t) => { 38 | t.context.passed = false; 39 | t.context.server = await new Promise((resolve, reject) => { 40 | const server = http.createServer() 41 | .once('error', reject) 42 | .once('listening', () => { 43 | server.removeListener('error', reject); 44 | resolve(server); 45 | }); 46 | server.listen(port++); 47 | }); 48 | const address = t.context.server.address(); 49 | if (address && typeof address === 'object') { 50 | t.context.port = address.port; 51 | t.context.baseURL = new URL(`http://localhost:${address.port}`); 52 | } else { 53 | throw new Error(`Invalid address: ${address}`); 54 | } 55 | }); 56 | 57 | test.afterEach(async (t) => { 58 | if (t.context.session) { 59 | await markResult(t.context.session, t.context.passed); 60 | } 61 | if (t.context.driver) { 62 | await t.context.driver.quit(); 63 | } 64 | await new Promise((resolve, reject) => { 65 | t.context.server.close((error) => { 66 | if (error) { 67 | reject(error); 68 | } else { 69 | resolve(); 70 | } 71 | }); 72 | }); 73 | if (t.context.bsLocal) { 74 | await new Promise((resolve) => { 75 | t.context.bsLocal.stop(resolve); 76 | }); 77 | } 78 | }); 79 | 80 | const builtProjects = new Set(); 81 | const build = async ( 82 | testDirectory: string, 83 | ) => { 84 | if (!builtProjects.has(testDirectory)) { 85 | const spawnOptions: childProcess.SpawnOptionsWithoutStdio = { 86 | cwd: testDirectory, 87 | shell: true, 88 | }; 89 | await spawn({command: 'npm install', options: spawnOptions}); 90 | await spawn({command: 'npm run build', options: spawnOptions}); 91 | builtProjects.add(testDirectory); 92 | } 93 | }; 94 | 95 | const testNameList = fs.readdirSync(__dirname).filter((name) => { 96 | try { 97 | return fs.statSync(path.join(__dirname, name, 'package.json')).isFile(); 98 | } catch { 99 | return false; 100 | } 101 | }); 102 | 103 | for (const testName of testNameList) { 104 | for (const $capability of capabilities) { 105 | const {'bstack:options': options} = $capability; 106 | const capability = { 107 | ...$capability, 108 | 'bstack:options': { 109 | ...options, 110 | sessionName: testName, 111 | }, 112 | }; 113 | const testDirectory = path.join(__dirname, testName); 114 | const outputDirectory = path.join(testDirectory, 'output'); 115 | test.serial(`${testName} ${options.os || options.deviceName || '-'} ${capability.browserName}`, async (t) => { 116 | t.timeout(120000); 117 | await build(testDirectory); 118 | t.context.server.on('request', createRequestHandler( 119 | outputDirectory, 120 | (message) => t.log(message), 121 | )); 122 | const builder = new selenium.Builder().withCapabilities(capability); 123 | t.context.builder = builder; 124 | if (browserStack) { 125 | builder.usingServer(browserStack.server); 126 | t.context.bsLocal = await createBrowserStackLocal({ 127 | accessKey: browserStack.accessKey, 128 | port: t.context.port, 129 | localIdentifier: capability['bstack:options'].localIdentifier, 130 | }); 131 | } 132 | const driver = t.context.driver = builder.build(); 133 | const baseURL = (/safari/i).test(capability.browserName) ? new URL(`http://bs-local.com:${t.context.port}`) : t.context.baseURL; 134 | t.context.session = await driver.getSession(); 135 | await driver.get(`${new URL('/index.html', baseURL)}`); 136 | await driver.wait(selenium.until.titleMatches(/(?:passed|failed)$/), 10000); 137 | const base64 = await driver.takeScreenshot(); 138 | const screenShot = Buffer.from(base64, 'base64'); 139 | await writeFile(path.join(outputDirectory, `${Date.now()}.png`), screenShot); 140 | const title = await driver.getTitle(); 141 | const passed = title === `${path.basename(testDirectory)} → passed`; 142 | t.true(passed); 143 | t.context.passed = passed; 144 | }); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esifycss", 3 | "version": "1.4.40", 4 | "description": "Generates .js or .ts exports class names and custom properties", 5 | "author": { 6 | "name": "Kei Ito", 7 | "email": "kei.itof@gmail.com", 8 | "url": "https://github.com/gjbkz" 9 | }, 10 | "license": "Apache-2.0", 11 | "engines": { 12 | "node": ">=10" 13 | }, 14 | "repository": "https://github.com/gjbkz/esifycss", 15 | "main": "lib/index.js", 16 | "files": [ 17 | "lib", 18 | "@types" 19 | ], 20 | "bin": { 21 | "esifycss": "lib/bin/esifycss.js" 22 | }, 23 | "scripts": { 24 | "lint": "eslint --ext .ts src scripts @types test-client", 25 | "build": "run-s build:*", 26 | "build:cleanup": "rimraf lib src/helper/*.js", 27 | "build:index": "nlib-indexen --exclude '**/bin/*' --exclude '**/helper/*' --exclude '**/*.(test|d).*' --exclude '*.d.ts' --output src/index.ts \"./**/*.ts\"", 28 | "build:tsc": "tsc --project tsconfig.build.json", 29 | "build:dts": "ts-node scripts/copy ./src/minifier/walker.d.ts ./lib/minifier/walker.d.ts", 30 | "build:helper": "run-s build:helper:*", 31 | "build:helper:cleanup": "rimraf lib/helper", 32 | "build:helper:copy1": "ts-node scripts/copy ./src/helper ./lib/helper", 33 | "build:helper:tsc": "tsc --project tsconfig.helper.json", 34 | "build:helper:copy2": "ts-node scripts/copy ./lib/helper ./src/helper", 35 | "build:bin": "ts-node scripts/chmodScripts.ts", 36 | "build:sample1": "run-s build:sample1:*", 37 | "build:sample1:cleanup": "rimraf sample/01-mangle", 38 | "build:sample1:copy": "ts-node scripts/copy sample/00-src sample/01-mangle", 39 | "build:sample1:esifycss": "node ./lib/bin/esifycss.js --helper sample/01-mangle/helper.js 'sample/01-mangle/**/*.css'", 40 | "build:sample2": "run-s build:sample2:*", 41 | "build:sample2:cleanup": "rimraf sample/02-no-mangle", 42 | "build:sample2:copy": "ts-node scripts/copy sample/00-src sample/02-no-mangle", 43 | "build:sample2:esifycss": "node ./lib/bin/esifycss.js --noMangle --helper sample/02-no-mangle/helper.js 'sample/02-no-mangle/**/*.css'", 44 | "test": "run-s test:*", 45 | "test:type": "tsc --noEmit", 46 | "test:ava": "ava --config ava.config.cjs", 47 | "test-client": "ava --config ava.config.client.cjs", 48 | "version": "run-s build:index version:*", 49 | "version:changelog": "nlib-changelog --output CHANGELOG.md", 50 | "version:add": "git add ." 51 | }, 52 | "dependencies": { 53 | "@hookun/parse-animation-shorthand": "0.1.5", 54 | "acorn": "8.10.0", 55 | "acorn-walk": "8.2.0", 56 | "chokidar": "3.5.3", 57 | "commander": "9.5.0", 58 | "postcss": "8.4.35", 59 | "postcss-selector-parser": "6.0.13", 60 | "vlq": "2.0.4" 61 | }, 62 | "devDependencies": { 63 | "@nlib/changelog": "0.3.1", 64 | "@nlib/eslint-config": "3.20.5", 65 | "@nlib/githooks": "0.2.0", 66 | "@nlib/indexen": "0.2.9", 67 | "@nlib/lint-commit": "0.2.0", 68 | "@types/anymatch": "3.0.0", 69 | "@types/jsdom": "20.0.1", 70 | "@types/micromatch": "4.0.6", 71 | "@types/node": "18.17.18", 72 | "@types/selenium-webdriver": "4.1.16", 73 | "@typescript-eslint/eslint-plugin": "5.62.0", 74 | "@typescript-eslint/parser": "5.62.0", 75 | "ava": "5.3.1", 76 | "browserstack-local": "1.5.5", 77 | "eslint": "8.49.0", 78 | "lint-staged": "13.3.0", 79 | "npm-run-all": "4.1.5", 80 | "postcss-scss": "4.0.9", 81 | "rimraf": "3.0.2", 82 | "rollup": "3.29.4", 83 | "selenium-webdriver": "4.12.0", 84 | "ts-node": "10.9.1", 85 | "typescript": "4.9.5" 86 | }, 87 | "eslintConfig": { 88 | "extends": [ 89 | "@nlib/eslint-config" 90 | ], 91 | "env": { 92 | "node": true 93 | }, 94 | "ignorePatterns": [ 95 | "**/temp/*", 96 | "*.css.ts" 97 | ], 98 | "rules": { 99 | "no-lone-blocks": "off", 100 | "@nlib/no-globals": "off", 101 | "import/no-relative-parent-imports": "off" 102 | }, 103 | "overrides": [ 104 | { 105 | "files": [ 106 | "scripts/*.ts" 107 | ], 108 | "rules": { 109 | "no-bitwise": "off", 110 | "no-console": "off" 111 | } 112 | }, 113 | { 114 | "files": [ 115 | "test-client/**/*.ts", 116 | "*.test.ts", 117 | "*.for-test.ts" 118 | ], 119 | "rules": { 120 | "no-console": "off", 121 | "no-process-env": "off", 122 | "max-lines-per-function": "off", 123 | "class-methods-use-this": "off", 124 | "require-atomic-updates": "off", 125 | "@typescript-eslint/no-floating-promises": "off", 126 | "@typescript-eslint/no-unnecessary-condition": "off", 127 | "@typescript-eslint/triple-slash-reference": "off" 128 | } 129 | }, 130 | { 131 | "files": [ 132 | "src/helper/**/*" 133 | ], 134 | "parserOptions": { 135 | "project": "./tsconfig.helper.json" 136 | }, 137 | "rules": { 138 | "no-bitwise": "off" 139 | } 140 | }, 141 | { 142 | "files": [ 143 | "src/helper/**/*", 144 | "test-client/*/src/*", 145 | "sample/**/*" 146 | ], 147 | "env": { 148 | "node": false, 149 | "browser": true 150 | } 151 | } 152 | ] 153 | }, 154 | "lint-staged": { 155 | "*.ts": [ 156 | "eslint" 157 | ] 158 | }, 159 | "renovate": { 160 | "extends": [ 161 | "github>nlibjs/renovate-config" 162 | ] 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/minifier/parseCSSModuleScript.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {parseCSSModuleScript} from './parseCSSModuleScript'; 3 | 4 | interface Test { 5 | input: { 6 | title: string, 7 | code: string, 8 | cssKey: string, 9 | }, 10 | expected: ReturnType | null, 11 | } 12 | 13 | ([ 14 | { 15 | input: { 16 | title: 'simple', 17 | code: [ 18 | ' import {addStyle} from \'esifycss\';', 19 | 'addStyle([{esifycss:\'aaa\'}]);', 20 | ].join('\n'), 21 | cssKey: 'esifycss', 22 | }, 23 | expected: { 24 | ranges: [ 25 | { 26 | css: 'aaa', 27 | start: 46, 28 | end: 62, 29 | }, 30 | ], 31 | expressionStatements: [{start: 36, end: 65}], 32 | importDeclarations: [{start: 1, end: 35}], 33 | }, 34 | }, 35 | { 36 | input: { 37 | title: 'InvalidArrayExpression', 38 | code: [ 39 | ' import {addStyle} from \'esifycss\';', 40 | 'addStyle({esifycss:\'aaa\'});', 41 | ].join('\n'), 42 | cssKey: 'esifycss', 43 | }, 44 | expected: { 45 | ranges: [], 46 | expressionStatements: [], 47 | importDeclarations: [{start: 1, end: 35}], 48 | }, 49 | }, 50 | { 51 | input: { 52 | title: 'NoAddStyle', 53 | code: 'addStyle([{esifycss:\'aaa\'}]);', 54 | cssKey: 'esifycss', 55 | }, 56 | expected: { 57 | ranges: [ 58 | { 59 | css: 'aaa', 60 | start: 10, 61 | end: 26, 62 | }, 63 | ], 64 | expressionStatements: [{start: 0, end: 29}], 65 | importDeclarations: [], 66 | }, 67 | }, 68 | { 69 | input: { 70 | title: 'InvalidObjectExpression', 71 | code: [ 72 | 'import {addStyle} from \'esifycss\';', 73 | 'addStyle([0]);', 74 | ].join('\n'), 75 | cssKey: 'esifycss', 76 | }, 77 | expected: { 78 | ranges: [], 79 | expressionStatements: [], 80 | importDeclarations: [{start: 0, end: 34}], 81 | }, 82 | }, 83 | { 84 | input: { 85 | title: 'EmptyArray', 86 | code: [ 87 | 'import {addStyle} from \'esifycss\';', 88 | 'addStyle([]);', 89 | ].join('\n'), 90 | cssKey: 'esifycss', 91 | }, 92 | expected: { 93 | ranges: [], 94 | expressionStatements: [], 95 | importDeclarations: [{start: 0, end: 34}], 96 | }, 97 | }, 98 | { 99 | input: { 100 | title: 'Empty', 101 | code: [ 102 | 'import {addStyle} from \'esifycss\';', 103 | 'addStyle();', 104 | ].join('\n'), 105 | cssKey: 'esifycss', 106 | }, 107 | expected: { 108 | ranges: [], 109 | expressionStatements: [], 110 | importDeclarations: [{start: 0, end: 34}], 111 | }, 112 | }, 113 | { 114 | input: { 115 | title: 'InvalidKey', 116 | code: [ 117 | 'import {addStyle} from \'esifycss\';', 118 | 'addStyle([{foo:\'aaa\'}]);', 119 | ].join('\n'), 120 | cssKey: 'esifycss', 121 | }, 122 | expected: { 123 | ranges: [], 124 | expressionStatements: [], 125 | importDeclarations: [{start: 0, end: 34}], 126 | }, 127 | }, 128 | { 129 | input: { 130 | title: 'NonLiteral', 131 | code: [ 132 | 'import {addStyle} from \'esifycss\';', 133 | 'addStyle([{esifycss:[]}]);', 134 | ].join('\n'), 135 | cssKey: 'esifycss', 136 | }, 137 | expected: null, 138 | }, 139 | { 140 | input: { 141 | title: 'InvalidCSSType', 142 | code: [ 143 | 'import {addStyle} from \'esifycss\';', 144 | 'addStyle([{esifycss:1}]);', 145 | ].join('\n'), 146 | cssKey: 'esifycss', 147 | }, 148 | expected: null, 149 | }, 150 | { 151 | input: { 152 | title: 'Locally declared const addFoooo', 153 | code: [ 154 | ' const addFoooo = () => null;', 155 | 'addFoooo([{esifycss:\'aaa\'}]);', 156 | ].join('\n'), 157 | cssKey: 'esifycss', 158 | }, 159 | expected: { 160 | ranges: [ 161 | { 162 | css: 'aaa', 163 | start: 40, 164 | end: 56, 165 | }, 166 | ], 167 | expressionStatements: [{start: 30, end: 59}], 168 | importDeclarations: [], 169 | }, 170 | }, 171 | { 172 | input: { 173 | title: 'Locally declared function addFoooo', 174 | code: [ 175 | ' function addFoooo() {return null}', 176 | 'addFoooo([{esifycss:\'aaa\'}]);', 177 | ].join('\n'), 178 | cssKey: 'esifycss', 179 | }, 180 | expected: { 181 | ranges: [ 182 | { 183 | css: 'aaa', 184 | start: 45, 185 | end: 61, 186 | }, 187 | ], 188 | expressionStatements: [{start: 35, end: 64}], 189 | importDeclarations: [], 190 | }, 191 | }, 192 | { 193 | input: { 194 | title: 'Ignore AssignmentExpression', 195 | code: [ 196 | ' function addFoooo() {return null}', 197 | 'addFoooo([{esifycss:\'aaa\'}]);', 198 | 'a += 1', 199 | ].join('\n'), 200 | cssKey: 'esifycss', 201 | localName: 'addFoooo', 202 | }, 203 | expected: { 204 | ranges: [ 205 | { 206 | css: 'aaa', 207 | start: 45, 208 | end: 61, 209 | }, 210 | ], 211 | expressionStatements: [{start: 35, end: 64}], 212 | importDeclarations: [], 213 | }, 214 | }, 215 | { 216 | input: { 217 | title: 'dynamic import', 218 | code: [ 219 | 'import(\'./foo\').catch((err) => console.error(err));', 220 | 'addStyle([{esifycss:\'aaa\'}]);', 221 | ].join('\n'), 222 | cssKey: 'esifycss', 223 | }, 224 | expected: { 225 | ranges: [ 226 | { 227 | css: 'aaa', 228 | start: 62, 229 | end: 78, 230 | }, 231 | ], 232 | expressionStatements: [ 233 | {start: 52, end: 81}, 234 | ], 235 | importDeclarations: [], 236 | }, 237 | }, 238 | ] as Array).forEach(({input, expected}, index) => { 239 | test(`#${index} ${input.title}${expected ? '' : ' → Error'}`, (t) => { 240 | if (expected) { 241 | const actual = parseCSSModuleScript(input); 242 | t.deepEqual(actual, expected); 243 | } else { 244 | const error = t.throws(() => parseCSSModuleScript(input)); 245 | t.is(error && error.message.slice(0, input.title.length), input.title); 246 | } 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /src/runner/Session.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as chokidar from 'chokidar'; 4 | import {minifyScripts} from '../minifier/minifyScripts'; 5 | import type {ExposedPromise} from '../util/createExposedPromise'; 6 | import {createExposedPromise} from '../util/createExposedPromise'; 7 | import {minifyScriptsForCSS} from '../minifier/minifyScriptsForCSS'; 8 | import {deleteFile} from '../util/deleteFile'; 9 | import {updateFile} from '../util/updateFile'; 10 | import {serialize} from '../util/serialize'; 11 | import {waitForInitialScanCompletion} from './waitForInitialScanCompletion'; 12 | import {generateScript} from './generateScript'; 13 | import {extractPluginResult} from './extractPluginResult'; 14 | import {parseCSS} from './parseCSS'; 15 | import {getSessionConfiguration} from './getSessionConfiguration'; 16 | import type {SessionOptions, SessionConfiguration} from './types'; 17 | 18 | const {copyFile} = fs.promises; 19 | 20 | export class Session { 21 | 22 | public readonly configuration: Readonly; 23 | 24 | protected watcher?: chokidar.FSWatcher; 25 | 26 | protected processedFiles: Set; 27 | 28 | protected initialTask: Array> | null; 29 | 30 | protected tasks: Set>; 31 | 32 | public constructor(parameters: SessionOptions = {}) { 33 | this.configuration = getSessionConfiguration(parameters); 34 | this.processedFiles = new Set(); 35 | this.initialTask = null; 36 | this.tasks = new Set(); 37 | } 38 | 39 | public get helperPath(): string { 40 | const srcDirectory = path.join(__dirname, '..', 'helper'); 41 | const name = this.configuration.output.type === 'script' ? 'default' : 'noop'; 42 | return path.join(srcDirectory, `${name}${this.configuration.ext}`); 43 | } 44 | 45 | public async start(): Promise { 46 | await this.outputHelperScript(); 47 | await this.startWatcher(); 48 | } 49 | 50 | public async stop(): Promise { 51 | await this.stopWatcher(); 52 | } 53 | 54 | public async outputHelperScript(): Promise { 55 | if (this.configuration.output.type === 'script') { 56 | await copyFile(this.helperPath, this.configuration.output.path); 57 | } 58 | } 59 | 60 | public async processCSS( 61 | filePath: string, 62 | ): Promise<{dest: string, code: string}> { 63 | if (0 < this.tasks.size) { 64 | await this.waitCurrentTasks(); 65 | } 66 | const exposedPromise = this.createExposedPromise(); 67 | const {configuration} = this; 68 | const postcssResult = await parseCSS({ 69 | plugins: configuration.postcssPlugins, 70 | options: configuration.postcssOptions, 71 | file: filePath, 72 | }).catch((error: unknown) => { 73 | exposedPromise.resolve(); 74 | throw error; 75 | }); 76 | const dest = path.join(`${filePath}${configuration.ext}`); 77 | this.processedFiles.add(filePath); 78 | const {output} = configuration; 79 | const code = [ 80 | ...generateScript({ 81 | output: dest, 82 | helper: output.type === 'css' ? this.helperPath : output.path, 83 | result: extractPluginResult(postcssResult), 84 | root: postcssResult.root, 85 | cssKey: this.configuration.cssKey, 86 | }), 87 | '', 88 | ].join('\n'); 89 | exposedPromise.resolve(); 90 | return {dest, code}; 91 | } 92 | 93 | public async minifyScripts(): Promise { 94 | const files = new Map( 95 | [...this.processedFiles].map((file) => [file, `${file}${this.configuration.ext}`]), 96 | ); 97 | const {cssKey, output} = this.configuration; 98 | if (output.type === 'css') { 99 | await minifyScriptsForCSS({files, cssKey, dest: output.path}); 100 | } else { 101 | await minifyScripts({files, cssKey, dest: output.path}); 102 | } 103 | this.log(`written: ${output.path}`); 104 | } 105 | 106 | protected async waitCurrentTasks(): Promise { 107 | await Promise.all([...this.tasks]); 108 | } 109 | 110 | protected createExposedPromise(): ExposedPromise { 111 | const exposedPromise = createExposedPromise(); 112 | this.tasks.add(exposedPromise.promise); 113 | const removeTask = () => this.tasks.delete(exposedPromise.promise); 114 | exposedPromise.promise.then(removeTask).catch(removeTask); 115 | return exposedPromise; 116 | } 117 | 118 | protected async startWatcher(): Promise { 119 | await this.stopWatcher(); 120 | this.initialTask = []; 121 | this.log(`watching: ${this.configuration.path.join(', ')}`); 122 | const onError = this.onError.bind(this); 123 | let postUpdate = async () => await Promise.resolve(); 124 | this.watcher = chokidar.watch(this.configuration.path, this.configuration.chokidar) 125 | .on('error', onError) 126 | .on('add', (file, stats) => { 127 | this.log(`[add] ${file}`); 128 | const promise = this.onAdd(file, stats); 129 | if (this.initialTask) { 130 | this.initialTask.push(promise); 131 | } 132 | promise.then(postUpdate).catch(onError); 133 | }) 134 | .on('change', (file, stats) => { 135 | this.log(`[change] ${file}`); 136 | this.onChange(file, stats).then(postUpdate).catch(onError); 137 | }) 138 | .on('unlink', (file) => { 139 | this.log(`[unlink] ${file}`); 140 | this.onUnlink(file).then(postUpdate).catch(onError); 141 | }); 142 | await waitForInitialScanCompletion(this.watcher); 143 | await Promise.all(this.initialTask); 144 | this.initialTask = null; 145 | if (this.configuration.watch) { 146 | if (this.configuration.output.type === 'css') { 147 | await this.minifyScripts(); 148 | postUpdate = this.minifyScripts.bind(this); 149 | } 150 | } else { 151 | await this.minifyScripts(); 152 | await this.stop(); 153 | } 154 | } 155 | 156 | protected log(...values: Array): void { 157 | const {configuration: {stdout}} = this; 158 | for (const value of values) { 159 | stdout.write(`${serialize(value)}\n`); 160 | } 161 | } 162 | 163 | protected logError(...values: Array): void { 164 | const {configuration: {stderr}} = this; 165 | for (const value of values) { 166 | stderr.write(`${serialize(value)}\n`); 167 | } 168 | } 169 | 170 | protected async stopWatcher(): Promise { 171 | if (this.watcher) { 172 | await this.watcher.close(); 173 | delete this.watcher; 174 | } 175 | await Promise.resolve(null); 176 | } 177 | 178 | protected onError(error: Error): void { 179 | this.logError(error); 180 | } 181 | 182 | protected async onAdd( 183 | file: string, 184 | stats?: fs.Stats, 185 | ): Promise { 186 | await this.onChange(file, stats); 187 | } 188 | 189 | protected async onChange( 190 | file: string, 191 | stats?: fs.Stats, 192 | ): Promise { 193 | if (!stats) { 194 | throw new Error(`no stats is given for ${file}.`); 195 | } 196 | if (!stats.isFile()) { 197 | throw new Error(`${file} is not a file.`); 198 | } 199 | const {dest, code} = await this.processCSS(file); 200 | await updateFile(dest, code); 201 | this.log(`written: ${dest}`); 202 | } 203 | 204 | protected async onUnlink( 205 | file: string, 206 | ): Promise { 207 | const outputPath = path.join(`${file}${this.configuration.ext}`); 208 | this.processedFiles.delete(file); 209 | await deleteFile(outputPath); 210 | this.log(`deleted: ${outputPath}`); 211 | } 212 | 213 | } 214 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | 1. Definitions. 7 | 8 | "License" shall mean the terms and conditions for use, reproduction, 9 | and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by 12 | the copyright owner that is granting the License. 13 | 14 | "Legal Entity" shall mean the union of the acting entity and all 15 | other entities that control, are controlled by, or are under common 16 | control with that entity. For the purposes of this definition, 17 | "control" means (i) the power, direct or indirect, to cause the 18 | direction or management of such entity, whether by contract or 19 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity 23 | exercising permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, 26 | including but not limited to software source code, documentation 27 | source, and configuration files. 28 | 29 | "Object" form shall mean any form resulting from mechanical 30 | transformation or translation of a Source form, including but 31 | not limited to compiled object code, generated documentation, 32 | and conversions to other media types. 33 | 34 | "Work" shall mean the work of authorship, whether in Source or 35 | Object form, made available under the License, as indicated by a 36 | copyright notice that is included in or attached to the work 37 | (an example is provided in the Appendix below). 38 | 39 | "Derivative Works" shall mean any work, whether in Source or Object 40 | form, that is based on (or derived from) the Work and for which the 41 | editorial revisions, annotations, elaborations, or other modifications 42 | represent, as a whole, an original work of authorship. For the purposes 43 | of this License, Derivative Works shall not include works that remain 44 | separable from, or merely link (or bind by name) to the interfaces of, 45 | the Work and Derivative Works thereof. 46 | 47 | "Contribution" shall mean any work of authorship, including 48 | the original version of the Work and any modifications or additions 49 | to that Work or Derivative Works thereof, that is intentionally 50 | submitted to Licensor for inclusion in the Work by the copyright owner 51 | or by an individual or Legal Entity authorized to submit on behalf of 52 | the copyright owner. For the purposes of this definition, "submitted" 53 | means any form of electronic, verbal, or written communication sent 54 | to the Licensor or its representatives, including but not limited to 55 | communication on electronic mailing lists, source code control systems, 56 | and issue tracking systems that are managed by, or on behalf of, the 57 | Licensor for the purpose of discussing and improving the Work, but 58 | excluding communication that is conspicuously marked or otherwise 59 | designated in writing by the copyright owner as "Not a Contribution." 60 | 61 | "Contributor" shall mean Licensor and any individual or Legal Entity 62 | on behalf of whom a Contribution has been received by Licensor and 63 | subsequently incorporated within the Work. 64 | 65 | 2. Grant of Copyright License. Subject to the terms and conditions of 66 | this License, each Contributor hereby grants to You a perpetual, 67 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 68 | copyright license to reproduce, prepare Derivative Works of, 69 | publicly display, publicly perform, sublicense, and distribute the 70 | Work and such Derivative Works in Source or Object form. 71 | 72 | 3. Grant of Patent License. Subject to the terms and conditions of 73 | this License, each Contributor hereby grants to You a perpetual, 74 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 75 | (except as stated in this section) patent license to make, have made, 76 | use, offer to sell, sell, import, and otherwise transfer the Work, 77 | where such license applies only to those patent claims licensable 78 | by such Contributor that are necessarily infringed by their 79 | Contribution(s) alone or by combination of their Contribution(s) 80 | with the Work to which such Contribution(s) was submitted. If You 81 | institute patent litigation against any entity (including a 82 | cross-claim or counterclaim in a lawsuit) alleging that the Work 83 | or a Contribution incorporated within the Work constitutes direct 84 | or contributory patent infringement, then any patent licenses 85 | granted to You under this License for that Work shall terminate 86 | as of the date such litigation is filed. 87 | 88 | 4. Redistribution. You may reproduce and distribute copies of the 89 | Work or Derivative Works thereof in any medium, with or without 90 | modifications, and in Source or Object form, provided that You 91 | meet the following conditions: 92 | 93 | (a) You must give any other recipients of the Work or 94 | Derivative Works a copy of this License; and 95 | 96 | (b) You must cause any modified files to carry prominent notices 97 | stating that You changed the files; and 98 | 99 | (c) You must retain, in the Source form of any Derivative Works 100 | that You distribute, all copyright, patent, trademark, and 101 | attribution notices from the Source form of the Work, 102 | excluding those notices that do not pertain to any part of 103 | the Derivative Works; and 104 | 105 | (d) If the Work includes a "NOTICE" text file as part of its 106 | distribution, then any Derivative Works that You distribute must 107 | include a readable copy of the attribution notices contained 108 | within such NOTICE file, excluding those notices that do not 109 | pertain to any part of the Derivative Works, in at least one 110 | of the following places: within a NOTICE text file distributed 111 | as part of the Derivative Works; within the Source form or 112 | documentation, if provided along with the Derivative Works; or, 113 | within a display generated by the Derivative Works, if and 114 | wherever such third-party notices normally appear. The contents 115 | of the NOTICE file are for informational purposes only and 116 | do not modify the License. You may add Your own attribution 117 | notices within Derivative Works that You distribute, alongside 118 | or as an addendum to the NOTICE text from the Work, provided 119 | that such additional attribution notices cannot be construed 120 | as modifying the License. 121 | 122 | You may add Your own copyright statement to Your modifications and 123 | may provide additional or different license terms and conditions 124 | for use, reproduction, or distribution of Your modifications, or 125 | for any such Derivative Works as a whole, provided Your use, 126 | reproduction, and distribution of the Work otherwise complies with 127 | the conditions stated in this License. 128 | 129 | 5. Submission of Contributions. Unless You explicitly state otherwise, 130 | any Contribution intentionally submitted for inclusion in the Work 131 | by You to the Licensor shall be under the terms and conditions of 132 | this License, without any additional terms or conditions. 133 | Notwithstanding the above, nothing herein shall supersede or modify 134 | the terms of any separate license agreement you may have executed 135 | with Licensor regarding such Contributions. 136 | 137 | 6. Trademarks. This License does not grant permission to use the trade 138 | names, trademarks, service marks, or product names of the Licensor, 139 | except as required for reasonable and customary use in describing the 140 | origin of the Work and reproducing the content of the NOTICE file. 141 | 142 | 7. Disclaimer of Warranty. Unless required by applicable law or 143 | agreed to in writing, Licensor provides the Work (and each 144 | Contributor provides its Contributions) on an "AS IS" BASIS, 145 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 146 | implied, including, without limitation, any warranties or conditions 147 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 148 | PARTICULAR PURPOSE. You are solely responsible for determining the 149 | appropriateness of using or redistributing the Work and assume any 150 | risks associated with Your exercise of permissions under this License. 151 | 152 | 8. Limitation of Liability. In no event and under no legal theory, 153 | whether in tort (including negligence), contract, or otherwise, 154 | unless required by applicable law (such as deliberate and grossly 155 | negligent acts) or agreed to in writing, shall any Contributor be 156 | liable to You for damages, including any direct, indirect, special, 157 | incidental, or consequential damages of any character arising as a 158 | result of this License or out of the use or inability to use the 159 | Work (including but not limited to damages for loss of goodwill, 160 | work stoppage, computer failure or malfunction, or any and all 161 | other commercial damages or losses), even if such Contributor 162 | has been advised of the possibility of such damages. 163 | 164 | 9. Accepting Warranty or Additional Liability. While redistributing 165 | the Work or Derivative Works thereof, You may choose to offer, 166 | and charge a fee for, acceptance of support, warranty, indemnity, 167 | or other liability obligations and/or rights consistent with this 168 | License. However, in accepting such obligations, You may act only 169 | on Your own behalf and on Your sole responsibility, not on behalf 170 | of any other Contributor, and only if You agree to indemnify, 171 | defend, and hold each Contributor harmless for any liability 172 | incurred by, or claims asserted against, such Contributor by reason 173 | of your accepting any such warranty or additional liability. 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EsifyCSS 2 | 3 | [![.github/workflows/test.yml](https://github.com/gjbkz/esifycss/actions/workflows/test.yml/badge.svg)](https://github.com/gjbkz/esifycss/actions/workflows/test.yml) 4 | [![BrowserStack Status](https://www.browserstack.com/automate/badge.svg?badge_key=WDQvOHgwbkRNTUFyUVkrc0RmdGgva0diVk01Tm9LWU95ZFNGVTByeHhpVT0tLUc2RW9lNnNaY2k4QkVCSjMyalRGTVE9PQ==--007efb48774305e72904bb3a15d3b0d048dbfb91)](https://www.browserstack.com/automate/public-build/WDQvOHgwbkRNTUFyUVkrc0RmdGgva0diVk01Tm9LWU95ZFNGVTByeHhpVT0tLUc2RW9lNnNaY2k4QkVCSjMyalRGTVE9PQ==--007efb48774305e72904bb3a15d3b0d048dbfb91) 5 | [![codecov](https://codecov.io/gh/gjbkz/esifycss/branch/master/graph/badge.svg)](https://codecov.io/gh/gjbkz/esifycss) 6 | 7 | ## Introduction 8 | 9 | EsifyCSS finds CSS files in your project and generates ES modules for each of 10 | them. 11 | 12 | Assume that you have `src/style1.css` and `src/style2.css`. They have the same 13 | contents: 14 | 15 | ```css 16 | /* src/style1.css, src/style2.css */ 17 | @keyframes FadeIn { 18 | 0%: {opacity: 0} 19 | 100%: {opacity: 0} 20 | } 21 | @keyframes Rotate { 22 | 0%: {transform: rotate( 0deg)} 23 | 100%: {transform: rotate(360deg)} 24 | } 25 | #container { 26 | animation: 0.2s linear FadeIn; 27 | } 28 | .icon { 29 | animation-duration: 1s; 30 | animation-iteration-count: infinite; 31 | animation-timing-function: linear; 32 | } 33 | .icon.rotate { 34 | animation-name: Rotate; 35 | } 36 | ``` 37 | 38 | Then, run `esifycss --helper src/helper.js src`. `--helper src/helper.js` is 39 | where the helper script is written. The last `src` specifies the directory that 40 | contains the file to be processed by EsifyCSS. 41 | 42 | The process finds CSS files, parses them, extracts identifiers, replaces them with 43 | values. 44 | 45 | After the process, you'll get `src/style1.css.js` and `src/style2.css.js`: 46 | 47 | ```javascript 48 | // src/style1.css.js 49 | import {addStyle} from './helper.js'; 50 | addStyle(["WYIGqCCQSCaAQEcSCaAUEE","WYIGsCCQSCeAgBiBIIQkBmBEcSCeAgBiByBkBmBEE","0BGQC2BA4BKOA6BoBIqBIGqCKE","sBGUCOM8BAUoBKOM+BMgCAiCKOMkCMmCAqBKE","sBG2CG4CCOMoCAGsCKE"]); 51 | export const className = { 52 | "icon": "_1", 53 | "rotate": "_2" 54 | }; 55 | export const id = { 56 | "container": "_0" 57 | }; 58 | export const keyframes = { 59 | "FadeIn": "_3", 60 | "Rotate": "_4" 61 | }; 62 | ``` 63 | 64 | ```javascript 65 | // src/style2.css.js 66 | import {addStyle} from './helper.js'; 67 | addStyle(["WYIGuBCQSCaAQEcSCaAUEE","WYIGwBCQSCeAgBiBIIQkBmBEcSCeAgBiByBkBmBEE","0BGuCC2BA4BKOA6BoBIqBIGuBKE","sBGwCCOM8BAUoBKOM+BMgCAiCKOMkCMmCAqBKE","sBGyCG0CCOMoCAGwBKE"]); 68 | export const className = { 69 | "icon": "_6", 70 | "rotate": "_7" 71 | }; 72 | export const id = { 73 | "container": "_5" 74 | }; 75 | export const keyframes = { 76 | "FadeIn": "_8", 77 | "Rotate": "_9" 78 | }; 79 | ``` 80 | 81 | The two modules are almost the same, but the exported objects are different. And 82 | there will be `src/helper.js` which exports the `addStyle` function which 83 | applies the style to documents. You can see the code at 84 | [sample/01-mangle/helper.js](sample/01-mangle/helper.js). 85 | 86 | The exported objects are mappings of identifiers of `className`, `id`, and 87 | `keyframes` that were replaced in the process. You should import them and use 88 | the replaced identifiers instead of original in the code: 89 | 90 | ```javascript 91 | import style from './style1.css.js'; 92 | const element = document.createElement('div'); 93 | element.classList.add(style.className.icon); 94 | ``` 95 | 96 | ## Tools 97 | 98 | EsifyCSS consists of **PostCSS plugin**, **Runner** and **CLI**. 99 | 100 | ### PostCSS plugin 101 | 102 | The plugin converts the identifiers in CSS and minifies them. It outputs the 103 | result of minifications using [Root.warn()]. 104 | 105 | [Root.warn()]: http://api.postcss.org/Root.html#warn 106 | 107 | ### Runner 108 | 109 | A runner process `.css` files in your project with PostCSS and output the 110 | results to `.css.js` or `.css.ts`. 111 | 112 | ### CLI 113 | 114 | ``` 115 | Usage: esifycss [options] 116 | 117 | Options: 118 | -V, --version output the version number 119 | --helper A path where the helper script will be output. You can't use --helper with --css. 120 | --css A path where the css will be output. You can't use --css with --helper. 121 | --ext An extension of scripts generated from css. 122 | --config A path to configuration files. 123 | --exclude Paths or patterns to be excluded. 124 | --noMangle Keep the original name for debugging. 125 | --watch Watch files and update the modules automatically. 126 | -h, --help output usage information 127 | ``` 128 | 129 | #### example: generate .css.ts and css-helper.ts 130 | 131 | ``` 132 | esifycss --helper=css-helper.ts --ext=.ts 133 | ``` 134 | 135 | #### example: TypeScript based Next.js project 136 | 137 | Assume that you have following files: 138 | 139 | ``` 140 | src/ 141 | styles/ 142 | global.css 143 | components/ 144 | Button/ 145 | index.ts 146 | style.module.css 147 | pages/ 148 | _app.tsx 149 | ``` 150 | 151 | Then, run the following command: 152 | 153 | ``` 154 | esifycss --css=src/pages/all.css --ext=.ts src 155 | ``` 156 | 157 | You'll get `src/pages/all.css`. `src/pages/_app.tsx` should import it: 158 | 159 | ```typescript 160 | // src/pages/_app.tsx 161 | import './all.css'; 162 | ``` 163 | 164 | ## Installation 165 | 166 | ```bash 167 | npm install --save-dev esifycss 168 | ``` 169 | 170 | ## `@import` Syntax 171 | 172 | You can use `@import` syntax if the style declarations requires identifiers 173 | declared in other files. 174 | 175 | For example, Assume you have the following `a.css` and `b.css`. 176 | 177 | ```css 178 | /* a.css */ 179 | .container {...} /* → ._0 */ 180 | ``` 181 | 182 | ```css 183 | /* b.css */ 184 | .container {...} /* → ._1 */ 185 | ``` 186 | 187 | The `container` class names will be shortened to unique names like `_0` and 188 | `_1`. You can import the shortened names with the `@import` syntax. 189 | 190 | ```css 191 | /* "modA-" is prefix for a.css */ 192 | @import './a.css' modA-; 193 | /* "bbbb" is prefix for b.css */ 194 | @import './b.css' BBB; 195 | .wrapper>.modA-container {...} /* → ._2>._0 */ 196 | .wrapper>.BBBcontainer {...} /* → ._2>._1 */ 197 | ``` 198 | 199 | ## JavaScript API for Runner 200 | 201 | ```javascript 202 | import {Session} from 'esifycss'; 203 | new Session(options).start() 204 | .then(() => console.log('Done')) 205 | .catch((error) => console.error(error)); 206 | ``` 207 | 208 | ### Options 209 | 210 | ```typescript 211 | export interface SessionOptions { 212 | /** 213 | * Pattern(s) to be included 214 | * @default "*" 215 | */ 216 | include?: string | Array, 217 | /** 218 | * Pattern(s) to be excluded. 219 | * @default ['node_modules'] 220 | */ 221 | exclude?: anymatch.Matcher, 222 | /** 223 | * File extension(s) to be included. 224 | * @default ['.css'] 225 | */ 226 | extensions?: Array, 227 | /** 228 | * Where this plugin outputs the helper script. 229 | * If you use TypeScript, set a value like '*.ts'. 230 | * You can't use this option with the css option. 231 | * The {hash} in the default value is calculated from the include option. 232 | * @default "helper.{hash}.css.js" 233 | */ 234 | helper?: string, 235 | /** 236 | * File extension of generated script. 237 | * @default options.helper ? path.extname(options.helper) : '.js' 238 | */ 239 | ext?: string, 240 | /** 241 | * Where this plugin outputs the css. 242 | * You can't use this option with the helper option. 243 | * @default undefined 244 | */ 245 | css?: string, 246 | /** 247 | * It it is true, a watcher is enabled. 248 | * @default false 249 | */ 250 | watch?: boolean, 251 | /** 252 | * Options passed to chokidar. 253 | * You can't set ignoreInitial to true. 254 | * @default { 255 | * ignore: exclude, 256 | * ignoreInitial: false, 257 | * useFsEvents: false, 258 | * } 259 | */ 260 | chokidar?: chokidar.WatchOptions, 261 | /** 262 | * An array of postcss plugins. 263 | * esifycss.plugin is appended to this array. 264 | * @default [] 265 | */ 266 | postcssPlugins?: Array, 267 | /** 268 | * https://github.com/postcss/postcss#options 269 | * @default undefined 270 | */ 271 | postcssOptions?: postcss.ProcessOptions, 272 | /** 273 | * Parameters for esifycss.plugin. 274 | */ 275 | esifycssPluginParameter?: PluginOptions, 276 | /** 277 | * A stream where the runner outputs logs. 278 | * @default process.stdout 279 | */ 280 | stdout?: stream.Writable, 281 | /** 282 | * A stream where the runner outputs errorlogs. 283 | * @default process.stderr 284 | */ 285 | stderr?: stream.Writable, 286 | } 287 | ``` 288 | 289 | Source: [src/runner/types.ts](src/runner/types.ts) 290 | 291 | ## JavaScript API for Plugin 292 | 293 | ```javascript 294 | const postcss = require('postcss'); 295 | const esifycss = require('esifycss'); 296 | postcss([ 297 | esifycss.plugin({/* Plugin Options */}), 298 | ]) 299 | .process(css, {from: '/foo/bar.css'}) 300 | .then((result) => { 301 | const pluginResult = esifycss.extractPluginResult(result); 302 | console.log(pluginResult); 303 | // → { 304 | // className: {bar: '_1'}, 305 | // id: {foo: '_0'}, 306 | // keyframes: {aaa: '_2'}, 307 | // } 308 | }); 309 | ``` 310 | 311 | The code is at [sample/plugin.js](sample/plugin.js). 312 | You can run it by `node sample/plugin.js` after cloning this repository and 313 | running `npm run build`. 314 | 315 | ### Options 316 | 317 | ```typescript 318 | export interface PluginOptions { 319 | /** 320 | * When it is true, this plugin minifies classnames. 321 | * @default true 322 | */ 323 | mangle?: boolean, 324 | /** 325 | * A function returns an unique number from a given file id. If you process 326 | * CSS files in multiple postcss processes, you should create an identifier 327 | * outside the processes and pass it as this value to keep the uniqueness 328 | * of mangled outputs. 329 | * @default esifycss.createIdGenerator() 330 | */ 331 | identifier?: IdGenerator, 332 | /** 333 | * Names starts with this value are not passed to mangler but replaced with 334 | * unprefixed names. 335 | * @default "raw-" 336 | */ 337 | rawPrefix?: string, 338 | /** 339 | * A custom mangler: (*id*, *type*, *name*) => string. 340 | * - *id*: string. A filepath to the CSS. 341 | * - *type*: 'id' | 'class' | 'keyframes'. The type of *name* 342 | * - *name*: string. An identifier in the style. 343 | * 344 | * If mangler is set, `mangle` and `identifier` options are ignored. 345 | * 346 | * For example, If the plugin processes `.foo{color:green}` in `/a.css`, 347 | * The mangler is called with `("/a.css", "class", "foo")`. A mangler should 348 | * return an unique string for each input pattern or the styles will be 349 | * overwritten unexpectedly. 350 | * @default undefined 351 | */ 352 | mangler?: PluginMangler, 353 | } 354 | ``` 355 | 356 | Source: [src/postcssPlugin/types.ts](src/postcssPlugin/types.ts) 357 | 358 | ## LICENSE 359 | 360 | The esifycss project is licensed under the terms of the Apache 2.0 License. 361 | -------------------------------------------------------------------------------- /src/runner/Session.test.ts: -------------------------------------------------------------------------------- 1 | import * as events from 'events'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as stream from 'stream'; 5 | import * as animationParser from '@hookun/parse-animation-shorthand'; 6 | import type {ExecutionContext, TestFn} from 'ava'; 7 | import anyTest from 'ava'; 8 | import * as postcss from 'postcss'; 9 | import * as scss from 'postcss-scss'; 10 | import {deleteFile} from '..'; 11 | import {createTemporaryDirectory} from '../util/createTemporaryDirectory'; 12 | import type {RunCodeResult} from '../util/runCode.for-test'; 13 | import {runCode} from '../util/runCode.for-test'; 14 | import {updateFile} from '../util/updateFile'; 15 | import {Session} from './Session'; 16 | import type {SessionOptions} from './types'; 17 | 18 | interface TestContext { 19 | directory: string, 20 | session?: Session, 21 | } 22 | 23 | const test = anyTest as TestFn; 24 | const isRule = (input: postcss.ChildNode): input is postcss.Rule => input.type === 'rule'; 25 | const createMessageListener = () => { 26 | const messageListener = new events.EventEmitter(); 27 | const waitForMessage = async ( 28 | expected: RegExp | string, 29 | ) => await new Promise((resolve, reject) => { 30 | const timeoutId = setTimeout(() => reject(new Error(`Timeout: waiting ${expected}`)), 1000); 31 | const onData = (message: string) => { 32 | if (typeof expected === 'string' ? message.includes(expected) : expected.test(message)) { 33 | clearTimeout(timeoutId); 34 | messageListener.removeListener('message', onData); 35 | resolve(); 36 | } 37 | }; 38 | messageListener.on('message', onData); 39 | }); 40 | return {messageListener, waitForMessage}; 41 | }; 42 | 43 | test.beforeEach(async (t) => { 44 | t.context.directory = await createTemporaryDirectory(); 45 | }); 46 | 47 | test.afterEach(async (t) => { 48 | if (t.context.session) { 49 | await t.context.session.stop(); 50 | } 51 | }); 52 | 53 | interface Test { 54 | parameters: Partial, 55 | files: Array<{ 56 | path: string, 57 | content: Array, 58 | test: ( 59 | t: ExecutionContext, 60 | result: RunCodeResult, 61 | ) => void, 62 | }>, 63 | } 64 | 65 | ([ 66 | { 67 | parameters: {}, 68 | files: [ 69 | { 70 | path: '/components/style.css', 71 | content: [ 72 | '.foo {color: red}', 73 | ], 74 | test: (t, {className, id, keyframes, root}) => { 75 | t.deepEqual(Object.keys(id), []); 76 | t.deepEqual(Object.keys(className), ['foo']); 77 | t.deepEqual(Object.keys(keyframes), []); 78 | const [ruleNode, anotherNode] = root.nodes as Array; 79 | t.falsy(anotherNode); 80 | t.like(ruleNode, { 81 | type: 'rule', 82 | selector: `.${className.foo}`, 83 | }); 84 | const [declaration, anotherDeclaration] = ruleNode.nodes; 85 | t.falsy(anotherDeclaration); 86 | t.like(declaration, { 87 | type: 'decl', 88 | prop: 'color', 89 | value: 'red', 90 | }); 91 | }, 92 | }, 93 | ], 94 | }, 95 | { 96 | parameters: {}, 97 | files: [ 98 | { 99 | path: '/components/style.css', 100 | content: [ 101 | '@charset "UTF-8";', 102 | '.foo {color: blue}', 103 | ], 104 | test: (t, {className, id, keyframes, root}) => { 105 | t.deepEqual(Object.keys(id), []); 106 | t.deepEqual(Object.keys(className), ['foo']); 107 | t.deepEqual(Object.keys(keyframes), []); 108 | const [ruleNode, anotherNode] = root.nodes as Array; 109 | t.falsy(anotherNode); 110 | t.like(ruleNode, { 111 | type: 'rule', 112 | selector: `.${className.foo}`, 113 | }); 114 | const [declaration, anotherDeclaration] = ruleNode.nodes; 115 | t.falsy(anotherDeclaration); 116 | t.like(declaration, { 117 | type: 'decl', 118 | prop: 'color', 119 | value: 'blue', 120 | }); 121 | }, 122 | }, 123 | ], 124 | }, 125 | { 126 | parameters: {}, 127 | files: [ 128 | { 129 | path: '/components/style.css', 130 | content: [ 131 | '@keyframes foo {0%{color: red}100%{color:green}}', 132 | '@keyframes bar {0%{color: red}100%{color:green}}', 133 | '.foo#bar {animation: 1s 0.5s linear infinite foo, 1s 0.5s ease 5 bar}', 134 | ], 135 | test: (t, {className, id, keyframes, root}) => { 136 | t.deepEqual(Object.keys(id), ['bar']); 137 | t.deepEqual(Object.keys(className), ['foo']); 138 | t.deepEqual(Object.keys(keyframes), ['foo', 'bar']); 139 | t.is(root.nodes.length, 3); 140 | t.like(root.nodes[0], { 141 | type: 'atrule', 142 | name: 'keyframes', 143 | params: keyframes.foo, 144 | }); 145 | t.like(root.nodes[1], { 146 | type: 'atrule', 147 | name: 'keyframes', 148 | params: keyframes.bar, 149 | }); 150 | { 151 | const node = root.nodes[2] as postcss.Rule; 152 | t.like(node, { 153 | type: 'rule', 154 | selector: `.${className.foo}#${id.bar}`, 155 | }); 156 | const declarations = node.nodes as Array; 157 | t.is(declarations.length, 1); 158 | t.is(declarations[0].prop, 'animation'); 159 | t.deepEqual( 160 | animationParser.parse(declarations[0].value), 161 | animationParser.parse([ 162 | `1s 0.5s linear infinite ${keyframes.foo}`, 163 | `1s 0.5s ease 5 ${keyframes.bar}`, 164 | ].join(',')), 165 | ); 166 | } 167 | }, 168 | }, 169 | ], 170 | }, 171 | { 172 | parameters: { 173 | postcssOptions: {parser: scss.parse}, 174 | extensions: ['.scss', '.css'], 175 | }, 176 | files: [ 177 | { 178 | path: '/components/style.scss', 179 | content: [ 180 | '.foo#bar {&>.baz {color: red}}', 181 | ], 182 | test: (t, {className, id, keyframes}) => { 183 | t.deepEqual(Object.keys(id), ['bar']); 184 | t.deepEqual(Object.keys(className), ['foo', 'baz']); 185 | t.deepEqual(Object.keys(keyframes), []); 186 | }, 187 | }, 188 | ], 189 | }, 190 | ] as Array).forEach(({parameters, files}, index) => { 191 | test.serial(`#${index}`, async (t) => { 192 | await Promise.all(files.map(async (file) => { 193 | const filePath = path.join(t.context.directory, file.path); 194 | await updateFile(filePath, file.content.join('\n')); 195 | })); 196 | const helper = path.join(t.context.directory, 'helper.js'); 197 | const writable = new stream.Writable({ 198 | write(chunk, _encoding, callback) { 199 | t.log(`${chunk}`.trim()); 200 | callback(); 201 | }, 202 | }); 203 | const session = t.context.session = new Session({ 204 | ...parameters, 205 | include: t.context.directory, 206 | helper, 207 | watch: false, 208 | stdout: writable, 209 | stderr: writable, 210 | }); 211 | await session.start(); 212 | const identifiers = new Map(); 213 | await Promise.all(files.map(async (file) => { 214 | const name = `${file.path}${path.extname(helper)}`; 215 | const result = await runCode(path.join(t.context.directory, name)); 216 | for (const map of [result.className, result.id, result.keyframes]) { 217 | for (const [key, value] of Object.entries(map)) { 218 | if (value) { 219 | t.false(identifiers.has(value), `${key}: ${value} is conflicted`); 220 | identifiers.set(value, key); 221 | } 222 | } 223 | } 224 | file.test(t, result); 225 | })); 226 | }); 227 | }); 228 | 229 | test('ignore output even if it is covered by the "include" pattern.', async (t) => { 230 | const files = [ 231 | { 232 | path: 'input1.css', 233 | content: [ 234 | '.a1 {color: a1; width: 10%}', 235 | '.b1 {color: b1; width: 20%}', 236 | ], 237 | }, 238 | { 239 | path: 'input2.css', 240 | content: [ 241 | '@charset "utf-8";', 242 | '.a2 {color: a2; width: 30%}', 243 | '.b2 {color: b2; width: 40%}', 244 | ], 245 | }, 246 | ]; 247 | await Promise.all(files.map(async (file) => { 248 | await updateFile( 249 | path.join(t.context.directory, file.path), 250 | file.content.join('\n'), 251 | ); 252 | })); 253 | const cssPath = path.join(t.context.directory, 'output.css'); 254 | const writable = new stream.Writable({ 255 | write(chunk, _encoding, callback) { 256 | t.log(`${chunk}`.trim()); 257 | callback(); 258 | }, 259 | }); 260 | const session = t.context.session = new Session({ 261 | css: cssPath, 262 | include: t.context.directory, 263 | watch: false, 264 | stdout: writable, 265 | stderr: writable, 266 | }); 267 | await session.start(); 268 | await t.throwsAsync(async () => { 269 | await fs.promises.stat(`${cssPath}.js`); 270 | }, {code: 'ENOENT'}); 271 | /** this call may include the output */ 272 | await session.start(); 273 | await t.throwsAsync(async () => { 274 | await fs.promises.stat(`${cssPath}.js`); 275 | }, {code: 'ENOENT'}); 276 | const outputScriptPath1 = path.join(t.context.directory, 'input1.css.js'); 277 | const outputScript1 = await fs.promises.readFile(outputScriptPath1, 'utf-8'); 278 | t.log(`==== outputScript1 ====\n${outputScript1.trim()}\n===================`); 279 | t.false(outputScript1.includes('addStyle')); 280 | const result1 = await runCode(outputScriptPath1); 281 | t.deepEqual(result1.className, { 282 | a1: '_0', 283 | b1: '_1', 284 | }); 285 | const outputScriptPath2 = path.join(t.context.directory, 'input2.css.js'); 286 | const outputScript2 = await fs.promises.readFile(outputScriptPath2, 'utf-8'); 287 | t.log(`==== outputScript2 ====\n${outputScript2.trim()}\n===================`); 288 | t.false(outputScript2.includes('addStyle')); 289 | const result2 = await runCode(outputScriptPath2); 290 | t.deepEqual(result2.className, { 291 | a2: '_2', 292 | b2: '_3', 293 | }); 294 | const resultCSS = await fs.promises.readFile(cssPath, 'utf8'); 295 | t.log(`==== resultCSS ====\n${resultCSS}\n===================`); 296 | const root = postcss.parse(resultCSS); 297 | t.log(root.toJSON()); 298 | t.truthy(root.nodes.find((node) => isRule(node) && node.selector === `.${result1.className.a1}`)); 299 | t.truthy(root.nodes.find((node) => isRule(node) && node.selector === `.${result1.className.b1}`)); 300 | t.truthy(root.nodes.find((node) => isRule(node) && node.selector === `.${result2.className.a2}`)); 301 | t.truthy(root.nodes.find((node) => isRule(node) && node.selector === `.${result2.className.b2}`)); 302 | }); 303 | 304 | test('watch', async (t) => { 305 | const cssPath = path.join(t.context.directory, '/components/style.css'); 306 | const helper = path.join(t.context.directory, 'helper.js'); 307 | const codePath = `${cssPath}${path.extname(helper)}`; 308 | const {messageListener, waitForMessage} = createMessageListener(); 309 | const writable = new stream.Writable({ 310 | write(chunk, _encoding, callback) { 311 | const message = `${chunk}`.trim(); 312 | messageListener.emit('message', message); 313 | t.log(message); 314 | callback(); 315 | }, 316 | }); 317 | t.context.session = new Session({ 318 | helper, 319 | watch: true, 320 | include: t.context.directory, 321 | stdout: writable, 322 | stderr: writable, 323 | }); 324 | await updateFile(cssPath, [ 325 | '@keyframes foo {0%{color:gold}100%{color:green}}', 326 | '.foo#bar {animation: 1s 0.5s linear infinite foo}', 327 | ].join('')); 328 | t.context.session.start().catch(t.fail); 329 | await waitForMessage(`written: ${codePath}`); 330 | const result1 = await runCode(codePath); 331 | await updateFile(cssPath, [ 332 | '@keyframes foo {0%{color:gold}100%{color:green}}', 333 | '.foo#bar {animation: 2s 1s linear infinite foo}', 334 | ].join('')); 335 | await waitForMessage(`written: ${codePath}`); 336 | const result2 = await runCode(codePath); 337 | await deleteFile(cssPath); 338 | await waitForMessage(`deleted: ${codePath}`); 339 | await t.throwsAsync(async () => await fs.promises.stat(codePath), {code: 'ENOENT'}); 340 | t.deepEqual(result1.className, result2.className); 341 | t.deepEqual(result1.id, result2.id); 342 | t.deepEqual(result1.keyframes, result2.keyframes); 343 | const nodes1 = result1.root.nodes || []; 344 | const nodes2 = result2.root.nodes || []; 345 | { 346 | const atRule1 = nodes1[0] as postcss.AtRule; 347 | const atRule2 = nodes2[0] as postcss.AtRule; 348 | t.is(atRule1.name, 'keyframes'); 349 | t.is(atRule2.name, 'keyframes'); 350 | t.is(atRule1.params, `${result1.keyframes.foo}`); 351 | t.is(atRule1.params, `${result1.keyframes.foo}`); 352 | } 353 | { 354 | const rule1 = nodes1[1] as postcss.Rule; 355 | const rule2 = nodes2[1] as postcss.Rule; 356 | t.is(rule1.selector, `.${result1.className.foo}#${result1.id.bar}`); 357 | t.is(rule2.selector, `.${result1.className.foo}#${result1.id.bar}`); 358 | const declarations1 = (rule1.nodes || []) as Array; 359 | const declarations2 = (rule2.nodes || []) as Array; 360 | t.is(declarations1.length, 1); 361 | t.is(declarations1[0].prop, 'animation'); 362 | t.is(declarations2.length, 1); 363 | t.is(declarations2[0].prop, 'animation'); 364 | t.deepEqual( 365 | animationParser.parse(declarations1[0].value), 366 | animationParser.parse(`1s 0.5s linear infinite ${result1.keyframes.foo}`), 367 | ); 368 | t.deepEqual( 369 | animationParser.parse(declarations2[0].value), 370 | animationParser.parse(`2s 1s linear infinite ${result1.keyframes.foo}`), 371 | ); 372 | } 373 | }); 374 | 375 | test('watch-css', async (t) => { 376 | const cssPath1 = path.join(t.context.directory, '/components/style1.css'); 377 | const cssPath2 = path.join(t.context.directory, '/components/style2.css'); 378 | const cssOutputPath = path.join(t.context.directory, 'output.css'); 379 | const {messageListener, waitForMessage} = createMessageListener(); 380 | const writable = new stream.Writable({ 381 | write(chunk, _encoding, callback) { 382 | const message = `${chunk}`.trim(); 383 | messageListener.emit('message', message); 384 | t.log(message); 385 | callback(); 386 | }, 387 | }); 388 | t.context.session = new Session({ 389 | css: cssOutputPath, 390 | watch: true, 391 | include: t.context.directory, 392 | stdout: writable, 393 | stderr: writable, 394 | }); 395 | await updateFile(cssPath1, [ 396 | '@keyframes foo1 {0%{color:gold}100%{color:green}}', 397 | '.foo1#bar {animation: 1s 0.5s linear infinite foo1}', 398 | ].join('')); 399 | await updateFile(cssPath2, [ 400 | '@keyframes foo2 {0%{color:gold}100%{color:pink}}', 401 | '.foo2#bar {animation: 1s 0.5s linear infinite foo2}', 402 | ].join('')); 403 | await t.context.session.start().catch(t.fail); 404 | const outputCss1 = await fs.promises.readFile(cssOutputPath, 'utf-8'); 405 | t.log('outputCss1', outputCss1); 406 | t.true(outputCss1.includes('color:green')); 407 | t.true(outputCss1.includes('color:pink')); 408 | const root1 = postcss.parse(outputCss1); 409 | t.is(root1.nodes.length, 4); 410 | await updateFile(cssPath1, [ 411 | '@keyframes bar1 {0%{color:gold}100%{color:blue}}', 412 | '.bar1#bar {animation: 2s 1s linear infinite bar1}', 413 | ].join('')); 414 | await waitForMessage(`written: ${cssOutputPath}`); 415 | const outputCss2 = await fs.promises.readFile(cssOutputPath, 'utf-8'); 416 | t.log('outputCss2', outputCss2); 417 | t.false(outputCss2.includes('color:green')); 418 | t.true(outputCss2.includes('color:blue')); 419 | t.true(outputCss2.includes('color:pink')); 420 | const root2 = postcss.parse(outputCss2); 421 | t.is(root2.nodes.length, 4); 422 | await updateFile(cssPath2, [ 423 | '@keyframes bar2 {0%{color:gold}100%{color:red}}', 424 | '.bar2#bar {animation: 1s 0.5s linear infinite bar2}', 425 | ].join('')); 426 | await waitForMessage(`written: ${cssOutputPath}`); 427 | const outputCss3 = await fs.promises.readFile(cssOutputPath, 'utf-8'); 428 | t.log('outputCss3', outputCss3); 429 | t.false(outputCss3.includes('color:green')); 430 | t.false(outputCss3.includes('color:pink')); 431 | t.true(outputCss3.includes('color:blue')); 432 | t.true(outputCss3.includes('color:red')); 433 | const root3 = postcss.parse(outputCss3); 434 | t.is(root3.nodes.length, 4); 435 | }); 436 | --------------------------------------------------------------------------------