attr-transform.macro
This is a babel-plugin-macros for built-time, pre-processor, transformation of jsx attributes (JSX.Attribute)
It is inspired by twin.macro and original design as pre-processor for that macro.
But it can be used to transform attributes in many scenarios.
A lot of the logic for the transformation is set in the config file,
so it's up to the user to define the transformation.
There will most likely be npm packages with predefined config for different use cases in the future.
It can also (but is not design for that purpose only),
validate attributes on an JSX.Element and throw an error if the attributes are not valid.
Install
npm install attr-transform.macro --save-dev
Or
yarn add -D attr-transform.macro
Usages
This is a babel-plugin-macros,
and work from babel detecting an import with the ".macro" post name.
The pre-build step is triggered by an import of the "attr-transform.macro" in a file.
import "attr-transform.macro"
The order of import define the order of the pre-process.
If you want to use it with twin.macro, the import most come before twin.macro.
import "attr-transform.macro"
import "twin.macro"
Examples of possible transformation
The config is a list of elements and attributes to transform,
including a list of options for the transformation.
let from = <Button onclick={} />;
let to = <Button onClick={} />;
elms:[
attr:[
{
matchName: /onclick/i,
replaceName: "onClick"
}
]
]
let from = <div tw="flex-col" />;
let to = <div tw="flex flex-col" />;
elms:[
attr:[
{
matchName: "tw",
replaceName: ({value}) => {
const values = value.split(" ");
if (values.includes("flex-col") || !values.includes("flex")) {
values.unshift("flex");
}
return values.join(" ");
},
}
]
]
let from = <div name-per data-prop="t" />;
let to = <div name="per" data-prop="t" />;
elms:[
attr:[
{
matchName: /(\w)\-(\w)/,
dontMatchName: /data\-/,
replaceName: ({match}) => match[0],
replaceValue: ({match}) => match[1]
}
]
]
let from = <div flex between p1 />;
let to = <div tw="flex items-center justify-between p-1" />;
elms:[
attr:[
{
matchName: "flex",
collect: true,
remove: true,
},
{
matchName: "between",
value: "justify-between",
collect: true,
remove: true,
},
{
name: "padding",
matchName: /p([1-9])/,
value: ({match}) => `p-${match[1]}`,
validate: ({ collectedAttributes }) => {
const countPadding = collectedAttributes.filter((attr) => attr.attrConfig.name === "padding").length
if (countPadding > 1) {
return "You can't use more than one 'padding ( p1, p2, ..., p9 )' on the same element"
}
},
collect: true,
remove: true,
},
{
description: "collect the original value if exists",
matchName: "tw",
collect: true
}
],
actions: [
{
description: "Create tw if not exists",
createAttribute: "tw",
condition: ({ collectedAttributes }) => collectedAttributes.length > 0,
value: ({ collectedAttributes }) => {
const flex = collectedAttributes.some((attr) => attr.name === "flex")
const col = collectedAttributes.some((attr) => attr.name === "col")
let value = collectedAttributes.some((attr) => attr.value ?? "").filter(x => !!x).join(" ");
if(flex){
tw += " flex"
if(col){
tw += " flex-col"
}else{
tw += " items-center"
}
}
return value
}
}
]
]
Logic steps and flow
The transformation is done in 5 steps:
- Collect all attributes that match the config
- Call validate-functions, value-functions, replace-function and match-funtions. (This is done after match to allow collect flag)
- Transform the attributes (replace name and/or value etc.)
- Run actions on the element
- Remove attributes that are marked for "remove: true"
Typescript difinitions
This pre-processor "just" take the (js) JSX attribute that matches and transform them.
To have typing for the attribute, you must either add the properties to the component,
or add global IntrinsicAttributes properties to a difinitions file.
See TS doc for jsx: https://www.typescriptlang.org/docs/handbook/jsx.html
declare global {
namespace JSX {
interface IntrinsicAttributes {
flex?: boolean;
line?: boolean;
p1?: boolean;
p2?: boolean;
}
}
}
For React app (Usin react-scripts
- like create-react-app
) that includes the react-app-env.d.ts
file,
you can add the typing there.
declare namespace React {
interface Attributes {
p1?: boolean;
p2?: boolean;
flex?: boolean;
}
}
Config
(optional): The config can be added in babel-plugin-macro's config (either in a babel.config.js or in package.json)
under the macro name "attr-transform" key.
Or it can be loaded from a config file (default: attr-transform.config.js
) in the root of the project.
(You can change the file name with the "config" properties in the babel-plugin-macros config)
See babel-plugin-macros docs for more info
Config types
const config =
{
elms: [
{
match: "div",
attrs:[
{
matchName: "name",
}
]
}
]
}
Full config types (interfaces):
export type AttrTransformMacroParams = Omit<MacroParams, "config"> & {
config?: AttrTransformConfig;
};
export type AttrTransformConfig = {
config?: string | false;
devMode?: boolean;
elms?: ElmConfig[];
};
export type ElmConfig = {
match?: string | RegExp;
dontMatch?: string | RegExp;
attrs: AttrConfig[];
actions?: PostMatchAction[];
};
export type ConditionFunc = (matchedAttributes: PostActionMatch) => boolean;
export type ActionValueFunc = (postActionMatch: PostActionMatch) => FullLegalAttributeValues;
export type CreateActionValueFunc = (postActionMatch: PostActionMatch) => string | T.JSXAttribute;
export type MatchValueFunc = (attrMatch: AttrMatch) => boolean;
export type AttrValueFunc = (attrMatch: AttrMatch) => FullLegalAttributeValues;
export type AttrStringValueFunc = (attrMatch: AttrMatch) => string;
export type CreateValueFunc = (attrMatch: AttrMatch) => string | T.JSXAttribute;
export type ValidateValueFunc = (attrMatch: AttrMatch) => string | undefined;
export type ActionFunc = (attrMatch: AttrMatch) => void;
export type AttrConfig = {
name?: string;
description?: string;
tags?: string[];
matchName?: string | RegExp;
dontMatchName?: string | RegExp;
value?: string | AttrValueFunc;
replaceValue?: string | AttrValueFunc;
replaceName?: string | AttrStringValueFunc;
validate?: ValidateValueFunc;
collect?: boolean;
remove?: boolean;
};
export type PostMatchAction = {
name?: string;
description?: string;
tags?: string[];
condition?: ConditionFunc;
createAttribute?: string | CreateActionValueFunc;
replaceName?: string | AttrStringValueFunc;
value?: string | ActionValueFunc;
action?: ActionFunc;
};
export type LegalAttributeValues =
| T.JSXElement
| T.JSXFragment
| T.StringLiteral
| T.JSXExpressionContainer
| null
| undefined;
export type FullLegalAttributeValues = string | number | boolean | LegalAttributeValues;
export type PostActionMatch = {
name: string;
value: FullLegalAttributeValues;
postMatchAction: PostMatchAction;
allMatchingAttributes: AttrMatch[];
collectedAttributes: AttrMatch[];
tagMatch?: RegExpMatchArray | null;
elmNodePath: NodePath<T.JSXOpeningElement>;
macroParams: AttrTransformMacroParams;
};
export type AttrMatch = {
name: string;
value: FullLegalAttributeValues;
attrConfig: AttrConfig;
matchFunction?: MatchValueFunc;
dontMatchFunction?: MatchValueFunc;
validateFunction?: ValidateValueFunc;
valueFunction?: AttrValueFunc;
match?: RegExpMatchArray | null;
tagMatch?: RegExpMatchArray | null;
allMatchingAttributes: AttrMatch[];
collectedAttributes: AttrMatch[];
nodePath: NodePath<T.JSXAttribute>;
elmNodePath: NodePath<T.JSXOpeningElement>;
macroParams: AttrTransformMacroParams;
collected?: boolean;
remove?: boolean;
};
Full config example
attr-transform.config.js
module.exports = {
elms: [
{
dontMatch: "img",
attrs: [
{
name: "tw padding",
match: /p([0-9])/,
value: ({ match }) => `p-${match?.[1]}`,
validate: ({ collectedAttributes }) => {
const countPadding = collectedAttributes.filter((attr) => attr.attrConfig.name === "tw padding").length
if (countPadding > 1) {
return "You can't use more than one 'tw padding ( p1, p2, ..., p9 )' on the same element"
}
},
collect: true,
remove: true,
},
{
name: "tw colors",
match: /(red|blue|green)/,
value: ({ match }) => `text-${match?.[1]}-600`,
collect: true,
remove: true,
},
{
name: "flex",
match: "flex",
validate: (matchAttr) => {
const notAllowed = matchAttr.allMatchingAttributes.some((attr) => attr.name === "line")
if (notAllowed) {
return "You can't use both 'flex' and 'line' on the same element"
}
},
value: "flex items-center justify-start",
collect: true,
remove: true,
},
{
name: "standard line element",
match: "line",
value: "flex items-center justify-start",
collect: true,
remove: true,
},
{
name: "tw attribute",
description: "Collect tw value if exists",
match: "tw",
collect: true
}
],
actions: [
{
name: "Update Tw attribute",
description: "Create tw attribute if not exists, and append collected values (including previous tw values)",
createAttribute: "tw",
condition: ({ collectedAttributes }) => collectedAttributes.length > 0,
replaceValue: ({ collectedAttributes }) => {
const value = collectedAttributes.map((attr) => attr.value).join(" ")
return value
}
}
]
},
],
}