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.
npm install attr-transform.macro --save-dev
Or
yarn add -D attr-transform.macro
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"
The config is a list of elements and attributes to transform, including a list of options and actions for the transformation.
// Rename attribute
let from = <Button onclick={} />;
let to = <Button onClick={} />;
// config:
elms:[
attrs:[
{
matchName: /onclick/i,
replaceName: "onClick"
}
]
]
// Change value
let from = <div tw="flex-col" />;
let to = <div tw="flex flex-col" />;
// config:
elms:[
attrs:[
{
matchName: "tw",
replaceName: ({value}) => {
const values = value.split(" ");
if (values.includes("flex-col") || !values.includes("flex")) {
values.unshift("flex");
}
return values.join(" ");
},
}
]
]
// Rename attribute and change value
let from = <div name-per data-prop="t" />;
let to = <div name="per" data-prop="t" />;
// config:
elms:[
attrs:[
{
matchName: /(\w)\-(\w)/,
dontMatchName: /data\-/,
replaceName: ({match}) => match[0],
replaceValue: ({match}) => match[1]
}
]
]
// Collect and transform (including remove attributes and create new attribute)
let from = <div flex between p1 />;
let to = <div tw="flex items-center justify-between p-1" />;
// config:
elms:[
attrs:[
{
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", // ensure tw attribute exists
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
}
}
]
]
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"
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
// ./types/attr-typing.d.ts
declare global {
namespace JSX {
interface IntrinsicAttributes {
/** tw: "flex" */
flex?: boolean;
/** tw: "flex items-center justify-start" */
line?: boolean;
/** tw: "p1" */
p1?: boolean;
/** tw: "p2" */
p2?: boolean;
}
}
}
For React app (Using react-scripts
- like create-react-app
) that includes the react-app-env.d.ts
file,
you can add the typing there.
/// <reference types="react-scripts" />
declare namespace React {
interface Attributes {
/** tw: "p1" */
p1?: boolean;
/** tw: "p2" */
p2?: boolean;
/** flex: "flex item-center" (Work in combination with "col") */
flex?: boolean;
}
}
(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
const config =
{
// a list of elements to transform
elms: [
{
match: "div", // match only div elements (If no "match" are provided, all elements are matched)
attrs:[
{
matchName: "name", // See "AttrConfig" below, for all options
}
]
}
]
}
Full config types (interfaces):
export type AttrTransformMacroParams = Omit<MacroParams, "config"> & {
config?: AttrTransformConfig;
};
export type AttrTransformConfig = {
config?: string | false; // file name (default: attr-transform.config.js)
devMode?: AttrTransformConfigDebug;
elms?: ElmConfig[];
};
export type AttrTransformConfigDebug = {
logToConsole?: boolean;
logWithThrow?: boolean;
printToFile?: boolean | string;
maxDepth?: number;
colors?: boolean;
};
export type ElmConfig = {
match?: string | RegExp; // Optional match // Special "*" matches all
dontMatch?: string | RegExp; // Optional dontMatch
attrs: AttrConfig[];
actions?: PostMatchAction[];
};
// elm/action methods
export type ConditionFunc = (matchedAttributes: PostActionMatch) => boolean;
export type ActionValueFunc = (postActionMatch: PostActionMatch) => FullLegalAttributeValues;
export type CreateActionValueFunc = (postActionMatch: PostActionMatch) => string | T.JSXAttribute;
// attri methods
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 = {
// a name for the config (for debugging and overview)
name?: string;
// a name for the config (for debugging and overview)
description?: string;
// a list of tags. Use for more complex match and replace (Like selecteing all collected with a tag)
tags?: string[];
// mosth match the attribute name ( Fx: "padding" or "p1" or /p(1-9)/)
matchName?: string | RegExp;
// most not match the attribute name ( Fx: "padding" or "p1" or /p(1-9)/)
dontMatchName?: string | RegExp;
// Calcalate a value from the AttrMatch Object. ( Fx: "p-1" or ({match}) => `p-${match[1]}` )
value?: string | AttrValueFunc;
// Replace the value of the matched attribute with the calculated value
replaceValue?: string | AttrValueFunc;
replaceName?: string | AttrStringValueFunc;
// Validate the value (Throw MacroError if not valid)
validate?: ValidateValueFunc;
// collect AttrMatch Object to be used by other attributes config (This is not need, mostly to indicate that it's macthed)
collect?: boolean;
// remove this attribute after processing of all attributes
remove?: boolean;
};
export type PostMatchAction = {
// a name for the config (for debugging and overview)
name?: string;
// a name for the config (for debugging and overview)
description?: string;
// a list of tags. Use for more complex match and replace (Like selecteing all collected with a tag)
tags?: string[];
// conditions
condition?: ConditionFunc;
// create the attribute if not exists
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;
// Meta data for the attribute use for calculate the value, validate etc in the config
export type PostActionMatch = {
name: string;
value: FullLegalAttributeValues;
postMatchAction: PostMatchAction;
allMatchingAttributes: AttrMatch[]; // all props found for this element
collectedAttributes: AttrMatch[]; // all props found for this element
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[]; // all props found for this element
collectedAttributes: AttrMatch[]; // all props found for this element
// babel types for advanced usage
nodePath: NodePath<T.JSXAttribute>;
elmNodePath: NodePath<T.JSXOpeningElement>;
// babel types for super advanced usage
//state: Babel.PluginPass
macroParams: AttrTransformMacroParams;
collected?: boolean;
remove?: boolean;
};
attr-transform.config.js
/** @type {import('attr-transform.macro/types/attr-transform.config.d.ts').AttrTransformConfig} */
module.exports = {
elms: [
{
// match: "div" // match all divs
dontMatch: "img", // dont match img
attrs: [
{
name: "tw padding",
matchName: /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",
matchName: /(red|blue|green)/,
value: ({ match }) => `text-${match?.[1]}-600`,
collect: true,
remove: true,
},
{
name: "flex",
matchName: "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",
matchName: "line",
value: "flex items-center justify-start",
collect: true,
remove: true,
},
{
name: "tw attribute",
description: "Collect tw value if exists",
matchName: "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", // ensure tw attribute exists
condition: ({ collectedAttributes }) => collectedAttributes.length > 1,
value: ({ collectedAttributes }) => {
const value = collectedAttributes.map((attr) => attr.value).join(" ")
return value
}
}
]
},
],
}
You can add devMode
in the config file to debug and test the configuration (and transformation)
The standard babal-plugin-macros
dont allow console.log so you must either change that (allow console.log - I do not know how to do that!!)
or hack the console by trowing an error (The plugin will, like when validation config fails, throw an MacroError)
The easiest way to debug is to use printToFile
config.
It will create a new file attr-transform.macro.debug-log.txt
in the root of your project when transformation is run.
It contains the ussed config, debug info of found elements and attributes, and the full transformed file content.
All debug optiosn:
colors
add console colors so it should only be used with logToConsole or logWithThrowlogToConsole
log to console (will not work with standard babel-plugin-macros)logWithThrow
log to console by throwing an error (will work with standard babel-plugin-macros)printToFile
print debug info to file (will work with standard babel-plugin-macros)maxDepth
max depth for serialising (like json-stringify) of debug object (default 10) - The Babel NodePath object can be huge! with very deep nesting!onlyTranformation
only print the transformation result (not the full debug info)onlyFiles
Only print to file matching this string / regex (default: undefined = all files)
module.exports = {
devMode: {
printToFile: true,
},
elms: [(...)]
}