diff --git a/package.json b/package.json index bab476a..c30d3e6 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": "*", diff --git a/src/config.ts b/src/config.ts index e0802d7..7eb6ba2 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 diff --git a/src/create-plugin.ts b/src/create-plugin.ts new file mode 100644 index 0000000..449aa85 --- /dev/null +++ b/src/create-plugin.ts @@ -0,0 +1,251 @@ +import type { Parser, ParserOptions, Plugin, Printer } from 'prettier' +import { getTailwindConfig } from './config' +import { createMatcher } from './options' +import { loadIfExists, maybeResolve } from './resolve' +import type { TransformOptions } from './transform' +import type { TransformerEnv, TransformerMetadata } from './types' + +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 + + 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] = 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] = 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 } +} + +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: [], + } + + 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 + } + } + + return plugin +} + +async function loadIfExistsESM(name: string): Promise> { + let mod = await loadIfExists>(name) + + return ( + mod ?? { + parsers: {}, + printers: {}, + languages: [], + options: {}, + defaultOptions: {}, + } + ) +} + +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 + } + } +} + +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 + 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 6d29d2b..b0fac1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,95 +1,32 @@ // @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' // @ts-ignore import lineColumn from 'line-column' -import type { Parser, ParserOptions, Printer } from 'prettier' 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 { getTailwindConfig } from './config.js' -import { createMatcher, type Matcher } from './options.js' -import { loadPlugins } from './plugins.js' +import { createPlugin } from './create-plugin.js' +import type { Matcher } from './options.js' import { sortClasses, sortClassList } from './sorting.js' -import type { Customizations, StringChange, TransformerContext, TransformerEnv, TransformerMetadata } from './types' +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 createParser( - parserFormat: string, - transform: (ast: any, context: TransformerContext) => 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 changes: any[] = [] - - transform(ast, { - env: { context, matcher, parsers: {}, options }, - changes, - }) - - if (parserFormat === 'svelte') { - ast.changes = changes - } - - return ast - }, - } -} - 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) { @@ -271,7 +208,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 +229,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 +289,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 }[] }) { @@ -372,7 +309,7 @@ function transformLiquid(ast: any, { env }: TransformerContext) { 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') { @@ -651,7 +588,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 +659,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) { @@ -733,7 +670,7 @@ function transformCss(ast: any, { env }: TransformerContext) { // 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 @@ -790,7 +727,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 +748,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 +794,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 +855,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 @@ -961,8 +898,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, }) @@ -972,8 +910,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') { @@ -1046,12 +984,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) } } @@ -1059,190 +997,265 @@ 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) } } export { options } from './options.js' -export const printers: Record = (function () { - let printers: Record = {} +type HtmlNode = { type: 'attribute'; name: string; value: string } | { kind: 'attribute'; name: string; value: string } - if (base.printers['svelte-ast']) { - function mutateOriginalText(path: any, options: any) { - if (options.__mutatedOriginalText) { - return - } +let html = defineTransform({ + staticAttrs: ['class'], - options.__mutatedOriginalText = true + load: ['prettier/plugins/html'], + compatible: ['prettier-plugin-organize-attributes'], - let changes: any[] = path.stack[0].changes + parsers: { + html: {}, + lwc: {}, + angular: { dynamicAttrs: ['[ngClass]'] }, + vue: { dynamicAttrs: [':class', 'v-bind:class'] }, + }, - if (changes?.length) { - let finder = lineColumn(options.originalText) + transform: transformHtml, +}) - 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), - } - }) +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 } - options.originalText = spliceChangesIntoString(options.originalText, changes) - } - } +let glimmer = defineTransform({ + staticAttrs: ['class'], + load: ['prettier/plugins/glimmer'], - let original = base.printers['svelte-ast'] - let printer = { ...original } + parsers: { + glimmer: {}, + }, - 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) - }, - }) + transform: transformGlimmer, +}) - 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) - }, - }) +type CssValueNode = { type: 'value-*'; name: string; params: string } +type CssNode = { + type: 'css-atrule' + name: string + params: string | CssValueNode +} + +let css = defineTransform({ + load: ['prettier/plugins/postcss'], + compatible: ['prettier-plugin-css-order'], + + parsers: { + css: {}, + scss: {}, + less: {}, + }, + + transform: transformCss, +}) + +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: { 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, +}) + +type SvelteNode = import('svelte/compiler').AST.SvelteNode & { + changes: StringChange[] +} + +let svelte = defineTransform({ + staticAttrs: ['class'], + load: ['prettier-plugin-svelte'], + + parsers: { + svelte: {}, + }, + + 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) + }, +}) + +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 } - printers['svelte-ast'] = printer - } +let astro = defineTransform({ + staticAttrs: ['class', 'className'], + dynamicAttrs: ['class:list', 'className'], + load: ['prettier-plugin-astro'], - 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'], - }), - - ...(base.parsers.svelte - ? { - svelte: createParser('svelte', transformSvelte, { - 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'], - }), - } - : {}), - ...(base.parsers.marko - ? { - marko: createParser('marko', transformMarko, { - staticAttrs: ['class'], - }), - } - : {}), - ...(base.parsers.twig - ? { - twig: createParser('twig', transformTwig, { - staticAttrs: ['class'], - }), - } - : {}), - ...(base.parsers.pug - ? { - pug: createParser('pug', transformPug, { - staticAttrs: ['class'], - }), - } - : {}), - ...(base.parsers['liquid-html'] - ? { - 'liquid-html': createParser('liquid-html', transformLiquid, { - staticAttrs: ['class'], - }), - } - : {}), + parsers: { + astro: {}, + }, + + transform: transformAstro, +}) + +type MarkoNode = import('@marko/compiler').types.Node + +let marko = defineTransform({ + staticAttrs: ['class'], + load: ['prettier-plugin-marko'], + + parsers: { + marko: {}, + }, + + transform: transformMarko, +}) + +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({ + staticAttrs: ['class'], + load: ['@zackad/prettier-plugin-twig'], + + parsers: { + twig: {}, + }, + + transform: transformTwig, +}) + +interface PugNode { + content: string + tokens: import('pug-lexer').Token[] } +let pug = defineTransform({ + staticAttrs: ['class'], + load: ['@prettier/plugin-pug'], + + parsers: { + pug: {}, + }, + + transform: transformPug, +}) + +type LiquidNode = + | Liquid.TextNode + | Liquid.AttributeNode + | Liquid.LiquidTag + | Liquid.HtmlElement + | Liquid.DocumentNode + | Liquid.LiquidExpression + +let liquid = defineTransform({ + staticAttrs: ['class'], + load: ['@shopify/prettier-plugin-liquid'], + + parsers: { 'liquid-html': {} }, + + transform: transformLiquid, +}) + +export const { parsers, printers } = createPlugin([ + // + html, + glimmer, + css, + js, + svelte, + astro, + marko, + twig, + pug, + liquid, +]) + export interface PluginOptions { /** * Path to the Tailwind config file. diff --git a/src/plugins.ts b/src/plugins.ts deleted file mode 100644 index 0c0c9c0..0000000 --- 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/sorting.ts b/src/sorting.ts index f11ad33..ecceb50 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) { diff --git a/src/transform.ts b/src/transform.ts new file mode 100644 index 0000000..ea03b3d --- /dev/null +++ b/src/transform.ts @@ -0,0 +1,84 @@ +import type { AstPath, ParserOptions } from 'prettier' +import type { TransformerEnv } from './types' + +export function defineTransform(opts: TransformOptions) { + return opts +} + +export interface TransformOptions { + /** + * Static attributes that are supported by default + */ + staticAttrs?: string[] + + /** + * Dynamic / expression attributes that are supported by default + */ + 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 + */ + staticAttrs?: string[] + + /** + * Dynamic / expression attributes that are supported by default + */ + dynamicAttrs?: string[] + } + > + + /** + * A list of supported parser names + */ + printers?: Record< + string, + { + /** + * Load the given plugins for the parsers and printers + */ + load?: 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 + + /** + * Transform entire ASTs + * + * @param ast The AST to transform + * @param env Provides options and mechanisms to sort classes + */ + reprint?(path: AstPath, options: ParserOptions): void +} diff --git a/src/types.ts b/src/types.ts index cdefec8..833b8fb 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][] } @@ -29,8 +24,8 @@ export interface UnifiedApi { export interface TransformerEnv { context: UnifiedApi matcher: Matcher - parsers: any options: ParserOptions + changes: StringChange[] } export interface StringChange { diff --git a/tests/utils.ts b/tests/utils.ts index 1e3ec5a..164b698 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -40,14 +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, { - pluginSearchDirs: [__dirname], // disable plugin autoload semi: false, singleQuote: true, printWidth: 9999, parser: 'html', ...options, - plugins: [...(options.plugins ?? []), pluginPath], + plugins: [ + // + ...(options.plugins ?? []), + // plugin, + pluginPath, + ], }) return result.trim() diff --git a/vitest.config.ts b/vitest.config.ts index d94857a..9b6b73a 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`) + }, + }, + ], })