@saykit/transform-jsx 0.6.1 → 0.8.0

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.
Files changed (2) hide show
  1. package/dist/parser.mjs +91 -45
  2. package/package.json +3 -3
package/dist/parser.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { AUTO_INCREMENT_IDENTIFIER, ArgumentMessage, ChoiceMessage, CompositeMessage, ElementMessage, LiteralMessage, validateBranchIdentifier } from "@saykit/config/features/messages";
1
+ import { AUTO_INCREMENT_IDENTIFIER, ArgumentMessage, ChoiceMessage, CompositeMessage, ElementMessage, LiteralMessage, isArgumentType, validateArgumentStyle, validateBranchIdentifier } from "@saykit/config/features/messages";
2
2
  import { unwrapPlaceholder } from "@saykit/transform-js/parser";
3
3
  import * as t from "@babel/types";
4
4
  //#region src/parser.ts
@@ -14,21 +14,7 @@ function parseJSXContainerElement(element) {
14
14
  const processed = processJSXOpeningElement(element.openingElement);
15
15
  if (!processed) return null;
16
16
  const [accessor] = processed;
17
- const children = element.children.reduce((p, c, i, a) => {
18
- if (t.isJSXText(c)) {
19
- const text = normalizeJSXText(c.value, i === 0, i === a.length - 1);
20
- if (text) p.push(new LiteralMessage(text));
21
- }
22
- if (t.isJSXElement(c)) p.push(parseJSXElement(c, true));
23
- else if (t.isJSXFragment(c)) p.push(new ElementMessage(AUTO_INCREMENT_IDENTIFIER, [], c));
24
- else if (t.isJSXExpressionContainer(c)) {
25
- if (t.isExpression(c.expression)) {
26
- const [identifier, value] = unwrapPlaceholder(c.expression);
27
- p.push(new ArgumentMessage(identifier, value));
28
- }
29
- }
30
- return p;
31
- }, []);
17
+ const children = buildMessageChildren(element);
32
18
  const descriptorId = findAttributeValueIfStringLiteralAsString(element.openingElement.attributes, "id");
33
19
  const descriptorContext = findAttributeValueIfStringLiteralAsString(element.openingElement.attributes, "context");
34
20
  const whitespace = findAttributeValueAsBoolean(element.openingElement.attributes, "whitespace");
@@ -42,15 +28,23 @@ function parseJSXOpeningElement(element) {
42
28
  const processed = processJSXOpeningElement(element);
43
29
  if (!processed) return null;
44
30
  const [accessor, kind] = processed;
31
+ const descriptorId = findAttributeValueIfStringLiteralAsString(element.attributes, "id");
32
+ const descriptorContext = findAttributeValueIfStringLiteralAsString(element.attributes, "context");
33
+ const wrap = (children) => new CompositeMessage({
34
+ id: descriptorId,
35
+ context: descriptorContext
36
+ }, [], getReferences(element), children, accessor);
45
37
  if (typeof kind === "string" && [
46
38
  "select",
47
39
  "ordinal",
48
40
  "plural"
49
41
  ].includes(kind)) {
42
+ const offset = kind === "select" ? void 0 : findPluralOffset(element.attributes);
50
43
  const branches = element.attributes.reduce((b, a) => {
51
44
  if (!t.isJSXAttribute(a)) return b;
52
45
  let identifier = getAttributeNameAsString(a);
53
46
  if (identifier === "_" || identifier === "id" || identifier === "context") return b;
47
+ if (offset !== void 0 && identifier === "offset") return b;
54
48
  if (identifier.startsWith("_") && identifier.length > 1 && !Number.isNaN(+identifier.slice(1))) identifier = identifier.slice(1);
55
49
  if (t.isStringLiteral(a.value)) b.push({
56
50
  identifier,
@@ -61,11 +55,13 @@ function parseJSXOpeningElement(element) {
61
55
  identifier,
62
56
  value: parseJSXElement(a.value.expression, true)
63
57
  });
64
- else if (t.isJSXFragment(a.value.expression)) b.push({
65
- identifier,
66
- value: new ElementMessage(AUTO_INCREMENT_IDENTIFIER, [], a.value.expression)
67
- });
68
- else if (t.isExpression(a.value.expression)) {
58
+ else if (t.isJSXFragment(a.value.expression)) {
59
+ const fragment = a.value.expression;
60
+ b.push({
61
+ identifier,
62
+ value: new CompositeMessage({}, [], [], buildMessageChildren(fragment), fragment)
63
+ });
64
+ } else if (t.isExpression(a.value.expression)) {
69
65
  const [name, value] = unwrapPlaceholder(a.value.expression);
70
66
  b.push({
71
67
  identifier,
@@ -79,38 +75,88 @@ function parseJSXOpeningElement(element) {
79
75
  const initialiser = findAttributeValueIfExpressionOrStringLiteral(element.attributes, "_");
80
76
  if (!initialiser) return null;
81
77
  const [identifier, selector] = unwrapPlaceholder(initialiser);
82
- const choice = new ChoiceMessage(kind, identifier, branches, selector);
83
- return new CompositeMessage({
84
- id: findAttributeValueIfStringLiteralAsString(element.attributes, "id"),
85
- context: findAttributeValueIfStringLiteralAsString(element.attributes, "context")
86
- }, [], getReferences(element), [choice], accessor);
78
+ return wrap([new ChoiceMessage(kind, identifier, branches, selector, offset)]);
79
+ }
80
+ if (typeof kind === "string" && isArgumentType(kind)) {
81
+ const initialiser = findAttributeValueIfExpressionOrStringLiteral(element.attributes, "_");
82
+ if (!initialiser) return null;
83
+ const style = findAttributeValueIfStringLiteralAsString(element.attributes, "style");
84
+ if (style !== void 0) validateArgumentStyle(kind, style);
85
+ const [identifier, value] = unwrapPlaceholder(initialiser);
86
+ return wrap([new ArgumentMessage(identifier, value, {
87
+ type: kind,
88
+ style
89
+ })]);
87
90
  }
88
91
  return null;
89
92
  }
90
93
  /**
91
- * Punctuation that never takes a space in front of it. A formatter wrapping
92
- * long JSX regularly strands these at the start of a line, on their own or
93
- * ahead of the rest of a clause.
94
+ * The `offset` a plural subtracts from its selector before `#` is formatted.
95
+ * See the JS parser's counterpart the constraints are the same, only the
96
+ * syntax carrying it differs.
94
97
  */
95
- const CLOSING_PUNCTUATION = /^[.,;:!?)\]}»”’]/;
98
+ function findPluralOffset(attributes) {
99
+ for (const attribute of attributes) {
100
+ if (!t.isJSXAttribute(attribute)) continue;
101
+ if (getAttributeNameAsString(attribute) !== "offset") continue;
102
+ if (!t.isJSXExpressionContainer(attribute.value)) continue;
103
+ const { expression } = attribute.value;
104
+ if (!t.isNumericLiteral(expression)) continue;
105
+ if (!Number.isInteger(expression.value) || expression.value < 0) continue;
106
+ return expression.value;
107
+ }
108
+ }
96
109
  /**
97
- * Collapse the whitespace in a JSX text child the way the source reads.
98
- *
99
- * A line break between a word and its neighbour is only how a formatter wrapped
100
- * a long line, so it still means a space. Dropping it would run the words
101
- * together and because ids are content hashes, silently orphan every existing
102
- * translation for the message.
110
+ * The children the JSX transform itself would compile, so a message extracts as
111
+ * the text that renders. Text children come back with their whitespace
112
+ * collapsed the way JSX collapses it a line break and the indentation around
113
+ * it are layout and disappear, while two lines that both hold text rejoin with
114
+ * the single space that separated the words every expression container comes
115
+ * back unwrapped, and a comment child comes back as nothing.
116
+ */
117
+ function buildMessageChildren(element, into = []) {
118
+ return t.react.buildChildren(element).reduce((p, c) => {
119
+ const literal = getExpressionAsLiteralText(c);
120
+ if (literal !== void 0) pushLiteral(p, literal);
121
+ else if (t.isJSXElement(c)) p.push(parseJSXElement(c, true));
122
+ else if (t.isJSXFragment(c)) buildMessageChildren(c, p);
123
+ else if (t.isExpression(c)) {
124
+ const [identifier, value] = unwrapPlaceholder(c);
125
+ p.push(new ArgumentMessage(identifier, value));
126
+ }
127
+ return p;
128
+ }, into);
129
+ }
130
+ /**
131
+ * Add text to the message, folding it into the literal in front of it when
132
+ * there is one, so `Hello{' '}world` is three children in the source and one
133
+ * run of text in the catalogue.
134
+ */
135
+ function pushLiteral(messages, text) {
136
+ if (!text) return;
137
+ const last = messages.at(-1);
138
+ if (last instanceof LiteralMessage) messages[messages.length - 1] = new LiteralMessage(last.text + text);
139
+ else messages.push(new LiteralMessage(text));
140
+ }
141
+ /**
142
+ * The text a child renders as, when that is knowable at build time — a text
143
+ * child, which `buildChildren` has already collapsed into a string, or an
144
+ * expression holding a literal with nothing interpolated into it.
103
145
  *
104
- * The exception is a line that begins with punctuation, which belongs tight
105
- * against whatever precedes it. Only an implicit break is treated this way: a
106
- * space the author wrote on the same line is deliberate and always survives.
146
+ * The second is what makes `{' '}` the way to write whitespace that has to
147
+ * survive a line break: it extracts as the space it renders as, rather than as
148
+ * a placeholder a translator can neither see nor move.
107
149
  */
108
- function normalizeJSXText(value, isFirst, isLast) {
109
- let text = value.replace(/\s+/g, " ");
110
- if (isFirst) text = text.trimStart();
111
- else if (text.startsWith(" ") && /^[^\S\n]*\n/.test(value) && CLOSING_PUNCTUATION.test(text.slice(1))) text = text.slice(1);
112
- if (isLast) text = text.trimEnd();
113
- return text;
150
+ function getExpressionAsLiteralText(expression) {
151
+ if (t.isStringLiteral(expression)) return expression.value;
152
+ if (t.isNumericLiteral(expression)) return String(expression.value);
153
+ if (t.isUnaryExpression(expression) && (expression.operator === "-" || expression.operator === "+") && t.isNumericLiteral(expression.argument)) return String(expression.operator === "-" ? -expression.argument.value : expression.argument.value);
154
+ if (t.isBooleanLiteral(expression) || t.isNullLiteral(expression)) return "";
155
+ if (t.isTemplateLiteral(expression) && expression.expressions.length === 0) {
156
+ const [chunk] = expression.quasis;
157
+ /* v8 ignore next */
158
+ return chunk?.value.cooked ?? "";
159
+ }
114
160
  }
115
161
  function processJSXOpeningElement(element) {
116
162
  if (t.isJSXIdentifier(element.name) && element.name.name === "Say") return [element.name, null];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saykit/transform-jsx",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "JSX and TSX source transformer for SayKit",
5
5
  "keywords": [
6
6
  "i18n",
@@ -47,8 +47,8 @@
47
47
  "@babel/parser": "^7.29.7",
48
48
  "@babel/traverse": "^7.29.7",
49
49
  "@babel/types": "^7.29.7",
50
- "@saykit/config": "^0.6.1",
51
- "@saykit/transform-js": "^0.6.1"
50
+ "@saykit/config": "^0.8.0",
51
+ "@saykit/transform-js": "^0.8.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@babel/core": "^7.29.7",