diff --git a/.changeset/wild-actors-film.md b/.changeset/wild-actors-film.md new file mode 100644 index 000000000..7717255ec --- /dev/null +++ b/.changeset/wild-actors-film.md @@ -0,0 +1,5 @@ +--- +"lingo.dev": patch +--- + +preserve formatting for yaml format diff --git a/packages/cli/src/cli/loaders/yaml.spec.ts b/packages/cli/src/cli/loaders/yaml.spec.ts index 845e2feef..d69bcd484 100644 --- a/packages/cli/src/cli/loaders/yaml.spec.ts +++ b/packages/cli/src/cli/loaders/yaml.spec.ts @@ -235,4 +235,186 @@ world: World`; const reparsed = await loader.pull("en", result); expect(reparsed).toEqual(data); }); + + it("push should preserve mixed value quoting (some quoted, some unquoted)", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Mixed quoting: some values quoted, some plain, some YAML references + const yamlInput = `gender: + f: "Feminine" + m: "Masculine" + female: :@f + male: :@m`; + + await loader.pull("en", yamlInput); + + const data = { + gender: { + f: "Femenino", + m: "Masculino", + female: ":@f", + male: ":@m", + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // Quoted values should remain quoted + expect(result).toContain('"Femenino"'); + expect(result).toContain('"Masculino"'); + + // YAML references should remain unquoted + expect(result).toContain("female: :@f"); + expect(result).toContain("male: :@m"); + + // Should NOT quote all values globally + expect(result).not.toMatch(/female:\s*":@f"/); + expect(result).not.toMatch(/male:\s*":@m"/); + }); + + it("push should preserve mixed key quoting (some keys quoted, some unquoted)", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Mixed key quoting: one key quoted, others plain + const yamlInput = `gender: + f: Feminine + "m": Masculine + n: Neutral`; + + await loader.pull("en", yamlInput); + + const data = { + gender: { + f: "Femenino", + m: "Masculino", + n: "Neutro", + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // Only 'm' key should be quoted + expect(result).toMatch(/"m":\s*Masculino/); + + // Other keys should NOT be quoted + expect(result).toMatch(/f:\s*Femenino/); + expect(result).not.toContain('"f":'); + expect(result).toMatch(/n:\s*Neutro/); + expect(result).not.toContain('"n":'); + + // Root key should not be quoted + expect(result).not.toContain('"gender":'); + }); + + it("push should preserve both mixed key and value quoting simultaneously", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Complex scenario: mixed keys AND mixed values + const yamlInput = `config: + "special-key": "quoted value" + normalKey: plain value + anotherKey: "another quoted"`; + + await loader.pull("en", yamlInput); + + const data = { + config: { + "special-key": "valor citado", + normalKey: "valor plano", + anotherKey: "otro citado", + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // Quoted key should remain quoted + expect(result).toMatch(/"special-key":/); + + // Other keys should not be quoted + expect(result).toMatch(/normalKey:/); + expect(result).not.toContain('"normalKey"'); + expect(result).toMatch(/anotherKey:/); + expect(result).not.toContain('"anotherKey"'); + + // Quoted values should remain quoted + expect(result).toContain('"valor citado"'); + expect(result).toContain('"otro citado"'); + + // Plain value should remain plain + expect(result).toMatch(/normalKey:\s*valor plano/); + }); + + it("push should preserve nested mixed quoting", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Nested structure with mixed quoting at different levels + const yamlInput = `i18n: + inflections: + gender: + f: "Feminine" + "m": "Masculine" + female: :@f`; + + await loader.pull("en", yamlInput); + + const data = { + i18n: { + inflections: { + gender: { + f: "Femenino", + m: "Masculino", + female: ":@f", + }, + }, + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // Only 'm' key should be quoted + expect(result).toMatch(/"m":/); + expect(result).not.toContain('"f":'); + + // Parent keys should not be quoted + expect(result).not.toContain('"i18n":'); + expect(result).not.toContain('"inflections":'); + expect(result).not.toContain('"gender":'); + + // Quoted value should be quoted, reference unquoted + expect(result).toContain('"Femenino"'); + expect(result).toMatch(/female:\s*:@f/); + }); + + it("push should preserve quoting in yaml-root-key format (locale as root)", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + const yamlInput = `en: + "greeting": "Hello!" + message: Welcome`; + + await loader.pull("en", yamlInput); + + const data = { + en: { + greeting: "¡Hola!", + message: "Bienvenido", + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // The quoted key and value should remain quoted + expect(result).toContain('"greeting":'); + expect(result).toContain('"¡Hola!"'); + + // The unquoted key and value should remain unquoted + expect(result).toMatch(/\smessage:\s/); // message key unquoted + expect(result).not.toContain('"message"'); + expect(result).toMatch(/message:\s*Bienvenido/); // value unquoted + }); }); diff --git a/packages/cli/src/cli/loaders/yaml.ts b/packages/cli/src/cli/loaders/yaml.ts index 302208fbd..7a11ebd74 100644 --- a/packages/cli/src/cli/loaders/yaml.ts +++ b/packages/cli/src/cli/loaders/yaml.ts @@ -2,6 +2,11 @@ import YAML, { ToStringOptions } from "yaml"; import { ILoader } from "./_types"; import { createLoader } from "./_utils"; +interface QuotingMetadata { + keys: Map; + values: Map; +} + export default function createYamlLoader(): ILoader< string, Record @@ -11,15 +16,211 @@ export default function createYamlLoader(): ILoader< return YAML.parse(input) || {}; }, async push(locale, payload, originalInput) { - return YAML.stringify(payload, { - lineWidth: -1, - defaultKeyType: getKeyType(originalInput), - defaultStringType: getStringType(originalInput), - }); + // If no original input, use simple stringify + if (!originalInput || !originalInput.trim()) { + return YAML.stringify(payload, { + lineWidth: -1, + defaultKeyType: "PLAIN", + defaultStringType: "PLAIN", + }); + } + + try { + // Parse source and extract quoting metadata + const sourceDoc = YAML.parseDocument(originalInput); + const metadata = extractQuotingMetadata(sourceDoc); + + // Create output document and apply source quoting + const outputDoc = YAML.parseDocument( + YAML.stringify(payload, { + lineWidth: -1, + defaultKeyType: "PLAIN", + }), + ); + applyQuotingMetadata(outputDoc, metadata); + + return outputDoc.toString({ lineWidth: -1 }); + } catch (error) { + console.warn("Failed to preserve YAML formatting:", error); + // Fallback to current behavior + return YAML.stringify(payload, { + lineWidth: -1, + defaultKeyType: getKeyType(originalInput), + defaultStringType: getStringType(originalInput), + }); + } }, }); } +// Extract quoting metadata from source document +function extractQuotingMetadata(doc: YAML.Document): QuotingMetadata { + const metadata: QuotingMetadata = { + keys: new Map(), + values: new Map(), + }; + const root = doc.contents; + if (!root) return metadata; + + walkAndExtract(root, [], metadata); + return metadata; +} + +// Walk AST and extract quoting information +function walkAndExtract( + node: any, + path: string[], + metadata: QuotingMetadata, +): void { + if (isScalar(node)) { + // Store non-PLAIN value quoting types + if (node.type && node.type !== "PLAIN") { + metadata.values.set(path.join("."), node.type); + } + } else if (isYAMLMap(node)) { + if (node.items && Array.isArray(node.items)) { + for (const pair of node.items) { + if (pair && pair.key) { + const key = getKeyValue(pair.key); + if (key !== null && key !== undefined) { + const keyPath = [...path, String(key)].join("."); + + // Store non-PLAIN key quoting types + if (pair.key.type && pair.key.type !== "PLAIN") { + metadata.keys.set(keyPath, pair.key.type); + } + + // Continue walking values + if (pair.value) { + walkAndExtract(pair.value, [...path, String(key)], metadata); + } + } + } + } + } + } else if (isYAMLSeq(node)) { + if (node.items && Array.isArray(node.items)) { + for (let i = 0; i < node.items.length; i++) { + if (node.items[i]) { + walkAndExtract(node.items[i], [...path, String(i)], metadata); + } + } + } + } +} + +// Apply quoting metadata to output document +function applyQuotingMetadata( + doc: YAML.Document, + metadata: QuotingMetadata, +): void { + const root = doc.contents; + if (!root) return; + + walkAndApply(root, [], metadata); +} + +// Walk AST and apply quoting information +function walkAndApply( + node: any, + path: string[], + metadata: QuotingMetadata, +): void { + if (isScalar(node)) { + // Apply value quoting + const pathKey = path.join("."); + const quoteType = metadata.values.get(pathKey); + if (quoteType) { + node.type = quoteType; + } + } else if (isYAMLMap(node)) { + if (node.items && Array.isArray(node.items)) { + for (const pair of node.items) { + if (pair && pair.key) { + const key = getKeyValue(pair.key); + if (key !== null && key !== undefined) { + const keyPath = [...path, String(key)].join("."); + + // Apply key quoting + const keyQuoteType = metadata.keys.get(keyPath); + if (keyQuoteType) { + pair.key.type = keyQuoteType; + } + + // Continue walking values + if (pair.value) { + walkAndApply(pair.value, [...path, String(key)], metadata); + } + } + } + } + } + } else if (isYAMLSeq(node)) { + if (node.items && Array.isArray(node.items)) { + for (let i = 0; i < node.items.length; i++) { + if (node.items[i]) { + walkAndApply(node.items[i], [...path, String(i)], metadata); + } + } + } + } +} + +// Type guards +function isScalar(node: any): boolean { + if (node?.constructor?.name === "Scalar") { + return true; + } + return ( + node && + typeof node === "object" && + "value" in node && + ("type" in node || "format" in node) + ); +} + +function isYAMLMap(node: any): boolean { + if (node?.constructor?.name === "YAMLMap") { + return true; + } + return ( + node && + typeof node === "object" && + "items" in node && + Array.isArray(node.items) && + !("value" in node) + ); +} + +function isYAMLSeq(node: any): boolean { + if (node?.constructor?.name === "YAMLSeq") { + return true; + } + return ( + node && + typeof node === "object" && + "items" in node && + Array.isArray(node.items) && + !("type" in node) && + !("value" in node) + ); +} + +function getKeyValue(key: any): string | number | null { + if (key === null || key === undefined) { + return null; + } + // Scalar key + if (typeof key === "object" && "value" in key) { + return key.value; + } + // Already a primitive + if (typeof key === "string" || typeof key === "number") { + return key; + } + return null; +} + // check if the yaml keys are using double quotes or single quotes function getKeyType( yamlString: string | null,