i18next-cli 1.24.13 → 1.24.15

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 (41) hide show
  1. package/dist/cjs/cli.js +1 -1
  2. package/dist/cjs/rename-key.js +1 -1
  3. package/dist/esm/cli.js +1 -1
  4. package/dist/esm/rename-key.js +1 -1
  5. package/package.json +6 -6
  6. package/types/cli.d.ts +3 -1
  7. package/types/cli.d.ts.map +1 -1
  8. package/CHANGELOG.md +0 -599
  9. package/src/cli.ts +0 -283
  10. package/src/config.ts +0 -215
  11. package/src/extractor/core/ast-visitors.ts +0 -259
  12. package/src/extractor/core/extractor.ts +0 -250
  13. package/src/extractor/core/key-finder.ts +0 -142
  14. package/src/extractor/core/translation-manager.ts +0 -750
  15. package/src/extractor/index.ts +0 -7
  16. package/src/extractor/parsers/ast-utils.ts +0 -87
  17. package/src/extractor/parsers/call-expression-handler.ts +0 -793
  18. package/src/extractor/parsers/comment-parser.ts +0 -424
  19. package/src/extractor/parsers/expression-resolver.ts +0 -391
  20. package/src/extractor/parsers/jsx-handler.ts +0 -488
  21. package/src/extractor/parsers/jsx-parser.ts +0 -1463
  22. package/src/extractor/parsers/scope-manager.ts +0 -445
  23. package/src/extractor/plugin-manager.ts +0 -116
  24. package/src/extractor.ts +0 -15
  25. package/src/heuristic-config.ts +0 -92
  26. package/src/index.ts +0 -22
  27. package/src/init.ts +0 -175
  28. package/src/linter.ts +0 -345
  29. package/src/locize.ts +0 -263
  30. package/src/migrator.ts +0 -208
  31. package/src/rename-key.ts +0 -398
  32. package/src/status.ts +0 -380
  33. package/src/syncer.ts +0 -133
  34. package/src/types-generator.ts +0 -139
  35. package/src/types.ts +0 -577
  36. package/src/utils/default-value.ts +0 -45
  37. package/src/utils/file-utils.ts +0 -167
  38. package/src/utils/funnel-msg-tracker.ts +0 -84
  39. package/src/utils/logger.ts +0 -36
  40. package/src/utils/nested-object.ts +0 -135
  41. package/src/utils/validation.ts +0 -72
