Skip to content
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ https://swc.rs/docs/configuration/swcrc
// Lingui strips non-essential fields in production builds for performance.
// You can override the default behavior with:
// "stripNonEssentialFields": false/true
// To configure custom JSX placeholder attribute and its defaults:
// "jsxPlaceholderAttribute": "_t",
// "jsxPlaceholderDefaults": {
// "a": "link",
// "em": "em"
// }
},
],
],
Expand Down
185 changes: 161 additions & 24 deletions src/builder.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
use crate::ast_utils::expand_ts_as_expr;
use crate::options::LinguiOptions;
use crate::tokens::{CaseOrOffset, IcuChoice, MsgToken};
use std::collections::HashSet;
use swc_core::{
common::{SyntaxContext, DUMMY_SP},
common::{EqIgnoreSpan, SyntaxContext, DUMMY_SP},
ecma::ast::*,
};

static NUMERIC_REGEX: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(r"^\d+$").unwrap());
static VALID_NAME_REGEX: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(r"^[a-zA-Z_]([\w.-]*\w)?$").unwrap());

fn dedup_values(mut v: Vec<ValueWithPlaceholder>) -> Vec<ValueWithPlaceholder> {
let mut uniques = HashSet::new();
v.retain(|e| uniques.insert(e.placeholder.clone()));
Expand All @@ -20,10 +26,21 @@ pub struct ValueWithPlaceholder {

impl ValueWithPlaceholder {
pub fn into_prop(self) -> PropOrSpread {
let ident = IdentName::new(self.placeholder.into(), DUMMY_SP);
let key = if self.placeholder.contains('-') || self.placeholder.contains('.') {
PropName::Computed(ComputedPropName {
span: DUMMY_SP,
expr: Box::new(Expr::Lit(Lit::Str(Str {
span: DUMMY_SP,
value: self.placeholder.clone().into(),
raw: None,
}))),
})
} else {
PropName::Ident(IdentName::new(self.placeholder.into(), DUMMY_SP))
};

PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
key: PropName::Ident(ident),
key,
value: self.value,
})))
}
Expand All @@ -36,24 +53,31 @@ pub struct MessageBuilderResult {
pub components: Option<Box<Expr>>,
}

pub struct MessageBuilder {
pub struct MessageBuilder<'a> {
message: String,

components_stack: Vec<usize>,
components_stack: Vec<String>,
components: Vec<ValueWithPlaceholder>,

values: Vec<ValueWithPlaceholder>,
values_indexed: Vec<ValueWithPlaceholder>,

options: &'a LinguiOptions,
elements_tracking: Vec<(String, JSXOpeningElement)>,
element_index: usize,
}

