Skip to content

Commit f4b674d

Browse files
authored
feat: add optional configurable JSX placeholder naming (#207)
1 parent 5f673bd commit f4b674d

21 files changed

Lines changed: 659 additions & 29 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ https://swc.rs/docs/configuration/swcrc
5353
// Lingui strips non-essential fields in production builds for performance.
5454
// You can override the default behavior with:
5555
// "stripNonEssentialFields": false/true
56+
// To configure custom JSX placeholder attribute and its defaults:
57+
// "jsxPlaceholderAttribute": "_t",
58+
// "jsxPlaceholderDefaults": {
59+
// "a": "link",
60+
// "em": "em"
61+
// }
5662
},
5763
],
5864
],

src/builder.rs

Lines changed: 161 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
use crate::ast_utils::expand_ts_as_expr;
2+
use crate::options::LinguiOptions;
23
use crate::tokens::{CaseOrOffset, IcuChoice, MsgToken};
34
use std::collections::HashSet;
45
use swc_core::{
5-
common::{SyntaxContext, DUMMY_SP},
6+
common::{EqIgnoreSpan, SyntaxContext, DUMMY_SP},
67
ecma::ast::*,
78
};
89

10+
static NUMERIC_REGEX: once_cell::sync::Lazy<regex::Regex> =
11+
once_cell::sync::Lazy::new(|| regex::Regex::new(r"^\d+$").unwrap());
12+
static VALID_NAME_REGEX: once_cell::sync::Lazy<regex::Regex> =
13+
once_cell::sync::Lazy::new(|| regex::Regex::new(r"^[a-zA-Z_]([\w.-]*\w)?$").unwrap());
14+
915
fn dedup_values(mut v: Vec<ValueWithPlaceholder>) -> Vec<ValueWithPlaceholder> {
1016
let mut uniques = HashSet::new();
1117
v.retain(|e| uniques.insert(e.placeholder.clone()));
@@ -20,10 +26,21 @@ pub struct ValueWithPlaceholder {
2026

2127
impl ValueWithPlaceholder {
2228
pub fn into_prop(self) -> PropOrSpread {
23-
let ident = IdentName::new(self.placeholder.into(), DUMMY_SP);
29+
let key = if self.placeholder.contains('-') || self.placeholder.contains('.') {
30+
PropName::Computed(ComputedPropName {
31+
span: DUMMY_SP,
32+
expr: Box::new(Expr::Lit(Lit::Str(Str {
33+
span: DUMMY_SP,
34+
value: self.placeholder.clone().into(),
35+
raw: None,
36+
}))),
37+
})
38+
} else {
39+
PropName::Ident(IdentName::new(self.placeholder.into(), DUMMY_SP))
40+
};
2441

2542
PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
26-
key: PropName::Ident(ident),
43+
key,
2744
value: self.value,
2845
})))
2946
}
@@ -36,24 +53,31 @@ pub struct MessageBuilderResult {
3653
pub components: Option<Box<Expr>>,
3754
}
3855

39-
pub struct MessageBuilder {
56+
pub struct MessageBuilder<'a> {
4057
message: String,
4158

42-
components_stack: Vec<usize>,
59+
components_stack: Vec<String>,
4360
components: Vec<ValueWithPlaceholder>,
4461

4562
values: Vec<ValueWithPlaceholder>,
4663
values_indexed: Vec<ValueWithPlaceholder>,
64+
65+
options: &'a LinguiOptions,
66+
elements_tracking: Vec<(String, JSXOpeningElement)>,
67+
element_index: usize,
4768
}
4869

