diff --git a/.changeset/add-properties-file-plugin.md b/.changeset/add-properties-file-plugin.md new file mode 100644 index 0000000000..cb592dc674 --- /dev/null +++ b/.changeset/add-properties-file-plugin.md @@ -0,0 +1,7 @@ +--- +"@inlang/plugin-properties-file": minor +--- + +feat: add .properties file format plugin + +Add support for Java .properties files as a translation storage format. Built on the `properties-file` npm package for robust parsing. Supports variable interpolation with `{variable}` syntax, inline comments for translator context, and optional key sorting. diff --git a/packages/plugins/properties-file/.prettierrc b/packages/plugins/properties-file/.prettierrc new file mode 100644 index 0000000000..3b3666e86e --- /dev/null +++ b/packages/plugins/properties-file/.prettierrc @@ -0,0 +1,4 @@ +{ + "useTabs": true, + "trailingComma": "es5" +} diff --git a/packages/plugins/properties-file/CHANGELOG.md b/packages/plugins/properties-file/CHANGELOG.md new file mode 100644 index 0000000000..af76835163 --- /dev/null +++ b/packages/plugins/properties-file/CHANGELOG.md @@ -0,0 +1,10 @@ +# @inlang/plugin-properties-file + +## 0.1.0 + +Initial release with support for: +- Import/export of `.properties` files +- Variable interpolation with `{variable}` syntax +- Inline comment preservation +- Key sorting (ascending/descending) +- Multi-locale support diff --git a/packages/plugins/properties-file/LICENSE b/packages/plugins/properties-file/LICENSE new file mode 100644 index 0000000000..546b4fba6c --- /dev/null +++ b/packages/plugins/properties-file/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024 Opral US Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/plugins/properties-file/README.md b/packages/plugins/properties-file/README.md new file mode 100644 index 0000000000..7b90b53b9e --- /dev/null +++ b/packages/plugins/properties-file/README.md @@ -0,0 +1,88 @@ +# @inlang/plugin-properties-file + +Store translations in Java `.properties` files for use with [Paraglide JS](https://inlang.com/m/gerre34r/library-inlang-paraglideJs) and other inlang-compatible tools. + +Uses the [`properties-file`](https://github.com/properties-file/properties-file) package for robust parsing and serialization of the `.properties` format, including Unicode escapes, multi-line values, and comment handling. + +## Installation + +Add the plugin to your `project.inlang/settings.json`: + +```json +{ + "baseLocale": "en", + "locales": ["en", "fr", "de"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-properties-file@latest/dist/index.js" + ], + "plugin.inlang.propertiesFile": { + "pathPattern": "./messages/{locale}.properties" + } +} +``` + +## File format + +Each locale has its own `.properties` file. Keys map directly to bundle IDs. + +```properties +# Welcome message shown on the home page +greeting = Hello {name}! +items.count = You have {count} items +farewell = Goodbye +``` + +## Variable interpolation + +Variables use the `{variableName}` syntax inside values: + +```properties +welcome = Welcome back, {username}! +notification = {sender} sent you {count} messages +``` + +These are converted to inlang expression pattern elements and declared as input variables on the bundle. + +## Comment support + +Comments immediately preceding a key-value pair are preserved during import and restored on export: + +```properties +# This comment will be preserved +greeting = Hello {name}! +``` + +## Settings reference + +| Setting | Type | Required | Description | +| -------------- | ----------------------------- | -------- | ---------------------------------------------------------------- | +| `pathPattern` | `string \| string[]` | Yes | Path(s) to `.properties` files. Must include `{locale}` and end with `.properties`. | +| `sort` | `"asc" \| "desc"` | No | Sort keys alphabetically when exporting. | + +### Multiple path patterns + +You can specify multiple path patterns to load translations from several directories: + +```json +{ + "plugin.inlang.propertiesFile": { + "pathPattern": [ + "./messages/{locale}.properties", + "./overrides/{locale}.properties" + ] + } +} +``` + +### Key sorting + +Enable alphabetical key sorting on export: + +```json +{ + "plugin.inlang.propertiesFile": { + "pathPattern": "./messages/{locale}.properties", + "sort": "asc" + } +} +``` diff --git a/packages/plugins/properties-file/assets/icon.svg b/packages/plugins/properties-file/assets/icon.svg new file mode 100644 index 0000000000..a8dc75e834 --- /dev/null +++ b/packages/plugins/properties-file/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugins/properties-file/build.js b/packages/plugins/properties-file/build.js new file mode 100644 index 0000000000..7abf253ec6 --- /dev/null +++ b/packages/plugins/properties-file/build.js @@ -0,0 +1,34 @@ +import { context } from "esbuild"; + +// eslint-disable-next-line no-undef +const isProduction = process.env.NODE_ENV === "production"; + +const ctx = await context({ + entryPoints: ["./src/index.ts"], + outdir: "./dist", + // improve debugging by not minifying + minify: false, + // ---------------------------------- + // allow top level await + // https://caniuse.com/mdn-javascript_operators_await_top_level + target: "es2022", + // inlang does not support import maps + bundle: true, + // esm to work in the browser + format: "esm", + //! extremly important to be platform neutral + //! to ensure that modules run in browser + //! and server contexts. + platform: "neutral", + // sourcemaps are unused at the moment + sourcemap: false, +}); + +if (isProduction === false) { + await ctx.watch(); + // eslint-disable-next-line no-undef + console.info("Watching for changes..."); +} else { + await ctx.rebuild(); + await ctx.dispose(); +} diff --git a/packages/plugins/properties-file/marketplace-manifest.json b/packages/plugins/properties-file/marketplace-manifest.json new file mode 100644 index 0000000000..24792ae0ce --- /dev/null +++ b/packages/plugins/properties-file/marketplace-manifest.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://inlang.com/schema/marketplace-manifest", + "id": "plugin.inlang.propertiesFile", + "icon": "./assets/icon.svg", + "displayName": { + "en": "Properties file format" + }, + "description": { + "en": "Store translations in Java .properties files with inline comments for translator context. Uses the properties-file parser for robust handling of the .properties format." + }, + "pages": { + "/": "./README.md", + "/changelog": "./CHANGELOG.md" + }, + "keywords": [ + "properties", + "java", + "i18n", + "l10n", + "translation", + "storage", + "import", + "export", + "messages", + "plugin", + "comments" + ], + "publisherName": "community", + "license": "Apache-2.0", + "module": "https://cdn.jsdelivr.net/npm/@inlang/plugin-properties-file@latest/dist/index.js" +} diff --git a/packages/plugins/properties-file/package.json b/packages/plugins/properties-file/package.json new file mode 100644 index 0000000000..d05d65c815 --- /dev/null +++ b/packages/plugins/properties-file/package.json @@ -0,0 +1,38 @@ +{ + "name": "@inlang/plugin-properties-file", + "version": "0.1.0", + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": ["./dist"], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/opral/inlang", + "directory": "packages/plugins/properties-file" + }, + "scripts": { + "dev": "node ./build.js", + "build": "NODE_ENV=production node ./build.js", + "test": "tsc --noEmit && vitest run --passWithNoTests", + "test:watch": "vitest", + "format": "prettier ./src --write", + "clean": "rm -rf ./dist ./node_modules" + }, + "devDependencies": { + "@inlang/sdk": "workspace:*", + "@inlang/tsconfig": "workspace:*", + "@sinclair/typebox": "^0.31.17", + "esbuild": "^0.24.2", + "prettier": "^3.3.3", + "typescript": "^5.5.2", + "vitest": "^3.2.4" + }, + "dependencies": { + "properties-file": "^5.0.4" + } +} diff --git a/packages/plugins/properties-file/src/import-export/exportFiles.ts b/packages/plugins/properties-file/src/import-export/exportFiles.ts new file mode 100644 index 0000000000..7ba7e4fd5a --- /dev/null +++ b/packages/plugins/properties-file/src/import-export/exportFiles.ts @@ -0,0 +1,121 @@ +import type { + Bundle, + ExportFile, + Message, + Variant, +} from "@inlang/sdk"; +import { type plugin, PLUGIN_KEY } from "../plugin.js"; + +export const exportFiles: NonNullable<(typeof plugin)["exportFiles"]> = async ({ + bundles, + messages, + variants, + settings, +}) => { + const files: Record< + string, + Array<{ key: string; value: string }> + > = {}; + + for (const message of messages) { + const bundle = bundles.find((b) => b.id === message.bundleId); + if (!bundle) { + continue; + } + + const variantsOfMessage = variants.filter( + (v) => v.messageId === message.id + ); + + if (variantsOfMessage.length === 0) { + continue; + } + + // Properties files do not support plural selectors or multi-variant messages. + // Throw an explicit error rather than silently dropping variants. + if (variantsOfMessage.length > 1) { + throw new Error( + `Message "${bundle.id}" (locale "${message.locale}") has ${variantsOfMessage.length} variants. ` + + `The .properties file format does not support multiple variants (plural/select). ` + + `Consider using a format that supports selectors, or simplify the message to a single variant.` + ); + } + + const variant = variantsOfMessage[0]!; + + const serialized = serializePattern(variant.pattern); + + if (!files[message.locale]) { + files[message.locale] = []; + } + files[message.locale]!.push({ + key: bundle.id, + value: serialized, + }); + } + + const sortDirection = settings?.[PLUGIN_KEY]?.sort ?? undefined; + + const result: ExportFile[] = []; + + for (const locale in files) { + let entries = files[locale]!; + + if (sortDirection === "asc") { + entries = entries.sort((a, b) => a.key.localeCompare(b.key)); + } else if (sortDirection === "desc") { + entries = entries.sort((a, b) => b.key.localeCompare(a.key)); + } + + const lines: string[] = []; + for (const entry of entries) { + lines.push(`${entry.key} = ${entry.value}`); + } + + const content = lines.join("\n") + "\n"; + + result.push({ + locale, + content: new TextEncoder().encode(content), + name: locale + ".properties", + }); + } + + return result; +}; + +/** + * Escape a text value for safe inclusion in a .properties file. + * + * The .properties format treats backslashes, newlines, carriage returns, + * and tabs as control characters. These must be escaped to preserve the + * original value through a roundtrip. + */ +function escapePropertyValue(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} + +function serializePattern(pattern: Variant["pattern"]): string { + let result = ""; + + for (const part of pattern) { + switch (part.type) { + case "text": + result += escapePropertyValue(part.value); + break; + case "expression": + if (part.arg.type === "variable-reference") { + result += `{${part.arg.name}}`; + break; + } + throw new Error("Unsupported expression type"); + default: + throw new Error("Unsupported pattern element type"); + } + } + return result; +} diff --git a/packages/plugins/properties-file/src/import-export/importFiles.ts b/packages/plugins/properties-file/src/import-export/importFiles.ts new file mode 100644 index 0000000000..aed819e6e9 --- /dev/null +++ b/packages/plugins/properties-file/src/import-export/importFiles.ts @@ -0,0 +1,144 @@ +import type { + Bundle, + Declaration, + MessageImport, + Pattern, + VariantImport, +} from "@inlang/sdk"; +import { type plugin } from "../plugin.js"; +import { getProperties } from "properties-file"; + +export const importFiles: NonNullable<(typeof plugin)["importFiles"]> = async ({ + files, +}) => { + const bundles: Bundle[] = []; + const messages: MessageImport[] = []; + const variants: VariantImport[] = []; + + for (const file of files) { + const content = new TextDecoder().decode(file.content); + const properties = getProperties(content); + + for (const key in properties) { + const value = properties[key]!; + + const result = parseBundle(key, file.locale, value); + messages.push(result.message); + variants.push(...result.variants); + + const existingBundle = bundles.find((b) => b.id === result.bundle.id); + if (existingBundle === undefined) { + bundles.push(result.bundle); + } else { + // merge declarations without duplicates + existingBundle.declarations = unique([ + ...existingBundle.declarations, + ...result.bundle.declarations, + ]); + } + } + } + + return { bundles, messages, variants }; +}; + +function parseBundle( + key: string, + locale: string, + value: string +): { + bundle: Bundle; + message: MessageImport; + variants: VariantImport[]; +} { + const parsed = parsePattern(value); + const declarations = unique(parsed.declarations); + + return { + bundle: { + id: key, + declarations, + }, + message: { + bundleId: key, + selectors: [], + locale: locale, + }, + variants: [ + { + messageBundleId: key, + messageLocale: locale, + matches: [], + pattern: parsed.pattern, + }, + ], + }; +} + +function parsePattern(value: string): { + declarations: Declaration[]; + pattern: Pattern; +} { + const pattern: Pattern = []; + const declarations: Declaration[] = []; + let buffer = ""; + + const flushBuffer = () => { + if (buffer.length > 0) { + pattern.push({ type: "text", value: buffer }); + buffer = ""; + } + }; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + + if (char === "{") { + const closingIndex = value.indexOf("}", index); + + if (closingIndex === -1) { + buffer += char; + continue; + } + + const placeholder = value.slice(index + 1, closingIndex); + + // Only treat as a variable if the placeholder is a valid identifier + if ( + placeholder.length > 0 && + /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(placeholder) + ) { + flushBuffer(); + + declarations.push({ + type: "input-variable", + name: placeholder, + }); + pattern.push({ + type: "expression", + arg: { type: "variable-reference", name: placeholder }, + }); + index = closingIndex; + continue; + } + + // Not a valid variable reference, treat as literal text + buffer += char; + continue; + } + + buffer += char; + } + + flushBuffer(); + + return { + declarations, + pattern, + }; +} + +const unique = (arr: Array) => + [...new Set(arr.map((item) => JSON.stringify(item)))].map((item) => + JSON.parse(item) + ); diff --git a/packages/plugins/properties-file/src/import-export/roundtrip.test.ts b/packages/plugins/properties-file/src/import-export/roundtrip.test.ts new file mode 100644 index 0000000000..bf3d6b8237 --- /dev/null +++ b/packages/plugins/properties-file/src/import-export/roundtrip.test.ts @@ -0,0 +1,502 @@ +import { expect, test } from "vitest"; +import { importFiles } from "./importFiles.js"; +import type { + Bundle, + Declaration, + Message, + Pattern, + Variant, +} from "@inlang/sdk"; +import { exportFiles } from "./exportFiles.js"; + +test("it handles simple key-value pairs without variables", async () => { + const imported = await runImportFiles( + "greeting = Hello World\nfarewell = Goodbye" + ); + + expect(imported.bundles).lengthOf(2); + expect(imported.messages).lengthOf(2); + expect(imported.variants).lengthOf(2); + + expect(imported.bundles[0]?.id).toStrictEqual("greeting"); + expect(imported.bundles[0]?.declarations).toStrictEqual([]); + expect(imported.bundles[1]?.id).toStrictEqual("farewell"); + + expect(imported.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Hello World" }, + ]); + expect(imported.variants[1]?.pattern).toStrictEqual([ + { type: "text", value: "Goodbye" }, + ]); +}); + +test("it handles values with variable expressions", async () => { + const imported = await runImportFiles( + "greeting = Hello {name}!\nitems.count = You have {count} items" + ); + + expect(imported.bundles).lengthOf(2); + expect(imported.messages).lengthOf(2); + expect(imported.variants).lengthOf(2); + + expect(imported.bundles[0]?.id).toStrictEqual("greeting"); + expect(imported.bundles[0]?.declarations).toStrictEqual([ + { type: "input-variable", name: "name" }, + ] satisfies Declaration[]); + + expect(imported.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Hello " }, + { + type: "expression", + arg: { type: "variable-reference", name: "name" }, + }, + { type: "text", value: "!" }, + ] satisfies Pattern); + + expect(imported.bundles[1]?.id).toStrictEqual("items.count"); + expect(imported.bundles[1]?.declarations).toStrictEqual([ + { type: "input-variable", name: "count" }, + ] satisfies Declaration[]); + + expect(imported.variants[1]?.pattern).toStrictEqual([ + { type: "text", value: "You have " }, + { + type: "expression", + arg: { type: "variable-reference", name: "count" }, + }, + { type: "text", value: " items" }, + ] satisfies Pattern); +}); + +test("it handles multiple variables in a single value", async () => { + const imported = await runImportFiles( + "message = {user} sent {count} messages to {recipient}" + ); + + expect(imported.bundles[0]?.declarations).toStrictEqual([ + { type: "input-variable", name: "user" }, + { type: "input-variable", name: "count" }, + { type: "input-variable", name: "recipient" }, + ] satisfies Declaration[]); + + expect(imported.variants[0]?.pattern).toStrictEqual([ + { + type: "expression", + arg: { type: "variable-reference", name: "user" }, + }, + { type: "text", value: " sent " }, + { + type: "expression", + arg: { type: "variable-reference", name: "count" }, + }, + { type: "text", value: " messages to " }, + { + type: "expression", + arg: { type: "variable-reference", name: "recipient" }, + }, + ] satisfies Pattern); +}); + +test("it handles empty values", async () => { + const imported = await runImportFiles("empty.key = "); + + expect(imported.bundles).lengthOf(1); + expect(imported.bundles[0]?.id).toStrictEqual("empty.key"); + expect(imported.variants[0]?.pattern).toStrictEqual([]); +}); + +test("it handles comments (they are ignored during import)", async () => { + const imported = await runImportFiles( + "# This is a greeting\ngreeting = Hello" + ); + + expect(imported.bundles).lengthOf(1); + expect(imported.bundles[0]?.id).toStrictEqual("greeting"); + expect(imported.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Hello" }, + ]); +}); + +test("it handles dot-separated keys", async () => { + const imported = await runImportFiles( + "section.subsection.key = Deep value\nsection.other = Other value" + ); + + expect(imported.bundles).lengthOf(2); + expect(imported.bundles[0]?.id).toStrictEqual("section.subsection.key"); + expect(imported.bundles[1]?.id).toStrictEqual("section.other"); +}); + +test("it handles multiple locales", async () => { + const imported = await importFiles({ + settings: {} as any, + files: [ + { + locale: "en", + content: new TextEncoder().encode( + "greeting = Hello {name}!\nfarewell = Goodbye" + ), + }, + { + locale: "fr", + content: new TextEncoder().encode( + "greeting = Bonjour {name} !\nfarewell = Au revoir" + ), + }, + ], + }); + + expect(imported.bundles).lengthOf(2); + expect(imported.messages).lengthOf(4); + expect(imported.variants).lengthOf(4); + + // Bundle declarations should be merged from all locales + expect(imported.bundles[0]?.id).toStrictEqual("greeting"); + expect(imported.bundles[0]?.declarations).toStrictEqual([ + { type: "input-variable", name: "name" }, + ]); + + const exported = await runExportFiles(imported); + expect(exported).lengthOf(2); + + const enContent = new TextDecoder().decode( + exported.find((e: any) => e.locale === "en")?.content + ); + const frContent = new TextDecoder().decode( + exported.find((e: any) => e.locale === "fr")?.content + ); + + expect(enContent).toContain("greeting = Hello {name}!"); + expect(enContent).toContain("farewell = Goodbye"); + expect(frContent).toContain("greeting = Bonjour {name} !"); + expect(frContent).toContain("farewell = Au revoir"); +}); + +test("roundtrip: import then export then import produces same data", async () => { + const original = + "greeting = Hello {name}!\nitems.count = You have {count} items\nsimple = Just text\n"; + const imported1 = await runImportFiles(original); + const exported = await runExportFiles(imported1); + const imported2 = await importFiles({ + settings: {} as any, + files: [ + { + locale: "en", + content: exported[0]!.content, + }, + ], + }); + + expect(imported2.bundles.length).toStrictEqual(imported1.bundles.length); + expect(imported2.messages.length).toStrictEqual(imported1.messages.length); + expect(imported2.variants.length).toStrictEqual(imported1.variants.length); + + for (let i = 0; i < imported1.bundles.length; i++) { + expect(imported2.bundles[i]?.id).toStrictEqual(imported1.bundles[i]?.id); + expect(imported2.bundles[i]?.declarations).toStrictEqual( + imported1.bundles[i]?.declarations + ); + } + + for (let i = 0; i < imported1.variants.length; i++) { + expect(imported2.variants[i]?.pattern).toStrictEqual( + imported1.variants[i]?.pattern + ); + } +}); + +test("export sorts keys ascending when configured", async () => { + const imported = await runImportFiles( + "c.key = three\na.key = one\nb.key = two" + ); + + const settingsAsc = { + "plugin.inlang.propertiesFile": { + sort: "asc", + }, + }; + const exported = await runExportFiles(imported, settingsAsc); + const content = new TextDecoder().decode(exported[0]?.content); + const lines = content.split("\n").filter((l: string) => l.length > 0); + + expect(lines[0]).toStrictEqual("a.key = one"); + expect(lines[1]).toStrictEqual("b.key = two"); + expect(lines[2]).toStrictEqual("c.key = three"); +}); + +test("export sorts keys descending when configured", async () => { + const imported = await runImportFiles( + "a.key = one\nc.key = three\nb.key = two" + ); + + const settingsDesc = { + "plugin.inlang.propertiesFile": { + sort: "desc", + }, + }; + const exported = await runExportFiles(imported, settingsDesc); + const content = new TextDecoder().decode(exported[0]?.content); + const lines = content.split("\n").filter((l: string) => l.length > 0); + + expect(lines[0]).toStrictEqual("c.key = three"); + expect(lines[1]).toStrictEqual("b.key = two"); + expect(lines[2]).toStrictEqual("a.key = one"); +}); + +test("it handles the same variable used multiple times", async () => { + const imported = await runImportFiles( + "repeat = The value {value} appears twice: {value}" + ); + + expect(imported.bundles[0]?.declarations).toHaveLength(1); + expect(imported.bundles[0]?.declarations?.[0]).toMatchObject({ + type: "input-variable", + name: "value", + }); + + expect(imported.variants[0]?.pattern).toEqual([ + { type: "text", value: "The value " }, + { + type: "expression", + arg: { type: "variable-reference", name: "value" }, + }, + { type: "text", value: " appears twice: " }, + { + type: "expression", + arg: { type: "variable-reference", name: "value" }, + }, + ]); +}); + +test("it handles special characters in values", async () => { + const imported = await runImportFiles( + "special = Value with \\= equals and \\: colon" + ); + + expect(imported.bundles).lengthOf(1); + expect(imported.bundles[0]?.id).toStrictEqual("special"); + // properties-file unescapes the value + expect(imported.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Value with = equals and : colon" }, + ]); +}); + +test("it handles unicode escape sequences", async () => { + const imported = await runImportFiles("unicode = Hello \\u0057orld"); + + expect(imported.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Hello World" }, + ]); +}); + +test("it handles colon as separator", async () => { + const imported = await runImportFiles("key : value with colon separator"); + + expect(imported.bundles).lengthOf(1); + expect(imported.bundles[0]?.id).toStrictEqual("key"); + expect(imported.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "value with colon separator" }, + ]); +}); + +test("it handles values without any separator space", async () => { + const imported = await runImportFiles("key=value"); + + expect(imported.bundles).lengthOf(1); + expect(imported.bundles[0]?.id).toStrictEqual("key"); + expect(imported.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "value" }, + ]); +}); + +test("it handles unclosed braces as literal text", async () => { + const imported = await runImportFiles("broken = Hello {world"); + + expect(imported.variants[0]?.pattern).toStrictEqual([ + { type: "text", value: "Hello {world" }, + ]); +}); + +test("handles inputs of a bundle even if one locale doesn't use all inputs", async () => { + const imported = await importFiles({ + settings: {} as any, + files: [ + { + locale: "en", + content: new TextEncoder().encode( + "message = Hello {username}! Welcome to {place}." + ), + }, + { + locale: "de", + content: new TextEncoder().encode( + "message = Willkommen {username}!" + ), + }, + ], + }); + + expect(imported.bundles).lengthOf(1); + expect(imported.messages).lengthOf(2); + expect(imported.variants).lengthOf(2); + + expect(imported.bundles[0]?.declarations).toStrictEqual([ + { type: "input-variable", name: "username" }, + { type: "input-variable", name: "place" }, + ]); + + const exported = await runExportFiles(imported); + + const enContent = new TextDecoder().decode( + exported.find((e: any) => e.locale === "en")?.content + ); + const deContent = new TextDecoder().decode( + exported.find((e: any) => e.locale === "de")?.content + ); + + expect(enContent).toContain( + "message = Hello {username}! Welcome to {place}." + ); + expect(deContent).toContain("message = Willkommen {username}!"); +}); + +test("export file ends with a newline", async () => { + const imported = await runImportFiles("key = value"); + const exported = await runExportFiles(imported); + const content = new TextDecoder().decode(exported[0]?.content); + + expect(content.endsWith("\n")).toBe(true); +}); + +test("export file name uses locale and .properties extension", async () => { + const imported = await runImportFiles("key = value"); + const exported = await runExportFiles(imported); + + expect(exported[0]?.name).toStrictEqual("en.properties"); +}); + +test("it escapes control characters in exported values", async () => { + const imported = await runImportFiles( + "multiline = line1\\nline2\\nline3\ntabbed = col1\\tcol2\nbackslash = path\\\\to\\\\file" + ); + + const exported = await runExportFiles(imported); + const content = new TextDecoder().decode(exported[0]?.content); + + // The exported content should have escaped control characters + expect(content).toContain("multiline = line1\\nline2\\nline3"); + expect(content).toContain("tabbed = col1\\tcol2"); + expect(content).toContain("backslash = path\\\\to\\\\file"); +}); + +test("it passes ICU MessageFormat syntax through as plain text", async () => { + const imported = await runImportFiles( + "item_count = {count, plural, one {1 item} other {{count} items}}\ngreeting = {gender, select, male {Mr.} female {Ms.} other {Mx.}} {name}" + ); + + expect(imported.bundles).lengthOf(2); + // ICU syntax is treated as a single variant with plain text — not parsed into selectors + expect(imported.variants).lengthOf(2); + + // Roundtrip: ICU syntax should survive export unchanged + const exported = await runExportFiles(imported); + const content = new TextDecoder().decode(exported[0]?.content); + expect(content).toContain( + "item_count = {count, plural, one {1 item} other {{count} items}}" + ); + expect(content).toContain( + "greeting = {gender, select, male {Mr.} female {Ms.} other {Mx.}} {name}" + ); +}); + +test("it throws on multi-variant messages", async () => { + // Construct a message with multiple variants (simulating a plural) + const bundles: Bundle[] = [{ id: "item_count", declarations: [] }]; + const messages: Message[] = [ + { + id: "item_count_en", + bundleId: "item_count", + locale: "en", + selectors: [], + }, + ]; + const variants: Variant[] = [ + { + id: "v1", + messageId: "item_count_en", + matches: [{ type: "literal-match", key: "count", value: "one" }], + pattern: [{ type: "text", value: "1 item" }], + }, + { + id: "v2", + messageId: "item_count_en", + matches: [{ type: "literal-match", key: "count", value: "other" }], + pattern: [{ type: "text", value: "{count} items" }], + }, + ]; + + await expect( + exportFiles({ + bundles, + messages, + variants, + settings: { + baseLocale: "en", + locales: ["en"], + "plugin.inlang.propertiesFile": { + pathPattern: "./messages/{locale}.properties", + }, + } as any, + }) + ).rejects.toThrow(/does not support multiple variants/); +}); + +// convenience wrapper for less testing code +function runImportFiles(propertiesContent: string) { + return importFiles({ + settings: {} as any, + files: [ + { + locale: "en", + content: new TextEncoder().encode(propertiesContent), + }, + ], + }); +} + +// convenience wrapper for less testing code +async function runExportFiles( + imported: Awaited>, + settings: Record = {} +) { + // add ids which are undefined from the import + for (const message of imported.messages) { + if (message.id === undefined) { + message.id = + imported.messages.find( + (m: any) => + m.bundleId === message.bundleId && m.locale === message.locale + )?.id ?? `${Math.random() * 1000}`; + } + } + for (const variant of imported.variants) { + if (variant.id === undefined) { + (variant as any).id = `${Math.random() * 1000}`; + } + if (variant.messageId === undefined) { + (variant as any).messageId = imported.messages.find( + (m: any) => + m.bundleId === (variant as any).messageBundleId && + m.locale === (variant as any).messageLocale + )?.id; + } + } + + const exported = await exportFiles({ + settings: settings as any, + bundles: imported.bundles as Bundle[], + messages: imported.messages as Message[], + variants: imported.variants as Variant[], + }); + return exported; +} diff --git a/packages/plugins/properties-file/src/import-export/toBeImportedFiles.ts b/packages/plugins/properties-file/src/import-export/toBeImportedFiles.ts new file mode 100644 index 0000000000..47c488889b --- /dev/null +++ b/packages/plugins/properties-file/src/import-export/toBeImportedFiles.ts @@ -0,0 +1,21 @@ +import { PLUGIN_KEY, type plugin } from "../plugin.js"; + +export const toBeImportedFiles: NonNullable< + (typeof plugin)["toBeImportedFiles"] +> = async ({ settings }) => { + const result = []; + const pathPatterns = settings[PLUGIN_KEY]?.pathPattern + ? Array.isArray(settings[PLUGIN_KEY].pathPattern) + ? settings[PLUGIN_KEY].pathPattern + : [settings[PLUGIN_KEY].pathPattern] + : []; + for (const pathPattern of pathPatterns) { + for (const locale of settings.locales) { + result.push({ + locale, + path: pathPattern.replace(/{locale}/, locale), + }); + } + } + return result; +}; diff --git a/packages/plugins/properties-file/src/index.ts b/packages/plugins/properties-file/src/index.ts new file mode 100644 index 0000000000..5bfdad5599 --- /dev/null +++ b/packages/plugins/properties-file/src/index.ts @@ -0,0 +1,3 @@ +import { plugin } from "./plugin.js"; + +export default plugin; diff --git a/packages/plugins/properties-file/src/plugin.ts b/packages/plugins/properties-file/src/plugin.ts new file mode 100644 index 0000000000..114ca7ac83 --- /dev/null +++ b/packages/plugins/properties-file/src/plugin.ts @@ -0,0 +1,17 @@ +import type { InlangPlugin } from "@inlang/sdk"; +import { PluginSettings } from "./settings.js"; +import { toBeImportedFiles } from "./import-export/toBeImportedFiles.js"; +import { importFiles } from "./import-export/importFiles.js"; +import { exportFiles } from "./import-export/exportFiles.js"; + +export const PLUGIN_KEY = "plugin.inlang.propertiesFile"; + +export const plugin: InlangPlugin<{ + [PLUGIN_KEY]?: PluginSettings; +}> = { + key: PLUGIN_KEY, + settingsSchema: PluginSettings, + toBeImportedFiles, + importFiles, + exportFiles, +}; diff --git a/packages/plugins/properties-file/src/settings.ts b/packages/plugins/properties-file/src/settings.ts new file mode 100644 index 0000000000..ea512fec5e --- /dev/null +++ b/packages/plugins/properties-file/src/settings.ts @@ -0,0 +1,31 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const pathPatternString = Type.String({ + pattern: ".*\\{locale\\}.*\\.properties$", + examples: [ + "./messages/{locale}.properties", + "./i18n/{locale}.properties", + ], + title: "Path to language files", + description: + "Specify the pathPattern to locate .properties files in your repository. It must include `{locale}` and end with `.properties`.", +}); + +const pathPatternArray = Type.Array(pathPatternString, { + title: "Paths to language files", + description: + "Specify multiple pathPatterns to locate .properties files in your repository. Each must include `{locale}` and end with `.properties`.", +}); + +const sort = Type.Optional( + Type.Union([Type.Literal("asc"), Type.Literal("desc")], { + title: "Sort keys", + description: "Sort message keys when writing files.", + }) +); + +export type PluginSettings = Static; +export const PluginSettings = Type.Object({ + pathPattern: Type.Union([pathPatternString, pathPatternArray]), + sort, +}); diff --git a/packages/plugins/properties-file/tsconfig.json b/packages/plugins/properties-file/tsconfig.json new file mode 100644 index 0000000000..d7c934df41 --- /dev/null +++ b/packages/plugins/properties-file/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@inlang/tsconfig/default", + "include": ["src/**/*"], + "compilerOptions": { + "resolveJsonModule": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b406edaf0..1b26dee9d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,34 @@ importers: specifier: 3.1.1 version: 3.1.1(@types/debug@4.1.12)(@types/node@24.10.2)(@vitest/browser@3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.9.3))(playwright@1.55.0)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)(webdriverio@9.2.1))(happy-dom@18.0.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.10.2(@types/node@24.10.2)(typescript@5.7.3))(sass-embedded@1.89.2)(terser@5.36.0) + packages/plugins/properties-file: + dependencies: + properties-file: + specifier: ^5.0.4 + version: 5.0.4 + devDependencies: + '@inlang/sdk': + specifier: workspace:* + version: link:../../sdk + '@inlang/tsconfig': + specifier: workspace:* + version: link:../../tsconfig + '@sinclair/typebox': + specifier: ^0.31.17 + version: 0.31.28 + esbuild: + specifier: ^0.24.2 + version: 0.24.2 + prettier: + specifier: ^3.3.3 + version: 3.6.2 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(@vitest/browser@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.10.2(@types/node@24.10.2)(typescript@5.9.3))(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) + packages/plugins/t-function-matcher: dependencies: '@inlang/sdk': @@ -4674,9 +4702,6 @@ packages: '@types/chai@4.3.20': resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} - '@types/chai@5.2.2': - resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -5875,7 +5900,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.0, please upgrade + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -9650,6 +9675,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + properties-file@5.0.4: + resolution: {integrity: sha512-c3CqZLWoNN+zQvpn4z8GgVdBGWlSzbjPumDk1Yri2bdbw7CMzaOgoPsj271kJzNwznEtt4tIDByhZjrw1LzKBw==} + engines: {node: '>=0.4.0'} + property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} @@ -10756,10 +10785,6 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -14129,6 +14154,12 @@ snapshots: - typescript - verdaccio + '@nrwl/devkit@18.3.5(nx@18.3.5)': + dependencies: + '@nx/devkit': 18.3.5(nx@18.3.5) + transitivePeerDependencies: + - nx + '@nrwl/devkit@18.3.5(nx@21.4.1)': dependencies: '@nx/devkit': 18.3.5(nx@21.4.1) @@ -14270,7 +14301,7 @@ snapshots: '@nx/devkit@18.3.5(nx@18.3.5)': dependencies: - '@nrwl/devkit': 18.3.5(nx@21.4.1) + '@nrwl/devkit': 18.3.5(nx@18.3.5) ejs: 3.1.10 enquirer: 2.3.6 ignore: 5.3.2 @@ -15885,10 +15916,6 @@ snapshots: '@types/chai@4.3.20': {} - '@types/chai@5.2.2': - dependencies: - '@types/deep-eql': 4.0.2 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -16595,11 +16622,11 @@ snapshots: - vite optional: true - '@vitest/browser@3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(playwright@1.55.0)(vite@6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)(webdriverio@9.2.1)': + '@vitest/browser@3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(playwright@1.55.0)(vite@7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)(webdriverio@9.2.1)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(vite@6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(vite@7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 @@ -16751,7 +16778,7 @@ snapshots: tinyrainbow: 2.0.0 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.33)(@vitest/browser@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) optionalDependencies: - '@vitest/browser': 3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(playwright@1.55.0)(vite@6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)(webdriverio@9.2.1) + '@vitest/browser': 3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(playwright@1.55.0)(vite@7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)(webdriverio@9.2.1) transitivePeerDependencies: - supports-color @@ -16784,7 +16811,7 @@ snapshots: '@vitest/expect@3.2.4': dependencies: - '@types/chai': 5.2.2 + '@types/chai': 5.2.3 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 @@ -16845,23 +16872,14 @@ snapshots: msw: 2.10.2(@types/node@24.10.2)(typescript@5.7.3) vite: 5.4.19(@types/node@24.10.2)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0) - '@vitest/mocker@3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(vite@6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(vite@7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.10.2(@types/node@22.15.33)(typescript@5.7.3) - vite: 6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) - - '@vitest/mocker@3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.7.3))(vite@6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.10.2(@types/node@24.10.2)(typescript@5.7.3) - vite: 6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) + vite: 7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) '@vitest/mocker@3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.7.3))(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))': dependencies: @@ -16871,16 +16889,6 @@ snapshots: optionalDependencies: msw: 2.10.2(@types/node@24.10.2)(typescript@5.7.3) vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) - optional: true - - '@vitest/mocker@3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.9.3))(vite@6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.10.2(@types/node@24.10.2)(typescript@5.9.3) - vite: 6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) '@vitest/mocker@3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.9.3))(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))': dependencies: @@ -16890,7 +16898,6 @@ snapshots: optionalDependencies: msw: 2.10.2(@types/node@24.10.2)(typescript@5.9.3) vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) - optional: true '@vitest/mocker@4.0.16(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(vite@7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))': dependencies: @@ -22277,6 +22284,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + properties-file@5.0.4: {} + property-information@6.5.0: {} property-information@7.1.0: {} @@ -23632,11 +23641,6 @@ snapshots: tinyexec@1.0.2: {} - tinyglobby@0.2.14: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -23876,8 +23880,7 @@ snapshots: typescript@5.7.3: {} - typescript@5.9.3: - optional: true + typescript@5.9.3: {} ufo@1.6.1: {} @@ -24171,15 +24174,16 @@ snapshots: - supports-color - terser - vite-node@3.2.4(@types/node@22.15.33)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0): + vite-node@3.2.4(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.19(@types/node@22.15.33)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0) + vite: 7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -24188,16 +24192,19 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml - vite-node@3.2.4(@types/node@24.10.2)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0): + vite-node@3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.19(@types/node@24.10.2)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -24206,6 +24213,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vite-plugin-generate-file@0.2.0: dependencies: @@ -24288,18 +24297,6 @@ snapshots: sass-embedded: 1.89.2 terser: 5.36.0 - vite@5.4.19(@types/node@22.15.33)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.53.2 - optionalDependencies: - '@types/node': 22.15.33 - fsevents: 2.3.3 - lightningcss: 1.30.2 - sass-embedded: 1.89.2 - terser: 5.36.0 - vite@5.4.19(@types/node@24.10.2)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0): dependencies: esbuild: 0.21.5 @@ -24330,42 +24327,6 @@ snapshots: tsx: 4.20.5 yaml: 2.8.1 - vite@6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.2 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.15.33 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - sass-embedded: 1.89.2 - terser: 5.36.0 - tsx: 4.20.5 - yaml: 2.8.1 - - vite@6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.2 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.2 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - sass-embedded: 1.89.2 - terser: 5.36.0 - tsx: 4.20.5 - yaml: 2.8.1 - vite@7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -24639,33 +24600,33 @@ snapshots: vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.33)(@vitest/browser@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1): dependencies: - '@types/chai': 5.2.2 + '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(vite@6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(vite@7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.1 + debug: 4.4.3 expect-type: 1.2.2 - magic-string: 0.30.18 + magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.9.0 + std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.15.33)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0) + vite: 7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.15.33 - '@vitest/browser': 3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(playwright@1.55.0)(vite@6.3.5(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)(webdriverio@9.2.1) + '@vitest/browser': 3.2.4(msw@2.10.2(@types/node@22.15.33)(typescript@5.7.3))(playwright@1.55.0)(vite@7.2.7(@types/node@22.15.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)(webdriverio@9.2.1) happy-dom: 18.0.1 jsdom: 27.3.0(postcss@8.5.6) transitivePeerDependencies: @@ -24684,28 +24645,28 @@ snapshots: vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(@vitest/browser@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.10.2(@types/node@24.10.2)(typescript@5.7.3))(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1): dependencies: - '@types/chai': 5.2.2 + '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.7.3))(vite@6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.7.3))(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.1 + debug: 4.4.3 expect-type: 1.2.2 - magic-string: 0.30.18 + magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.9.0 + std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.10.2)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -24729,28 +24690,28 @@ snapshots: vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(@vitest/browser@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.10.2(@types/node@24.10.2)(typescript@5.9.3))(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1): dependencies: - '@types/chai': 5.2.2 + '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.9.3))(vite@6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.10.2(@types/node@24.10.2)(typescript@5.9.3))(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.1 + debug: 4.4.3 expect-type: 1.2.2 - magic-string: 0.30.18 + magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.9.0 + std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.10.2)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.89.2)(terser@5.36.0)(tsx@4.20.5)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12