otion
Version:
Atomic CSS-in-JS with a featherweight runtime
412 lines (363 loc) • 12.4 kB
text/typescript
import hash from "@emotion/hash";
import { prefixProperty, prefixValue } from "tiny-css-prefixer";
import { CSSKeyframeRules, ScopedCSSRules } from "./cssTypes";
import { isBrowser, isDev } from "./env";
import {
CSSOMInjector,
DOMInjector,
InjectorInstance,
NoOpInjector,
} from "./injectors";
import { minifyCondition, minifyValue } from "./minify";
import { PROPERTY_ACCEPTS_UNITLESS_VALUES } from "./propertyMatchers";
import { rulePrecedence } from "./rulePrecedence";
export const PRECEDENCE_GROUP_COUNT = 72;
function toHyphenLower(match: string): string {
return `-${match.toLowerCase()}`;
}
export interface OtionConfig {
/** Style insertion methodology to be used. */
injector?: InjectorInstance;
/** Auto-prefixer method for CSS property–value pairs. */
prefix?: (property: string, value: string) => string;
}
export interface OtionInstance {
/**
* Customizes the otion instance. May only be called once, before using the instance for anything else.
*/
setup(options: OtionConfig): void;
/**
* Marks server-rendered CSS identity names as available to avoid re-injecting them to the style sheet during runtime.
*/
hydrate(): void;
/**
* Decomposes CSS into atomic styles. Rules are injected to the style sheet if not already available.
*
* @param rules Scoped CSS as [object styles](https://gist.github.com/threepointone/9f87907a91ec6cbcd376dded7811eb31), with value fallbacks represented as arrays.
*
* - Numbers assigned to non-unitless properties are postfixed with "px".
* - Excess white space characters are truncated.
*
* @returns A space-separated list of stably generated unique class names.
*
* @example
* const classNames = css({
* display: "flex",
* justifyContent: ["space-around", "space-evenly"], // Last takes precedence
* padding: 8, // "8px"
* lineHeight: 1.5, // "1.5" without a unit
* selectors: {
* // Advanced selectors must start with "&"
* "& > * + *": {
* paddingLeft: 16
* }
* }
* });
*/
css(rules: ScopedCSSRules): string;
// used to specify the values for the animating properties at various points during the animation
/**
* Creates keyframes for animating values of given properties over time.
*
* @param rules CSS keyframe rules as [object styles](https://gist.github.com/threepointone/9f87907a91ec6cbcd376dded7811eb31), with value fallbacks represented as arrays.
*
* - Numbers assigned to non-unitless properties are postfixed with "px".
* - Excess white space characters are truncated.
*
* @returns Lazy method for stably generating a unique animation name upon usage.
*
* @example
* const pulse = keyframes({
* from: { opacity: 0 },
* to: { opacity: 1 }
* });
*
* // Referencing
* const className = css({
* animation: `${pulse} 3s infinite alternate`
* });
*/
keyframes(rules: CSSKeyframeRules): { /** @private */ toString(): string };
}
/**
* Creates a new otion instance. Usable for managing styles of multiple browsing contexts (e.g. an `<iframe>` besides the main document).
*/
export function createInstance(): OtionInstance {
let injector: InjectorInstance;
let prefix: (property: string, value: string) => string;
let ruleIndexesByIdentName: Map<string, number>;
let nextRuleIndexesByPrecedenceGroup: Uint16Array;
function checkSetup(): void {
if (!injector || !prefix || !ruleIndexesByIdentName) {
throw new Error(
"On a custom otion instance, `setup()` must be called before usage.",
);
}
}
function updatePrecedenceGroupRanges(fromPrecedence: number) {
for (let i = fromPrecedence; i <= PRECEDENCE_GROUP_COUNT; ++i) {
++nextRuleIndexesByPrecedenceGroup[i];
}
}
function hydrateScopedSubtree(
cssRule: CSSRule,
isConditionalRule?: boolean,
): void {
if (cssRule.type === 1 /* CSSRule.STYLE_RULE */) {
const { selectorText, style } = cssRule as CSSStyleRule;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [, identName, pseudoClass] = /^..([0-9a-z]+)(:.*)?/.exec(
selectorText,
)!;
const property = style[0];
if (property) {
// Broken rule declarations are ignored
updatePrecedenceGroupRanges(
rulePrecedence(property, pseudoClass, !!isConditionalRule),
);
}
ruleIndexesByIdentName.set(identName, ruleIndexesByIdentName.size);
} else {
/* cssRule.type === CSSRule.MEDIA_RULE */
hydrateScopedSubtree((cssRule as CSSGroupingRule).cssRules[0], true);
}
}
function normalizeDeclaration(
property: string,
value: string | number,
): string {
const formattedValue =
typeof value === "number" &&
!PROPERTY_ACCEPTS_UNITLESS_VALUES.test(property)
? `${value}px` // Append missing unit
: minifyValue(`${value}`);
return prefix(property, formattedValue);
}
function serializeDeclarationList(
property: string,
value: string | number | Array<string | number | undefined>,
): string {
if (typeof value !== "object") {
return normalizeDeclaration(property, value);
}
let cssText = "";
value.forEach((fallbackValue) => {
if (fallbackValue) {
cssText += `;${normalizeDeclaration(property, fallbackValue)}`;
}
});
// The leading declaration separator character gets removed
return cssText.slice(1);
}
function decomposeToClassNames(
rules: ScopedCSSRules,
cssTextHead: string,
cssTextTail: string,
maxPrecedingConditionalRuleIndexesByPrecedenceGroup: Uint16Array,
classSelectorStartIndex?: number,
): string {
let classNames = "";
// TODO: Replace `var` with `const` once it minifies equivalently
// eslint-disable-next-line guard-for-in, no-restricted-syntax, no-var, vars-on-top
for (var key in rules) {
const value = rules[key as keyof typeof rules];
if (value != null) {
if (typeof value !== "object" || Array.isArray(value)) {
// Class specificities are controlled with repetition, see:
// https://csswizardry.com/2014/07/hacks-for-dealing-with-specificity/
// TODO: Consider removing IE vendor prefix support
const property = key.replace(/^ms|[A-Z]/g, toHyphenLower);
const declarations = serializeDeclarationList(property, value);
const className = `_${hash(cssTextHead + declarations)}`;
const isConditionalRule = cssTextTail;
let ruleIndex = ruleIndexesByIdentName.get(className);
if (ruleIndex == null || isConditionalRule) {
const precedence = rulePrecedence(
property,
classSelectorStartIndex == null
? ""
: cssTextHead.slice(classSelectorStartIndex),
!!isConditionalRule,
);
if (
ruleIndex == null ||
// Re-insert conditional rule if necessary to fix CSS source order
maxPrecedingConditionalRuleIndexesByPrecedenceGroup[precedence] >
ruleIndex
) {
const scopeSelector = `.${className}`;
injector.insert(
`${
cssTextHead.slice(0, classSelectorStartIndex) +
scopeSelector +
(classSelectorStartIndex != null
? `${
cssTextHead
.slice(classSelectorStartIndex)
.replace(/&/g, scopeSelector) // Resolve references
}{`
: "{")
}${declarations}}${cssTextTail}`,
nextRuleIndexesByPrecedenceGroup[precedence],
);
updatePrecedenceGroupRanges(precedence);
ruleIndex = ruleIndexesByIdentName.size;
ruleIndexesByIdentName.set(className, ruleIndex);
if (isConditionalRule) {
// eslint-disable-next-line no-param-reassign
maxPrecedingConditionalRuleIndexesByPrecedenceGroup[
precedence
] = Math.max(
maxPrecedingConditionalRuleIndexesByPrecedenceGroup[
precedence
],
ruleIndex,
);
}
}
}
classNames += ` ${className}`;
} else {
let parentRuleHeads: string[] | undefined;
let firstParentRuleHead =
key[0] === ":" || key[0] === "@" || key[0] === "&"
? key
: minifyCondition(key);
let parentRuleTail = "";
let scopeClassSelectorStartIndex = classSelectorStartIndex;
if (scopeClassSelectorStartIndex == null) {
if (
firstParentRuleHead[0] === ":" ||
firstParentRuleHead[0] === "&"
) {
scopeClassSelectorStartIndex = cssTextHead.length;
parentRuleHeads = firstParentRuleHead
.split(
// Separate selector list items by ","
// Inspired by: https://stackoverflow.com/a/9030062
/,(?![^[]*?[^\\]["']\s*?\])/,
)
.map(
(singleSelector) =>
// Keep non-first occurrences of "&" for later replacement
minifyValue(singleSelector).replace("&", ""), // lgtm [js/incomplete-sanitization]
);
} else if (firstParentRuleHead === "selectors") {
firstParentRuleHead = "";
} else if (firstParentRuleHead[0] !== "@") {
firstParentRuleHead += "{";
parentRuleTail = "}";
}
}
(parentRuleHeads || [firstParentRuleHead]).forEach(
// eslint-disable-next-line no-loop-func
(parentRuleHead) => {
classNames += decomposeToClassNames(
value as ScopedCSSRules,
cssTextHead + parentRuleHead,
parentRuleTail + cssTextTail,
maxPrecedingConditionalRuleIndexesByPrecedenceGroup,
scopeClassSelectorStartIndex,
);
},
);
}
}
}
return classNames;
}
return {
setup(options): void {
injector =
options.injector ||
// eslint-disable-next-line no-nested-ternary
(isBrowser
? isDev
? DOMInjector({})
: CSSOMInjector({})
: NoOpInjector);
prefix =
options.prefix ||
((property: string, value: string): string => {
const declaration = `${property}:${prefixValue(property, value)}`;
let cssText = declaration;
const flag = prefixProperty(property);
if (flag & 0b001) cssText += `;-ms-${declaration}`;
if (flag & 0b010) cssText += `;-moz-${declaration}`;
if (flag & 0b100) cssText += `;-webkit-${declaration}`;
return cssText;
});
ruleIndexesByIdentName = new Map();
nextRuleIndexesByPrecedenceGroup = new Uint16Array(
PRECEDENCE_GROUP_COUNT,
);
},
hydrate(): void {
if (isDev) checkSetup();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { cssRules } = injector.sheet!;
for (let i = 0, { length } = cssRules; i < length; ++i) {
const cssRule = cssRules[i];
if (cssRule.type === 7 /* CSSRule.KEYFRAMES_RULE */) {
// Keyframes needn't be checked recursively, as they are never nested
ruleIndexesByIdentName.set(
(cssRule as CSSKeyframesRule).name,
ruleIndexesByIdentName.size,
);
} else {
hydrateScopedSubtree(cssRule);
}
}
},
css(rules): string {
if (isDev) checkSetup();
// The leading white space character gets removed
return decomposeToClassNames(
rules,
"",
"",
new Uint16Array(PRECEDENCE_GROUP_COUNT),
).slice(1);
},
keyframes(rules): { toString(): string } {
if (isDev) checkSetup();
let identName: string | undefined;
return {
toString(): string {
if (!identName) {
let cssText = "";
// TODO: Replace var with const once it minifies equivalently
// eslint-disable-next-line guard-for-in, no-restricted-syntax, no-var, vars-on-top
for (var time in rules) {
cssText += `${time}{`;
const declarations = rules[time as keyof typeof rules];
// TODO: Replace var with const once it minifies equivalently
// eslint-disable-next-line guard-for-in, no-restricted-syntax, no-var, vars-on-top
for (var property in declarations) {
const value =
declarations[property as keyof typeof declarations];
if (value != null) {
cssText += serializeDeclarationList(property, value);
}
}
cssText += "}";
}
identName = `_${hash(cssText)}`;
if (!ruleIndexesByIdentName.has(identName)) {
injector.insert(
`@keyframes ${identName}{${cssText}}`,
ruleIndexesByIdentName.size,
);
ruleIndexesByIdentName.set(
identName,
ruleIndexesByIdentName.size,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return identName!;
},
};
},
};
}