49-
impl MessageBuilder {
50-
pub fn parse(tokens: Vec<MsgToken>) -> MessageBuilderResult {
70+
impl<'a> MessageBuilder<'a> {
71+
pub fn parse(tokens: Vec<MsgToken>, options: &'a LinguiOptions) -> MessageBuilderResult {
5172
let mut builder = MessageBuilder {
5273
message: String::new(),
5374
components_stack: Vec::new(),
5475
components: Vec::new(),
5576
values: Vec::new(),
5677
values_indexed: Vec::new(),
78+
options,
79+
elements_tracking: Vec::new(),
80+
element_index: 0,
5781
};
5882

5983
builder.process_tokens(tokens);
@@ -133,30 +157,143 @@ impl MessageBuilder {
133157
self.message.push_str(val);
134158
}
135159

136-
fn push_tag_opening(&mut self, el: JSXOpeningElement, self_closing: bool) {
137-
let current = self.components.len();
160+
fn push_tag_opening(&mut self, mut el: JSXOpeningElement, self_closing: bool) {
161+
let mut base_name: Option<String> = None;
162+
163+
if let Some(attr_name) = &self.options.jsx_placeholder_attribute {
164+
if let Some(idx) = el.attrs.iter().position(|a| {
165+
if let JSXAttrOrSpread::JSXAttr(attr) = a {
166+
if let JSXAttrName::Ident(ident) = &attr.name {
167+
return &ident.sym == attr_name;
168+
}
169+
}
170+
false
171+
}) {
172+
let attr = el.attrs.remove(idx);
173+
if let JSXAttrOrSpread::JSXAttr(attr) = attr {
174+
let mut is_valid = false;
175+
if let Some(JSXAttrValue::Str(s)) = attr.value {
176+
let val = s.value.to_string_lossy().into_owned();
177+
if !val.is_empty() {
178+
base_name = Some(val);
179+
is_valid = true;
180+
}
181+
}
182+
183+
if !is_valid {
184+
swc_core::plugin::errors::HANDLER.with(|h| {
185+
h.struct_span_err(
186+
el.span,
187+
&format!("The `{attr_name}` attribute must be a non-empty string literal."),
188+
).emit();
189+
});
190+
}
191+
}
192+
}
193+
}
194+
195+
if base_name.is_none() {
196+
if let Some(defaults) = &self.options.jsx_placeholder_defaults {
197+
if let JSXElementName::Ident(ident) = &el.name {
198+
if let Some(def) = defaults.get(&ident.sym.to_string()) {
199+
base_name = Some(def.clone());
200+
}
201+
}
202+
}
203+
}
204+
205+
let name = if let Some(n) = base_name {
206+
if NUMERIC_REGEX.is_match(&n) {
207+
swc_core::plugin::errors::HANDLER.with(|h| {
208+
h.struct_span_err(
209+
el.span,
210+
&format!("Placeholder name `{n}` is not allowed because it conflicts with auto-generated numeric placeholders. Use a non-numeric name instead."),
211+
).emit();
212+
});
213+
} else if !VALID_NAME_REGEX.is_match(&n) {
214+
swc_core::plugin::errors::HANDLER.with(|h| {
215+
h.struct_span_err(
216+
el.span,
217+
&format!("Placeholder name `{n}` is not valid. Names must start and end with a letter/digit/underscore, but may contain `.-` in between."),
218+
).emit();
219+
});
220+
}
221+
222+
if let Some((_, orig_el)) = self.elements_tracking.iter().find(|(k, _)| k == &n) {
223+
let has_spreads = orig_el
224+
.attrs
225+
.iter()
226+
.any(|a| matches!(a, JSXAttrOrSpread::SpreadElement(_)));
227+
let attrs_equal = if orig_el.attrs.len() == el.attrs.len() {
228+
if has_spreads {
229+
orig_el
230+
.attrs
231+
.iter()
232+
.zip(el.attrs.iter())
233+
.all(|(a, b)| a.eq_ignore_span(b))
234+
} else {
235+
orig_el
236+
.attrs
237+
.iter()
238+
.all(|a| el.attrs.iter().any(|b| a.eq_ignore_span(b)))
239+
}
240+
} else {
241+
false
242+
};
243+
244+
let tags_equal = el.name.eq_ignore_span(&orig_el.name);
245+
246+
if !tags_equal || !attrs_equal {
247+
swc_core::plugin::errors::HANDLER.with(|h| {
248+
let attr_name = self.options.jsx_placeholder_attribute.as_deref().unwrap_or("_t");
249+
let eg = format!("(e.g. `<element {attr_name}=\"newName\" />`)");
250+
let msg = format!(
251+
"Multiple distinct JSX elements with the same placeholder name (`{n}`). Differentiate them by {} {eg}.",
252+
if self.options.jsx_placeholder_attribute.is_some() {
253+
format!("adding/modifying the `{attr_name}` attribute")
254+
} else {
255+
"setting `macro.jsxPlaceholderAttribute` in the lingui config and then adding the attribute to your JSX elements".to_string()
256+
}
257+
);
258+
h.struct_span_err(el.span, &msg).emit();
259+
});
260+
}
261+
} else {
262+
self.elements_tracking.push((n.clone(), el.clone()));
263+
}
264+
265+
n
266+
} else {
267+
let n = self.element_index.to_string();
268+
self.element_index += 1;
269+
self.elements_tracking.push((n.clone(), el.clone()));
270+
n
271+
};
272+
138273
if self_closing {
139-
self.push_msg(&format!("<{current}/>"));
274+
self.push_msg(&format!("<{name}/>"));
140275
} else {
141-
self.components_stack.push(current);
142-
self.push_msg(&format!("<{current}>"));
276+
self.components_stack.push(name.clone());
277+
self.push_msg(&format!("<{name}>"));
143278
}
144279

145-
// todo: it looks very dirty and bad to cloning this jsx values
146-
self.components.push(ValueWithPlaceholder {
147-
placeholder: self.components.len().to_string(),
148-
value: Box::new(Expr::JSXElement(Box::new(JSXElement {
149-
opening: el,
150-
closing: None,
151-
children: vec![],
152-
span: DUMMY_SP,
153-
}))),
154-
});
280+
if !self.components.iter().any(|c| c.placeholder == name) {
281+
// todo: it looks very dirty and bad to cloning this jsx values
282+
self.components.push(ValueWithPlaceholder {
283+
placeholder: name.clone(),
284+
value: Box::new(Expr::JSXElement(Box::new(JSXElement {
285+
opening: el,
286+
closing: None,
287+
children: vec![],
288+
span: DUMMY_SP,
289+
}))),
290+
});
291+
}
155292
}
156293

157294
fn push_tag_closing(&mut self) {
158-
if let Some(index) = self.components_stack.pop() {
159-
self.push_msg(&format!("</{index}>"));
295+
if let Some(name) = self.components_stack.pop() {
296+
self.push_msg(&format!("</{name}>"));
160297
} else {
161298
// todo JSX tags mismatch. write tests for tags mismatch, swc should not crash in that case
162299
}

src/js_macro_folder.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ where
3131
}
3232

3333
fn create_message_descriptor_from_tokens(&mut self, tokens: Vec<MsgToken>, span: Span) -> Expr {
34-
let parsed = MessageBuilder::parse(tokens);
34+
let parsed = MessageBuilder::parse(tokens, &self.ctx.options);
3535

3636
let mut props: Vec<PropOrSpread> = vec![create_key_value_prop(
3737
"id",
@@ -115,7 +115,7 @@ where
115115
if let Some(prop) = message_prop {
116116
let tokens = self.ctx.try_tokenize_expr(&prop.value).unwrap_or_default();
117117

118-
let parsed = MessageBuilder::parse(tokens);
118+
let parsed = MessageBuilder::parse(tokens, &self.ctx.options);
119119

120120
if id_prop.is_none() {
121121
new_props.push(create_key_value_prop(

src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ where
115115
el.visit_children_with(&mut trans_visitor);
116116
}
117117

118-
let parsed = MessageBuilder::parse(trans_visitor.tokens);
118+
let parsed = MessageBuilder::parse(trans_visitor.tokens, &self.ctx.options);
119119
let id_attr = get_jsx_attr(&el.opening, "id").and_then(|attr| attr.value.as_ref());
120120
let context_attr =
121121
get_jsx_attr(&el.opening, "context").and_then(|attr| attr.value.as_ref());
@@ -330,11 +330,11 @@ r#"You have to destructure `t` when using the `useLingui` macro, i.e:
330330
};
331331

332332
// use lingui matched above
333-
if ident_replacer.is_some() {
333+
if let Some(mut replacer) = ident_replacer {
334334
block = block
335335
.fold_children_with(&mut JsMacroFolder::new(&mut ctx, &self.comments))
336336
// replace other
337-
.fold_children_with(&mut ident_replacer.unwrap());
337+
.fold_children_with(&mut replacer);
338338
}
339339

340340
block.fold_children_with(self)

src/options.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
use serde::Deserialize;
2+
use std::collections::HashMap;
23

34
#[derive(Deserialize, Debug, PartialEq)]
45
#[serde(rename_all = "camelCase")]
56
pub struct LinguiJsOptions {
67
runtime_modules: Option<RuntimeModulesConfigMap>,
78
#[serde(default)]
89
strip_non_essential_fields: Option<bool>,
10+
#[serde(default)]
11+
jsx_placeholder_attribute: Option<String>,
12+
#[serde(default)]
13+
jsx_placeholder_defaults: Option<HashMap<String, String>>,
914
}
1015

1116
#[derive(Deserialize, Debug, PartialEq)]
@@ -32,6 +37,8 @@ impl LinguiJsOptions {
3237
strip_non_essential_fields: self
3338
.strip_non_essential_fields
3439
.unwrap_or(matches!(env_name, "production")),
40+
jsx_placeholder_attribute: self.jsx_placeholder_attribute.clone(),
41+
jsx_placeholder_defaults: self.jsx_placeholder_defaults.clone(),
3542
runtime_modules: RuntimeModulesConfigMapNormalized {
3643
i18n: (
3744
self.runtime_modules
@@ -77,13 +84,17 @@ impl LinguiJsOptions {
7784
#[derive(Debug, Clone)]
7885
pub struct LinguiOptions {
7986
pub strip_non_essential_fields: bool,
87+
pub jsx_placeholder_attribute: Option<String>,
88+
pub jsx_placeholder_defaults: Option<HashMap<String, String>>,
8089
pub runtime_modules: RuntimeModulesConfigMapNormalized,
8190
}
8291

8392
impl Default for LinguiOptions {
8493
fn default() -> LinguiOptions {
8594
LinguiOptions {
8695
strip_non_essential_fields: false,
96+
jsx_placeholder_attribute: None,
97+
jsx_placeholder_defaults: None,
8798
runtime_modules: RuntimeModulesConfigMapNormalized {
8899
i18n: ("@lingui/core".into(), "i18n".into()),
89100
trans: ("@lingui/react".into(), "Trans".into()),
@@ -128,6 +139,8 @@ mod lib_tests {
128139
)),
129140
}),
130141
strip_non_essential_fields: None,
142+
jsx_placeholder_attribute: None,
143+
jsx_placeholder_defaults: None,
131144
}
132145
)
133146
}
@@ -152,6 +165,8 @@ mod lib_tests {
152165
use_lingui: None,
153166
}),
154167
strip_non_essential_fields: None,
168+
jsx_placeholder_attribute: None,
169+
jsx_placeholder_defaults: None,
155170
}
156171
)
157172
}
@@ -181,6 +196,27 @@ mod lib_tests {
181196
assert!(!options.strip_non_essential_fields);
182197
}
183198

199+
#[test]
200+
fn test_jsx_placeholder_config() {
201+
let config = serde_json::from_str::<LinguiJsOptions>(
202+
r#"{
203+
"jsxPlaceholderAttribute": "_t",
204+
"jsxPlaceholderDefaults": {
205+
"a": "link",
206+
"em": "emphasis"
207+
}
208+
}"#,
209+
)
210+
.unwrap();
211+
212+
let options = config.into_options("development");
213+
assert_eq!(options.jsx_placeholder_attribute.unwrap(), "_t");
214+
215+
let defaults = options.jsx_placeholder_defaults.unwrap();
216+
assert_eq!(defaults.get("a").unwrap(), "link");
217+
assert_eq!(defaults.get("em").unwrap(), "emphasis");
218+
}
219+
184220
#[test]
185221
fn test_strip_non_essential_fields_default() {
186222
let config = serde_json::from_str::<LinguiJsOptions>(
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Trans as Trans_ } from "@lingui/react";
2+
<Trans_ {.../*i18n*/ {
3+
id: "/0hJpt",
4+
components: {
5+
["ns.link"]: <a href="/"/>
6+
},
7+
message: "<ns.link>click</ns.link>"
8+
}}/>;

0 commit comments

Comments
 (0)