Skip to content

Commit be6643f

Browse files
committed
Parse JSX and TSX source files
Add a shared Winnow-backed source profile that isolates quoted class attributes from JavaScript and TypeScript syntax. Extend the pinned parity harness with JSX coverage and enforce source preservation outside parsed attribute values.
1 parent b8690fa commit be6643f

19 files changed

Lines changed: 819 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
### Added
66

77
- Add language-aware class extraction for HTML, Svelte, Django, Jinja, Twig,
8-
Liquid, Handlebars, ERB, EJS, PHP, Blade, Lit, and Ruby
8+
Liquid, Handlebars, ERB, EJS, PHP, Blade, Lit, Ruby, JSX, and TSX
9+
- Parse JSX and TSX with Winnow so only directly quoted `class` and `className`
10+
attributes in real JSX elements are sorted
911
- Add `--language` to override source-language inference and
1012
`--stdin-filename` to infer the language of standard input
1113
- Preserve template expressions as opaque boundaries while independently
@@ -44,6 +46,8 @@
4446
- Recognized markup languages only sort literal `class` and `className`
4547
attributes in actual tags. Class-like text in comments, raw-text elements,
4648
program strings, and dynamic or namespaced attributes is no longer rewritten
49+
- `SourceLanguage` now includes a `Jsx` variant used for both JSX and TSX.
50+
Downstream exhaustive matches must handle it
4751

4852
## [0.25.2] - 2026-07-08
4953

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,11 @@ Provide a filename when sorting standard input so RustyWind can infer its templa
6969

7070
- `cat Component.svelte | rustywind --stdin --stdin-filename Component.svelte`
7171

72-
Supported language profiles are HTML, Svelte, Astro, Django, Jinja, Twig, Liquid, Handlebars, ERB,
73-
EJS, PHP, Blade, Lit, and Ruby. Astro frontmatter, expressions, dynamic class attributes, and
74-
`class:list` directives are preserved while static quoted class attributes are sorted. Files with
72+
Supported language profiles are HTML, JSX/TSX, Svelte, Astro, Django, Jinja, Twig, Liquid,
73+
Handlebars, ERB, EJS, PHP, Blade, Lit, and Ruby. JSX and TSX program text, comments, and dynamic
74+
attributes are preserved while directly quoted `class` and `className` attributes are sorted.
75+
Astro frontmatter, expressions, dynamic class attributes, and `class:list` directives are
76+
preserved while static quoted class attributes are sorted. Files with
7577
unrecognized extensions use conservative legacy-compatible extraction: simple static class
7678
attributes are sorted, while template-looking attributes remain unchanged. Because a plain `.html`
7779
extension does not identify its template engine, attributes with template syntax are left unchanged
@@ -160,7 +162,7 @@ Options:
160162
-l, --language <LANGUAGE>
161163
Source language to use for all inputs, overriding filename inference
162164

163-
[possible values: html, svelte, astro, django, jinja, twig, liquid, handlebars, erb, ejs, php, blade, lit, ruby]
165+
[possible values: html, svelte, astro, jsx, tsx, django, jinja, twig, liquid, handlebars, erb, ejs, php, blade, lit, ruby]
164166

165167
-f, --stdin-filename <PATH>
166168
Filename used to infer the source language of stdin

rustywind-cli/src/main.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,18 @@ mod tests {
321321
assert!(matches!(cli.language, Some(CliSourceLanguage::Svelte)));
322322
}
323323

