diff --git a/CHANGELOG.md b/CHANGELOG.md
index 092c24b..92b6ff1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for `prettier-plugin-marko` ([#151](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/151))
+- Allow sorting of custom attributes, functions, and tagged template literals ([#155](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/155))
### Fixed
diff --git a/README.md b/README.md
index 2c8de63..79eaa1f 100644
--- a/README.md
+++ b/README.md
@@ -21,9 +21,11 @@ module.exports = {
}
```
-## Resolving your Tailwind configuration
+## Options
-To ensure that the class sorting is taking into consideration any of your project's Tailwind customizations, it needs access to your [Tailwind configuration file](https://tailwindcss.com/docs/configuration) (`tailwind.config.js`).
+### Customizing your Tailwind config path
+
+To ensure that the class sorting takes into consideration any of your project's Tailwind customizations, it needs access to your [Tailwind configuration file](https://tailwindcss.com/docs/configuration) (`tailwind.config.js`).
By default the plugin will look for this file in the same directory as your Prettier configuration file. However, if your Tailwind configuration is somewhere else, you can specify this using the `tailwindConfig` option in your Prettier configuration.
@@ -38,6 +40,93 @@ module.exports = {
If a local configuration file cannot be found the plugin will fallback to the default Tailwind configuration.
+## Sorting non-standard attributes
+
+By default this plugin only sorts classes in the `class` attribute as well as any framework-specific equivalents like `class`, `className`, `:class`, `[ngClass]`, etc.
+
+You can sort additional attributes using the `tailwindAttributes` option, which takes an array of attribute names:
+
+```js
+// prettier.config.js
+module.exports = {
+ tailwindAttributes: ['myClassList'],
+}
+```
+
+With this configuration, any classes found in the `myClassList` attribute will be sorted:
+
+```jsx
+function MyButton({ children }) {
+ return (
+
+ );
+}
+```
+
+## Sorting classes in function calls
+
+In addition to sorting classes in attributes, you can also sort classes in strings provided to function calls. This is useful when working with libraries like [clsx](https://github.com/lukeed/clsx) or [cva](https://cva.style/).
+
+You can sort classes in function calls using the `tailwindFunctions` option, which takes a list of function names:
+
+```js
+// prettier.config.js
+module.exports = {
+ tailwindFunctions: ['clsx'],
+}
+```
+
+With this configuration, any classes in `clsx()` function calls will be sorted:
+
+```jsx
+import clsx from 'clsx'
+
+function MyButton({ isHovering, children }) {
+ let classes = clsx(
+ 'rounded bg-blue-500 px-4 py-2 text-base text-white',
+ {
+ 'bg-blue-700 text-gray-100': isHovering,
+ },
+ )
+
+ return (
+
+ )
+}
+```
+
+## Sorting classes in template literals
+
+This plugin also enables sorting of classes in tagged template literals.
+
+You can sort classes in template literals using the `tailwindFunctions` option, which takes a list of function names:
+
+```js
+// prettier.config.js
+module.exports = {
+ tailwindFunctions: ['tw'],
+}
+```
+
+With this configuration, any classes in template literals tagged with `tw` will automatically be sorted:
+
+```jsx
+import { View, Text } from 'react-native'
+import tw from 'twrnc'
+
+function MyScreen() {
+ return (
+
+ Hello World
+
+ )
+}
+```
+
## Compatibility with other Prettier plugins
This plugin uses Prettier APIs that can only be used by one plugin at a time, making it incompatible with other Prettier plugins implemented the same way. To solve this we've added explicit per-plugin workarounds that enable compatibility with the following Prettier plugins:
diff --git a/prettier.config.js b/prettier.config.js
index 4e5a4b5..ae5bfa6 100644
--- a/prettier.config.js
+++ b/prettier.config.js
@@ -3,4 +3,7 @@ module.exports = {
semi: false,
singleQuote: true,
trailingComma: 'all',
+ pluginSearchDirs: false,
+ plugins: ['@ianvs/prettier-plugin-sort-imports'],
+ importOrder: ['^@', '^[a-zA-Z0-9-]+', '^[./]'],
}
diff --git a/src/config.js b/src/config.js
index d38ec9f..75565f4 100644
--- a/src/config.js
+++ b/src/config.js
@@ -10,18 +10,8 @@ import { createContext as createContextFallback } from 'tailwindcss/lib/lib/setu
import loadConfigFallback from 'tailwindcss/loadConfig'
import resolveConfigFallback from 'tailwindcss/resolveConfig'
-/**
- * @typedef {object} ContextContainer
- * @property {any} context
- * @property {() => any} generateRules
- * @property {any} tailwindConfig
- **/
-
-/**
- * @typedef {object} PluginOptions
- * @property {string} [tailwindConfig]
- * @property {string} filepath
- **/
+/** @typedef {import('./types').ContextContainer} ContextContainer **/
+/** @typedef {import('./types').PluginOptions} PluginOptions **/
/**
* @template K
diff --git a/src/index.js b/src/index.js
index 234f6ba..873cb83 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,11 +1,4 @@
-import {
- getCompatibleParser,
- getAdditionalParsers,
- getAdditionalPrinters,
-} from './compat.js'
-import { getTailwindConfig } from './config.js'
-import { sortClasses, sortClassList } from './sorting.js'
-import { visit } from './utils.js'
+// @ts-check
import * as astTypes from 'ast-types'
import jsesc from 'jsesc'
import lineColumn from 'line-column'
@@ -19,10 +12,35 @@ import prettierParserMeriyah from 'prettier/parser-meriyah'
import prettierParserPostCSS from 'prettier/parser-postcss'
import prettierParserTypescript from 'prettier/parser-typescript'
import * as recast from 'recast'
+import {
+ getCompatibleParser,
+ getAdditionalParsers,
+ getAdditionalPrinters,
+} from './compat.js'
+import { getTailwindConfig } from './config.js'
+import { getCustomizations } from './options.js'
+import { sortClasses, sortClassList } from './sorting.js'
+import { visit } from './utils.js'
let base = getBasePlugins()
-function createParser(parserFormat, transform) {
+/** @typedef {import('./types').Customizations} Customizations */
+/** @typedef {import('./types').TransformerContext} TransformerContext */
+/** @typedef {import('./types').TransformerMetadata} TransformerMetadata */
+
+/**
+ * @param {string} parserFormat
+ * @param {(ast: any, context: TransformerContext) => void} transform
+ * @param {TransformerMetadata} meta
+ */
+function createParser(parserFormat, transform, meta = {}) {
+ /** @type {Customizations} */
+ let customizationDefaults = {
+ staticAttrs: new Set(meta.staticAttrs ?? []),
+ dynamicAttrs: new Set(meta.dynamicAttrs ?? []),
+ functions: new Set(meta.functions ?? []),
+ }
+
return {
...base.parsers[parserFormat],
preprocess(code, options) {
@@ -31,6 +49,13 @@ function createParser(parserFormat, transform) {
return original.preprocess ? original.preprocess(code, options) : code
},
+ /**
+ *
+ * @param {string} text
+ * @param {any} parsers
+ * @param {import('./types').PluginOptions} options
+ * @returns
+ */
parse(text, parsers, options = {}) {
let { context, generateRules } = getTailwindConfig(options)
@@ -41,7 +66,24 @@ function createParser(parserFormat, transform) {
}
let ast = original.parse(text, parsers, options)
- transform(ast, { env: { context, generateRules, parsers, options } })
+
+ let customizations = getCustomizations(
+ options,
+ parserFormat,
+ customizationDefaults,
+ )
+
+ let changes = []
+
+ transform(ast, {
+ env: { context, customizations, generateRules, parsers, options },
+ changes,
+ })
+
+ if (parserFormat === 'svelte') {
+ ast.changes = changes
+ }
+
return ast
},
}
@@ -69,92 +111,119 @@ function tryParseAngularAttribute(value, env) {
errors.forEach((err) => console.warn(err))
}
-function transformHtml(
- attributes,
- computedAttributes = [],
- computedType = 'js',
-) {
- let transform = (ast, { env }) => {
- for (let attr of ast.attrs ?? []) {
- if (attributes.includes(attr.name)) {
- attr.value = sortClasses(attr.value, { env })
- } else if (computedAttributes.includes(attr.name)) {
- if (!/[`'"]/.test(attr.value)) {
- continue
- }
+function transformDynamicAngularAttribute(attr, env) {
+ let directiveAst = tryParseAngularAttribute(attr.value, env)
+
+ // If we've reached this point we couldn't parse the expression we we should bail
+ // `tryParseAngularAttribute` will display some warnings/errors
+ // But we shouldn't fail outright — just miss parsing some attributes
+ if (!directiveAst) {
+ return
+ }
+
+ visit(directiveAst, {
+ StringLiteral(node) {
+ if (!node.value) return
+ attr.value =
+ attr.value.slice(0, node.start + 1) +
+ sortClasses(node.value, { env }) +
+ attr.value.slice(node.end - 1)
+ },
+ })
+}
+
+function transformDynamicJsAttribute(attr, env) {
+ let { functions } = env.customizations
+
+ let ast = recast.parse(`let __prettier_temp__ = ${attr.value}`, {
+ parser: prettierParserBabel.parsers['babel-ts'],
+ })
- if (computedType === 'angular') {
- let directiveAst = tryParseAngularAttribute(attr.value, env)
-
- // If we've reached this point we couldn't parse the expression we we should bail
- // `tryParseAngularAttribute` will display some warnings/errors
- // But we shouldn't fail outright — just miss parsing some attributes
- if (!directiveAst) {
- continue
- }
-
- visit(directiveAst, {
- StringLiteral(node) {
- if (!node.value) return
- attr.value =
- attr.value.slice(0, node.start + 1) +
- sortClasses(node.value, { env }) +
- attr.value.slice(node.end - 1)
- },
+ let didChange = false
+
+ astTypes.visit(ast, {
+ visitLiteral(path) {
+ if (isStringLiteral(path.node)) {
+ if (sortStringLiteral(path.node, { env })) {
+ didChange = true
+
+ // https://github.com/benjamn/recast/issues/171#issuecomment-224996336
+ // @ts-ignore
+ let quote = path.node.extra.raw[0]
+ let value = jsesc(path.node.value, {
+ quotes: quote === "'" ? 'single' : 'double',
})
- continue
+ // @ts-ignore
+ path.node.value = new String(quote + value + quote)
}
+ }
+ this.traverse(path)
+ },
- let ast = recast.parse(`let __prettier_temp__ = ${attr.value}`, {
- parser: prettierParserBabel.parsers['babel-ts'],
- })
- let didChange = false
-
- astTypes.visit(ast, {
- visitLiteral(path) {
- if (isStringLiteral(path.node)) {
- if (sortStringLiteral(path.node, { env })) {
- didChange = true
-
- // https://github.com/benjamn/recast/issues/171#issuecomment-224996336
- let quote = path.node.extra.raw[0]
- let value = jsesc(path.node.value, {
- quotes: quote === "'" ? 'single' : 'double',
- })
- path.node.value = new String(quote + value + quote)
- }
- }
- this.traverse(path)
- },
- visitTemplateLiteral(path) {
- if (sortTemplateLiteral(path.node, { env })) {
- didChange = true
- }
- this.traverse(path)
- },
- })
+ visitTemplateLiteral(path) {
+ if (sortTemplateLiteral(path.node, { env })) {
+ didChange = true
+ }
+ this.traverse(path)
+ },
- if (didChange) {
- attr.value = recast.print(
- ast.program.body[0].declarations[0].init,
- ).code
+ visitTaggedTemplateExpression(path) {
+ if (
+ path.node.tag.type === 'Identifier' &&
+ functions.has(path.node.tag.name)
+ ) {
+ if (sortTemplateLiteral(path.node.quasi, { env })) {
+ didChange = true
}
}
- }
+ this.traverse(path)
+ },
+ })
- for (let child of ast.children ?? []) {
- transform(child, { env })
+ if (didChange) {
+ attr.value = recast.print(ast.program.body[0].declarations[0].init).code
+ }
+}
+
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
+function transformHtml(ast, { env, changes }) {
+ let { staticAttrs, dynamicAttrs } = env.customizations
+ let { parser } = env.options
+
+ for (let attr of ast.attrs ?? []) {
+ if (staticAttrs.has(attr.name)) {
+ attr.value = sortClasses(attr.value, { env })
+ } else if (dynamicAttrs.has(attr.name)) {
+ if (!/[`'"]/.test(attr.value)) {
+ continue
+ }
+
+ if (parser === 'angular') {
+ transformDynamicAngularAttribute(attr, env)
+ } else {
+ transformDynamicJsAttribute(attr, env)
+ }
}
}
- return transform
+
+ for (let child of ast.children ?? []) {
+ transformHtml(child, { env, changes })
+ }
}
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
function transformGlimmer(ast, { env }) {
+ let { staticAttrs } = env.customizations
+
visit(ast, {
AttrNode(attr, parent, key, index, meta) {
- let attributes = ['class']
-
- if (attributes.includes(attr.name) && attr.value) {
+ if (staticAttrs.has(attr.name) && attr.value) {
meta.sortTextNodes = true
}
},
@@ -195,12 +264,20 @@ function transformGlimmer(ast, { env }) {
})
}
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
function transformLiquid(ast, { env }) {
+ let { staticAttrs } = env.customizations
+
/** @param {{name: string | {type: string, value: string}[]}} node */
function isClassAttr(node) {
return Array.isArray(node.name)
- ? node.name.every((n) => n.type === 'TextNode' && n.value === 'class')
- : node.name === 'class'
+ ? node.name.every(
+ (n) => n.type === 'TextNode' && staticAttrs.has(n.value),
+ )
+ : staticAttrs.has(node.name)
}
/**
@@ -349,29 +426,63 @@ function sortTemplateLiteral(node, { env }) {
return didChange
}
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
function transformJavaScript(ast, { env }) {
+ let { staticAttrs, functions } = env.customizations
+
+ function sortInside(ast) {
+ visit(ast, (node) => {
+ if (isStringLiteral(node)) {
+ sortStringLiteral(node, { env })
+ } else if (node.type === 'TemplateLiteral') {
+ sortTemplateLiteral(node, { env })
+ } else if (node.type === 'TaggedTemplateExpression') {
+ if (node.tag.type === 'Identifier' && functions.has(node.tag.name)) {
+ sortTemplateLiteral(node.quasi, { env })
+ }
+ }
+ })
+ }
+
visit(ast, {
JSXAttribute(node) {
if (!node.value) {
return
}
- if (['class', 'className'].includes(node.name.name)) {
- if (isStringLiteral(node.value)) {
- sortStringLiteral(node.value, { env })
- } else if (node.value.type === 'JSXExpressionContainer') {
- visit(node.value, (node, parent, key) => {
- if (isStringLiteral(node)) {
- sortStringLiteral(node, { env })
- } else if (node.type === 'TemplateLiteral') {
- sortTemplateLiteral(node, { env })
- }
- })
- }
+
+ if (!staticAttrs.has(node.name.name)) {
+ return
+ }
+
+ if (isStringLiteral(node.value)) {
+ sortStringLiteral(node.value, { env })
+ } else if (node.value.type === 'JSXExpressionContainer') {
+ sortInside(node.value)
+ }
+ },
+
+ CallExpression(node) {
+ if (!node.arguments?.length) return
+ if (!functions.has(node.callee?.name ?? '')) return
+
+ node.arguments.forEach((arg) => sortInside(arg))
+ },
+
+ TaggedTemplateExpression(node) {
+ if (node.tag.type === 'Identifier' && functions.has(node.tag.name)) {
+ sortTemplateLiteral(node.quasi, { env })
}
},
})
}
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
function transformCss(ast, { env }) {
ast.walk((node) => {
if (node.type === 'css-atrule' && node.name === 'apply') {
@@ -383,96 +494,13 @@ function transformCss(ast, { env }) {
})
}
-export const options = {
- tailwindConfig: {
- type: 'string',
- category: 'Tailwind CSS',
- description: 'TODO',
- },
-}
-
-export const printers = {
- ...(base.printers['svelte-ast']
- ? {
- 'svelte-ast': {
- ...base.printers['svelte-ast'],
- print: (path, options, print) => {
- if (!options.__mutatedOriginalText) {
- options.__mutatedOriginalText = true
- let changes = path.stack[0].changes
- if (changes?.length) {
- let finder = lineColumn(options.originalText)
-
- for (let change of changes) {
- let start = finder.toIndex(
- change.loc.start.line,
- change.loc.start.column + 1,
- )
- let end = finder.toIndex(
- change.loc.end.line,
- change.loc.end.column + 1,
- )
-
- options.originalText =
- options.originalText.substring(0, start) +
- change.text +
- options.originalText.substring(end)
- }
- }
- }
-
- return base.printers['svelte-ast'].print(path, options, print)
- },
- },
- }
- : {}),
-}
-
-export const parsers = {
- html: createParser('html', transformHtml(['class'])),
- glimmer: createParser('glimmer', transformGlimmer),
- lwc: createParser('lwc', transformHtml(['class'])),
- angular: createParser(
- 'angular',
- transformHtml(['class'], ['[ngClass]'], 'angular'),
- ),
- vue: createParser('vue', transformHtml(['class'], [':class'])),
- css: createParser('css', transformCss),
- scss: createParser('scss', transformCss),
- less: createParser('less', transformCss),
- babel: createParser('babel', transformJavaScript),
- 'babel-flow': createParser('babel-flow', transformJavaScript),
- flow: createParser('flow', transformJavaScript),
- typescript: createParser('typescript', transformJavaScript),
- 'babel-ts': createParser('babel-ts', transformJavaScript),
- espree: createParser('espree', transformJavaScript),
- meriyah: createParser('meriyah', transformJavaScript),
- __js_expression: createParser('__js_expression', transformJavaScript),
- ...(base.parsers.svelte
- ? {
- svelte: createParser('svelte', (ast, { env }) => {
- let changes = []
- transformSvelte(ast.html, { env, changes })
- ast.changes = changes
- }),
- }
- : {}),
- ...(base.parsers.astro
- ? { astro: createParser('astro', transformAstro) }
- : {}),
- ...(base.parsers.marko
- ? { marko: createParser('marko', transformMarko) }
- : {}),
- ...(base.parsers.melody
- ? { melody: createParser('melody', transformMelody) }
- : {}),
- ...(base.parsers.pug ? { pug: createParser('pug', transformPug) } : {}),
- ...(base.parsers['liquid-html']
- ? { 'liquid-html': createParser('liquid-html', transformLiquid) }
- : {}),
-}
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
+function transformAstro(ast, { env, changes }) {
+ let { staticAttrs } = env.customizations
-function transformAstro(ast, { env }) {
if (
ast.type === 'element' ||
ast.type === 'custom-element' ||
@@ -480,7 +508,7 @@ function transformAstro(ast, { env }) {
) {
for (let attr of ast.attributes ?? []) {
if (
- attr.name === 'class' &&
+ staticAttrs.has(attr.name) &&
attr.type === 'attribute' &&
attr.kind === 'quoted'
) {
@@ -492,11 +520,17 @@ function transformAstro(ast, { env }) {
}
for (let child of ast.children ?? []) {
- transformAstro(child, { env })
+ transformAstro(child, { env, changes })
}
}
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
function transformMarko(ast, { env }) {
+ let { staticAttrs } = env.customizations
+
const nodesToVisit = [ast]
while (nodesToVisit.length > 0) {
const currentNode = nodesToVisit.pop()
@@ -515,38 +549,41 @@ function transformMarko(ast, { env }) {
nodesToVisit.push(...currentNode.body)
break
case 'MarkoAttribute':
- if (currentNode.name === 'class') {
- switch (currentNode.value.type) {
- case 'ArrayExpression':
- const classList = currentNode.value.elements
- for (const node of classList) {
- if (node.type === 'StringLiteral') {
- node.value = sortClasses(node.value, { env })
- }
+ if (!staticAttrs.has(currentNode.name)) break
+ switch (currentNode.value.type) {
+ case 'ArrayExpression':
+ const classList = currentNode.value.elements
+ for (const node of classList) {
+ if (node.type === 'StringLiteral') {
+ node.value = sortClasses(node.value, { env })
}
- break
- case 'StringLiteral':
- currentNode.value.value = sortClasses(currentNode.value.value, {
- env,
- })
- break
- }
+ }
+ break
+ case 'StringLiteral':
+ currentNode.value.value = sortClasses(currentNode.value.value, {
+ env,
+ })
+ break
}
break
}
}
}
-function transformMelody(ast, { env }) {
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
+function transformMelody(ast, { env, changes }) {
+ let { staticAttrs } = env.customizations
+
for (let child of ast.expressions ?? []) {
- transformMelody(child, { env })
+ transformMelody(child, { env, changes })
}
visit(ast, {
Attribute(node, _parent, _key, _index, meta) {
- if (node.name.name !== 'class') {
- return
- }
+ if (!staticAttrs.has(node.name.name)) return
meta.sortTextNodes = true
},
@@ -563,7 +600,13 @@ function transformMelody(ast, { env }) {
})
}
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
function transformPug(ast, { env }) {
+ let { staticAttrs } = env.customizations
+
// This isn't optimal
// We should merge the classes together across class attributes and class tokens
// And then we sort them
@@ -571,7 +614,7 @@ function transformPug(ast, { env }) {
// First sort the classes in attributes
for (const token of ast.tokens) {
- if (token.type === 'attribute' && token.name === 'class') {
+ if (token.type === 'attribute' && staticAttrs.has(token.name)) {
token.val = [
token.val.slice(0, 1),
sortClasses(token.val.slice(1, -1), { env }),
@@ -617,44 +660,51 @@ function transformPug(ast, { env }) {
}
}
+/**
+ * @param {any} ast
+ * @param {TransformerContext} param1
+ */
function transformSvelte(ast, { env, changes }) {
+ let { staticAttrs } = env.customizations
+
for (let attr of ast.attributes ?? []) {
- if (attr.name === 'class' && attr.type === 'Attribute') {
- for (let i = 0; i < attr.value.length; i++) {
- let value = attr.value[i]
- if (value.type === 'Text') {
- let same = value.raw === value.data
- value.raw = sortClasses(value.raw, {
- env,
- ignoreFirst: i > 0 && !/^\s/.test(value.raw),
- ignoreLast: i < attr.value.length - 1 && !/\s$/.test(value.raw),
- })
- value.data = same
- ? value.raw
- : sortClasses(value.data, {
- env,
- ignoreFirst: i > 0 && !/^\s/.test(value.data),
- ignoreLast:
- i < attr.value.length - 1 && !/\s$/.test(value.data),
- })
- } else if (value.type === 'MustacheTag') {
- visit(value.expression, {
- Literal(node) {
- if (isStringLiteral(node)) {
- if (sortStringLiteral(node, { env })) {
- changes.push({ text: node.raw, loc: node.loc })
- }
+ if (!staticAttrs.has(attr.name) || attr.type !== 'Attribute') {
+ continue
+ }
+
+ for (let i = 0; i < attr.value.length; i++) {
+ let value = attr.value[i]
+ if (value.type === 'Text') {
+ let same = value.raw === value.data
+ value.raw = sortClasses(value.raw, {
+ env,
+ ignoreFirst: i > 0 && !/^\s/.test(value.raw),
+ ignoreLast: i < attr.value.length - 1 && !/\s$/.test(value.raw),
+ })
+ value.data = same
+ ? value.raw
+ : sortClasses(value.data, {
+ env,
+ ignoreFirst: i > 0 && !/^\s/.test(value.data),
+ ignoreLast: i < attr.value.length - 1 && !/\s$/.test(value.data),
+ })
+ } else if (value.type === 'MustacheTag') {
+ visit(value.expression, {
+ Literal(node) {
+ if (isStringLiteral(node)) {
+ if (sortStringLiteral(node, { env })) {
+ changes.push({ text: node.raw, loc: node.loc })
}
- },
- TemplateLiteral(node) {
- if (sortTemplateLiteral(node, { env })) {
- for (let quasi of node.quasis) {
- changes.push({ text: quasi.value.raw, loc: quasi.loc })
- }
+ }
+ },
+ TemplateLiteral(node) {
+ if (sortTemplateLiteral(node, { env })) {
+ for (let quasi of node.quasis) {
+ changes.push({ text: quasi.value.raw, loc: quasi.loc })
}
- },
- })
- }
+ }
+ },
+ })
}
}
}
@@ -676,8 +726,153 @@ function transformSvelte(ast, { env, changes }) {
transformSvelte(child, { env, changes })
}
}
+
+ if (ast.html) {
+ transformSvelte(ast.html, { env, changes })
+ }
+}
+
+export { options } from './options.js'
+
+export const printers = {
+ ...(base.printers['svelte-ast']
+ ? {
+ 'svelte-ast': {
+ ...base.printers['svelte-ast'],
+ print: (path, options, print) => {
+ if (!options.__mutatedOriginalText) {
+ options.__mutatedOriginalText = true
+ let changes = path.stack[0].changes
+ if (changes?.length) {
+ let finder = lineColumn(options.originalText)
+
+ for (let change of changes) {
+ let start = finder.toIndex(
+ change.loc.start.line,
+ change.loc.start.column + 1,
+ )
+ let end = finder.toIndex(
+ change.loc.end.line,
+ change.loc.end.column + 1,
+ )
+
+ options.originalText =
+ options.originalText.substring(0, start) +
+ change.text +
+ options.originalText.substring(end)
+ }
+ }
+ }
+
+ return base.printers['svelte-ast'].print(path, options, print)
+ },
+ },
+ }
+ : {}),
+}
+
+export const parsers = {
+ html: createParser('html', transformHtml, {
+ staticAttrs: ['class'],
+ }),
+ glimmer: createParser('glimmer', transformGlimmer, {
+ staticAttrs: ['class'],
+ }),
+ lwc: createParser('lwc', transformHtml, {
+ staticAttrs: ['class'],
+ }),
+ angular: createParser('angular', transformHtml, {
+ staticAttrs: ['class'],
+ dynamicAttrs: ['[ngClass]'],
+ }),
+ vue: createParser('vue', transformHtml, {
+ staticAttrs: ['class'],
+ dynamicAttrs: [':class', 'v-bind:class'],
+ }),
+
+ css: createParser('css', transformCss),
+ scss: createParser('scss', transformCss),
+ less: createParser('less', transformCss),
+ babel: createParser('babel', transformJavaScript, {
+ staticAttrs: ['class', 'className'],
+ }),
+
+ 'babel-flow': createParser('babel-flow', transformJavaScript, {
+ staticAttrs: ['class', 'className'],
+ }),
+
+ flow: createParser('flow', transformJavaScript, {
+ staticAttrs: ['class', 'className'],
+ }),
+
+ typescript: createParser('typescript', transformJavaScript, {
+ staticAttrs: ['class', 'className'],
+ }),
+
+ 'babel-ts': createParser('babel-ts', transformJavaScript, {
+ staticAttrs: ['class', 'className'],
+ }),
+
+ espree: createParser('espree', transformJavaScript, {
+ staticAttrs: ['class', 'className'],
+ }),
+
+ meriyah: createParser('meriyah', transformJavaScript, {
+ staticAttrs: ['class', 'className'],
+ }),
+
+ __js_expression: createParser('__js_expression', transformJavaScript, {
+ staticAttrs: ['class', 'className'],
+ }),
+
+ ...(base.parsers.svelte
+ ? {
+ svelte: createParser('svelte', transformSvelte, {
+ staticAttrs: ['class'],
+ }),
+ }
+ : {}),
+ ...(base.parsers.astro
+ ? {
+ astro: createParser('astro', transformAstro, {
+ staticAttrs: ['class'],
+ }),
+ }
+ : {}),
+ ...(base.parsers.marko
+ ? {
+ marko: createParser('marko', transformMarko, {
+ staticAttrs: ['class'],
+ }),
+ }
+ : {}),
+ ...(base.parsers.melody
+ ? {
+ melody: createParser('melody', transformMelody, {
+ staticAttrs: ['class'],
+ }),
+ }
+ : {}),
+ ...(base.parsers.pug
+ ? {
+ pug: createParser('pug', transformPug, {
+ staticAttrs: ['class'],
+ }),
+ }
+ : {}),
+ ...(base.parsers['liquid-html']
+ ? {
+ 'liquid-html': createParser('liquid-html', transformLiquid, {
+ staticAttrs: ['class'],
+ }),
+ }
+ : {}),
}
+/**
+ *
+ * @returns {{parsers: Record>, printers: Record>}}
+ */
function getBasePlugins() {
return {
parsers: {
diff --git a/src/options.js b/src/options.js
new file mode 100644
index 0000000..b94a0d2
--- /dev/null
+++ b/src/options.js
@@ -0,0 +1,87 @@
+/** @type {Record} */
+export const options = {
+ tailwindConfig: {
+ since: '0.0.0',
+ type: 'string',
+ category: 'Tailwind CSS',
+ description: 'Path to Tailwind configuration file',
+ },
+ tailwindAttributes: {
+ since: '0.3.0',
+ type: 'string',
+ array: true,
+ default: [{ value: [] }],
+ category: 'Tailwind CSS',
+ description:
+ 'List of attributes/props that contain sortable Tailwind classes',
+ },
+ tailwindFunctions: {
+ since: '0.3.0',
+ type: 'string',
+ array: true,
+ default: [{ value: [] }],
+ category: 'Tailwind CSS',
+ description:
+ 'List of functions and tagged templates that contain sortable Tailwind classes',
+ },
+}
+
+/** @typedef {import('./types').PluginOptions} PluginOptions */
+/** @typedef {import('./types').Customizations} Customizations */
+
+/**
+ * @param {PluginOptions} options
+ * @param {string} parser
+ * @param {Customizations} defaults
+ * @returns {Customizations}
+ */
+export function getCustomizations(options, parser, defaults) {
+ /** @type {Set} */
+ let staticAttrs = new Set(defaults.staticAttrs)
+
+ /** @type {Set} */
+ let dynamicAttrs = new Set(defaults.dynamicAttrs)
+
+ /** @type {Set} */
+ let functions = new Set(defaults.functions)
+
+ // Create a list of "static" attributes
+ for (let attr of options.tailwindAttributes ?? []) {
+ if (parser === 'vue' && attr.startsWith(':')) {
+ staticAttrs.add(attr.slice(1))
+ } else if (parser === 'vue' && attr.startsWith('v-bind:')) {
+ staticAttrs.add(attr.slice(7))
+ } else if (parser === 'vue' && attr.startsWith('v-')) {
+ dynamicAttrs.add(attr)
+ } else if (
+ parser === 'angular' &&
+ attr.startsWith('[') &&
+ attr.endsWith(']')
+ ) {
+ staticAttrs.add(attr.slice(1, -1))
+ } else {
+ staticAttrs.add(attr)
+ }
+ }
+
+ // Generate a list of dynamic attributes
+ for (let attr of staticAttrs) {
+ if (parser === 'vue') {
+ dynamicAttrs.add(`:${attr.name}`)
+ dynamicAttrs.add(`v-bind:${attr.name}`)
+ } else if (parser === 'angular') {
+ dynamicAttrs.add(`[${attr.name}]`)
+ }
+ }
+
+ // Generate a list of supported functions
+ for (let fn of options.tailwindFunctions ?? []) {
+ functions.add(fn)
+ }
+
+ return {
+ functions,
+ staticAttrs,
+ dynamicAttrs,
+ }
+}
diff --git a/src/types.d.ts b/src/types.d.ts
new file mode 100644
index 0000000..2801076
--- /dev/null
+++ b/src/types.d.ts
@@ -0,0 +1,45 @@
+import { ParserOptions, Printer } from 'prettier'
+
+export interface TransformerMetadata {
+ // Default customizations for a given transformer
+ staticAttrs?: string[]
+ dynamicAttrs?: string[]
+ functions?: string[]
+}
+
+export interface Customizations {
+ functions: Set
+ staticAttrs: Set
+ dynamicAttrs: Set
+}
+
+export interface TransformerContext {
+ env: TransformerEnv
+ changes: { text: string; loc: any }[]
+}
+
+export interface TransformerEnv {
+ context: any
+ customizations: Customizations
+ generateRules: () => any
+ parsers: any
+ options: any
+}
+
+export interface RawOptions {
+ tailwindConfig?: string
+ tailwindFunctions?: string[]
+ tailwindAttributes?: string[]
+}
+
+export interface InternalOptions {
+ printer: Printer
+}
+
+export interface ContextContainer {
+ context: any
+ generateRules: () => any
+ tailwindConfig: any
+}
+
+export type PluginOptions = ParserOptions & RawOptions & InternalOptions
diff --git a/tests/fixtures/custom-jsx/index.jsx b/tests/fixtures/custom-jsx/index.jsx
new file mode 100644
index 0000000..253f151
--- /dev/null
+++ b/tests/fixtures/custom-jsx/index.jsx
@@ -0,0 +1,11 @@
+const a = sortMeFn("sm:p-1 p-2");
+const b = sortMeFn({
+ foo: "sm:p-1 p-2",
+});
+
+const c = dontSortFn("sm:p-1 p-2");
+const d = sortMeTemplate`sm:p-1 p-2`;
+const e = dontSortMeTemplate`sm:p-1 p-2`;
+
+const A = (props) => ;
+const B = () => ;
diff --git a/tests/fixtures/custom-jsx/prettier.config.js b/tests/fixtures/custom-jsx/prettier.config.js
new file mode 100644
index 0000000..3703eb8
--- /dev/null
+++ b/tests/fixtures/custom-jsx/prettier.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ tailwindFunctions: ['sortMeFn', 'sortMeTemplate'],
+ tailwindAttributes: ['sortMe'],
+};
diff --git a/tests/fixtures/custom-jsx/tailwind.config.js b/tests/fixtures/custom-jsx/tailwind.config.js
new file mode 100644
index 0000000..1fb3b37
--- /dev/null
+++ b/tests/fixtures/custom-jsx/tailwind.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ theme: {},
+}
diff --git a/tests/fixtures/custom-vue/index.vue b/tests/fixtures/custom-vue/index.vue
new file mode 100644
index 0000000..e1a3b78
--- /dev/null
+++ b/tests/fixtures/custom-vue/index.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/tests/fixtures/custom-vue/prettier.config.js b/tests/fixtures/custom-vue/prettier.config.js
new file mode 100644
index 0000000..3703eb8
--- /dev/null
+++ b/tests/fixtures/custom-vue/prettier.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ tailwindFunctions: ['sortMeFn', 'sortMeTemplate'],
+ tailwindAttributes: ['sortMe'],
+};
diff --git a/tests/fixtures/custom-vue/tailwind.config.js b/tests/fixtures/custom-vue/tailwind.config.js
new file mode 100644
index 0000000..1fb3b37
--- /dev/null
+++ b/tests/fixtures/custom-vue/tailwind.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ theme: {},
+}
diff --git a/tests/test.js b/tests/test.js
index 5a8ec02..41c52ff 100644
--- a/tests/test.js
+++ b/tests/test.js
@@ -1,8 +1,10 @@
const prettier = require('prettier')
const path = require('path')
const fs = require('fs')
-const { execSync } = require('child_process')
+const { exec } = require('child_process')
const { t, yes, no } = require('./utils')
+const { promisify } = require('util')
+const execAsync = promisify(exec)
function format(str, options = {}) {
options.plugins = options.plugins ?? [
@@ -25,15 +27,16 @@ function format(str, options = {}) {
.trim()
}
-function formatFixture(name) {
+async function formatFixture(name, extension) {
let binPath = path.resolve(__dirname, '../node_modules/.bin/prettier')
- let filePath = path.resolve(__dirname, `fixtures/${name}/index.html`)
+ let filePath = path.resolve(__dirname, `fixtures/${name}/index.${extension}`)
+
let cmd = `${binPath} ${filePath} --plugin-search-dir ${__dirname} --plugin ${path.resolve(
__dirname,
'..',
)}`
- return execSync(cmd).toString().trim()
+ return execAsync(cmd).then(({ stdout }) => stdout.trim())
}
let html = [
@@ -329,6 +332,38 @@ let fixtures = [
dir: 'plugins',
output: '',
},
+ {
+ name: 'customizations: js/jsx',
+ dir: 'custom-jsx',
+ ext: 'jsx',
+ output: `const a = sortMeFn("p-2 sm:p-1");
+const b = sortMeFn({
+ foo: "p-2 sm:p-1",
+});
+
+const c = dontSortFn("sm:p-1 p-2");
+const d = sortMeTemplate\`p-2 sm:p-1\`;
+const e = dontSortMeTemplate\`sm:p-1 p-2\`;
+
+const A = (props) => ;
+const B = () => ;`,
+ },
+ {
+ name: 'customizations: vue',
+ dir: 'custom-vue',
+ ext: 'vue',
+ output: `
+
+
+
+`,
+ },
]
describe('parsers', () => {
@@ -379,12 +414,18 @@ describe('fixtures', () => {
]
// Temporarily move config files out of the way so they don't interfere with the tests
- beforeAll(() => configs.forEach(({ from, to }) => fs.renameSync(from, to)))
- afterAll(() => configs.forEach(({ from, to }) => fs.renameSync(to, from)))
+ beforeAll(() =>
+ Promise.all(configs.map(({ from, to }) => fs.promises.rename(from, to))),
+ )
+
+ afterAll(() =>
+ Promise.all(configs.map(({ from, to }) => fs.promises.rename(to, from))),
+ )
for (const fixture of fixtures) {
- test(fixture.name, () => {
- expect(formatFixture(fixture.dir)).toEqual(fixture.output)
+ test(fixture.name, async () => {
+ let formatted = await formatFixture(fixture.dir, fixture.ext ?? 'html')
+ expect(formatted).toEqual(fixture.output)
})
}
})