From c3cf4c561821ec226656840907db787ab31d3edd Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 15:23:56 -0500 Subject: [PATCH 01/14] Update types --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index e0802d77..7eb6ba25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,7 +13,7 @@ import { loadV4 } from './versions/v4' let pathToApiMap = expiringMap>(10_000) -export async function getTailwindConfig(options: ParserOptions): Promise { +export async function getTailwindConfig(options: ParserOptions): Promise { let cwd = process.cwd() // Locate the file being processed From dd48bcfd78140fde96a421467bc105149eca2bb8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 13:23:27 -0500 Subject: [PATCH 02/14] Handle CSS imports when running tests --- vitest.config.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index d94857a4..9b6b73ab 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,18 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { testTimeout: 10000, + css: true, }, + + plugins: [ + { + name: 'force-inline-css', + enforce: 'pre', + resolveId(id) { + if (!id.endsWith('.css')) return + if (id.includes('?raw')) return + return this.resolve(`${id}?raw`) + }, + }, + ], }) From 3c5f6965961957d701ccd8a120400987b7cc28b0 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 13:26:25 -0500 Subject: [PATCH 03/14] Remove old `pluginSearchDirs` setting It stopped being used with Prettier v3 --- tests/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/utils.ts b/tests/utils.ts index 1e3ec5ab..5a638382 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -41,7 +41,6 @@ export let pluginPath = path.resolve(__dirname, '../dist/index.mjs') export async function format(str: string, options: prettier.Options = {}) { let result = await prettier.format(str, { - pluginSearchDirs: [__dirname], // disable plugin autoload semi: false, singleQuote: true, printWidth: 9999, From dc0a80fe4b0c6f786299b74050032ca03cdb3e1b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 13:26:56 -0500 Subject: [PATCH 04/14] Rerun tests when editing source files --- tests/utils.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/utils.ts b/tests/utils.ts index 5a638382..164b6981 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -40,13 +40,20 @@ export function t(strings: TemplateStringsArray, ...values: string[]): TestEntry export let pluginPath = path.resolve(__dirname, '../dist/index.mjs') export async function format(str: string, options: prettier.Options = {}) { + let plugin: prettier.Plugin = (await import('../src/index.ts')) as any + let result = await prettier.format(str, { semi: false, singleQuote: true, printWidth: 9999, parser: 'html', ...options, - plugins: [...(options.plugins ?? []), pluginPath], + plugins: [ + // + ...(options.plugins ?? []), + // plugin, + pluginPath, + ], }) return result.trim() From e9a4a69e286e66734159977cd89b62f7b1bc5fd2 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 13:27:37 -0500 Subject: [PATCH 05/14] Bump minimum Prettier version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bab476a8..c30d3e60 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", - "prettier": "^3.0", + "prettier": "^3.6.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", From ae29eb2c9cb2df5ed715737f182d0b3e7e7f2f6c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 15:23:57 -0500 Subject: [PATCH 06/14] Refactor sorting code --- src/index.ts | 5 ++-- src/sorting.ts | 71 +++++++++++++++++++++++++------------------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6d29d2ba..2e4bf2d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -961,8 +961,9 @@ function transformPug(ast: any, { env }: TransformerContext) { for (const [startIdx, endIdx] of ranges) { const classes = ast.tokens.slice(startIdx, endIdx + 1).map((token: any) => token.val) - const { classList } = sortClassList(classes, { - env, + const { classList } = sortClassList({ + classList: classes, + api: env.context, removeDuplicates: false, }) diff --git a/src/sorting.ts b/src/sorting.ts index f11ad33c..ecceb504 100644 --- a/src/sorting.ts +++ b/src/sorting.ts @@ -1,19 +1,11 @@ -import type { TransformerEnv } from './types' +import type { TransformerEnv, UnifiedApi } from './types' import { bigSign } from './utils' -function reorderClasses(classList: string[], { env }: { env: TransformerEnv }) { - let orderedClasses = env.context.getClassOrder(classList) - - return orderedClasses.sort(([nameA, a], [nameZ, z]) => { - // Move `...` to the end of the list - if (nameA === '...' || nameA === '…') return 1 - if (nameZ === '...' || nameZ === '…') return -1 - - if (a === z) return 0 - if (a === null) return -1 - if (z === null) return 1 - return bigSign(a - z) - }) +export interface SortOptions { + ignoreFirst?: boolean + ignoreLast?: boolean + removeDuplicates?: boolean + collapseWhitespace?: false | { start: boolean; end: boolean } } export function sortClasses( @@ -24,12 +16,8 @@ export function sortClasses( ignoreLast = false, removeDuplicates = true, collapseWhitespace = { start: true, end: true }, - }: { + }: SortOptions & { env: TransformerEnv - ignoreFirst?: boolean - ignoreLast?: boolean - removeDuplicates?: boolean - collapseWhitespace?: false | { start: boolean; end: boolean } }, ): string { if (typeof classStr !== 'string' || classStr === '') { @@ -46,6 +34,10 @@ export function sortClasses( collapseWhitespace = false } + if (env.options.tailwindPreserveDuplicates) { + removeDuplicates = false + } + // This class list is purely whitespace // Collapse it to a single space if the option is enabled if (/^[\t\r\f\n ]+$/.test(classStr) && collapseWhitespace) { @@ -75,8 +67,9 @@ export function sortClasses( suffix = `${whitespace.pop() ?? ''}${classes.pop() ?? ''}` } - let { classList, removedIndices } = sortClassList(classes, { - env, + let { classList, removedIndices } = sortClassList({ + classList: classes, + api: env.context, removeDuplicates, }) @@ -99,24 +92,30 @@ export function sortClasses( return prefix + result + suffix } -export function sortClassList( - classList: string[], - { - env, - removeDuplicates, - }: { - env: TransformerEnv - removeDuplicates: boolean - }, -) { +export function sortClassList({ + classList, + api, + removeDuplicates, +}: { + classList: string[] + api: UnifiedApi + removeDuplicates: boolean +}) { // Re-order classes based on the Tailwind CSS configuration - let orderedClasses = reorderClasses(classList, { env }) + let orderedClasses = api.getClassOrder(classList) - // Remove duplicate Tailwind classes - if (env.options.tailwindPreserveDuplicates) { - removeDuplicates = false - } + orderedClasses.sort(([nameA, a], [nameZ, z]) => { + // Move `...` to the end of the list + if (nameA === '...' || nameA === '…') return 1 + if (nameZ === '...' || nameZ === '…') return -1 + + if (a === z) return 0 + if (a === null) return -1 + if (z === null) return 1 + return bigSign(a - z) + }) + // Remove duplicate Tailwind classes let removedIndices = new Set() if (removeDuplicates) { From a298179a013338fa2fb13cfd297c5dc4edb456a3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 14:30:57 -0500 Subject: [PATCH 07/14] Combine `TransformerContext` and `TransformerEnv` --- src/index.ts | 55 +++++++++++++++++++++++++++------------------------- src/types.ts | 6 +----- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2e4bf2d8..7da9a357 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { getTailwindConfig } from './config.js' import { createMatcher, type Matcher } from './options.js' import { loadPlugins } from './plugins.js' import { sortClasses, sortClassList } from './sorting.js' -import type { Customizations, StringChange, TransformerContext, TransformerEnv, TransformerMetadata } from './types' +import type { Customizations, StringChange, TransformerEnv, TransformerMetadata } from './types' import { spliceChangesIntoString, visit, type Path } from './utils.js' let base = await loadPlugins() @@ -23,7 +23,7 @@ const ESCAPE_SEQUENCE_PATTERN = /\\(['"\\nrtbfv0-7xuU])/g function createParser( parserFormat: string, - transform: (ast: any, context: TransformerContext) => void, + transform: (ast: any, env: TransformerEnv) => void, meta: TransformerMetadata = {}, ) { let customizationDefaults: Customizations = { @@ -54,15 +54,18 @@ function createParser( let matcher = createMatcher(options, parserFormat, customizationDefaults) - let changes: any[] = [] + let env: TransformerEnv = { + context, + matcher, + parsers: {}, + options, + changes: [], + } - transform(ast, { - env: { context, matcher, parsers: {}, options }, - changes, - }) + transform(ast, env) if (parserFormat === 'svelte') { - ast.changes = changes + ast.changes = env.changes } return ast @@ -271,7 +274,7 @@ function transformDynamicJsAttribute(attr: any, env: TransformerEnv) { } } -function transformHtml(ast: any, { env, changes }: TransformerContext) { +function transformHtml(ast: any, env: TransformerEnv) { let { matcher } = env let { parser } = env.options @@ -292,11 +295,11 @@ function transformHtml(ast: any, { env, changes }: TransformerContext) { } for (let child of ast.children ?? []) { - transformHtml(child, { env, changes }) + transformHtml(child, env) } } -function transformGlimmer(ast: any, { env }: TransformerContext) { +function transformGlimmer(ast: any, env: TransformerEnv) { let { matcher } = env visit(ast, { @@ -352,7 +355,7 @@ function transformGlimmer(ast: any, { env }: TransformerContext) { }) } -function transformLiquid(ast: any, { env }: TransformerContext) { +function transformLiquid(ast: any, env: TransformerEnv) { let { matcher } = env function isClassAttr(node: { name: string | { type: string; value: string }[] }) { @@ -651,7 +654,7 @@ function canCollapseWhitespaceIn(path: Path) { // // We cross several parsers that share roughly the same shape so things are // good enough. The actual AST we should be using is probably estree + ts. -function transformJavaScript(ast: import('@babel/types').Node, { env }: TransformerContext) { +function transformJavaScript(ast: import('@babel/types').Node, env: TransformerEnv) { let { matcher } = env function sortInside(ast: import('@babel/types').Node) { @@ -722,7 +725,7 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor }) } -function transformCss(ast: any, { env }: TransformerContext) { +function transformCss(ast: any, env: TransformerEnv) { // `parseValue` inside Prettier's CSS parser is private API so we have to // produce the same result by parsing an import statement with the same params function tryParseAtRuleParams(name: string, params: any) { @@ -790,7 +793,7 @@ function transformCss(ast: any, { env }: TransformerContext) { }) } -function transformAstro(ast: any, { env, changes }: TransformerContext) { +function transformAstro(ast: any, env: TransformerEnv) { let { matcher } = env if (ast.type === 'element' || ast.type === 'custom-element' || ast.type === 'component') { @@ -811,11 +814,11 @@ function transformAstro(ast: any, { env, changes }: TransformerContext) { } for (let child of ast.children ?? []) { - transformAstro(child, { env, changes }) + transformAstro(child, env) } } -function transformMarko(ast: any, { env }: TransformerContext) { +function transformMarko(ast: any, env: TransformerEnv) { let { matcher } = env const nodesToVisit = [ast] @@ -857,11 +860,11 @@ function transformMarko(ast: any, { env }: TransformerContext) { } } -function transformTwig(ast: any, { env, changes }: TransformerContext) { +function transformTwig(ast: any, env: TransformerEnv) { let { matcher } = env for (let child of ast.expressions ?? []) { - transformTwig(child, { env, changes }) + transformTwig(child, env) } visit(ast, { @@ -918,7 +921,7 @@ function transformTwig(ast: any, { env, changes }: TransformerContext) { }) } -function transformPug(ast: any, { env }: TransformerContext) { +function transformPug(ast: any, env: TransformerEnv) { let { matcher } = env // This isn't optimal @@ -973,8 +976,8 @@ function transformPug(ast: any, { env }: TransformerContext) { } } -function transformSvelte(ast: any, { env, changes }: TransformerContext) { - let { matcher } = env +function transformSvelte(ast: any, env: TransformerEnv) { + let { matcher, changes } = env for (let attr of ast.attributes ?? []) { if (!matcher.hasStaticAttr(attr.name) || attr.type !== 'Attribute') { @@ -1047,12 +1050,12 @@ function transformSvelte(ast: any, { env, changes }: TransformerContext) { } for (let child of ast.children ?? []) { - transformSvelte(child, { env, changes }) + transformSvelte(child, env) } if (ast.type === 'IfBlock') { for (let child of ast.else?.children ?? []) { - transformSvelte(child, { env, changes }) + transformSvelte(child, env) } } @@ -1060,12 +1063,12 @@ function transformSvelte(ast: any, { env, changes }: TransformerContext) { let nodes = [ast.pending, ast.then, ast.catch] for (let child of nodes) { - transformSvelte(child, { env, changes }) + transformSvelte(child, env) } } if (ast.html) { - transformSvelte(ast.html, { env, changes }) + transformSvelte(ast.html, env) } } diff --git a/src/types.ts b/src/types.ts index cdefec88..3fcad226 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,11 +17,6 @@ export interface Customizations { functionsRegex: RegExp[] } -export interface TransformerContext { - env: TransformerEnv - changes: StringChange[] -} - export interface UnifiedApi { getClassOrder(classList: string[]): [string, bigint | null][] } @@ -31,6 +26,7 @@ export interface TransformerEnv { matcher: Matcher parsers: any options: ParserOptions + changes: StringChange[] } export interface StringChange { From bfca8ce524422771dc98290f663fe279d8569918 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 14:33:59 -0500 Subject: [PATCH 08/14] Refactor angular attribute parsing --- src/index.ts | 25 ++++++------------------- src/types.ts | 1 - 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7da9a357..dcb34a28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,7 +57,6 @@ function createParser( let env: TransformerEnv = { context, matcher, - parsers: {}, options, changes: [], } @@ -74,25 +73,13 @@ function createParser( } function tryParseAngularAttribute(value: string, env: TransformerEnv) { - let parsers = [ - // Try parsing as an angular directive - prettierParserAngular.parsers.__ng_directive, - - // If this fails we fall back to arbitrary parsing of a JS expression - { parse: env.parsers.__js_expression }, - ] - - let errors: unknown[] = [] - for (const parser of parsers) { - try { - return parser.parse(value, env.parsers, env.options) - } catch (err) { - errors.push(err) - } + try { + return prettierParserAngular.parsers.__ng_directive.parse(value, env.options) + } catch (err) { + console.warn('prettier-plugin-tailwindcss: Unable to parse angular directive') + console.warn(err) + return null } - - console.warn('prettier-plugin-tailwindcss: Unable to parse angular directive') - errors.forEach((err) => console.warn(err)) } function transformDynamicAngularAttribute(attr: any, env: TransformerEnv) { diff --git a/src/types.ts b/src/types.ts index 3fcad226..833b8fb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,7 +24,6 @@ export interface UnifiedApi { export interface TransformerEnv { context: UnifiedApi matcher: Matcher - parsers: any options: ParserOptions changes: StringChange[] } From 747c1863fca9ddfb17aff2e4fe5c9960919ac9a7 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 18:55:57 -0500 Subject: [PATCH 09/14] Refactor parser setup --- src/create-plugin.ts | 75 ++++++++++++ src/index.ts | 263 ++++++++++++++++--------------------------- src/transform.ts | 29 +++++ 3 files changed, 201 insertions(+), 166 deletions(-) create mode 100644 src/create-plugin.ts create mode 100644 src/transform.ts diff --git a/src/create-plugin.ts b/src/create-plugin.ts new file mode 100644 index 00000000..0c2b22e0 --- /dev/null +++ b/src/create-plugin.ts @@ -0,0 +1,75 @@ +import type { Parser, ParserOptions } from 'prettier' +import { getTailwindConfig } from './config' +import { createMatcher } from './options' +import type { loadPlugins } from './plugins' +import type { TransformOptions } from './transform' +import type { Customizations, TransformerEnv, TransformerMetadata } from './types' + +type Base = Awaited> + +export function createPlugin(base: Base, transforms: TransformOptions[]) { + let parsers: Record> = Object.create(null) + + for (let opts of transforms) { + for (let [name, meta] of Object.entries(opts.parsers)) { + parsers[name] = createParser(base, name, opts.transform, { + staticAttrs: meta.staticAttrs ?? [], + dynamicAttrs: meta.dynamicAttrs ?? [], + }) + } + } + + return { parsers } +} + +function createParser( + base: Base, + parserFormat: string, + transform: (ast: any, env: TransformerEnv) => void, + meta: TransformerMetadata = {}, +) { + let customizationDefaults: Customizations = { + staticAttrs: new Set(meta.staticAttrs ?? []), + dynamicAttrs: new Set(meta.dynamicAttrs ?? []), + functions: new Set(meta.functions ?? []), + staticAttrsRegex: [], + dynamicAttrsRegex: [], + functionsRegex: [], + } + + return { + ...base.parsers[parserFormat], + + preprocess(code: string, options: ParserOptions) { + let original = base.originalParser(parserFormat, options) + + return original.preprocess ? original.preprocess(code, options) : code + }, + + async parse(text: string, options: ParserOptions) { + let context = await getTailwindConfig(options) + + let original = base.originalParser(parserFormat, options) + + // @ts-ignore: We pass three options in the case of plugins that support Prettier 2 _and_ 3. + let ast = await original.parse(text, options, options) + + let matcher = createMatcher(options, parserFormat, customizationDefaults) + + let env: TransformerEnv = { + context, + matcher, + options, + changes: [], + } + + transform(ast, env) + + if (parserFormat === 'svelte') { + ast.changes = env.changes + } + + return ast + }, + } +} diff --git a/src/index.ts b/src/index.ts index dcb34a28..bb44d8e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,73 +5,22 @@ import * as astTypes from 'ast-types' import jsesc from 'jsesc' // @ts-ignore import lineColumn from 'line-column' -import type { Parser, ParserOptions, Printer } from 'prettier' +import type { Printer } from 'prettier' import * as prettierParserAngular from 'prettier/plugins/angular' import * as prettierParserBabel from 'prettier/plugins/babel' // @ts-ignore import * as recast from 'recast' -import { getTailwindConfig } from './config.js' -import { createMatcher, type Matcher } from './options.js' +import { createPlugin } from './create-plugin.js' +import type { Matcher } from './options.js' import { loadPlugins } from './plugins.js' import { sortClasses, sortClassList } from './sorting.js' -import type { Customizations, StringChange, TransformerEnv, TransformerMetadata } from './types' +import type { StringChange, TransformerEnv } from './types' import { spliceChangesIntoString, visit, type Path } from './utils.js' let base = await loadPlugins() const ESCAPE_SEQUENCE_PATTERN = /\\(['"\\nrtbfv0-7xuU])/g -function createParser( - parserFormat: string, - transform: (ast: any, env: TransformerEnv) => void, - meta: TransformerMetadata = {}, -) { - let customizationDefaults: Customizations = { - staticAttrs: new Set(meta.staticAttrs ?? []), - dynamicAttrs: new Set(meta.dynamicAttrs ?? []), - functions: new Set(meta.functions ?? []), - staticAttrsRegex: [], - dynamicAttrsRegex: [], - functionsRegex: [], - } - - return { - ...base.parsers[parserFormat], - - preprocess(code: string, options: ParserOptions) { - let original = base.originalParser(parserFormat, options) - - return original.preprocess ? original.preprocess(code, options) : code - }, - - async parse(text: string, options: ParserOptions) { - let context = await getTailwindConfig(options) - - let original = base.originalParser(parserFormat, options) - - // @ts-ignore: We pass three options in the case of plugins that support Prettier 2 _and_ 3. - let ast = await original.parse(text, options, options) - - let matcher = createMatcher(options, parserFormat, customizationDefaults) - - let env: TransformerEnv = { - context, - matcher, - options, - changes: [], - } - - transform(ast, env) - - if (parserFormat === 'svelte') { - ast.changes = env.changes - } - - return ast - }, - } -} - function tryParseAngularAttribute(value: string, env: TransformerEnv) { try { return prettierParserAngular.parsers.__ng_directive.parse(value, env.options) @@ -1116,123 +1065,105 @@ export const printers: Record = (function () { return printers })() -export const parsers: Record = { - 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'], - }), - - hermes: createParser('hermes', transformJavaScript, { - staticAttrs: ['class', 'className'], - }), - - typescript: createParser('typescript', transformJavaScript, { - staticAttrs: ['class', 'className'], - }), - - 'babel-ts': createParser('babel-ts', transformJavaScript, { - staticAttrs: ['class', 'className'], - }), - - oxc: createParser('oxc', transformJavaScript, { - staticAttrs: ['class', 'className'], - }), - 'oxc-ts': createParser('oxc-ts', transformJavaScript, { - staticAttrs: ['class', 'className'], - }), - - acorn: createParser('acorn', transformJavaScript, { - staticAttrs: ['class', 'className'], - }), - - meriyah: createParser('meriyah', transformJavaScript, { - staticAttrs: ['class', 'className'], - }), - - __js_expression: createParser('__js_expression', transformJavaScript, { - staticAttrs: ['class', 'className'], - }), - +export const { parsers } = createPlugin(base, [ + { + transform: transformHtml, + parsers: { + html: { staticAttrs: ['class'] }, + lwc: { staticAttrs: ['class'] }, + angular: { staticAttrs: ['class'], dynamicAttrs: ['[ngClass]'] }, + vue: { staticAttrs: ['class'], dynamicAttrs: [':class', 'v-bind:class'] }, + }, + }, + { + transform: transformGlimmer, + parsers: { + glimmer: { staticAttrs: ['class'] }, + }, + }, + { + transform: transformCss, + parsers: { + css: {}, + scss: {}, + less: {}, + }, + }, + { + transform: transformJavaScript, + parsers: { + babel: { staticAttrs: ['class', 'className'] }, + 'babel-flow': { staticAttrs: ['class', 'className'] }, + flow: { staticAttrs: ['class', 'className'] }, + hermes: { staticAttrs: ['class', 'className'] }, + typescript: { staticAttrs: ['class', 'className'] }, + 'babel-ts': { staticAttrs: ['class', 'className'] }, + oxc: { staticAttrs: ['class', 'className'] }, + 'oxc-ts': { staticAttrs: ['class', 'className'] }, + acorn: { staticAttrs: ['class', 'className'] }, + meriyah: { staticAttrs: ['class', 'className'] }, + __js_expression: { staticAttrs: ['class', 'className'] }, + ...(base.parsers.astroExpressionParser + ? { astroExpressionParser: { staticAttrs: ['class'], dynamicAttrs: ['class:list'] } } + : {}), + }, + }, ...(base.parsers.svelte - ? { - svelte: createParser('svelte', transformSvelte, { - staticAttrs: ['class'], - }), - } - : {}), + ? [ + { + transform: transformSvelte, + parsers: { + svelte: { staticAttrs: ['class'] }, + }, + }, + ] + : []), ...(base.parsers.astro - ? { - astro: createParser('astro', transformAstro, { - staticAttrs: ['class', 'className'], - dynamicAttrs: ['class:list', 'className'], - }), - } - : {}), - ...(base.parsers.astroExpressionParser - ? { - astroExpressionParser: createParser('astroExpressionParser', transformJavaScript, { - staticAttrs: ['class'], - dynamicAttrs: ['class:list'], - }), - } - : {}), + ? [ + { + transform: transformAstro, + parsers: { + astro: { + staticAttrs: ['class', 'className'], + dynamicAttrs: ['class:list', 'className'], + }, + }, + }, + ] + : []), ...(base.parsers.marko - ? { - marko: createParser('marko', transformMarko, { - staticAttrs: ['class'], - }), - } - : {}), + ? [ + { + transform: transformMarko, + parsers: { marko: { staticAttrs: ['class'] } }, + }, + ] + : []), ...(base.parsers.twig - ? { - twig: createParser('twig', transformTwig, { - staticAttrs: ['class'], - }), - } - : {}), + ? [ + { + transform: transformTwig, + parsers: { twig: { staticAttrs: ['class'] } }, + }, + ] + : []), ...(base.parsers.pug - ? { - pug: createParser('pug', transformPug, { - staticAttrs: ['class'], - }), - } - : {}), + ? [ + { + transform: transformPug, + parsers: { pug: { staticAttrs: ['class'] } }, + }, + ] + : []), ...(base.parsers['liquid-html'] - ? { - 'liquid-html': createParser('liquid-html', transformLiquid, { - staticAttrs: ['class'], - }), - } - : {}), -} + ? [ + { + transform: transformLiquid, + parsers: { 'liquid-html': { staticAttrs: ['class'] } }, + }, + ] + : []), +]) export interface PluginOptions { /** diff --git a/src/transform.ts b/src/transform.ts new file mode 100644 index 00000000..36b0fa7b --- /dev/null +++ b/src/transform.ts @@ -0,0 +1,29 @@ +import type { TransformerEnv } from './types' + +export interface TransformOptions { + /** + * A list of supported parser names + */ + parsers: Record< + string, + { + /** + * Static attributes that are supported by default + */ + staticAttrs?: string[] + + /** + * Dynamic / expression attributes that are supported by default + */ + dynamicAttrs?: string[] + } + > + + /** + * Transform entire ASTs + * + * @param ast The AST to transform + * @param env Provides options and mechanisms to sort classes + */ + transform(ast: T, env: TransformerEnv): void +} From d596fd14457a37a50f6da61cdda414fdbbc6b3d1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 16:17:47 -0500 Subject: [PATCH 10/14] Lift transform definitions --- src/index.ts | 193 ++++++++++++++++++++++++----------------------- src/transform.ts | 4 + 2 files changed, 104 insertions(+), 93 deletions(-) diff --git a/src/index.ts b/src/index.ts index bb44d8e8..086caf24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { createPlugin } from './create-plugin.js' import type { Matcher } from './options.js' import { loadPlugins } from './plugins.js' import { sortClasses, sortClassList } from './sorting.js' +import { defineTransform, type TransformOptions } from './transform.js' import type { StringChange, TransformerEnv } from './types' import { spliceChangesIntoString, visit, type Path } from './utils.js' @@ -1065,104 +1066,110 @@ export const printers: Record = (function () { return printers })() -export const { parsers } = createPlugin(base, [ - { - transform: transformHtml, - parsers: { - html: { staticAttrs: ['class'] }, - lwc: { staticAttrs: ['class'] }, - angular: { staticAttrs: ['class'], dynamicAttrs: ['[ngClass]'] }, - vue: { staticAttrs: ['class'], dynamicAttrs: [':class', 'v-bind:class'] }, - }, +let html = defineTransform({ + parsers: { + html: { staticAttrs: ['class'] }, + lwc: { staticAttrs: ['class'] }, + angular: { staticAttrs: ['class'], dynamicAttrs: ['[ngClass]'] }, + vue: { staticAttrs: ['class'], dynamicAttrs: [':class', 'v-bind:class'] }, }, - { - transform: transformGlimmer, - parsers: { - glimmer: { staticAttrs: ['class'] }, - }, + + transform: transformHtml, +}) + +let glimmer = defineTransform({ + parsers: { + glimmer: { staticAttrs: ['class'] }, }, - { - transform: transformCss, - parsers: { - css: {}, - scss: {}, - less: {}, - }, + + transform: transformGlimmer, +}) + +let css = defineTransform({ + parsers: { + css: {}, + scss: {}, + less: {}, }, - { - transform: transformJavaScript, - parsers: { - babel: { staticAttrs: ['class', 'className'] }, - 'babel-flow': { staticAttrs: ['class', 'className'] }, - flow: { staticAttrs: ['class', 'className'] }, - hermes: { staticAttrs: ['class', 'className'] }, - typescript: { staticAttrs: ['class', 'className'] }, - 'babel-ts': { staticAttrs: ['class', 'className'] }, - oxc: { staticAttrs: ['class', 'className'] }, - 'oxc-ts': { staticAttrs: ['class', 'className'] }, - acorn: { staticAttrs: ['class', 'className'] }, - meriyah: { staticAttrs: ['class', 'className'] }, - __js_expression: { staticAttrs: ['class', 'className'] }, - ...(base.parsers.astroExpressionParser - ? { astroExpressionParser: { staticAttrs: ['class'], dynamicAttrs: ['class:list'] } } - : {}), + + transform: transformCss, +}) + +let js = defineTransform({ + parsers: { + babel: { staticAttrs: ['class', 'className'] }, + 'babel-flow': { staticAttrs: ['class', 'className'] }, + flow: { staticAttrs: ['class', 'className'] }, + hermes: { staticAttrs: ['class', 'className'] }, + typescript: { staticAttrs: ['class', 'className'] }, + 'babel-ts': { staticAttrs: ['class', 'className'] }, + oxc: { staticAttrs: ['class', 'className'] }, + 'oxc-ts': { staticAttrs: ['class', 'className'] }, + acorn: { staticAttrs: ['class', 'className'] }, + meriyah: { staticAttrs: ['class', 'className'] }, + __js_expression: { staticAttrs: ['class', 'className'] }, + ...(base.parsers.astroExpressionParser + ? { astroExpressionParser: { staticAttrs: ['class'], dynamicAttrs: ['class:list'] } } + : {}), + }, + + transform: transformJavaScript, +}) + +let svelte = defineTransform({ + parsers: { + svelte: { staticAttrs: ['class'] }, + }, + + transform: transformSvelte, +}) + +let astro = defineTransform({ + parsers: { + astro: { + staticAttrs: ['class', 'className'], + dynamicAttrs: ['class:list', 'className'], }, }, - ...(base.parsers.svelte - ? [ - { - transform: transformSvelte, - parsers: { - svelte: { staticAttrs: ['class'] }, - }, - }, - ] - : []), - ...(base.parsers.astro - ? [ - { - transform: transformAstro, - parsers: { - astro: { - staticAttrs: ['class', 'className'], - dynamicAttrs: ['class:list', 'className'], - }, - }, - }, - ] - : []), - ...(base.parsers.marko - ? [ - { - transform: transformMarko, - parsers: { marko: { staticAttrs: ['class'] } }, - }, - ] - : []), - ...(base.parsers.twig - ? [ - { - transform: transformTwig, - parsers: { twig: { staticAttrs: ['class'] } }, - }, - ] - : []), - ...(base.parsers.pug - ? [ - { - transform: transformPug, - parsers: { pug: { staticAttrs: ['class'] } }, - }, - ] - : []), - ...(base.parsers['liquid-html'] - ? [ - { - transform: transformLiquid, - parsers: { 'liquid-html': { staticAttrs: ['class'] } }, - }, - ] - : []), + + transform: transformAstro, +}) + +let marko = defineTransform({ + parsers: { marko: { staticAttrs: ['class'] } }, + + transform: transformMarko, +}) + +let twig = defineTransform({ + parsers: { twig: { staticAttrs: ['class'] } }, + + transform: transformTwig, +}) + +let pug = defineTransform({ + parsers: { pug: { staticAttrs: ['class'] } }, + + transform: transformPug, +}) + +let liquid = defineTransform({ + parsers: { 'liquid-html': { staticAttrs: ['class'] } }, + + transform: transformLiquid, +}) + +export const { parsers } = createPlugin(base, [ + html, + glimmer, + css, + js, + ...(base.parsers.svelte ? [svelte] : []), + ...(base.parsers.astro ? [astro] : []), + ...(base.parsers.marko ? [marko] : []), + ...(base.parsers.twig ? [twig] : []), + ...(base.parsers.pug ? [pug] : []), + ...(base.parsers['liquid-html'] ? [liquid] : []), ]) export interface PluginOptions { diff --git a/src/transform.ts b/src/transform.ts index 36b0fa7b..afb5cfb7 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,5 +1,9 @@ import type { TransformerEnv } from './types' +export function defineTransform(opts: TransformOptions) { + return opts +} + export interface TransformOptions { /** * A list of supported parser names From 9a24025e0d77ece95ba672af8a1cc3d6292e19ec Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 16:35:13 -0500 Subject: [PATCH 11/14] Move Svelte AST printer into transform definition --- src/create-plugin.ts | 40 ++++++++++++++++++++-- src/index.ts | 80 +++++++++++++------------------------------- src/transform.ts | 14 ++++++++ 3 files changed, 75 insertions(+), 59 deletions(-) diff --git a/src/create-plugin.ts b/src/create-plugin.ts index 0c2b22e0..fb24a95b 100644 --- a/src/create-plugin.ts +++ b/src/create-plugin.ts @@ -1,4 +1,4 @@ -import type { Parser, ParserOptions } from 'prettier' +import type { AstPath, Parser, ParserOptions, Printer } from 'prettier' import { getTailwindConfig } from './config' import { createMatcher } from './options' import type { loadPlugins } from './plugins' @@ -9,6 +9,7 @@ type Base = Awaited> export function createPlugin(base: Base, transforms: TransformOptions[]) { let parsers: Record> = Object.create(null) + let printers: Record> = Object.create(null) for (let opts of transforms) { for (let [name, meta] of Object.entries(opts.parsers)) { @@ -17,9 +18,15 @@ export function createPlugin(base: Base, transforms: TransformOptions[]) { dynamicAttrs: meta.dynamicAttrs ?? [], }) } + + for (let [name, meta] of Object.entries(opts.printers ?? {})) { + if (!opts.reprint) continue + + printers[name] = createPrinter(base, name, opts.reprint) + } } - return { parsers } + return { parsers, printers } } function createParser( @@ -73,3 +80,32 @@ function createParser( }, } } + +function createPrinter( + base: Base, + name: string, + reprint: (path: AstPath, options: ParserOptions) => void, +): Printer { + let original = base.printers[name] + let printer = { ...original } + + printer.print = new Proxy(original.print, { + apply(target, thisArg, args) { + let [path, options] = args as Parameters + reprint(path, options) + return Reflect.apply(target, thisArg, args) + }, + }) + + if (original.embed) { + printer.embed = new Proxy(original.embed, { + apply(target, thisArg, args) { + let [path, options] = args as Parameters + reprint(path, options as any) + return Reflect.apply(target, thisArg, args) + }, + }) + } + + return printer +} diff --git a/src/index.ts b/src/index.ts index 086caf24..ce0a9d05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import * as astTypes from 'ast-types' import jsesc from 'jsesc' // @ts-ignore import lineColumn from 'line-column' -import type { Printer } from 'prettier' import * as prettierParserAngular from 'prettier/plugins/angular' import * as prettierParserBabel from 'prettier/plugins/babel' // @ts-ignore @@ -1011,61 +1010,6 @@ function transformSvelte(ast: any, env: TransformerEnv) { export { options } from './options.js' -export const printers: Record = (function () { - let printers: Record = {} - - if (base.printers['svelte-ast']) { - function mutateOriginalText(path: any, options: any) { - if (options.__mutatedOriginalText) { - return - } - - options.__mutatedOriginalText = true - - let changes: any[] = path.stack[0].changes - - if (changes?.length) { - let finder = lineColumn(options.originalText) - - changes = changes.map((change) => { - return { - ...change, - start: finder.toIndex(change.start.line, change.start.column + 1), - end: finder.toIndex(change.end.line, change.end.column + 1), - } - }) - - options.originalText = spliceChangesIntoString(options.originalText, changes) - } - } - - let original = base.printers['svelte-ast'] - let printer = { ...original } - - printer.print = new Proxy(original.print, { - apply(target, thisArg, args) { - let [path, options] = args as Parameters - mutateOriginalText(path, options) - return Reflect.apply(target, thisArg, args) - }, - }) - - if (original.embed) { - printer.embed = new Proxy(original.embed, { - apply(target, thisArg, args) { - let [path, options] = args as Parameters - mutateOriginalText(path, options) - return Reflect.apply(target, thisArg, args) - }, - }) - } - - printers['svelte-ast'] = printer - } - - return printers -})() - let html = defineTransform({ parsers: { html: { staticAttrs: ['class'] }, @@ -1121,7 +1065,29 @@ let svelte = defineTransform({ svelte: { staticAttrs: ['class'] }, }, + printers: { + 'svelte-ast': {}, + }, + transform: transformSvelte, + + reprint(path, options) { + if (options.__mutatedOriginalText) return + options.__mutatedOriginalText = true + + let changes: any[] = path.stack[0].changes + if (!changes?.length) return + + let finder = lineColumn(options.originalText) + + changes = changes.map((change) => ({ + ...change, + start: finder.toIndex(change.start.line, change.start.column + 1), + end: finder.toIndex(change.end.line, change.end.column + 1), + })) + + options.originalText = spliceChangesIntoString(options.originalText, changes) + }, }) let astro = defineTransform({ @@ -1159,7 +1125,7 @@ let liquid = defineTransform({ transform: transformLiquid, }) -export const { parsers } = createPlugin(base, [ +export const { parsers, printers } = createPlugin(base, [ html, glimmer, css, diff --git a/src/transform.ts b/src/transform.ts index afb5cfb7..3454c4bf 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,3 +1,4 @@ +import type { AstPath, ParserOptions } from 'prettier' import type { TransformerEnv } from './types' export function defineTransform(opts: TransformOptions) { @@ -23,6 +24,11 @@ export interface TransformOptions { } > + /** + * A list of supported parser names + */ + printers?: Record + /** * Transform entire ASTs * @@ -30,4 +36,12 @@ export interface TransformOptions { * @param env Provides options and mechanisms to sort classes */ transform(ast: T, env: TransformerEnv): void + + /** + * Transform entire ASTs + * + * @param ast The AST to transform + * @param env Provides options and mechanisms to sort classes + */ + reprint?(path: AstPath, options: ParserOptions): void } From 493aeeb3cfba395f64f3153d5cf08b584b9a0a9b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 16:51:31 -0500 Subject: [PATCH 12/14] Add types --- src/index.ts | 98 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index ce0a9d05..e54660a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ // @ts-ignore -import type { AttrDoubleQuoted, AttrSingleQuoted } from '@shopify/prettier-plugin-liquid/dist/types.js' +import type * as Liquid from '@shopify/prettier-plugin-liquid/dist/types.js' import * as astTypes from 'ast-types' // @ts-ignore import jsesc from 'jsesc' @@ -311,7 +311,7 @@ function transformLiquid(ast: any, env: TransformerEnv) { let changes: StringChange[] = [] - function sortAttribute(attr: AttrSingleQuoted | AttrDoubleQuoted) { + function sortAttribute(attr: Liquid.AttrSingleQuoted | Liquid.AttrDoubleQuoted) { for (let i = 0; i < attr.value.length; i++) { let node = attr.value[i] if (node.type === 'TextNode') { @@ -1010,7 +1010,9 @@ function transformSvelte(ast: any, env: TransformerEnv) { export { options } from './options.js' -let html = defineTransform({ +type HtmlNode = { type: 'attribute'; name: string; value: string } | { kind: 'attribute'; name: string; value: string } + +let html = defineTransform({ parsers: { html: { staticAttrs: ['class'] }, lwc: { staticAttrs: ['class'] }, @@ -1021,7 +1023,14 @@ let html = defineTransform({ transform: transformHtml, }) -let glimmer = defineTransform({ +type GlimmerNode = + | { type: 'TextNode'; chars: string } + | { type: 'StringLiteral'; value: string } + | { type: 'ConcatStatement'; parts: GlimmerNode[] } + | { type: 'SubExpression'; path: { original: string } } + | { type: 'AttrNode'; name: string; value: GlimmerNode } + +let glimmer = defineTransform({ parsers: { glimmer: { staticAttrs: ['class'] }, }, @@ -1029,7 +1038,14 @@ let glimmer = defineTransform({ transform: transformGlimmer, }) -let css = defineTransform({ +type CssValueNode = { type: 'value-*'; name: string; params: string } +type CssNode = { + type: 'css-atrule' + name: string + params: string | CssValueNode +} + +let css = defineTransform({ parsers: { css: {}, scss: {}, @@ -1039,7 +1055,7 @@ let css = defineTransform({ transform: transformCss, }) -let js = defineTransform({ +let js = defineTransform({ parsers: { babel: { staticAttrs: ['class', 'className'] }, 'babel-flow': { staticAttrs: ['class', 'className'] }, @@ -1053,14 +1069,23 @@ let js = defineTransform({ meriyah: { staticAttrs: ['class', 'className'] }, __js_expression: { staticAttrs: ['class', 'className'] }, ...(base.parsers.astroExpressionParser - ? { astroExpressionParser: { staticAttrs: ['class'], dynamicAttrs: ['class:list'] } } + ? { + astroExpressionParser: { + staticAttrs: ['class'], + dynamicAttrs: ['class:list'], + }, + } : {}), }, transform: transformJavaScript, }) -let svelte = defineTransform({ +type SvelteNode = import('svelte/compiler').AST.SvelteNode & { + changes: StringChange[] +} + +let svelte = defineTransform({ parsers: { svelte: { staticAttrs: ['class'] }, }, @@ -1090,7 +1115,20 @@ let svelte = defineTransform({ }, }) -let astro = defineTransform({ +type AstroNode = + | { type: 'element'; attributes: Extract[] } + | { + type: 'custom-element' + attributes: Extract[] + } + | { + type: 'component' + attributes: Extract[] + } + | { type: 'attribute'; kind: 'quoted'; name: string; value: string } + | { type: 'attribute'; kind: 'expression'; name: string; value: unknown } + +let astro = defineTransform({ parsers: { astro: { staticAttrs: ['class', 'className'], @@ -1101,25 +1139,61 @@ let astro = defineTransform({ transform: transformAstro, }) -let marko = defineTransform({ +type MarkoNode = import('@marko/compiler').types.Node + +let marko = defineTransform({ parsers: { marko: { staticAttrs: ['class'] } }, transform: transformMarko, }) -let twig = defineTransform({ +type TwigIdentifier = { type: 'Identifier'; name: string } + +type TwigMemberExpression = { + type: 'MemberExpression' + property: TwigIdentifier | TwigCallExpression | TwigMemberExpression +} + +type TwigCallExpression = { + type: 'CallExpression' + callee: TwigIdentifier | TwigCallExpression | TwigMemberExpression +} + +type TwigNode = + | { type: 'Attribute'; name: TwigIdentifier } + | { type: 'StringLiteral'; value: string } + | { type: 'BinaryConcatExpression' } + | { type: 'BinaryAddExpression' } + | TwigIdentifier + | TwigMemberExpression + | TwigCallExpression + +let twig = defineTransform({ parsers: { twig: { staticAttrs: ['class'] } }, transform: transformTwig, }) +interface PugNode { + content: string + tokens: import('pug-lexer').Token[] +} + let pug = defineTransform({ parsers: { pug: { staticAttrs: ['class'] } }, transform: transformPug, }) -let liquid = defineTransform({ +type LiquidNode = + | Liquid.TextNode + | Liquid.AttributeNode + | Liquid.LiquidTag + | Liquid.HtmlElement + | Liquid.DocumentNode + | Liquid.LiquidExpression + +let liquid = defineTransform({ parsers: { 'liquid-html': { staticAttrs: ['class'] } }, transform: transformLiquid, From b515e95a4f070e5f77b288e8fb050ec361051906 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 17:32:39 -0500 Subject: [PATCH 13/14] Hoist common lists of attributes --- src/create-plugin.ts | 4 +-- src/index.ts | 73 +++++++++++++++++++++++++++++--------------- src/transform.ts | 10 ++++++ 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/create-plugin.ts b/src/create-plugin.ts index fb24a95b..ec9f5149 100644 --- a/src/create-plugin.ts +++ b/src/create-plugin.ts @@ -14,8 +14,8 @@ export function createPlugin(base: Base, transforms: TransformOptions[]) { for (let opts of transforms) { for (let [name, meta] of Object.entries(opts.parsers)) { parsers[name] = createParser(base, name, opts.transform, { - staticAttrs: meta.staticAttrs ?? [], - dynamicAttrs: meta.dynamicAttrs ?? [], + staticAttrs: meta.staticAttrs ?? opts.staticAttrs ?? [], + dynamicAttrs: meta.dynamicAttrs ?? opts.dynamicAttrs ?? [], }) } diff --git a/src/index.ts b/src/index.ts index e54660a7..d2f5b5e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1013,11 +1013,13 @@ export { options } from './options.js' type HtmlNode = { type: 'attribute'; name: string; value: string } | { kind: 'attribute'; name: string; value: string } let html = defineTransform({ + staticAttrs: ['class'], + parsers: { - html: { staticAttrs: ['class'] }, - lwc: { staticAttrs: ['class'] }, - angular: { staticAttrs: ['class'], dynamicAttrs: ['[ngClass]'] }, - vue: { staticAttrs: ['class'], dynamicAttrs: [':class', 'v-bind:class'] }, + html: {}, + lwc: {}, + angular: { dynamicAttrs: ['[ngClass]'] }, + vue: { dynamicAttrs: [':class', 'v-bind:class'] }, }, transform: transformHtml, @@ -1031,8 +1033,10 @@ type GlimmerNode = | { type: 'AttrNode'; name: string; value: GlimmerNode } let glimmer = defineTransform({ + staticAttrs: ['class'], + parsers: { - glimmer: { staticAttrs: ['class'] }, + glimmer: {}, }, transform: transformGlimmer, @@ -1056,18 +1060,21 @@ let css = defineTransform({ }) let js = defineTransform({ + staticAttrs: ['class', 'className'], + parsers: { - babel: { staticAttrs: ['class', 'className'] }, - 'babel-flow': { staticAttrs: ['class', 'className'] }, - flow: { staticAttrs: ['class', 'className'] }, - hermes: { staticAttrs: ['class', 'className'] }, - typescript: { staticAttrs: ['class', 'className'] }, - 'babel-ts': { staticAttrs: ['class', 'className'] }, - oxc: { staticAttrs: ['class', 'className'] }, - 'oxc-ts': { staticAttrs: ['class', 'className'] }, - acorn: { staticAttrs: ['class', 'className'] }, - meriyah: { staticAttrs: ['class', 'className'] }, - __js_expression: { staticAttrs: ['class', 'className'] }, + babel: {}, + 'babel-flow': {}, + 'babel-ts': {}, + __js_expression: {}, + typescript: {}, + meriyah: {}, + acorn: {}, + flow: {}, + oxc: {}, + 'oxc-ts': {}, + hermes: {}, + ...(base.parsers.astroExpressionParser ? { astroExpressionParser: { @@ -1086,8 +1093,10 @@ type SvelteNode = import('svelte/compiler').AST.SvelteNode & { } let svelte = defineTransform({ + staticAttrs: ['class'], + parsers: { - svelte: { staticAttrs: ['class'] }, + svelte: {}, }, printers: { @@ -1129,11 +1138,11 @@ type AstroNode = | { type: 'attribute'; kind: 'expression'; name: string; value: unknown } let astro = defineTransform({ + staticAttrs: ['class', 'className'], + dynamicAttrs: ['class:list', 'className'], + parsers: { - astro: { - staticAttrs: ['class', 'className'], - dynamicAttrs: ['class:list', 'className'], - }, + astro: {}, }, transform: transformAstro, @@ -1142,7 +1151,11 @@ let astro = defineTransform({ type MarkoNode = import('@marko/compiler').types.Node let marko = defineTransform({ - parsers: { marko: { staticAttrs: ['class'] } }, + staticAttrs: ['class'], + + parsers: { + marko: {}, + }, transform: transformMarko, }) @@ -1169,7 +1182,11 @@ type TwigNode = | TwigCallExpression let twig = defineTransform({ - parsers: { twig: { staticAttrs: ['class'] } }, + staticAttrs: ['class'], + + parsers: { + twig: {}, + }, transform: transformTwig, }) @@ -1180,7 +1197,11 @@ interface PugNode { } let pug = defineTransform({ - parsers: { pug: { staticAttrs: ['class'] } }, + staticAttrs: ['class'], + + parsers: { + pug: {}, + }, transform: transformPug, }) @@ -1194,7 +1215,9 @@ type LiquidNode = | Liquid.LiquidExpression let liquid = defineTransform({ - parsers: { 'liquid-html': { staticAttrs: ['class'] } }, + staticAttrs: ['class'], + + parsers: { 'liquid-html': {} }, transform: transformLiquid, }) diff --git a/src/transform.ts b/src/transform.ts index 3454c4bf..4412727f 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -6,6 +6,16 @@ export function defineTransform(opts: TransformOptions) { } export interface TransformOptions { + /** + * Static attributes that are supported by default + */ + staticAttrs?: string[] + + /** + * Dynamic / expression attributes that are supported by default + */ + dynamicAttrs?: string[] + /** * A list of supported parser names */ From 11ccd3196664271e7865515bbf292f3162dd7ae0 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 17:10:12 -0500 Subject: [PATCH 14/14] Lazy load compatible plugins --- src/create-plugin.ts | 250 +++++++++++++++++++++++++++++++++---------- src/index.ts | 83 ++++++++------ src/plugins.ts | 199 ---------------------------------- src/transform.ts | 29 ++++- 4 files changed, 275 insertions(+), 286 deletions(-) delete mode 100644 src/plugins.ts diff --git a/src/create-plugin.ts b/src/create-plugin.ts index ec9f5149..449aa85b 100644 --- a/src/create-plugin.ts +++ b/src/create-plugin.ts @@ -1,94 +1,234 @@ -import type { AstPath, Parser, ParserOptions, Printer } from 'prettier' +import type { Parser, ParserOptions, Plugin, Printer } from 'prettier' import { getTailwindConfig } from './config' import { createMatcher } from './options' -import type { loadPlugins } from './plugins' +import { loadIfExists, maybeResolve } from './resolve' import type { TransformOptions } from './transform' -import type { Customizations, TransformerEnv, TransformerMetadata } from './types' +import type { TransformerEnv, TransformerMetadata } from './types' -type Base = Awaited> +export function createPlugin(transforms: TransformOptions[]) { + // Prettier parsers and printers may be async functions at definition time. + // They'll be awaited when the plugin is loaded but must also be swapped out + // with the resolved value before returning as later Prettier internals + // assume that parsers and printers are objects and not functions. + type Init = (() => Promise) | T | undefined -export function createPlugin(base: Base, transforms: TransformOptions[]) { - let parsers: Record> = Object.create(null) - let printers: Record> = Object.create(null) + let parsers: Record>> = Object.create(null) + let printers: Record>> = Object.create(null) for (let opts of transforms) { for (let [name, meta] of Object.entries(opts.parsers)) { - parsers[name] = createParser(base, name, opts.transform, { - staticAttrs: meta.staticAttrs ?? opts.staticAttrs ?? [], - dynamicAttrs: meta.dynamicAttrs ?? opts.dynamicAttrs ?? [], - }) + parsers[name] = async () => { + let plugin = await loadPlugins(meta.load ?? opts.load ?? []) + let original = plugin.parsers?.[name] + if (!original) return + + // Now load parsers from "compatible" plugins if any + let compatible: { pluginName: string; mod: Plugin }[] = [] + + for (let pluginName of opts.compatible ?? []) { + compatible.push({ + pluginName, + mod: await loadIfExistsESM(pluginName), + }) + } + + // TODO: Find a way to drop this. We have to do this for compatible + // plugins that are intended to override builtin ones + parsers[name] = await createParser({ + original, + transform: opts.transform, + meta: { + staticAttrs: meta.staticAttrs ?? opts.staticAttrs ?? [], + dynamicAttrs: meta.dynamicAttrs ?? opts.dynamicAttrs ?? [], + }, + + loadCompatible(options) { + let parser: Parser = { ...original } + + for (let { pluginName, mod } of compatible) { + let plugin = findEnabledPlugin(options, pluginName, mod) + if (plugin) Object.assign(parser, plugin.parsers[name]) + } + + return parser + }, + }) + + return parsers[name] + } } for (let [name, meta] of Object.entries(opts.printers ?? {})) { if (!opts.reprint) continue - printers[name] = createPrinter(base, name, opts.reprint) + printers[name] = async () => { + let plugin = await loadPlugins(meta.load ?? opts.load ?? []) + let original = plugin.printers?.[name] + if (!original) return + + printers[name] = createPrinter({ + original, + reprint: opts.reprint!, + }) + + return printers[name] + } } } return { parsers, printers } } -function createParser( - base: Base, - parserFormat: string, - transform: (ast: any, env: TransformerEnv) => void, - meta: TransformerMetadata = {}, -) { - let customizationDefaults: Customizations = { - staticAttrs: new Set(meta.staticAttrs ?? []), - dynamicAttrs: new Set(meta.dynamicAttrs ?? []), - functions: new Set(meta.functions ?? []), - staticAttrsRegex: [], - dynamicAttrsRegex: [], - functionsRegex: [], +async function loadPlugins(fns: string[]) { + let plugin: Plugin = { + parsers: Object.create(null), + printers: Object.create(null), + options: Object.create(null), + defaultOptions: Object.create(null), + languages: [], } - return { - ...base.parsers[parserFormat], + for (let moduleName of fns) { + try { + let loaded = await loadIfExistsESM(moduleName) + Object.assign(plugin.parsers!, loaded.parsers ?? {}) + Object.assign(plugin.printers!, loaded.printers ?? {}) + Object.assign(plugin.options!, loaded.options ?? {}) + Object.assign(plugin.defaultOptions!, loaded.defaultOptions ?? {}) + + plugin.languages = [...(plugin.languages ?? []), ...(loaded.languages ?? [])] + } catch (err) { + throw err + } + } - preprocess(code: string, options: ParserOptions) { - let original = base.originalParser(parserFormat, options) + return plugin +} - return original.preprocess ? original.preprocess(code, options) : code - }, +async function loadIfExistsESM(name: string): Promise> { + let mod = await loadIfExists>(name) - async parse(text: string, options: ParserOptions) { - let context = await getTailwindConfig(options) + return ( + mod ?? { + parsers: {}, + printers: {}, + languages: [], + options: {}, + defaultOptions: {}, + } + ) +} - let original = base.originalParser(parserFormat, options) +function findEnabledPlugin(options: ParserOptions, name: string, mod: any) { + let path = maybeResolve(name) - // @ts-ignore: We pass three options in the case of plugins that support Prettier 2 _and_ 3. - let ast = await original.parse(text, options, options) + for (let plugin of options.plugins) { + if (plugin instanceof URL) { + if (plugin.protocol !== 'file:') continue + if (plugin.hostname !== '') continue - let matcher = createMatcher(options, parserFormat, customizationDefaults) + plugin = plugin.pathname + } - let env: TransformerEnv = { - context, - matcher, - options, - changes: [], + if (typeof plugin === 'string') { + if (plugin === name || plugin === path) { + return mod } - transform(ast, env) + continue + } - if (parserFormat === 'svelte') { - ast.changes = env.changes - } + // options.plugins.*.name == name + if (plugin.name === name) { + return mod + } - return ast - }, + // options.plugins.*.name == path + if (plugin.name === path) { + return mod + } + + // basically options.plugins.* == mod + // But that can't work because prettier normalizes plugins which destroys top-level object identity + if (plugin.parsers && mod.parsers && plugin.parsers == mod.parsers) { + return mod + } } } -function createPrinter( - base: Base, - name: string, - reprint: (path: AstPath, options: ParserOptions) => void, -): Printer { - let original = base.printers[name] - let printer = { ...original } +async function createParser({ + original, + loadCompatible, + meta, + transform, +}: { + original: Parser + meta: TransformerMetadata + loadCompatible: (options: ParserOptions) => Parser + transform: NonNullable['transform']> +}) { + let parser: Parser = { ...original } + + // TODO: Prettier v3.6.2+ allows preprocess to be async however this breaks + // - Astro + // - prettier-plugin-multiline-arrays + // - @trivago/prettier-plugin-sort-imports + // - prettier-plugin-jsdoc + parser.preprocess = (code: string, options: ParserOptions) => { + let parser = loadCompatible(options) + + return parser.preprocess ? parser.preprocess(code, options) : code + } + + parser.parse = async (code, options) => { + let original = loadCompatible(options) + + // @ts-expect-error: `options` is passed twice for compat with older plugins that were written + // for Prettier v2 but still work with v3. + // + // Currently only the Twig plugin requires this. + let ast = await original.parse(code, options, options) + + let context = await getTailwindConfig(options) + + let matcher = createMatcher(options, options.parser as string, { + staticAttrs: new Set(meta.staticAttrs ?? []), + dynamicAttrs: new Set(meta.dynamicAttrs ?? []), + functions: new Set(), + staticAttrsRegex: [], + dynamicAttrsRegex: [], + functionsRegex: [], + }) + + let env: TransformerEnv = { + context, + matcher, + options, + changes: [], + } + + transform(ast, env) + + if (options.parser === 'svelte') { + ast.changes = env.changes + } + + return ast + } + + return parser +} + +function createPrinter({ + original, + reprint, +}: { + original: Printer + reprint: NonNullable['reprint']> +}) { + let printer: Printer = { ...original } + // Hook into the preprocessing phase to load the config printer.print = new Proxy(original.print, { apply(target, thisArg, args) { let [path, options] = args as Parameters diff --git a/src/index.ts b/src/index.ts index d2f5b5e5..b0fac1d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,18 +7,16 @@ import jsesc from 'jsesc' import lineColumn from 'line-column' import * as prettierParserAngular from 'prettier/plugins/angular' import * as prettierParserBabel from 'prettier/plugins/babel' +import * as prettierParserCss from 'prettier/plugins/postcss' // @ts-ignore import * as recast from 'recast' import { createPlugin } from './create-plugin.js' import type { Matcher } from './options.js' -import { loadPlugins } from './plugins.js' import { sortClasses, sortClassList } from './sorting.js' import { defineTransform, type TransformOptions } from './transform.js' import type { StringChange, TransformerEnv } from './types' import { spliceChangesIntoString, visit, type Path } from './utils.js' -let base = await loadPlugins() - const ESCAPE_SEQUENCE_PATTERN = /\\(['"\\nrtbfv0-7xuU])/g function tryParseAngularAttribute(value: string, env: TransformerEnv) { @@ -672,7 +670,7 @@ function transformCss(ast: any, env: TransformerEnv) { // Otherwise we let prettier re-parse the params into its custom value AST // based on postcss-value parser. try { - let parser = base.parsers.css + let parser = prettierParserCss.parsers.css let root = parser.parse(`@import ${params};`, { // We can't pass env.options directly because css.parse overwrites @@ -1015,6 +1013,9 @@ type HtmlNode = { type: 'attribute'; name: string; value: string } | { kind: 'at let html = defineTransform({ staticAttrs: ['class'], + load: ['prettier/plugins/html'], + compatible: ['prettier-plugin-organize-attributes'], + parsers: { html: {}, lwc: {}, @@ -1034,6 +1035,7 @@ type GlimmerNode = let glimmer = defineTransform({ staticAttrs: ['class'], + load: ['prettier/plugins/glimmer'], parsers: { glimmer: {}, @@ -1050,6 +1052,9 @@ type CssNode = { } let css = defineTransform({ + load: ['prettier/plugins/postcss'], + compatible: ['prettier-plugin-css-order'], + parsers: { css: {}, scss: {}, @@ -1061,28 +1066,37 @@ let css = defineTransform({ let js = defineTransform({ staticAttrs: ['class', 'className'], + compatible: [ + // The following plugins must come *before* the jsdoc plugin for it to + // function correctly. Additionally `multiline-arrays` usually needs to be + // placed before import sorting plugins. + // + // https://github.com/electrovir/prettier-plugin-multiline-arrays#compatibility + 'prettier-plugin-multiline-arrays', + '@ianvs/prettier-plugin-sort-imports', + '@trivago/prettier-plugin-sort-imports', + 'prettier-plugin-organize-imports', + 'prettier-plugin-sort-imports', + 'prettier-plugin-jsdoc', + ], parsers: { - babel: {}, - 'babel-flow': {}, - 'babel-ts': {}, - __js_expression: {}, - typescript: {}, - meriyah: {}, - acorn: {}, - flow: {}, - oxc: {}, - 'oxc-ts': {}, - hermes: {}, - - ...(base.parsers.astroExpressionParser - ? { - astroExpressionParser: { - staticAttrs: ['class'], - dynamicAttrs: ['class:list'], - }, - } - : {}), + babel: { load: ['prettier/plugins/babel'] }, + 'babel-flow': { load: ['prettier/plugins/babel'] }, + 'babel-ts': { load: ['prettier/plugins/babel'] }, + __js_expression: { load: ['prettier/plugins/babel'] }, + typescript: { load: ['prettier/plugins/typescript'] }, + meriyah: { load: ['prettier/plugins/meriyah'] }, + acorn: { load: ['prettier/plugins/acorn'] }, + flow: { load: ['prettier/plugins/flow'] }, + oxc: { load: ['@prettier/plugin-oxc'] }, + 'oxc-ts': { load: ['@prettier/plugin-oxc'] }, + hermes: { load: ['@prettier/plugin-hermes'] }, + astroExpressionParser: { + load: ['prettier-plugin-astro'], + staticAttrs: ['class'], + dynamicAttrs: ['class:list'], + }, }, transform: transformJavaScript, @@ -1094,6 +1108,7 @@ type SvelteNode = import('svelte/compiler').AST.SvelteNode & { let svelte = defineTransform({ staticAttrs: ['class'], + load: ['prettier-plugin-svelte'], parsers: { svelte: {}, @@ -1140,6 +1155,7 @@ type AstroNode = let astro = defineTransform({ staticAttrs: ['class', 'className'], dynamicAttrs: ['class:list', 'className'], + load: ['prettier-plugin-astro'], parsers: { astro: {}, @@ -1152,6 +1168,7 @@ type MarkoNode = import('@marko/compiler').types.Node let marko = defineTransform({ staticAttrs: ['class'], + load: ['prettier-plugin-marko'], parsers: { marko: {}, @@ -1183,6 +1200,7 @@ type TwigNode = let twig = defineTransform({ staticAttrs: ['class'], + load: ['@zackad/prettier-plugin-twig'], parsers: { twig: {}, @@ -1198,6 +1216,7 @@ interface PugNode { let pug = defineTransform({ staticAttrs: ['class'], + load: ['@prettier/plugin-pug'], parsers: { pug: {}, @@ -1216,23 +1235,25 @@ type LiquidNode = let liquid = defineTransform({ staticAttrs: ['class'], + load: ['@shopify/prettier-plugin-liquid'], parsers: { 'liquid-html': {} }, transform: transformLiquid, }) -export const { parsers, printers } = createPlugin(base, [ +export const { parsers, printers } = createPlugin([ + // html, glimmer, css, js, - ...(base.parsers.svelte ? [svelte] : []), - ...(base.parsers.astro ? [astro] : []), - ...(base.parsers.marko ? [marko] : []), - ...(base.parsers.twig ? [twig] : []), - ...(base.parsers.pug ? [pug] : []), - ...(base.parsers['liquid-html'] ? [liquid] : []), + svelte, + astro, + marko, + twig, + pug, + liquid, ]) export interface PluginOptions { diff --git a/src/plugins.ts b/src/plugins.ts deleted file mode 100644 index 0c0c9c07..00000000 --- a/src/plugins.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { Parser, ParserOptions, Plugin, Printer } from 'prettier' -import './types' -import * as prettierParserAcorn from 'prettier/plugins/acorn' -import * as prettierParserBabel from 'prettier/plugins/babel' -import * as prettierParserFlow from 'prettier/plugins/flow' -import * as prettierParserGlimmer from 'prettier/plugins/glimmer' -import * as prettierParserHTML from 'prettier/plugins/html' -import * as prettierParserMeriyah from 'prettier/plugins/meriyah' -import * as prettierParserPostCSS from 'prettier/plugins/postcss' -import * as prettierParserTypescript from 'prettier/plugins/typescript' -import { loadIfExists, maybeResolve } from './resolve' - -interface PluginDetails { - parsers: Record> - printers: Record> -} - -async function loadIfExistsESM(name: string): Promise> { - let mod = await loadIfExists>(name) - - mod ??= { - parsers: {}, - printers: {}, - } - - return mod -} - -export async function loadPlugins() { - const builtin = await loadBuiltinPlugins() - const thirdparty = await loadThirdPartyPlugins() - const compatible = await loadCompatiblePlugins() - - let parsers = { - ...builtin.parsers, - ...thirdparty.parsers, - } - - let printers = { - ...builtin.printers, - ...thirdparty.printers, - } - - function findEnabledPlugin(options: ParserOptions, name: string, mod: any) { - let path = maybeResolve(name) - - for (let plugin of options.plugins) { - if (plugin instanceof URL) { - if (plugin.protocol !== 'file:') continue - if (plugin.hostname !== '') continue - - plugin = plugin.pathname - } - - if (typeof plugin === 'string') { - if (plugin === name || plugin === path) { - return mod - } - - continue - } - - // options.plugins.*.name == name - if (plugin.name === name) { - return mod - } - - // options.plugins.*.name == path - if (plugin.name === path) { - return mod - } - - // basically options.plugins.* == mod - // But that can't work because prettier normalizes plugins which destroys top-level object identity - if (plugin.parsers && mod.parsers && plugin.parsers == mod.parsers) { - return mod - } - } - - return null - } - - return { - parsers, - printers, - - originalParser(format: string, options: ParserOptions) { - if (!options.plugins) { - return parsers[format] - } - - let parser = { ...parsers[format] } - - // Now load parsers from "compatible" plugins if any - for (const { name, mod } of compatible) { - let plugin = findEnabledPlugin(options, name, mod) - if (plugin) { - Object.assign(parser, plugin.parsers[format]) - } - } - - return parser - }, - } -} - -async function loadBuiltinPlugins(): Promise { - return { - parsers: { - html: prettierParserHTML.parsers.html, - glimmer: prettierParserGlimmer.parsers.glimmer, - lwc: prettierParserHTML.parsers.lwc, - angular: prettierParserHTML.parsers.angular, - vue: prettierParserHTML.parsers.vue, - css: prettierParserPostCSS.parsers.css, - scss: prettierParserPostCSS.parsers.scss, - less: prettierParserPostCSS.parsers.less, - babel: prettierParserBabel.parsers.babel, - 'babel-flow': prettierParserBabel.parsers['babel-flow'], - flow: prettierParserFlow.parsers.flow, - typescript: prettierParserTypescript.parsers.typescript, - 'babel-ts': prettierParserBabel.parsers['babel-ts'], - acorn: prettierParserAcorn.parsers.acorn, - meriyah: prettierParserMeriyah.parsers.meriyah, - __js_expression: prettierParserBabel.parsers.__js_expression, - }, - printers: { - // - }, - } -} - -async function loadThirdPartyPlugins(): Promise { - // These plugins *must* be loaded sequentially. Race conditions are possible - // when using await import(…), require(esm), and Promise.all(…). - let astro = await loadIfExistsESM('prettier-plugin-astro') - let liquid = await loadIfExistsESM('@shopify/prettier-plugin-liquid') - let marko = await loadIfExistsESM('prettier-plugin-marko') - let twig = await loadIfExistsESM('@zackad/prettier-plugin-twig') - let hermes = await loadIfExistsESM('@prettier/plugin-hermes') - let oxc = await loadIfExistsESM('@prettier/plugin-oxc') - let pug = await loadIfExistsESM('@prettier/plugin-pug') - let svelte = await loadIfExistsESM('prettier-plugin-svelte') - - return { - parsers: { - ...astro.parsers, - ...liquid.parsers, - ...marko.parsers, - ...twig.parsers, - ...hermes.parsers, - ...oxc.parsers, - ...pug.parsers, - ...svelte.parsers, - }, - printers: { - ...hermes.printers, - ...oxc.printers, - ...svelte.printers, - }, - } -} - -async function loadCompatiblePlugins() { - // Plugins are loaded in a specific order for proper interoperability - let plugins = [ - 'prettier-plugin-css-order', - 'prettier-plugin-organize-attributes', - - // The following plugins must come *before* the jsdoc plugin for it to - // function correctly. Additionally `multiline-arrays` usually needs to be - // placed before import sorting plugins. - // - // https://github.com/electrovir/prettier-plugin-multiline-arrays#compatibility - 'prettier-plugin-multiline-arrays', - '@ianvs/prettier-plugin-sort-imports', - '@trivago/prettier-plugin-sort-imports', - 'prettier-plugin-organize-imports', - 'prettier-plugin-sort-imports', - - 'prettier-plugin-jsdoc', - ] - - let list: { name: string; mod: unknown }[] = [] - - // Load all the available compatible plugins up front. These are wrapped in - // try/catch internally so failure doesn't cause issues. - // - // We're always executing these plugins even if they're not enabled. Sadly, - // there is no way around this currently. - // - // These plugins *must* be loaded sequentially. Race conditions are possible - // when using await import(…), require(esm), and Promise.all(…). - for (let name of plugins) { - list.push({ name, mod: await loadIfExistsESM(name) }) - } - - return list -} diff --git a/src/transform.ts b/src/transform.ts index 4412727f..ea03b3d9 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -16,12 +16,31 @@ export interface TransformOptions { */ dynamicAttrs?: string[] + /** + * Load the given plugins for the parsers and printers + */ + load?: string[] + + /** + * A list of compatible, third-party plugins for this transformation step + * + * The loading of these is delayed until the actual parse call as + * using the parse() function from these plugins may cause errors + * if they haven't already been loaded by Prettier. + */ + compatible?: string[] + /** * A list of supported parser names */ parsers: Record< string, { + /** + * Load the given plugins for the parsers and printers + */ + load?: string[] + /** * Static attributes that are supported by default */ @@ -37,7 +56,15 @@ export interface TransformOptions { /** * A list of supported parser names */ - printers?: Record + printers?: Record< + string, + { + /** + * Load the given plugins for the parsers and printers + */ + load?: string[] + } + > /** * Transform entire ASTs