i18next-cli 1.24.12 → 1.24.14
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.
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/extractor/parsers/expression-resolver.js +1 -1
- package/dist/esm/cli.js +1 -1
- package/dist/esm/extractor/parsers/expression-resolver.js +1 -1
- package/package.json +6 -6
- package/types/cli.d.ts +3 -1
- package/types/cli.d.ts.map +1 -1
- package/types/extractor/parsers/expression-resolver.d.ts.map +1 -1
- package/CHANGELOG.md +0 -595
- package/src/cli.ts +0 -283
- package/src/config.ts +0 -215
- package/src/extractor/core/ast-visitors.ts +0 -259
- package/src/extractor/core/extractor.ts +0 -250
- package/src/extractor/core/key-finder.ts +0 -142
- package/src/extractor/core/translation-manager.ts +0 -750
- package/src/extractor/index.ts +0 -7
- package/src/extractor/parsers/ast-utils.ts +0 -87
- package/src/extractor/parsers/call-expression-handler.ts +0 -793
- package/src/extractor/parsers/comment-parser.ts +0 -424
- package/src/extractor/parsers/expression-resolver.ts +0 -353
- package/src/extractor/parsers/jsx-handler.ts +0 -488
- package/src/extractor/parsers/jsx-parser.ts +0 -1463
- package/src/extractor/parsers/scope-manager.ts +0 -445
- package/src/extractor/plugin-manager.ts +0 -116
- package/src/extractor.ts +0 -15
- package/src/heuristic-config.ts +0 -92
- package/src/index.ts +0 -22
- package/src/init.ts +0 -175
- package/src/linter.ts +0 -345
- package/src/locize.ts +0 -263
- package/src/migrator.ts +0 -208
- package/src/rename-key.ts +0 -398
- package/src/status.ts +0 -380
- package/src/syncer.ts +0 -133
- package/src/types-generator.ts +0 -139
- package/src/types.ts +0 -577
- package/src/utils/default-value.ts +0 -45
- package/src/utils/file-utils.ts +0 -167
- package/src/utils/funnel-msg-tracker.ts +0 -84
- package/src/utils/logger.ts +0 -36
- package/src/utils/nested-object.ts +0 -135
- 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
|
-
}
|