wix-style-react
Version:
215 lines (187 loc) • 6.51 kB
text/typescript
import { Stylable } from '@stylable/core';
import { FileSystem } from '@stylable/node';
import { camelCase, upperFirst } from 'lodash';
import { basename, join, relative, dirname, resolve } from 'path';
import components from '../../../../.wuf/components.json';
function addDotSlash(p: string) {
p = p.replace(/\\/g, '/');
return p.startsWith('.') ? p : './' + p;
}
function ensureDirectory(dir: string, fs: FileSystem) {
if (dir === '.' || fs.existsSync(dir)) {
return;
}
try {
fs.mkdirSync(dir);
} catch (e) {
const parentDir = dirname(dir);
if (parentDir !== dir) {
ensureDirectory(parentDir, fs);
fs.mkdirSync(dir);
}
}
}
function tryRun<T>(fn: () => T, errorMessage: string): T {
try {
return fn();
} catch (e) {
throw new Error(errorMessage + ': \n' + e.stack);
}
}
export interface ReExports {
root: string;
classes: Record<string, string>;
keyframes: Record<string, string>;
vars: Record<string, string>;
stVars: Record<string, string>;
}
export class Generator {
private indexFileOutput: Array<{
from: string;
reExports: ReExports;
}> = [];
private collisionDetector = new NameCollisionDetector<string>();
constructor(
public stylable: Stylable,
private log: (...args: string[]) => void,
) {}
public generateReExports(filePath: string): ReExports {
const meta = this.stylable.process(resolve(filePath));
const rootExport = this.filename2varname(filePath);
const vars = Object.keys(meta.cssVars).reduce<Record<string, string>>(
(acc, varName) => {
acc[varName] = `--${rootExport}__${varName.slice(2)}`;
return acc;
},
{},
);
const keyframes = Object.keys(meta.mappedKeyframes).reduce<
Record<string, string>
>((acc, keyframe) => {
acc[keyframe] = `${rootExport}__${keyframe}`;
return acc;
}, {});
const classes = Object.keys(meta.classes)
.filter(name => name !== meta.root)
.reduce<Record<string, string>>((acc, className) => {
acc[className] = `${rootExport}__${className}`;
return acc;
}, {});
const stVars = meta.vars.reduce<Record<string, string>>((acc, { name }) => {
acc[name] = `${rootExport}__${name}`;
return acc;
}, {});
return {
root: rootExport,
classes,
keyframes,
stVars,
vars,
};
}
public generateFileIndexEntry(filePath: string, fullOutDir: string) {
const extractedFileName = filePath
.match(/[ \w-]+\.st.css/)[0]
.replace('.st.css', '');
// ignore non component name stylable stylesheets
if (!components[extractedFileName]) {
return;
}
const componentRelativePath = components[extractedFileName].path;
const componentFullPath = `${
filePath.split('src/')[0]
}${componentRelativePath}/${extractedFileName}.st.css`;
// ignore all other stylable files that are not directly positioned next to the component
if (componentFullPath !== filePath) {
return;
}
const from = addDotSlash(relative(fullOutDir, filePath));
const reExports = this.generateReExports(filePath);
this.checkForCollisions(reExports, filePath);
this.log('[Generator Index]', `Add file: ${filePath}`);
this.indexFileOutput.push({ reExports, from });
}
public generateIndexFile(
fs: FileSystem,
fullOutDir: string,
indexFile: string,
) {
const indexFileTargetPath = join(fullOutDir, indexFile);
const indexFileContent = this.generateIndexSource(indexFileTargetPath);
ensureDirectory(fullOutDir, fs);
tryRun(
() =>
fs.writeFileSync(indexFileTargetPath, '\n' + indexFileContent + '\n'),
'Write Index File Error',
);
this.log(
'[Generator Index]',
'creating index file: ' + indexFileTargetPath,
);
}
public filename2varname(filePath: string) {
const varname = basename(basename(filePath, '.css'), '.st') // remove prefixes and .st.css ext
.replace(/^\d+/, ''); // remove leading numbers
return upperFirst(camelCase(varname));
}
protected generateIndexSource(_indexFileTargetPath: string) {
return this.indexFileOutput
.map(_ => createImportForComponent(_.from, _.reExports))
.join('\n');
}
private checkForCollisions(reExports: ReExports, filePath: string) {
this.collisionDetector.detect(reExports.root, filePath);
for (const asName of Object.values(reExports.classes)) {
this.collisionDetector.detect(asName, filePath);
}
for (const asName of Object.values(reExports.vars)) {
this.collisionDetector.detect(asName, filePath);
}
for (const asName of Object.values(reExports.stVars)) {
this.collisionDetector.detect(asName, filePath);
}
for (const asName of Object.values(reExports.keyframes)) {
this.collisionDetector.detect(`keyframes(${asName})`, filePath);
}
if (this.collisionDetector.collisions.size) {
let errorMessage = 'Name Collision Error:';
for (const [name, origin] of this.collisionDetector.collisions) {
errorMessage += `\nexport symbol ${name} from ${filePath} is already used by ${origin}`;
}
throw new Error(errorMessage);
}
}
}
class NameCollisionDetector<Origin> {
nameMapping = new Map<string, Origin>();
collisions = new Map<string, Origin>();
detect(name: string, origin: Origin) {
if (this.nameMapping.has(name)) {
this.collisions.set(name, this.nameMapping.get(name)!);
} else {
this.nameMapping.set(name, origin);
}
}
}
function createImportForComponent(from: string, reExports: ReExports) {
const namedPart = [
...Object.entries(reExports.classes).map(symbolMapper),
...Object.entries(reExports.stVars).map(symbolMapper),
...Object.entries(reExports.vars).map(symbolMapper),
...Object.entries(reExports.keyframes).map(keyframesSymbolMapper),
].join(', ');
const usagePart = Object.values(reExports.classes)
.map(exportName => `.root .${exportName}{}`)
.join(' ');
return `:import {-st-from: ${JSON.stringify(from)};-st-default:${
reExports.root
};${namedPart ? `-st-named: ${namedPart};` : ''}}\n.root ${reExports.root}{}${
usagePart ? `\n${usagePart}` : ''
}`;
}
function symbolMapper([name, as]: [string, string]) {
return name === as ? as : `${name} as ${as}`;
}
function keyframesSymbolMapper([name, as]: [string, string]) {
return name === as ? `keyframes(${as})` : `keyframes(${name} as ${as})`;
}