diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5a8a08f..b1ef53e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,4 +1,4 @@ -name: Check Build TypeScript Project +name: Check Go Project on: push: @@ -13,15 +13,19 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "22" - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - run: pnpm test - - run: pnpm run build + - run: go vet ./... + - run: go test -race ./... + - run: go build ./cmd/data-localizer + - run: npm run build + - run: node bindings/node/smoke-test.js + - run: npm pack --dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61845aa..a7dedeb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,50 +1,31 @@ -name: Auto Release from Package Version +name: Verify Go Release on: push: - branches: - - main + tags: + - "v*" workflow_dispatch: -permissions: - contents: write - -concurrency: - group: auto-release - cancel-in-progress: false - jobs: - release: + verify: runs-on: ubuntu-latest - steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Read package version - id: version - shell: bash - run: | - VERSION="$(node -p "require('./package.json').version")" - TAG="v${VERSION}" - - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - - if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "${TAG} already exists; no release is needed." - else - echo "exists=false" >> "$GITHUB_OUTPUT" - echo "Creating release ${TAG}." - fi + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod - - name: Create GitHub Release - if: steps.version.outputs.exists == 'false' - uses: softprops/action-gh-release@v2 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - tag_name: ${{ steps.version.outputs.tag }} - name: Release ${{ steps.version.outputs.tag }} - target_commitish: ${{ github.sha }} - generate_release_notes: true + node-version: "22" + + - run: go test -race ./... + - run: go vet ./... + - run: go build ./cmd/data-localizer + - run: npm run build + - run: node bindings/node/smoke-test.js + - run: npm pack --dry-run diff --git a/.gitignore b/.gitignore index 9fc69cb..9ff57db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ -node_modules +/bin/ dist -.pnpm-store +/.gocache/ +/.gomodcache/ +/.gotelemetry/ +/.npm-cache/ +/coverage.out +*.exe +*.test +docs +node_modules +*.npm-cache diff --git a/.npmignore b/.npmignore index bc0e6e1..fec16f8 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,13 @@ node_modules -src -tests +.gocache +.gomodcache +.gotelemetry +.npm-cache +bin +bindings +cmd +docs +tools +*.go +go.mod +go.sum diff --git a/README.md b/README.md index 80ebd85..ad26dd8 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,259 @@ # data-localizer -A lightweight utility to localize JSON objects and arrays using language fields (e.g. `{"ar": "...", "en": "..."}`) and an HTTP language header. -Works out of the box with **ISO 639-1** language codes (e.g., `en`, `ar`, `fr`, …) and safely falls back when a language isn’t supported. +`data-localizer` is a dependency-free Go library and CLI for recursively +localizing JSON-compatible data. It resolves objects such as +`{"en":"Hello","ar":"مرحبا"}` using an `Accept-Language` value. ---- +It performs no network requests and does not call any external API. -## Installation +The repository also builds an npm package. Its localization logic is the same +Go code compiled to WebAssembly; JavaScript only provides the Node.js wrapper. + +## Install ```bash -npm install data-localizer -# or -pnpm add data-localizer +go get github.com/elrefai99/data-localizer +``` + +The module requires Go 1.22 or newer and uses only the standard library. + +## Library usage + +```go +package main + +import ( + "fmt" + "log" + + localizer "github.com/elrefai99/data-localizer" +) + +func main() { + data := map[string]any{ + "title": map[string]any{ + "en": "Coffee", + "ar": "قهوة", + }, + } + + result, err := localizer.Localize(data, "ar-EG, en;q=0.8") + if err != nil { + log.Fatal(err) + } + fmt.Printf("%v\n", result) + // map[title:قهوة] +} ``` ---- +`Localize` uses English as its fallback. A third argument changes it: -## Features +```go +result, err := localizer.Localize(data, "unknown", "ar") +``` -- Localize **objects and arrays** recursively -- Validates `Accept-Language` against an ISO 639-1 list (`langJSON`) -- **Fallback** language when preferred language is missing/unsupported -- Auto-strips region parts: `en-US` → `en`, `fr-CA` → `fr` -- Zero dependencies, TypeScript-first +## Configured localizer ---- +Create one reusable localizer when the application has a known language set or +needs an explicit missing-value policy: -## Quick Start +```go +engine, err := localizer.New(localizer.Options{ + SupportedLanguages: []string{"en", "ar", "fr", "fr-CA"}, + FallbackLanguage: "en", + FallbackLanguages: []string{"fr"}, + MissingTranslation: localizer.MissingPreserve, +}) +if err != nil { + log.Fatal(err) +} + +result, err := engine.Localize(data, "fr-CA,fr;q=0.9,en;q=0.8") +``` + +Available policies are `MissingPreserve`, `MissingEmpty`, `MissingNull`, and +`MissingError`. Custom detection and missing handlers are available through +`Options.IsTranslationMap` and `Options.OnMissing`. + +The engine: + +- parses quality values and regional language tags; +- keeps valid empty strings, `false`, and zero translations; +- recursively copies maps, slices, and arrays without mutating the input; +- treats only supported-language scalar maps as translations by default; +- preserves structs and other non-JSON values unchanged; +- returns `CycleError` for cyclic maps or slices. + +## CLI + +Build the command: + +```bash +go build -o bin/data-localizer ./cmd/data-localizer +``` + +Read JSON from stdin: + +```bash +echo '{"title":{"en":"Hello","ar":"مرحبا"}}' | + ./bin/data-localizer -lang 'ar-EG, en;q=0.8' -pretty +``` + +Or pass a file: + +```bash +./bin/data-localizer -lang fr -fallback en input.json +``` + +CLI flags: + +- `-lang`: an `Accept-Language` value; +- `-fallback`: the primary fallback language (default `en`); +- `-supported`: optional comma-separated language list; +- `-missing`: `preserve`, `empty`, `null`, or `error`; +- `-pretty`: pretty-print output JSON. + +## npm package + +Build the JavaScript wrapper, TypeScript declarations, and Go WebAssembly +binary: + +```bash +npm run build +``` + +Use it from JavaScript or TypeScript with the familiar synchronous API: -### Example with Arrays ```ts -import { localizeDatas } from "data-localizer"; +import { localize, createLocalizer } from "data-localizer"; -const items = [ - { title: { ar: "مرحبا", en: "Hello" } }, - { title: { ar: "عالم", en: "World" } } -]; +const result = localize( + { title: { en: "Coffee", ar: "قهوة" } }, + "ar-EG,en;q=0.8", +); +// { title: "قهوة" } -localizeDatas(items, "ar"); -// => [ { title: "مرحبا" }, { title: "عالم" } ] +const engine = createLocalizer({ + supportedLanguages: ["en", "ar"], + fallbackLanguage: "en", + missingTranslation: "preserve", +}); +``` -localizeDatas(items, "en"); -// => [ { title: "Hello" }, { title: "World" } ] +Create and inspect the archive before publishing: + +```bash +npm pack --dry-run +npm publish +``` + +The published package has no npm dependencies. It targets Node.js 18 or newer +and contains `index.js`, `index.d.ts`, the Go runtime, and the compiled `.wasm` +engine. + +## Framework adapters + +Frameworks are optional peer dependencies. Install only the framework used by +the application. + +### Express + +```ts +import express from "express"; +import { expressLocalizer } from "data-localizer/express"; + +const app = express(); +app.use(expressLocalizer({ + supportedLanguages: ["en", "ar"], + fallbackLanguage: "en", + autoLocalizeResponse: true, +})); + +app.get("/product", (_request, response) => { + response.json({ title: { en: "Coffee", ar: "قهوة" } }); +}); +``` + +Without `autoLocalizeResponse`, call `request.localizeData(data)` explicitly. + +CommonJS JavaScript is also supported: + +```js +const express = require("express"); +const expressLocalizer = require("data-localizer/express"); + +const app = express(); +app.use(expressLocalizer({ fallbackLanguage: "en" })); + +app.get("/product", (request, response) => { + response.json(request.localizeData({ + title: { en: "Coffee", ar: "قهوة" }, + })); +}); +``` + +For JavaScript ESM, use the same default import shown in the TypeScript +example. Named imports also work: + +```js +import { expressLocalizer } from "data-localizer/express"; ``` -### Example with Objects +### NestJS + +```ts +import { createNestInterceptor } from "data-localizer/nest"; + +app.useGlobalInterceptors(createNestInterceptor({ + supportedLanguages: ["en", "ar"], + fallbackLanguage: "en", +})); +``` + +`NestDataLocalizer` is also exported for use as a service or custom provider. + +### Fastify + ```ts -import { localizeDatas } from "data-localizer"; +import fastifyLocalizer from "data-localizer/fastify"; -const user = { - name: { ar: "محمد", en: "Mohamed" }, - age: 30 -}; +await app.register(fastifyLocalizer, { + supportedLanguages: ["en", "ar"], + fallbackLanguage: "en", + autoLocalizeResponse: true, +}); +``` + +Every Fastify request receives `request.localizeData(data)`. -localizeDatas(user, "ar"); -// => { name: "محمد", age: 30 } +### Koa -localizeDatas(user, "en"); -// => { name: "Mohamed", age: 30 } +```ts +import { koaLocalizer } from "data-localizer/koa"; + +app.use(koaLocalizer({ + fallbackLanguage: "en", + autoLocalizeResponse: true, +})); ``` -### Example with Nested Structures +For other Node.js or TypeScript frameworks, use the neutral adapter: + ```ts -import { localizeObjectFields } from "data-localizer"; +import { createRequestLocalizer } from "data-localizer/framework"; -const payload = { - post: { - title: { ar: "عنوان", en: "Title" }, - meta: { - description: { ar: "وصف", en: "Description" } - } - } -}; +const adapter = createRequestLocalizer({ fallbackLanguage: "en" }); +const result = adapter.localize(data, request); +``` -localizeObjectFields(payload, "ar"); -// => { post: { title: "عنوان", meta: { description: "وصف" } } } +## Development -localizeObjectFields(payload, "en"); -// => { post: { title: "Title", meta: { description: "Description" } } } +```bash +go test ./... +go vet ./... +go build ./cmd/data-localizer ``` + +## License + +MIT diff --git a/bindings/node/adapters/express.d.ts b/bindings/node/adapters/express.d.ts new file mode 100644 index 0000000..8ef9b0e --- /dev/null +++ b/bindings/node/adapters/express.d.ts @@ -0,0 +1,25 @@ +import type { Request, RequestHandler } from "express"; +import type { JsonValue } from "../index"; +import type { RequestAdapterOptions } from "./framework"; + +export interface ExpressLocalizerOptions extends RequestAdapterOptions { + autoLocalizeResponse?: boolean; + getLanguageHeader?: (request: Request) => string | undefined; +} + +declare global { + namespace Express { + interface Request { + localizeData( + data: JsonValue, + languageHeader?: string, + ): TOutput; + } + } +} + +export declare function expressLocalizer( + options?: ExpressLocalizerOptions, +): RequestHandler; + +export default expressLocalizer; diff --git a/bindings/node/adapters/express.js b/bindings/node/adapters/express.js new file mode 100644 index 0000000..ad231df --- /dev/null +++ b/bindings/node/adapters/express.js @@ -0,0 +1,22 @@ +"use strict"; + +const { createRequestLocalizer } = require("./shared.js"); + +function expressLocalizer(options = {}) { + const adapter = createRequestLocalizer(options); + + return function dataLocalizerMiddleware(request, response, next) { + request.localizeData = adapter.forRequest(request); + + if (options.autoLocalizeResponse && typeof response.json === "function") { + const sendJSON = response.json.bind(response); + response.json = (body) => sendJSON(request.localizeData(body)); + } + + next(); + }; +} + +module.exports = expressLocalizer; +module.exports.expressLocalizer = expressLocalizer; +module.exports.default = expressLocalizer; diff --git a/bindings/node/adapters/fastify.d.ts b/bindings/node/adapters/fastify.d.ts new file mode 100644 index 0000000..826bbac --- /dev/null +++ b/bindings/node/adapters/fastify.d.ts @@ -0,0 +1,21 @@ +import type { FastifyPluginCallback, FastifyRequest } from "fastify"; +import type { JsonValue } from "../index"; +import type { RequestAdapterOptions } from "./framework"; + +export interface FastifyLocalizerOptions extends RequestAdapterOptions { + autoLocalizeResponse?: boolean; + getLanguageHeader?: (request: FastifyRequest) => string | undefined; +} + +declare module "fastify" { + interface FastifyRequest { + localizeData( + data: JsonValue, + languageHeader?: string, + ): TOutput; + } +} + +export declare const fastifyLocalizer: FastifyPluginCallback; + +export default fastifyLocalizer; diff --git a/bindings/node/adapters/fastify.js b/bindings/node/adapters/fastify.js new file mode 100644 index 0000000..51ce704 --- /dev/null +++ b/bindings/node/adapters/fastify.js @@ -0,0 +1,35 @@ +"use strict"; + +const { createRequestLocalizer } = require("./shared.js"); + +function fastifyLocalizer(fastify, options = {}, done) { + try { + const adapter = createRequestLocalizer(options); + fastify.decorateRequest("localizeData", function localizeData( + data, + languageHeader, + ) { + return adapter.forRequest(this)(data, languageHeader); + }); + + if (options.autoLocalizeResponse) { + fastify.addHook( + "preSerialization", + function localizeResponse(request, _reply, payload, next) { + try { + next(null, request.localizeData(payload)); + } catch (error) { + next(error); + } + }, + ); + } + done(); + } catch (error) { + done(error); + } +} + +module.exports = fastifyLocalizer; +module.exports.fastifyLocalizer = fastifyLocalizer; +module.exports.default = fastifyLocalizer; diff --git a/bindings/node/adapters/framework.d.ts b/bindings/node/adapters/framework.d.ts new file mode 100644 index 0000000..7d70244 --- /dev/null +++ b/bindings/node/adapters/framework.d.ts @@ -0,0 +1,31 @@ +import type { JsonValue, LocalizerOptions } from "../index"; + +export type RequestLike = + | string + | { + headers?: + | { get(name: string): string | null } + | Record; + get?(name: string): string | undefined; + }; + +export interface RequestAdapterOptions extends LocalizerOptions { + getLanguageHeader?: (request: TRequest) => string | undefined; +} + +export interface RequestLocalizer { + localize( + data: JsonValue, + requestOrLanguage?: RequestLike, + ): TOutput; + forRequest( + request: unknown, + ): (data: JsonValue, languageHeader?: string) => TOutput; + languageFor(request: unknown): string; +} + +export declare function getAcceptLanguage(request: RequestLike): string; + +export declare function createRequestLocalizer( + options?: RequestAdapterOptions, +): RequestLocalizer; diff --git a/bindings/node/adapters/framework.js b/bindings/node/adapters/framework.js new file mode 100644 index 0000000..c2cfbcd --- /dev/null +++ b/bindings/node/adapters/framework.js @@ -0,0 +1,9 @@ +"use strict"; + +const { + createRequestLocalizer, + getAcceptLanguage, +} = require("./shared.js"); + +exports.createRequestLocalizer = createRequestLocalizer; +exports.getAcceptLanguage = getAcceptLanguage; diff --git a/bindings/node/adapters/koa.d.ts b/bindings/node/adapters/koa.d.ts new file mode 100644 index 0000000..76f5e96 --- /dev/null +++ b/bindings/node/adapters/koa.d.ts @@ -0,0 +1,21 @@ +import Koa = require("koa"); +import type { JsonValue } from "../index"; +import type { RequestAdapterOptions } from "./framework"; + +export interface KoaLocalizerOptions extends RequestAdapterOptions { + autoLocalizeResponse?: boolean; + getLanguageHeader?: (request: Koa.Request) => string | undefined; +} + +declare module "koa" { + interface BaseContext { + localizeData( + data: JsonValue, + languageHeader?: string, + ): TOutput; + } +} + +export declare function koaLocalizer(options?: KoaLocalizerOptions): Koa.Middleware; + +export default koaLocalizer; diff --git a/bindings/node/adapters/koa.js b/bindings/node/adapters/koa.js new file mode 100644 index 0000000..be6c058 --- /dev/null +++ b/bindings/node/adapters/koa.js @@ -0,0 +1,20 @@ +"use strict"; + +const { createRequestLocalizer } = require("./shared.js"); + +function koaLocalizer(options = {}) { + const adapter = createRequestLocalizer(options); + + return async function dataLocalizerMiddleware(context, next) { + context.localizeData = adapter.forRequest(context.request || context); + await next(); + + if (options.autoLocalizeResponse && context.body !== undefined) { + context.body = context.localizeData(context.body); + } + }; +} + +module.exports = koaLocalizer; +module.exports.koaLocalizer = koaLocalizer; +module.exports.default = koaLocalizer; diff --git a/bindings/node/adapters/module-smoke-test.mjs b/bindings/node/adapters/module-smoke-test.mjs new file mode 100644 index 0000000..dd4ad85 --- /dev/null +++ b/bindings/node/adapters/module-smoke-test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; + +import expressDefault, { + expressLocalizer, +} from "../../../dist/adapters/express.js"; +import fastifyDefault, { + fastifyLocalizer, +} from "../../../dist/adapters/fastify.js"; +import koaDefault, { koaLocalizer } from "../../../dist/adapters/koa.js"; + +assert.equal(expressDefault, expressLocalizer); +assert.equal(fastifyDefault, fastifyLocalizer); +assert.equal(koaDefault, koaLocalizer); + +const request = { headers: { "accept-language": "ar" } }; +let localized; +expressDefault()(request, {}, () => { + localized = request.localizeData({ title: { en: "Hello", ar: "مرحبا" } }); +}); +assert.deepEqual(localized, { title: "مرحبا" }); + +console.log("JavaScript ESM adapter smoke test passed"); diff --git a/bindings/node/adapters/nest.d.ts b/bindings/node/adapters/nest.d.ts new file mode 100644 index 0000000..6d1214b --- /dev/null +++ b/bindings/node/adapters/nest.d.ts @@ -0,0 +1,31 @@ +import type { + CallHandler, + ExecutionContext, + NestInterceptor, +} from "@nestjs/common"; +import type { Observable } from "rxjs"; +import type { JsonValue } from "../index"; +import type { RequestAdapterOptions } from "./framework"; + +export type RequestLocalizeFunction = ( + data: JsonValue, + languageHeader?: string, +) => TOutput; + +export declare class NestDataLocalizer { + constructor(options?: RequestAdapterOptions); + localize( + data: JsonValue, + languageHeader?: string, + ): TOutput; + forRequest(request: unknown): RequestLocalizeFunction; +} + +export declare class DataLocalizerInterceptor implements NestInterceptor { + constructor(options?: RequestAdapterOptions); + intercept(context: ExecutionContext, next: CallHandler): Observable; +} + +export declare function createNestInterceptor( + options?: RequestAdapterOptions, +): DataLocalizerInterceptor; diff --git a/bindings/node/adapters/nest.js b/bindings/node/adapters/nest.js new file mode 100644 index 0000000..0021e1c --- /dev/null +++ b/bindings/node/adapters/nest.js @@ -0,0 +1,43 @@ +"use strict"; + +const { createRequestLocalizer } = require("./shared.js"); + +class NestDataLocalizer { + constructor(options = {}) { + this.adapter = createRequestLocalizer(options); + } + + localize(data, languageHeader = "") { + return this.adapter.localize(data, languageHeader); + } + + forRequest(request) { + return this.adapter.forRequest(request); + } +} + +class DataLocalizerInterceptor { + constructor(options = {}) { + this.adapter = createRequestLocalizer(options); + } + + intercept(context, next) { + const request = context.switchToHttp().getRequest(); + request.localizeData = this.adapter.forRequest(request); + + // Nest applications already depend on RxJS. Requiring it lazily keeps the + // core and all non-Nest adapters free from this peer dependency. + const { map } = require("rxjs/operators"); + return next + .handle() + .pipe(map((payload) => request.localizeData(payload))); + } +} + +function createNestInterceptor(options = {}) { + return new DataLocalizerInterceptor(options); +} + +exports.NestDataLocalizer = NestDataLocalizer; +exports.DataLocalizerInterceptor = DataLocalizerInterceptor; +exports.createNestInterceptor = createNestInterceptor; diff --git a/bindings/node/adapters/shared.js b/bindings/node/adapters/shared.js new file mode 100644 index 0000000..991b157 --- /dev/null +++ b/bindings/node/adapters/shared.js @@ -0,0 +1,73 @@ +"use strict"; + +const { createLocalizer } = require("../index.js"); + +function getAcceptLanguage(request) { + if (typeof request === "string") { + return request; + } + if (!request || typeof request !== "object") { + return ""; + } + + if (typeof request.get === "function") { + const value = request.get("Accept-Language"); + if (typeof value === "string") { + return value; + } + } + + const headers = request.headers; + if (!headers) { + return ""; + } + if (typeof headers.get === "function") { + return headers.get("accept-language") || ""; + } + + const key = Object.keys(headers).find( + (name) => name.toLowerCase() === "accept-language", + ); + const value = key ? headers[key] : undefined; + return Array.isArray(value) ? value.join(",") : value || ""; +} + +function toLocalizerOptions(options) { + return { + supportedLanguages: options.supportedLanguages, + fallbackLanguage: options.fallbackLanguage, + fallbackLanguages: options.fallbackLanguages, + missingTranslation: options.missingTranslation, + }; +} + +function createRequestLocalizer(options = {}) { + const engine = createLocalizer(toLocalizerOptions(options)); + const readLanguage = options.getLanguageHeader || getAcceptLanguage; + + function languageFor(request) { + const value = readLanguage(request); + return typeof value === "string" ? value : ""; + } + + return Object.freeze({ + localize(data, requestOrLanguage = "") { + const language = + typeof requestOrLanguage === "string" + ? requestOrLanguage + : languageFor(requestOrLanguage); + return engine.localize(data, language); + }, + forRequest(request) { + return (data, languageHeader) => + engine.localize( + data, + languageHeader === undefined ? languageFor(request) : languageHeader, + ); + }, + languageFor, + }); +} + +exports.getAcceptLanguage = getAcceptLanguage; +exports.createRequestLocalizer = createRequestLocalizer; diff --git a/bindings/node/adapters/smoke-test.js b/bindings/node/adapters/smoke-test.js new file mode 100644 index 0000000..fb3589d --- /dev/null +++ b/bindings/node/adapters/smoke-test.js @@ -0,0 +1,94 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + createRequestLocalizer, + getAcceptLanguage, +} = require("../../../dist/adapters/framework.js"); +const expressModule = require("../../../dist/adapters/express.js"); +const { expressLocalizer } = expressModule; +const { + NestDataLocalizer, +} = require("../../../dist/adapters/nest.js"); +const { + fastifyLocalizer, +} = require("../../../dist/adapters/fastify.js"); +const { koaLocalizer } = require("../../../dist/adapters/koa.js"); + +const data = { title: { en: "Hello", ar: "مرحبا" } }; + +async function main() { + assert.equal(typeof expressModule, "function"); + assert.equal(expressModule, expressLocalizer); + assert.equal( + getAcceptLanguage({ headers: { "Accept-Language": ["ar", "en;q=0.8"] } }), + "ar,en;q=0.8", + ); + + const generic = createRequestLocalizer(); + assert.deepEqual( + generic.localize(data, { headers: { "accept-language": "ar-EG" } }), + { title: "مرحبا" }, + ); + + let expressOutput; + const expressRequest = { headers: { "accept-language": "ar" } }; + const expressResponse = { + json(body) { + expressOutput = body; + return this; + }, + }; + expressLocalizer({ autoLocalizeResponse: true })( + expressRequest, + expressResponse, + () => expressResponse.json(data), + ); + assert.deepEqual(expressOutput, { title: "مرحبا" }); + assert.deepEqual(expressRequest.localizeData(data, "en"), { title: "Hello" }); + + const nest = new NestDataLocalizer(); + assert.deepEqual(nest.forRequest({ headers: { "accept-language": "ar" } })(data), { + title: "مرحبا", + }); + + let fastifyDecorator; + let fastifyHook; + fastifyLocalizer( + { + decorateRequest(_name, value) { + fastifyDecorator = value; + }, + addHook(_name, hook) { + fastifyHook = hook; + }, + }, + { autoLocalizeResponse: true }, + (error) => { + if (error) throw error; + }, + ); + const fastifyRequest = { headers: { "accept-language": "ar" } }; + fastifyRequest.localizeData = fastifyDecorator; + await new Promise((resolve, reject) => { + fastifyHook(fastifyRequest, {}, data, (error, result) => { + if (error) return reject(error); + assert.deepEqual(result, { title: "مرحبا" }); + resolve(); + }); + }); + + const koaContext = { + request: { headers: { "accept-language": "ar" } }, + body: data, + }; + await koaLocalizer({ autoLocalizeResponse: true })(koaContext, async () => {}); + assert.deepEqual(koaContext.body, { title: "مرحبا" }); + + console.log("framework adapter smoke tests passed"); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts new file mode 100644 index 0000000..22fba73 --- /dev/null +++ b/bindings/node/index.d.ts @@ -0,0 +1,38 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +export type MissingTranslationPolicy = "preserve" | "empty" | "null" | "error"; + +export interface LocalizerOptions { + supportedLanguages?: readonly string[]; + fallbackLanguage?: string; + fallbackLanguages?: readonly string[]; + missingTranslation?: MissingTranslationPolicy; +} + +export interface Localizer { + localize(data: JsonValue, languageHeader?: string): TOutput; +} + +export declare function localize( + data: JsonValue, + languageHeader?: string, + fallbackLanguage?: string, +): TOutput; + +export declare function createLocalizer(options?: LocalizerOptions): Localizer; + +export declare class DataLocalizerError extends Error {} + +export declare class ConfigurationError extends DataLocalizerError { + readonly field?: string; +} + +export declare class MissingTranslationError extends DataLocalizerError { + readonly path?: string; + readonly attemptedLanguages: readonly string[]; +} + +export declare class CycleError extends DataLocalizerError { + readonly path?: string; + readonly originalPath?: string; +} diff --git a/bindings/node/index.js b/bindings/node/index.js new file mode 100644 index 0000000..5efad7c --- /dev/null +++ b/bindings/node/index.js @@ -0,0 +1,123 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { webcrypto } = require("node:crypto"); + +if (!globalThis.crypto) { + globalThis.crypto = webcrypto; +} + +require("./wasm_exec.js"); + +let invokeGo; + +function initialize() { + if (invokeGo) { + return invokeGo; + } + + const go = new globalThis.Go(); + const wasmPath = path.join(__dirname, "data-localizer.wasm"); + const module = new WebAssembly.Module(fs.readFileSync(wasmPath)); + const instance = new WebAssembly.Instance(module, go.importObject); + void go.run(instance); + + if (typeof globalThis.__dataLocalizerInvoke !== "function") { + throw new Error("the data-localizer Go WebAssembly runtime did not initialize"); + } + invokeGo = globalThis.__dataLocalizerInvoke; + return invokeGo; +} + +function callGo(request) { + let serialized; + try { + serialized = JSON.stringify(request); + } catch (error) { + throw new TypeError(`data must be JSON-compatible: ${error.message}`); + } + if (serialized === undefined) { + throw new TypeError("data must be JSON-compatible"); + } + + const result = JSON.parse(initialize()(serialized)); + if (!result.ok) { + throw hydrateError(result.error); + } + return result.value; +} + +function localize(data, languageHeader = "", fallbackLanguage = "en") { + return callGo({ data, languageHeader, fallbackLanguage }); +} + +function createLocalizer(options = {}) { + const configuredOptions = { + supportedLanguages: options.supportedLanguages, + fallbackLanguage: options.fallbackLanguage, + fallbackLanguages: options.fallbackLanguages, + missingTranslation: options.missingTranslation, + }; + return Object.freeze({ + localize(data, languageHeader = "") { + return callGo({ data, languageHeader, options: configuredOptions }); + }, + }); +} + +class DataLocalizerError extends Error { + constructor(message) { + super(message); + this.name = new.target.name; + } +} + +class ConfigurationError extends DataLocalizerError { + constructor(message, field) { + super(message); + this.field = field; + } +} + +class MissingTranslationError extends DataLocalizerError { + constructor(message, path, attemptedLanguages) { + super(message); + this.path = path; + this.attemptedLanguages = attemptedLanguages; + } +} + +class CycleError extends DataLocalizerError { + constructor(message, path, originalPath) { + super(message); + this.path = path; + this.originalPath = originalPath; + } +} + +function hydrateError(details = {}) { + switch (details.name) { + case "ConfigurationError": + return new ConfigurationError(details.message, details.field); + case "MissingTranslationError": + return new MissingTranslationError( + details.message, + details.path, + details.attemptedLanguages || [], + ); + case "CycleError": + return new CycleError(details.message, details.path, details.originalPath); + case "TypeError": + return new TypeError(details.message); + default: + return new DataLocalizerError(details.message || "localization failed"); + } +} + +exports.localize = localize; +exports.createLocalizer = createLocalizer; +exports.DataLocalizerError = DataLocalizerError; +exports.ConfigurationError = ConfigurationError; +exports.MissingTranslationError = MissingTranslationError; +exports.CycleError = CycleError; diff --git a/bindings/node/smoke-test.js b/bindings/node/smoke-test.js new file mode 100644 index 0000000..720f3aa --- /dev/null +++ b/bindings/node/smoke-test.js @@ -0,0 +1,34 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + MissingTranslationError, + createLocalizer, + localize, +} = require("../../dist/index.js"); + +const input = { + title: { en: "Hello", ar: "مرحبا" }, + items: [{ name: { en: "First", ar: "الأول" } }], +}; + +assert.deepEqual(localize(input, "ar-EG,en;q=0.8"), { + title: "مرحبا", + items: [{ name: "الأول" }], +}); +assert.deepEqual(input.title, { en: "Hello", ar: "مرحبا" }); + +const strict = createLocalizer({ + supportedLanguages: ["en", "ar"], + fallbackLanguage: "en", + missingTranslation: "error", +}); +assert.throws( + () => strict.localize({ title: { ar: null } }, "ar"), + (error) => + error instanceof MissingTranslationError && + error.path === "$.title" && + error.attemptedLanguages.join(",") === "ar,en", +); + +console.log("npm WebAssembly smoke test passed"); diff --git a/cmd/data-localizer/main.go b/cmd/data-localizer/main.go new file mode 100644 index 0000000..d8834d1 --- /dev/null +++ b/cmd/data-localizer/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + localizer "github.com/elrefai99/data-localizer" +) + +func main() { + if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "data-localizer:", err) + os.Exit(1) + } +} + +func run(arguments []string, stdin io.Reader, stdout io.Writer) error { + flags := flag.NewFlagSet("data-localizer", flag.ContinueOnError) + flags.SetOutput(io.Discard) + language := flags.String("lang", "", "Accept-Language value") + fallback := flags.String("fallback", "en", "fallback language") + supported := flags.String("supported", "", "comma-separated supported language tags") + missing := flags.String("missing", "preserve", "preserve, empty, null, or error") + pretty := flags.Bool("pretty", false, "indent JSON output") + if err := flags.Parse(arguments); err != nil { + return err + } + if flags.NArg() > 1 { + return errors.New("usage: data-localizer [flags] [input.json]") + } + + input := stdin + if flags.NArg() == 1 && flags.Arg(0) != "-" { + file, err := os.Open(flags.Arg(0)) + if err != nil { + return err + } + defer file.Close() + input = file + } + + policy, err := parsePolicy(*missing) + if err != nil { + return err + } + options := localizer.Options{FallbackLanguage: *fallback, MissingTranslation: policy} + if strings.TrimSpace(*supported) != "" { + options.SupportedLanguages = splitList(*supported) + } + engine, err := localizer.New(options) + if err != nil { + return err + } + + decoder := json.NewDecoder(input) + decoder.UseNumber() + var data any + if err := decoder.Decode(&data); err != nil { + return fmt.Errorf("decode input JSON: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return errors.New("input contains more than one JSON value") + } + return fmt.Errorf("decode input JSON: %w", err) + } + + result, err := engine.Localize(data, *language) + if err != nil { + return err + } + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + if *pretty { + encoder.SetIndent("", " ") + } + return encoder.Encode(result) +} + +func parsePolicy(value string) (localizer.MissingTranslationPolicy, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "preserve": + return localizer.MissingPreserve, nil + case "empty": + return localizer.MissingEmpty, nil + case "null": + return localizer.MissingNull, nil + case "error": + return localizer.MissingError, nil + default: + return 0, fmt.Errorf("unknown missing policy %q", value) + } +} + +func splitList(value string) []string { + result := make([]string, 0) + for _, item := range strings.Split(value, ",") { + if item = strings.TrimSpace(item); item != "" { + result = append(result, item) + } + } + return result +} diff --git a/cmd/data-localizer/main_test.go b/cmd/data-localizer/main_test.go new file mode 100644 index 0000000..4b47c89 --- /dev/null +++ b/cmd/data-localizer/main_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRunLocalizesStandardInput(t *testing.T) { + input := strings.NewReader(`{"title":{"en":"Hello","ar":"مرحبا"},"count":2}`) + var output bytes.Buffer + if err := run([]string{"-lang", "ar"}, input, &output); err != nil { + t.Fatal(err) + } + want := `{"count":2,"title":"مرحبا"}` + "\n" + if output.String() != want { + t.Fatalf("output = %q, want %q", output.String(), want) + } +} + +func TestRunRejectsMultipleJSONValues(t *testing.T) { + err := run(nil, strings.NewReader(`{} {}`), &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "more than one JSON value") { + t.Fatalf("error = %v", err) + } +} diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 0000000..0751472 --- /dev/null +++ b/cmd/wasm/main.go @@ -0,0 +1,142 @@ +//go:build js && wasm + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "syscall/js" + + localizer "github.com/elrefai99/data-localizer" +) + +type request struct { + Data json.RawMessage `json:"data"` + LanguageHeader string `json:"languageHeader"` + FallbackLanguage string `json:"fallbackLanguage"` + Options *requestOptions `json:"options"` +} + +type requestOptions struct { + SupportedLanguages []string `json:"supportedLanguages"` + FallbackLanguage string `json:"fallbackLanguage"` + FallbackLanguages []string `json:"fallbackLanguages"` + MissingTranslation string `json:"missingTranslation"` +} + +type response struct { + OK bool `json:"ok"` + Value any `json:"value"` + Error *responseError `json:"error,omitempty"` +} + +type responseError struct { + Name string `json:"name"` + Message string `json:"message"` + Path string `json:"path,omitempty"` + OriginalPath string `json:"originalPath,omitempty"` + AttemptedLanguages []string `json:"attemptedLanguages,omitempty"` + Field string `json:"field,omitempty"` +} + +func main() { + js.Global().Set("__dataLocalizerInvoke", js.FuncOf(invoke)) + select {} +} + +func invoke(_ js.Value, arguments []js.Value) any { + if len(arguments) != 1 || arguments[0].Type() != js.TypeString { + return encodeResponse(response{OK: false, Error: &responseError{ + Name: "TypeError", Message: "the Go bridge expects one JSON string", + }}) + } + + var input request + if err := json.Unmarshal([]byte(arguments[0].String()), &input); err != nil { + return encodeError(err) + } + data, err := decodeJSON(input.Data) + if err != nil { + return encodeError(err) + } + + var result any + if input.Options == nil { + result, err = localizer.Localize(data, input.LanguageHeader, input.FallbackLanguage) + } else { + policy, policyErr := parsePolicy(input.Options.MissingTranslation) + if policyErr != nil { + return encodeError(policyErr) + } + engine, createErr := localizer.New(localizer.Options{ + SupportedLanguages: input.Options.SupportedLanguages, + FallbackLanguage: input.Options.FallbackLanguage, + FallbackLanguages: input.Options.FallbackLanguages, + MissingTranslation: policy, + }) + if createErr != nil { + return encodeError(createErr) + } + result, err = engine.Localize(data, input.LanguageHeader) + } + if err != nil { + return encodeError(err) + } + return encodeResponse(response{OK: true, Value: result}) +} + +func decodeJSON(raw json.RawMessage) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + return value, nil +} + +func parsePolicy(value string) (localizer.MissingTranslationPolicy, error) { + switch value { + case "", "preserve": + return localizer.MissingPreserve, nil + case "empty": + return localizer.MissingEmpty, nil + case "null": + return localizer.MissingNull, nil + case "error": + return localizer.MissingError, nil + default: + return 0, &localizer.ConfigurationError{Field: "MissingTranslation", Message: "unknown policy " + value} + } +} + +func encodeError(err error) string { + details := &responseError{Name: "Error", Message: err.Error()} + var configuration *localizer.ConfigurationError + var missing *localizer.MissingTranslationError + var cycle *localizer.CycleError + switch { + case errors.As(err, &configuration): + details.Name = "ConfigurationError" + details.Field = configuration.Field + case errors.As(err, &missing): + details.Name = "MissingTranslationError" + details.Path = missing.Path + details.AttemptedLanguages = missing.AttemptedLanguages + case errors.As(err, &cycle): + details.Name = "CycleError" + details.Path = cycle.Path + details.OriginalPath = cycle.OriginalPath + } + return encodeResponse(response{OK: false, Error: details}) +} + +func encodeResponse(value response) string { + encoded, err := json.Marshal(value) + if err != nil { + fallback, _ := json.Marshal(response{OK: false, Error: &responseError{Name: "Error", Message: err.Error()}}) + return string(fallback) + } + return string(encoded) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..4e122dd --- /dev/null +++ b/doc.go @@ -0,0 +1 @@ +package localizer diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7c89434 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/elrefai99/data-localizer + +go 1.22 diff --git a/language.go b/language.go new file mode 100644 index 0000000..27cb550 --- /dev/null +++ b/language.go @@ -0,0 +1,138 @@ +package localizer + +import ( + "sort" + "strconv" + "strings" +) + +type LanguagePreference struct { + Tag string + Quality float64 + Order int +} + +func NormalizeLanguageTag(tag string) string { + return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")) +} + +func validLanguageTag(tag string) bool { + if tag == "*" { + return true + } + parts := strings.Split(tag, "-") + if len(parts) == 0 || len(parts[0]) < 1 || len(parts[0]) > 8 { + return false + } + for index, part := range parts { + if len(part) < 1 || len(part) > 8 { + return false + } + for _, character := range part { + isLetter := character >= 'a' && character <= 'z' + isDigit := character >= '0' && character <= '9' + if !isLetter && (index == 0 || !isDigit) { + return false + } + } + } + return true +} + +func ParseAcceptLanguage(header string) []LanguagePreference { + if strings.TrimSpace(header) == "" { + return nil + } + + preferences := make([]LanguagePreference, 0) + for order, rawEntry := range strings.Split(header, ",") { + parts := strings.Split(rawEntry, ";") + tag := NormalizeLanguageTag(parts[0]) + if !validLanguageTag(tag) { + continue + } + + quality := 1.0 + valid := true + for _, parameter := range parts[1:] { + parameter = strings.TrimSpace(parameter) + name, rawValue, found := strings.Cut(parameter, "=") + if !found || !strings.EqualFold(strings.TrimSpace(name), "q") { + continue + } + value := strings.TrimSpace(rawValue) + if !validQuality(value) { + valid = false + break + } + quality, _ = strconv.ParseFloat(value, 64) + } + if valid && quality > 0 { + preferences = append(preferences, LanguagePreference{Tag: tag, Quality: quality, Order: order}) + } + } + + sort.SliceStable(preferences, func(i, j int) bool { + return preferences[i].Quality > preferences[j].Quality + }) + return preferences +} + +func validQuality(value string) bool { + if value == "0" || value == "1" { + return true + } + if strings.HasPrefix(value, "0.") { + digits := value[2:] + return len(digits) <= 3 && onlyDigits(digits) + } + if strings.HasPrefix(value, "1.") { + digits := value[2:] + return len(digits) <= 3 && onlyDigits(digits) && strings.Trim(digits, "0") == "" + } + return false +} + +func onlyDigits(value string) bool { + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} + +func negotiate(header string, supported map[string]struct{}, fallbacks []string) ([]string, string) { + preferences := ParseAcceptLanguage(header) + candidates := make([]string, 0, len(preferences)+len(fallbacks)) + requested := "" + if len(preferences) > 0 { + requested = preferences[0].Tag + } + + appendUnique := func(language string) { + if _, exists := supported[language]; !exists { + return + } + for _, candidate := range candidates { + if candidate == language { + return + } + } + candidates = append(candidates, language) + } + + for _, preference := range preferences { + if preference.Tag == "*" { + continue + } + appendUnique(preference.Tag) + if separator := strings.IndexByte(preference.Tag, '-'); separator > 0 { + appendUnique(preference.Tag[:separator]) + } + } + for _, fallback := range fallbacks { + appendUnique(fallback) + } + return candidates, requested +} diff --git a/language_test.go b/language_test.go new file mode 100644 index 0000000..00b14b4 --- /dev/null +++ b/language_test.go @@ -0,0 +1,42 @@ +package localizer + +import ( + "reflect" + "testing" +) + +func TestParseAcceptLanguageOrdersByQualityAndHeaderOrder(t *testing.T) { + preferences := ParseAcceptLanguage("fr-CA;q=0.7, ar;q=0.9, en;q=0.9, *;q=0.1") + want := []LanguagePreference{ + {Tag: "ar", Quality: 0.9, Order: 1}, + {Tag: "en", Quality: 0.9, Order: 2}, + {Tag: "fr-ca", Quality: 0.7, Order: 0}, + {Tag: "*", Quality: 0.1, Order: 3}, + } + if !reflect.DeepEqual(preferences, want) { + t.Fatalf("ParseAcceptLanguage() = %#v, want %#v", preferences, want) + } +} + +func TestParseAcceptLanguageIgnoresInvalidAndRejectedEntries(t *testing.T) { + preferences := ParseAcceptLanguage("en;q=0, bad tag, ar;q=2, fr;q=0.75") + want := []LanguagePreference{{Tag: "fr", Quality: 0.75, Order: 3}} + if !reflect.DeepEqual(preferences, want) { + t.Fatalf("ParseAcceptLanguage() = %#v, want %#v", preferences, want) + } +} + +func TestNormalizeLanguageTag(t *testing.T) { + if got := NormalizeLanguageTag(" AR_eg "); got != "ar-eg" { + t.Fatalf("NormalizeLanguageTag() = %q, want ar-eg", got) + } +} + +func TestNegotiateUsesExactBaseAndFallbackLanguages(t *testing.T) { + supported := map[string]struct{}{"fr-ca": {}, "fr": {}, "en": {}, "ar": {}} + got, requested := negotiate("fr-CA;q=0.9,ar;q=0.5", supported, []string{"en"}) + want := []string{"fr-ca", "fr", "ar", "en"} + if requested != "fr-ca" || !reflect.DeepEqual(got, want) { + t.Fatalf("negotiate() = %#v, %q; want %#v, fr-ca", got, requested, want) + } +} diff --git a/languages.go b/languages.go new file mode 100644 index 0000000..15042ac --- /dev/null +++ b/languages.go @@ -0,0 +1,192 @@ +package localizer + +var defaultLanguages = []string{ + "aa", + "ab", + "ae", + "af", + "ak", + "am", + "an", + "ar", + "as", + "av", + "ay", + "az", + "ba", + "be", + "bg", + "bh", + "bi", + "bm", + "bn", + "bo", + "br", + "bs", + "ca", + "ce", + "ch", + "co", + "cr", + "cs", + "cu", + "cv", + "cy", + "da", + "de", + "dv", + "dz", + "ee", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "ff", + "fi", + "fj", + "fo", + "fr", + "fy", + "ga", + "gd", + "gl", + "gn", + "gu", + "gv", + "ha", + "he", + "hi", + "ho", + "hr", + "ht", + "hu", + "hy", + "hz", + "ia", + "id", + "ie", + "ig", + "ii", + "ik", + "io", + "is", + "it", + "iu", + "ja", + "jv", + "ka", + "kg", + "ki", + "kj", + "kk", + "kl", + "km", + "kn", + "ko", + "kr", + "ks", + "ku", + "kv", + "kw", + "ky", + "la", + "lb", + "lg", + "li", + "ln", + "lo", + "lt", + "lu", + "lv", + "mg", + "mh", + "mi", + "mk", + "ml", + "mn", + "mr", + "ms", + "mt", + "my", + "na", + "nb", + "nd", + "ne", + "ng", + "nl", + "nn", + "no", + "nr", + "nv", + "ny", + "oc", + "oj", + "om", + "or", + "os", + "pa", + "pi", + "pl", + "ps", + "pt", + "qu", + "rm", + "rn", + "ro", + "ru", + "rw", + "sa", + "sc", + "sd", + "se", + "sg", + "si", + "sk", + "sl", + "sm", + "sn", + "so", + "sq", + "sr", + "ss", + "st", + "su", + "sv", + "sw", + "ta", + "te", + "tg", + "th", + "ti", + "tk", + "tl", + "tn", + "to", + "tr", + "ts", + "tt", + "tw", + "ty", + "ug", + "uk", + "ur", + "uz", + "ve", + "vi", + "vo", + "wa", + "wo", + "xh", + "yi", + "yo", + "za", + "zh", + "zu", +} + +func DefaultLanguages() []string { + return append([]string(nil), defaultLanguages...) +} diff --git a/localizer.go b/localizer.go new file mode 100644 index 0000000..50e2294 --- /dev/null +++ b/localizer.go @@ -0,0 +1,293 @@ +package localizer + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strconv" + "strings" +) + +type Localizer struct { + supported map[string]struct{} + fallbacks []string + policy MissingTranslationPolicy + detector Detector + onMissing MissingHandler +} + +func New(options Options) (*Localizer, error) { + languages := options.SupportedLanguages + if len(languages) == 0 { + languages = defaultLanguages + } + + supported := make(map[string]struct{}, len(languages)) + for _, language := range languages { + normalized := NormalizeLanguageTag(language) + if !validLanguageTag(normalized) || normalized == "*" { + return nil, &ConfigurationError{Field: "SupportedLanguages", Message: fmt.Sprintf("invalid language tag %q", language)} + } + supported[normalized] = struct{}{} + } + if len(supported) == 0 { + return nil, &ConfigurationError{Field: "SupportedLanguages", Message: "must contain at least one language"} + } + + primaryFallback := NormalizeLanguageTag(options.FallbackLanguage) + if primaryFallback == "" { + if _, hasEnglish := supported["en"]; hasEnglish { + primaryFallback = "en" + } else { + normalized := make([]string, 0, len(supported)) + for language := range supported { + normalized = append(normalized, language) + } + sort.Strings(normalized) + primaryFallback = normalized[0] + } + } + + fallbacks := make([]string, 0, 1+len(options.FallbackLanguages)) + for _, fallback := range append([]string{primaryFallback}, options.FallbackLanguages...) { + normalized := NormalizeLanguageTag(fallback) + if _, exists := supported[normalized]; !exists { + return nil, &ConfigurationError{Field: "FallbackLanguages", Message: fmt.Sprintf("unsupported language %q", fallback)} + } + if !contains(fallbacks, normalized) { + fallbacks = append(fallbacks, normalized) + } + } + + if options.MissingTranslation > MissingError { + return nil, &ConfigurationError{Field: "MissingTranslation", Message: "unknown policy"} + } + + return &Localizer{ + supported: supported, + fallbacks: fallbacks, + policy: options.MissingTranslation, + detector: options.IsTranslationMap, + onMissing: options.OnMissing, + }, nil +} + +func MustNew(options Options) *Localizer { + localizer, err := New(options) + if err != nil { + panic(err) + } + return localizer +} + +func Localize(data any, languageHeader string, fallbackLanguage ...string) (any, error) { + fallback := "en" + if len(fallbackLanguage) > 0 && strings.TrimSpace(fallbackLanguage[0]) != "" { + fallback = fallbackLanguage[0] + } + engine, err := New(Options{FallbackLanguage: fallback, MissingTranslation: MissingEmpty}) + if err != nil { + return nil, err + } + return engine.Localize(data, languageHeader) +} + +func (l *Localizer) Localize(data any, languageHeader string) (any, error) { + candidates, requested := negotiate(languageHeader, l.supported, l.fallbacks) + state := walkState{active: make(map[visit]string)} + return l.walk(reflect.ValueOf(data), "$", requested, candidates, &state) +} + +type visit struct { + kind reflect.Kind + ptr uintptr +} + +type walkState struct { + active map[visit]string +} + +func (l *Localizer) walk(value reflect.Value, path, requested string, candidates []string, state *walkState) (any, error) { + if !value.IsValid() { + return nil, nil + } + if value.Kind() == reflect.Interface { + if value.IsNil() { + return nil, nil + } + return l.walk(value.Elem(), path, requested, candidates, state) + } + + switch value.Kind() { + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return value.Interface(), nil + } + if value.IsNil() { + return nil, nil + } + key := visit{kind: reflect.Map, ptr: value.Pointer()} + if original, exists := state.active[key]; exists && key.ptr != 0 { + return nil, &CycleError{Path: path, OriginalPath: original} + } + if key.ptr != 0 { + state.active[key] = path + defer delete(state.active, key) + } + + object := make(TranslationMap, value.Len()) + iterator := value.MapRange() + for iterator.Next() { + object[iterator.Key().String()] = iterator.Value().Interface() + } + context := Context{Path: path, RequestedLanguage: requested, AttemptedLanguages: cloneStrings(candidates)} + if l.isTranslationMap(object, context) { + return l.resolve(object, context) + } + + result := make(map[string]any, len(object)) + for key, child := range object { + localized, err := l.walk(reflect.ValueOf(child), childPath(path, key), requested, candidates, state) + if err != nil { + return nil, err + } + result[key] = localized + } + return result, nil + + case reflect.Slice, reflect.Array: + if value.Kind() == reflect.Slice && value.IsNil() { + return nil, nil + } + var key visit + if value.Kind() == reflect.Slice { + key = visit{kind: reflect.Slice, ptr: value.Pointer()} + if original, exists := state.active[key]; exists && key.ptr != 0 { + return nil, &CycleError{Path: path, OriginalPath: original} + } + if key.ptr != 0 { + state.active[key] = path + defer delete(state.active, key) + } + } + result := make([]any, value.Len()) + for index := 0; index < value.Len(); index++ { + localized, err := l.walk(value.Index(index), fmt.Sprintf("%s[%d]", path, index), requested, candidates, state) + if err != nil { + return nil, err + } + result[index] = localized + } + return result, nil + + case reflect.Pointer, reflect.Func, reflect.Chan, reflect.Struct: + return value.Interface(), nil + default: + return value.Interface(), nil + } +} + +func (l *Localizer) isTranslationMap(value TranslationMap, context Context) bool { + if l.detector != nil { + return l.detector(cloneMap(value), context) + } + if len(value) == 0 { + return false + } + seen := make(map[string]struct{}, len(value)) + for language, translation := range value { + normalized := NormalizeLanguageTag(language) + if _, exists := l.supported[normalized]; !exists || !isTranslationLeaf(translation) { + return false + } + if _, duplicate := seen[normalized]; duplicate { + return false + } + seen[normalized] = struct{}{} + } + return true +} + +func isTranslationLeaf(value any) bool { + if value == nil { + return true + } + switch value.(type) { + case string, bool, json.Number, + int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return true + default: + return false + } +} + +func (l *Localizer) resolve(value TranslationMap, context Context) (any, error) { + entries := make(map[string]any, len(value)) + for language, translation := range value { + entries[NormalizeLanguageTag(language)] = translation + } + for _, language := range context.AttemptedLanguages { + if translation, exists := entries[language]; exists && translation != nil { + return translation, nil + } + } + if l.onMissing != nil { + return l.onMissing(cloneMap(value), context) + } + switch l.policy { + case MissingPreserve: + return cloneMap(value), nil + case MissingEmpty: + return "", nil + case MissingNull: + return nil, nil + case MissingError: + return nil, &MissingTranslationError{Path: context.Path, AttemptedLanguages: cloneStrings(context.AttemptedLanguages)} + default: + panic("unreachable missing-translation policy") + } +} + +func cloneMap(value TranslationMap) TranslationMap { + result := make(TranslationMap, len(value)) + for key, child := range value { + result[key] = child + } + return result +} + +func cloneStrings(values []string) []string { + return append([]string(nil), values...) +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func childPath(parent, key string) string { + if validIdentifier(key) { + return parent + "." + key + } + return parent + "[" + strconv.Quote(key) + "]" +} + +func validIdentifier(value string) bool { + if value == "" { + return false + } + for index, character := range value { + letter := character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' + if !letter && character != '_' && character != '$' && (index == 0 || character < '0' || character > '9') { + return false + } + } + return true +} diff --git a/localizer_test.go b/localizer_test.go new file mode 100644 index 0000000..efdb950 --- /dev/null +++ b/localizer_test.go @@ -0,0 +1,194 @@ +package localizer + +import ( + "errors" + "reflect" + "testing" + "time" +) + +func TestLocalizeNestedDataWithoutMutation(t *testing.T) { + input := map[string]any{ + "title": map[string]any{"ar": "عنوان", "en": "Title"}, + "posts": []any{ + map[string]any{"name": map[string]any{"ar": "الأول", "en": "First"}, "published": true}, + map[string]any{"name": map[string]any{"ar": "الثاني", "en": "Second"}, "published": false}, + }, + "count": 2, + } + want := map[string]any{ + "title": "عنوان", + "posts": []any{ + map[string]any{"name": "الأول", "published": true}, + map[string]any{"name": "الثاني", "published": false}, + }, + "count": 2, + } + + got, err := Localize(input, " ar-EG, en;q=0.8 ") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Localize() = %#v, want %#v", got, want) + } + if input["title"].(map[string]any)["ar"] != "عنوان" { + t.Fatal("Localize mutated its input") + } + gotMap := got.(map[string]any) + if reflect.ValueOf(gotMap).Pointer() == reflect.ValueOf(input).Pointer() { + t.Fatal("Localize returned the original map") + } +} + +func TestLocalizeUsesQualityValues(t *testing.T) { + input := map[string]any{"greeting": map[string]any{"en": "Hello", "fr": "Bonjour"}} + got, err := Localize(input, "en;q=0.5,fr;q=0.9") + if err != nil { + t.Fatal(err) + } + want := map[string]any{"greeting": "Bonjour"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Localize() = %#v, want %#v", got, want) + } +} + +func TestTranslationsPreserveFalsyValues(t *testing.T) { + engine := MustNew(Options{SupportedLanguages: []string{"en", "ar"}, FallbackLanguage: "en"}) + tests := []struct { + name string + in any + want any + }{ + {name: "empty string", in: map[string]any{"ar": "", "en": "fallback"}, want: ""}, + {name: "false", in: map[string]any{"ar": false, "en": true}, want: false}, + {name: "zero", in: map[string]any{"ar": 0, "en": 1}, want: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := engine.Localize(test.in, "ar") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("Localize() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestDefaultDetectorDoesNotCollapseDomainObjects(t *testing.T) { + input := map[string]any{ + "routes": map[string]any{ + "en": map[string]any{"path": "/english", "enabled": true}, + "ar": map[string]any{"path": "/arabic", "enabled": false}, + }, + } + got, err := Localize(input, "en") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, input) { + t.Fatalf("Localize() = %#v, want ordinary object preserved", got) + } +} + +func TestMissingTranslationPolicies(t *testing.T) { + input := map[string]any{"title": map[string]any{"ar": nil}} + tests := []struct { + policy MissingTranslationPolicy + want any + }{ + {policy: MissingPreserve, want: map[string]any{"title": TranslationMap{"ar": nil}}}, + {policy: MissingEmpty, want: map[string]any{"title": ""}}, + {policy: MissingNull, want: map[string]any{"title": nil}}, + } + for _, test := range tests { + engine := MustNew(Options{ + SupportedLanguages: []string{"en", "ar"}, + FallbackLanguage: "en", + MissingTranslation: test.policy, + }) + got, err := engine.Localize(input, "ar") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("policy %v returned %#v, want %#v", test.policy, got, test.want) + } + } +} + +func TestMissingErrorContainsPathAndLanguages(t *testing.T) { + engine := MustNew(Options{ + SupportedLanguages: []string{"en", "ar"}, + FallbackLanguage: "en", + MissingTranslation: MissingError, + }) + _, err := engine.Localize(map[string]any{"items": []any{map[string]any{"name": map[string]any{"ar": nil}}}}, "ar") + var missing *MissingTranslationError + if !errors.As(err, &missing) { + t.Fatalf("error = %v, want MissingTranslationError", err) + } + if missing.Path != "$.items[0].name" || !reflect.DeepEqual(missing.AttemptedLanguages, []string{"ar", "en"}) { + t.Fatalf("MissingTranslationError = %#v", missing) + } +} + +func TestCustomDetectorAndMissingHandler(t *testing.T) { + engine := MustNew(Options{ + SupportedLanguages: []string{"en"}, + FallbackLanguage: "en", + IsTranslationMap: func(value TranslationMap, context Context) bool { + return context.Path == "$.label" + }, + OnMissing: func(value TranslationMap, context Context) (any, error) { + return "missing at " + context.Path, nil + }, + }) + got, err := engine.Localize(map[string]any{"label": map[string]any{"other": "value"}}, "en") + if err != nil { + t.Fatal(err) + } + want := map[string]any{"label": "missing at $.label"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Localize() = %#v, want %#v", got, want) + } +} + +func TestTypedMapsSlicesAndSpecialValues(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + input := map[string]any{ + "labels": []map[string]string{{"en": "One", "ar": "واحد"}}, + "time": now, + } + got, err := Localize(input, "en") + if err != nil { + t.Fatal(err) + } + want := map[string]any{"labels": []any{"One"}, "time": now} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Localize() = %#v, want %#v", got, want) + } +} + +func TestCycleReturnsTypedError(t *testing.T) { + input := map[string]any{} + input["self"] = input + _, err := Localize(input, "en") + var cycle *CycleError + if !errors.As(err, &cycle) { + t.Fatalf("error = %v, want CycleError", err) + } + if cycle.Path != "$.self" || cycle.OriginalPath != "$" { + t.Fatalf("CycleError = %#v", cycle) + } +} + +func TestNewRejectsUnsupportedFallback(t *testing.T) { + _, err := New(Options{SupportedLanguages: []string{"ar"}, FallbackLanguage: "en"}) + var configuration *ConfigurationError + if !errors.As(err, &configuration) { + t.Fatalf("error = %v, want ConfigurationError", err) + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..a459602 --- /dev/null +++ b/options.go @@ -0,0 +1,75 @@ +package localizer + +import "fmt" + +type MissingTranslationPolicy uint8 + +const ( + MissingPreserve MissingTranslationPolicy = iota + MissingEmpty + MissingNull + MissingError +) + +type TranslationMap map[string]any + +type Context struct { + Path string + RequestedLanguage string + AttemptedLanguages []string +} + +type Detector func(value TranslationMap, context Context) bool + +type MissingHandler func(value TranslationMap, context Context) (any, error) + +type Options struct { + SupportedLanguages []string + FallbackLanguage string + FallbackLanguages []string + MissingTranslation MissingTranslationPolicy + IsTranslationMap Detector + OnMissing MissingHandler +} + +type ConfigurationError struct { + Field string + Message string +} + +func (e *ConfigurationError) Error() string { + return fmt.Sprintf("invalid localizer configuration %q: %s", e.Field, e.Message) +} + +type MissingTranslationError struct { + Path string + AttemptedLanguages []string +} + +func (e *MissingTranslationError) Error() string { + attempted := "none" + if len(e.AttemptedLanguages) > 0 { + attempted = join(e.AttemptedLanguages, ", ") + } + return fmt.Sprintf("missing translation at %s; attempted: %s", e.Path, attempted) +} + +type CycleError struct { + Path string + OriginalPath string +} + +func (e *CycleError) Error() string { + return fmt.Sprintf("cycle at %s refers to %s", e.Path, e.OriginalPath) +} + +func join(values []string, separator string) string { + if len(values) == 0 { + return "" + } + result := values[0] + for _, value := range values[1:] { + result += separator + value + } + return result +} diff --git a/package.json b/package.json index ac915d9..e741cc9 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,104 @@ { "name": "data-localizer", - "version": "0.1.4", - "description": "A simple utility to localize JSON data fields between ar/en based on headers.", + "version": "0.2.4", + "description": "Dependency-free JSON data localization powered by Go WebAssembly.", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./framework": { + "types": "./dist/adapters/framework.d.ts", + "require": "./dist/adapters/framework.js", + "default": "./dist/adapters/framework.js" + }, + "./express": { + "types": "./dist/adapters/express.d.ts", + "require": "./dist/adapters/express.js", + "default": "./dist/adapters/express.js" + }, + "./nest": { + "types": "./dist/adapters/nest.d.ts", + "require": "./dist/adapters/nest.js", + "default": "./dist/adapters/nest.js" + }, + "./fastify": { + "types": "./dist/adapters/fastify.d.ts", + "require": "./dist/adapters/fastify.js", + "default": "./dist/adapters/fastify.js" + }, + "./koa": { + "types": "./dist/adapters/koa.d.ts", + "require": "./dist/adapters/koa.js", + "default": "./dist/adapters/koa.js" + } + }, "files": [ "dist" ], "scripts": { - "build": "tsc", - "test": "vitest run", - "test:watch": "vitest", - "prepublishOnly": "npm run build" + "build": "go run ./tools/buildnpm", + "test": "go test ./... && npm run build && node bindings/node/smoke-test.js && node bindings/node/adapters/smoke-test.js && node bindings/node/adapters/module-smoke-test.mjs", + "prepack": "npm test" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@nestjs/common": ">=9", + "express": ">=4", + "fastify": ">=4", + "koa": ">=2", + "rxjs": ">=7" + }, + "peerDependenciesMeta": { + "@nestjs/common": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + }, + "koa": { + "optional": true + }, + "rxjs": { + "optional": true + } + }, + "typesVersions": { + "*": { + "framework": [ + "dist/adapters/framework.d.ts" + ], + "express": [ + "dist/adapters/express.d.ts" + ], + "nest": [ + "dist/adapters/nest.d.ts" + ], + "fastify": [ + "dist/adapters/fastify.d.ts" + ], + "koa": [ + "dist/adapters/koa.d.ts" + ], + "*": [ + "dist/index.d.ts" + ] + } }, "keywords": [ "localization", "i18n", "json", - "data", - "ar", - "en" + "golang", + "webassembly" ], "author": "elrefai99 ", "homepage": "https://github.com/elrefai99/data-localizer#readme", @@ -28,14 +107,5 @@ "url": "git+https://github.com/elrefai99/data-localizer.git" }, "bugs": "https://github.com/elrefai99/data-localizer/issues", - "license": "MIT", - "packageManager": "pnpm@10.6.5", - "dependencies": { - "@types/node": "^24.3.0", - "ts-node": "^10.9.2", - "typescript": "^5.9.2" - }, - "devDependencies": { - "vitest": "^4.1.10" - } + "license": "MIT" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 02de052..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,832 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@types/node': - specifier: ^24.3.0 - version: 24.3.0 - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@24.3.0)(typescript@5.9.2) - typescript: - specifier: ^5.9.2 - version: 5.9.2 - devDependencies: - vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@24.3.0)(vite@8.2.1(@types/node@24.3.0)) - -packages: - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - - '@oxc-project/types@0.144.0': - resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} - - '@rolldown/binding-android-arm64@1.2.4': - resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.2.4': - resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.2.4': - resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.2.4': - resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.2.4': - resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.2.4': - resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-arm64-musl@1.2.4': - resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-ppc64-gnu@1.2.4': - resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@rolldown/binding-linux-s390x-gnu@1.2.4': - resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@rolldown/binding-linux-x64-gnu@1.2.4': - resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-linux-x64-musl@1.2.4': - resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-openharmony-arm64@1.2.4': - resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-win32-arm64-msvc@1.2.4': - resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.2.4': - resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/node@24.3.0': - resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} - - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - - es-module-lexer@2.3.2: - resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} - engines: {node: '>=12.0.0'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - lightningcss-android-arm64@1.33.0: - resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.33.0: - resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.33.0: - resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.33.0: - resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.33.0: - resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.33.0: - resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.33.0: - resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.33.0: - resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.33.0: - resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.33.0: - resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.33.0: - resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.33.0: - resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} - engines: {node: '>= 12.0.0'} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - obug@2.1.4: - resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} - engines: {node: '>=12.20.0'} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} - engines: {node: ^10 || ^12 || >=14} - - rolldown@1.2.4: - resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} - engines: {node: '>=18'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tinyrainbow@3.1.1: - resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} - engines: {node: '>=14.0.0'} - - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} - - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - - vite@8.2.1: - resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - -snapshots: - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@oxc-project/types@0.144.0': {} - - '@rolldown/binding-android-arm64@1.2.4': - optional: true - - '@rolldown/binding-darwin-arm64@1.2.4': - optional: true - - '@rolldown/binding-darwin-x64@1.2.4': - optional: true - - '@rolldown/binding-freebsd-x64@1.2.4': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.2.4': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.2.4': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.2.4': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.2.4': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.2.4': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.2.4': - optional: true - - '@rolldown/binding-linux-x64-musl@1.2.4': - optional: true - - '@rolldown/binding-openharmony-arm64@1.2.4': - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.2.4': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.2.4': - optional: true - - '@rolldown/pluginutils@1.0.1': {} - - '@standard-schema/spec@1.1.0': {} - - '@tsconfig/node10@1.0.11': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.9': {} - - '@types/node@24.3.0': - dependencies: - undici-types: 7.10.0 - - '@vitest/expect@4.1.10': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - chai: 6.2.2 - tinyrainbow: 3.1.1 - - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.3.0))': - dependencies: - '@vitest/spy': 4.1.10 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.2.1(@types/node@24.3.0) - - '@vitest/pretty-format@4.1.10': - dependencies: - tinyrainbow: 3.1.1 - - '@vitest/runner@4.1.10': - dependencies: - '@vitest/utils': 4.1.10 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.10': - dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.10': {} - - '@vitest/utils@4.1.10': - dependencies: - '@vitest/pretty-format': 4.1.10 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.1 - - acorn-walk@8.3.4: - dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} - - arg@4.1.3: {} - - assertion-error@2.0.1: {} - - chai@6.2.2: {} - - convert-source-map@2.0.0: {} - - create-require@1.1.1: {} - - detect-libc@2.1.2: {} - - diff@4.0.2: {} - - es-module-lexer@2.3.2: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - expect-type@1.4.0: {} - - fdir@6.5.0(picomatch@4.0.5): - optionalDependencies: - picomatch: 4.0.5 - - fsevents@2.3.3: - optional: true - - lightningcss-android-arm64@1.33.0: - optional: true - - lightningcss-darwin-arm64@1.33.0: - optional: true - - lightningcss-darwin-x64@1.33.0: - optional: true - - lightningcss-freebsd-x64@1.33.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.33.0: - optional: true - - lightningcss-linux-arm64-gnu@1.33.0: - optional: true - - lightningcss-linux-arm64-musl@1.33.0: - optional: true - - lightningcss-linux-x64-gnu@1.33.0: - optional: true - - lightningcss-linux-x64-musl@1.33.0: - optional: true - - lightningcss-win32-arm64-msvc@1.33.0: - optional: true - - lightningcss-win32-x64-msvc@1.33.0: - optional: true - - lightningcss@1.33.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.33.0 - lightningcss-darwin-arm64: 1.33.0 - lightningcss-darwin-x64: 1.33.0 - lightningcss-freebsd-x64: 1.33.0 - lightningcss-linux-arm-gnueabihf: 1.33.0 - lightningcss-linux-arm64-gnu: 1.33.0 - lightningcss-linux-arm64-musl: 1.33.0 - lightningcss-linux-x64-gnu: 1.33.0 - lightningcss-linux-x64-musl: 1.33.0 - lightningcss-win32-arm64-msvc: 1.33.0 - lightningcss-win32-x64-msvc: 1.33.0 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - make-error@1.3.6: {} - - nanoid@3.3.18: {} - - obug@2.1.4: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.5: {} - - postcss@8.5.26: - dependencies: - nanoid: 3.3.18 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - rolldown@1.2.4: - dependencies: - '@oxc-project/types': 0.144.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.2.4 - '@rolldown/binding-darwin-arm64': 1.2.4 - '@rolldown/binding-darwin-x64': 1.2.4 - '@rolldown/binding-freebsd-x64': 1.2.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 - '@rolldown/binding-linux-arm64-gnu': 1.2.4 - '@rolldown/binding-linux-arm64-musl': 1.2.4 - '@rolldown/binding-linux-ppc64-gnu': 1.2.4 - '@rolldown/binding-linux-s390x-gnu': 1.2.4 - '@rolldown/binding-linux-x64-gnu': 1.2.4 - '@rolldown/binding-linux-x64-musl': 1.2.4 - '@rolldown/binding-openharmony-arm64': 1.2.4 - '@rolldown/binding-win32-arm64-msvc': 1.2.4 - '@rolldown/binding-win32-x64-msvc': 1.2.4 - - siginfo@2.0.0: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - std-env@4.2.0: {} - - tinybench@2.9.0: {} - - tinyexec@1.3.0: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - - tinyrainbow@3.1.1: {} - - ts-node@10.9.2(@types/node@24.3.0)(typescript@5.9.2): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 24.3.0 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.2 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - - typescript@5.9.2: {} - - undici-types@7.10.0: {} - - v8-compile-cache-lib@3.0.1: {} - - vite@8.2.1(@types/node@24.3.0): - dependencies: - lightningcss: 1.33.0 - picomatch: 4.0.5 - postcss: 8.5.26 - rolldown: 1.2.4 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.3.0 - fsevents: 2.3.3 - - vitest@4.1.10(@types/node@24.3.0)(vite@8.2.1(@types/node@24.3.0)): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.3.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.2 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 - vite: 8.2.1(@types/node@24.3.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.3.0 - transitivePeerDependencies: - - msw - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - yn@3.1.1: {} diff --git a/src/@types/index.d.ts b/src/@types/index.d.ts deleted file mode 100644 index a40d944..0000000 --- a/src/@types/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export type AnyObject = { [key: string]: any }; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 6a53223..0000000 --- a/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./script/localizeObjectFields"; -export * from "./script/localizeDatas"; diff --git a/src/lang/lang.json b/src/lang/lang.json deleted file mode 100644 index dc007d7..0000000 --- a/src/lang/lang.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "aa": "Afar", - "ab": "Abkhazian", - "ae": "Avestan", - "af": "Afrikaans", - "ak": "Akan", - "am": "Amharic", - "an": "Aragonese", - "ar": "Arabic", - "as": "Assamese", - "av": "Avaric", - "ay": "Aymara", - "az": "Azerbaijani", - "ba": "Bashkir", - "be": "Belarusian", - "bg": "Bulgarian", - "bh": "Bihari", - "bi": "Bislama", - "bm": "Bambara", - "bn": "Bengali", - "bo": "Tibetan", - "br": "Breton", - "bs": "Bosnian", - "ca": "Catalan", - "ce": "Chechen", - "ch": "Chamorro", - "co": "Corsican", - "cr": "Cree", - "cs": "Czech", - "cu": "Church Slavic", - "cv": "Chuvash", - "cy": "Welsh", - "da": "Danish", - "de": "German", - "dv": "Divehi", - "dz": "Dzongkha", - "ee": "Ewe", - "el": "Greek", - "en": "English", - "eo": "Esperanto", - "es": "Spanish", - "et": "Estonian", - "eu": "Basque", - "fa": "Persian", - "ff": "Fulah", - "fi": "Finnish", - "fj": "Fijian", - "fo": "Faroese", - "fr": "French", - "fy": "Western Frisian", - "ga": "Irish", - "gd": "Scottish Gaelic", - "gl": "Galician", - "gn": "Guarani", - "gu": "Gujarati", - "gv": "Manx", - "ha": "Hausa", - "he": "Hebrew", - "hi": "Hindi", - "ho": "Hiri Motu", - "hr": "Croatian", - "ht": "Haitian", - "hu": "Hungarian", - "hy": "Armenian", - "hz": "Herero", - "ia": "Interlingua", - "id": "Indonesian", - "ie": "Interlingue", - "ig": "Igbo", - "ii": "Sichuan Yi", - "ik": "Inupiaq", - "io": "Ido", - "is": "Icelandic", - "it": "Italian", - "iu": "Inuktitut", - "ja": "Japanese", - "jv": "Javanese", - "ka": "Georgian", - "kg": "Kongo", - "ki": "Kikuyu", - "kj": "Kuanyama", - "kk": "Kazakh", - "kl": "Kalaallisut", - "km": "Khmer", - "kn": "Kannada", - "ko": "Korean", - "kr": "Kanuri", - "ks": "Kashmiri", - "ku": "Kurdish", - "kv": "Komi", - "kw": "Cornish", - "ky": "Kirghiz", - "la": "Latin", - "lb": "Luxembourgish", - "lg": "Ganda", - "li": "Limburgan", - "ln": "Lingala", - "lo": "Lao", - "lt": "Lithuanian", - "lu": "Luba-Katanga", - "lv": "Latvian", - "mg": "Malagasy", - "mh": "Marshallese", - "mi": "Maori", - "mk": "Macedonian", - "ml": "Malayalam", - "mn": "Mongolian", - "mr": "Marathi", - "ms": "Malay", - "mt": "Maltese", - "my": "Burmese", - "na": "Nauru", - "nb": "Norwegian Bokmål", - "nd": "North Ndebele", - "ne": "Nepali", - "ng": "Ndonga", - "nl": "Dutch", - "nn": "Norwegian Nynorsk", - "no": "Norwegian", - "nr": "South Ndebele", - "nv": "Navajo", - "ny": "Chichewa", - "oc": "Occitan", - "oj": "Ojibwa", - "om": "Oromo", - "or": "Oriya", - "os": "Ossetian", - "pa": "Punjabi" -} diff --git a/src/lang/lang.ts b/src/lang/lang.ts deleted file mode 100644 index 7af2afc..0000000 --- a/src/lang/lang.ts +++ /dev/null @@ -1,3 +0,0 @@ -import data from "./lang.json"; - -export const langJSON = Object.keys(data); diff --git a/src/script/localizeDatas.ts b/src/script/localizeDatas.ts deleted file mode 100644 index 6549e43..0000000 --- a/src/script/localizeDatas.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { AnyObject } from "../@types"; -import { langJSON } from "../lang/lang"; - -export function localizeDatas(data: T, langHeader: string, fallbackLang: string = 'en'): T { - const langCode = langHeader?.split(',')[0]?.toLowerCase().trim().split('-')[0] || ''; - const preferredLang = langJSON.includes(langCode) ? langCode : fallbackLang; - - const localizeValue = (value: AnyObject): AnyObject => { - if (Array.isArray(value)) { - return value.map(localizeValue); - } - - if (value && typeof value === 'object') { - if ('ar' in value || 'en' in value) { - return value[preferredLang] ?? value[fallbackLang] ?? ''; - } - - const result: AnyObject = {}; - for (const key in value) { - result[key] = localizeValue(value[key]); - } - return result; - } - - return value; - }; - - return localizeValue(data) as T; -} diff --git a/src/script/localizeObjectFields.ts b/src/script/localizeObjectFields.ts deleted file mode 100644 index 374c3a0..0000000 --- a/src/script/localizeObjectFields.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { AnyObject } from "../@types"; -import { langJSON } from "../lang/lang"; - -export function localizeObjectFields(data: T, langHeader: string, fallbackLang: string = 'en'): T { - const langCode = langHeader?.split(',')[0]?.toLowerCase().trim().split('-')[0]; - const preferredLang = langJSON.includes(langCode) ? langCode : fallbackLang; - - const localize = (obj: AnyObject): AnyObject => { - if (Array.isArray(obj)) { - return obj.map(localize); - } - - if (obj && typeof obj === 'object') { - if ('ar' in obj || 'en' in obj) { - return obj[preferredLang] || obj[fallbackLang] || ''; - } - - const result: AnyObject = {}; - for (const key in obj) { - result[key] = localize(obj[key]); - } - return result; - } - - return obj; - }; - - return localize(data) as T; -} diff --git a/tests/localizers.test.ts b/tests/localizers.test.ts deleted file mode 100644 index ce140a3..0000000 --- a/tests/localizers.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { localizeDatas, localizeObjectFields } from "../src"; - -const localizers = [ - ["localizeDatas", localizeDatas], - ["localizeObjectFields", localizeObjectFields], -] as const; - -describe.each(localizers)("%s", (_name, localize) => { - it("localizes nested objects and arrays recursively", () => { - const input = { - title: { ar: "عنوان", en: "Title" }, - posts: [ - { name: { ar: "الأول", en: "First" }, published: true }, - { name: { ar: "الثاني", en: "Second" }, published: false }, - ], - count: 2, - metadata: null, - }; - - expect(localize(input, "ar")).toEqual({ - title: "عنوان", - posts: [ - { name: "الأول", published: true }, - { name: "الثاني", published: false }, - ], - count: 2, - metadata: null, - }); - }); - - it("normalizes casing, whitespace, and region subtags", () => { - const input = { greeting: { ar: "مرحبا", en: "Hello" } }; - - expect(localize(input, " AR-EG ")).toEqual({ greeting: "مرحبا" }); - }); - - it("uses the first language in an Accept-Language-style header", () => { - const input = { greeting: { ar: "مرحبا", en: "Hello" } }; - - expect(localize(input, "en-US,ar;q=0.9")).toEqual({ greeting: "Hello" }); - }); - - it("uses English when the requested language is unsupported", () => { - const input = { greeting: { ar: "مرحبا", en: "Hello" } }; - - expect(localize(input, "not-a-language")).toEqual({ greeting: "Hello" }); - }); - - it("supports a custom fallback language", () => { - const input = { - greeting: { ar: "مرحبا", en: "Hello" }, - description: { ar: "وصف", en: "Description" }, - }; - - expect(localize(input, "not-a-language", "ar")).toEqual({ - greeting: "مرحبا", - description: "وصف", - }); - }); - - it("falls back when the preferred translation is missing", () => { - const input = { greeting: { ar: "مرحبا", en: "Hello", fr: undefined } }; - - expect(localize(input, "fr", "en")).toEqual({ greeting: "Hello" }); - }); - - it("does not mutate the input", () => { - const input = { - title: { ar: "عنوان", en: "Title" }, - nested: [{ label: { ar: "وسم", en: "Label" } }], - }; - const snapshot = structuredClone(input); - - const result = localize(input, "en"); - - expect(input).toEqual(snapshot); - expect(result).not.toBe(input); - expect(result.nested).not.toBe(input.nested); - }); -}); - -describe("nullish translation behavior", () => { - it("localizeDatas preserves an empty preferred translation", () => { - expect(localizeDatas({ title: { ar: "", en: "Title" } }, "ar")).toEqual({ - title: "", - }); - }); - - it("localizeObjectFields falls back from an empty preferred translation", () => { - expect( - localizeObjectFields({ title: { ar: "", en: "Title" } }, "ar"), - ).toEqual({ title: "Title" }); - }); -}); diff --git a/tools/buildnpm/main.go b/tools/buildnpm/main.go new file mode 100644 index 0000000..496ec11 --- /dev/null +++ b/tools/buildnpm/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "buildnpm:", err) + os.Exit(1) + } +} + +func run() error { + root, err := findModuleRoot() + if err != nil { + return err + } + destination := filepath.Join(root, "dist") + if err := os.RemoveAll(destination); err != nil { + return err + } + if err := os.MkdirAll(destination, 0o755); err != nil { + return err + } + + wasmPath := filepath.Join(destination, "data-localizer.wasm") + command := exec.Command("go", "build", "-trimpath", "-o", wasmPath, "./cmd/wasm") + command.Dir = root + command.Env = buildEnvironment(os.Environ(), map[string]string{ + "CGO_ENABLED": "0", + "GOARCH": "wasm", + "GOOS": "js", + }) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + return fmt.Errorf("compile WebAssembly: %w", err) + } + + wasmExec, err := findWasmExec() + if err != nil { + return err + } + files := [][2]string{ + {filepath.Join(root, "bindings", "node", "index.js"), filepath.Join(destination, "index.js")}, + {filepath.Join(root, "bindings", "node", "index.d.ts"), filepath.Join(destination, "index.d.ts")}, + {wasmExec, filepath.Join(destination, "wasm_exec.js")}, + {filepath.Join(runtime.GOROOT(), "LICENSE"), filepath.Join(destination, "GO-LICENSE")}, + } + for _, name := range []string{ + "shared.js", + "framework.js", "framework.d.ts", + "express.js", "express.d.ts", + "nest.js", "nest.d.ts", + "fastify.js", "fastify.d.ts", + "koa.js", "koa.d.ts", + } { + files = append(files, [2]string{ + filepath.Join(root, "bindings", "node", "adapters", name), + filepath.Join(destination, "adapters", name), + }) + } + for _, file := range files { + if err := copyFile(file[0], file[1]); err != nil { + return err + } + } + return nil +} + +func findModuleRoot() (string, error) { + directory, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil { + return directory, nil + } + parent := filepath.Dir(directory) + if parent == directory { + return "", fmt.Errorf("could not find go.mod") + } + directory = parent + } +} + +func findWasmExec() (string, error) { + candidates := []string{ + filepath.Join(runtime.GOROOT(), "lib", "wasm", "wasm_exec.js"), + filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js"), + } + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + return "", fmt.Errorf("wasm_exec.js was not found under GOROOT %s", runtime.GOROOT()) +} + +func buildEnvironment(current []string, overrides map[string]string) []string { + result := make([]string, 0, len(current)+len(overrides)) + for _, entry := range current { + name, _, found := strings.Cut(entry, "=") + if !found { + continue + } + if _, replaced := overrides[strings.ToUpper(name)]; !replaced { + result = append(result, entry) + } + } + for name, value := range overrides { + result = append(result, name+"="+value) + } + return result +} + +func copyFile(source, destination string) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + + output, err := os.Create(destination) + if err != nil { + return err + } + if _, err := io.Copy(output, input); err != nil { + output.Close() + return err + } + return output.Close() +} diff --git a/tsconfig.build.json b/tsconfig.build.json deleted file mode 100644 index 258470d..0000000 --- a/tsconfig.build.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": [ - "node_modules", - "test", - "dist", - "**/*spec.ts", - "**/*.test.ts" - ] -} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 50fa622..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2019", - "module": "CommonJS", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "resolveJsonModule": true - }, - "include": [ - "src" - ] -} diff --git a/tsconfig.test.json b/tsconfig.test.json deleted file mode 100644 index 1d57ec7..0000000 --- a/tsconfig.test.json +++ /dev/null @@ -1,12 +0,0 @@ -// tsconfig.test.json -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": ".", - "noEmit": true - }, - "include": [ - "src/**/*", - "tests/**/*" - ] -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 2e1f5a4..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'vitest/config' -import path from "node:path"; - -export default defineConfig({ - test: { - env: { - NODE_ENV: 'test' - }, - globals: true, - environment: 'node', - include: ["src/**/*.{test,spec}.ts", "tests/**/*.{test,spec}.ts"], - clearMocks: true, - restoreMocks: true, - coverage: { - provider: 'v8', - include: ['tests/**/*.ts'], - exclude: ['tests/**/*.d.ts'], - }, - }, - resolve: { alias: { "@": path.resolve(__dirname, "src") } }, -})