@herb-tools/rewriter 0.8.10 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { getStaticAttributeName, isLiteralNode } from "@herb-tools/core"
1
+ import { getStaticAttributeName, isLiteralNode, isPureWhitespaceNode, splitLiteralsAtWhitespace, groupNodesByClass } from "@herb-tools/core"
2
2
  import { LiteralNode, Location, Visitor } from "@herb-tools/core"
3
3
 
4
4
  import { TailwindClassSorter } from "@herb-tools/tailwind-class-sorter"
@@ -45,7 +45,23 @@ class TailwindClassSorterVisitor extends Visitor {
45
45
  const attributeName = getStaticAttributeName(node.name)
46
46
  if (attributeName !== "class") return
47
47
 
48
- this.visit(node.value)
48
+ const classAttributeSorter = new ClassAttributeSorter(this.sorter)
49
+
50
+ classAttributeSorter.visit(node.value)
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Visitor that sorts classes within a single class attribute value.
56
+ * Only operates on the content of a class attribute, not the full document.
57
+ */
58
+ class ClassAttributeSorter extends Visitor {
59
+ private sorter: TailwindClassSorter
60
+
61
+ constructor(sorter: TailwindClassSorter) {
62
+ super()
63
+
64
+ this.sorter = sorter
49
65
  }
50
66
 
51
67
  visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void {
@@ -127,104 +143,181 @@ class TailwindClassSorterVisitor extends Visitor {
127
143
  })
128
144
  }
129
145
 
130
- private startsWithClassLiteral(nodes: Node[]): boolean {
131
- return nodes.length > 0 && isLiteralNode(nodes[0]) && !!nodes[0].content.trim()
146
+ private isInterpolatedGroup(group: Node[]): boolean {
147
+ return group.some(node => !isLiteralNode(node))
132
148
  }
133
149
 
134
- private isWhitespaceLiteral(node: Node): boolean {
135
- return isLiteralNode(node) && !node.content.trim()
150
+ private isWhitespaceGroup(group: Node[]): boolean {
151
+ return group.every(node => isPureWhitespaceNode(node))
136
152
  }
137
153
 
138
- private formatNodes(nodes: Node[], isNested: boolean): Node[] {
139
- const { classLiterals, others } = this.partitionNodes(nodes)
140
- const preserveLeadingSpace = isNested || this.startsWithClassLiteral(nodes)
141
-
142
- return this.formatSortedClasses(classLiterals, others, preserveLeadingSpace, isNested)
154
+ private getStaticClassContent(group: Node[]): string {
155
+ return group
156
+ .filter(node => isLiteralNode(node))
157
+ .map(node => (node as LiteralNode).content)
158
+ .join("")
143
159
  }
144
160
 
145
- private partitionNodes(nodes: Node[]): { classLiterals: LiteralNode[], others: Node[] } {
146
- const classLiterals: LiteralNode[] = []
147
- const others: Node[] = []
161
+ private categorizeGroups(groups: Node[][]): { staticClasses: string[], interpolationGroups: Node[][], standaloneERBNodes: Node[] } {
162
+ const staticClasses: string[] = []
163
+ const interpolationGroups: Node[][] = []
164
+ const standaloneERBNodes: Node[] = []
165
+
166
+ for (const group of groups) {
167
+ if (this.isWhitespaceGroup(group)) {
168
+ continue
169
+ }
170
+
171
+ if (this.isInterpolatedGroup(group)) {
172
+ const hasAttachedLiteral = group.some(node => isLiteralNode(node) && node.content.trim())
173
+
174
+ if (hasAttachedLiteral) {
175
+ for (const node of group) {
176
+ if (!isLiteralNode(node)) {
177
+ this.visit(node)
178
+ }
179
+ }
148
180
 
149
- for (const node of nodes) {
150
- if (isLiteralNode(node)) {
151
- if (node.content.trim()) {
152
- classLiterals.push(node)
181
+ interpolationGroups.push(group)
153
182
  } else {
154
- others.push(node)
183
+ for (const node of group) {
184
+ if (!isLiteralNode(node)) {
185
+ this.visit(node)
186
+ standaloneERBNodes.push(node)
187
+ }
188
+ }
155
189
  }
156
190
  } else {
157
- this.visit(node)
158
- others.push(node)
191
+ const content = this.getStaticClassContent(group).trim()
192
+
193
+ if (content) {
194
+ staticClasses.push(content)
195
+ }
159
196
  }
160
197
  }
161
198
 
162
- return { classLiterals, others }
199
+ return { staticClasses, interpolationGroups, standaloneERBNodes }
163
200
  }
164
201
 
165
- private formatSortedClasses(literals: LiteralNode[], others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] {
166
- if (literals.length === 0 && others.length === 0) return []
167
- if (literals.length === 0) return others
202
+ private formatNodes(nodes: Node[], isNested: boolean): Node[] {
203
+ if (nodes.length === 0) return nodes
204
+ if (nodes.every(child => isPureWhitespaceNode(child))) return nodes
205
+
206
+ const splitNodes = splitLiteralsAtWhitespace(nodes)
207
+ const groups = groupNodesByClass(splitNodes)
208
+ const groupPrecedingWhitespace = new Map<Node[], Node[]>()
209
+ const nodePrecedingWhitespace = new Map<Node, Node[]>()
210
+
211
+ for (let i = 1; i < groups.length; i++) {
212
+ if (!this.isWhitespaceGroup(groups[i]) && this.isWhitespaceGroup(groups[i - 1])) {
213
+ groupPrecedingWhitespace.set(groups[i], groups[i - 1])
214
+
215
+ for (const node of groups[i]) {
216
+ if (!isLiteralNode(node)) {
217
+ nodePrecedingWhitespace.set(node, groups[i - 1])
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ let leadingWhitespace: Node[] | null = null
224
+ let trailingWhitespace: Node[] | null = null
168
225
 
169
- const fullContent = literals.map(n => n.content).join("")
170
- const trimmedClasses = fullContent.trim()
226
+ if (isNested && groups.length > 0) {
227
+ if (this.isWhitespaceGroup(groups[0])) {
228
+ leadingWhitespace = groups[0]
229
+ }
230
+ if (groups.length > 1 && this.isWhitespaceGroup(groups[groups.length - 1])) {
231
+ trailingWhitespace = groups[groups.length - 1]
232
+ }
233
+ }
171
234
 
172
- if (!trimmedClasses) return others.length > 0 ? others : []
235
+ const { staticClasses, interpolationGroups, standaloneERBNodes } = this.categorizeGroups(groups)
173
236
 
174
- try {
175
- const sortedClasses = this.sorter.sortClasses(trimmedClasses)
237
+ const allStaticContent = staticClasses.join(" ")
238
+ let sortedContent = allStaticContent
176
239
 
177
- if (others.length === 0) {
178
- return this.formatSortedLiteral(literals[0], fullContent, sortedClasses, trimmedClasses)
240
+ if (allStaticContent) {
241
+ try {
242
+ sortedContent = this.sorter.sortClasses(allStaticContent)
243
+ } catch {
244
+ // Keep original on error
179
245
  }
246
+ }
180
247
 
181
- return this.formatSortedLiteralWithERB(literals[0], fullContent, sortedClasses, others, preserveLeadingSpace, isNested)
182
- } catch (error) {
183
- return [...literals, ...others]
248
+ const parts: Node[] = []
249
+
250
+ if (sortedContent) {
251
+ parts.push(new LiteralNode({
252
+ type: "AST_LITERAL_NODE",
253
+ content: sortedContent,
254
+ errors: [],
255
+ location: Location.zero
256
+ }))
184
257
  }
185
- }
186
258
 
187
- private formatSortedLiteral(literal: LiteralNode, fullContent: string, sortedClasses: string, trimmedClasses: string): Node[] {
188
- const leadingSpace = fullContent.match(/^\s*/)?.[0] || ""
189
- const trailingSpace = fullContent.match(/\s*$/)?.[0] || ""
190
- const alreadySorted = sortedClasses === trimmedClasses
259
+ for (const group of interpolationGroups) {
260
+ if (parts.length > 0) {
261
+ const whitespace = groupPrecedingWhitespace.get(group)
262
+ parts.push(...(whitespace ?? [this.spaceLiteral]))
263
+ }
264
+
265
+ parts.push(...this.trimGroupWhitespace(group))
266
+ }
191
267
 
192
- const sortedContent = alreadySorted ? fullContent : (leadingSpace + sortedClasses + trailingSpace)
268
+ for (const node of standaloneERBNodes) {
269
+ if (parts.length > 0) {
270
+ const whitespace = nodePrecedingWhitespace.get(node)
271
+ parts.push(...(whitespace ?? [this.spaceLiteral]))
272
+ }
273
+ parts.push(node)
274
+ }
193
275
 
194
- asMutable(literal).content = sortedContent
276
+ if (isNested && parts.length > 0) {
277
+ const leading = leadingWhitespace ?? [this.spaceLiteral]
278
+ const trailing = trailingWhitespace ?? [this.spaceLiteral]
279
+ return [...leading, ...parts, ...trailing]
280
+ }
195
281
 
196
- return [literal]
282
+ return parts
197
283
  }
198
284
 
199
- private formatSortedLiteralWithERB(literal: LiteralNode, fullContent: string, sortedClasses: string, others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] {
200
- const leadingSpace = fullContent.match(/^\s*/)?.[0] || ""
201
- const trailingSpace = fullContent.match(/\s*$/)?.[0] || ""
285
+ private trimGroupWhitespace(group: Node[]): Node[] {
286
+ if (group.length === 0) return group
202
287
 
203
- const leading = preserveLeadingSpace ? leadingSpace : ""
204
- const firstIsWhitespace = this.isWhitespaceLiteral(others[0])
205
- const spaceBetween = firstIsWhitespace ? "" : " "
288
+ const result = [...group]
206
289
 
207
- asMutable(literal).content = leading + sortedClasses + spaceBetween
290
+ if (isLiteralNode(result[0])) {
291
+ const first = result[0] as LiteralNode
292
+ const trimmed = first.content.trimStart()
208
293
 
209
- const othersWithWhitespace = this.addSpacingBetweenERBNodes(others, isNested, trailingSpace)
294
+ if (trimmed !== first.content) {
295
+ result[0] = new LiteralNode({
296
+ type: "AST_LITERAL_NODE",
297
+ content: trimmed,
298
+ errors: [],
299
+ location: first.location
300
+ })
301
+ }
302
+ }
210
303
 
211
- return [literal, ...othersWithWhitespace]
212
- }
304
+ const lastIndex = result.length - 1
213
305
 
214
- private addSpacingBetweenERBNodes(nodes: Node[], isNested: boolean, trailingSpace: string): Node[] {
215
- return nodes.flatMap((node, index) => {
216
- const isLast = index >= nodes.length - 1
306
+ if (isLiteralNode(result[lastIndex])) {
307
+ const last = result[lastIndex] as LiteralNode
308
+ const trimmed = last.content.trimEnd()
217
309
 
218
- if (isLast) {
219
- return isNested && trailingSpace ? [node, this.spaceLiteral] : [node]
310
+ if (trimmed !== last.content) {
311
+ result[lastIndex] = new LiteralNode({
312
+ type: "AST_LITERAL_NODE",
313
+ content: trimmed,
314
+ errors: [],
315
+ location: last.location
316
+ })
220
317
  }
318
+ }
221
319
 
222
- const currentIsWhitespace = this.isWhitespaceLiteral(node)
223
- const nextIsWhitespace = this.isWhitespaceLiteral(nodes[index + 1])
224
- const needsSpace = !currentIsWhitespace && !nextIsWhitespace
225
-
226
- return needsSpace ? [node, this.spaceLiteral] : [node]
227
- })
320
+ return result
228
321
  }
229
322
  }
230
323
 
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { ASTRewriter } from "./ast-rewriter.js"
2
+ export { ActionViewTagHelperToHTMLRewriter } from "./built-ins/action-view-tag-helper-to-html.js"
3
+ export { HTMLToActionViewTagHelperRewriter } from "./built-ins/html-to-action-view-tag-helper.js"
2
4
  export { StringRewriter } from "./string-rewriter.js"
3
5
 
4
6
  export { asMutable } from "./mutable.js"
@@ -1,4 +1,6 @@
1
1
  import { TailwindClassSorterRewriter } from "./built-ins/tailwind-class-sorter.js"
2
+ import { ActionViewTagHelperToHTMLRewriter } from "./built-ins/action-view-tag-helper-to-html.js"
3
+ import { HTMLToActionViewTagHelperRewriter } from "./built-ins/html-to-action-view-tag-helper.js"
2
4
 
3
5
  export interface TailwindClassSorterOptions {
4
6
  /**
@@ -35,3 +37,11 @@ export async function tailwindClassSorter(options: TailwindClassSorterOptions =
35
37
 
36
38
  return rewriter
37
39
  }
40
+
41
+ export function actionViewTagHelperToHTML(): ActionViewTagHelperToHTMLRewriter {
42
+ return new ActionViewTagHelperToHTMLRewriter()
43
+ }
44
+
45
+ export function htmlToActionViewTagHelper(): HTMLToActionViewTagHelperRewriter {
46
+ return new HTMLToActionViewTagHelperRewriter()
47
+ }