refactor: style serialization

This commit is contained in:
Mark Lawlor
2022-06-02 14:25:41 +10:00
parent 792c947a24
commit beb5b6475e
10 changed files with 260 additions and 89 deletions

View File

@@ -17,10 +17,10 @@ tailwindRunner(
{
border: [
{
borderBottomWidth: "hairlineWidth",
borderTopWidth: "hairlineWidth",
borderLeftWidth: "hairlineWidth",
borderRightWidth: "hairlineWidth",
borderBottomWidth: "styleSheet(hairlineWidth)",
borderTopWidth: "styleSheet(hairlineWidth)",
borderLeftWidth: "styleSheet(hairlineWidth)",
borderRightWidth: "styleSheet(hairlineWidth)",
} as Style,
],
},
@@ -30,8 +30,8 @@ tailwindRunner(
{
"border-x": [
{
borderLeftWidth: "hairlineWidth",
borderRightWidth: "hairlineWidth",
borderLeftWidth: "styleSheet(hairlineWidth)",
borderRightWidth: "styleSheet(hairlineWidth)",
} as Style,
],
},
@@ -41,27 +41,39 @@ tailwindRunner(
{
"border-y": [
{
borderTopWidth: "hairlineWidth",
borderBottomWidth: "hairlineWidth",
borderTopWidth: "styleSheet(hairlineWidth)",
borderBottomWidth: "styleSheet(hairlineWidth)",
} as Style,
],
},
],
[
"border-t",
{ "border-t": [{ borderTopWidth: "hairlineWidth" } as Style] },
{
"border-t": [{ borderTopWidth: "styleSheet(hairlineWidth)" } as Style],
},
],
[
"border-b",
{ "border-b": [{ borderBottomWidth: "hairlineWidth" } as Style] },
{
"border-b": [
{ borderBottomWidth: "styleSheet(hairlineWidth)" } as Style,
],
},
],
[
"border-l",
{ "border-l": [{ borderLeftWidth: "hairlineWidth" } as Style] },
{
"border-l": [{ borderLeftWidth: "styleSheet(hairlineWidth)" } as Style],
},
],
[
"border-r",
{ "border-r": [{ borderRightWidth: "hairlineWidth" } as Style] },
{
"border-r": [
{ borderRightWidth: "styleSheet(hairlineWidth)" } as Style,
],
},
],
],
createTests("border", scenarios, (n) => ({

View File

@@ -1,12 +1,12 @@
import { TailwindConfig } from "tailwindcss/tailwind-config";
import { extractStyles } from "../../../src/postcss/extract-styles";
import { StyleError, StyleRecord } from "../../../src/types/common";
import { testStyleSerializer } from "../../../src/utils/serialize-styles";
import plugin from "../../../src/tailwind";
import { nativePlugin } from "../../../src/tailwind/native";
import { TailwindProvider, TailwindProviderProps } from "../../../src";
import { PropsWithChildren } from "react";
import { serializeStyles } from "../../../src/utils/serialize-styles";
export type Test = [string, StyleRecord] | [string, StyleRecord, true];
@@ -40,30 +40,31 @@ export function assertStyles(
{ raw: "", extension: "html" },
] as unknown as TailwindConfig["content"],
safelist: [css],
serializer: (styles) => styles,
});
expect(output).toEqual({ styles });
if (shouldError) {
errors.push(...outputErrors);
expect(errors.length).toBeGreaterThan(0);
expect([...errors, ...outputErrors].length).toBeGreaterThan(0);
} else {
expect(outputErrors.length).toBe(0);
}
expect(output.output).toEqual(styles);
}
export function TestProvider({
css,
...props
}: PropsWithChildren<TailwindProviderProps & { css: string }>) {
globalThis.hairlineWidthValue = 1;
const { styles } = extractStyles({
const { output } = extractStyles({
theme: {},
plugins: [plugin, nativePlugin()],
content: [
{ raw: "", extension: "html" },
] as unknown as TailwindConfig["content"],
safelist: [css],
serializer: testStyleSerializer,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <TailwindProvider {...serializeStyles(styles)} {...props} />;
return <TailwindProvider {...output} {...props} />;
}

View File

@@ -10,6 +10,7 @@ import { visitor, VisitorState } from "./visitor";
import { getAllowedOptions, isAllowedProgramPath } from "./utils/allowed-paths";
import { getTailwindConfig } from "./utils/get-tailwind-config";
import { StyleError } from "../types/common";
import { babelStyleSerializer } from "../utils/serialize-styles";
export default function rootVisitor(
options: TailwindcssReactNativeBabelOptions,
@@ -98,12 +99,13 @@ export default function rootVisitor(
/**
* Override tailwind to only process the classnames in this file
*/
const { styles } = extractStyles({
const { output } = extractStyles({
...tailwindConfig,
content: [filename],
// If the file doesn't have any Tailwind styles, it will print a warning
// We force an empty style to prevent this
safelist: [".native-hmr-empty"],
serializer: babelStyleSerializer,
});
const bodyNode = path.node.body;
@@ -117,9 +119,9 @@ export default function rootVisitor(
}
// If there are no styles, early exit
if (Object.keys(styles).length === 0) return;
if (Object.keys(output.styles).length === 0) return;
appendVariables(bodyNode, styles);
appendVariables(bodyNode, output);
if (!hasStyleSheetImport) {
prependImport(
@@ -136,13 +138,16 @@ export default function rootVisitor(
/**
* Override tailwind to only process the classnames in this file
*/
const { styles } = extractStyles(tailwindConfig);
const { output } = extractStyles({
...tailwindConfig,
serializer: babelStyleSerializer,
});
// If there are no styles, early exit
if (Object.keys(styles).length === 0) return;
if (Object.keys(output.styles).length === 0) return;
const bodyNode = path.node.body;
appendVariables(bodyNode, styles);
appendVariables(bodyNode, output);
if (!hasStyleSheetImport) {
prependImport(

View File

@@ -1,3 +1,5 @@
import { DefaultSerializedStyles } from "../../utils/serialize-styles";
import {
arrayExpression,
assignmentExpression,
@@ -6,6 +8,7 @@ import {
Expression,
expressionStatement,
identifier,
isExpression,
logicalExpression,
memberExpression,
nullLiteral,
@@ -17,12 +20,10 @@ import {
unaryExpression,
} from "@babel/types";
import { StyleRecord } from "../../types/common";
import { serializeStyles } from "../../utils/serialize-styles";
export function appendVariables(body: Statement[], styleRecord: StyleRecord) {
const { styles, media } = serializeStyles(styleRecord);
export function appendVariables(
body: Statement[],
{ styles, media }: DefaultSerializedStyles
) {
body.push(
expressionStatement(
assignmentExpression(
@@ -47,7 +48,7 @@ export function appendVariables(body: Statement[], styleRecord: StyleRecord) {
identifier("RNStyleSheet"),
identifier("create")
),
[serialize(styles)]
[babelSerializeObject(styles)]
),
]
)
@@ -71,7 +72,7 @@ export function appendVariables(body: Statement[], styleRecord: StyleRecord) {
),
identifier("{}")
),
serialize(media),
babelSerializeObject(media),
]
)
)
@@ -79,21 +80,20 @@ export function appendVariables(body: Statement[], styleRecord: StyleRecord) {
);
}
function serialize(literal: unknown): Expression {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function babelSerializeObject(literal: any): Expression {
if (isExpression(literal)) {
return literal;
}
if (literal === null) {
return nullLiteral();
}
switch (typeof literal) {
case "number":
return numericLiteral(literal);
case "string":
if (literal === "hairlineWidth") {
return memberExpression(
identifier("RNStyleSheet"),
identifier("hairlineWidth")
);
}
return stringLiteral(literal);
case "boolean":
return booleanLiteral(literal);
@@ -101,7 +101,7 @@ function serialize(literal: unknown): Expression {
return unaryExpression("void", numericLiteral(0), true);
default:
if (Array.isArray(literal)) {
return arrayExpression(literal.map((n) => serialize(n)));
return arrayExpression(literal.map((n) => babelSerializeObject(n)));
}
if (isObject(literal)) {
@@ -111,7 +111,10 @@ function serialize(literal: unknown): Expression {
return typeof literal[k] !== "undefined";
})
.map((k) => {
return objectProperty(stringLiteral(k), serialize(literal[k]));
return objectProperty(
stringLiteral(k),
babelSerializeObject(literal[k])
);
})
);
}

View File

@@ -6,10 +6,19 @@ import plugin from "../postcss";
import { StyleError, StyleRecord } from "../types/common";
export function extractStyles(
tailwindConfig: TailwindConfig,
export interface ExtractStylesOptions<T> extends TailwindConfig {
serializer: (styleRecord: StyleRecord) => T;
}
export interface ExtractStyles<T> {
output: T;
errors: StyleError[];
}
export function extractStyles<T>(
{ serializer, ...tailwindConfig }: ExtractStylesOptions<T>,
cssInput = "@tailwind components;@tailwind utilities;"
) {
): ExtractStyles<T> {
let styles: StyleRecord = {};
let errors: StyleError[] = [];
@@ -27,7 +36,7 @@ export function extractStyles(
postcss(plugins).process(cssInput).css;
return {
styles,
output: serializer(styles),
errors,
};
}

View File

@@ -0,0 +1,16 @@
import { StyleRecord } from "../types/common";
import { postCSSSerializer } from "../utils/serialize-styles";
export function outputFormatter(
styles: StyleRecord,
platform?: string
): string {
const { style, media } = postCSSSerializer(styles);
return `// This file was generated by tailwindcss-react-native. Do not edit!
const { StyleSheet } = require("react-native")
module.exports = { ${platform ? `\nplatform: '${platform},` : ""}
styles: ${style},
media: ${media},
}`;
}

View File

@@ -3,7 +3,7 @@ import { Plugin, PluginCreator } from "postcss";
import { normalizeSelector } from "../shared/selector";
import { toReactNative } from "./to-react-native";
import { StyleRecord, Style, StyleError, AtRuleTuple } from "../types/common";
import { serializeStyles } from "../utils/serialize-styles";
import { outputFormatter } from "./output-formatter";
const atRuleSymbol = Symbol("media");
@@ -73,31 +73,10 @@ export const plugin: PluginCreator<PostcssPluginOptions> = ({
}
});
/*
* Why not serialize styles here?
* Because this is used by the tests and its cleaner to write tests
* for the non-serialized version (the at rules are next to the declarations)
*
* If please create an issue if you want a done function
* that serializes the styles
*/
if (done) done({ styles, errors });
if (output) {
const serialized = serializeStyles(styles);
writeFileSync(
output,
`// This file was generated by tailwindcss-react-native. Do not edit!
const { StyleSheet } = require("react-native")
module.exports = { ${platform ? `\nplatform: '${platform},` : ""}
styles: ${JSON.stringify(serialized.styles).replace(
new RegExp('"hairlineWidth"', "g"),
"StyleSheet.hairlineWidth"
)},
media: ${JSON.stringify(serialized.media)},
}`
);
writeFileSync(output, outputFormatter(styles, platform));
}
},
} as Plugin;

View File

@@ -1,11 +1,6 @@
import { getStylesForProperty, Style } from "css-to-react-native";
import { ImageStyle, TextStyle, ViewStyle } from "react-native";
declare global {
// eslint-disable-next-line no-var
var hairlineWidthValue: number | undefined;
}
export type PropertyGuard<T extends string> = (
value: string,
name: string
@@ -56,16 +51,37 @@ export function only<
const callback = (value: string, name: string) => {
const isNaN = Number.isNaN(Number.parseInt(value));
if (number) {
if (value === "hairlineWidth") {
return JSON.parse(
JSON.stringify(getStylesForProperty(name, "1px")).replace(
new RegExp("1", "g"),
globalThis.hairlineWidthValue?.toString() ?? '"hairlineWidth"'
)
);
}
if (isFunctionValue(value)) {
/**
* This is a hack to support platform values: styleSheet(hairlineWidth)
*
* We need to preserve this value all the way to the style serialization
* where they are outputted as runtime values: StyleSheet.hairlineWidth
*
* But we also need to convert shorthand css property names to their long form
*
* so { borderWidth: styleSheet(hairlineWidth) } needs to be turned into
*
* {
* "borderBottomWidth": "styleSheet(hairlineWidth)",
* "borderLeftWidth": "styleSheet(hairlineWidth)",
* "borderRightWidth": "styleSheet(hairlineWidth)",
* "borderTopWidth": "styleSheet(hairlineWidth)",
* }
*
* We achieve this by generating a fake style object and replacing its values.
*/
const fakePropertyStyles =
number || units
? getStylesForProperty(name, "1px")
: getStylesForProperty(name, "transparent");
return Object.fromEntries(
Object.entries(fakePropertyStyles).map(([key]) => [key, value])
);
}
if (number) {
if (isNaN) {
throw new Error(name);
}
@@ -96,3 +112,7 @@ export function only<
return callback;
}
function isFunctionValue(value: string) {
return value.startsWith("styleSheet(") || value.startsWith("platformColor(");
}

View File

@@ -77,7 +77,7 @@ export const nativePlugin = plugin.withOptions<NativePluginOptions | undefined>(
widest: "1px",
},
borderWidth: {
DEFAULT: "hairlineWidth",
DEFAULT: "styleSheet(hairlineWidth)",
0: "0px",
2: "2px",
4: "4px",

View File

@@ -1,6 +1,25 @@
import { MediaRecord, StyleRecord } from "../types/common";
import {
callExpression,
identifier,
memberExpression,
stringLiteral,
} from "@babel/types";
import { MediaRecord, Style, StyleRecord } from "../types/common";
export function serializeStyles(styleRecord: StyleRecord) {
export interface DefaultSerializedStyles {
styles: {
[k: string]: Style;
};
media: MediaRecord;
}
/**
* The Default Serializer which separates the styles into { style, media }
*/
export function serializeStyles(
styleRecord: StyleRecord,
replacer?: (key: string, value: string) => [string, unknown]
): DefaultSerializedStyles {
const media: MediaRecord = {};
const styles = Object.fromEntries(
@@ -14,6 +33,13 @@ export function serializeStyles(styleRecord: StyleRecord) {
media[key].push(atRules);
style = rest;
}
if (replacer) {
style = Object.fromEntries(
Object.entries(style).map((v) => replacer(...v))
);
}
return [newKey, style];
});
})
@@ -21,3 +47,103 @@ export function serializeStyles(styleRecord: StyleRecord) {
return { styles, media };
}
/**
* Used by Jest
*
* Replaces platform function strings with known values
*/
export function testStyleSerializer(styleRecord: StyleRecord) {
return serializeStyles(styleRecord, (key, value) => {
if (value === "styleSheet(hairlineWidth)") {
return [key, 1];
}
return [key, value];
});
}
/**
* Used by Babel
*
* Replaces platform function strings with actual platform functions
*/
export function babelStyleSerializer(styleRecord: StyleRecord) {
return serializeStyles(styleRecord, (key, value) => {
if (typeof value !== "string") {
return [key, value];
}
if (value === "styleSheet(hairlineWidth)") {
return [
key,
memberExpression(
identifier("RNStyleSheet"),
identifier("hairlineWidth")
),
];
}
if (value.startsWith("platformColor(")) {
const result = /platformColor\((.+?)\)/.exec(value);
if (!result) return [key, identifier("undefined")];
const variables = result[1]
.split(/[ ,]+/)
.filter(Boolean)
.map((v: string) => stringLiteral(v));
return [key, callExpression(identifier("RNPlatformColor"), variables)];
}
return [key, value];
});
}
/**
* Used by Post CSS
*
* Replaces platform function strings with actual platform functions
*/
export function postCSSSerializer(styleRecord: StyleRecord) {
const serialized = serializeStyles(styleRecord);
const styleString = JSON.stringify(serialized.styles)
.replace(...styleSheetReplacer)
.replace(...platformColorReplacer);
const mediaString = JSON.stringify(serialized.media);
return {
style: styleString,
media: mediaString,
};
}
// eslint-disable-next-line @typescript-eslint/ban-types
const styleSheetReplacer: Parameters<String["replace"]> = [
// '"styleSheet(hairlineWidth)"' will be captured as ["styleSheet", "hairlineWidth"]
new RegExp(`"(styleSheet)\\((.+?)\\)"`, "g"),
// [styleSheet, hairlineWidth] => StyleSheet.hairlineWidth
(_: string, s: string, attribute: string) => {
const styleSheet = s.charAt(0).toUpperCase() + s.slice(1);
return `${styleSheet}.${attribute}`;
},
];
// eslint-disable-next-line @typescript-eslint/ban-types
const platformColorReplacer: Parameters<String["replace"]> = [
// '"platformColor(color1, color2)"' will be captured as ["platformColor", "color1, color2"]
new RegExp(`"(platformColor)\\((.+?)\\)"`, "g"),
// [platformColor, "color1, color2"] => PlatformColor("color1","color2")
(_: string, s: string, values: string) => {
const platformColor = s.charAt(0).toUpperCase() + s.slice(1);
const variables = values
.split(/[ ,]+/)
.filter(Boolean)
.map((v) => `"${v}"`)
.join(",");
return `${platformColor}(${variables})`;
},
];