impl MessageBuilder {
pub fn parse(tokens: Vec<MsgToken>) -> MessageBuilderResult {
impl<'a> MessageBuilder<'a> {
pub fn parse(tokens: Vec<MsgToken>, options: &'a LinguiOptions) -> MessageBuilderResult {
let mut builder = MessageBuilder {
message: String::new(),
components_stack: Vec::new(),
components: Vec::new(),
values: Vec::new(),
values_indexed: Vec::new(),
options,
elements_tracking: Vec::new(),
element_index: 0,
};

builder.process_tokens(tokens);
Expand Down Expand Up @@ -133,30 +157,143 @@ impl MessageBuilder {
self.message.push_str(val);
}

fn push_tag_opening(&mut self, el: JSXOpeningElement, self_closing: bool) {
let current = self.components.len();
fn push_tag_opening(&mut self, mut el: JSXOpeningElement, self_closing: bool) {
let mut base_name: Option<String> = None;

if let Some(attr_name) = &self.options.jsx_placeholder_attribute {
if let Some(idx) = el.attrs.iter().position(|a| {
if let JSXAttrOrSpread::JSXAttr(attr) = a {
if let JSXAttrName::Ident(ident) = &attr.name {
return &ident.sym == attr_name;
}
}
false
}) {
let attr = el.attrs.remove(idx);
if let JSXAttrOrSpread::JSXAttr(attr) = attr {
let mut is_valid = false;
if let Some(JSXAttrValue::Str(s)) = attr.value {
let val = s.value.to_string_lossy().into_owned();
if !val.is_empty() {
base_name = Some(val);
is_valid = true;
}
}

if !is_valid {
swc_core::plugin::errors::HANDLER.with(|h| {
h.struct_span_err(
el.span,
&format!("The `{attr_name}` attribute must be a non-empty string literal."),
).emit();
});
}
}
}
}

if base_name.is_none() {
if let Some(defaults) = &self.options.jsx_placeholder_defaults {
if let JSXElementName::Ident(ident) = &el.name {
if let Some(def) = defaults.get(&ident.sym.to_string()) {
base_name = Some(def.clone());
}
}
}
}

let name = if let Some(n) = base_name {
if NUMERIC_REGEX.is_match(&n) {
swc_core::plugin::errors::HANDLER.with(|h| {
h.struct_span_err(
el.span,
&format!("Placeholder name `{n}` is not allowed because it conflicts with auto-generated numeric placeholders. Use a non-numeric name instead."),
).emit();
});
} else if !VALID_NAME_REGEX.is_match(&n) {
swc_core::plugin::errors::HANDLER.with(|h| {
h.struct_span_err(
el.span,
&format!("Placeholder name `{n}` is not valid. Names must start and end with a letter/digit/underscore, but may contain `.-` in between."),
Comment thread
mogelbrod marked this conversation as resolved.
).emit();
});
}
Comment thread
mogelbrod marked this conversation as resolved.

if let Some((_, orig_el)) = self.elements_tracking.iter().find(|(k, _)| k == &n) {
let has_spreads = orig_el
.attrs
.iter()
.any(|a| matches!(a, JSXAttrOrSpread::SpreadElement(_)));
let attrs_equal = if orig_el.attrs.len() == el.attrs.len() {
if has_spreads {
orig_el
.attrs
.iter()
.zip(el.attrs.iter())
.all(|(a, b)| a.eq_ignore_span(b))
} else {
orig_el
.attrs
.iter()
.all(|a| el.attrs.iter().any(|b| a.eq_ignore_span(b)))
}
} else {
false
};

let tags_equal = el.name.eq_ignore_span(&orig_el.name);

if !tags_equal || !attrs_equal {
swc_core::plugin::errors::HANDLER.with(|h| {
let attr_name = self.options.jsx_placeholder_attribute.as_deref().unwrap_or("_t");
let eg = format!("(e.g. `<element {attr_name}=\"newName\" />`)");
let msg = format!(
"Multiple distinct JSX elements with the same placeholder name (`{n}`). Differentiate them by {} {eg}.",
if self.options.jsx_placeholder_attribute.is_some() {
format!("adding/modifying the `{attr_name}` attribute")
} else {
"setting `macro.jsxPlaceholderAttribute` in the lingui config and then adding the attribute to your JSX elements".to_string()
}
);
h.struct_span_err(el.span, &msg).emit();
});
}
} else {
self.elements_tracking.push((n.clone(), el.clone()));
}

n
} else {
let n = self.element_index.to_string();
self.element_index += 1;
self.elements_tracking.push((n.clone(), el.clone()));
n
};
Comment thread
mogelbrod marked this conversation as resolved.

if self_closing {
self.push_msg(&format!("<{current}/>"));
self.push_msg(&format!("<{name}/>"));
} else {
self.components_stack.push(current);
self.push_msg(&format!("<{current}>"));
self.components_stack.push(name.clone());
self.push_msg(&format!("<{name}>"));
}

// todo: it looks very dirty and bad to cloning this jsx values
self.components.push(ValueWithPlaceholder {
placeholder: self.components.len().to_string(),
value: Box::new(Expr::JSXElement(Box::new(JSXElement {
opening: el,
closing: None,
children: vec![],
span: DUMMY_SP,
}))),
});
if !self.components.iter().any(|c| c.placeholder == name) {
// todo: it looks very dirty and bad to cloning this jsx values
self.components.push(ValueWithPlaceholder {
placeholder: name.clone(),
value: Box::new(Expr::JSXElement(Box::new(JSXElement {
opening: el,
closing: None,
children: vec![],
span: DUMMY_SP,
}))),
});
}
}

fn push_tag_closing(&mut self) {
if let Some(index) = self.components_stack.pop() {
self.push_msg(&format!("</{index}>"));
if let Some(name) = self.components_stack.pop() {
self.push_msg(&format!("</{name}>"));
} else {
// todo JSX tags mismatch. write tests for tags mismatch, swc should not crash in that case
}
Expand Down
4 changes: 2 additions & 2 deletions src/js_macro_folder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ where
}

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

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

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

if id_prop.is_none() {
new_props.push(create_key_value_prop(
Expand Down
6 changes: 3 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ where
el.visit_children_with(&mut trans_visitor);
}

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

// use lingui matched above
if ident_replacer.is_some() {
if let Some(mut replacer) = ident_replacer {
block = block
.fold_children_with(&mut JsMacroFolder::new(&mut ctx, &self.comments))
// replace other
.fold_children_with(&mut ident_replacer.unwrap());
.fold_children_with(&mut replacer);
}

block.fold_children_with(self)
Expand Down
36 changes: 36 additions & 0 deletions src/options.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
use serde::Deserialize;
use std::collections::HashMap;

#[derive(Deserialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LinguiJsOptions {
runtime_modules: Option<RuntimeModulesConfigMap>,
#[serde(default)]
strip_non_essential_fields: Option<bool>,
#[serde(default)]
jsx_placeholder_attribute: Option<String>,
#[serde(default)]
jsx_placeholder_defaults: Option<HashMap<String, String>>,
Comment thread
mogelbrod marked this conversation as resolved.
}

#[derive(Deserialize, Debug, PartialEq)]
Expand All @@ -32,6 +37,8 @@ impl LinguiJsOptions {
strip_non_essential_fields: self
.strip_non_essential_fields
.unwrap_or(matches!(env_name, "production")),
jsx_placeholder_attribute: self.jsx_placeholder_attribute.clone(),
jsx_placeholder_defaults: self.jsx_placeholder_defaults.clone(),
runtime_modules: RuntimeModulesConfigMapNormalized {
i18n: (
self.runtime_modules
Expand Down Expand Up @@ -77,13 +84,17 @@ impl LinguiJsOptions {
#[derive(Debug, Clone)]
pub struct LinguiOptions {
pub strip_non_essential_fields: bool,
pub jsx_placeholder_attribute: Option<String>,
pub jsx_placeholder_defaults: Option<HashMap<String, String>>,
pub runtime_modules: RuntimeModulesConfigMapNormalized,
}

impl Default for LinguiOptions {
fn default() -> LinguiOptions {
LinguiOptions {
strip_non_essential_fields: false,
jsx_placeholder_attribute: None,
jsx_placeholder_defaults: None,
runtime_modules: RuntimeModulesConfigMapNormalized {
i18n: ("@lingui/core".into(), "i18n".into()),
trans: ("@lingui/react".into(), "Trans".into()),
Expand Down Expand Up @@ -128,6 +139,8 @@ mod lib_tests {
)),
}),
strip_non_essential_fields: None,
jsx_placeholder_attribute: None,
jsx_placeholder_defaults: None,
}
)
}
Expand All @@ -152,6 +165,8 @@ mod lib_tests {
use_lingui: None,
}),
strip_non_essential_fields: None,
jsx_placeholder_attribute: None,
jsx_placeholder_defaults: None,
}
)
}
Expand Down Expand Up @@ -181,6 +196,27 @@ mod lib_tests {
assert!(!options.strip_non_essential_fields);
}

#[test]
fn test_jsx_placeholder_config() {
let config = serde_json::from_str::<LinguiJsOptions>(
r#"{
"jsxPlaceholderAttribute": "_t",
"jsxPlaceholderDefaults": {
"a": "link",
"em": "emphasis"
}
}"#,
)
.unwrap();

let options = config.into_options("development");
assert_eq!(options.jsx_placeholder_attribute.unwrap(), "_t");

let defaults = options.jsx_placeholder_defaults.unwrap();
assert_eq!(defaults.get("a").unwrap(), "link");
assert_eq!(defaults.get("em").unwrap(), "emphasis");
}

#[test]
fn test_strip_non_essential_fields_default() {
let config = serde_json::from_str::<LinguiJsOptions>(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Trans as Trans_ } from "@lingui/react";
<Trans_ {.../*i18n*/ {
id: "/0hJpt",
components: {
["ns.link"]: <a href="/"/>
},
message: "<ns.link>click</ns.link>"
}}/>;
Loading
Loading