Skip to content

Commit 9f429c6

Browse files
authored
fix: yaml preserve formatting (#1642)
* fix: preserve formatting in YAML files * chore: add changeset
1 parent a48fdce commit 9f429c6

File tree

8 files changed

+197
-37
lines changed

8 files changed

+197
-37
lines changed

.changeset/hip-rivers-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
Preserve formatting in YAML files
Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
1-
es:
1+
en:
22
navigation:
3-
home: "Inicio"
4-
about: "Sobre nosotros"
5-
contact: "Contacto"
6-
services: "Servicios"
7-
forms:
8-
title: "Formulario de contacto"
9-
name_label: "Su nombre"
10-
email_label: "Dirección de correo electrónico"
11-
message_label: "Mensaje"
12-
submit_button: "Enviar mensaje"
13-
success_message: "¡Gracias por su mensaje!"
3+
home: "Home"
4+
about: "About us"
5+
contact: "Contact"
6+
services: "Services"
7+
forms:
8+
title: "Contact form"
9+
name_label: "Your name"
10+
email_label: "Email address"
11+
"message_label": Message
12+
submit_button: Submit message
13+
success_message: "Thank you for your message!"
14+
inflections:
15+
number_of_items: 100
16+
gender:
17+
f: "Feminine"
18+
m: "Masculine"
19+
n: "Neuter"
20+
female: :@f
21+
male: :@m
22+
neuter: :@n
23+
F: :@f
24+
M: :@m
25+
N: :@n
26+
default: :m
Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,26 @@
1-
es: {}
1+
es:
2+
navigation:
3+
home: "Inicio"
4+
about: "Sobre nosotros"
5+
contact: "Contacto"
6+
services: "Servicios"
7+
forms:
8+
title: "Formulario de contacto"
9+
name_label: "Tu nombre"
10+
email_label: "Dirección de correo electrónico"
11+
"message_label": Mensaje
12+
submit_button: Enviar mensaje
13+
success_message: "¡Gracias por tu mensaje!"
14+
inflections:
15+
number_of_items: 100
16+
gender:
17+
f: "Femenino"
18+
m: "Masculino"
19+
n: "Neutro"
20+
female: :@f
21+
male: :@m
22+
neuter: :@n
23+
F: :@f
24+
M: :@m
25+
N: :@n
26+
default: :m
Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,25 @@
11
title: "MyApp"
22
description: Hello, world!
3-
welcome_message: 'Welcome to MyApp'
4-
3+
welcome_message: "Welcome to MyApp"
54
user_profile:
65
display_name: "John Doe"
76
bio: Software developer
8-
97
navigation_items:
108
- "Home"
119
- "About"
1210
- "Contact"
13-
1411
product:
1512
name: "MyWidget"
1613
tagline: The best widget ever
1714
features:
1815
- "Easy to use"
1916
- "Fast and reliable"
20-
2117
settings:
2218
max_users: 100
2319
enabled: true
2420
timeout: 30.5
25-
2621
complex_structure:
2722
level_one:
2823
level_two:
2924
message: "Deep nested text"
30-
locked_key_1: "This value is locked and should not be changed"
31-
ignored_key_1: "This value is ignored and should not appear in target locales"
25+
locked_key_1: "This value is locked and should not be changed"

packages/cli/demo/yaml/es/example.yml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
title: "MyApp"
2-
description: "¡Hola, mundo!"
2+
description: ¡Hola, mundo!
33
welcome_message: "Bienvenido a MyApp"
44
user_profile:
5-
display_name: "Juan Pérez"
6-
bio: "Desarrollador de software"
5+
display_name: "John Doe"
6+
bio: Desarrollador de software
77
navigation_items:
8-
- "Inicio"
9-
- "Acerca de"
10-
- "Contacto"
8+
- Inicio
9+
- Acerca de
10+
- Contacto
1111
product:
1212
name: "MyWidget"
13-
tagline: "El mejor widget de todos"
13+
tagline: El mejor widget de todos
1414
features:
15-
- "Fácil de usar"
16-
- "Rápido y confiable"
15+
- Fácil de usar
16+
- Rápido y confiable
1717
settings:
1818
max_users: 100
1919
enabled: true

packages/cli/i18n.lock

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,27 @@ checksums:
610610
content/16: 14f593e7cf3b3df84a21e17db318912e
611611
content/17: 5f42d26a42aa29be063019eea27ad07c
612612
content/18: 48bb7e89e72d68d6de12f5cdac64fc18
613-
e6d8e00051ea40ca138a9549ed52e1c6: {}
613+
e6d8e00051ea40ca138a9549ed52e1c6:
614+
navigation/home: 104a3db3b671c04e167eafbe21e57881
615+
navigation/about: 7ed93e7bbfca42a405d61ea3c2791aae
616+
navigation/contact: 9afa39bc47019ee6dec6c74b6273967c
617+
navigation/services: 8ea10b45b9abab2a3bfc3c07e1c9cdc6
618+
navigation/forms/title: cd1568dd5f8241c9429dc634de250ef4
619+
navigation/forms/name_label: b00c01deec0af9a441331a5134210de1
620+
navigation/forms/email_label: 3ba3f099b1b9be6c35ad797da660cb9f
621+
navigation/forms/message_label: f2f72126bd244cfc534eab395e054362
622+
navigation/forms/submit_button: da352018f0db23d97405e3e44ccfe50d
623+
navigation/forms/success_message: a0a7aa980dffa31d4d194af718a917b3
624+
navigation/inflections/gender/f: 1cdef9a43e68074eae7dce0248f7e5a9
625+
navigation/inflections/gender/m: 91f7f601c08b37b397f14f952416623f
626+
navigation/inflections/gender/n: cab8f0be0df82bac41435dee4d2eb1df
627+
navigation/inflections/gender/female: f4adbe8df79a872d3c16329a7e7a361a
628+
navigation/inflections/gender/male: 9ebdcb660f503bb2618ae7ae086617e2
629+
navigation/inflections/gender/neuter: 603743850a2510aaa6a5eb9dbfbe7416
630+
navigation/inflections/gender/F: f4adbe8df79a872d3c16329a7e7a361a
631+
navigation/inflections/gender/M: 9ebdcb660f503bb2618ae7ae086617e2
632+
navigation/inflections/gender/N: 603743850a2510aaa6a5eb9dbfbe7416
633+
navigation/inflections/gender/default: 453a466f60641d9934bbee33dc4cd2b6
614634
1254631a73b754e11a1b9ca8f7362025:
615635
item_count/variations/plural: 2b2b1ff20c417ea68d07d66ba30a5c14
616636
notification_message/stringUnit: d14316154e233634917e317452c5f42c

packages/cli/src/cli/loaders/yaml.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,4 +417,37 @@ world: World`;
417417
expect(result).not.toContain('"message"');
418418
expect(result).toMatch(/message:\s*Bienvenido/); // value unquoted
419419
});
420+
421+
it("push should preserve quoting across different locale keys", async () => {
422+
const loader = createYamlLoader();
423+
loader.setDefaultLocale("en");
424+
425+
// Source has 'en:' root key
426+
const yamlInput = `en:
427+
navigation:
428+
home: "Home"
429+
forms:
430+
"message_label": "Message"`;
431+
432+
await loader.pull("en", yamlInput);
433+
434+
// Target has 'es:' root key (different from source!)
435+
const data = {
436+
es: {
437+
navigation: {
438+
home: "Inicio",
439+
},
440+
forms: {
441+
message_label: "Mensaje",
442+
},
443+
},
444+
};
445+
446+
const result = await loader.push("es", data, yamlInput);
447+
448+
// Quoting should be preserved despite different root keys (en vs es)
449+
expect(result).toContain('home: "Inicio"');
450+
expect(result).toContain('"message_label":');
451+
expect(result).toContain('"Mensaje"');
452+
});
420453
});

packages/cli/src/cli/loaders/yaml.ts

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,21 @@ export default function createYamlLoader(): ILoader<
2828
try {
2929
// Parse source and extract quoting metadata
3030
const sourceDoc = YAML.parseDocument(originalInput);
31-
const metadata = extractQuotingMetadata(sourceDoc);
3231

33-
// Create output document and apply source quoting
32+
// Create output document - let the library handle smart quoting
3433
const outputDoc = YAML.parseDocument(
3534
YAML.stringify(payload, {
3635
lineWidth: -1,
3736
defaultKeyType: "PLAIN",
3837
}),
3938
);
40-
applyQuotingMetadata(outputDoc, metadata);
39+
40+
// Detect if this is yaml-root-key format by comparing structures
41+
const isRootKeyFormat = detectRootKeyFormat(sourceDoc, outputDoc);
42+
43+
// Extract and apply metadata with root-key awareness
44+
const metadata = extractQuotingMetadata(sourceDoc, isRootKeyFormat);
45+
applyQuotingMetadata(outputDoc, metadata, isRootKeyFormat);
4146

4247
return outputDoc.toString({ lineWidth: -1 });
4348
} catch (error) {
@@ -53,16 +58,70 @@ export default function createYamlLoader(): ILoader<
5358
});
5459
}
5560

61+
// Detect if this is yaml-root-key format by comparing source and output structures
62+
function detectRootKeyFormat(
63+
sourceDoc: YAML.Document,
64+
outputDoc: YAML.Document,
65+
): boolean {
66+
const sourceRoot = sourceDoc.contents;
67+
const outputRoot = outputDoc.contents;
68+
69+
// Both must be maps with single root key
70+
if (!isYAMLMap(sourceRoot) || !isYAMLMap(outputRoot)) {
71+
return false;
72+
}
73+
74+
const sourceMap = sourceRoot as any;
75+
const outputMap = outputRoot as any;
76+
77+
if (
78+
!sourceMap.items ||
79+
sourceMap.items.length !== 1 ||
80+
!outputMap.items ||
81+
outputMap.items.length !== 1
82+
) {
83+
return false;
84+
}
85+
86+
const sourceRootKey = getKeyValue(sourceMap.items[0].key);
87+
const outputRootKey = getKeyValue(outputMap.items[0].key);
88+
89+
// If both have single root keys that are DIFFERENT strings, it's yaml-root-key format
90+
// (e.g., source has "en:", output has "es:")
91+
if (
92+
sourceRootKey !== outputRootKey &&
93+
typeof sourceRootKey === "string" &&
94+
typeof outputRootKey === "string"
95+
) {
96+
return true;
97+
}
98+
99+
return false;
100+
}
101+
56102
// Extract quoting metadata from source document
57-
function extractQuotingMetadata(doc: YAML.Document): QuotingMetadata {
103+
function extractQuotingMetadata(
104+
doc: YAML.Document,
105+
skipRootKey: boolean,
106+
): QuotingMetadata {
58107
const metadata: QuotingMetadata = {
59108
keys: new Map<string, string>(),
60109
values: new Map<string, string>(),
61110
};
62111
const root = doc.contents;
63112
if (!root) return metadata;
64113

65-
walkAndExtract(root, [], metadata);
114+
let startNode: any = root;
115+
116+
// If yaml-root-key format, skip the locale root key
117+
if (skipRootKey && isYAMLMap(root)) {
118+
const rootMap = root as any;
119+
if (rootMap.items && rootMap.items.length === 1) {
120+
startNode = rootMap.items[0].value;
121+
}
122+
}
123+
124+
walkAndExtract(startNode, [], metadata);
66125
return metadata;
67126
}
68127

@@ -113,11 +172,22 @@ function walkAndExtract(
113172
function applyQuotingMetadata(
114173
doc: YAML.Document,
115174
metadata: QuotingMetadata,
175+
skipRootKey: boolean,
116176
): void {
117177
const root = doc.contents;
118178
if (!root) return;
119179

120-
walkAndApply(root, [], metadata);
180+
let startNode: any = root;
181+
182+
// If yaml-root-key format, skip the locale root key
183+
if (skipRootKey && isYAMLMap(root)) {
184+
const rootMap = root as any;
185+
if (rootMap.items && rootMap.items.length === 1) {
186+
startNode = rootMap.items[0].value;
187+
}
188+
}
189+
190+
walkAndApply(startNode, [], metadata);
121191
}
122192

123193
// Walk AST and apply quoting information

0 commit comments

Comments
 (0)