324+
#[test]
325+
fn jsx_and_tsx_cli_values_share_the_jsx_profile() {
326+
for language in ["jsx", "tsx"] {
327+
let cli = Cli::try_parse_from(["rustywind", "--language", language, "input.js"])
328+
.expect("JSX language alias should parse");
329+
assert!(matches!(
330+
cli.language,
331+
Some(CliSourceLanguage::Jsx | CliSourceLanguage::Tsx)
332+
));
333+
}
334+
}
335+
324336
#[test]
325337
fn parses_stdin_filename_for_language_inference() {
326338
let cli = Cli::try_parse_from(["rustywind", "--stdin", "-f", "components/card.blade.php"])

rustywind-cli/src/options.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ pub enum CliSourceLanguage {
5757
Html,
5858
Svelte,
5959
Astro,
60+
Jsx,
61+
Tsx,
6062
Django,
6163
Jinja,
6264
Twig,
@@ -76,6 +78,7 @@ impl From<CliSourceLanguage> for SourceLanguage {
7678
CliSourceLanguage::Html => Self::Html,
7779
CliSourceLanguage::Svelte => Self::Svelte,
7880
CliSourceLanguage::Astro => Self::Astro,
81+
CliSourceLanguage::Jsx | CliSourceLanguage::Tsx => Self::Jsx,
7982
CliSourceLanguage::Django => Self::Django,
8083
CliSourceLanguage::Jinja => Self::Jinja,
8184
CliSourceLanguage::Twig => Self::Twig,
@@ -290,4 +293,14 @@ mod tests {
290293
);
291294
assert_eq!(resolve_source_language(None, None), SourceLanguage::Unknown);
292295
}
296+
297+
#[test]
298+
fn jsx_and_tsx_filenames_share_the_jsx_profile() {
299+
for path in ["component.jsx", "component.tsx"] {
300+
assert_eq!(
301+
resolve_source_language(None, Some(Path::new(path))),
302+
SourceLanguage::Jsx
303+
);
304+
}
305+
}
293306
}

rustywind-core/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
- Add first-class Astro parsing for static quoted class attributes while preserving frontmatter,
88
expressions, `class:list`, raw content, and component semantics.
9+
- Add first-class Winnow parsing for directly quoted JSX and TSX `class` and `className`
10+
attributes while preserving host-language program text and dynamic attributes.
911
- Add `-p`, `-l`, and `-f` aliases for `--tailwind-prefix`, `--language`, and
1012
`--stdin-filename`.
1113

@@ -19,6 +21,8 @@
1921
### Breaking changes
2022

2123
- `SourceLanguage` now includes an `Astro` variant. Downstream exhaustive matches must handle it.
24+
- `SourceLanguage` now includes a `Jsx` variant shared by `.jsx` and `.tsx` sources. Downstream
25+
exhaustive matches must handle it.
2226
- The default extractor for known markup languages only sorts actual quoted `class` and `className`
2327
attributes. Class-looking text in comments, raw elements, non-markup program expressions, and
2428
other attributes is no longer rewritten.

rustywind-core/src/app.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use std::{borrow::Cow, fmt, ops::Range};
22

33
use crate::{
4+
attribute_parser::{ClassAttribute, class_attributes},
45
class_wrapping::ClassWrapping,
56
consts::{VARIANT_SEARCHER, VARIANTS},
67
hybrid_sorter::HybridSorter,
7-
markup_parser::{ClassAttribute, class_attributes, is_attribute_name_character},
8+
markup_parser::is_attribute_name_character,
89
sorter::{FinderRegex, Sorter},
910
source::{
1011
ClassValueAnalysis, SourceDocument, StaticRuns, analyze_class_value, is_plain_class_list,
@@ -199,7 +200,7 @@ impl RustyWind {
199200
/// Checks whether a source document contains a sortable static class run
200201
pub fn has_classes(&self, document: SourceDocument<'_>) -> bool {
201202
if matches!(self.regex, FinderRegex::DefaultRegex)
202-
&& document.language().markup_profile().is_some()
203+
&& document.language().attribute_parser_profile().is_some()
203204
{
204205
return class_attributes(document).is_some_and(|attributes| {
205206
attributes
@@ -219,7 +220,7 @@ impl RustyWind {
219220
/// deduplication never crosses an expression boundary
220221
pub fn sort_document<'a>(&self, document: SourceDocument<'a>) -> Cow<'a, str> {
221222
if matches!(self.regex, FinderRegex::DefaultRegex)
222-
&& document.language().markup_profile().is_some()
223+
&& document.language().attribute_parser_profile().is_some()
223224
{
224225
return self.sort_structured_document(document);
225226
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
use std::ops::Range;
2+
3+
use crate::{
4+
jsx_parser, markup_parser,
5+
source::{AttributeParserProfile, SourceDocument},
6+
};
7+
8+
#[derive(Debug, Clone, PartialEq, Eq)]
9+
pub(crate) struct ClassAttribute(Range<usize>);
10+
11+
impl ClassAttribute {
12+
pub(crate) const fn new(value: Range<usize>) -> Self {
13+
Self(value)
14+
}
15+
16+
pub(crate) fn value_range(&self) -> Range<usize> {
17+
self.0.clone()
18+
}
19+
}
20+
21+
pub(crate) fn class_attributes(document: SourceDocument<'_>) -> Option<Vec<ClassAttribute>> {
22+
match document.language().attribute_parser_profile()? {
23+
AttributeParserProfile::Markup(profile) => {
24+
markup_parser::class_attributes(document.text(), profile)
25+
}
26+
AttributeParserProfile::Jsx => jsx_parser::class_attributes(document.text()),
27+
}
28+
}

0 commit comments

Comments
 (0)