From a8e9653a12c8afebafb39c14b716b57e1a243e85 Mon Sep 17 00:00:00 2001 From: bc-yevhenii-buliuk Date: Wed, 6 May 2026 22:24:17 +0300 Subject: [PATCH] chore(LTRAC-633): add messageformat candidates benchmark script --- docs/benchmark-mf-results.md | 238 ++++++++ package.json | 8 +- scripts/benchmark-mf-cornerstone.js | 247 ++++++++ scripts/benchmark-mf.js | 350 ++++++++++++ scripts/cornerstone-en.json | 847 ++++++++++++++++++++++++++++ 5 files changed, 1689 insertions(+), 1 deletion(-) create mode 100644 docs/benchmark-mf-results.md create mode 100644 scripts/benchmark-mf-cornerstone.js create mode 100644 scripts/benchmark-mf.js create mode 100644 scripts/cornerstone-en.json diff --git a/docs/benchmark-mf-results.md b/docs/benchmark-mf-results.md new file mode 100644 index 0000000..d10b621 --- /dev/null +++ b/docs/benchmark-mf-results.md @@ -0,0 +1,238 @@ +# MessageFormat Library Benchmark — Results & Conclusions + +**Ticket:** LTRAC-633 +**Script:** `npm run benchmark:mf` (`scripts/benchmark-mf.js`) +**Duration per case:** 2 s (tinybench) + +--- + +## What are we measuring and why? + +When a page is rendered, every translation string (e.g. `{{lang 'cart.label' quantity=3}}`) goes through two steps: + +1. **Compile** — the library reads the ICU string, understands its structure (variables, plural rules, etc.) and produces a ready-to-use function/object. +2. **Render** — the compiled function is called with actual values (`{quantity: 3}`) to produce the final translated string. + +In the current implementation the compiled result is **cached within one request**: the first call to a key triggers compile + render; all subsequent calls to the same key within that request do render only. Between requests the cache is reset. + +Both scenarios are therefore relevant: +- **Compile + render** — first call to any key (most common case, happens for every key on every request). +- **Render only** — repeated call to the same key on the same page (e.g. a product list showing the same label many times). + +**Compile only** is also measured in isolation to identify where the cost sits. + +An additional scenario matters for **CF Worker (stencil-renderer-worker)**: there is a technical possibility to compile translations at theme deploy time, leaving only render in the runtime hot path (if such a pipeline is implemented). For that scenario **render only** is the key test. + +--- + +## Libraries under test + +| Label | Package | Notes | +|-------|---------|-------| +| **Current** | `messageformat@0.3.1` | Currently in production | +| **C1** | `@messageformat/icu-messageformat-1@0.12.0` | Candidate | +| **C2** | `intl-messageformat@11.2.4` | Candidate | +| **C3** | `icu-minify@4.11.0` | Candidate | + +--- + +## Fixtures + +Real ICU strings from the Cornerstone theme (`spec/fixtures/lang.json`) plus synthetic patterns not present in the repo: + +| # | Name | ICU string | +|---|------|------------| +| 1 | simple `{variable}` | `Welcome back, {name}` | +| 2 | plural one/other | `Your Cart ({quantity, plural, one {# item} other {# items}})` | +| 3 | plural `=0` exact match | `{NUM, plural, =0{(0 items)} one {(# item)} other {(# items)}}` | +| 4 | plural + variable combo | `{ count, plural, one {# result} other {# results} } for '{ search_query }'` | +| 5 | single quotes around `'{var}'` | `Configure '{name}'` | +| 6 | select (gender) ¹ | `{gender, select, male {He placed} female {She placed} other {They placed}} an order` | +| 7 | static string (no params) | `Add to Cart` | +| 8 | date formatting ¹ | `Order placed on {date, date, short}` — Current does not support this ICU type → `n/a` | +| 9 | error: missing param ¹ | Same string as #1, called without params — tests graceful degradation | +| 10 | error: invalid syntax ¹ | `{count, plural, broken syntax` — all libs throw at compile → `n/a` everywhere | + +¹ Synthetic — not found in existing lang files. + +--- + +## Results + +### Compile only (ops/sec — higher is better) + +First isolated cost: how fast can each library parse and build the ICU string? + +| Fixture | Current | C1 | C2 | C3 | +|---------|--------:|---:|---:|---:| +| simple `{variable}` | 422,486 | 240,978 | 355,038 | **2,048,377** | +| plural one/other | 108,600 | 136,695 | 225,300 | **501,471** | +| plural `=0` | 94,611 | 126,482 | 201,607 | **402,411** | +| plural + variable | 96,938 | 130,967 | 207,287 | **468,209** | +| single quotes | 391,267 | 240,488 | 353,007 | **2,758,617** | +| select (gender) | 92,824 | 126,874 | 197,421 | **388,255** | +| static string | 493,343 | 244,443 | 367,264 | **3,695,106** | +| date formatting | n/a | 201,648 | 305,243 | **1,227,201** | +| error: missing param | 426,873 | 232,792 | 339,080 | **1,970,609** | +| error: invalid syntax | n/a | n/a | n/a | n/a | + +### Render only (ops/sec — higher is better) + +Second isolated cost: how fast is substitution when the compiled handle is already cached? + +| Fixture | Current | C1 | C2 | C3 | +|---------|--------:|---:|---:|---:| +| simple `{variable}` | **24,441,262** | 6,342,935 | 9,894,910 | 18,137,069 | +| plural one/other | **9,619,537** | 40,444 | 708,990 | 57,088 | +| plural `=0` | **22,020,454** | 191,771 | 9,910,454 | 8,068,283 | +| plural + variable | **8,498,484** | 41,431 | 741,855 | 58,389 | +| single quotes | **25,808,895** | 19,102,700 | 26,477,022 | 24,146,816 | +| select (gender) | **23,244,980** | 2,268,634 | 7,983,345 | 11,992,048 | +| static string | 24,099,949 | 19,839,508 | **26,392,706** | 24,436,844 | +| date formatting | n/a | 29,235 | **697,159** | 38,117 | +| error: missing param | **24,152,192** | 193,388 | 121,895 | 15,959,897 | +| error: invalid syntax | n/a | n/a | n/a | n/a | + +### Compile + render — full cycle (ops/sec — higher is better) + +Most common scenario: compile and render happen together on the first call to each key. + +| Fixture | Current | C1 | C2 | C3 | +|---------|--------:|---:|---:|---:| +| simple `{variable}` | 425,347 | 218,699 | 318,975 | **1,692,534** | +| plural one/other | **111,280** | 30,257 | 40,675 | 49,594 | +| plural `=0` | 96,507 | 71,600 | 194,415 | **392,961** | +| plural + variable | **105,466** | 29,936 | 40,221 | 49,346 | +| single quotes | 399,493 | 237,251 | 345,523 | **2,571,788** | +| select (gender) | 94,762 | 123,531 | 192,017 | **402,709** | +| static string | 519,510 | 251,597 | 363,740 | **3,482,812** | +| date formatting | n/a | 24,209 | 32,266 | **36,407** | +| error: missing param | 425,347 | 92,215 | 112,957 | **1,680,949** | +| error: invalid syntax | n/a | n/a | n/a | n/a | + +--- + +## Error handling behavior + +How each library behaves when something goes wrong — independent of performance: + +| Scenario | Current | C1 | C2 | C3 | +|----------|---------|----|----|-----| +| Missing param (`{name}` called without `name`) | Silently returns `"…undefined"` | Returns raw token `{$name}` — visible in UI | **Throws exception** — fail-fast | Silently returns `"…undefined"` | +| Invalid ICU syntax | Throws at compile | Throws at compile | Throws at compile | Throws at compile | + +--- + +## Conclusions + +### Compile speed + +**C3 is the fastest to compile** across all fixture types — 4–7× faster than Current on simple strings, 4–5× on plural and select. **C2 is second** — consistently faster than Current on plural, select, and date. Current is competitive only on simple variable substitution. + +### Render speed (when compile is cached) + +**Current dominates** on every supported fixture: +- Plural: **230× faster than C1**, **13× faster than C2**, **165× faster than C3**. +- Select: **10× faster than C3**, **3× faster than C2**. +- Simple strings and static text: gap is smaller but Current still leads. + +**C1 and C3 have a significant render bottleneck on plural** (~40–58k ops/sec vs ~9.6M for Current). **C2 is the closest alternative**: ~720k ops/sec on plural (13× behind Current, but 17× ahead of C1 and 12× ahead of C3). + +### Compile + render (most common scenario) + +**C3 wins** on simple strings, static text, select, and single quotes — its very cheap compile compensates for slower render on non-plural patterns. **Current wins on plural** even with compile in the loop. **C2 is the best alternative on plural** in the full cycle (~40k vs ~111k for Current). + +### Date support + +**Current does not support the `{date, date, short}` ICU type at all.** Among candidates: C3 compiles date fastest; C2 renders date fastest (~700k ops/sec vs ~38k for C1/C3). + +### Missing param behavior + +Current and C3 **silently return `undefined`** — production bugs are invisible. C1 returns the raw token visible in the UI. **C2 throws an exception** — strictest validation, requires error handling in the caller wrapper. + +--- + +## Summary — winner per scenario + +| Fixture | Compile only | Render only | Full cycle | +|---------|:---:|:---:|:---:| +| simple `{variable}` | C3 | Current | C3 | +| plural one/other | C3 | Current | Current | +| plural `=0` | C3 | Current | C3 | +| plural + variable | C3 | Current | Current | +| single quotes | C3 | Current ≈ C2 | C3 | +| select (gender) | C3 | Current | C3 | +| static string | C3 | Current ≈ all | C3 | +| date formatting | C3 | C2 | C3 | + +--- + +## Final candidates: C2 vs C3 + +**C1 is eliminated** due to critical plural regression: ~40k ops/sec on render-only and ~30k on full cycle — 230× slower than Current on render and 3.7× on full cycle. + +| | C2 | C3 | +|-|----|----| +| Compile speed | ⚠️ Average | ✅ Fastest | +| Render plural (repeated calls) | ✅ ~720k (best among candidates) | ❌ ~58k | +| Full cycle plural | ⚠️ ~40k | ⚠️ ~50k | +| Full cycle simple strings | ⚠️ ~320k | ✅ ~1.7M | +| Date ICU support | ✅ | ✅ | +| `translator.js` wrapper changes needed | Minimal | Required (single quotes, missing param, error messages) | + +**C2** — if priority is render plural (repeated calls to the same key on one page). +**C3** — if priority is compile speed and the workload is mostly simple/static strings. + +**Recommendation:** given that CF Worker with possible pre-compile is the priority direction for this migration — **C2 is the recommended library**. For SFR-2 where compile happens at runtime, C3 is faster in the full cycle, but that scenario is not the priority for the current migration. + +--- + +## Full Cornerstone translation file benchmark + +**Script:** `scripts/benchmark-mf-cornerstone.js` +**File:** `scripts/cornerstone-en.json` (real Cornerstone theme `lang/en.json`) +**File size:** 778 strings +**One iteration = processing all 778 strings** — closest to a real full-page render cycle. + +### Methodology + +Instead of individual fixtures, each library is run over **all strings in the file** in a single pass. This gives a realistic picture of performance on mixed content: simple strings, plural, variables, select — all together. + +### Results (ops/sec — higher is better) + +| Library | Compile only | Render only | Compile + render | +|---------|-------------:|------------:|----------------:| +| **Current** | 438 | **108,502** | 428 | +| C1 | 307 | 841 | 214 | +| C2 | 400 | 12,689 | 334 | +| **C3** | **2,396** | 3,047 | **1,240** | + +**mean (ms) per full-file pass:** + +| Library | Compile only | Render only | Compile + render | +|---------|-------------:|------------:|----------------:| +| Current | 2.314 ms | **0.010 ms** | 2.347 ms | +| C1 | 3.267 ms | 1.261 ms | 4.729 ms | +| C2 | 2.659 ms | 0.082 ms | 3.195 ms | +| **C3** | **0.545 ms** | 0.339 ms | **0.924 ms** | + +### Full-file conclusions + +**Compile all strings:** +C3 compiles 778 strings in **0.54 ms** — **4.2×** faster than Current (2.31 ms) and **4.9×** faster than C2. Cold-start / first request with C3 is substantially faster. + +**Render all strings (compile cached):** +Current renders the whole file in **0.01 ms** — **8.5×** faster than C2, **34×** faster than C3. This confirms Current is untouchable on the hot render path. However this scenario only applies when the same key appears multiple times on the same page. + +**Compile + render (typical scenario — first call per key):** +C3 processes the whole file in **0.92 ms** vs **2.35 ms** for Current — **2.5× faster**. C2 at 3.19 ms is slightly slower than Current. For pages where each key appears once, C3 gives a real throughput gain. + +### C2 vs C3 on the real file + +| Scenario | C2 | C3 | Winner | +|----------|---:|---:|:---:| +| Compile whole file | 2.66 ms | **0.54 ms** | C3 (4.9×) | +| Render whole file | **0.08 ms** | 0.34 ms | C2 (4.2×) | +| Compile + render (typical) | 3.19 ms | **0.92 ms** | C3 (3.5×) | + +C3 wins on compile and full cycle on the real file. C2 wins on render-only — the priority scenario for CF Worker, where there is a technical possibility to compile translations at theme deploy time and execute only render at runtime (if such a pipeline is implemented). diff --git a/package.json b/package.json index 5167911..d0b8efc 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "lint-and-fix": "eslint . --fix", "test": "lab -v -t 94 --ignore i18n,WebAssembly,SharedArrayBuffer,Atomics,BigUint64Array,BigInt64Array,BigInt,URL,URLSearchParams,TextEncoder,TextDecoder,queueMicrotask,FinalizationRegistry,WeakRef,plural,en,number,select,__extends,__assign,__rest,__decorate,__param,__esDecorate,__runInitializers,__propKey,__setFunctionName,__metadata,__awaiter,__generator,__exportStar,__createBinding,__values,__read,__spread,__spreadArrays,__spreadArray,__await,__asyncGenerator,__asyncDelegator,__asyncValues,__makeTemplateObject,__importStar,__importDefault,__classPrivateFieldGet,__classPrivateFieldSet,__classPrivateFieldIn,__rewriteRelativeImportExtension,AggregateError,BroadcastChannel,structuredClone,DOMException,AbortController,AbortSignal,EventTarget,Event,MessageChannel,MessagePort,MessageEvent,atob,btoa,Blob,Performance,performance,ReadableStream,ReadableStreamDefaultReader,ReadableStreamBYOBReader,ReadableStreamBYOBRequest,ReadableByteStreamController,ReadableStreamDefaultController,TransformStream,TransformStreamDefaultController,WritableStream,WritableStreamDefaultWriter,WritableStreamDefaultController,ByteLengthQueuingStrategy,CountQueuingStrategy,TextEncoderStream,TextDecoderStream,CompressionStream,DecompressionStream,fetch,FormData,Headers,Request,Response,__addDisposableResource,__disposeResources,File,PerformanceEntry,PerformanceMark,PerformanceMeasure,PerformanceObserver,PerformanceObserverEntryList,PerformanceResourceTiming,WebSocket,Iterator,Navigator,navigator,crypto,Crypto,CryptoKey,SubtleCrypto,CustomEvent,URLPattern,CloseEvent,SuppressedError,DisposableStack,AsyncDisposableStack,Float16Array spec", "coverage": "lab -c -r console -o stdout -r html -o coverage.html spec", + "benchmark:mf": "node scripts/benchmark-mf.js", + "benchmark:mf-cornerstone": "node scripts/benchmark-mf-cornerstone.js", "release": "semantic-release" }, "repository": { @@ -32,6 +34,7 @@ "devDependencies": { "@commitlint/cli": "^19.4.0", "@commitlint/config-conventional": "^19.2.2", + "@messageformat/icu-messageformat-1": "^0.12.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", @@ -41,9 +44,12 @@ "code": "~4.0.0", "eslint": "^7.8.1", "husky": "^8.0.1", + "icu-minify": "^4.11.0", + "intl-messageformat": "^11.2.4", "lab": "~13.0.1", "semantic-release": "^25.0.2", "semantic-release-github-pullrequest": "https://github.com/jairo-bc/semantic-release-github-pullrequest", - "sinon": "~7.5.0" + "sinon": "~7.5.0", + "tinybench": "^6.0.1" } } diff --git a/scripts/benchmark-mf-cornerstone.js b/scripts/benchmark-mf-cornerstone.js new file mode 100644 index 0000000..a739e93 --- /dev/null +++ b/scripts/benchmark-mf-cornerstone.js @@ -0,0 +1,247 @@ +'use strict'; + +/** + * LTRAC-633 — Cornerstone full lang/en.json benchmark + * + * Measures how long each library takes to process the entire Cornerstone + * translation file (all ~778 strings) in three scenarios: + * + * 1) compile only — parse/build every string, no format call + * 2) render only — all strings pre-compiled; timed body is format only + * 3) compile+render — compile and render every string each iteration + * + * "One iteration" = processing ALL strings in the file once, simulating + * a full page render cycle. + */ + +const { Bench } = require('tinybench'); +const MessageFormat = require('messageformat'); +const { mf1ToMessage } = require('@messageformat/icu-messageformat-1'); +const IntlMessageFormat = require('intl-messageformat').default; +const icuCompile = require('icu-minify/compile').default; +const icuFormat = require('icu-minify/format').default; + +const ICU_FORMATTERS = { + formatters: { + getPluralRules: (l, o) => new Intl.PluralRules(l, o), + getNumberFormat: (l, o) => new Intl.NumberFormat(l, o), + getDateTimeFormat: (l, o) => new Intl.DateTimeFormat(l, o), + }, +}; + +// --------------------------------------------------------------------------- +// Load and flatten Cornerstone en.json +// --------------------------------------------------------------------------- +const raw = require('./cornerstone-en.json'); + +function flatten(obj, prefix) { + const out = []; + for (const key of Object.keys(obj)) { + const val = obj[key]; + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof val === 'string') { + out.push({ key: fullKey, str: val }); + } else if (typeof val === 'object' && val !== null) { + out.push(...flatten(val, fullKey)); + } + } + return out; +} + +const ALL_STRINGS = flatten(raw, ''); + +// Generic params that cover every variable name used in Cornerstone strings. +const PARAMS = { + name: 'Joe', phone_number: '+1 234 567 8900', + quantity: 3, NUM: 5, num_products: 3, number: 5, + products: 3, total: 10, current: 1, CODE: 'USD', code: 'USD', + credit: '$50', store_credit: '$50', store_name: 'My Store', + limit: 5, store: 'My Store', min: 1, max: 10, + days: 3, discount: '10%', id: 123, date: '2024-01-15', + from: '$10', to: '$50', category: 'Electronics', + rating: 4, title: 'Filters', street: '123 Main St', + city: 'New York', state: 'NY', zip: '10001', country: 'US', + card: '4242', last_four: '4242', month: '01', year: '25', + qty: 2, num_new_messages: 3, num_wishlists: 2, + shopPath: '/shop', cart_url: '/cart', email: 'user@example.com', + index: 1, url: 'http://example.com', count: 5, + search_query: 'shoes', limitTo: 1, limitFrom: 100, + amount: '$10', tax_label: 'VAT', swatch_name: 'Blue', + rating_target: 'Product', current_rating: 4, max_rating: 5, + slide_number: 1, certificate_name: 'My Certificate', + cost: '$99', cost_total: '$99', num_products_total: 3, + gender: 'other', +}; + +// Sink prevents dead-code elimination in tight loops. +let _benchSink; + +// --------------------------------------------------------------------------- +// Library adapters +// --------------------------------------------------------------------------- +const LIBRARIES = [ + { + name: 'Current (messageformat@0.3.1)', + compile: (str) => new MessageFormat('en').compile(str), + renderOnly: (compiled, params) => compiled(params), + full: function(str, params) { return this.renderOnly(this.compile(str), params); }, + }, + { + name: 'C1 (@messageformat/icu-messageformat-1)', + compile: (str) => mf1ToMessage('en', str), + renderOnly: (compiled, params) => compiled.format(params), + full: function(str, params) { return this.renderOnly(this.compile(str), params); }, + }, + { + name: 'C2 (intl-messageformat)', + compile: (str) => new IntlMessageFormat(str, 'en'), + renderOnly: (compiled, params) => compiled.format(params), + full: function(str, params) { return this.renderOnly(this.compile(str), params); }, + }, + { + name: 'C3 (icu-minify)', + compile: (str) => icuCompile(str), + renderOnly: (compiled, params) => icuFormat(compiled, 'en', params, ICU_FORMATTERS), + full: function(str, params) { return this.renderOnly(this.compile(str), params); }, + }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function safeCompile(lib, str) { + try { return lib.compile(str); } catch(e) { return null; } +} + +function safeRender(lib, compiled, params) { + if (!compiled) { return ''; } + try { return lib.renderOnly(compiled, params); } catch(e) { return ''; } +} + +function safeFull(lib, str, params) { + try { return lib.full(str, params); } catch(e) { return ''; } +} + +// --------------------------------------------------------------------------- +// Benchmark runners — one iteration processes ALL strings +// --------------------------------------------------------------------------- +async function runAllStrings(bodyFn) { + const DURATION_MS = 3000; + const results = []; + + for (const lib of LIBRARIES) { + const bench = new Bench({ time: DURATION_MS }); + bench.add(lib.name, bodyFn.bind(null, lib)); + await bench.run(); + + const task = bench.tasks[0]; + const opsPerSec = task.result.throughput.mean; + const meanMs = task.result.latency.mean; + + results.push({ + library: lib.name, + opsPerSec: Math.round(opsPerSec).toLocaleString(), + meanMs: meanMs.toFixed(3), + // Time per single string = mean / number of strings + perStringUs: ((meanMs / ALL_STRINGS.length) * 1000).toFixed(4), + }); + } + return results; +} + +async function runRenderOnly() { + const DURATION_MS = 3000; + const results = []; + + for (const lib of LIBRARIES) { + // Pre-compile all strings outside the timer + const compiled = ALL_STRINGS.map(({ str }) => safeCompile(lib, str)); + + const bench = new Bench({ time: DURATION_MS }); + bench.add(lib.name, function() { + for (let i = 0; i < ALL_STRINGS.length; i++) { + _benchSink = safeRender(lib, compiled[i], PARAMS); + } + }); + await bench.run(); + + const task = bench.tasks[0]; + const opsPerSec = task.result.throughput.mean; + const meanMs = task.result.latency.mean; + + results.push({ + library: lib.name, + opsPerSec: Math.round(opsPerSec).toLocaleString(), + meanMs: meanMs.toFixed(3), + perStringUs: ((meanMs / ALL_STRINGS.length) * 1000).toFixed(4), + }); + } + return results; +} + +// --------------------------------------------------------------------------- +// Print +// --------------------------------------------------------------------------- +function printTable(title, results) { + const libW = 45; + const opsW = 14; + const msW = 12; + const perW = 18; + + const header = + 'Library'.padEnd(libW) + + 'ops/sec'.padStart(opsW) + + 'mean (ms)'.padStart(msW) + + 'µs/string'.padStart(perW); + const divider = '-'.repeat(libW + opsW + msW + perW); + + console.log(''); + console.log(title); + console.log(`(${ALL_STRINGS.length} strings per iteration)`); + console.log(divider); + console.log(header); + console.log(divider); + for (const r of results) { + console.log( + r.library.padEnd(libW) + + r.opsPerSec.padStart(opsW) + + r.meanMs.padStart(msW) + + r.perStringUs.padStart(perW), + ); + } + console.log(divider); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function run() { + console.log(`\nCORNERSTONE FULL FILE BENCHMARK — ${ALL_STRINGS.length} strings`); + console.log('Duration per case: 3 s (tinybench)\n'); + + // 1) Compile only + const resultsCompile = await runAllStrings(function(lib) { + for (const { str } of ALL_STRINGS) { + _benchSink = safeCompile(lib, str); + } + }); + + // 2) Render only (pre-compiled) + const resultsRender = await runRenderOnly(); + + // 3) Compile + render + const resultsFull = await runAllStrings(function(lib) { + for (const { str } of ALL_STRINGS) { + _benchSink = safeFull(lib, str, PARAMS); + } + }); + + printTable('1) compile only — parse/build all strings per iteration', resultsCompile); + printTable('2) render only — compile outside timer; timed: format all strings per iteration', resultsRender); + printTable('3) compile + render — full cycle every iteration', resultsFull); + + void _benchSink; + console.log('\nDone.'); +} + +run().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/benchmark-mf.js b/scripts/benchmark-mf.js new file mode 100644 index 0000000..27cc99a --- /dev/null +++ b/scripts/benchmark-mf.js @@ -0,0 +1,350 @@ +'use strict'; + +/** + * LTRAC-633 — Performance benchmark for MF library candidates + * + * Strategy (Jairo): measure three costs separately — + * 1) compile only — parse/build per iteration; no format / no substitution + * 2) render only — compile once per (lib, fixture); timed body is format only + * 3) compile + render — both steps every iteration (no reuse of compiled handle) + * + * Libraries under test: + * Current — messageformat@0.3.1 + * C1 — @messageformat/icu-messageformat-1 + * C2 — intl-messageformat + * C3 — icu-minify + * + * Fixtures: real strings from Cornerstone theme lang/en.json + * https://github.com/bigcommerce/cornerstone/blob/master/lang/en.json + */ + +const { Bench } = require('tinybench'); +const MessageFormat = require('messageformat'); +const { mf1ToMessage } = require('@messageformat/icu-messageformat-1'); +const IntlMessageFormat = require('intl-messageformat').default; +const icuCompile = require('icu-minify/compile').default; +const icuFormat = require('icu-minify/format').default; + +// Intl factories required by icu-minify/format +const ICU_FORMATTERS = { + formatters: { + getPluralRules: function(l, o) { return new Intl.PluralRules(l, o); }, + getNumberFormat: function(l, o) { return new Intl.NumberFormat(l, o); }, + getDateTimeFormat: function(l, o) { return new Intl.DateTimeFormat(l, o); }, + }, +}; + +// --------------------------------------------------------------------------- +// Fixtures — real Cornerstone strings +// https://github.com/bigcommerce/cornerstone/blob/master/lang/en.json +// --------------------------------------------------------------------------- +const FIXTURES = [ + { + name: 'simple {variable}', + key: 'header.welcome_back', + str: 'Welcome back, {name}', + params: { name: 'Joe' }, + }, + { + name: 'plural one/other', + key: 'cart.label', + str: 'Your Cart ({quantity, plural, one {# item} other {# items}})', + params: { quantity: 3 }, + }, + { + name: 'plural =0 exact match', + key: 'cart.items', + str: '{NUM, plural, =0{(0 items)} one {(# item)} other {(# items)}}', + params: { NUM: 0 }, + }, + { + name: 'plural + variable combo', + key: 'search.results.count', + str: "{ count, plural, one {# result} other {# results} } for '{ search_query }'", + params: { count: 3, search_query: 'shoes' }, + }, + { + name: "single quotes around '{var}'", + key: 'cart.reconfigure_product', + str: "Configure '{name}'", + params: { name: 'Shirt' }, + }, + { + // Synthetic — no select/gender pattern exists in spec/fixtures/lang.json + name: 'select (gender)', + key: 'synthetic.gender', + str: '{gender, select, male {He placed} female {She placed} other {They placed}} an order', + params: { gender: 'female' }, + }, + { + // Static string with no ICU tokens — baseline / no-op for format step + name: 'static string (no params)', + key: 'products.add_to_cart', + str: 'Add to Cart', + params: {}, + }, + { + // Synthetic — messageformat@0.3.1 does not support {date} ICU type; + // Current is skipped for this fixture (marked via skipLibs). + name: 'date formatting', + key: 'synthetic.date', + str: 'Order placed on {date, date, short}', + params: { date: new Date('2024-01-15') }, + skipLibs: ['Current (messageformat@0.3.1)'], + }, + { + // Error case: missing param. Each lib handles it differently — + // Current/C3: return "undefined" silently; C1: returns original token; + // C2: throws at render time. All wrapped in try/catch in the runners. + name: 'error: missing param', + key: 'synthetic.missing_param', + str: 'Welcome back, {name}', + params: {}, + isErrorFixture: true, + }, + { + // Error case: invalid ICU syntax — all libs throw at compile time. + // render-only is skipped for all libs (skipLibs = all) since precompile + // always throws and there is no handle to cache. + name: 'error: invalid syntax', + key: 'synthetic.invalid_syntax', + str: '{count, plural, broken syntax', + params: { count: 3 }, + isErrorFixture: true, + skipLibs: [ + 'Current (messageformat@0.3.1)', + 'C1 (@messageformat/icu-messageformat-1)', + 'C2 (intl-messageformat)', + 'C3 (icu-minify)', + ], + }, +]; + +// Sink for benchmark callback return values. If a result is unused, the engine may +// drop the call (dead code elimination) and skew timings. Assigning here keeps the +// work observable; we never read _benchSink for application logic. +let _benchSink; + +// --------------------------------------------------------------------------- +// Library adapters — three steps matching the benchmark strategy: +// compile(str) — parse/build only; returns a reusable compiled handle +// renderOnly(c, p) — substitute params into an existing handle +// full(str, p) — one-shot compile then render (same as render after compile) +// --------------------------------------------------------------------------- +const LIBRARIES = [ + { + name: 'Current (messageformat@0.3.1)', + compile: function(str) { + return new MessageFormat('en').compile(str); + }, + renderOnly: function(compiled, params) { + return compiled(params); + }, + full: function(str, params) { + return this.renderOnly(this.compile(str), params); + }, + }, + { + name: 'C1 (@messageformat/icu-messageformat-1)', + compile: function(str) { + return mf1ToMessage('en', str); + }, + renderOnly: function(compiled, params) { + return compiled.format(params); + }, + full: function(str, params) { + return this.renderOnly(this.compile(str), params); + }, + }, + { + name: 'C2 (intl-messageformat)', + compile: function(str) { + return new IntlMessageFormat(str, 'en'); + }, + renderOnly: function(compiled, params) { + return compiled.format(params); + }, + full: function(str, params) { + return this.renderOnly(this.compile(str), params); + }, + }, + { + name: 'C3 (icu-minify)', + compile: function(str) { + return icuCompile(str); + }, + renderOnly: function(compiled, params) { + return icuFormat(compiled, 'en', params, ICU_FORMATTERS); + }, + full: function(str, params) { + return this.renderOnly(this.compile(str), params); + }, + }, +]; + +// --------------------------------------------------------------------------- +// tinybench runner shared by compile-only and full-cycle passes: everything timed +// lives in the callback (either compile only or compile+render each iteration). +// --------------------------------------------------------------------------- +async function runBenchmark(bodyFn) { + const DURATION_MS = 2000; + const results = []; + + for (const fixture of FIXTURES) { + for (const lib of LIBRARIES) { + if (fixture.skipLibs && fixture.skipLibs.includes(lib.name)) { + results.push({ + library: lib.name, + fixture: fixture.name, + opsPerSec: 'n/a', + meanMs: 'n/a', + }); + continue; + } + + const bench = new Bench({ time: DURATION_MS }); + // Error fixtures: wrap body in try/catch so throws are measured too. + const wrappedBody = fixture.isErrorFixture + ? function() { try { bodyFn(lib, fixture); } catch(e) { _benchSink = e; } } + : bodyFn.bind(null, lib, fixture); + bench.add(`${lib.name} | ${fixture.name}`, wrappedBody); + await bench.run(); + + const task = bench.tasks[0]; + const opsPerSec = task.result.throughput.mean; + const meanMs = task.result.latency.mean; + + results.push({ + library: lib.name, + fixture: fixture.name, + opsPerSec: Math.round(opsPerSec).toLocaleString(), + meanMs: meanMs.toFixed(4), + }); + } + } + + return results; +} + +// Render-only runner: compile once per (lib, fixture) before the timer; timed +// callback is renderOnly only. _benchSink in the callback avoids DCE on the result. +async function runBenchmarkRenderOnly() { + const DURATION_MS = 2000; + const results = []; + + for (const fixture of FIXTURES) { + for (const lib of LIBRARIES) { + if (fixture.skipLibs && fixture.skipLibs.includes(lib.name)) { + results.push({ + library: lib.name, + fixture: fixture.name, + opsPerSec: 'n/a', + meanMs: 'n/a', + }); + continue; + } + + const compiled = lib.compile(fixture.str); + const bench = new Bench({ time: DURATION_MS }); + // Error fixtures: some libs throw on render (e.g. C2 on missing param). + const callback = fixture.isErrorFixture + ? function() { try { _benchSink = lib.renderOnly(compiled, fixture.params); } catch(e) { _benchSink = e; } } + : function() { _benchSink = lib.renderOnly(compiled, fixture.params); }; + bench.add(`${lib.name} | ${fixture.name}`, callback); + await bench.run(); + + const task = bench.tasks[0]; + const opsPerSec = task.result.throughput.mean; + const meanMs = task.result.latency.mean; + + results.push({ + library: lib.name, + fixture: fixture.name, + opsPerSec: Math.round(opsPerSec).toLocaleString(), + meanMs: meanMs.toFixed(4), + }); + } + } + + return results; +} + +function printTable(title, results) { + const libWidth = 45; + const fixWidth = 35; + const opsWidth = 15; + const msWidth = 12; + + const header = + 'Library'.padEnd(libWidth) + + 'Fixture'.padEnd(fixWidth) + + 'ops/sec'.padStart(opsWidth) + + 'mean (ms)'.padStart(msWidth); + + const divider = '-'.repeat(libWidth + fixWidth + opsWidth + msWidth); + + console.log(''); + console.log(title); + console.log(divider); + console.log(header); + console.log(divider); + + let lastFixture = ''; + for (const r of results) { + if (r.fixture !== lastFixture) { + if (lastFixture !== '') { + console.log(''); + } + lastFixture = r.fixture; + } + const row = + r.library.padEnd(libWidth) + + r.fixture.padEnd(fixWidth) + + r.opsPerSec.padStart(opsWidth) + + r.meanMs.padStart(msWidth); + console.log(row); + } + + console.log(divider); +} + +async function run() { + console.log('Running benchmarks (2s per case)...\n'); + + // 1) Compile on every iteration; no parameter substitution. + const resultsCompileOnly = await runBenchmark(function(lib, fixture) { + _benchSink = lib.compile(fixture.str); + }); + + // 2) Compile outside the timer; timed loop is render only (see runBenchmarkRenderOnly). + const resultsRenderOnly = await runBenchmarkRenderOnly(); + + // 3) Full cycle each iteration: compile then render again every time. + // Assign to _benchSink so the engine does not drop the whole call as unused. + const resultsCompileAndRender = await runBenchmark(function(lib, fixture) { + _benchSink = lib.full(fixture.str, fixture.params); + }); + + printTable( + 'compile only — parse/build per iteration (no substitution)', + resultsCompileOnly, + ); + printTable( + 'render only — compile once per case; timed: format / substitution only', + resultsRenderOnly, + ); + printTable( + 'compile + render — full cycle every iteration (fresh compile each time)', + resultsCompileAndRender, + ); + + // Satisfy no-unused-vars after many assignments inside benchmark callbacks. + void _benchSink; + + console.log('\nDone.'); +} + +run().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/cornerstone-en.json b/scripts/cornerstone-en.json new file mode 100644 index 0000000..3fd43f8 --- /dev/null +++ b/scripts/cornerstone-en.json @@ -0,0 +1,847 @@ +{ + "header": { + "welcome_back": "Welcome back, {name}", + "skip_to_main": "Skip to main content" + }, + "footer": { + "title": "Footer Start", + "brands": "Popular Brands", + "navigate": "Navigate", + "info": "Info", + "categories": "Categories", + "call_us": "Call us at {phone_number}", + "powered_by": "Powered by" + }, + "home": { "heading": "Home" }, + "blog": { + "recent_posts": "Recent Posts", + "label": "Blog", + "posted_by": "Posted by {name}", + "read_more": "read more" + }, + "unavailable": { + "hibernation_title": "We'll be back", + "hibernation_message": "Thanks for visiting. Our store is currently unavailable. We apologize for any inconvenience caused.", + "maintenance_title": "Down for Maintenance", + "maintenance_message": "This store is currently unavailable due to maintenance. It should be available again shortly. We apologize for any inconvenience caused." + }, + "brands": { "no_products": "There are no products listed under this brand." }, + "categories": { "no_products": "There are no products listed under this category." }, + "checkout": { "title": "Checkout" }, + "cart": { + "nav_aria_label": "Cart with 0 items", + "continue_shopping": "Click here to continue shopping", + "login_to_checkout": "Login to proceed to checkout", + "items": "{NUM, plural, =0{(0 items)} one {(# item)} other {(# items)}}", + "checkout": { + "address": { "multiple": "check out with multiple addresses", "or": "or" }, + "button": "Check out", + "empty_cart": "Your cart is empty", + "title": "Click here to proceed to checkout", + "item": "Item", + "price": "Price", + "quantity": "Quantity", + "total": "Total", + "subtotal": "Subtotal", + "enter_code": "Coupon code / Gift certificate", + "gift_wrapping": "Gift wrapping", + "shipping": "Shipping", + "grand_total": "Grand total", + "select": "Select", + "update": "Update shipping cost", + "you_saved": "You saved", + "in_total": "in total!" + }, + "preview": { + "checkout_now": "Check out now", + "checkout_multiple": "or check out with multiple addresses", + "view_cart": "View Cart" + }, + "label": "Your Cart ({quantity, plural, one {# item} other {# items}})", + "is_empty": "Your cart is empty", + "invalid_entry_message": "[ENTRY] is not a valid entry", + "coupon_code": "Coupon Code", + "discount": "Discount", + "discounts": "Discounts", + "manual_discount": "Manual discount", + "automatic_discount": "Automatic discount", + "included_in_total": " Included in Total", + "remove_file": "Remove this file", + "freeshipping": "Free Shipping", + "reconfigure_product": "Configure '{name}'", + "shipping_peritem": "Per Item Shipping", + "remove_item": "Remove {name} from cart", + "confirm_delete": "Are you sure you want to delete this item?", + "coupons": { + "apply_coupon": "Apply coupon", + "apply_coupon_code": "Apply coupon code", + "empty_error": "Please enter your coupon code.", + "cancel": "Cancel", + "add_code": "Add code", + "add_coupon": "Add Coupon", + "button": "Apply", + "coupon_code": "Enter your coupon code", + "code_label": "Coupon ({code})", + "remove": "Remove", + "maximum_discount_applied": "Maximum discount applied" + }, + "gift_certificates": { + "apply_gift_certificate": "Apply gift certificate", + "change_gift_certificate": "Change {certificate_name}", + "add_cert_code": "Add Certificate", + "gift_certificate": "Gift Certificate", + "code_label": "Gift Certificate ({code})", + "cert_code": "Enter your certificate code", + "remove": "Remove" + }, + "shipping_estimator": { + "add_info": "Add Info", + "cancel": "Cancel", + "select_a_country": "Country", + "select_a_state": "State/province", + "estimate_shipping": "Estimate Shipping", + "suburb_city": "Suburb/city", + "zip_postal_code": "Zip/postcode", + "free_shipping": "Free shipping", + "hide_ups_rates": "Hide UPS Rates", + "show_ups_rates": "Show UPS Rates", + "empty_country_error": "The 'Country' field cannot be blank.", + "empty_province_error": "The 'State/Province' field cannot be blank." + }, + "gift_wrapping": { + "title": "Gift Wrapping", + "add": "Add", + "change": "Change", + "remove": "Remove", + "choose_how": "Please choose how you'd like to have this item gift wrapped.", + "option_same": "I'd like to wrap each of this item using the same wrapping options", + "item_single": "Gift wrapping - {name}", + "item_multiple": "Item {index} - {name}", + "option_different": "I'd like to gift wrap each item separately", + "choose_type": "Please choose a type of gift wrapping", + "gift_message": "Gift Message", + "remove_confirm": "Are you sure you want to remove the gift wrapping from this item?", + "preview": "Preview" + }, + "added_to_cart": { + "what_next": "Ok, {num_products, plural, one {1 item was} other {# items were}} added to your cart. What's next?", + "your_cart_contains": "Your cart contains {num_products, plural, one {1 item} other {# items}}", + "proceed_to_checkout": "Proceed to checkout", + "order_subtotal": "Order subtotal", + "continue_shopping": "Continue Shopping", + "view_or_edit_cart": "View or edit your cart", + "you_might_also_like": "You may also like" + } + }, + "common": { + "store_credit": "You have {store_credit} of store credit. To use it, simply place your order and you will be able to choose store credit as the payment method when it comes time to pay for your order.", + "store_credit_overview": "{credit} Store Credit", + "generic_error": "Oops! Something went wrong.", + "currency": "Currency: {code}", + "currency_switch_promotion": "Promotions and gift certificates that don't apply to the new currency will be removed from your cart. Are you sure you want to continue?", + "channel": "Store: {code}", + "channel_switch_warning": "Warning text for store switching", + "locale_switch_warning": "Warning text for language switching", + "locale": "Language:", + "newsletter_signup": "Register for our newsletter", + "form_submit": "Submit", + "no_preference": "No Preference", + "other_details": "Other Details", + "required": "Required", + "optional": "Optional", + "email_address": "Email Address", + "edit": "Edit", + "not_applicable": "N/A", + "no": "No", + "yes": "Yes", + "from": "From", + "to": "to", + "ok": "OK", + "cancel": "Cancel", + "close": "Close", + "or": "or", + "and": "and", + "compare": "Compare", + "change": "Change", + "sign_up": "Register", + "login": "Sign in", + "login_for_pricing": "Log in for pricing", + "logout": "Sign out", + "account": "Account", + "cart": "Cart", + "search": "Search", + "gift_cert": "Gift Certificates", + "save": "Save", + "share": "Share", + "delete": "Delete", + "public": "Public", + "private": "Private", + "view_all": "View All", + "all": "All", + "paginator": { "page_of": "Page {current} of {total}" }, + "pagination": "Pagination", + "prices_are_in": "Prices are in {CODE}", + "previous": "Previous", + "next": "Next", + "sorter": { + "relevance": "Relevance", + "sort_by": "Sort By:", + "featured": "Featured Items", + "top_sellers": "Best Selling", + "newest": "Newest Items", + "alpha_asc": "A to Z", + "alpha_desc": "Z to A", + "reviews": "By Review", + "price_asc": "Price: Ascending", + "price_desc": "Price: Descending" + }, + "loading": "Loading", + "month": "Month", + "day": "Day", + "year": "Year", + "short_months": { + "1": "Jan", "2": "Feb", "3": "Mar", "4": "Apr", + "5": "May", "6": "Jun", "7": "Jul", "8": "Aug", + "9": "Sep", "10": "Oct", "11": "Nov", "12": "Dec" + } + }, + "compare": { + "button": "Compare Products", + "header": "Comparing {products, plural, one {# Product} other {# Products}}", + "remove": "Remove", + "no_remove": "At least 2 products are needed to make a valid comparison.", + "add_to_cart": "Add to cart", + "no_compare": "You must select at least two products to compare" + }, + "category": { + "label": "Categories", + "product_label": "Filtered products", + "shop_by_price": "Shop By Price", + "shop_by_price_range_aria": "Price range from {from} to {to}", + "filter_price_range": "Price range:", + "add_cart_announcement": "The item has been added", + "reset": "Reset", + "filter_reset_announcement": "The filter has been reset", + "filter_select_announcement": "The filter has been applied", + "view_all": { "name": "All {category}" } + }, + "brand": { "label": "Brands", "view_brands": "View all brands" }, + "gift_certificate": { + "heading": "Gift Certificates", + "purchase": { "heading": "Purchase Gift Certificate" }, + "redeem": { + "heading": "Redeem Gift Certificate", + "intro": "To redeem a gift certificate at {store_name}, follow the simple steps below.", + "item1": "You need your unique gift certificate code, which is part of the gift certificate that was emailed to you as an attachment. It will look something like Z50-Y6K-COS-402.", + "item2": "Browse the store and add items to your cart as you normally would.", + "item3": "Click the 'View Cart' link to view the contents of your shopping cart.", + "item4": "Type your gift certificate code in to the 'Redeem Gift Certificate' box and click 'Go'." + }, + "balance": { + "heading": "Check Gift Certificate Balance", + "intro": "You can check the balance of a gift certificate by typing the code in to the box below." + } + }, + "create_account": { + "heading": "New Account", + "created": { + "heading": "Your account has been created", + "intro": "Thank you for creating your account at {store_name}. Your account details have been emailed to {email}", + "continue": "Continue Shopping" + }, + "recaptcha_title": "Google recaptcha" + }, + "login": { + "heading": "Sign in", + "field_email": "Email Address:", + "field_password": "Password:", + "submit_value": "Sign in", + "forgot_password": "Forgot your password?", + "new_customer": { + "heading": "New Customer?", + "intro": "Create an account with us and you'll be able to:", + "fact1": "Check out faster", + "fact2": "Save multiple shipping addresses", + "fact3": "Access your order history", + "fact4": "Track new orders", + "fact5": "Save items to your Wish List", + "create_account": "Create Account" + } + }, + "reset_password": { + "heading": "Reset Password", + "intro": "Fill in your email below to request a new password. An email will be sent to the address below containing a link to verify your email address." + }, + "account": { + "breadcrumb": "Your Account", + "nav": { + "overview": "Overview", + "orders": "Orders", + "returns": "Returns", + "messages": "Messages ({num_new_messages})", + "wishlists": "Wish Lists", + "recently_viewed": "Recently Viewed", + "settings": "Account Settings", + "addresses": "Addresses", + "payment_methods": "Payment Methods" + }, + "mobile_nav": { "messages": "Messages", "wishlists": "Wish Lists" }, + "addresses": { + "heading": "Addresses", + "add_heading": "Add New Address", + "primary": "Primary", + "phone": "Phone:", + "new_address": "New Address", + "no_addresses": "You currently have no addresses added to your profile" + }, + "messages": { + "heading": "Messages", + "customer_said": "You said:", + "merchant_said": "{store_name} said:" + }, + "orders": { + "heading": "Orders", + "none": "You haven't placed any orders with us. When you do, their status will appear on this page.", + "return": { + "already_returned": "This order contains no products that can be returned. Have you already returned all of the items within this order?", + "back_button": "Go back", + "order_item": "Item", + "item_price": "Price", + "quantity": "Qty To Return", + "why": "Why are you returning these items?", + "reason": "Return Reason", + "action": "Return Action", + "comments": "Comments" + }, + "gift_wrapping": "Gift wrapping:", + "refunded": "(Refunded)", + "refunded_quantity": "({qty} refunded)", + "return_item": "Return", + "return_items": "Return Items?", + "order_placed": "Order Placed", + "last_update": "Last Update", + "list": { + "order_number": "Order #{number}", + "product_details": "{num_products, plural, one {1 product} other {# products}} totaling {cost}" + }, + "details": { + "heading": "Order #{number}", + "order_contents": "Order Contents", + "ship_to": "Ship To", + "ship_to_multi": "Items shipped to {street}, {city}, {state}, {zip}, {country}", + "ship_to_multi_text": "Order will be shipped to multiple addresses", + "pickup_details": "Pickup Details", + "bill_to": "Bill To", + "how_to_pay": "Here's how to pay for your order:", + "order_details": "Order Details", + "order_total": "Order total:", + "order_status": "Order status:", + "payment_date": "Order date:", + "payment_method": "Payment method:", + "comments": "Order Comments", + "download_items": "Download Items", + "card_ending": "ending in {card}", + "shipments": { "date": "Date Shipped", "method": "Shipping Method", "link": "Tracking Link", "header": "Shipping Details" }, + "actions": "Actions", + "reorder": "Reorder", + "return": "Return", + "pickup": "Pickup Details", + "pickup_method": "Pickup method", + "in_store_pickup": "In-store pickup", + "print_invoice": "Print Invoice", + "phone": "Phone", + "email": "Email", + "opening_hours": "Opening hours" + }, + "downloads": { + "heading": "Order #{number} Downloads", + "download_files_below": "Below you can download the files for", + "expired_content": "File has expired", + "days_remaining": "{number, plural, one {1 day} other {# days}}", + "downloads_remaining": "{number, plural, one {1 download} other {# downloads}} remaining", + "days_or_downloads": "or {number} downloads", + "remaining": "remaining" + } + }, + "payment_methods": { + "heading": "Payment Methods", + "payment_method": "Payment method", + "bank_account": "Bank account", + "billing_address": "Billing address", + "card_ending_in": "ending in {last_four}", + "card_expiry": "{month}/{year}", + "new_payment_method": "Add new payment method", + "no_methods": "You currently have no payment methods added to your account", + "name_on_card": "Name on Card", + "credit_card_number": "Credit Card Number", + "expiration": "Expiration", + "cvv": "CVV", + "payment_icons_label": "Payment icons", + "card_types": { + "american_express": "American Express", + "credit_card": "Credit Card", + "dankort": "Dankort", + "diners_club": "Diners Club", + "discover": "Discover", + "jcb": "JCB", + "maestro": "Maestro", + "mastercard": "Mastercard", + "unionpay": "Union Pay", + "visa": "Visa" + }, + "ach": "ACH", + "sepa_direct_debit": "SEPA Direct Debit", + "paypal": "PayPal", + "billing_address_labels": { + "address_line_1": "Address Line 1", + "address_line_2": "Address Line 2", + "suburb_city": "Suburb/City", + "state_province": "State/Province", + "choose_state": "Choose a State", + "country": "Country", + "choose_country": "Choose a Country", + "zip_postcode": "Zip/Postcode" + } + }, + "returns": { + "heading": "Returns", + "instructions": "Return Instructions", + "error_no_qty": "Please select one or more items to return.", + "none": "You haven't placed any returns with us. When you do, they will appear on this page.", + "new_return": "New Return", + "from_order": "Return Items from Order #{id}", + "date_requested": "Return Requested", + "successful_heading": "Return Request Submitted", + "successful": "Your return was submitted successfully. We'll respond as soon as we can.", + "return": "Return to Orders", + "last_update": "Last Update", + "reason": "Return Reason", + "action": "Return Action", + "comments": "Your Comments", + "submit_request": "Submit Return Request", + "list": { "return_number": "Return #{id}", "product_details": "Returning {num_products}" }, + "status": { + "pending": "Pending", "received": "Received", "authorized": "Authorized", + "repaired": "Repaired", "refunded": "Refunded", "rejected": "Rejected", "cancelled": "Cancelled" + } + }, + "settings": { + "heading": "Account Settings", + "first_name": "First Name", + "last_name": "Last Name", + "email": "Email Address", + "company": "Company", + "phone": "Phone Number", + "old_password": "Old Password", + "new_password": "New Password", + "confirm_password": "Confirm Password", + "update_details": "Update Details" + }, + "recent_items": { "heading": "Recently Viewed", "no_items": "The items you've recently looked at on our site will appear below." }, + "wishlists": { + "heading": "Wish Lists", + "tag_line": "Fill in the form below to create a new Wish List. Click the \"Create Wish List\" button when you're done.", + "new": "New Wish List", + "you_have_none": "You have no Wish Lists, add one now.", + "empty_wishlist": "Your Wish List is empty. When you add items to your Wish List they will appear here.", + "num_items": "Items", + "add_item": "Add to Wish List", + "remove_item": "Remove Item", + "name": "Wish List Name", + "shared": "Shared", + "action": "Action", + "add": "Add Wish List", + "delete_all": "Delete All", + "edit": "Edit Wish List", + "view_heading": "Wish List: {name}", + "share_intro": "Share this Wish List with friends:", + "num_products": "{num_products, plural, one {1 product} other {# products}}", + "create": "Create Wish List", + "save": "Save Wish List", + "delete_alert": "Are you sure you want to delete your Wish List(s)? This action cannot be undone.", + "create_new": "Create New Wish List", + "add_to_default": "Add to My Wish List", + "enter_wishlist_name_error": "You must enter a wishlist name." + } + }, + "page": { + "label": "Quick Links", + "rss": { + "intro": "RSS feeds are used for syndicating regularly changing content on a web site, including this one.", + "heading": "RSS Syndication", + "blog": { + "heading": "Recent Blog Posts", + "intro": "The recent post feed contains the latest {limit} blog posts published on {store}.", + "rss": "Latest {limit} Blog Posts (RSS)", + "rss_atom": "Latest {limit} Blog Posts (Atom)" + }, + "products": { + "new": { + "heading": "New Products", + "intro": "The latest products feed contains the latest {limit} products added to {store}.", + "rss": "Latest {limit} New Products (RSS)", + "rss_atom": "Latest {limit} New Products (Atom)" + }, + "popular": { + "heading": "Popular Products", + "intro": "The popular products feed contains the top {limit} most popular products on {store} as rated by users.", + "rss": "Latest {limit} Popular Products (RSS)", + "rss_atom": "Latest {limit} Popular Products (Atom)" + }, + "featured": { + "heading": "Featured Products", + "intro": "The featured products feed contains the latest {limit} featured products on {store}.", + "rss": "Latest {limit} Featured Products (RSS)", + "rss_atom": "Latest {limit} Featured Products (Atom)" + } + }, + "search": { + "heading": "Product Searches", + "intro1": "Product search feeds allow you to save your custom product searches as syndication feed that will always update when there are new results.", + "intro2": "To create a product search feed, perform a standard search on {store} and at the bottom of the page click on one of the syndication options." + } + } + }, + "forms": { + "range": "You need to enter numbers only between: {limitTo} and {limitFrom}", + "contact_us": { + "full_name": "Full Name", + "email": "Email Address", + "company": "Company Name", + "phone": "Phone Number", + "order": "Order Number", + "rma": "RMA Number", + "question": "Comments/Questions", + "submit": "Submit Form", + "successful": "We've received your feedback and will respond shortly if required. Continue.", + "manual_captcha_instruction": "Please answer the question below for additional verification." + }, + "create_account": { "submit_value": "Create Account" }, + "new_password": { "heading": "Change Password", "password": "New Password", "password2": "Confirm Password", "submit_value": "Continue" }, + "address": { + "add": { "heading": "Add New Address", "description": "Use the form below to change any/all details of your shipping address. Click the 'Save Address' button when you are done." }, + "edit": { "heading": "Update Address" }, + "confirm_delete": "Are you sure you want to delete this address?", + "submit_value": "Save Address" + }, + "payment_methods": { + "add": { "heading": "Add Payment Method" }, + "edit": { "heading": "Update Payment Method" }, + "confirm_delete": "Are you sure you want to delete this payment method?", + "submit_value": "Save Payment Method", + "first_name": "First Name", + "last_name": "Last Name", + "company": "Company Name", + "phone": "Phone Number", + "address1": "Address Line 1", + "address2": "Address Line 2", + "city": "Suburb/City", + "country": "Country", + "state": "State/Province", + "postal_code": "Zip/Postcode", + "choose_country": "Choose a Country", + "default_instrument": "Default Payment Method" + }, + "gift_certificate": { + "purchase": { + "to_name": "Recipient's Name", + "to_email": "Recipient's Email", + "from_name": "Your Name", + "from_email": "Your Email", + "message": "Optional Message", + "amount": "Amount", + "theme": "Gift Certificate Theme", + "custom_range": "(Value must be between {min} and {max})", + "agree": "I understand that Gift Certificates expire after {days, plural, one {1 day} other {# days}}", + "agree2": "I agree that Gift Certificates are nonrefundable", + "preview": "Preview", + "preview_error": "There was a problem loading the preview. Please try again later.", + "submit_value": "Add Gift Certificate to Cart" + }, + "balance": { "code": "Gift Certificate Code", "submit_value": "Check Balance" } + }, + "inbox": { + "order": "Order:", + "order_display": "Order #{id} - Placed on {date} for {total}", + "subject": "Subject", + "message": "Message", + "submit_value": "Send Message", + "send_message": "Send a Message", + "clear_value": "Clear", + "no_orders": "Once you place an order you'll have full access to send messages from this page." + }, + "search": { + "query": "Search Keyword", + "show": "Show Search Form", + "hide": "Hide Search Form", + "title": "Advanced Search", + "price_range": "Price Range", + "by_price": "Search By Price", + "by_setting": "Search By Setting", + "search": "Search", + "free_shipping": { "title": "Free Shipping", "free": "Only Free Shipping", "paid": "Only Paid Shipping" }, + "featured_products": { "title": "Featured Products", "enabled": "Only Featured Products", "disabled": "Only Non-Featured Products" }, + "categories": "Categories", + "did_you_mean": "Did you mean:", + "your_search_for": "Your search for", + "no_match": "did not match any products or information.", + "suggestions": { "title": "Suggestions:", "line1": "Make sure all words are spelled correctly.", "line2": "Try different keywords.", "line3": "Try more general keywords." }, + "refine": "Refine Search", + "brand": "Brands", + "searchsubs": "Automatically search sub categories" + }, + "wishlist": { "name": "Wish List Name:", "public": "Share Wish List?" }, + "validate": { + "account": { + "edit": { "password": "You must enter your current password.", "first_name": "You must enter a first name.", "last_name": "You must enter a last name.", "phone": "You must enter a phone number." }, + "reorder": { "select_item": "Please select one or more items to reorder." }, + "inbox": { "order": "You must select an order.", "subject": "You must enter a subject.", "message": "You must enter a message." } + }, + "common": { "name": "You must enter your name.", "password": "You must enter a password.", "password_match": "Your passwords do not match.", "email_address": "Please use a valid email address, such as user@example.com." }, + "contact": { "email_address": "Please use a valid email address, such as user@example.com.", "question": "You must enter your question." }, + "gift": { "to_name": "You must enter a valid recipient name.", "to_email": "You must enter a valid recipient email.", "from_name": "You must enter your name.", "from_email": "You must enter a valid email.", "cert_theme": "You must select a gift certificate theme.", "agree_terms": "You must agree to these terms." }, + "payment_method": { "credit_card_number": "You must enter a valid credit card number.", "expiration": "You must enter a valid expiration date.", "name_on_card": "You must enter a name.", "cvv": "You must enter a valid cvv." }, + "reviews": { "rating": "The 'Rating' field cannot be blank.", "title": "The 'Review Subject' field cannot be blank.", "name": "The 'Name' field cannot be blank.", "comment": "The 'Comments' field cannot be blank." } + } + }, + "products": { + "current_stock": "Current Stock:", + "quantity": "Quantity:", + "change_product_options": "Change options for {name}", + "quantity_decrease": "Decrease Quantity of {name}", + "quantity_increase": "Increase Quantity of {name}", + "quantity_backordered": "{quantity} will be backordered", + "quantity_on_hand": "{quantity} ready to ship", + "quantity_error_message": "The quantity should contain only numbers", + "purchase_units": "{quantity, plural, =0{0 units} one {# unit} other {# units}}", + "max_purchase_quantity": "Maximum Purchase:", + "min_purchase_quantity": "Minimum Purchase:", + "related_products": "Related Products", + "top": "Most Popular Products", + "similar_by_views": "Customers Also Viewed", + "featured": "Featured Products", + "file_option_set": "Currently: {name}", + "new": "New Products", + "warranty": "Warranty Information", + "reviews": { + "hide": "Hide Reviews", + "new": "Write a Review", + "show": "Show Reviews", + "header": "{total, plural, =0{0 Reviews} one {# Review} other {# Reviews}}", + "link_to_review": "({total, plural, =0{No reviews yet} one {# review} other {# reviews}})", + "post_on_by": "Posted by { name } on { date }", + "rating_label": "Rating", + "select_rating": "Select Rating", + "anonymous_poster": "Unknown", + "rating_aria_label": "{rating_target} rating is {current_rating} of {max_rating}", + "rating": { "1": "1 star (worst)", "2": "2 stars", "3": "3 stars (average)", "4": "4 stars", "5": "5 stars (best)" }, + "write_a_review": "Write a Review", + "no_reviews": "No Reviews", + "form_write": { "name": "Name", "email": "Email", "subject": "Review Subject", "comments": "Comments", "submit_value": "Submit Review" } + }, + "videos": { "header": "Videos", "hide": "Hide Videos", "show": "Show Videos" }, + "add_to_cart": "Add to Cart", + "adding_to_cart": "Adding to cart\u2026", + "options": "Options", + "none": "None", + "sku": "SKU:", + "upc": "UPC:", + "condition": "Condition:", + "availability": "Availability:", + "swatch_option_announcement": "Selected {swatch_name} is", + "shipping": "Shipping:", + "shipping_fixed": "{amount} (Fixed Shipping Cost)", + "shipping_free": "Free Shipping", + "shipping_calculated": "Calculated at Checkout", + "sold_out": "Sold Out", + "out_of_stock_default_message": "Sold Out", + "pre_order": "Pre-Order Now", + "choose_options": "Choose Options", + "quick_view": "Quick view", + "compare": "Compare", + "max_filesize": "Maximum file size is", + "kilobytes_abbreviation": "KB", + "filetypes": "file types are", + "choose_an_option": "Please choose an option", + "select_one": "Please select one", + "description": "Description", + "price_with_tax": "(Inc. {tax_label})", + "price_without_tax": "(Ex. {tax_label})", + "including_tax": "Including Tax", + "excluding_tax": "Excluding Tax", + "weight": "Weight:", + "width": "Width:", + "height": "Height:", + "depth": "Depth:", + "measurement": { "metric": "cm", "imperial": "in" }, + "you_save_opening_text": "(You save", + "you_save_closing_bracket": ")", + "gift_wrapping": "Gift wrapping:", + "gift_wrapping_available": "Options available", + "quantity_min": "The minimum purchasable quantity is {quantity}", + "quantity_max": "The maximum purchasable quantity is {quantity}", + "bulk_pricing": { + "title": "Bulk Pricing:", + "view": "Buy in bulk and save", + "modal_title": "Bulk discount rates", + "instructions": "Below are the available bulk discount rates for each individual item when you purchase a certain amount", + "range": "Buy {min} {max, plural, =0{or above} other {- #}}", + "percent": "and get {discount} off", + "price": "and get {discount} off", + "fixed": "and pay only {discount} each", + "fixed_both": { "prefix": "and pay only", "suffix": "each" } + }, + "card_default_image_alt": "Image coming soon" + }, + "invoice": { + "for_order": "{name} Invoice for Order #{id}", + "phone": "Phone: {number}", + "email": "Email: {email}", + "order": "Order:", + "payment_method": "Payment Method:", + "order_date": "Order Date:", + "shipping_method": "Shipping Method:", + "order_items": "Order Items", + "qty": "Qty", + "code": "Code/SKU", + "shipping_address": "Shipping Address", + "fulfillment": "Fulfillment", + "digital": "Digital", + "shipping": "Shipping", + "pickup": "Pickup", + "product_name": "Product Name", + "price": "Price", + "total": "Total", + "comments": "Comments" + }, + "newsletter": { + "subscribe": "Subscribe to our newsletter", + "subscribe_intro": "Get the latest updates on new products and upcoming sales", + "subscribe_submit": "Subscribe", + "email_placeholder": "Your email address", + "subscribed_heading": "Thanks for Subscribing!", + "subscribed_heading_error": "Oops...", + "subscribed_message": "Thank you for joining our mailing list. You'll be sent the next issue of our newsletter shortly", + "unsubscribed_heading": "Unsubscribed!", + "unsubscribed_message": "You will no longer receive marketing emails from {store_name}" + }, + "social": { "connect": "Connect With Us" }, + "errors": { + "shipping_quote_failure": "There was a problem retrieving your shipping quote", + "state_error": "There was a problem retrieving states/provinces" + }, + "search": { + "errorMessage": "There was a problem retrieving your results. Please try again in a few seconds.", + "quick_search": { "input_label": "Search", "input_placeholder": "Search the store" }, + "results": { + "form_label": "Search Keyword:", + "form_button_text": "Search", + "count": "{ count, plural, one {# result} other {# results} } for '{ search_query }'", + "quick_count": "{ count, plural, one {# product result} other {# product results} } for '{ search_query }'", + "quick_count_live": "product results for", + "product_count": "Products ({count})", + "content_count": "News & Information ({count})" + }, + "faceted": { + "selected": { + "title": "Refine by", + "facet-label": "Applied filters", + "rating-label": "Rated {rating, plural, one {# Star} other {# Stars}} Or More", + "no-filters": "No filters applied", + "clear-all": "Clear all" + }, + "range": { "update": "Update", "min-placeholder": "Min.", "max-placeholder": "Max.", "min-description": "Enter the minimum price to filter products by", "max-description": "Enter the maximum price to filter products by" }, + "rating": { "and-up": "& up" }, + "label": "The following text field filters the results that follow as you type", + "toggleSection": "Toggle {title} filter section", + "clear": "Clear", + "more": "More", + "show-more": "Show More", + "show-less": "Show Less", + "show-filters": "Show Filters", + "hide-filters": "Hide Filters", + "browse-by": "Browse by" + }, + "error": { "empty_field": "Search field cannot be empty." }, + "tabs_accesibility_hint": "Use left and right arrows to navigate between tabs." + }, + "page_not_found": { "page_heading": "404 Error - Page not found", "message": "Uh oh, looks like the page you are looking for has moved or no longer exists." }, + "server_error": { "page_heading": "Something Went Wrong...", "message": "Store is currently unavailable, please try again in a few minutes" }, + "forbidden": { "page_heading": "Sorry! Please sign in to continue", "if_you_were_signed_in": "If you were signed in,", "sign_in": "please sign back in", "to_continue": ", to resume your work in a new session." }, + "sitemap": { "page_title": "Sitemap", "show_all_link": "Show All" }, + "text_truncate": { "view_more": "View more details", "view_less": "Hide details" }, + "maintenance": { "down": "Down for Maintenance" }, + "carousel": { + "arrow_and_dot_aria_label": "Go to slide [SLIDE_NUMBER] of [SLIDES_QUANTITY]", + "active_dot_aria_label": "active", + "content_announce_message": "You are currently on slide [SLIDE_NUMBER] of [SLIDES_QUANTITY]", + "play_pause_button_play": "Play", + "play_pause_button_pause": "Pause", + "play_pause_button_aria_play": "Play carousel", + "play_pause_button_aria_pause": "Pause carousel", + "slide_number": "Slide number {slide_number}" + }, + "validation_messages": { + "valid_email": "You must enter a valid email.", + "password": "You must enter a password.", + "password_match": "Your passwords do not match.", + "invalid_password": "Passwords must be at least 7 characters and contain both alphabetic and numeric characters.", + "field_not_blank": " field cannot be blank.", + "certificate_amount": "You must enter a gift certificate amount.", + "certificate_amount_range": "You must enter a certificate amount between [MIN] and [MAX]", + "price_min_evaluation": "Min. price must be less than max. price.", + "price_max_evaluation": "Min. price must be less than max. price.", + "price_min_not_entered": "Min. price is required.", + "price_max_not_entered": "Max. price is required.", + "price_invalid_value": "Input must be greater than 0.", + "invalid_gift_certificate": "Please enter your valid certificate code." + }, + "page_builder": { + "pdp_sale_badge_label": "On Sale!", + "pdp_sold_out_label": "Sold Out", + "pdp-sale-price-label": "Now:", + "pdp-non-sale-price-label": "Was:", + "pdp-retail-price-label": "MSRP:", + "pdp-custom-fields-tab-label": "Additional Information" + }, + "consent_manager": { + "data_collection_warning": "We use cookies (and other similar technologies) to collect data to improve your shopping experience.", + "accept_all_cookies": "Accept All Cookies", + "gdpr_settings": "Settings", + "data_collection_preferences": "Website Data Collection Preferences", + "manage_data_collection_preferences": "Manage Website Data Collection Preferences", + "use_data_by_cookies": " uses data collected by cookies and JavaScript libraries to improve your shopping experience.", + "data_categories_table": "The table below outlines how we use this data by category. To opt out of a category of data collection, select 'No' and save your preferences.", + "allow": "Allow", + "accept": "Accept", + "deny": "Deny", + "dismiss": "Dismiss", + "reject_all": "Reject all", + "category": "Category", + "purpose": "Purpose", + "functional_category": "Functional", + "functional_purpose": "Enables enhanced functionality, such as videos and live chat. If you do not allow these, then some or all of these functions may not work properly.", + "analytics_category": "Analytics", + "analytics_purpose": "Provide statistical information on site usage, e.g., web analytics so we can improve this website over time.", + "targeting_category": "Targeting", + "advertising_category": "Advertising", + "advertising_purpose": "Used to create profiles or personalize content to enhance your shopping experience.", + "essential_category": "Essential", + "esential_purpose": "Essential for the site and any requested services to work, but do not perform any additional or secondary function.", + "yes": "Yes", + "no": "No", + "not_available": "N/A", + "cancel": "Cancel", + "save": "Save", + "back_to_preferences": "Back to Preferences", + "close_without_changes": "You have unsaved changes to your data collection preferences. Are you sure you want to close without saving?", + "unsaved_changes": "You have unsaved changes", + "by_using": "By using our website, you're agreeing to our", + "agree_on_data_collection": "By using our website, you're agreeing to the collection of data as described in our ", + "change_preferences": "You can change your preferences at any time", + "cancel_dialog_title": "Are you sure you want to cancel?", + "privacy_policy": "Privacy Policy", + "allow_category_tracking": "Allow [CATEGORY_NAME] tracking", + "disallow_category_tracking": "Disallow [CATEGORY_NAME] tracking" + } +}