diff --git a/README.md b/README.md index 4d7c330..42a27af 100644 --- a/README.md +++ b/README.md @@ -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" + // } }, ], ], diff --git a/src/builder.rs b/src/builder.rs index 125e3b7..ceefa2d 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -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 = + once_cell::sync::Lazy::new(|| regex::Regex::new(r"^\d+$").unwrap()); +static VALID_NAME_REGEX: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| regex::Regex::new(r"^[a-zA-Z_]([\w.-]*\w)?$").unwrap()); + fn dedup_values(mut v: Vec) -> Vec { let mut uniques = HashSet::new(); v.retain(|e| uniques.insert(e.placeholder.clone())); @@ -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, }))) } @@ -36,24 +53,31 @@ pub struct MessageBuilderResult { pub components: Option>, } -pub struct MessageBuilder { +pub struct MessageBuilder<'a> { message: String, - components_stack: Vec, + components_stack: Vec, components: Vec, values: Vec, values_indexed: Vec, + + options: &'a LinguiOptions, + elements_tracking: Vec<(String, JSXOpeningElement)>, + element_index: usize, } -impl MessageBuilder { - pub fn parse(tokens: Vec) -> MessageBuilderResult { +impl<'a> MessageBuilder<'a> { + pub fn parse(tokens: Vec, 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); @@ -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 = 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."), + ).emit(); + }); + } + + 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. ``)"); + 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 + }; + 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!("")); + if let Some(name) = self.components_stack.pop() { + self.push_msg(&format!("")); } else { // todo JSX tags mismatch. write tests for tags mismatch, swc should not crash in that case } diff --git a/src/js_macro_folder.rs b/src/js_macro_folder.rs index 14ce7dc..f9c577a 100644 --- a/src/js_macro_folder.rs +++ b/src/js_macro_folder.rs @@ -31,7 +31,7 @@ where } fn create_message_descriptor_from_tokens(&mut self, tokens: Vec, span: Span) -> Expr { - let parsed = MessageBuilder::parse(tokens); + let parsed = MessageBuilder::parse(tokens, &self.ctx.options); let mut props: Vec = vec![create_key_value_prop( "id", @@ -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( diff --git a/src/lib.rs b/src/lib.rs index 9ab17bf..89f43ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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()); @@ -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) diff --git a/src/options.rs b/src/options.rs index a76743c..943ab27 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use std::collections::HashMap; #[derive(Deserialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] @@ -6,6 +7,10 @@ pub struct LinguiJsOptions { runtime_modules: Option, #[serde(default)] strip_non_essential_fields: Option, + #[serde(default)] + jsx_placeholder_attribute: Option, + #[serde(default)] + jsx_placeholder_defaults: Option>, } #[derive(Deserialize, Debug, PartialEq)] @@ -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 @@ -77,6 +84,8 @@ impl LinguiJsOptions { #[derive(Debug, Clone)] pub struct LinguiOptions { pub strip_non_essential_fields: bool, + pub jsx_placeholder_attribute: Option, + pub jsx_placeholder_defaults: Option>, pub runtime_modules: RuntimeModulesConfigMapNormalized, } @@ -84,6 +93,8 @@ 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()), @@ -128,6 +139,8 @@ mod lib_tests { )), }), strip_non_essential_fields: None, + jsx_placeholder_attribute: None, + jsx_placeholder_defaults: None, } ) } @@ -152,6 +165,8 @@ mod lib_tests { use_lingui: None, }), strip_non_essential_fields: None, + jsx_placeholder_attribute: None, + jsx_placeholder_defaults: None, } ) } @@ -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::( + 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::( diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_allows_dotted.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_allows_dotted.js new file mode 100644 index 0000000..961e1f0 --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_allows_dotted.js @@ -0,0 +1,8 @@ +import { Trans as Trans_ } from "@lingui/react"; + + }, + message: "click" +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_allows_hyphenated.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_allows_hyphenated.js new file mode 100644 index 0000000..7b075bf --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_allows_hyphenated.js @@ -0,0 +1,8 @@ +import { Trans as Trans_ } from "@lingui/react"; + + }, + message: "click" +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_attribute_ignored_when_not_configured.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_attribute_ignored_when_not_configured.js new file mode 100644 index 0000000..21a689c --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_attribute_ignored_when_not_configured.js @@ -0,0 +1,8 @@ +import { Trans as Trans_ } from "@lingui/react"; + + }, + message: "Hello <0>world!" +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_basic.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_basic.js new file mode 100644 index 0000000..6b3dbdc --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_basic.js @@ -0,0 +1,8 @@ +import { Trans as Trans_ } from "@lingui/react"; + + }, + message: "Hello world!" +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_different_props.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_different_props.js new file mode 100644 index 0000000..c426058 --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_different_props.js @@ -0,0 +1,9 @@ +import { Trans as Trans_ } from "@lingui/react"; +, + a2: + }, + message: "Hello link 1, normal, link 2." +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_identical.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_identical.js new file mode 100644 index 0000000..c8793e4 --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_identical.js @@ -0,0 +1,8 @@ +import { Trans as Trans_ } from "@lingui/react"; + + }, + message: "Hello emphasis, normal, more emphasis." +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_with_stripped_props.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_with_stripped_props.js new file mode 100644 index 0000000..f1b496b --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_deduplication_with_stripped_props.js @@ -0,0 +1,9 @@ +import { Trans as Trans_ } from "@lingui/react"; +, + link2: + }, + message: "Hello link 1, normal, link 1 copy and link 2." +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_defaults.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_defaults.js new file mode 100644 index 0000000..55acd3e --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_defaults.js @@ -0,0 +1,9 @@ +import { Trans as Trans_ } from "@lingui/react"; +, + em: + }, + message: "Here's a link and emphasis." +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_identical_spreads_reused.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_identical_spreads_reused.js new file mode 100644 index 0000000..05dfa6a --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_identical_spreads_reused.js @@ -0,0 +1,8 @@ +import { Trans as Trans_ } from "@lingui/react"; + + }, + message: "A B" +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_mixed_explicit_and_defaults.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_mixed_explicit_and_defaults.js new file mode 100644 index 0000000..edba351 --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_mixed_explicit_and_defaults.js @@ -0,0 +1,9 @@ +import { Trans as Trans_ } from "@lingui/react"; +, + link2: + }, + message: "Hello link 1, normal, link 2." +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_prop_order.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_prop_order.js new file mode 100644 index 0000000..99ff206 --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_prop_order.js @@ -0,0 +1,8 @@ +import { Trans as Trans_ } from "@lingui/react"; + + }, + message: "Hello link 1, normal, link 1 copy." +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_prop_order2.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_prop_order2.js new file mode 100644 index 0000000..9caf556 --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_prop_order2.js @@ -0,0 +1,9 @@ +import { Trans as Trans_ } from "@lingui/react"; +, + link2: + }, + message: "Hello link 1, normal, link 1 copy." +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_stripped_ast.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_stripped_ast.js new file mode 100644 index 0000000..9f6c088 --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_stripped_ast.js @@ -0,0 +1,8 @@ +import { Trans as Trans_ } from "@lingui/react"; + + }, + message: "About" +}}/>; diff --git a/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_throws_on_non_string_attribute_value.js b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_throws_on_non_string_attribute_value.js new file mode 100644 index 0000000..6610fb6 --- /dev/null +++ b/tests/__swc_snapshots__/tests/jsx.rs/jsx_named_placeholders_throws_on_non_string_attribute_value.js @@ -0,0 +1,9 @@ +import { Trans as Trans_ } from "@lingui/react"; +const name = "link"; + + }, + message: "<0>click" +}}/>; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 344b1db..0c058e3 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -40,3 +40,28 @@ macro_rules! to { ); }; } + +#[macro_export] +macro_rules! to_panic { + ($name:ident, $options:expr, $input:expr) => { + #[test] + #[should_panic] + fn $name() { + swc_core::ecma::transforms::testing::test_inlined_transform( + stringify!($name), + swc_core::ecma::parser::Syntax::Typescript(swc_core::ecma::parser::TsSyntax { + tsx: true, + ..Default::default() + }), + None, + |tester| { + swc_core::ecma::visit::fold_pass(lingui_macro_plugin::LinguiMacroFolder::new( + $options, + Some(tester.comments.clone()), + )) + }, + $input, + ); + } + }; +} diff --git a/tests/jsx.rs b/tests/jsx.rs index f8e8d30..b9f1c22 100644 --- a/tests/jsx.rs +++ b/tests/jsx.rs @@ -348,3 +348,311 @@ to!( // ; // `, // }, + +to!( + jsx_named_placeholders_basic, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; + + Hello world! +; + "# +); + +to!( + jsx_named_placeholders_stripped_ast, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; + + About +; + "# +); + +to!( + jsx_named_placeholders_defaults, + LinguiOptions { + jsx_placeholder_defaults: Some(std::collections::HashMap::from([ + ("a".into(), "link".into()), + ("em".into(), "em".into()), + ])), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; + + Here's a link and emphasis. +; + "# +); + +to!( + jsx_named_placeholders_mixed_explicit_and_defaults, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + jsx_placeholder_defaults: Some(std::collections::HashMap::from([( + "a".into(), + "link".into() + ),])), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; +Hello link 1, normal, link 2.; + "# +); + +to_panic!( + jsx_named_placeholders_deduplication_different_props, + LinguiOptions { + jsx_placeholder_defaults: Some(std::collections::HashMap::from( + [("a".into(), "a".into()),] + )), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; +Hello link 1, normal, link 2.; + "# +); + +to!( + jsx_named_placeholders_deduplication_identical, + LinguiOptions { + jsx_placeholder_defaults: Some(std::collections::HashMap::from([( + "em".into(), + "em".into() + ),])), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; +Hello emphasis, normal, more emphasis.; + "# +); + +to_panic!( + jsx_named_placeholders_deduplication_with_stripped_props, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; +Hello link 1, normal, link 1 copy and link 2.; + "# +); + +to!( + jsx_named_placeholders_attribute_ignored_when_not_configured, + LinguiOptions { + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; + + Hello world! +; + "# +); + +to!( + jsx_named_placeholders_prop_order, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; +Hello link 1, normal, link 1 copy.; + "# +); + +to_panic!( + jsx_named_placeholders_prop_order2, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from "@lingui/react/macro"; +Hello link 1, normal, link 1 copy.; + "# +); + +to_panic!( + jsx_named_placeholders_throws_on_non_string_attribute_value, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +const name = "link"; +click + "# +); + +to_panic!( + jsx_named_placeholders_throws_on_empty_attribute_value, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +); + +to_panic!( + jsx_named_placeholders_throws_on_numeric_name, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +); + +to!( + jsx_named_placeholders_allows_hyphenated, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +); + +to!( + jsx_named_placeholders_allows_dotted, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +); + +to_panic!( + jsx_named_placeholders_throws_starting_with_hyphen, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +); + +to_panic!( + jsx_named_placeholders_throws_ending_with_dot, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +); + +to_panic!( + jsx_named_placeholders_same_name_different_element_throws, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +A and B + "# +); + +to!( + jsx_named_placeholders_identical_spreads_reused, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +A B + "# +); + +to_panic!( + jsx_named_placeholders_different_spreads_throw, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +A B + "# +); + +to_panic!( + jsx_named_placeholders_same_spread_different_order_throws, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +A B + "# +); + +to_panic!( + jsx_named_placeholders_throws_on_empty_string, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +); + +to_panic!( + jsx_named_placeholders_throws_on_jsx_expr, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +); + +to_panic!( + jsx_named_placeholders_throws_on_boolean_expr, + LinguiOptions { + jsx_placeholder_attribute: Some("_t".into()), + ..Default::default() + }, + r#" +import { Trans } from '@lingui/react/macro'; +click + "# +);