@@ -1,1463 +0,0 @@
1
- import type { Expression, JSXAttribute, JSXElement, JSXExpression, ObjectExpression } from '@swc/core'
2
- import type { I18nextToolkitConfig } from '../../types'
3
- import { getObjectPropValue, getObjectPropValueExpression, isSimpleTemplateLiteral } from './ast-utils'
4
-
5
- export interface ExtractedJSXAttributes {
6
- /** holds the raw key expression from the AST */
7
- keyExpression?: Expression;
8
-
9
- /** holds the serialized JSX children from the AST */
10
- serializedChildren: string;
11
-
12
- /** Default value to use in the primary language */
13
- defaultValue?: string;
14
-
15
- /** Namespace this key belongs to (if defined on <Trans />) */
16
- ns?: string;
17
-
18
- /** Whether this key is used with pluralization (count parameter) */
19
- hasCount?: boolean;
20
-
21
- /** Whether this key is used with ordinal pluralization */
22
- isOrdinal?: boolean;
23
-
24
- /** AST node for options object, used for advanced plural handling in Trans */
25
- optionsNode?: ObjectExpression;
26
-
27
- /** hold the raw context expression from the AST */
28
- contextExpression?: Expression;
29
-
30
- /** Whether the defaultValue was explicitly provided on the <Trans /> (defaults prop or tOptions defaultValue*) */
31
- explicitDefault?: boolean;
32
- }
33
-
34
- function getStringLiteralFromExpression (expression: JSXExpression | null): string | undefined {
35
- if (!expression) return undefined
36
-
37
- if (expression.type === 'StringLiteral') {
38
- return expression.value
39
- }
40
-
41
- if (expression.type === 'TemplateLiteral' && isSimpleTemplateLiteral(expression)) {
42
- return expression.quasis[0].cooked
43
- }
44
-
45
- return undefined
46
- }
47
-
48
- function getStringLiteralFromAttribute (attr: JSXAttribute): string | undefined {
49
- if (attr.value?.type === 'StringLiteral') {
50
- return attr.value.value
51
- }
52
-
53
- if (attr.value?.type === 'JSXExpressionContainer') {
54
- return getStringLiteralFromExpression(attr.value.expression)
55
- }
56
-
57
- return undefined
58
- }
59
-
60
- /**
61
- * Extracts translation keys from JSX Trans components.
62
- *
63
- * This function handles various Trans component patterns:
64
- * - Explicit i18nKey prop: `<Trans i18nKey="my.key">content</Trans>`
65
- * - Implicit keys from children: `<Trans>Hello World</Trans>`
66
- * - Namespace specification: `<Trans ns="common">content</Trans>`
67
- * - Default values: `<Trans defaults="Default text">content</Trans>`
68
- * - Pluralization: `<Trans count={count}>content</Trans>`
69
- * - HTML preservation: `<Trans>Hello <strong>world</strong></Trans>`
70
- *
71
- * @param node - The JSX element node to process
72
- * @param config - The toolkit configuration containing extraction settings
73
- * @returns Extracted key information or null if no valid key found
74
- *
75
- * @example
76
- * ```typescript
77
- * // Input JSX:
78
- * // <Trans i18nKey="welcome.title" ns="home" defaults="Welcome!">
79
- * // Welcome to our <strong>amazing</strong> app!
80
- * // </Trans>
81
- *
82
- * const result = extractFromTransComponent(jsxNode, config)
83
- * // Returns: {
84
- * // key: 'welcome.title',
85
- * // keyExpression: { ... },
86
- * // ns: 'home',
87
- * // defaultValue: 'Welcome!',
88
- * // hasCount: false
89
- * // }
90
- * ```
91
- */
92
- export function extractFromTransComponent (node: JSXElement, config: I18nextToolkitConfig): ExtractedJSXAttributes | null {
93
- const i18nKeyAttr = node.opening.attributes?.find(
94
- (attr) =>
95
- attr.type === 'JSXAttribute' &&
96
- attr.name.type === 'Identifier' &&
97
- attr.name.value === 'i18nKey'
98
- )
99
-
100
- const defaultsAttr = node.opening.attributes?.find(
101
- (attr) =>
102
- attr.type === 'JSXAttribute' &&
103
- attr.name.type === 'Identifier' &&
104
- attr.name.value === 'defaults'
105
- )
106
-
107
- const countAttr = node.opening.attributes?.find(
108
- (attr) =>
109
- attr.type === 'JSXAttribute' &&
110
- attr.name.type === 'Identifier' &&
111
- attr.name.value === 'count'
112
- )
113
-
114
- const valuesAttr = node.opening.attributes?.find(
115
- (attr) => attr.type === 'JSXAttribute' && attr.name.type === 'Identifier' && attr.name.value === 'values'
116
- )
117
-
118
- // Find the 'count' property in the 'values' object if count={...} is not defined
119
- let valuesCountProperty: Expression | undefined
120
- if (
121
- !countAttr &&
122
- valuesAttr?.type === 'JSXAttribute' &&
123
- valuesAttr.value?.type === 'JSXExpressionContainer' &&
124
- valuesAttr.value.expression.type === 'ObjectExpression'
125
- ) {
126
- valuesCountProperty = getObjectPropValueExpression(valuesAttr.value.expression, 'count')
127
- }
128
-
129
- const hasCount = !!countAttr || !!valuesCountProperty
130
-
131
- const tOptionsAttr = node.opening.attributes?.find(
132
- (attr) =>
133
- attr.type === 'JSXAttribute' &&
134
- attr.name.type === 'Identifier' &&
135
- attr.name.value === 'tOptions'
136
- )
137
- const optionsNode = (tOptionsAttr?.type === 'JSXAttribute' && tOptionsAttr.value?.type === 'JSXExpressionContainer' && tOptionsAttr.value.expression.type === 'ObjectExpression')
138
- ? tOptionsAttr.value.expression
139
- : undefined
140
-
141
- // Find isOrdinal prop on the <Trans> component
142
- const ordinalAttr = node.opening.attributes?.find(
143
- (attr) =>
144
- attr.type === 'JSXAttribute' &&
145
- attr.name.type === 'Identifier' &&
146
- attr.name.value === 'ordinal'
147
- )
148
- const isOrdinal = !!ordinalAttr
149
-
150
- const contextAttr = node.opening.attributes?.find(
151
- (attr) =>
152
- attr.type === 'JSXAttribute' &&
153
- attr.name.type === 'Identifier' &&
154
- attr.name.value === 'context'
155
- )
156
- let contextExpression = (contextAttr?.type === 'JSXAttribute' && contextAttr.value?.type === 'JSXExpressionContainer')
157
- ? contextAttr.value.expression
158
- : (contextAttr?.type === 'JSXAttribute' && contextAttr.value?.type === 'StringLiteral')
159
- ? contextAttr.value
160
- : undefined
161
-
162
- // 1. Prioritize direct props for 'ns' and 'context'
163
- const nsAttr = node.opening.attributes?.find(attr => attr.type === 'JSXAttribute' && attr.name.type === 'Identifier' && attr.name.value === 'ns')
164
- let ns: string | undefined
165
- if (nsAttr?.type === 'JSXAttribute') {
166
- ns = getStringLiteralFromAttribute(nsAttr)
167
- } else {
168
- ns = undefined
169
- }
170
-
171
- // 2. If not found, fall back to looking inside tOptions
172
- if (optionsNode) {
173
- if (ns === undefined) {
174
- ns = getObjectPropValue(optionsNode, 'ns') as string | undefined
175
- }
176
- if (contextExpression === undefined) {
177
- contextExpression = getObjectPropValueExpression(optionsNode, 'context')
178
- }
179
- }
180
-
181
- const serialized = serializeJSXChildren(node.children, config)
182
-
183
- // Handle default value properly
184
- let defaultValue: string
185
-
186
- const defaultAttributeLiteral = defaultsAttr?.type === 'JSXAttribute' ? getStringLiteralFromAttribute(defaultsAttr) : undefined
187
- if (defaultAttributeLiteral !== undefined) {
188
- // Explicit defaults attribute takes precedence
189
- defaultValue = defaultAttributeLiteral
190
- } else {
191
- // Use the configured default value or fall back to empty string
192
- const configuredDefault = config.extract.defaultValue
193
- if (typeof configuredDefault === 'string') {
194
- defaultValue = configuredDefault
195
- } else {
196
- // For function-based defaults or undefined, use empty string as placeholder
197
- // The translation manager will handle function resolution with proper context
198
- defaultValue = ''
199
- }
200
- }
201
-
202
- let keyExpression: Expression | undefined
203
- let processedKeyValue: string | undefined
204
-
205
- if (i18nKeyAttr?.type === 'JSXAttribute') {
206
- if (i18nKeyAttr.value?.type === 'StringLiteral') {
207
- keyExpression = i18nKeyAttr.value
208
- processedKeyValue = keyExpression.value
209
-
210
- // Validate that the key is not empty
211
- if (!processedKeyValue || processedKeyValue.trim() === '') {
212
- return null
213
- }
214
-
215
- // Handle namespace prefix removal when both ns and i18nKey are provided
216
- if (ns && keyExpression.type === 'StringLiteral') {
217
- const nsSeparator = config.extract.nsSeparator ?? ':'
218
- const keyValue = keyExpression.value
219
-
220
- // If the key starts with the namespace followed by the separator, remove the prefix
221
- if (nsSeparator && keyValue.startsWith(`${ns}${nsSeparator}`)) {
222
- processedKeyValue = keyValue.slice(`${ns}${nsSeparator}`.length)
223
-
224
- // Validate processed key is not empty
225
- if (!processedKeyValue || processedKeyValue.trim() === '') {
226
- return null
227
- }
228
-
229
- // Create a new StringLiteral with the namespace prefix removed
230
- keyExpression = {
231
- ...keyExpression,
232
- value: processedKeyValue
233
- }
234
- }
235
- }
236
- } else if (
237
- i18nKeyAttr.value?.type === 'JSXExpressionContainer' &&
238
- i18nKeyAttr.value.expression.type !== 'JSXEmptyExpression'
239
- ) {
240
- keyExpression = i18nKeyAttr.value.expression
241
- }
242
-
243
- if (!keyExpression) return null
244
- }
245
-
246
- // If no explicit defaults provided and we have a processed key, use it as default value
247
- // This matches the behavior of other similar tests in the codebase
248
- if (!defaultsAttr && processedKeyValue && !serialized.trim()) {
249
- defaultValue = processedKeyValue
250
- } else if (!defaultsAttr && serialized.trim()) {
251
- defaultValue = serialized
252
- }
253
-
254
- // Determine if tOptions contained explicit defaultValue* properties
255
- const optionsHasDefaultProps = (opts?: ObjectExpression) => {
256
- if (!opts || !Array.isArray((opts as any).properties)) return false
257
- for (const p of (opts as any).properties) {
258
- if (p && p.type === 'KeyValueProperty' && p.key) {
259
- const keyName = (p.key.type === 'Identifier' && p.key.value) || (p.key.type === 'StringLiteral' && p.key.value)
260
- if (typeof keyName === 'string' && keyName.startsWith('defaultValue')) return true
261
- }
262
- }
263
- return false
264
- }
265
-
266
- const explicitDefault = defaultAttributeLiteral !== undefined || optionsHasDefaultProps(optionsNode)
267
-
268
- return {
269
- keyExpression,
270
- serializedChildren: serialized,
271
- ns,
272
- defaultValue,
273
- hasCount,
274
- isOrdinal,
275
- contextExpression,
276
- optionsNode,
277
- explicitDefault
278
- }
279
- }
280
-
281
- /**
282
- * Serializes JSX children into a string, correctly indexing component placeholders.
283
- * This version correctly calculates an element's index based on its position
284
- * among its sibling *elements*, ignoring text nodes for indexing purposes.
285
- */
286
- function serializeJSXChildren (children: any[], config: I18nextToolkitConfig): string {
287
- if (!children || children.length === 0) return ''
288
-
289
- const allowedTags = new Set(config.extract.transKeepBasicHtmlNodesFor ?? ['br', 'strong', 'i', 'p'])
290
-
291
- const isFormattingWhitespace = (n: any) =>
292
- n &&
293
- n.type === 'JSXText' &&
294
- /^\s*$/.test(n.value) &&
295
- n.value.includes('\n')
296
-
297
- // Build deterministic global slot list (pre-order)
298
- function collectSlots (nodes: any[], slots: any[], parentIsNonPreserved = false, isRootLevel = false) {
299
- if (!nodes || !nodes.length) return
300
-
301
- // Check if there are multiple <p> elements at root level
302
- const multiplePAtRoot = isRootLevel && nodes.filter((n: any) =>
303
- n && n.type === 'JSXElement' && n.opening?.name?.value === 'p'
304
- ).length > 1
305
-
306
- // First, identify boundary whitespace nodes (start and end of sibling list)
307
- // We trim ONLY pure-whitespace JSXText nodes from the boundaries
308
- let startIdx = 0
309
- let endIdx = nodes.length - 1
310
-
311
- // Skip leading boundary whitespace (pure whitespace JSXText only)
312
- while (startIdx <= endIdx && isFormattingWhitespace(nodes[startIdx])) {
313
- startIdx++
314
- }
315
-
316
- // Skip trailing boundary whitespace (pure whitespace JSXText only)
317
- while (endIdx >= startIdx && isFormattingWhitespace(nodes[endIdx])) {
318
- endIdx--
319
- }
320
-
321
- // Now process all nodes in the range [startIdx, endIdx] - this includes interior whitespace
322
- const meaningfulNodes = startIdx <= endIdx ? nodes.slice(startIdx, endIdx + 1) : []
323
-
324
- // If the parent is non-preserved but it contains element/fragment children,
325
- // we want to keep meaningful text & simple expressions so inline-tags inside
326
- // a single wrapper (e.g. <span>.. <code/> ..</span>) preserve sibling ordering.
327
- // If there are NO element children (e.g. <pre>some text</pre>) then all inner
328
- // text/expressions should be treated as part of the parent and NOT create slots.
329
- const parentHasElementChildren = meaningfulNodes.some(
330
- (n) => n && (n.type === 'JSXElement' || n.type === 'JSXFragment')
331
- )
332
-
333
- for (let i = 0; i < meaningfulNodes.length; i++) {
334
- const n = meaningfulNodes[i]
335
- if (!n) continue
336
-
337
- if (n.type === 'JSXText') {
338
- // When inside a non-preserved parent that has no element children (e.g. <pre>),
339
- // skip all inner text nodes — they're part of the parent and must not shift indexes.
340
- if (parentIsNonPreserved && !parentHasElementChildren) {
341
- continue
342
- }
343
-
344
- // For non-preserved parents that DO have element children, only skip pure
345
- // formatting whitespace; keep meaningful text so inline sibling indexes stay correct.
346
- if (parentIsNonPreserved && isFormattingWhitespace(n)) {
347
- continue
348
- }
349
-
350
- // Otherwise, preserve previous behavior for boundary/formatting merging.
351
- if (isFormattingWhitespace(n)) {
352
- // If this formatting whitespace sits between two element/fragment nodes
353
- // (e.g. <br />\n <small>...), treat it as layout-only and skip it so it
354
- // doesn't become its own slot and shift subsequent element indexes.
355
- const prevOrig = meaningfulNodes[i - 1]
356
- const nextOrig = meaningfulNodes[i + 1]
357
- if (
358
- prevOrig &&
359
- (prevOrig.type === 'JSXElement' || prevOrig.type === 'JSXFragment') &&
360
- nextOrig &&
361
- (nextOrig.type === 'JSXElement' || nextOrig.type === 'JSXFragment')
362
- ) {
363
- continue
364
- }
365
-
366
- const prevSlot = slots[slots.length - 1]
367
- const prevOriginal = meaningfulNodes[i - 1]
368
-
369
- if (prevSlot) {
370
- // If the previous original sibling is an expression container, treat
371
- // this formatting whitespace as formatting after an expression and skip it.
372
- if (prevOriginal && prevOriginal.type === 'JSXExpressionContainer') {
373
- continue
374
- }
375
- // Only merge into previous text when the previous original sibling was also JSXText.
376
- if (prevSlot.type === 'JSXText' && prevOriginal && prevOriginal.type === 'JSXText') {
377
- prevSlot.value = String(prevSlot.value) + n.value
378
- continue
379
- }
380
- }
381
- }
382
-
383
- // Don't treat the FIRST meaningful text node inside a non-preserved parent
384
- // that also contains element children as an independent global slot.
385
- // This preserves expected placeholder indexing for inline tags wrapped
386
- // inside a single container (e.g. <span>text <code/> more <code/> ...</span>).
387
- if (parentIsNonPreserved && parentHasElementChildren && i === 0) {
388
- continue
389
- }
390
-
391
- // Add all other JSXText nodes
392
- slots.push(n)
393
- continue
394
- }
395
-
396
- if (n.type === 'JSXExpressionContainer') {
397
- // If this expression is inside a non-preserved parent element that has NO
398
- // element children (e.g. <pre>{'foo'}</pre>), treat common simple expressions
399
- // as part of the parent and do NOT add them as separate sibling/global slots.
400
- if (parentIsNonPreserved && !parentHasElementChildren && n.expression) {
401
- const exprType = n.expression.type
402
- // ObjectExpression placeholders ({{ key: value }}) should be treated
403
- // as part of the parent.
404
- if (exprType === 'ObjectExpression') {
405
- const prop = n.expression.properties && n.expression.properties[0]
406
- if (prop && prop.type === 'KeyValueProperty') {
407
- continue
408
- }
409
- }
410
-
411
- // For common simple content expressions, don't allocate a separate slot.
412
- // However, if it's a pure-space StringLiteral ({" "}) we want to keep the
413
- // special-space handling below, so only skip non-whitespace string literals.
414
- const textVal = getStringLiteralFromExpression(n.expression)
415
- if (textVal !== undefined) {
416
- const isPureSpaceNoNewline = /^\s*$/.test(textVal) && !textVal.includes('\n')
417
- if (!isPureSpaceNoNewline) {
418
- continue
419
- }
420
- // otherwise fall through to the pure-space handling below
421
- } else if (exprType === 'Identifier' || exprType === 'MemberExpression' || exprType === 'CallExpression') {
422
- continue
423
- }
424
- }
425
-
426
- // Handle pure-string expression containers (e.g. {" "}):
427
- // - If it's pure space (no newline) and it directly follows a non-text sibling
428
- // (element/fragment), treat it as formatting and skip it.
429
- // - Otherwise, count it as a slot (do NOT merge it into previous JSXText).
430
- const textVal = getStringLiteralFromExpression(n.expression)
431
- if (textVal !== undefined) {
432
- const isPureSpaceNoNewline = /^\s*$/.test(textVal) && !textVal.includes('\n')
433
- const prevOriginal = meaningfulNodes[i - 1]
434
- const nextOriginal = meaningfulNodes[i + 1]
435
-
436
- if (isPureSpaceNoNewline) {
437
- // If the explicit {" "} is followed by a newline-only JSXText which
438
- // itself is followed by an element/fragment, treat the {" "}
439
- // as layout-only and skip it. This covers cases where the space
440
- // precedes a newline-only separator before an element (fixes index
441
- // shifting in object-expression span tests).
442
- const nextNextOriginal = meaningfulNodes[i + 2]
443
- if (
444
- nextOriginal &&
445
- nextOriginal.type === 'JSXText' &&
446
- isFormattingWhitespace(nextOriginal) &&
447
- nextNextOriginal &&
448
- (nextNextOriginal.type === 'JSXElement' || nextNextOriginal.type === 'JSXFragment')
449
- ) {
450
- const prevOriginalCandidate = meaningfulNodes[i - 1]
451
- const prevPrevOriginal = meaningfulNodes[i - 2]
452
- const shouldSkip =
453
- !prevOriginalCandidate ||
454
- (prevOriginalCandidate.type !== 'JSXText' && prevPrevOriginal && prevPrevOriginal.type === 'JSXExpressionContainer')
455
-
456
- if (shouldSkip) {
457
- continue
458
- }
459
- }
460
-
461
- // Only treat {" "} as pure formatting to skip when it sits between
462
- // an element/fragment and a newline-only JSXText. In that specific
463
- // boundary case the explicit space is merely layout and must be ignored.
464
- if (
465
- prevOriginal &&
466
- (prevOriginal.type === 'JSXElement' || prevOriginal.type === 'JSXFragment') &&
467
- nextOriginal &&
468
- nextOriginal.type === 'JSXText' &&
469
- isFormattingWhitespace(nextOriginal)
470
- ) {
471
- continue
472
- }
473
-
474
- // 1) Merge into previous text when the previous original sibling is JSXText
475
- // and the next original sibling is either missing or a non-formatting JSXText.
476
- // This preserves "foo{' '}bar" as a single text node but avoids merging
477
- // when the {" "} is followed by newline-only formatting before an element.
478
- const nextIsTextNonFormatting = !nextOriginal || (nextOriginal.type === 'JSXText' && !isFormattingWhitespace(nextOriginal))
479
- if (prevOriginal && prevOriginal.type === 'JSXText' && nextIsTextNonFormatting) {
480
- const prevSlot = slots[slots.length - 1]
481
- if (prevSlot && prevSlot.type === 'JSXText') {
482
- prevSlot.value = String(prevSlot.value) + n.expression.value
483
- continue
484
- }
485
- }
486
-
487
- // 2) Skip when this explicit space sits between an element/fragment
488
- // and a newline-only formatting JSXText (boundary formatting).
489
- if (
490
- prevOriginal &&
491
- (prevOriginal.type === 'JSXElement' || prevOriginal.type === 'JSXFragment') &&
492
- nextOriginal &&
493
- nextOriginal.type === 'JSXText' &&
494
- isFormattingWhitespace(nextOriginal)
495
- ) {
496
- continue
497
- }
498
- // 3) Otherwise fallthrough and count this expression as a slot.
499
- }
500
- }
501
-
502
- // All JSXExpressionContainers count as slots for indexing.
503
- slots.push(n)
504
- continue
505
- }
506
-
507
- if (n.type === 'JSXElement') {
508
- const tagName = n.opening && n.opening.name && n.opening.name.type === 'Identifier'
509
- ? n.opening.name.value
510
- : undefined
511
- if (tagName && allowedTags.has(tagName)) {
512
- // Check if this preserved tag will actually be preserved as literal HTML
513
- // or if it will be indexed (has complex children or attributes)
514
- const hasAttrs =
515
- n.opening &&
516
- Array.isArray((n.opening as any).attributes) &&
517
- (n.opening as any).attributes.length > 0
518
- const children = n.children || []
519
- // Check for single PURE text child (JSXText OR simple string expression)
520
- const isSinglePureTextChild =
521
- children.length === 1 && (
522
- children[0]?.type === 'JSXText' ||
523
- (children[0]?.type === 'JSXExpressionContainer' &&
524
- getStringLiteralFromExpression(children[0].expression) !== undefined)
525
- )
526
-
527
- // Self-closing tags (no children) should be added to slots but rendered as literal HTML
528
- // Tags with single pure text child should NOT be added to slots and rendered as literal HTML
529
- const isSelfClosing = !children.length
530
- const hasTextContent = isSinglePureTextChild
531
-
532
- if (hasAttrs && !isSinglePureTextChild) {
533
- // Has attributes AND complex children -> will be indexed, add to slots
534
- slots.push(n)
535
- collectSlots(n.children || [], slots, true)
536
- } else if (isSelfClosing) {
537
- // Self-closing tag with no attributes: add to slots (affects indexes) but will render as literal
538
- slots.push(n)
539
- } else if (!hasTextContent) {
540
- // Has complex children but no attributes
541
- // For <p> tags at root level with multiple <p> siblings, index them
542
- // For other preserved tags, preserve as literal (don't add to slots)
543
- if (tagName === 'p' && multiplePAtRoot) {
544
- slots.push(n)
545
- collectSlots(n.children || [], slots, true, false)
546
- } else {
547
- // Other preserved tags: preserve as literal, don't add to slots
548
- // But DO process children to add them to slots
549
- collectSlots(n.children || [], slots, false, false)
550
- }
551
- } else {
552
- // Has single pure text child and no attributes: preserve as literal, don't add to slots
553
- // Don't process children either - they're part of the preserved tag
554
- }
555
- continue
556
- } else {
557
- // non-preserved element: the element itself is a single slot.
558
- // Pre-order: allocate the parent's slot first, then descend into its
559
- // children. While descending, mark parentIsNonPreserved so
560
- // KeyValueProperty-style object-expression placeholders are not added
561
- // as separate sibling slots.
562
- slots.push(n)
563
- collectSlots(n.children || [], slots, true)
564
- }
565
- continue
566
- }
567
-
568
- if (n.type === 'JSXFragment') {
569
- collectSlots(n.children || [], slots, parentIsNonPreserved)
570
- continue
571
- }
572
-
573
- // ignore unknown node types
574
- }
575
- }
576
-
577
- // prepare the global slot list for the whole subtree
578
- const globalSlots: any[] = []
579
- collectSlots(children, globalSlots, false, true)
580
-
581
- // Helper: more precise detection whether children contain non-element global
582
- // slots that should force the parent to use global indexing. This mirrors
583
- // the later inlined logic and avoids counting trailing text as forcing global.
584
- function hasNonElementGlobalSlotsAmongChildren (childrenList: any[]) {
585
- if (!childrenList || !childrenList.length) return false
586
- let foundElement = false
587
- for (const ch of childrenList) {
588
- if (!ch) continue
589
- if (ch.type === 'JSXElement') {
590
- foundElement = true
591
- continue
592
- }
593
- if (ch.type === 'JSXExpressionContainer' && globalSlots.indexOf(ch) !== -1) {
594
- // Only count an expression as forcing global indexing when it appears
595
- // after at least one element (i.e. between elements), not if it's before.
596
- return foundElement
597
- }
598
- if (ch.type === 'JSXText' && globalSlots.indexOf(ch) !== -1) {
599
- // Exclude pure formatting whitespace
600
- if (isFormattingWhitespace(ch)) continue
601
-
602
- // If text appears before the first element -> force global indexing
603
- if (!foundElement) return true
604
-
605
- // If text is between elements -> force global indexing
606
- const idx = childrenList.indexOf(ch)
607
- const remaining = childrenList.slice(idx + 1)
608
- const hasMoreElements = remaining.some((n: any) => n && n.type === 'JSXElement')
609
- if (hasMoreElements) return true
610
- // Trailing text after last element does not force global indexing
611
- }
612
- }
613
- return false
614
- }
615
-
616
- // Track ELEMENT NODES that MUST be tight (no spaces) because the element splits a word across a newline.
617
- // We'll map these node refs to numeric global indices later before string cleanup.
618
- const tightNoSpaceNodes = new Set<any>()
619
-
620
- // Trim only newline-only indentation at the edges of serialized inner text.
621
- // This preserves single leading/trailing spaces which are meaningful between inline placeholders.
622
- const trimFormattingEdges = (s: string) =>
623
- String(s)
624
- // remove leading newline-only indentation
625
- .replace(/^\s*\n\s*/g, '')
626
- // remove trailing newline-only indentation
627
- .replace(/\s*\n\s*$/g, '')
628
-
629
- function visitNodes (nodes: any[], localIndexMap?: Map<any, number>, isRootLevel = false): string {
630
- if (!nodes || nodes.length === 0) return ''
631
- let out = ''
632
-
633
- // Resolve a numeric index for a node using localIndexMap when possible.
634
- // Some AST node references may not match by identity in maps (e.g. after cloning),
635
- // so also attempt to match by span positions as a fallback.
636
- const resolveIndex = (n: any) => {
637
- if (!n) return -1
638
- if (localIndexMap && localIndexMap.has(n)) return localIndexMap.get(n)
639
- if (localIndexMap) {
640
- for (const [k, v] of localIndexMap.entries()) {
641
- try {
642
- if (k && n && k.span && n.span && k.span.start === n.span.start && k.span.end === n.span.end) return v
643
- } catch (e) { /* ignore */ }
644
- }
645
- }
646
- return globalSlots.indexOf(n)
647
- }
648
-
649
- // At root level, build index based on element position among siblings
650
- let rootElementIndex = 0
651
-
652
- for (let i = 0; i < nodes.length; i++) {
653
- const node = nodes[i]
654
- if (!node) continue
655
-
656
- if (node.type === 'JSXText') {
657
- if (isFormattingWhitespace(node)) continue
658
-
659
- const nextNode = nodes[i + 1]
660
- const prevNode = nodes[i - 1]
661
-
662
- // If this text follows a preserved tag and starts with newline+whitespace, trim it
663
- if (prevNode && prevNode.type === 'JSXElement') {
664
- const prevTag = prevNode.opening?.name?.type === 'Identifier' ? prevNode.opening.name.value : undefined
665
- const prevIsPreservedTag = prevTag && allowedTags.has(prevTag)
666
-
667
- // Only trim leading whitespace after SELF-CLOSING preserved tags (like <br />)
668
- // Block tags like <p> or inline tags like <strong> need surrounding spaces
669
- const prevChildren = prevNode.children || []
670
- const prevIsSelfClosing = prevChildren.length === 0
671
-
672
- if (prevIsPreservedTag && prevIsSelfClosing && /^\s*\n\s*/.test(node.value)) {
673
- // Text starts with newline after a self-closing preserved tag - trim leading formatting
674
- const trimmedValue = node.value.replace(/^\s*\n\s*/, '')
675
- if (trimmedValue) {
676
- out += trimmedValue
677
- continue
678
- }
679
- // If nothing left after trimming, skip this node
680
- continue
681
- }
682
-
683
- // If previous node is a non-preserved element (an indexed placeholder)
684
- // and the current text starts with a formatting newline, detect
685
- // the "word-split" scenario: previous meaningful text (before the element)
686
- // ends with an alnum char and this trimmed text starts with alnum.
687
- // In that case do NOT insert any separating space — the inline element
688
- // split a single word across lines.
689
- if (!prevIsPreservedTag && /^\s*\n\s*/.test(node.value)) {
690
- const trimmedValue = node.value.replace(/^\s*\n\s*/, '')
691
- if (trimmedValue) {
692
- // If the previous element is a self-closing non-preserved element,
693
- // do not insert an extra separating space — common for inline
694
- // components like <NumberInput/>days
695
- if (prevNode && prevNode.type === 'JSXElement' && Array.isArray(prevNode.children) && prevNode.children.length === 0) {
696
- out += trimmedValue
697
- continue
698
- }
699
-
700
- const prevPrev = nodes[i - 2]
701
- if (prevPrev && prevPrev.type === 'JSXText') {
702
- const prevPrevTrimmed = prevPrev.value.replace(/\n\s*$/, '')
703
- const prevEndsAlnum = /[A-Za-z0-9]$/.test(prevPrevTrimmed)
704
- const nextStartsAlnum = /^[A-Za-z0-9]/.test(trimmedValue)
705
- const nextStartsLowercase = /^[a-z]/.test(trimmedValue)
706
- if (prevEndsAlnum && nextStartsAlnum && nextStartsLowercase) {
707
- // word-split: do NOT insert a space
708
- out += trimmedValue
709
- continue
710
- }
711
- }
712
- // non-word-split: insert a separating space before the trimmed text
713
- out += ' ' + trimmedValue
714
- continue
715
- }
716
- continue
717
- }
718
- }
719
-
720
- // If this text node ends with newline+whitespace and is followed by an element,
721
- if (/\n\s*$/.test(node.value) && nextNode && nextNode.type === 'JSXElement') {
722
- const textWithoutTrailingNewline = node.value.replace(/\n\s*$/, '')
723
- if (textWithoutTrailingNewline.trim()) {
724
- // Check if the next element is a preserved tag
725
- const nextTag = nextNode.opening?.name?.type === 'Identifier' ? nextNode.opening.name.value : undefined
726
- const isPreservedTag = nextTag && allowedTags.has(nextTag)
727
-
728
- // Check if the preserved tag has children (not self-closing)
729
- const nextChildren = nextNode.children || []
730
- const nextHasChildren = nextChildren.length > 0
731
-
732
- // Check if there was a space BEFORE the newline in the source
733
- const hasSpaceBeforeNewline = /\s\n/.test(node.value)
734
-
735
- // Check if there's text content AFTER the next element
736
- const nodeAfterNext = nodes[i + 2]
737
- const hasTextAfter = nodeAfterNext &&
738
- nodeAfterNext.type === 'JSXText' &&
739
- !isFormattingWhitespace(nodeAfterNext) &&
740
- /[a-zA-Z0-9]/.test(nodeAfterNext.value)
741
-
742
- // Does the next element have attributes? (helps decide spacing for tags like <a href="...">)
743
- const nextHasAttrs = !!(nextNode.opening && Array.isArray((nextNode.opening as any).attributes) && (nextNode.opening as any).attributes.length > 0)
744
-
745
- // Preserve leading whitespace
746
- // Only treat a real leading space (not a leading newline + indentation) as "leading space"
747
- const hasLeadingSpace = /^\s/.test(textWithoutTrailingNewline) && !/^\n/.test(textWithoutTrailingNewline)
748
-
749
- const trimmed = textWithoutTrailingNewline.trim()
750
- const withLeading = hasLeadingSpace ? ' ' + trimmed : trimmed
751
-
752
- // Add trailing space only if:
753
- // 1. There was an explicit space before the newline, OR
754
- // 2. The next element is NOT a preserved tag AND has text after (word boundary)
755
- // Preserved tags like <br />, <p>, etc. provide their own separation
756
- // Require an explicit leading space for the "non-preserved + hasTextAfter" case
757
- // Detect "word-split" case more strictly:
758
- // - previous trimmed ends with alnum
759
- // - the text after the element starts with alnum
760
- // - there was no explicit space before the newline and no explicit leading space,
761
- // - AND the text-after does NOT itself start with an explicit space.
762
- const prevEndsWithAlnum = /[A-Za-z0-9]$/.test(trimmed)
763
- const nextStartsWithAlnum = nodeAfterNext && typeof nodeAfterNext.value === 'string' && /^[A-Za-z0-9]/.test(nodeAfterNext.value.trim())
764
- const nextStartsWithLowercase = nodeAfterNext && typeof nodeAfterNext.value === 'string' && /^[a-z]/.test(nodeAfterNext.value.trim())
765
- // Treat newline-leading indentation as NOT an explicit leading space.
766
- const nextHasLeadingSpace = nodeAfterNext && typeof nodeAfterNext.value === 'string' && /^\s/.test(nodeAfterNext.value) && !/^\n/.test(nodeAfterNext.value)
767
-
768
- // Only treat as a word-split (no space) when the following word begins
769
- // with a lowercase letter — this avoids removing spaces between separate
770
- // capitalized / distinct words like "First <1>Second".
771
- const shouldInsertForNextWithAttrs = nextHasAttrs && nextHasChildren && hasTextAfter && !(
772
- prevEndsWithAlnum &&
773
- nextStartsWithAlnum &&
774
- nextStartsWithLowercase &&
775
- !hasSpaceBeforeNewline &&
776
- !hasLeadingSpace &&
777
- !nextHasLeadingSpace
778
- )
779
- // If the text after the next element begins with punctuation, do not insert a space
780
- const nextStartsWithPunctuation = nodeAfterNext && typeof nodeAfterNext.value === 'string' && /^[,;:!?.]/.test(nodeAfterNext.value.trim())
781
- const shouldInsertForNextWithAttrsFinal = shouldInsertForNextWithAttrs && !nextStartsWithPunctuation
782
-
783
- // Persist a "tight" decision so post-normalization can remove any artificial
784
- // spaces that were introduced by whitespace collapsing/newline handling.
785
- // This ensures cases like "word\n <1>link</1>\n word" become "word<1>link</1>word".
786
- const isWordSplitStrict = prevEndsWithAlnum && nextStartsWithAlnum && nextStartsWithLowercase && !hasSpaceBeforeNewline && !hasLeadingSpace && !nextHasLeadingSpace
787
- if (isWordSplitStrict) {
788
- // mark the actual element node; map to numeric index later
789
- tightNoSpaceNodes.add(nextNode)
790
- }
791
-
792
- if (
793
- hasSpaceBeforeNewline ||
794
- (isPreservedTag && nextHasChildren) ||
795
- // non-preserved with text after must have an explicit leading space
796
- (!isPreservedTag && hasTextAfter && hasLeadingSpace) ||
797
- // next element with attrs: only when not a word-split (see above)
798
- shouldInsertForNextWithAttrsFinal
799
- ) {
800
- out += withLeading + ' '
801
- } else {
802
- out += withLeading
803
- }
804
- continue
805
- }
806
- }
807
-
808
- out += node.value
809
- continue
810
- }
811
-
812
- if (node.type === 'JSXExpressionContainer') {
813
- const expr = node.expression
814
- if (!expr) continue
815
-
816
- const textVal = getStringLiteralFromExpression(expr)
817
- if (textVal !== undefined) {
818
- out += textVal
819
- } else if (expr.type === 'Identifier') {
820
- out += `{{${expr.value}}}`
821
- } else if (expr.type === 'ObjectExpression') {
822
- const prop = expr.properties[0]
823
- if (prop && prop.type === 'KeyValueProperty' && prop.key && prop.key.type === 'Identifier') {
824
- out += `{{${prop.key.value}}}`
825
- } else if (prop && prop.type === 'Identifier') {
826
- out += `{{${prop.value}}}`
827
- } else {
828
- out += '{{value}}'
829
- }
830
- } else if (expr.type === 'MemberExpression' && expr.property && expr.property.type === 'Identifier') {
831
- out += `{{${expr.property.value}}}`
832
- } else if (expr.type === 'CallExpression' && expr.callee?.type === 'Identifier') {
833
- out += `{{${expr.callee.value}}}`
834
- } else {
835
- out += '{{value}}'
836
- }
837
- continue
838
- }
839
-
840
- if (node.type === 'JSXElement') {
841
- let tag: string | undefined
842
- if (node.opening && node.opening.name && node.opening.name.type === 'Identifier') {
843
- tag = node.opening.name.value
844
- }
845
-
846
- // Track root element index for root-level elements
847
- const myRootIndex = isRootLevel ? rootElementIndex : undefined
848
- if (isRootLevel && node.type === 'JSXElement') {
849
- rootElementIndex++
850
- }
851
-
852
- if (tag && allowedTags.has(tag)) {
853
- // Match react-i18next behavior: only preserve as literal HTML when:
854
- // 1. No children (!childChildren) AND no attributes (!childPropsCount)
855
- // 2. OR: Has children but only the children prop (childPropsCount === 1) AND children is a simple string (isString(childChildren))
856
-
857
- const hasAttrs =
858
- node.opening &&
859
- Array.isArray((node.opening as any).attributes) &&
860
- (node.opening as any).attributes.length > 0
861
-
862
- const children = node.children || []
863
- const hasChildren = children.length > 0
864
-
865
- // Check if children is a single PURE text node (JSXText OR simple string expression)
866
- const isSinglePureTextChild =
867
- children.length === 1 && (
868
- children[0]?.type === 'JSXText' ||
869
- (children[0]?.type === 'JSXExpressionContainer' &&
870
- getStringLiteralFromExpression(children[0].expression) !== undefined)
871
- )
872
-
873
- // Preserve as literal HTML in two cases:
874
- // 1. No children and no attributes: <br />
875
- // 2. Single pure text child (with or without attributes): <strong>text</strong> or <strong title="...">text</strong>
876
- if ((!hasChildren || isSinglePureTextChild)) {
877
- const inner = isSinglePureTextChild ? visitNodes(children, undefined) : ''
878
- const hasMeaningfulChildren = String(inner).trim() !== ''
879
-
880
- if (!hasMeaningfulChildren) {
881
- // Self-closing
882
- const prevOriginal = nodes[i - 1]
883
- if (prevOriginal && prevOriginal.type === 'JSXText' && /\n\s*$/.test(prevOriginal.value)) {
884
- out = out.replace(/\s+$/, '')
885
- }
886
- out += `<${tag} />`
887
- } else {
888
- // Preserve with content: <strong>text</strong>
889
- // trim formatting-only edges inside preserved tags so surrounding
890
- // newline/indentation doesn't leak into the preserved-inner text
891
- out += `<${tag}>${trimFormattingEdges(inner)}</${tag}>`
892
- }
893
- } else if (hasAttrs && !isSinglePureTextChild) {
894
- // Has attributes -> treat as indexed element with numeric placeholder
895
- const childrenLocal = children
896
- // determine this element's numeric index once for all branches
897
- const idx = resolveIndex(node)
898
- // Use precise detection so trailing text doesn't force global indexing
899
- const hasNonElementGlobalSlots = hasNonElementGlobalSlotsAmongChildren(childrenLocal)
900
-
901
- if (hasNonElementGlobalSlots) {
902
- // Build a local index map for inner children so nested placeholders
903
- // restart locally instead of using global indices.
904
- const childrenLocalMap = new Map<any, number>()
905
- // always restart local child indices at 1
906
- let localIdxCounter = 1
907
- for (const ch of childrenLocal) {
908
- if (!ch) continue
909
- if (ch.type === 'JSXElement') {
910
- const chTag = ch.opening && ch.opening.name && ch.opening.name.type === 'Identifier'
911
- ? ch.opening.name.value
912
- : undefined
913
- if (chTag && allowedTags.has(chTag)) {
914
- const chHasAttrs =
915
- ch.opening &&
916
- Array.isArray((ch.opening as any).attributes) &&
917
- (ch.opening as any).attributes.length > 0
918
- const chChildren = ch.children || []
919
- const chIsSinglePureText =
920
- chChildren.length === 1 && (
921
- chChildren[0]?.type === 'JSXText' ||
922
- (chChildren[0]?.type === 'JSXExpressionContainer' &&
923
- getStringLiteralFromExpression(chChildren[0].expression) !== undefined)
924
- )
925
- const chWillBePreserved = !chHasAttrs && (!chChildren.length || chIsSinglePureText)
926
- if (!chWillBePreserved) {
927
- childrenLocalMap.set(ch, localIdxCounter++)
928
- }
929
- } else {
930
- childrenLocalMap.set(ch, localIdxCounter++)
931
- }
932
- }
933
- }
934
- const inner = visitNodes(childrenLocal, childrenLocalMap.size ? childrenLocalMap : undefined)
935
- out += `<${idx}>${trimFormattingEdges(inner)}</${idx}>`
936
- } else {
937
- const childrenLocalMap = new Map<any, number>()
938
- // Local child indexes always restart at 1 for the inner mapping.
939
- let localIdxCounter = 1
940
-
941
- for (const ch of children) {
942
- if (!ch) continue
943
- if (ch.type === 'JSXElement') {
944
- const chTag = ch.opening && ch.opening.name && ch.opening.name.type === 'Identifier'
945
- ? ch.opening.name.value
946
- : undefined
947
- if (chTag && allowedTags.has(chTag)) {
948
- // Check if this child will be preserved as literal HTML
949
- const chHasAttrs =
950
- ch.opening &&
951
- Array.isArray((ch.opening as any).attributes) &&
952
- (ch.opening as any).attributes.length > 0
953
- const chChildren = ch.children || []
954
- const chIsSinglePureText =
955
- chChildren.length === 1 && (
956
- chChildren[0]?.type === 'JSXText' ||
957
- (chChildren[0]?.type === 'JSXExpressionContainer' &&
958
- getStringLiteralFromExpression(chChildren[0].expression) !== undefined)
959
- )
960
- const chWillBePreserved = !chHasAttrs && (!chChildren.length || chIsSinglePureText)
961
- if (!chWillBePreserved) {
962
- // Will be indexed, add to local map
963
- childrenLocalMap.set(ch, localIdxCounter++)
964
- }
965
- } else {
966
- childrenLocalMap.set(ch, localIdxCounter++)
967
- }
968
- }
969
- }
970
-
971
- const inner = visitNodes(children, childrenLocalMap.size ? childrenLocalMap : undefined)
972
- out += `<${idx}>${trimFormattingEdges(inner)}</${idx}>`
973
- }
974
- } else {
975
- // Has complex children but no attributes -> preserve tag as literal but index children
976
- // Check if this tag is in globalSlots - if so, index it
977
- const idx = globalSlots.indexOf(node)
978
- if (idx !== -1) {
979
- // This tag is in globalSlots, so index it
980
- // At root level, use the element's position among root elements
981
- const indexToUse = myRootIndex !== undefined ? myRootIndex : idx
982
-
983
- // Check if children have text/expression nodes in globalSlots
984
- // that appear BEFORE or BETWEEN element children (not just trailing)
985
- // Exclude formatting whitespace (newline-only text) from this check
986
- const hasNonElementGlobalSlots = (() => {
987
- let foundElement = false
988
-
989
- for (const ch of children) {
990
- if (!ch) continue
991
-
992
- if (ch.type === 'JSXElement') {
993
- foundElement = true
994
- continue
995
- }
996
-
997
- if (ch.type === 'JSXExpressionContainer' && globalSlots.indexOf(ch) !== -1) {
998
- // Only count if before/between elements, not trailing
999
- return foundElement // false if before first element, true if after
1000
- }
1001
-
1002
- if (ch.type === 'JSXText' && globalSlots.indexOf(ch) !== -1) {
1003
- // Exclude formatting whitespace
1004
- if (isFormattingWhitespace(ch)) continue
1005
-
1006
- // Only count text that appears BEFORE the first element
1007
- // Trailing text after all elements should not force global indexing
1008
- if (!foundElement) {
1009
- // Text before first element - counts
1010
- return true
1011
- }
1012
- // Text after an element - check if there are more elements after this text
1013
- const remainingNodes = children.slice(children.indexOf(ch) + 1)
1014
- const hasMoreElements = remainingNodes.some((n: any) => n && n.type === 'JSXElement')
1015
- if (hasMoreElements) {
1016
- // Text between elements - counts
1017
- return true
1018
- }
1019
- // Trailing text after last element - doesn't count
1020
- }
1021
- }
1022
-
1023
- return false
1024
- })()
1025
-
1026
- // If children have non-element global slots, use global indexes
1027
- // Otherwise use local indexes starting from parent's index + 1
1028
- if (hasNonElementGlobalSlots) {
1029
- // For non-root parents we compact child indexes locally.
1030
- // For root-level parents (index 0) preserve global indexes so tests
1031
- // that expect global numbering (1,3,5...) keep working.
1032
- if (indexToUse === 0) {
1033
- const inner = visitNodes(children, undefined, false)
1034
- out += `<${indexToUse}>${trimFormattingEdges(inner)}</${indexToUse}>`
1035
- continue
1036
- }
1037
-
1038
- // Build a local index map for the inner children so nested placeholders
1039
- // restart locally (avoids leaking global indices into the parent's inner string).
1040
- const childrenLocalMap = new Map<any, number>()
1041
- // local children numbering should start at 1
1042
- let localIdxCounter = 1
1043
- for (const ch of children) {
1044
- if (!ch) continue
1045
- if (ch.type === 'JSXElement') {
1046
- const chTag = ch.opening && ch.opening.name && ch.opening.name.type === 'Identifier'
1047
- ? ch.opening.name.value
1048
- : undefined
1049
- if (chTag && allowedTags.has(chTag)) {
1050
- const chHasAttrs =
1051
- ch.opening &&
1052
- Array.isArray((ch.opening as any).attributes) &&
1053
- (ch.opening as any).attributes.length > 0
1054
- const chChildren = ch.children || []
1055
- const chIsSinglePureText =
1056
- chChildren.length === 1 && (
1057
- chChildren[0]?.type === 'JSXText' ||
1058
- (chChildren[0]?.type === 'JSXExpressionContainer' &&
1059
- getStringLiteralFromExpression(chChildren[0].expression) !== undefined)
1060
- )
1061
- const chWillBePreserved = !chHasAttrs && (!chChildren.length || chIsSinglePureText)
1062
- if (!chWillBePreserved) {
1063
- childrenLocalMap.set(ch, localIdxCounter++)
1064
- }
1065
- } else {
1066
- childrenLocalMap.set(ch, localIdxCounter++)
1067
- }
1068
- }
1069
- }
1070
-
1071
- const inner = visitNodes(children, childrenLocalMap.size ? childrenLocalMap : undefined, false)
1072
- out += `<${indexToUse}>${trimFormattingEdges(inner)}</${indexToUse}>`
1073
- continue
1074
- }
1075
-
1076
- // Build local index map for children of this indexed element
1077
- const childrenLocalMap = new Map<any, number>()
1078
- // Local child indexes restart at 1 inside this element (do not start from parent index)
1079
- let localIdxCounter = 1
1080
- for (const ch of children) {
1081
- if (!ch) continue
1082
- if (ch.type === 'JSXElement') {
1083
- const chTag = ch.opening && ch.opening.name && ch.opening.name.type === 'Identifier'
1084
- ? ch.opening.name.value
1085
- : undefined
1086
-
1087
- if (chTag && allowedTags.has(chTag)) {
1088
- // Check if this child will be preserved as literal HTML
1089
- const chHasAttrs =
1090
- ch.opening &&
1091
- Array.isArray((ch.opening as any).attributes) &&
1092
- (ch.opening as any).attributes.length > 0
1093
- const chChildren = ch.children || []
1094
- const chIsSinglePureText =
1095
- chChildren.length === 1 && (
1096
- chChildren[0]?.type === 'JSXText' ||
1097
- (chChildren[0]?.type === 'JSXExpressionContainer' &&
1098
- getStringLiteralFromExpression(chChildren[0].expression) !== undefined)
1099
- )
1100
- const chWillBePreserved = !chHasAttrs && (!chChildren.length || chIsSinglePureText)
1101
- if (!chWillBePreserved) {
1102
- // Will be indexed, add to local map
1103
- childrenLocalMap.set(ch, localIdxCounter++)
1104
- }
1105
- } else {
1106
- // Non-preserved tag, always indexed
1107
- childrenLocalMap.set(ch, localIdxCounter++)
1108
- }
1109
- }
1110
- }
1111
- const inner = visitNodes(children, childrenLocalMap.size > 0 ? childrenLocalMap : undefined, false)
1112
- out += `<${indexToUse}>${trimFormattingEdges(inner)}</${indexToUse}>`
1113
- } else {
1114
- // Not in globalSlots, preserve as literal HTML
1115
- const inner = visitNodes(children, undefined, false)
1116
- out += `<${tag}>${trimFormattingEdges(inner)}</${tag}>`
1117
- }
1118
- }
1119
- } else {
1120
- // Decide whether to use local (restarted) indexes for this element's
1121
- // immediate children or fall back to the global pre-order indexes.
1122
- // If the element's children contain non-element slots (JSXText /
1123
- // JSXExpressionContainer) that were collected as global slots, we must
1124
- // use the global indexes so those text slots keep their positions.
1125
- const children = node.children || []
1126
- const hasNonElementGlobalSlots = children.some((ch: any) =>
1127
- ch && (ch.type === 'JSXText' || ch.type === 'JSXExpressionContainer') && globalSlots.indexOf(ch) !== -1
1128
- )
1129
-
1130
- // If there are non-element global slots among the children, render using
1131
- // global indexes. Otherwise build a compact local index map so nested
1132
- // placeholders restart at 0 inside this parent element.
1133
- if (hasNonElementGlobalSlots) {
1134
- const idx = globalSlots.indexOf(node)
1135
- const inner = visitNodes(children, undefined)
1136
- out += `<${idx}>${trimFormattingEdges(inner)}</${idx}>`
1137
- } else {
1138
- const childrenLocalMap = new Map<any, number>()
1139
- const idx = resolveIndex(node)
1140
- // Local child indexes: restart at 0 when parent idx === 0, otherwise at 1
1141
- let localIdxCounter = 1
1142
-
1143
- for (const ch of children) {
1144
- if (!ch) continue
1145
- if (ch.type === 'JSXElement') {
1146
- const chTag = ch.opening && ch.opening.name && ch.opening.name.type === 'Identifier'
1147
- ? ch.opening.name.value
1148
- : undefined
1149
- if (chTag && allowedTags.has(chTag)) {
1150
- // Check if this child will be preserved as literal HTML
1151
- const chHasAttrs =
1152
- ch.opening &&
1153
- Array.isArray((ch.opening as any).attributes) &&
1154
- (ch.opening as any).attributes.length > 0
1155
- const chChildren = ch.children || []
1156
- const chIsSinglePureText = chChildren.length === 1 && chChildren[0]?.type === 'JSXText'
1157
- const chWillBePreserved = !chHasAttrs && (!chChildren.length || chIsSinglePureText)
1158
- if (!chWillBePreserved) {
1159
- // Will be indexed, add to local map
1160
- childrenLocalMap.set(ch, localIdxCounter++)
1161
- }
1162
- } else {
1163
- childrenLocalMap.set(ch, localIdxCounter++)
1164
- }
1165
- }
1166
- }
1167
-
1168
- const inner = visitNodes(children, childrenLocalMap.size ? childrenLocalMap : undefined)
1169
- out += `<${idx}>${trimFormattingEdges(inner)}</${idx}>`
1170
- }
1171
- }
1172
- continue
1173
- }
1174
-
1175
- if (node.type === 'JSXFragment') {
1176
- out += visitNodes(node.children || [])
1177
- continue
1178
- }
1179
-
1180
- // unknown node types: ignore
1181
- }
1182
-
1183
- return out
1184
- }
1185
-
1186
- const result = visitNodes(children, undefined, true)
1187
-
1188
- // console.log('[serializeJSXChildren] result before cleanup:', JSON.stringify(result))
1189
- // console.log('[serializeJSXChildren] tightNoSpaceNodes:', Array.from(tightNoSpaceNodes || []))
1190
- // console.log('[serializeJSXChildren] globalSlots:', JSON.stringify(globalSlots, null, 2))
1191
- // const slotContexts = globalSlots.map((s, idx) => {
1192
- // const prev = globalSlots[idx - 1]
1193
- // const next = globalSlots[idx + 1]
1194
- // return {
1195
- // idx,
1196
- // type: s ? s.type : null,
1197
- // tag: s && s.type === 'JSXElement' ? s.opening?.name?.value : undefined,
1198
- // preview: s && s.type === 'JSXText' ? String(s.value).slice(0, 40) : undefined,
1199
- // prevType: prev ? prev.type : null,
1200
- // prevPreview: prev && prev.type === 'JSXText' ? String(prev.value).slice(0, 40) : undefined,
1201
- // nextType: next ? next.type : null,
1202
- // nextPreview: next && next.type === 'JSXText' ? String(next.value).slice(0, 40) : undefined
1203
- // }
1204
- // })
1205
- // console.log('[serializeJSXChildren] slotContexts:', JSON.stringify(slotContexts, null, 2))
1206
-
1207
- // Final cleanup in correct order:
1208
- // 1. First, handle <br /> followed by whitespace+newline (boundary formatting)
1209
- const afterBrCleanup = String(result).replace(/<br \/>\s*\n\s*/g, '<br />')
1210
-
1211
- const raw = String(afterBrCleanup)
1212
-
1213
- const tightNoSpaceIndices = new Set<number>()
1214
-
1215
- // Map node-based tight markers into numeric global-slot indices (used by later regex passes).
1216
- if (tightNoSpaceNodes && tightNoSpaceNodes.size > 0) {
1217
- for (let i = 0; i < globalSlots.length; i++) {
1218
- if (tightNoSpaceNodes.has(globalSlots[i])) tightNoSpaceIndices.add(i)
1219
- }
1220
- }
1221
-
1222
- // 1) Remove spaces around explicitly-marked tight indices (word-splits)
1223
- let tmp = String(raw)
1224
- if (tightNoSpaceIndices && tightNoSpaceIndices.size > 0) {
1225
- for (const id of tightNoSpaceIndices) {
1226
- try {
1227
- tmp = tmp.replace(new RegExp('\\s+<' + id + '>', 'g'), '<' + id + '>')
1228
- tmp = tmp.replace(new RegExp('<\\/' + id + '>\\s+', 'g'), '</' + id + '>')
1229
- } catch (e) { /* ignore */ }
1230
- }
1231
- }
1232
-
1233
- // 2) For non-tight placeholders, if there was a newline boundary between
1234
- // a closing tag and following text OR between preceding text and an
1235
- // opening tag, ensure a single separating space. This recovers spaces
1236
- // that are semantically meaningful when the source had newline
1237
- // boundaries but not a word-split.
1238
- tmp = tmp.replace(/<\/(\d+)>\s*\n\s*(\S)/g, (m, idx, after) => {
1239
- const id = Number(idx)
1240
- return tightNoSpaceIndices.has(id) ? `</${idx}>${after}` : `</${idx}> ${after}`
1241
- })
1242
- tmp = tmp.replace(/(\S)\s*\n\s*<(\d+)/g, (m, before, idx) => {
1243
- const id = Number(idx)
1244
- return tightNoSpaceIndices.has(id) ? `${before}<${idx}` : `${before} <${idx}`
1245
- })
1246
-
1247
- // 3) Collapse remaining newlines/indentation and whitespace to single spaces,
1248
- // remove space before period and trim.
1249
- tmp = tmp.replace(/\s*\n\s*/g, ' ')
1250
- tmp = tmp.replace(/\s+/g, ' ')
1251
- // remove spaces before common punctuation (comma, semicolon, colon, question, exclamation, period)
1252
- tmp = tmp.replace(/\s+([,;:!?.])/g, '$1')
1253
- const finalResult = tmp.trim()
1254
-
1255
- // Final guaranteed cleanup for tight (word-split) placeholders:
1256
- // remove any spaces (including NBSP) left before opening or after closing numeric placeholders
1257
- // to ensure "word <1>link</1>word" -> "word<1>link</1>word" when marked tight.
1258
- let postFinal = String(finalResult)
1259
- if (tightNoSpaceIndices && tightNoSpaceIndices.size > 0) {
1260
- for (const id of tightNoSpaceIndices) {
1261
- try {
1262
- // remove ordinary whitespace and non-breaking space variants
1263
- postFinal = postFinal.replace(new RegExp('[\\s\\u00A0]+<' + id + '>', 'g'), '<' + id + '>')
1264
- postFinal = postFinal.replace(new RegExp('<\\/' + id + '>[\\s\\u00A0]+', 'g'), '</' + id + '>')
1265
- } catch (e) { /* ignore */ }
1266
- }
1267
- }
1268
-
1269
- // Additional deterministic pass:
1270
- // If globalSlots show an element whose previous slot is JSXText ending with alnum
1271
- // and next slot is JSXText starting with alnum, and the original previous text did
1272
- // not have an explicit space-before-newline nor the next text a leading space,
1273
- // remove any single space left before the opening placeholder in the final string.
1274
- try {
1275
- for (let idx = 0; idx < globalSlots.length; idx++) {
1276
- const s = globalSlots[idx]
1277
- if (!s || s.type !== 'JSXElement') continue
1278
- const prev = globalSlots[idx - 1]
1279
- const next = globalSlots[idx + 1]
1280
- if (!prev || !next) continue
1281
- if (prev.type !== 'JSXText' || next.type !== 'JSXText') continue
1282
-
1283
- const prevRaw = String(prev.value)
1284
- const nextRaw = String(next.value)
1285
- const prevTrimmed = prevRaw.replace(/\n\s*$/, '')
1286
- const prevEndsAlnum = /[A-Za-z0-9]$/.test(prevTrimmed)
1287
- const nextStartsAlnum = /^[A-Za-z0-9]/.test(nextRaw.trim())
1288
- const nextStartsLowercase = /^[a-z]/.test(nextRaw.trim())
1289
- const hasSpaceBeforeNewline = /\s\n/.test(prevRaw)
1290
- // Treat newline-leading indentation as NOT an explicit leading space.
1291
- const nextHasLeadingSpace = nextRaw && /^\s/.test(nextRaw) && !/^\n/.test(nextRaw)
1292
-
1293
- // Only collapse the space for true word-splits where the next token starts lowercase.
1294
- if (prevEndsAlnum && nextStartsAlnum && nextStartsLowercase && !hasSpaceBeforeNewline && !nextHasLeadingSpace) {
1295
- const id = idx
1296
- postFinal = postFinal.replace(new RegExp('\\s+<' + id + '>', 'g'), '<' + id + '>')
1297
- }
1298
- }
1299
- } catch (e) { /* ignore */ }
1300
-
1301
- // Remap numeric placeholders to compact local indices inside each parent placeholder.
1302
- // This fixes cases where global pre-order indices leak into a parent's inner string
1303
- // (e.g. "<4>...<6>...</6></4>") — we want the inner child placeholders to restart
1304
- // locally (1,2...) when appropriate.
1305
- function remapNumericPlaceholders (input: string) {
1306
- if (!input || !input.includes('<')) return input
1307
-
1308
- type Node = { type: 'text'; text: string } | { type: 'ph'; idx: number; children: Node[] }
1309
-
1310
- // Parse into a simple tree of numeric-placeholder nodes and text nodes.
1311
- function parse (s: string): Node[] {
1312
- const nodes: Node[] = []
1313
- const stack: { node: Node; idx: number }[] = []
1314
- let lastIndex = 0
1315
- const re = /<\/?(\d+)>|<[^>]+>/g
1316
- let m: RegExpExecArray | null
1317
- while ((m = re.exec(s))) {
1318
- const match = m[0]
1319
- const matchIndex = m.index
1320
- if (matchIndex > lastIndex) {
1321
- const text = s.slice(lastIndex, matchIndex)
1322
- const textNode: Node = { type: 'text', text }
1323
- if (stack.length) (stack[stack.length - 1].node as any).children.push(textNode)
1324
- else nodes.push(textNode)
1325
- }
1326
-
1327
- const closingNumeric = /^<\/(\d+)>$/.exec(match)
1328
- const openingNumeric = /^<(\d+)>$/.exec(match)
1329
-
1330
- if (openingNumeric) {
1331
- const idx = Number(openingNumeric[1])
1332
- const ph: Node = { type: 'ph', idx, children: [] }
1333
- if (stack.length) (stack[stack.length - 1].node as any).children.push(ph)
1334
- else nodes.push(ph)
1335
- stack.push({ node: ph, idx })
1336
- } else if (closingNumeric) {
1337
- // pop matching numeric placeholder; if mismatch, just pop last
1338
- if (stack.length) {
1339
- stack.pop()
1340
- }
1341
- } else {
1342
- // non-numeric tag (preserved HTML like <br /> or <strong>) -> treat as text
1343
- const textNode: Node = { type: 'text', text: match }
1344
- if (stack.length) (stack[stack.length - 1].node as any).children.push(textNode)
1345
- else nodes.push(textNode)
1346
- }
1347
-
1348
- lastIndex = re.lastIndex
1349
- }
1350
- if (lastIndex < s.length) {
1351
- const text = s.slice(lastIndex)
1352
- const textNode: Node = { type: 'text', text }
1353
- if (stack.length) (stack[stack.length - 1].node as any).children.push(textNode)
1354
- else nodes.push(textNode)
1355
- }
1356
- return nodes
1357
- }
1358
-
1359
- // Reconstruct string with remapped local indices.
1360
- function build (nodes: Node[], parentIdx: number | null = null): string {
1361
- let out = ''
1362
- for (const n of nodes) {
1363
- if (n.type === 'text') {
1364
- out += n.text
1365
- } else {
1366
- // Map direct child placeholder indices to local sequence
1367
- const childPhs = (n as any).children.filter((c: any) => c.type === 'ph') as { type: 'ph'; idx: number; children: Node[] }[]
1368
- // If the child's original global indices are NOT contiguous (i.e. gaps),
1369
- // do not remap — emit original numbers to preserve tests that expect
1370
- // global pre-order indices like 1,3,5.
1371
- const origIndices = childPhs.map(c => c.idx)
1372
- const isContiguous = origIndices.length <= 1 || origIndices.every((v, i) => i === 0 || v === origIndices[i - 1] + 1)
1373
- // Do not remap when original child indices are non-contiguous.
1374
- if (!isContiguous) {
1375
- const innerNoRemap = (n as any).children.map((child: any) => {
1376
- if (child.type === 'text') return child.text
1377
- return `<${child.idx}>${build(child.children, child.idx)}</${child.idx}>`
1378
- }).join('')
1379
- out += `<${n.idx}>${innerNoRemap}</${n.idx}>`
1380
- continue
1381
- }
1382
- const map = new Map<number, number>()
1383
- // Determine start index: if this parent AST node contains N non-element global slots
1384
- // inside its span, then the first indexed child should be (N + 1). This mirrors
1385
- // how local indexing must account for explicit expression/text slots that appear
1386
- // before an element child inside the same parent.
1387
- let start = (n.idx === 0) ? 0 : 1
1388
- try {
1389
- const parentAst = globalSlots[n.idx]
1390
- if (parentAst && parentAst.span) {
1391
- const parentStart = parentAst.span.start
1392
- const parentEnd = parentAst.span.end
1393
- // Find the first element child (by globalSlots index) that lies inside this parent.
1394
- let firstElementGIdx = -1
1395
- for (let gIdx = n.idx + 1; gIdx < globalSlots.length; gIdx++) {
1396
- const s = globalSlots[gIdx]
1397
- if (!s || !s.span) continue
1398
- if (s.span.start >= parentStart && s.span.end <= parentEnd && s.type === 'JSXElement') {
1399
- firstElementGIdx = gIdx
1400
- break
1401
- }
1402
- }
1403
-
1404
- // Count only non-element global slots that appear before the first element child
1405
- // (these shift the local numbering of element children).
1406
- let nonElementBefore = 0
1407
- if (firstElementGIdx !== -1) {
1408
- for (let gIdx = n.idx + 1; gIdx < firstElementGIdx; gIdx++) {
1409
- const s = globalSlots[gIdx]
1410
- if (!s || !s.span) continue
1411
- if (s.span.start >= parentStart && s.span.end <= parentEnd) {
1412
- if (s.type === 'JSXText' || s.type === 'JSXExpressionContainer') nonElementBefore++
1413
- }
1414
- }
1415
- } else {
1416
- // No element child found: count non-element slots inside parent (fallback)
1417
- for (let gIdx = n.idx + 1; gIdx < globalSlots.length; gIdx++) {
1418
- const s = globalSlots[gIdx]
1419
- if (!s || !s.span) continue
1420
- if (s.span.start >= parentStart && s.span.end <= parentEnd) {
1421
- if (s.type === 'JSXText' || s.type === 'JSXExpressionContainer') nonElementBefore++
1422
- }
1423
- }
1424
- }
1425
-
1426
- if (typeof n.idx === 'number') {
1427
- start = n.idx === 0 ? 0 : Math.max(1, nonElementBefore + 1)
1428
- }
1429
- }
1430
- } catch (e) { /* ignore and fall back to default start */ }
1431
- // assign new numbers in order of appearance among direct children
1432
- for (const c of childPhs) {
1433
- if (!map.has(c.idx)) {
1434
- map.set(c.idx, start++)
1435
- }
1436
- }
1437
-
1438
- // recursively build children, but when emitting child ph tags replace indices
1439
- const inner = (n as any).children.map((child: any) => {
1440
- if (child.type === 'text') return child.text
1441
- const orig = child.idx
1442
- const newIdx = map.has(orig) ? map.get(orig)! : orig
1443
- return `<${newIdx}>${build(child.children, newIdx)}</${newIdx}>`
1444
- }).join('')
1445
-
1446
- out += `<${n.idx}>${inner}</${n.idx}>`
1447
- }
1448
- }
1449
- return out
1450
- }
1451
-
1452
- try {
1453
- const parsed = parse(input)
1454
- return build(parsed)
1455
- } catch (e) {
1456
- return input
1457
- }
1458
- }
1459
-
1460
- postFinal = remapNumericPlaceholders(postFinal)
1461
-
1462
- return postFinal.trim()
1463
- }