@saykit/transform-js 0.5.0 → 0.6.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.
@@ -4,7 +4,7 @@ import * as t from "@babel/types";
4
4
  function generateSayCallExpression(message) {
5
5
  const id = message.descriptor.id ?? message.toHashString();
6
6
  const children = generateChildExpressions(message.children);
7
- const properties = t.objectExpression([t.objectProperty(t.identifier("id"), t.stringLiteral(id)), ...children.map(([ident, expr]) => t.objectProperty(t.identifier(ident), expr))]);
7
+ const properties = t.objectExpression([t.objectProperty(t.identifier("id"), t.stringLiteral(id)), ...[...new Map(children).entries()].map(([ident, expr]) => t.objectProperty(t.identifier(`_${ident}`), expr))]);
8
8
  return t.callExpression(t.memberExpression(message.accessor, t.identifier("call")), [properties]);
9
9
  }
10
10
  function generateChildExpressions(messages) {
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { generateSayCallExpression } from "./generator.mjs";
2
- import { parseExpression } from "./parser.mjs";
2
+ import { isEquivalentPlaceholder, parseExpression } from "./parser.mjs";
3
3
  import { generate } from "@babel/generator";
4
4
  import * as parser from "@babel/parser";
5
5
  import traverse_ from "@babel/traverse";
@@ -44,7 +44,7 @@ function createJsTransformer() {
44
44
  path.node.leadingComments = path.node.leadingComments ?? [];
45
45
  const message = parseExpression(path.node);
46
46
  if (message) {
47
- assignSequenceIdentifiers(message, { current: 0 });
47
+ assignSequenceIdentifiers(message, { current: 0 }, isEquivalentPlaceholder);
48
48
  messages.push(message);
49
49
  path.skip();
50
50
  }
@@ -64,7 +64,7 @@ function createJsTransformer() {
64
64
  path.node.leadingComments = path.node.leadingComments ?? [];
65
65
  const message = parseExpression(path.node);
66
66
  if (message) {
67
- assignSequenceIdentifiers(message, { current: 0 });
67
+ assignSequenceIdentifiers(message, { current: 0 }, isEquivalentPlaceholder);
68
68
  const replacement = generateSayCallExpression(message);
69
69
  path.replaceWith(replacement);
70
70
  path.skip();
package/dist/parser.d.mts CHANGED
@@ -1,10 +1,32 @@
1
- import { ArgumentMessage, CompositeMessage } from "@saykit/config/features/messages";
1
+ import { AUTO_INCREMENT_IDENTIFIER, ArgumentMessage, CompositeMessage } from "@saykit/config/features/messages";
2
2
  import * as t from "@babel/types";
3
3
  //#region src/parser.d.ts
4
4
  declare function parseTaggedTemplateExpression(tagged: t.TaggedTemplateExpression): CompositeMessage | null;
5
5
  declare function parseCallExpression(call: t.CallExpression): CompositeMessage | null;
6
6
  declare function processExpression(expression: t.Node): [t.Expression, t.ObjectExpression | null, string | null] | null;
7
+ /**
8
+ * Read the name off a value written as a single-key object and hand back the
9
+ * value alone, so the wrapper never reaches the output: `${{ total: sum() }}`
10
+ * is `{total}` rather than `{0}`.
11
+ *
12
+ * An inline one-key object is the only shape this claims, and it is a shape
13
+ * that could not mean anything else — interpolating an object stringifies it
14
+ * to `[object Object]`, and rendering one in JSX throws. Anything else is
15
+ * returned untouched and named the way it always was.
16
+ */
17
+ declare function unwrapPlaceholder(expression: t.Expression): [string | typeof AUTO_INCREMENT_IDENTIFIER, t.Expression];
18
+ /**
19
+ * Whether two placeholders sharing a name are the same placeholder, and so
20
+ * compile to one prop a translator can move around the sentence freely.
21
+ *
22
+ * A value is compared whole: the same variable interpolated twice is one value,
23
+ * while `${name}` beside `${{ name: author.name }}` is two things claiming one
24
+ * name. An element is compared on its opening element alone — `say-tag` has
25
+ * been stripped by the time this runs, and its children come from the
26
+ * translation rather than the source.
27
+ */
28
+ declare function isEquivalentPlaceholder(a: any, b: any): boolean;
7
29
  declare function parseExpression(expression: t.Expression, fallback?: false): CompositeMessage | null;
8
30
  declare function parseExpression(expression: t.Expression, fallback: true): CompositeMessage | ArgumentMessage;
9
31
  //#endregion
10
- export { parseCallExpression, parseExpression, parseTaggedTemplateExpression, processExpression };
32
+ export { isEquivalentPlaceholder, parseCallExpression, parseExpression, parseTaggedTemplateExpression, processExpression, unwrapPlaceholder };
package/dist/parser.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AUTO_INCREMENT_IDENTIFIER, ArgumentMessage, ChoiceMessage, CompositeMessage, LiteralMessage } from "@saykit/config/features/messages";
2
2
  import * as t from "@babel/types";
3
3
  //#region src/parser.ts
4
+ const PLACEHOLDER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
5
  function parseTaggedTemplateExpression(tagged) {
5
6
  const processed = processExpression(tagged.tag);
6
7
  if (!processed) return null;
@@ -38,8 +39,8 @@ function parseCallExpression(call) {
38
39
  });
39
40
  return c;
40
41
  }, []);
41
- const value = call.arguments[0];
42
- const choice = new ChoiceMessage(kind, getExpressionAsKey(value), branches, value);
42
+ const [identifier, value] = unwrapPlaceholder(call.arguments[0]);
43
+ const choice = new ChoiceMessage(kind, identifier, branches, value);
43
44
  return new CompositeMessage({
44
45
  id: descriptor ? findPropertyValueIfStringLiteralAsString(descriptor, "id") : void 0,
45
46
  context: descriptor ? findPropertyValueIfStringLiteralAsString(descriptor, "context") : void 0
@@ -88,6 +89,44 @@ function getExpressionAsKey(node) {
88
89
  if (t.isJSXIdentifier(node)) return node.name;
89
90
  return AUTO_INCREMENT_IDENTIFIER;
90
91
  }
92
+ /**
93
+ * Read the name off a value written as a single-key object and hand back the
94
+ * value alone, so the wrapper never reaches the output: `${{ total: sum() }}`
95
+ * is `{total}` rather than `{0}`.
96
+ *
97
+ * An inline one-key object is the only shape this claims, and it is a shape
98
+ * that could not mean anything else — interpolating an object stringifies it
99
+ * to `[object Object]`, and rendering one in JSX throws. Anything else is
100
+ * returned untouched and named the way it always was.
101
+ */
102
+ function unwrapPlaceholder(expression) {
103
+ if (!t.isObjectExpression(expression) || expression.properties.length !== 1) return [getExpressionAsKey(expression), expression];
104
+ const [property] = expression.properties;
105
+ if (!t.isObjectProperty(property) || property.computed || !t.isExpression(property.value)) return [getExpressionAsKey(expression), expression];
106
+ const name = getPropertyNameAsString(property.key);
107
+ /* v8 ignore next */
108
+ if (typeof name !== "string") return [getExpressionAsKey(expression), expression];
109
+ if (!PLACEHOLDER_PATTERN.test(name)) throw new Error(`Invalid placeholder name '${name}', expected a letter or underscore followed by letters, digits, or underscores`);
110
+ return [name, property.value];
111
+ }
112
+ /**
113
+ * Whether two placeholders sharing a name are the same placeholder, and so
114
+ * compile to one prop a translator can move around the sentence freely.
115
+ *
116
+ * A value is compared whole: the same variable interpolated twice is one value,
117
+ * while `${name}` beside `${{ name: author.name }}` is two things claiming one
118
+ * name. An element is compared on its opening element alone — `say-tag` has
119
+ * been stripped by the time this runs, and its children come from the
120
+ * translation rather than the source.
121
+ */
122
+ function isEquivalentPlaceholder(a, b) {
123
+ if (t.isJSXElement(a) || t.isJSXElement(b)) {
124
+ if (!t.isJSXElement(a) || !t.isJSXElement(b)) return false;
125
+ return t.isNodesEquivalent(a.openingElement, b.openingElement);
126
+ }
127
+ if (!t.isNode(a) || !t.isNode(b)) return false;
128
+ return t.isNodesEquivalent(a, b);
129
+ }
91
130
  function parseExpression(expression, fallback) {
92
131
  let message = null;
93
132
  switch (true) {
@@ -99,8 +138,14 @@ function parseExpression(expression, fallback) {
99
138
  break;
100
139
  }
101
140
  if (message) return message;
102
- else if (fallback) return new ArgumentMessage(getExpressionAsKey(expression), expression);
103
- else return null;
141
+ else if (fallback) {
142
+ const [key, value] = unwrapPlaceholder(expression);
143
+ if (value !== expression) {
144
+ const nested = parseExpression(value);
145
+ if (nested) return nested;
146
+ }
147
+ return new ArgumentMessage(key, value);
148
+ } else return null;
104
149
  }
105
150
  function getPropertyNameAsString(key) {
106
151
  if (t.isIdentifier(key)) return key.name;
@@ -123,4 +168,4 @@ function getTranslatorComments(comments) {
123
168
  }, []);
124
169
  }
125
170
  //#endregion
126
- export { parseCallExpression, parseExpression, parseTaggedTemplateExpression, processExpression };
171
+ export { isEquivalentPlaceholder, parseCallExpression, parseExpression, parseTaggedTemplateExpression, processExpression, unwrapPlaceholder };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saykit/transform-js",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "JavaScript and TypeScript transformer for SayKit",
5
5
  "keywords": [
6
6
  "i18n",
@@ -47,7 +47,7 @@
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.5.0"
50
+ "@saykit/config": "^0.6.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@babel/core": "^7.29.7",