From b17acc9dd0ca35a0b92c3136363c7f0dbefedfc1 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 19:38:57 +0000 Subject: [PATCH 01/42] Feature: HF-24 introduce defaultStringifyCurrency --- src/format/format.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/format/format.ts b/src/format/format.ts index e605209f5d..da0922cda1 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -229,3 +229,20 @@ export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: st return result } + +/** + * Default implementation of the `stringifyCurrency` config option. + * + * Returning `undefined` instructs the formatter to fall through to the + * built-in number formatter, preserving HyperFormula's zero-dependency + * default behavior. Replace this default by setting the + * [`stringifyCurrency`](../../api/interfaces/configparams.md#stringifycurrency) + * config option. + * + * @param {number} _value - the numeric value to format (unused in default). + * @param {string} _formatArg - the format string passed to `TEXT` (unused in default). + * @returns {Maybe} `undefined` — caller should fall through to the built-in formatter. + */ +export function defaultStringifyCurrency(_value: number, _formatArg: string): Maybe { + return undefined +} From 7a0f836ce3527416ad358503420e904917f629de Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 19:52:44 +0000 Subject: [PATCH 02/42] Feature: HF-24 declare stringifyCurrency on ConfigParams interface --- src/ConfigParams.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ConfigParams.ts b/src/ConfigParams.ts index ad7344a3b1..a4d94b155f 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -310,6 +310,21 @@ export interface ConfigParams { * @category Date and Time */ stringifyDuration: (time: SimpleTime, timeFormat: string) => Maybe, + /** + * Sets a function that converts numeric values into currency-formatted strings. + * + * The function receives the raw value and the format string passed to `TEXT` + * and should return a string or `undefined`. Returning `undefined` lets the + * formatter fall through to the built-in number formatter, so a callback that + * recognizes only some format strings can safely opt out of the rest. + * + * For more information, see the [Date and time handling guide](/guide/date-and-time-handling.md#currency-integration). + * + * @default defaultStringifyCurrency + * + * @category Number + */ + stringifyCurrency: (value: number, currencyFormat: string) => Maybe, /** * When set to `false`, no rounding happens, and numbers are equal if and only if they are of truly identical value. * From 19ebe2677210cfbc1da7be496e89997cca97164a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 19:58:34 +0000 Subject: [PATCH 03/42] Feature: HF-24 wire stringifyCurrency through Config class --- src/Config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Config.ts b/src/Config.ts index d47323384f..8e8a924eee 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -15,7 +15,7 @@ import {defaultParseToDateTime} from './DateTimeDefault' import {DateTime, instanceOfSimpleDate, SimpleDate, SimpleDateTime, SimpleTime} from './DateTimeHelper' import {AlwaysDense, ChooseAddressMapping} from './DependencyGraph/AddressMapping/ChooseAddressMappingPolicy' import {ConfigValueEmpty, ExpectedValueOfTypeError} from './errors' -import {defaultStringifyDateTime, defaultStringifyDuration} from './format/format' +import {defaultStringifyCurrency, defaultStringifyDateTime, defaultStringifyDuration} from './format/format' import {checkLicenseKeyValidity, LicenseKeyValidityState} from './helpers/licenseKeyValidator' import {HyperFormula} from './HyperFormula' import {TranslationPackage} from './i18n' @@ -59,6 +59,7 @@ export class Config implements ConfigParams, ParserConfig { smartRounding: true, stringifyDateTime: defaultStringifyDateTime, stringifyDuration: defaultStringifyDuration, + stringifyCurrency: defaultStringifyCurrency, timeFormats: ['hh:mm', 'hh:mm:ss.sss'], thousandSeparator: '', undoLimit: 20, @@ -120,6 +121,8 @@ export class Config implements ConfigParams, ParserConfig { /** @inheritDoc */ public readonly stringifyDuration: (time: SimpleTime, formatArg: string) => Maybe /** @inheritDoc */ + public readonly stringifyCurrency: (value: number, currencyFormat: string) => Maybe + /** @inheritDoc */ public readonly precisionEpsilon: number /** @inheritDoc */ public readonly precisionRounding: number @@ -195,6 +198,7 @@ export class Config implements ConfigParams, ParserConfig { precisionRounding, stringifyDateTime, stringifyDuration, + stringifyCurrency, smartRounding, timeFormats, thousandSeparator, @@ -243,6 +247,7 @@ export class Config implements ConfigParams, ParserConfig { this.parseDateTime = configValueFromParam(parseDateTime, 'function', 'parseDateTime') this.stringifyDateTime = configValueFromParam(stringifyDateTime, 'function', 'stringifyDateTime') this.stringifyDuration = configValueFromParam(stringifyDuration, 'function', 'stringifyDuration') + this.stringifyCurrency = configValueFromParam(stringifyCurrency, 'function', 'stringifyCurrency') this.translationPackage = HyperFormula.getLanguage(this.language) this.errorMapping = this.translationPackage.buildErrorMapping() this.nullDate = configValueFromParamCheck(nullDate, instanceOfSimpleDate, 'IDate', 'nullDate') From e2b2b52f69a3601b39010367232511132788b094 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 20:08:54 +0000 Subject: [PATCH 04/42] Feature: HF-24 dispatch stringifyCurrency in format() --- src/format/format.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/format/format.ts b/src/format/format.ts index da0922cda1..5bcce5abb2 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -19,6 +19,10 @@ export function format(value: number, formatArg: string, config: Config, dateHel if (tryDuration !== undefined) { return tryDuration } + const tryCurrency = config.stringifyCurrency(value, formatArg) + if (tryCurrency !== undefined) { + return tryCurrency + } const expression = parseForNumberFormat(formatArg) if (expression !== undefined) { return numberFormat(expression.tokens, value) From 4d1a7a93a60fa4ebbb4c0851d110ba327f3bbe5d Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 20:41:51 +0000 Subject: [PATCH 05/42] Docs: HF-24 add Currency integration section to date-and-time guide --- docs/guide/date-and-time-handling.md | 132 +++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 752d50170e..d0ca1da15a 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -96,6 +96,138 @@ const data = [["31st Jan 00", "2nd Jun 01", "=B1-A1"]]; And now, HyperFormula recognizes these values as valid dates and can operate on them. +## Currency integration + +By default, the `TEXT` function recognizes a limited set of currency-looking formats such as `"$#,##0.00"` via the built-in number formatter. When you need richer, locale-aware currency output — for example `"[$€-2] #,##0.00"` (EUR with German grouping) or `"[$zł-415] #,##0.00"` (PLN, locale `pl-PL`) — provide a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback. + +HyperFormula itself ships with **no currency data** and **no currency library dependency**. You choose how to format: native `Intl.NumberFormat`, a third-party library, or a hand-rolled lookup table. The callback receives the raw number and the Excel format string and returns either a formatted string or `undefined` (to fall through to the built-in formatter). + +### Example: `Intl.NumberFormat` adapter (zero dependencies) + +This example maps a small but representative subset of Excel currency format strings onto the native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) API. + +```javascript +// Minimal Excel-format-string → Intl.NumberFormat adapter. +// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. + +const LCID_TO_LOCALE = { + '-409': { locale: 'en-US', currency: 'USD' }, // USD + '-2': { locale: 'de-DE', currency: 'EUR' }, // EUR (generic) + '-411': { locale: 'ja-JP', currency: 'JPY' }, // JPY + '-415': { locale: 'pl-PL', currency: 'PLN' }, // PLN + '-809': { locale: 'en-GB', currency: 'GBP' }, // GBP +} + +const CURRENCY_RULES = [ + // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency + { + pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, + build: (match) => { + const lcid = '-' + match[2] + const fractionDigits = (match[3] || '.').length - 1 + const entry = LCID_TO_LOCALE[lcid] || { locale: 'en-US', currency: 'USD' } + return new Intl.NumberFormat(entry.locale, { + style: 'currency', + currency: entry.currency, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + }, + }, + // $#,##0.00 — USD shorthand + { + pattern: /^\$#,##0(\.0+)?$/, + build: (match) => new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: (match[1] || '.').length - 1, + maximumFractionDigits: (match[1] || '.').length - 1, + }), + }, + // #,##0.00 "SYM" — trailing quoted symbol (e.g. zł, €) + { + pattern: /^#,##0(\.0+)?\s+"([^"]+)"$/, + build: (match) => { + const fractionDigits = (match[1] || '.').length - 1 + const symbol = match[2] + const localeBySymbol = { 'zł': 'pl-PL', '€': 'de-DE', '£': 'en-GB', '¥': 'ja-JP' } + const locale = localeBySymbol[symbol] || 'en-US' + const nf = new Intl.NumberFormat(locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + return { format: (value) => `${nf.format(value)} ${symbol}` } + }, + }, +] + +// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses +function tryAccountingFormat(value, format) { + const sections = format.split(';') + if (sections.length !== 2) return undefined + const isNegative = value < 0 + const section = sections[isNegative ? 1 : 0] + const parenMatch = /^\(\$#,##0(\.0+)?\)$/.exec(section) + const plainMatch = /^\$#,##0(\.0+)?$/.exec(section) + if (!parenMatch && !plainMatch) return undefined + const fractionDigits = ((parenMatch || plainMatch)[1] || '.').length - 1 + const nf = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + const formatted = nf.format(Math.abs(value)) + return isNegative && parenMatch ? `(${formatted})` : formatted +} + +export const customStringifyCurrency = (value, currencyFormat) => { + const accounting = tryAccountingFormat(value, currencyFormat) + if (accounting !== undefined) return accounting + + for (const rule of CURRENCY_RULES) { + const match = rule.pattern.exec(currencyFormat) + if (match) return rule.build(match).format(value) + } + // Not a recognized currency format — let HyperFormula fall through + // to the built-in number formatter. + return undefined +} +``` + +Then plug it into your [configuration options](configuration-options.md): + +```javascript +const options = { + stringifyCurrency: customStringifyCurrency, +} + +const hf = HyperFormula.buildFromArray([ + [1234.5, '=TEXT(A1, "[$€-2] #,##0.00")'], + [12345.5, '=TEXT(A2, "[$zł-415] #,##0.00")'], + [-1234.5, '=TEXT(A3, "$#,##0.00;($#,##0.00)")'], +], options) + +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })) // "12 345,50 zł" +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" +``` + +### When to swap in a library + +The adapter above covers six common Excel format shapes in under one page of code. If you need: + +- Arbitrary Excel-style format strings beyond this subset, +- Precision-safe arithmetic on currency values (e.g. cents as integers), +- ISO 4217 currency metadata for dozens of currencies, + +consider wrapping [`Dinero.js` v2](https://v2.dinerojs.com/) or your own format library inside the callback. The contract is the same: `(value: number, currencyFormat: string) => string | undefined`. Return `undefined` for any format string you don't want to handle and HyperFormula will fall back to its built-in number formatter. + +### Related configuration + +- [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol) — governs how HyperFormula **parses** currency literals in input (e.g. `"$100"` → `100`). It is **independent** of `stringifyCurrency`, which governs TEXT output. +- [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) / [`stringifyDuration`](../api/interfaces/configparams.md#stringifyduration) — sister callbacks for date and duration formatting. + ## Demo ::: example #example1 --html 1 --css 2 --js 3 --ts 4 From 5124c128e64d0d8abdede9be168d3cc73c1afd26 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 20:42:48 +0000 Subject: [PATCH 06/42] Docs: HF-24 changelog entry for stringifyCurrency --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 683e1da144..cc0a88e229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added `maxPendingLazyTransformations` configuration option to control memory usage by limiting accumulated transformations before cleanup. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Added a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) - Added a new function: SEQUENCE. [#1645](https://github.com/handsontable/hyperformula/pull/1645) +- Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1572](https://github.com/handsontable/hyperformula/issues/1572) ### Fixed From ac5dc8913484694e7e2db16256dcf9e213d8c334 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 21:22:13 +0000 Subject: [PATCH 07/42] Fix: HF-24 correct CHANGELOG issue ref and clarify docs adapter - Replace wrong #1572 with correct #1145 (TEXT currency formats issue) - Add parser limitation note to trailing-quote adapter rule - Add NBSP note above console.log output example block --- CHANGELOG.md | 2 +- docs/guide/date-and-time-handling.md | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0a88e229..ae7d6e5e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added `maxPendingLazyTransformations` configuration option to control memory usage by limiting accumulated transformations before cleanup. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Added a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) - Added a new function: SEQUENCE. [#1645](https://github.com/handsontable/hyperformula/pull/1645) -- Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1572](https://github.com/handsontable/hyperformula/issues/1572) +- Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) ### Fixed diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index d0ca1da15a..a19b17e463 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -144,7 +144,10 @@ const CURRENCY_RULES = [ maximumFractionDigits: (match[1] || '.').length - 1, }), }, - // #,##0.00 "SYM" — trailing quoted symbol (e.g. zł, €) + // #,##0.00 "SYM" — trailing quoted symbol (e.g. zł, €). + // Note: HyperFormula's formula parser does not accept embedded double quotes + // inside TEXT format strings. This rule is illustrative for callback usage + // outside TEXT — to format PLN through TEXT, prefer "[$zł-415] #,##0.00". { pattern: /^#,##0(\.0+)?\s+"([^"]+)"$/, build: (match) => { @@ -207,7 +210,11 @@ const hf = HyperFormula.buildFromArray([ [12345.5, '=TEXT(A2, "[$zł-415] #,##0.00")'], [-1234.5, '=TEXT(A3, "$#,##0.00;($#,##0.00)")'], ], options) +``` + +Note: the actual return values from `Intl.NumberFormat` use non-breaking spaces (U+00A0) as locale-appropriate separators. The comments above show them as regular spaces for readability. Be aware when comparing strings programmatically. +```javascript console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })) // "12 345,50 zł" console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" From f47ca12c87359c87f53a87cbd7e2730a73cd5fbe Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 29 Apr 2026 10:21:12 +0000 Subject: [PATCH 08/42] Docs: HF-24 strip {type} JSDoc tags from defaultStringifyCurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per code review — TypeScript signature already declares parameter and return types, so {type} brackets in JSDoc are redundant noise and inconsistent with the sibling exported functions in this file (defaultStringifyDuration, defaultStringifyDateTime have no JSDoc type tags). --- src/format/format.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/format/format.ts b/src/format/format.ts index 5bcce5abb2..9c1a9906b4 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -243,9 +243,9 @@ export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: st * [`stringifyCurrency`](../../api/interfaces/configparams.md#stringifycurrency) * config option. * - * @param {number} _value - the numeric value to format (unused in default). - * @param {string} _formatArg - the format string passed to `TEXT` (unused in default). - * @returns {Maybe} `undefined` — caller should fall through to the built-in formatter. + * @param _value - the numeric value to format (unused in default). + * @param _formatArg - the format string passed to `TEXT` (unused in default). + * @returns `undefined` — caller should fall through to the built-in formatter. */ export function defaultStringifyCurrency(_value: number, _formatArg: string): Maybe { return undefined From 4a48ea718a7c530f13edee915ba26be76404fcb5 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 8 May 2026 11:57:47 +0000 Subject: [PATCH 09/42] Docs: HF-24 align stringifyCurrency JSDoc @category with sibling stringify callbacks --- src/ConfigParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ConfigParams.ts b/src/ConfigParams.ts index a4d94b155f..93c244e9df 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -322,7 +322,7 @@ export interface ConfigParams { * * @default defaultStringifyCurrency * - * @category Number + * @category Date and Time */ stringifyCurrency: (value: number, currencyFormat: string) => Maybe, /** From fe2fb44b4e2c38b286b0de76af09bf1cc5488607 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sat, 9 May 2026 02:17:02 +0000 Subject: [PATCH 10/42] Chore: HF-24 retrigger codecov upload From 39967c81a4c20ed805e6b69fc3e3c5c672847be2 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 03:40:22 +0000 Subject: [PATCH 11/42] Docs: HF-24 align currency-integration text with PR body (drop count claim, mention U+202F NBSP) --- docs/guide/date-and-time-handling.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index a19b17e463..4f7c7cb96a 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -212,7 +212,7 @@ const hf = HyperFormula.buildFromArray([ ], options) ``` -Note: the actual return values from `Intl.NumberFormat` use non-breaking spaces (U+00A0) as locale-appropriate separators. The comments above show them as regular spaces for readability. Be aware when comparing strings programmatically. +Note: the actual return values from `Intl.NumberFormat` use non-breaking spaces as locale-appropriate separators — typically U+00A0 (regular NBSP), but modern ICU/CLDR also emit U+202F (narrow NBSP) for some locales, e.g. `pl-PL` digit grouping. The comments above show both as regular spaces for readability. Be aware when comparing strings programmatically; normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. ```javascript console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" @@ -222,7 +222,7 @@ console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" ### When to swap in a library -The adapter above covers six common Excel format shapes in under one page of code. If you need: +The adapter above covers a small but representative subset of Excel currency format strings (LCID-tagged, USD shorthand, accounting two-section) in under one page of code, with a fall-through path for everything else. If you need: - Arbitrary Excel-style format strings beyond this subset, - Precision-safe arithmetic on currency values (e.g. cents as integers), From 10be7fe34138df643129a34719d46b103008bd77 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 03:51:10 +0000 Subject: [PATCH 12/42] Docs: HF-24 final cross-doc polish (TEXT xref, currency callback diff entry, embedded-quote nuance, adapter guard) --- docs/guide/built-in-functions.md | 2 +- docs/guide/date-and-time-handling.md | 1 + docs/guide/known-limitations.md | 1 + docs/guide/list-of-differences.md | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index 5798f0404d..ee027e0e29 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -531,7 +531,7 @@ Total number of functions: **{{ $page.functionsCount }}** | SPLIT | Divides the provided text using the space character as a separator and returns the substring at the zero-based position specified by the second argument.
`SPLIT("Lorem ipsum", 0) -> "Lorem"`
`SPLIT("Lorem ipsum", 1) -> "ipsum"` | SPLIT(Text, Index) | | SUBSTITUTE | Returns string where occurrences of Old_text are replaced by New_text. Replaces only specific occurrence if last parameter is provided. | SUBSTITUTE(Text, Old_text, New_text, [Occurrence]) | | T | Returns text if given value is text, empty string otherwise. | T(Value) | -| TEXT | Converts a number into text according to a given format.
By default, accepts the same formats that can be passed to the [`dateFormats`](../api/interfaces/configparams.md#dateformats) option, but can be further customized with the [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) option. | TEXT(Number, Format) | +| TEXT | Converts a number into text according to a given format.
By default, accepts the same formats that can be passed to the [`dateFormats`](../api/interfaces/configparams.md#dateformats) option, but can be further customized with the [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) and [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) options. | TEXT(Number, Format) | | TEXTJOIN | Joins text from multiple strings and/or ranges with a delimiter. Supports array/range delimiters that cycle through gaps. When ignore_empty is TRUE, empty strings are skipped. Returns #VALUE! if result exceeds 32,767 characters. | TEXTJOIN(Delimiter, Ignore_empty, Text1, [Text2, ...]) | | TRIM | Strips extra spaces from text. | TRIM("Text") | | UNICHAR | Returns the character created by using provided code point. | UNICHAR(Number) | diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 4f7c7cb96a..d4d35851ec 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -185,6 +185,7 @@ function tryAccountingFormat(value, format) { } export const customStringifyCurrency = (value, currencyFormat) => { + if (typeof currencyFormat !== 'string') return undefined const accounting = tryAccountingFormat(value, currencyFormat) if (accounting !== undefined) return accounting diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 3da1d2eece..6b9eebee81 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,6 +38,7 @@ you can't compare the arguments in a formula like this: * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. +* The TEXT function does not accept embedded double-quote literals in the format string (e.g., `=TEXT(A1, "#,##0.00 ""zł""")` fails at parse time). Use the LCID-tagged form (`[$zł-415] #,##0.00`) or supply a custom [`stringifyCurrency`](configuration-options.md#stringifycurrency) callback that handles such formats outside the parser. ### OFFSET function diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 2ba4e9479a..8c59c67d64 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -34,7 +34,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | Applying a scalar value to a function taking range | COLUMNS(A1) | `CellRangeExpected` error. | Treats the element as length-1 range. Returns 1 for the example. | Same as Google Sheets. | | Coercion of explicit arguments | VARP(2, 3, 4, TRUE(), FALSE(), "1",) | 1.9592, based on the behavior of Microsoft Excel. | GoogleSheets implementation is not consistent with the standard (see also `VAR.S`, `STDEV.P`, and `STDEV.S` function.) | 1.9592 | | Ranges created with `:` | A1:A2

A$1:$A$2

A:C

1:2

Sheet1!A1:A2 | Allowed ranges consist of two addresses (A1:B5), columns (A:C) or rows (3:5).
They cannot be mixed or contain named expressions. | Everything allowed. | Same as Google Sheets. | -| Formatting inside the TEXT function | TEXT(A1,"dd-mm-yy")

TEXT(A1,"###.###”) | Not all formatting options are supported,
e.g., only some date formatting options: (`hh`, `mm`, `ss`, `am`, `pm`, `a`, `p`, `dd`, `yy`, and `yyyy`).

No currency formatting inside the TEXT function. | A wide variety of options for string formatting is supported. | Same as Google Sheets. | +| Formatting inside the TEXT function | TEXT(A1,"dd-mm-yy")

TEXT(A1,"###.###”) | Not all formatting options are supported,
e.g., only some date formatting options: (`hh`, `mm`, `ss`, `am`, `pm`, `a`, `p`, `dd`, `yy`, and `yyyy`).

Currency formatting is opt-in via the [`stringifyCurrency`](date-and-time-handling.md#currency-integration) callback; without it, currency format strings fall through to the built-in number formatter.

Embedded double-quote literals (e.g. `#,##0.00 "zł"`) are not accepted by the parser; use the LCID-tagged form (`[$zł-415] #,##0.00`) instead. | A wide variety of options for string formatting is supported. | Same as Google Sheets. | | Cell references inside inline arrays | ={A1, A2} | The array's value is calculated but not updated when the cells' values change. | The array's value is calculated and updated when the cells' values change. | ERROR: invalid array | | SPLIT function | =SPLIT("Lorem ipsum dolor", 0) | This function works differently from Google Sheets version but should be sufficient to achieve the same functionality in most scenarios. Read SPLIT function description on [the Built-in Functions page](built-in-functions.md#text). | Different syntax and return value. | No such function. | | DATEVALUE function | =DATEVALUE("25/02/1991") | Type of the returned value: `CellValueDetailedType.NUMBER_DATE` (compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard) | Cell auto-formatted as **regular number** | Cell auto-formatted as **regular number** | From ca91f3aa7754794f0821b257e9537c5126192f86 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 04:32:42 +0000 Subject: [PATCH 13/42] Docs: HF-24 wrap NBSP note in :::tip callout for discoverability --- docs/guide/date-and-time-handling.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index d4d35851ec..df4be58890 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -213,7 +213,9 @@ const hf = HyperFormula.buildFromArray([ ], options) ``` -Note: the actual return values from `Intl.NumberFormat` use non-breaking spaces as locale-appropriate separators — typically U+00A0 (regular NBSP), but modern ICU/CLDR also emit U+202F (narrow NBSP) for some locales, e.g. `pl-PL` digit grouping. The comments above show both as regular spaces for readability. Be aware when comparing strings programmatically; normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. +::: tip +The actual return values from `Intl.NumberFormat` use non-breaking spaces as locale-appropriate separators — typically U+00A0 (regular NBSP), but modern ICU/CLDR also emit U+202F (narrow NBSP) for some locales, e.g. `pl-PL` digit grouping. The comments above show both as regular spaces for readability. Be aware when comparing strings programmatically; normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. +::: ```javascript console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" From 08f02673f59ff5ac89f1477730d56e759c8b02fb Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 05:45:27 +0000 Subject: [PATCH 14/42] Docs: HF-24 redesign Currency integration for friction elimination - Add typed contract signature block at top - Add Minimal example subsection (3-line callback for fresh-user contract) - Add Default behavior subsection (explains defaultStringifyCurrency) - Add Error behavior subsection (callback exception propagation) - Add MS-LCID specification link in adapter intro - Drop trailing-quote rule from CURRENCY_RULES (not callable from TEXT) - Move NBSP tip below console.log output (was between config and output) --- docs/guide/date-and-time-handling.md | 62 +++++++++++++++++----------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index df4be58890..428b623929 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -100,11 +100,43 @@ And now, HyperFormula recognizes these values as valid dates and can operate on By default, the `TEXT` function recognizes a limited set of currency-looking formats such as `"$#,##0.00"` via the built-in number formatter. When you need richer, locale-aware currency output — for example `"[$€-2] #,##0.00"` (EUR with German grouping) or `"[$zł-415] #,##0.00"` (PLN, locale `pl-PL`) — provide a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback. -HyperFormula itself ships with **no currency data** and **no currency library dependency**. You choose how to format: native `Intl.NumberFormat`, a third-party library, or a hand-rolled lookup table. The callback receives the raw number and the Excel format string and returns either a formatted string or `undefined` (to fall through to the built-in formatter). +HyperFormula itself ships with **no currency data** and **no currency library dependency**. You choose how to format: native `Intl.NumberFormat`, a third-party library, or a hand-rolled lookup table. + +The callback contract: + +```ts +stringifyCurrency: (value: number, currencyFormat: string) => string | undefined +``` + +The function receives the raw number and the format string passed to `TEXT`. Return a formatted string to override the built-in formatter, or `undefined` to fall through to it. + +### Minimal example + +```javascript +// Recognize "$..."-prefixed formats and ignore the rest: +const stringifyCurrency = (value, fmt) => + fmt.startsWith('$') ? `$${value.toFixed(2)}` : undefined + +const hf = HyperFormula.buildFromArray([ + [1234.5, '=TEXT(A1, "$#,##0.00")'], +], { stringifyCurrency }) + +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "$1234.50" +``` + +This callback handles `$`-prefixed formats and falls through (returns `undefined`) for everything else. Dates, durations, and unrecognized formats continue through HyperFormula's existing dispatch chain. + +### Default behavior + +If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input — the built-in number formatter then handles `$`-prefixed formats and other Excel format strings as before. The callback is purely additive; leaving it unset preserves the existing `TEXT` behavior bit-for-bit. + +### Error behavior + +If your callback throws, HyperFormula propagates the exception. Wrap your formatter in `try/catch` if it can fail, and return `undefined` as the opt-out signal for unsupported formats — throwing is reserved for unexpected errors. ### Example: `Intl.NumberFormat` adapter (zero dependencies) -This example maps a small but representative subset of Excel currency format strings onto the native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) API. +This adapter handles a representative subset of Excel currency format strings using native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). Extend the `LCID_TO_LOCALE` map to cover more locales — see the [MS-LCID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) specification for canonical identifiers. ```javascript // Minimal Excel-format-string → Intl.NumberFormat adapter. @@ -144,24 +176,6 @@ const CURRENCY_RULES = [ maximumFractionDigits: (match[1] || '.').length - 1, }), }, - // #,##0.00 "SYM" — trailing quoted symbol (e.g. zł, €). - // Note: HyperFormula's formula parser does not accept embedded double quotes - // inside TEXT format strings. This rule is illustrative for callback usage - // outside TEXT — to format PLN through TEXT, prefer "[$zł-415] #,##0.00". - { - pattern: /^#,##0(\.0+)?\s+"([^"]+)"$/, - build: (match) => { - const fractionDigits = (match[1] || '.').length - 1 - const symbol = match[2] - const localeBySymbol = { 'zł': 'pl-PL', '€': 'de-DE', '£': 'en-GB', '¥': 'ja-JP' } - const locale = localeBySymbol[symbol] || 'en-US' - const nf = new Intl.NumberFormat(locale, { - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }) - return { format: (value) => `${nf.format(value)} ${symbol}` } - }, - }, ] // Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses @@ -213,16 +227,16 @@ const hf = HyperFormula.buildFromArray([ ], options) ``` -::: tip -The actual return values from `Intl.NumberFormat` use non-breaking spaces as locale-appropriate separators — typically U+00A0 (regular NBSP), but modern ICU/CLDR also emit U+202F (narrow NBSP) for some locales, e.g. `pl-PL` digit grouping. The comments above show both as regular spaces for readability. Be aware when comparing strings programmatically; normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. -::: - ```javascript console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })) // "12 345,50 zł" console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" ``` +::: tip +The output values above contain non-breaking spaces (U+00A0 or U+202F depending on locale and ICU/CLDR version) as locale-appropriate separators. The comments show them as regular spaces for readability. When comparing programmatically, normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. +::: + ### When to swap in a library The adapter above covers a small but representative subset of Excel currency format strings (LCID-tagged, USD shorthand, accounting two-section) in under one page of code, with a fall-through path for everything else. If you need: From 79abac4064b482d706fe4ee0902833c7bc033526 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 06:58:01 +0000 Subject: [PATCH 15/42] Fix: HF-24 dispatch stringifyCurrency before stringifyDateTime to prevent letter-format hijack The previous order (DateTime -> Duration -> Currency) let parseForDateTimeFormat greedily match characters D, M, S, Y, H inside currency format strings. Formats like '[$USD-409] #,##0.00' or 'USD #,##0.00' were converted to '[$US9-409] #,##0.00' before the user-supplied stringifyCurrency callback could intercept them. Currency dispatch now runs first. The default callback returns undefined for every input, so the existing date/time/duration/number-format chain is preserved bit-for-bit when stringifyCurrency is not set. Found by Codex review (codex-cli 0.130.0, base develop, max effort). --- src/format/format.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/format/format.ts b/src/format/format.ts index 9c1a9906b4..b78cc00af1 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -11,6 +11,16 @@ import {Maybe} from '../Maybe' import {FormatToken, parseForDateTimeFormat, parseForNumberFormat, TokenType} from './parser' export function format(value: number, formatArg: string, config: Config, dateHelper: DateTimeHelper): RawScalarValue { + // Currency callback runs first so a user-supplied stringifyCurrency can + // intercept LCID-tagged or bare-letter currency formats before the + // date/time parser greedily consumes characters like 'D', 'M', 'S', 'Y' + // (e.g. '[$USD-409] #,##0.00' would otherwise become '[$US9-409] #,##0.00'). + // The default callback returns undefined for every input, preserving the + // existing dispatch path bit-for-bit when stringifyCurrency is not set. + const tryCurrency = config.stringifyCurrency(value, formatArg) + if (tryCurrency !== undefined) { + return tryCurrency + } const tryDateTime = config.stringifyDateTime(dateHelper.numberToSimpleDateTime(value), formatArg) // default points to defaultStringifyDateTime() if (tryDateTime !== undefined) { return tryDateTime @@ -19,10 +29,6 @@ export function format(value: number, formatArg: string, config: Config, dateHel if (tryDuration !== undefined) { return tryDuration } - const tryCurrency = config.stringifyCurrency(value, formatArg) - if (tryCurrency !== undefined) { - return tryCurrency - } const expression = parseForNumberFormat(formatArg) if (expression !== undefined) { return numberFormat(expression.tokens, value) From e23818a52dad2d14a914f0eb50b571119d8f19a1 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 07:32:35 +0000 Subject: [PATCH 16/42] Fix: HF-24 skip date dispatch for LCID-tagged currency formats defaultStringifyDateTime now returns undefined when formatArg contains Excel's LCID-tagged currency notation [$SYMBOL-LCID]. Without this guard, parseForDateTimeFormat greedily consumed D/M/S/Y/H letters inside the currency code, mangling output even when a user-supplied stringifyCurrency callback returned undefined for opt-out. Before: TEXT(100, '[$USD-409] #,##0.00') with partial callback -> '[$US9-409] #,##0.00' (D->9 mangle) After: TEXT(100, '[$USD-409] #,##0.00') with partial callback -> '[$USD-41009] #,##0.00' (USD preserved, falls through to numberFormat) Excel never uses [$...] for date formats, so the guard is unambiguous. Found by Codex re-review (after first dispatch reorder fix d119b4cea). --- src/format/format.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/format/format.ts b/src/format/format.ts index b78cc00af1..834af0c728 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -153,6 +153,15 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M } export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: string): Maybe { + // Skip date/time interpretation for currency formats marked with Excel's + // LCID-tagged notation `[$SYMBOL-LCID]`. parseForDateTimeFormat would + // otherwise greedily consume characters like D, M, S, Y, H inside the + // currency code (e.g. 'USD' contains D, 'CHF' contains H), mangling the + // output when a user-supplied stringifyCurrency callback opts out by + // returning undefined. Excel format strings never use `[$...]` for dates. + if (/\[\$[^\]]*\]/.test(formatArg)) { + return undefined + } const expression = parseForDateTimeFormat(formatArg) if (expression === undefined) { return undefined From 9d4c1a6b2cc7705de437a62de3fe96b1bb323659 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 07:39:34 +0000 Subject: [PATCH 17/42] Fix: HF-24 narrow LCID guard to currency tags only (preserve [$-LCID] date locale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review identified that the prior LCID guard (introduced in d496e3000) over-matched Excel's locale-only modifier syntax `[$-LCID]` used in date and time formats (e.g. `[$-409]dd/mm/yyyy`), incorrectly skipping date dispatch and falling through to numberFormat. The guard regex now requires a non-empty SYMBOL portion between `[$` and the dash. Currency tags (`[$USD-409]`, `[$€-2]`, `[$zł-415]`) continue to skip date dispatch as intended; locale-only modifiers (`[$-409]`, `[$-F800]`) flow through to parseForDateTimeFormat as before. Also softens the 'bit-for-bit preserved' doc claim: for LCID-tagged currency formats without a callback, output now goes through numberFormat (best-effort) instead of the pre-existing date-parser hijack. Setting stringifyCurrency remains the recommended path. --- docs/guide/date-and-time-handling.md | 2 +- src/format/format.ts | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 428b623929..672aab6ec2 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -128,7 +128,7 @@ This callback handles `$`-prefixed formats and falls through (returns `undefined ### Default behavior -If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input — the built-in number formatter then handles `$`-prefixed formats and other Excel format strings as before. The callback is purely additive; leaving it unset preserves the existing `TEXT` behavior bit-for-bit. +If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input — the built-in dispatch chain (date, duration, and number formatters) handles the format string. For non-currency formats this preserves the existing `TEXT` behavior. For LCID-tagged currency formats (`[$SYMBOL-LCID] ...`), the built-in number formatter produces best-effort output (the LCID tag is treated as literal characters); setting `stringifyCurrency` is the recommended way to get locale-aware output for these formats. ### Error behavior diff --git a/src/format/format.ts b/src/format/format.ts index 834af0c728..1be80a55b4 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -153,13 +153,18 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M } export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: string): Maybe { - // Skip date/time interpretation for currency formats marked with Excel's - // LCID-tagged notation `[$SYMBOL-LCID]`. parseForDateTimeFormat would - // otherwise greedily consume characters like D, M, S, Y, H inside the - // currency code (e.g. 'USD' contains D, 'CHF' contains H), mangling the - // output when a user-supplied stringifyCurrency callback opts out by - // returning undefined. Excel format strings never use `[$...]` for dates. - if (/\[\$[^\]]*\]/.test(formatArg)) { + // Skip date/time interpretation for Excel currency formats tagged with + // `[$SYMBOL-LCID]` (non-empty SYMBOL portion). parseForDateTimeFormat + // would otherwise greedily consume characters like D, M, S, Y, H inside + // the currency code (e.g. 'USD' contains D, 'CHF' contains H), mangling + // the output when a user-supplied stringifyCurrency callback opts out by + // returning undefined. + // + // The guard intentionally requires at least one character between `[$` + // and the `-` to distinguish currency tags (`[$USD-409]`, `[$€-2]`) from + // Excel's locale-only modifier (`[$-409]`, `[$-F800]`), which is valid + // on date/time formats and must continue to flow through this function. + if (/\[\$[^\-\]]+-/.test(formatArg)) { return undefined } const expression = parseForDateTimeFormat(formatArg) From 0990c0941aa8e7188ac96953212bfbc6996cfa4e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 08:10:35 +0000 Subject: [PATCH 18/42] Fix: HF-24 add LCID guard to defaultStringifyDuration (sibling consistency) Bugbot identified that the LCID-tagged currency guard added to defaultStringifyDateTime (b7c61a5be) was missing from its sibling defaultStringifyDuration. Currency symbols containing duration-token letters (H in CHF/HUF, M in AMD/HMD) were interpreted as time tokens when a stringifyCurrency callback returned undefined. Applies the same regex `/\[\$[^\-\]]+-/` guard in identical position to preserve sibling parity with defaultStringifyDateTime. --- src/format/format.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/format/format.ts b/src/format/format.ts index 1be80a55b4..fa92b0a7bb 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -91,6 +91,14 @@ function numberFormat(tokens: FormatToken[], value: number): RawScalarValue { } export function defaultStringifyDuration(time: SimpleTime, formatArg: string): Maybe { + // Same LCID-tagged currency guard as defaultStringifyDateTime — Excel + // currency tags `[$SYMBOL-LCID]` contain duration-token letters + // (H in CHF/HUF, m in AMD/HMD) that parseForDateTimeFormat would + // otherwise interpret as time tokens. See defaultStringifyDateTime + // for the symbol-vs-locale-modifier rationale. + if (/\[\$[^\-\]]+-/.test(formatArg)) { + return undefined + } const expression = parseForDateTimeFormat(formatArg) if (expression === undefined) { return undefined From 9171146efbd64e9bb1e96da98ea9ad3e07d30ec3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 08:23:40 +0000 Subject: [PATCH 19/42] Docs: HF-24 clarify dispatcher comment after LCID guard introduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot Low: previous comment 'preserving the existing dispatch path bit-for-bit when stringifyCurrency is not set' was inaccurate after the LCID guards landed in defaultStringifyDateTime/Duration. For non-currency formats bit-for-bit holds; for LCID-tagged currency formats output now falls through to numberFormat instead of being mangled by the date parser — a deliberate improvement, not a preservation. Comment reworded to acknowledge this. --- src/format/format.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/format/format.ts b/src/format/format.ts index fa92b0a7bb..58b4c941bf 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -15,8 +15,12 @@ export function format(value: number, formatArg: string, config: Config, dateHel // intercept LCID-tagged or bare-letter currency formats before the // date/time parser greedily consumes characters like 'D', 'M', 'S', 'Y' // (e.g. '[$USD-409] #,##0.00' would otherwise become '[$US9-409] #,##0.00'). - // The default callback returns undefined for every input, preserving the - // existing dispatch path bit-for-bit when stringifyCurrency is not set. + // The default callback returns undefined for every input. For non-currency + // formats (dates, durations, $#,##0.00, etc.) this preserves the existing + // dispatch path bit-for-bit. For LCID-tagged currency formats (`[$SYMBOL-LCID] ...`) + // the LCID guards in defaultStringifyDateTime/Duration also short-circuit, + // so the value falls through to parseForNumberFormat — a deliberate change + // versus pre-HF-24 behavior, where the date parser would mangle the symbol. const tryCurrency = config.stringifyCurrency(value, formatArg) if (tryCurrency !== undefined) { return tryCurrency From 378c077ede53d6b0c71802eea1d825480c1fa221 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 09:04:42 +0000 Subject: [PATCH 20/42] Chore: HF-24 retrigger CI after tests-repo develop merge Public branch merged upstream/develop in d77d5a643 (bringing HF-85 D-function code). Tests-repo branch merged origin/develop in 354b872 (bringing HF-85 D-function tests). CI clones tests-repo by matching branch name, so this empty commit re-runs the full matrix with the updated tests checkout. Should resolve the codecov/project drop (was -1.40% because D-function code shipped without matching tests in the same branch namespace). From 6997cc4a4ca13f5fb30ea5933b1cc8878db6d6cd Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 13:43:37 +0000 Subject: [PATCH 21/42] Docs: HF-24 correct default behavior claims for currency formats The previous wording suggested that the built-in number formatter handles `$#,##0.00` via the default dispatch path. Sandbox audit showed the built-in numberFormat actually fails on any format that includes the comma thousands separator: TEXT(1234.5, "$#,##0.00") -> "$1235,##0.00" (not "$1,234.50") The intro paragraph now lists only the formats that genuinely work without a callback (`$0.00`, `$0`, `$#.00`) and explicitly calls out the broken cases (`$#,##0.00`, LCID-tagged, accounting two-section). The Default behavior subsection gains a side-by-side comparison table (without callback / with adapter / Excel) and a recommendation to set `stringifyCurrency` for any application showing currency to users. Docs-only change. No source or test impact. --- docs/guide/date-and-time-handling.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 672aab6ec2..afb8ee23a6 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -98,7 +98,7 @@ And now, HyperFormula recognizes these values as valid dates and can operate on ## Currency integration -By default, the `TEXT` function recognizes a limited set of currency-looking formats such as `"$#,##0.00"` via the built-in number formatter. When you need richer, locale-aware currency output — for example `"[$€-2] #,##0.00"` (EUR with German grouping) or `"[$zł-415] #,##0.00"` (PLN, locale `pl-PL`) — provide a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback. +By default, the `TEXT` function renders only the simplest currency-looking formats — `"$0.00"`, `"$0"`, or `"$#.00"` (no thousands separator). Common Excel patterns such as `"$#,##0.00"` (with comma grouping), `"[$€-2] #,##0.00"` (EUR with German grouping), `"[$zł-415] #,##0.00"` (PLN), or accounting two-section formats like `"$#,##0.00;($#,##0.00)"` are **not** rendered correctly by the built-in number formatter; provide a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback to handle them. HyperFormula itself ships with **no currency data** and **no currency library dependency**. You choose how to format: native `Intl.NumberFormat`, a third-party library, or a hand-rolled lookup table. @@ -128,7 +128,17 @@ This callback handles `$`-prefixed formats and falls through (returns `undefined ### Default behavior -If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input — the built-in dispatch chain (date, duration, and number formatters) handles the format string. For non-currency formats this preserves the existing `TEXT` behavior. For LCID-tagged currency formats (`[$SYMBOL-LCID] ...`), the built-in number formatter produces best-effort output (the LCID tag is treated as literal characters); setting `stringifyCurrency` is the recommended way to get locale-aware output for these formats. +If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input. For non-currency formats (`mm/dd/yyyy`, `hh:mm`, etc.) the built-in dispatch chain handles the format string and preserves the existing `TEXT` behavior bit-for-bit. For currency-looking formats the built-in number formatter is intentionally limited: + +| Format | `TEXT(1234.5, ...)` without callback | With docs adapter callback | Excel | +|---|---|---|---| +| `"$0.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | +| `"$#.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | +| `"$#,##0.00"` | `"$1235,##0.00"` (broken) | `"$1,234.50"` | `"$1,234.50"` | +| `"[$€-2] #,##0.00"` | `"[$€-2] 1235,##0.00"` (broken) | `"1.234,50 €"` | `"1.234,50 €"` | +| `"$#,##0.00;($#,##0.00)"` (value `-1234.5`) | `"$-1235,##0.00;($#,##0.00)"` (broken) | `"($1,234.50)"` | `"($1,234.50)"` | + +**Recommendation:** for any application that surfaces currency to end users, configure `stringifyCurrency` — either with the `Intl.NumberFormat` adapter below (zero dependencies) or with a library of your choice. Leaving it unset is appropriate only when the formula corpus does not include currency-shaped TEXT formats. ### Error behavior From 9c349ac2f088e579e7ab540c23f2cf685be0673d Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 20 May 2026 04:13:34 +0000 Subject: [PATCH 22/42] Docs: HF-24 split currency handling into dedicated guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #1665: - Extract Currency integration section from date-and-time-handling.md into a new top-level guide currency-handling.md so the topic stands on its own and is reachable from the sidebar. - Open the guide with a positive framing: out-of-the-box support for simple $-prefixed formats, with stringifyCurrency as the additive extension point for richer patterns. Lead with a default-behavior example before introducing the callback. - known-limitations: explain what an LCID tag is inline and clarify that the Polish '1234,56 zł' (decimal-comma) pattern requires the stringifyCurrency callback because the built-in number formatter always emits '.' as the decimal separator. - list-of-differences: drop the TEXT-formatting paragraphs that were duplicating the new guide; replace with a one-liner pointing at stringifyDateTime + stringifyCurrency for full TEXT coverage. - compatibility-with-microsoft-excel and compatibility-with-google-sheets: add a 'TEXT function formats' subsection noting that both callbacks together cover the full TEXT format range. - Update ConfigParams.ts JSDoc cross-reference to point at the new currency-handling guide. Regenerated docs/api artifacts (gitignored) follow automatically via typedoc on docs:build. --- docs/.vuepress/config.js | 1 + .../guide/compatibility-with-google-sheets.md | 4 + .../compatibility-with-microsoft-excel.md | 4 + docs/guide/currency-handling.md | 190 ++++++++++++++++++ docs/guide/date-and-time-handling.md | 166 +-------------- docs/guide/known-limitations.md | 2 +- docs/guide/list-of-differences.md | 2 +- src/ConfigParams.ts | 2 +- 8 files changed, 203 insertions(+), 168 deletions(-) create mode 100644 docs/guide/currency-handling.md diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index d185119bc9..a4a8bd6675 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -255,6 +255,7 @@ module.exports = { ['/guide/i18n-features', 'Internationalization features'], ['/guide/localizing-functions', 'Localizing functions'], ['/guide/date-and-time-handling', 'Date and time handling'], + ['/guide/currency-handling', 'Currency handling'], ] }, { diff --git a/docs/guide/compatibility-with-google-sheets.md b/docs/guide/compatibility-with-google-sheets.md index 5e98ea3b4a..507cc997a4 100644 --- a/docs/guide/compatibility-with-google-sheets.md +++ b/docs/guide/compatibility-with-google-sheets.md @@ -87,6 +87,10 @@ Options related to date and time formats: - [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) - [`stringifyDuration()`](../api/interfaces/configparams.md#stringifyduration) +### `TEXT` function formats + +Google Sheets' `TEXT` function supports a wide range of date, time, and currency formats. To cover the full range in HyperFormula, supply both [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) (for dates and durations) and [`stringifyCurrency()`](../api/interfaces/configparams.md#stringifycurrency) (for currency formats — locale-aware grouping, non-`$` symbols, accounting two-section patterns). See [Currency handling](currency-handling.md) for an `Intl.NumberFormat`-based example. + ## Full configuration This configuration aligns HyperFormula with the default behavior of Google Sheets (set to locale `en-US`), as closely as possible at this development stage (version `{{ $page.version }}`). diff --git a/docs/guide/compatibility-with-microsoft-excel.md b/docs/guide/compatibility-with-microsoft-excel.md index fade7ed966..9afd28a3a0 100644 --- a/docs/guide/compatibility-with-microsoft-excel.md +++ b/docs/guide/compatibility-with-microsoft-excel.md @@ -156,6 +156,10 @@ Options related to date and time formats: - [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) - [`stringifyDuration()`](../api/interfaces/configparams.md#stringifyduration) +### `TEXT` function formats + +Excel's `TEXT` function supports a wide range of date, time, and currency formats. To cover the full range in HyperFormula, supply both [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) (for dates and durations) and [`stringifyCurrency()`](../api/interfaces/configparams.md#stringifycurrency) (for currency formats — locale-aware grouping, non-`$` symbols, accounting two-section patterns). See [Currency handling](currency-handling.md) for an `Intl.NumberFormat`-based example. + ## Full configuration This configuration aligns HyperFormula with the default behavior of Microsoft Excel (set to locale `en-US`), as closely as possible at this development stage (version `{{ $page.version }}`). diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md new file mode 100644 index 0000000000..ab34b57f20 --- /dev/null +++ b/docs/guide/currency-handling.md @@ -0,0 +1,190 @@ +# Currency handling + +The `TEXT` function renders numbers as strings, and HyperFormula handles the most common currency-shaped formats out of the box. For richer locale-aware rendering — locale-specific decimal separators, non-`$` symbols, accounting two-section patterns — plug in a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback and pick the formatter that fits your application (native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat), a third-party library, or a hand-rolled lookup). + +HyperFormula ships with no currency data and no currency-library dependency — you stay in control of locale, symbol placement, and grouping. + +## Default behavior + +By default (no `stringifyCurrency` configured) HyperFormula's built-in number formatter handles simple `$`-prefixed formats — `"$0.00"`, `"$0"`, and `"$#.00"`: + +```javascript +const hf = HyperFormula.buildFromArray([ + [1234.5, '=TEXT(A1, "$0.00")'], + [1234.5, '=TEXT(A2, "$#.00")'], +]); + +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })); // "$1234.50" +``` + +Configure `stringifyCurrency` when your formula corpus uses any of: + +- thousands grouping (`"$#,##0.00"`), +- non-`$` symbols (`"[$€-2] #,##0.00"`, `"[$zł-415] #,##0.00"`), +- locale-specific decimal separators (e.g. the Polish `"1234,50 zł"` pattern, which the built-in formatter cannot produce because it always emits `.` as the decimal), +- accounting two-section formats (`"$#,##0.00;($#,##0.00)"`). + +## Custom currency formatting + +The callback contract: + +```ts +stringifyCurrency: (value: number, currencyFormat: string) => string | undefined +``` + +The function receives the raw number and the format string passed to `TEXT`. Return a formatted string to override the built-in formatter, or `undefined` to fall through to it. + +### Minimal example + +```javascript +// Recognize "$..."-prefixed formats and ignore the rest: +const stringifyCurrency = (value, fmt) => + fmt.startsWith('$') ? `$${value.toFixed(2)}` : undefined; + +const hf = HyperFormula.buildFromArray([ + [1234.5, '=TEXT(A1, "$#,##0.00")'], +], { stringifyCurrency }); + +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" +``` + +This callback handles `$`-prefixed formats and falls through (returns `undefined`) for everything else. Dates, durations, and unrecognized formats continue through HyperFormula's existing dispatch chain. + +### Reference table + +Side-by-side comparison of the default formatter, the docs adapter from the section below, and Excel: + +| Format | `TEXT(1234.5, ...)` without callback | With docs adapter callback | Excel | +|---|---|---|---| +| `"$0.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | +| `"$#.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | +| `"$#,##0.00"` | `"$1235,##0.00"` (no grouping) | `"$1,234.50"` | `"$1,234.50"` | +| `"[$€-2] #,##0.00"` | `"[$€-2] 1235,##0.00"` (no grouping) | `"1.234,50 €"` | `"1.234,50 €"` | +| `"$#,##0.00;($#,##0.00)"` (value `-1234.5`) | `"$-1235,##0.00;($#,##0.00)"` (no grouping) | `"($1,234.50)"` | `"($1,234.50)"` | + +### Error behavior + +If your callback throws, HyperFormula propagates the exception. Wrap your formatter in `try/catch` if it can fail, and return `undefined` as the opt-out signal for unsupported formats — throwing is reserved for unexpected errors. + +### Example: `Intl.NumberFormat` adapter (zero dependencies) + +This adapter handles a representative subset of Excel currency format strings using native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). Extend the `LCID_TO_LOCALE` map to cover more locales — see the [MS-LCID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) specification for canonical identifiers. + +```javascript +// Minimal Excel-format-string → Intl.NumberFormat adapter. +// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. + +const LCID_TO_LOCALE = { + '-409': { locale: 'en-US', currency: 'USD' }, // USD + '-2': { locale: 'de-DE', currency: 'EUR' }, // EUR (generic) + '-411': { locale: 'ja-JP', currency: 'JPY' }, // JPY + '-415': { locale: 'pl-PL', currency: 'PLN' }, // PLN + '-809': { locale: 'en-GB', currency: 'GBP' }, // GBP +} + +const CURRENCY_RULES = [ + // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency + { + pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, + build: (match) => { + const lcid = '-' + match[2] + const fractionDigits = (match[3] || '.').length - 1 + const entry = LCID_TO_LOCALE[lcid] || { locale: 'en-US', currency: 'USD' } + return new Intl.NumberFormat(entry.locale, { + style: 'currency', + currency: entry.currency, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + }, + }, + // $#,##0.00 — USD shorthand + { + pattern: /^\$#,##0(\.0+)?$/, + build: (match) => new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: (match[1] || '.').length - 1, + maximumFractionDigits: (match[1] || '.').length - 1, + }), + }, +] + +// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses +function tryAccountingFormat(value, format) { + const sections = format.split(';') + if (sections.length !== 2) return undefined + const isNegative = value < 0 + const section = sections[isNegative ? 1 : 0] + const parenMatch = /^\(\$#,##0(\.0+)?\)$/.exec(section) + const plainMatch = /^\$#,##0(\.0+)?$/.exec(section) + if (!parenMatch && !plainMatch) return undefined + const fractionDigits = ((parenMatch || plainMatch)[1] || '.').length - 1 + const nf = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + const formatted = nf.format(Math.abs(value)) + return isNegative && parenMatch ? `(${formatted})` : formatted +} + +export const customStringifyCurrency = (value, currencyFormat) => { + if (typeof currencyFormat !== 'string') return undefined + const accounting = tryAccountingFormat(value, currencyFormat) + if (accounting !== undefined) return accounting + + for (const rule of CURRENCY_RULES) { + const match = rule.pattern.exec(currencyFormat) + if (match) return rule.build(match).format(value) + } + // Not a recognized currency format — let HyperFormula fall through + // to the built-in number formatter. + return undefined +} +``` + +Then plug it into your [configuration options](configuration-options.md): + +```javascript +const options = { + stringifyCurrency: customStringifyCurrency, +} + +const hf = HyperFormula.buildFromArray([ + [1234.5, '=TEXT(A1, "[$€-2] #,##0.00")'], + [12345.5, '=TEXT(A2, "[$zł-415] #,##0.00")'], + [-1234.5, '=TEXT(A3, "$#,##0.00;($#,##0.00)")'], +], options) +``` + +```javascript +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })) // "12 345,50 zł" +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" +``` + +::: tip +The output values above contain non-breaking spaces (U+00A0 or U+202F depending on locale and ICU/CLDR version) as locale-appropriate separators. The comments show them as regular spaces for readability. When comparing programmatically, normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. +::: + +### What is an LCID tag? + +Excel can mark a currency format with a [Microsoft Locale Identifier](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) (LCID) so the symbol carries locale context. The syntax is `[$SYMBOL-LCID]` followed by the number template — for example `[$zł-415] #,##0.00` means *"Polish złoty, hex LCID `415` = `pl-PL`"*, and `[$€-2] #,##0.00` means *"euro, generic"*. The adapter above parses the LCID to pick the matching `Intl.NumberFormat` locale and ISO 4217 currency code. + +### When to swap in a library + +The adapter above covers a small but representative subset of Excel currency format strings (LCID-tagged, USD shorthand, accounting two-section) in under one page of code, with a fall-through path for everything else. If you need: + +- Arbitrary Excel-style format strings beyond this subset, +- Precision-safe arithmetic on currency values (e.g. cents as integers), +- ISO 4217 currency metadata for dozens of currencies, + +consider wrapping [`Dinero.js` v2](https://v2.dinerojs.com/) or your own format library inside the callback. The contract is the same: `(value: number, currencyFormat: string) => string | undefined`. Return `undefined` for any format string you don't want to handle and HyperFormula will fall back to its built-in number formatter. + +## Related configuration + +- [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol) — governs how HyperFormula **parses** currency literals in input (e.g. `"$100"` → `100`). It is **independent** of `stringifyCurrency`, which governs `TEXT` output. +- [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) / [`stringifyDuration`](../api/interfaces/configparams.md#stringifyduration) — sister callbacks for date and duration formatting. Combine with `stringifyCurrency` when your formulas mix date/time and currency formats. diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index afb8ee23a6..cb0ee3b0a2 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -96,171 +96,7 @@ const data = [["31st Jan 00", "2nd Jun 01", "=B1-A1"]]; And now, HyperFormula recognizes these values as valid dates and can operate on them. -## Currency integration - -By default, the `TEXT` function renders only the simplest currency-looking formats — `"$0.00"`, `"$0"`, or `"$#.00"` (no thousands separator). Common Excel patterns such as `"$#,##0.00"` (with comma grouping), `"[$€-2] #,##0.00"` (EUR with German grouping), `"[$zł-415] #,##0.00"` (PLN), or accounting two-section formats like `"$#,##0.00;($#,##0.00)"` are **not** rendered correctly by the built-in number formatter; provide a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback to handle them. - -HyperFormula itself ships with **no currency data** and **no currency library dependency**. You choose how to format: native `Intl.NumberFormat`, a third-party library, or a hand-rolled lookup table. - -The callback contract: - -```ts -stringifyCurrency: (value: number, currencyFormat: string) => string | undefined -``` - -The function receives the raw number and the format string passed to `TEXT`. Return a formatted string to override the built-in formatter, or `undefined` to fall through to it. - -### Minimal example - -```javascript -// Recognize "$..."-prefixed formats and ignore the rest: -const stringifyCurrency = (value, fmt) => - fmt.startsWith('$') ? `$${value.toFixed(2)}` : undefined - -const hf = HyperFormula.buildFromArray([ - [1234.5, '=TEXT(A1, "$#,##0.00")'], -], { stringifyCurrency }) - -console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "$1234.50" -``` - -This callback handles `$`-prefixed formats and falls through (returns `undefined`) for everything else. Dates, durations, and unrecognized formats continue through HyperFormula's existing dispatch chain. - -### Default behavior - -If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input. For non-currency formats (`mm/dd/yyyy`, `hh:mm`, etc.) the built-in dispatch chain handles the format string and preserves the existing `TEXT` behavior bit-for-bit. For currency-looking formats the built-in number formatter is intentionally limited: - -| Format | `TEXT(1234.5, ...)` without callback | With docs adapter callback | Excel | -|---|---|---|---| -| `"$0.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | -| `"$#.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | -| `"$#,##0.00"` | `"$1235,##0.00"` (broken) | `"$1,234.50"` | `"$1,234.50"` | -| `"[$€-2] #,##0.00"` | `"[$€-2] 1235,##0.00"` (broken) | `"1.234,50 €"` | `"1.234,50 €"` | -| `"$#,##0.00;($#,##0.00)"` (value `-1234.5`) | `"$-1235,##0.00;($#,##0.00)"` (broken) | `"($1,234.50)"` | `"($1,234.50)"` | - -**Recommendation:** for any application that surfaces currency to end users, configure `stringifyCurrency` — either with the `Intl.NumberFormat` adapter below (zero dependencies) or with a library of your choice. Leaving it unset is appropriate only when the formula corpus does not include currency-shaped TEXT formats. - -### Error behavior - -If your callback throws, HyperFormula propagates the exception. Wrap your formatter in `try/catch` if it can fail, and return `undefined` as the opt-out signal for unsupported formats — throwing is reserved for unexpected errors. - -### Example: `Intl.NumberFormat` adapter (zero dependencies) - -This adapter handles a representative subset of Excel currency format strings using native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). Extend the `LCID_TO_LOCALE` map to cover more locales — see the [MS-LCID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) specification for canonical identifiers. - -```javascript -// Minimal Excel-format-string → Intl.NumberFormat adapter. -// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. - -const LCID_TO_LOCALE = { - '-409': { locale: 'en-US', currency: 'USD' }, // USD - '-2': { locale: 'de-DE', currency: 'EUR' }, // EUR (generic) - '-411': { locale: 'ja-JP', currency: 'JPY' }, // JPY - '-415': { locale: 'pl-PL', currency: 'PLN' }, // PLN - '-809': { locale: 'en-GB', currency: 'GBP' }, // GBP -} - -const CURRENCY_RULES = [ - // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency - { - pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, - build: (match) => { - const lcid = '-' + match[2] - const fractionDigits = (match[3] || '.').length - 1 - const entry = LCID_TO_LOCALE[lcid] || { locale: 'en-US', currency: 'USD' } - return new Intl.NumberFormat(entry.locale, { - style: 'currency', - currency: entry.currency, - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }) - }, - }, - // $#,##0.00 — USD shorthand - { - pattern: /^\$#,##0(\.0+)?$/, - build: (match) => new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: (match[1] || '.').length - 1, - maximumFractionDigits: (match[1] || '.').length - 1, - }), - }, -] - -// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses -function tryAccountingFormat(value, format) { - const sections = format.split(';') - if (sections.length !== 2) return undefined - const isNegative = value < 0 - const section = sections[isNegative ? 1 : 0] - const parenMatch = /^\(\$#,##0(\.0+)?\)$/.exec(section) - const plainMatch = /^\$#,##0(\.0+)?$/.exec(section) - if (!parenMatch && !plainMatch) return undefined - const fractionDigits = ((parenMatch || plainMatch)[1] || '.').length - 1 - const nf = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }) - const formatted = nf.format(Math.abs(value)) - return isNegative && parenMatch ? `(${formatted})` : formatted -} - -export const customStringifyCurrency = (value, currencyFormat) => { - if (typeof currencyFormat !== 'string') return undefined - const accounting = tryAccountingFormat(value, currencyFormat) - if (accounting !== undefined) return accounting - - for (const rule of CURRENCY_RULES) { - const match = rule.pattern.exec(currencyFormat) - if (match) return rule.build(match).format(value) - } - // Not a recognized currency format — let HyperFormula fall through - // to the built-in number formatter. - return undefined -} -``` - -Then plug it into your [configuration options](configuration-options.md): - -```javascript -const options = { - stringifyCurrency: customStringifyCurrency, -} - -const hf = HyperFormula.buildFromArray([ - [1234.5, '=TEXT(A1, "[$€-2] #,##0.00")'], - [12345.5, '=TEXT(A2, "[$zł-415] #,##0.00")'], - [-1234.5, '=TEXT(A3, "$#,##0.00;($#,##0.00)")'], -], options) -``` - -```javascript -console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" -console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })) // "12 345,50 zł" -console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" -``` - -::: tip -The output values above contain non-breaking spaces (U+00A0 or U+202F depending on locale and ICU/CLDR version) as locale-appropriate separators. The comments show them as regular spaces for readability. When comparing programmatically, normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. -::: - -### When to swap in a library - -The adapter above covers a small but representative subset of Excel currency format strings (LCID-tagged, USD shorthand, accounting two-section) in under one page of code, with a fall-through path for everything else. If you need: - -- Arbitrary Excel-style format strings beyond this subset, -- Precision-safe arithmetic on currency values (e.g. cents as integers), -- ISO 4217 currency metadata for dozens of currencies, - -consider wrapping [`Dinero.js` v2](https://v2.dinerojs.com/) or your own format library inside the callback. The contract is the same: `(value: number, currencyFormat: string) => string | undefined`. Return `undefined` for any format string you don't want to handle and HyperFormula will fall back to its built-in number formatter. - -### Related configuration - -- [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol) — governs how HyperFormula **parses** currency literals in input (e.g. `"$100"` → `100`). It is **independent** of `stringifyCurrency`, which governs TEXT output. -- [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) / [`stringifyDuration`](../api/interfaces/configparams.md#stringifyduration) — sister callbacks for date and duration formatting. +For currency formatting in the `TEXT` function (locale-aware grouping, non-`$` symbols, accounting patterns), see the [Currency handling](currency-handling.md) guide. ## Demo diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 6b9eebee81..e947586ac4 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,7 +38,7 @@ you can't compare the arguments in a formula like this: * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. -* The TEXT function does not accept embedded double-quote literals in the format string (e.g., `=TEXT(A1, "#,##0.00 ""zł""")` fails at parse time). Use the LCID-tagged form (`[$zł-415] #,##0.00`) or supply a custom [`stringifyCurrency`](configuration-options.md#stringifycurrency) callback that handles such formats outside the parser. +* The TEXT function does not accept embedded double-quote literals in the format string (e.g., `=TEXT(A1, "#,##0.00 ""zł""")` fails at parse time). Use Excel's LCID-tagged form — `[$SYMBOL-LCID]` where LCID is a hex [Microsoft Locale ID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid), e.g. `[$zł-415] #,##0.00` for Polish złoty — or supply a custom [`stringifyCurrency`](currency-handling.md) callback that handles such formats outside the parser. For locale-specific patterns like the Polish `"1234,50 zł"` (decimal comma), the callback is required because the built-in number formatter always emits `.` as the decimal separator. ### OFFSET function diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 8c59c67d64..a4f75c6a7f 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -34,7 +34,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | Applying a scalar value to a function taking range | COLUMNS(A1) | `CellRangeExpected` error. | Treats the element as length-1 range. Returns 1 for the example. | Same as Google Sheets. | | Coercion of explicit arguments | VARP(2, 3, 4, TRUE(), FALSE(), "1",) | 1.9592, based on the behavior of Microsoft Excel. | GoogleSheets implementation is not consistent with the standard (see also `VAR.S`, `STDEV.P`, and `STDEV.S` function.) | 1.9592 | | Ranges created with `:` | A1:A2

A$1:$A$2

A:C

1:2

Sheet1!A1:A2 | Allowed ranges consist of two addresses (A1:B5), columns (A:C) or rows (3:5).
They cannot be mixed or contain named expressions. | Everything allowed. | Same as Google Sheets. | -| Formatting inside the TEXT function | TEXT(A1,"dd-mm-yy")

TEXT(A1,"###.###”) | Not all formatting options are supported,
e.g., only some date formatting options: (`hh`, `mm`, `ss`, `am`, `pm`, `a`, `p`, `dd`, `yy`, and `yyyy`).

Currency formatting is opt-in via the [`stringifyCurrency`](date-and-time-handling.md#currency-integration) callback; without it, currency format strings fall through to the built-in number formatter.

Embedded double-quote literals (e.g. `#,##0.00 "zł"`) are not accepted by the parser; use the LCID-tagged form (`[$zł-415] #,##0.00`) instead. | A wide variety of options for string formatting is supported. | Same as Google Sheets. | +| Formatting inside the TEXT function | TEXT(A1,"dd-mm-yy")

TEXT(A1,"###.###”) | Not all formatting options are supported,
e.g., only some date formatting options: (`hh`, `mm`, `ss`, `am`, `pm`, `a`, `p`, `dd`, `yy`, and `yyyy`). Plug in [`stringifyDateTime`](compatibility-with-microsoft-excel.md#date-and-time-formats) and [`stringifyCurrency`](currency-handling.md) for full coverage. | A wide variety of options for string formatting is supported. | Same as Google Sheets. | | Cell references inside inline arrays | ={A1, A2} | The array's value is calculated but not updated when the cells' values change. | The array's value is calculated and updated when the cells' values change. | ERROR: invalid array | | SPLIT function | =SPLIT("Lorem ipsum dolor", 0) | This function works differently from Google Sheets version but should be sufficient to achieve the same functionality in most scenarios. Read SPLIT function description on [the Built-in Functions page](built-in-functions.md#text). | Different syntax and return value. | No such function. | | DATEVALUE function | =DATEVALUE("25/02/1991") | Type of the returned value: `CellValueDetailedType.NUMBER_DATE` (compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard) | Cell auto-formatted as **regular number** | Cell auto-formatted as **regular number** | diff --git a/src/ConfigParams.ts b/src/ConfigParams.ts index 93c244e9df..68d36d81f0 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -318,7 +318,7 @@ export interface ConfigParams { * formatter fall through to the built-in number formatter, so a callback that * recognizes only some format strings can safely opt out of the rest. * - * For more information, see the [Date and time handling guide](/guide/date-and-time-handling.md#currency-integration). + * For more information, see the [Currency handling guide](/guide/currency-handling.md). * * @default defaultStringifyCurrency * From ca650a99ab9d9ef57a66839078c3bb95a9583d28 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 20 May 2026 04:19:18 +0000 Subject: [PATCH 23/42] Chore: HF-24 retrigger CI for docs-only iter on f1eb4efb2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous push uploaded commit f1eb4efb2 to upstream but the pull_request:synchronize event did not fire workflow runs — only the lightweight bot checks (Cursor Bugbot, Netlify rules) attached. This empty commit forces a fresh synchronize so the full matrix (Lint, Test, Build on various envs, Performance, Security, CodeQL, Build docs) runs against the new docs reorganization. From a4b05ae3c86977ff313829dc726480f1e3f0c21f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 21 May 2026 13:49:28 +0000 Subject: [PATCH 24/42] Docs: HF-24 unify currency input/output in currency-handling guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Kuba's review feedback on the input-vs-output framing question: - currency-handling.md becomes the single authority for currency: both the input-parsing mechanism (currencySymbol) and the output formatting mechanism (stringifyCurrency callback) live together, with an explicit framing of their independence at the top of the guide. - Add a 'Currency input' section showing currencySymbol with a Polish złoty example, the prefix/suffix detection, and the NUMBER_CURRENCY detailed type that input parsing produces. - Reorganize the rest of the guide as 'Currency output' so the structure mirrors the framing — default behavior, custom callback, reference table, Intl.NumberFormat adapter, LCID explainer, library swap. - i18n-features.md drops its standalone 'Currency symbol' subsection and links to the new guide instead, removing the duplication. --- docs/guide/currency-handling.md | 68 +++++++++++++++++++++++++-------- docs/guide/i18n-features.md | 8 +--- 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index ab34b57f20..e3e19337a2 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -1,12 +1,42 @@ # Currency handling -The `TEXT` function renders numbers as strings, and HyperFormula handles the most common currency-shaped formats out of the box. For richer locale-aware rendering — locale-specific decimal separators, non-`$` symbols, accounting two-section patterns — plug in a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback and pick the formatter that fits your application (native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat), a third-party library, or a hand-rolled lookup). +HyperFormula treats currency through **two independent mechanisms**: -HyperFormula ships with no currency data and no currency-library dependency — you stay in control of locale, symbol placement, and grouping. +- **Currency input** — recognizing currency literals (e.g. `"100 zł"`) when they appear in cell values, so they become numeric values tagged as currency rather than strings. Controlled by [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol). +- **Currency output** — rendering numbers as currency strings via the `TEXT` function. Simple `$`-prefixed formats work out of the box; richer locale-aware patterns plug in through [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency). -## Default behavior +The two mechanisms are orthogonal — configure both for full coverage. HyperFormula ships with no currency data and no currency-library dependency, so you stay in control of which symbols are recognized and how they render. -By default (no `stringifyCurrency` configured) HyperFormula's built-in number formatter handles simple `$`-prefixed formats — `"$0.00"`, `"$0"`, and `"$#.00"`: +## Currency input + +By default, HyperFormula recognizes `$` as a currency symbol in cell input. To add more (for example Polish złoty), pass an array of recognized symbols to [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol): + +```javascript +const hf = HyperFormula.buildFromArray( + [['100 zł', '=A1 * 1.23']], + { currencySymbol: ['$', 'zł'] } +); + +console.log(hf.getCellValue({ sheet: 0, col: 0, row: 0 })); // 100 +console.log(hf.getCellValueDetailedType({ sheet: 0, col: 0, row: 0 })); // 'NUMBER_CURRENCY' +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // 123 +``` + +Notes: + +- The symbol can appear as a **prefix** (`"$100"`) or as a **suffix** (`"100 zł"`). Both forms are recognized. +- Each entry in `currencySymbol` is a literal string — no regular expressions. To support multiple locales, list every symbol you want recognized. +- Detected literals are exposed as numeric values; the currency tag is available via [`getCellValueDetailedType()`](../api/classes/hyperformula.md#getcellvaluedetailedtype) as `NUMBER_CURRENCY`. + +`currencySymbol` controls **only** how HyperFormula parses input. It does not influence what the `TEXT` function returns — that is governed by the format string and the [`stringifyCurrency`](#currency-output) callback described below. + +## Currency output + +The `TEXT` function renders a number with a format string. HyperFormula's built-in number formatter handles the simplest currency-shaped patterns out of the box; richer patterns need a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback. + +### Default behavior + +With no `stringifyCurrency` configured, the built-in formatter handles simple `$`-prefixed formats — `"$0.00"`, `"$0"`, and `"$#.00"`: ```javascript const hf = HyperFormula.buildFromArray([ @@ -18,14 +48,21 @@ console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })); // "$1234.50" ``` -Configure `stringifyCurrency` when your formula corpus uses any of: +A non-`$` symbol used purely as a suffix (no thousands grouping, no decimal-comma) also passes through unchanged: + +```javascript +const hf = HyperFormula.buildFromArray([[1234.5, '=TEXT(A1, "0.00 zł")']]); +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "1234.50 zł" +``` + +Configure `stringifyCurrency` when your formula corpus needs: - thousands grouping (`"$#,##0.00"`), -- non-`$` symbols (`"[$€-2] #,##0.00"`, `"[$zł-415] #,##0.00"`), -- locale-specific decimal separators (e.g. the Polish `"1234,50 zł"` pattern, which the built-in formatter cannot produce because it always emits `.` as the decimal), +- non-`$` symbols with grouping (`"[$€-2] #,##0.00"`, `"[$zł-415] #,##0.00"`), +- locale-specific decimal separators (e.g. the Polish `"1234,50 zł"` pattern — the built-in formatter always emits `.` as the decimal), - accounting two-section formats (`"$#,##0.00;($#,##0.00)"`). -## Custom currency formatting +### Custom currency formatting The callback contract: @@ -35,7 +72,7 @@ stringifyCurrency: (value: number, currencyFormat: string) => string | undefined The function receives the raw number and the format string passed to `TEXT`. Return a formatted string to override the built-in formatter, or `undefined` to fall through to it. -### Minimal example +#### Minimal example ```javascript // Recognize "$..."-prefixed formats and ignore the rest: @@ -51,7 +88,7 @@ console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" This callback handles `$`-prefixed formats and falls through (returns `undefined`) for everything else. Dates, durations, and unrecognized formats continue through HyperFormula's existing dispatch chain. -### Reference table +#### Reference table Side-by-side comparison of the default formatter, the docs adapter from the section below, and Excel: @@ -63,11 +100,11 @@ Side-by-side comparison of the default formatter, the docs adapter from the sect | `"[$€-2] #,##0.00"` | `"[$€-2] 1235,##0.00"` (no grouping) | `"1.234,50 €"` | `"1.234,50 €"` | | `"$#,##0.00;($#,##0.00)"` (value `-1234.5`) | `"$-1235,##0.00;($#,##0.00)"` (no grouping) | `"($1,234.50)"` | `"($1,234.50)"` | -### Error behavior +#### Error behavior If your callback throws, HyperFormula propagates the exception. Wrap your formatter in `try/catch` if it can fail, and return `undefined` as the opt-out signal for unsupported formats — throwing is reserved for unexpected errors. -### Example: `Intl.NumberFormat` adapter (zero dependencies) +#### Example: `Intl.NumberFormat` adapter (zero dependencies) This adapter handles a representative subset of Excel currency format strings using native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). Extend the `LCID_TO_LOCALE` map to cover more locales — see the [MS-LCID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) specification for canonical identifiers. @@ -167,14 +204,14 @@ console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" ``` ::: tip -The output values above contain non-breaking spaces (U+00A0 or U+202F depending on locale and ICU/CLDR version) as locale-appropriate separators. The comments show them as regular spaces for readability. When comparing programmatically, normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. +The output values above contain non-breaking spaces (U+00A0 or U+202F depending on locale and ICU/CLDR version) as locale-appropriate separators. The comments show them as regular spaces for readability. When comparing programmatically, normalize with `.replace(/[ ]/g, ' ')` if you need ASCII-space output. ::: -### What is an LCID tag? +#### What is an LCID tag? Excel can mark a currency format with a [Microsoft Locale Identifier](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) (LCID) so the symbol carries locale context. The syntax is `[$SYMBOL-LCID]` followed by the number template — for example `[$zł-415] #,##0.00` means *"Polish złoty, hex LCID `415` = `pl-PL`"*, and `[$€-2] #,##0.00` means *"euro, generic"*. The adapter above parses the LCID to pick the matching `Intl.NumberFormat` locale and ISO 4217 currency code. -### When to swap in a library +#### When to swap in a library The adapter above covers a small but representative subset of Excel currency format strings (LCID-tagged, USD shorthand, accounting two-section) in under one page of code, with a fall-through path for everything else. If you need: @@ -186,5 +223,4 @@ consider wrapping [`Dinero.js` v2](https://v2.dinerojs.com/) or your own format ## Related configuration -- [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol) — governs how HyperFormula **parses** currency literals in input (e.g. `"$100"` → `100`). It is **independent** of `stringifyCurrency`, which governs `TEXT` output. - [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) / [`stringifyDuration`](../api/interfaces/configparams.md#stringifyduration) — sister callbacks for date and duration formatting. Combine with `stringifyCurrency` when your formulas mix date/time and currency formats. diff --git a/docs/guide/i18n-features.md b/docs/guide/i18n-features.md index 14bb7b3a05..f5c08280be 100644 --- a/docs/guide/i18n-features.md +++ b/docs/guide/i18n-features.md @@ -48,13 +48,7 @@ thousandSeparator: ',', ## Currency symbol -To match your users' currency, you can configure multiple currency symbols ([`currencySymbol`](../api/interfaces/configparams.md#currencysymbol)). - -The default currency symbol is `$`. To add `USD` as an alternative, set: - -```js -currencySymbol: ['$', 'USD'], -``` +To match your users' currency, configure recognized currency symbols and (optionally) custom `TEXT` output formatting. Both sides are covered in the dedicated [Currency handling](currency-handling.md) guide. ## String comparison rules From f6cac82e659c2b2ab016a6326e1255084763f905 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 21 May 2026 15:14:12 +0000 Subject: [PATCH 25/42] Refactor: HF-24 address prep-ultra polish (category, regex hoist, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three nits surfaced by the final fresh-eyes review: - ConfigParams.ts: stringifyCurrency was filed under @category 'Date and Time' (copied from sibling stringifyDateTime/Duration), placing a currency-formatting option under the Date and Time TypeDoc nav group. Move it to @category 'Number' to match where currencySymbol already lives, so currency-related config clusters together in generated API docs. - ConfigParams.ts: expand the stringifyCurrency JSDoc to state plainly that the formatter calls the callback for every format string that reaches it, not only currency-shaped ones, and that returning undefined is the opt-out path for unsupported formats. IDE users reading the contract no longer have to infer this from the guide. - format.ts: hoist the LCID-tagged currency-guard regex into a module-level const (LCID_CURRENCY_TAG) shared by defaultStringifyDateTime and defaultStringifyDuration. Avoids re-instantiating the same RegExp on every format() call (TEXT can be invoked thousands of times per recalc), and documents at the declaration site why the pattern is intentionally unanchored — Excel does not mix date/time tokens with currency tags in a single format string, so a mid-string match cannot misclassify a legitimate composite. --- src/ConfigParams.ts | 9 +++++---- src/format/format.ts | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/ConfigParams.ts b/src/ConfigParams.ts index 68d36d81f0..71aeb0bb7d 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -314,15 +314,16 @@ export interface ConfigParams { * Sets a function that converts numeric values into currency-formatted strings. * * The function receives the raw value and the format string passed to `TEXT` - * and should return a string or `undefined`. Returning `undefined` lets the - * formatter fall through to the built-in number formatter, so a callback that - * recognizes only some format strings can safely opt out of the rest. + * and should return a string or `undefined`. The formatter calls this for + * every format string that reaches it, not only currency-shaped ones — return + * `undefined` for any format your callback does not handle and HyperFormula + * will fall through to the built-in number formatter. * * For more information, see the [Currency handling guide](/guide/currency-handling.md). * * @default defaultStringifyCurrency * - * @category Date and Time + * @category Number */ stringifyCurrency: (value: number, currencyFormat: string) => Maybe, /** diff --git a/src/format/format.ts b/src/format/format.ts index 58b4c941bf..f34c5a5944 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -10,6 +10,21 @@ import {RawScalarValue} from '../interpreter/InterpreterValue' import {Maybe} from '../Maybe' import {FormatToken, parseForDateTimeFormat, parseForNumberFormat, TokenType} from './parser' +/** + * Detects Excel LCID-tagged currency tags (`[$SYMBOL-LCID]` with a non-empty + * SYMBOL portion). Shared by `defaultStringifyDateTime` and + * `defaultStringifyDuration` so a format string carrying such a tag short- + * circuits both date and duration dispatch and falls through to the + * number formatter (or the user-supplied `stringifyCurrency` callback). + * + * The pattern is intentionally unanchored: any occurrence of `[$SYMBOL-` + * in the format string triggers the guard. Excel does not mix date/time + * tokens with a currency tag in the same format string, so a mid-string + * match cannot misclassify a legitimate composite — every observed + * format string with a currency tag is currency-only. + */ +const LCID_CURRENCY_TAG = /\[\$[^\-\]]+-/ + export function format(value: number, formatArg: string, config: Config, dateHelper: DateTimeHelper): RawScalarValue { // Currency callback runs first so a user-supplied stringifyCurrency can // intercept LCID-tagged or bare-letter currency formats before the @@ -100,7 +115,7 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M // (H in CHF/HUF, m in AMD/HMD) that parseForDateTimeFormat would // otherwise interpret as time tokens. See defaultStringifyDateTime // for the symbol-vs-locale-modifier rationale. - if (/\[\$[^\-\]]+-/.test(formatArg)) { + if (LCID_CURRENCY_TAG.test(formatArg)) { return undefined } const expression = parseForDateTimeFormat(formatArg) @@ -176,7 +191,7 @@ export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: st // and the `-` to distinguish currency tags (`[$USD-409]`, `[$€-2]`) from // Excel's locale-only modifier (`[$-409]`, `[$-F800]`), which is valid // on date/time formats and must continue to flow through this function. - if (/\[\$[^\-\]]+-/.test(formatArg)) { + if (LCID_CURRENCY_TAG.test(formatArg)) { return undefined } const expression = parseForDateTimeFormat(formatArg) From 9570581dbdee93ec20aeeb3d0260d0d0b168d7ae Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 04:42:46 +0000 Subject: [PATCH 26/42] Docs: HF-24 enrich JSDoc for default stringify guards (LCID design intent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline rationale block with proper JSDoc on `defaultStringifyDateTime` and `defaultStringifyDuration`. Captures: - The historical pre-HF-24 mis-formatting (`[$USD-409] #,##0.00` → `[$US9-409] #,##0.00` because `D` was treated as a day token). - Why the guard is the deliberate correction, not a regression — every non-currency format remains bit-for-bit compatible. - The `[$SYMBOL-LCID]` vs `[$-LCID]` distinction the regex enforces. - The dispatch contract (`undefined` = defer to next handler) so future callers reason about the fall-through path explicitly. Addresses the design-intent angle raised by Bugbot wave 2 (low-severity inline at format.ts:196). No behavioural change. --- src/format/format.ts | 63 +++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/src/format/format.ts b/src/format/format.ts index f34c5a5944..52e1192547 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -109,12 +109,27 @@ function numberFormat(tokens: FormatToken[], value: number): RawScalarValue { return result } +/** + * Default `stringifyDuration` callback — formats a duration value against an + * Excel-style time format string (e.g. `[hh]:mm:ss`). + * + * Returns `undefined` for format strings that are not duration formats so the + * dispatcher in `format()` can fall through to other handlers. + * + * **LCID currency-tag guard** — sibling to the same guard in + * `defaultStringifyDateTime`; explicitly returns `undefined` for Excel + * currency tags `[$SYMBOL-LCID]` because the SYMBOL portion contains + * duration-token letters (`H` in CHF/HUF, `m` in AMD/HMD) that + * `parseForDateTimeFormat` would otherwise interpret as time tokens and + * mangle the output. See `defaultStringifyDateTime` for the full + * symbol-vs-locale-modifier rationale and the historical pre-HF-24 + * behaviour the guard corrects. + * + * @param time parsed duration value to render + * @param formatArg Excel-style format string + * @returns formatted string, or `undefined` to defer to the next dispatch step + */ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): Maybe { - // Same LCID-tagged currency guard as defaultStringifyDateTime — Excel - // currency tags `[$SYMBOL-LCID]` contain duration-token letters - // (H in CHF/HUF, m in AMD/HMD) that parseForDateTimeFormat would - // otherwise interpret as time tokens. See defaultStringifyDateTime - // for the symbol-vs-locale-modifier rationale. if (LCID_CURRENCY_TAG.test(formatArg)) { return undefined } @@ -179,18 +194,34 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M return result } +/** + * Default `stringifyDateTime` callback — formats a date/time value against an + * Excel-style format string (e.g. `YYYY-MM-DD HH:mm:ss`). + * + * Returns `undefined` for format strings that are not date/time formats so the + * dispatcher in `format()` can fall through to `parseForNumberFormat` (or to a + * user-supplied `stringifyCurrency` callback for currency-tagged formats). + * + * **LCID currency-tag guard** — explicitly returns `undefined` for Excel + * currency tags `[$SYMBOL-LCID]` (non-empty SYMBOL portion). Without the + * guard, `parseForDateTimeFormat` greedily consumes letters like `D`/`M`/`S`/`Y`/`H` + * inside the currency code (e.g. `D` in USD, `H` in CHF, `M`+`D` in AMD), + * mangling the output of an `[$USD-409] #,##0.00` format into + * `[$US9-409] #,##0.00` because `D` is read as a day token. The pre-HF-24 + * behaviour was to mis-format; the guarded return is the deliberate + * correction, not a regression. Bit-for-bit compatibility is preserved for + * every non-currency format (dates, durations, `$#,##0.00`, etc.). + * + * The guard pattern (`/\[\$[^\-\]]+-/`) requires ≥1 character between `[$` + * and `-` so it distinguishes currency tags (`[$USD-409]`, `[$€-2]`) from + * Excel's locale-only modifier (`[$-409]`, `[$-F800]`), which is valid on + * date/time formats and must continue to flow through this function. + * + * @param dateTime parsed date/time value to render + * @param formatArg Excel-style format string + * @returns formatted string, or `undefined` to defer to the next dispatch step + */ export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: string): Maybe { - // Skip date/time interpretation for Excel currency formats tagged with - // `[$SYMBOL-LCID]` (non-empty SYMBOL portion). parseForDateTimeFormat - // would otherwise greedily consume characters like D, M, S, Y, H inside - // the currency code (e.g. 'USD' contains D, 'CHF' contains H), mangling - // the output when a user-supplied stringifyCurrency callback opts out by - // returning undefined. - // - // The guard intentionally requires at least one character between `[$` - // and the `-` to distinguish currency tags (`[$USD-409]`, `[$€-2]`) from - // Excel's locale-only modifier (`[$-409]`, `[$-F800]`), which is valid - // on date/time formats and must continue to flow through this function. if (LCID_CURRENCY_TAG.test(formatArg)) { return undefined } From e3f73686e3cff6c7def96c83c941c647cb2d594a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 04:53:44 +0000 Subject: [PATCH 27/42] =?UTF-8?q?Tools:=20HF-24=20snippet=20codegen=20?= =?UTF-8?q?=E2=80=94=20close=20O5=20docs=E2=86=94test=20source-of-truth=20?= =?UTF-8?q?gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the build infrastructure to keep the `customStringifyCurrency` adapter (and any future documented snippet) in lockstep between docs/guide/currency-handling.md and downstream test/utility code. Why this exists: The O5 pattern that HF-24 itself surfaced (Bugbot wave 1, commit b81d4afac) was a docs adapter gaining a `typeof !== 'string'` guard while an inline copy in test/.../function-text.spec.ts stayed out of date — edge tests then crashed on `null.split(';')`. Manual "synchronize on every edit" doesn't survive contact with reality. What this lands: - `script/extract-doc-snippets.js` — walks docs/**/*.md, extracts every `` ... `` block (fenced code inside), writes verbatim to test-utils/snippets/.generated.ts with a header banner. Zero npm deps; runs in the same Node we use for `compile`. - `npm run snippets:extract` — regenerates all snippets. - `npm run snippets:check` — extracts + `git diff --exit-code` on the output dir, so CI can gate against drift in a single command. - docs/guide/currency-handling.md — adapter wrapped in `snippet:currency-adapter` markers (cosmetic-only edit; the rendered docs are unchanged because VuePress treats `` as a comment). - test-utils/snippets/currency-adapter.generated.ts — initial extraction committed so downstream callers can `import { customStringifyCurrency }` from a stable path. What's deferred (follow-up PR in hyperformula-tests): - Switch test/hyperformula-tests/unit/interpreter/function-text.spec.ts to `import { customStringifyCurrency } from '../../../..//test-utils/snippets/currency-adapter.generated'` instead of re-defining the adapter inline. That closes the fixture-flagged O5 finding for good; doing it in this PR would mix cross-repo changes and complicate review. - CI integration: a `npm run snippets:check` step in the lint/test workflow. Trivial follow-up once the marker convention lands. Single-source-of-truth direction: documentation. Edit the markdown snippet, run `npm run snippets:extract`, commit the regenerated file — or let CI catch the drift. --- docs/guide/currency-handling.md | 2 + package.json | 2 + script/extract-doc-snippets.js | 145 ++++++++++++++++++ .../snippets/currency-adapter.generated.ts | 76 +++++++++ 4 files changed, 225 insertions(+) create mode 100755 script/extract-doc-snippets.js create mode 100644 test-utils/snippets/currency-adapter.generated.ts diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index e3e19337a2..5b33857fea 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -108,6 +108,7 @@ If your callback throws, HyperFormula propagates the exception. Wrap your format This adapter handles a representative subset of Excel currency format strings using native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). Extend the `LCID_TO_LOCALE` map to cover more locales — see the [MS-LCID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) specification for canonical identifiers. + ```javascript // Minimal Excel-format-string → Intl.NumberFormat adapter. // Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. @@ -182,6 +183,7 @@ export const customStringifyCurrency = (value, currencyFormat) => { return undefined } ``` + Then plug it into your [configuration options](configuration-options.md): diff --git a/package.json b/package.json index e2c8d29d17..3884a6f94e 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "docs:code-examples:generate-js": "bash docs/code-examples-generator.sh", "docs:code-examples:generate-all-js": "bash docs/code-examples-generator.sh --generateAll", "docs:code-examples:format-all-ts": "bash docs/code-examples-generator.sh --formatAllTsExamples", + "snippets:extract": "node script/extract-doc-snippets.js", + "snippets:check": "node script/extract-doc-snippets.js && git diff --exit-code -- test-utils/snippets/", "bundle-all": "cross-env HF_COMPILE=1 npm-run-all clean compile bundle:** verify-bundles", "bundle:es": "(node script/if-ne-env.js HF_COMPILE=1 || npm run compile) && cross-env-shell BABEL_ENV=es env-cmd -f ht.config.js babel lib --out-file-extension .mjs --out-dir es", "bundle:cjs": "(node script/if-ne-env.js HF_COMPILE=1 || npm run compile) && cross-env-shell BABEL_ENV=commonjs env-cmd -f ht.config.js babel lib --out-dir commonjs", diff --git a/script/extract-doc-snippets.js b/script/extract-doc-snippets.js new file mode 100755 index 0000000000..faf6fac3c6 --- /dev/null +++ b/script/extract-doc-snippets.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * extract-doc-snippets.js — extract documented code snippets so tests can + * import the same source-of-truth the docs publish. + * + * Walks `docs/**\/*.md`. For every snippet block of the form + * + * + * ``` + * // code … + * ``` + * + * + * writes the code (verbatim) to `test-utils/snippets/.generated.ts` with + * a header banner naming the source file. Tests then `import { … }` from the + * generated file instead of re-defining the snippet inline; CI gates drift via + * `npm run snippets:extract && git diff --exit-code -- test-utils/snippets/`. + * + * The script intentionally has zero npm deps so it runs in the same Node we + * use for `compile` without adding to package.json. + * + * Exit codes: + * 0 — snippets extracted successfully + * 1 — duplicate snippet name across files, mismatched markers, or other + * structural error + */ +'use strict' + +const fs = require('fs') +const path = require('path') + +const REPO_ROOT = path.resolve(__dirname, '..') +const DOCS_DIR = path.join(REPO_ROOT, 'docs') +const OUT_DIR = path.join(REPO_ROOT, 'test-utils', 'snippets') + +/** Recursively list every `.md` file under `dir`. */ +function listMarkdown(dir) { + const out = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...listMarkdown(p)) + else if (entry.isFile() && entry.name.endsWith('.md')) out.push(p) + } + return out +} + +/** Parse a markdown file and yield { name, lang, code, sourceFile, line }. */ +function * extractSnippets(filePath) { + const text = fs.readFileSync(filePath, 'utf8') + const lines = text.split('\n') + const openRe = /^\s*$/ + const closeRe = /^\s*$/ + const fenceRe = /^```([a-zA-Z0-9]*)\s*$/ + + let i = 0 + while (i < lines.length) { + const openMatch = openRe.exec(lines[i]) + if (!openMatch) { i++; continue } + + const name = openMatch[1] + const startLine = i + 1 + let j = i + 1 + // Skip blank lines between marker and fence. + while (j < lines.length && lines[j].trim() === '') j++ + const fenceOpen = fenceRe.exec(lines[j]) + if (!fenceOpen) { + throw new Error(`${filePath}:${startLine} — snippet:${name} marker not followed by a fenced code block`) + } + const lang = fenceOpen[1] || 'ts' + const codeStart = j + 1 + let k = codeStart + while (k < lines.length && !/^```\s*$/.test(lines[k])) k++ + if (k >= lines.length) { + throw new Error(`${filePath}:${startLine} — snippet:${name} fence opened but never closed`) + } + // Find closing snippet marker after the fence close. + let m = k + 1 + while (m < lines.length && lines[m].trim() === '') m++ + const closeMatch = m < lines.length ? closeRe.exec(lines[m]) : null + if (!closeMatch) { + throw new Error(`${filePath}:${startLine} — snippet:${name} missing closing after fence`) + } + if (closeMatch[1] !== name) { + throw new Error(`${filePath}:${m + 1} — close marker /snippet:${closeMatch[1]} does not match open snippet:${name}`) + } + + yield { + name, + lang, + code: lines.slice(codeStart, k).join('\n'), + sourceFile: path.relative(REPO_ROOT, filePath), + line: startLine, + } + i = m + 1 + } +} + +/** Render the generated file with a stable header banner. */ +function render({ name, lang, code, sourceFile, line }) { + const banner = [ + '// Auto-generated by script/extract-doc-snippets.js — DO NOT EDIT.', + `// Source: ${sourceFile}:${line} (snippet:${name})`, + '// Edit the source markdown then run `npm run snippets:extract`.', + '// CI fails if this file drifts from the source.', + '', + ].join('\n') + // Snippets in docs are JS for readability; the generated `.ts` file consumes + // them as TypeScript. Preserve the original content byte-for-byte. + return banner + code + (code.endsWith('\n') ? '' : '\n') +} + +function main() { + if (!fs.existsSync(DOCS_DIR)) { + console.error(`extract-doc-snippets: docs dir not found at ${DOCS_DIR}`) + process.exit(1) + } + fs.mkdirSync(OUT_DIR, { recursive: true }) + + const seen = new Map() + const written = [] + for (const file of listMarkdown(DOCS_DIR)) { + for (const snippet of extractSnippets(file)) { + if (seen.has(snippet.name)) { + const prev = seen.get(snippet.name) + console.error(`extract-doc-snippets: duplicate snippet name "${snippet.name}"`) + console.error(` first: ${prev.sourceFile}:${prev.line}`) + console.error(` second: ${snippet.sourceFile}:${snippet.line}`) + process.exit(1) + } + seen.set(snippet.name, snippet) + const outPath = path.join(OUT_DIR, `${snippet.name}.generated.ts`) + fs.writeFileSync(outPath, render(snippet)) + written.push(path.relative(REPO_ROOT, outPath)) + } + } + + if (written.length === 0) { + console.log('extract-doc-snippets: no blocks found') + return + } + console.log(`extract-doc-snippets: wrote ${written.length} file(s)`) + for (const w of written) console.log(` ${w}`) +} + +main() diff --git a/test-utils/snippets/currency-adapter.generated.ts b/test-utils/snippets/currency-adapter.generated.ts new file mode 100644 index 0000000000..ffc80ed4d2 --- /dev/null +++ b/test-utils/snippets/currency-adapter.generated.ts @@ -0,0 +1,76 @@ +// Auto-generated by script/extract-doc-snippets.js — DO NOT EDIT. +// Source: docs/guide/currency-handling.md:111 (snippet:currency-adapter) +// Edit the source markdown then run `npm run snippets:extract`. +// CI fails if this file drifts from the source. +// Minimal Excel-format-string → Intl.NumberFormat adapter. +// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. + +const LCID_TO_LOCALE = { + '-409': { locale: 'en-US', currency: 'USD' }, // USD + '-2': { locale: 'de-DE', currency: 'EUR' }, // EUR (generic) + '-411': { locale: 'ja-JP', currency: 'JPY' }, // JPY + '-415': { locale: 'pl-PL', currency: 'PLN' }, // PLN + '-809': { locale: 'en-GB', currency: 'GBP' }, // GBP +} + +const CURRENCY_RULES = [ + // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency + { + pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, + build: (match) => { + const lcid = '-' + match[2] + const fractionDigits = (match[3] || '.').length - 1 + const entry = LCID_TO_LOCALE[lcid] || { locale: 'en-US', currency: 'USD' } + return new Intl.NumberFormat(entry.locale, { + style: 'currency', + currency: entry.currency, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + }, + }, + // $#,##0.00 — USD shorthand + { + pattern: /^\$#,##0(\.0+)?$/, + build: (match) => new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: (match[1] || '.').length - 1, + maximumFractionDigits: (match[1] || '.').length - 1, + }), + }, +] + +// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses +function tryAccountingFormat(value, format) { + const sections = format.split(';') + if (sections.length !== 2) return undefined + const isNegative = value < 0 + const section = sections[isNegative ? 1 : 0] + const parenMatch = /^\(\$#,##0(\.0+)?\)$/.exec(section) + const plainMatch = /^\$#,##0(\.0+)?$/.exec(section) + if (!parenMatch && !plainMatch) return undefined + const fractionDigits = ((parenMatch || plainMatch)[1] || '.').length - 1 + const nf = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + const formatted = nf.format(Math.abs(value)) + return isNegative && parenMatch ? `(${formatted})` : formatted +} + +export const customStringifyCurrency = (value, currencyFormat) => { + if (typeof currencyFormat !== 'string') return undefined + const accounting = tryAccountingFormat(value, currencyFormat) + if (accounting !== undefined) return accounting + + for (const rule of CURRENCY_RULES) { + const match = rule.pattern.exec(currencyFormat) + if (match) return rule.build(match).format(value) + } + // Not a recognized currency format — let HyperFormula fall through + // to the built-in number formatter. + return undefined +} From 4597aa04e7a7657e2598b4222b2c3458fa0334a9 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 04:57:12 +0000 Subject: [PATCH 28/42] Tools: HF-24 wire snippets:check into the lint CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs `npm run snippets:check` (= extract + `git diff --exit-code` on `test-utils/snippets/`) before the linter. Fails the lint job — same gate as eslint — when a documented snippet has drifted from the committed generated file. Closes the feedback loop the codegen infrastructure was built for: docs become a load-bearing source of truth, not a parallel artifact that drifts silently. Companion to 6a441b310 (the codegen MVP); together these mean any future edit to `docs/guide/currency-handling.md`'s adapter snippet must either be regenerated locally or the CI will block the merge. --- .github/workflows/lint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 936ef53fa2..5410b3ddd5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -44,5 +44,8 @@ jobs: - name: Install dependencies run: npm ci + - name: Check docs snippets are in sync with extracted source-of-truth + run: npm run snippets:check + - name: Run linter run: npm run lint From cd7680d4bcd4b644928de0ab0b6f75f753a466a6 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 08:09:23 +0000 Subject: [PATCH 29/42] Tools: HF-24 close 7 review findings from A+C parallel review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the must-fix + should-fix findings from the parallel code-review-quality (A) and sherlock-review (C) passes on PR #1665. **P0 — `tryAccountingFormat` sign-loss bug** (A's only must-fix bug): docs adapter `customStringifyCurrency` in `currency-handling.md` mis-rendered negative values when the negative section was plain `$#,##0.00` (no parens). `Math.abs(value)` was formatted without ever adding the `-` prefix, so `value = -1234.5` against format `"$#,##0.00;$#,##0.00"` returned `$1,234.50` (positive-looking) instead of `-$1,234.50`. New branch: `parenMatch ? "(" + formatted + ")" : "-" + formatted`. Regression test for this path landed in hyperformula-tests `85979a0`. **`extract-doc-snippets.js` hardening** (C's main concerns): - Detect malformed `` markers (no spaces around the body) and fail loudly instead of silently skipping. Pre-fix, a typo in a docs author's marker meant the snippet was silently dropped from `test-utils/snippets/` while `snippets:check` still passed — drift undetected. - Skip symbolic links during `docs/` recursion (loop / sandbox-escape guard). - Cap recursion at `MAX_DEPTH = 8`. - Sort `readdirSync` output for deterministic generated content across platforms (CI-determinism, no more "works on my Mac" drift). - Prune orphan `*.generated.ts` files when a snippet is renamed or removed (otherwise rename leaves a tracked stale file behind, which `snippets:check` still treats as drift-free because it just looks for new diffs). - Render generated file with a `// @ts-nocheck` pragma so the body (verbatim untyped JS from the docs snippet) doesn't block the TypeScript compile when consumed. - Extra blank line between banner and content for grep-friendliness. **CI ordering** (A's should-fix): `npm run snippets:check` now runs AFTER `npm run lint` in `.github/workflows/lint.yml` — an extractor crash on a future malformed marker no longer masks a real lint failure as a docs-tooling failure. **tsconfig.test.json**: `test-utils` added to `include` so the generated snippet IS in the test build graph (combined with `@ts-nocheck`, the file is reachable for `import` without breaking compile). Together these close findings B5 (duplicate-reply pattern is documented in memory as part of the same review), plus all the should-fix items A and C surfaced on the codegen MVP. The follow-up A path (expand docs adapter so the test inline copy can be replaced by an `import` from `test-utils/snippets/currency-adapter.generated`) remains a product decision for Sequba and is tracked in [[project_hf_24_followups]]. --- .github/workflows/lint.yml | 8 +- docs/guide/currency-handling.md | 4 +- script/extract-doc-snippets.js | 101 +++++++++++++++--- .../snippets/currency-adapter.generated.ts | 6 +- tsconfig.test.json | 2 +- 5 files changed, 100 insertions(+), 21 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5410b3ddd5..807c652563 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -44,8 +44,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Check docs snippets are in sync with extracted source-of-truth - run: npm run snippets:check - - name: Run linter run: npm run lint + + - name: Check docs snippets are in sync with extracted source-of-truth + # After lint so an extractor crash doesn't mask lint failures + # downstream consumers care about. + run: npm run snippets:check diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index 5b33857fea..c2fc61c242 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -150,6 +150,7 @@ const CURRENCY_RULES = [ ] // Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses +// (or $#,##0.00;$#,##0.00 — both sections plain, sign rendered explicitly) function tryAccountingFormat(value, format) { const sections = format.split(';') if (sections.length !== 2) return undefined @@ -166,7 +167,8 @@ function tryAccountingFormat(value, format) { maximumFractionDigits: fractionDigits, }) const formatted = nf.format(Math.abs(value)) - return isNegative && parenMatch ? `(${formatted})` : formatted + if (!isNegative) return formatted + return parenMatch ? `(${formatted})` : `-${formatted}` } export const customStringifyCurrency = (value, currencyFormat) => { diff --git a/script/extract-doc-snippets.js b/script/extract-doc-snippets.js index faf6fac3c6..d8af9b08e6 100755 --- a/script/extract-doc-snippets.js +++ b/script/extract-doc-snippets.js @@ -19,10 +19,20 @@ * The script intentionally has zero npm deps so it runs in the same Node we * use for `compile` without adding to package.json. * + * Constraints: + * - Snippet bodies must NOT contain nested triple-backtick fences. The + * closing fence matcher accepts any `^```\s*$` line, so a nested fenced + * block inside a snippet would terminate the outer match early. + * - Symlinks under `docs/` are not followed (loop / sandbox-escape guard). + * - Recursion depth is capped at MAX_DEPTH to prevent runaway walks. + * - File enumeration order is stabilised via sort() so generated content + * is byte-identical across platforms (CI-determinism). + * * Exit codes: - * 0 — snippets extracted successfully - * 1 — duplicate snippet name across files, mismatched markers, or other - * structural error + * 0 — snippets extracted successfully (or no markers present) + * 1 — any structural error: malformed marker, mismatched markers, + * duplicate name, fence opened-but-not-closed, marker mismatch, + * or missing docs dir */ 'use strict' @@ -32,30 +42,60 @@ const path = require('path') const REPO_ROOT = path.resolve(__dirname, '..') const DOCS_DIR = path.join(REPO_ROOT, 'docs') const OUT_DIR = path.join(REPO_ROOT, 'test-utils', 'snippets') +const MAX_DEPTH = 8 -/** Recursively list every `.md` file under `dir`. */ -function listMarkdown(dir) { +/** + * Recursively list every `.md` file under `dir`. Skips symlinks and bails + * past MAX_DEPTH so a stray loop under `docs/` can't hang the build. + * Entries are sorted at every level for deterministic ordering across + * platforms / filesystems. + */ +function listMarkdown(dir, depth = 0) { + if (depth > MAX_DEPTH) { + console.error(`extract-doc-snippets: depth limit (${MAX_DEPTH}) exceeded under ${dir} — refusing to recurse further`) + return [] + } + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => !e.isSymbolicLink()) // never follow symlinks + .sort((a, b) => a.name.localeCompare(b.name)) const out = [] - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + for (const entry of entries) { const p = path.join(dir, entry.name) - if (entry.isDirectory()) out.push(...listMarkdown(p)) + if (entry.isDirectory()) out.push(...listMarkdown(p, depth + 1)) else if (entry.isFile() && entry.name.endsWith('.md')) out.push(p) } return out } +/** Throw with a precise diagnostic when a line looks like a snippet marker + * but doesn't match the strict grammar (open or close). Catches typos such + * as `` (no spaces) which the strict regex skips silently. */ +const malformedSniffRe = /\s*$/ - const closeRe = /^\s*$/ + const openRe = /^\s*$/ + const closeRe = /^\s*$/ const fenceRe = /^```([a-zA-Z0-9]*)\s*$/ let i = 0 while (i < lines.length) { - const openMatch = openRe.exec(lines[i]) - if (!openMatch) { i++; continue } + const line = lines[i] + const openMatch = openRe.exec(line) + if (!openMatch) { + // Distinguish "not a marker at all" from "looks like a malformed marker". + if (malformedSniffRe.test(line) && !closeRe.exec(line)) { + throw new Error( + `${filePath}:${i + 1} — line looks like a snippet marker but doesn't match the grammar. ` + + `Required form: \`\` (note the spaces around the marker body). ` + + `Got: \`${line.trim()}\``, + ) + } + i++ + continue + } const name = openMatch[1] const startLine = i + 1 @@ -103,12 +143,32 @@ function render({ name, lang, code, sourceFile, line }) { '// Edit the source markdown then run `npm run snippets:extract`.', '// CI fails if this file drifts from the source.', '', + // Docs snippets are written in JS without TypeScript annotations (they + // need to copy-paste runnable for the reader). The .ts extension lets + // consumers `import { … }` without a tsconfig.allowJs change, but the + // body is intentionally untyped — `@ts-nocheck` keeps tsc quiet without + // forcing every snippet author to learn TypeScript. + '// @ts-nocheck', + '', ].join('\n') // Snippets in docs are JS for readability; the generated `.ts` file consumes // them as TypeScript. Preserve the original content byte-for-byte. return banner + code + (code.endsWith('\n') ? '' : '\n') } +/** Remove `*.generated.ts` files in OUT_DIR that aren't in `keep`. */ +function pruneOrphans(keep) { + if (!fs.existsSync(OUT_DIR)) return [] + const removed = [] + for (const name of fs.readdirSync(OUT_DIR).sort()) { + if (!name.endsWith('.generated.ts')) continue + if (keep.has(name)) continue + fs.unlinkSync(path.join(OUT_DIR, name)) + removed.push(path.relative(REPO_ROOT, path.join(OUT_DIR, name))) + } + return removed +} + function main() { if (!fs.existsSync(DOCS_DIR)) { console.error(`extract-doc-snippets: docs dir not found at ${DOCS_DIR}`) @@ -118,6 +178,7 @@ function main() { const seen = new Map() const written = [] + const keepBasenames = new Set() for (const file of listMarkdown(DOCS_DIR)) { for (const snippet of extractSnippets(file)) { if (seen.has(snippet.name)) { @@ -128,18 +189,28 @@ function main() { process.exit(1) } seen.set(snippet.name, snippet) - const outPath = path.join(OUT_DIR, `${snippet.name}.generated.ts`) + const basename = `${snippet.name}.generated.ts` + const outPath = path.join(OUT_DIR, basename) fs.writeFileSync(outPath, render(snippet)) written.push(path.relative(REPO_ROOT, outPath)) + keepBasenames.add(basename) } } - if (written.length === 0) { + const removed = pruneOrphans(keepBasenames) + + if (written.length === 0 && removed.length === 0) { console.log('extract-doc-snippets: no blocks found') return } - console.log(`extract-doc-snippets: wrote ${written.length} file(s)`) - for (const w of written) console.log(` ${w}`) + if (written.length > 0) { + console.log(`extract-doc-snippets: wrote ${written.length} file(s)`) + for (const w of written) console.log(` ${w}`) + } + if (removed.length > 0) { + console.log(`extract-doc-snippets: pruned ${removed.length} orphan(s)`) + for (const r of removed) console.log(` - ${r}`) + } } main() diff --git a/test-utils/snippets/currency-adapter.generated.ts b/test-utils/snippets/currency-adapter.generated.ts index ffc80ed4d2..712050d6e8 100644 --- a/test-utils/snippets/currency-adapter.generated.ts +++ b/test-utils/snippets/currency-adapter.generated.ts @@ -2,6 +2,8 @@ // Source: docs/guide/currency-handling.md:111 (snippet:currency-adapter) // Edit the source markdown then run `npm run snippets:extract`. // CI fails if this file drifts from the source. + +// @ts-nocheck // Minimal Excel-format-string → Intl.NumberFormat adapter. // Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. @@ -42,6 +44,7 @@ const CURRENCY_RULES = [ ] // Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses +// (or $#,##0.00;$#,##0.00 — both sections plain, sign rendered explicitly) function tryAccountingFormat(value, format) { const sections = format.split(';') if (sections.length !== 2) return undefined @@ -58,7 +61,8 @@ function tryAccountingFormat(value, format) { maximumFractionDigits: fractionDigits, }) const formatted = nf.format(Math.abs(value)) - return isNegative && parenMatch ? `(${formatted})` : formatted + if (!isNegative) return formatted + return parenMatch ? `(${formatted})` : `-${formatted}` } export const customStringifyCurrency = (value, currencyFormat) => { diff --git a/tsconfig.test.json b/tsconfig.test.json index a40e705170..9c874320bf 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -7,7 +7,7 @@ "sourceMap": true }, "extends": "./tsconfig", - "include": ["src", "test"], + "include": ["src", "test", "test-utils"], /* Exclude files that are specific for jest setup */ "exclude": ["test/_setupFiles/jest"] } From f66fd41c532c2eb2a52b1bea1ac4fa05bdaf8217 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 08:23:15 +0000 Subject: [PATCH 30/42] chore: HF-24 retrigger CI to pick up tests-repo fix (ed38a4f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser-tests + unit-tests CI runs on HEAD `83dafd163` failed against the previous tests-repo HEAD `85979a0` whose `function-text.spec.ts` letter-hijack assertion was too strict. Fixed in hyperformula-tests:feature/hf-24-stringify-currency commit `ed38a4f` (reverts to checking only `[$SYMBOL-` boundary, which the date-parser hijack would destroy, instead of the full `[$SYMBOL-LCID]` prefix that gets re-shaped by the fallback number formatter). Empty commit triggers CI re-fetch of tests-repo by branch name — same pattern as the standard cross-repo iteration loop ([[reference_cross_repo_ci]]). No HF code/docs changes in this commit. From e1f30917184b2a49482786e3aee4eec2ce8bc21e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 09:29:49 +0000 Subject: [PATCH 31/42] =?UTF-8?q?Docs:=20HF-24=20revert=20tryAccountingFor?= =?UTF-8?q?mat=20sign-loss=20"fix"=20=E2=80=94=20pre-fix=20matches=20Excel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the explicit `-` prefix branch introduced for the plain-negative section case in `83dafd163`. Empirical Excel 2021 verification on 2026-05-25 against format `$#,##0.00;$#,##0.00` with value -1234.5 renders `$1 234.50` (no minus) — NOT `-$1 234.50` as the original code-review reasoning claimed. Excel format-string spec: an explicit two-section format `;` is honoured AS-IS. Auto-sign only applies to single-section formats. The pre-fix adapter logic `return isNegative && parenMatch ? '(' + formatted + ')' : formatted` mirrors Excel exactly across all four (pos/neg) × (paren/plain) cases. The earlier "fix" introduced a regression for the negative×plain case. Updated the inline comment to call out the Excel-spec rationale so the behaviour is no longer misread as an oversight by future readers. Regenerated `test-utils/snippets/currency-adapter.generated.ts` via `npm run snippets:extract` to keep the source-of-truth in sync. Companion test revert + regression-test reframing in hyperformula-tests commit `5e8226c` on `feature/hf-24-stringify-currency`. Closes-out the false-positive must-fix that consumed two commits and a Slack-Q draft cycle. --- docs/guide/currency-handling.md | 10 ++++++---- test-utils/snippets/currency-adapter.generated.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index c2fc61c242..ecc35bf061 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -149,8 +149,11 @@ const CURRENCY_RULES = [ }, ] -// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses -// (or $#,##0.00;$#,##0.00 — both sections plain, sign rendered explicitly) +// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses. +// Note: when both sections are plain (e.g. `$#,##0.00;$#,##0.00`), Excel +// honors the negative section AS-IS without auto-prepending `-` — the +// format author explicitly opted out of automatic sign. This adapter +// mirrors that behavior. function tryAccountingFormat(value, format) { const sections = format.split(';') if (sections.length !== 2) return undefined @@ -167,8 +170,7 @@ function tryAccountingFormat(value, format) { maximumFractionDigits: fractionDigits, }) const formatted = nf.format(Math.abs(value)) - if (!isNegative) return formatted - return parenMatch ? `(${formatted})` : `-${formatted}` + return isNegative && parenMatch ? `(${formatted})` : formatted } export const customStringifyCurrency = (value, currencyFormat) => { diff --git a/test-utils/snippets/currency-adapter.generated.ts b/test-utils/snippets/currency-adapter.generated.ts index 712050d6e8..d1b0ef8511 100644 --- a/test-utils/snippets/currency-adapter.generated.ts +++ b/test-utils/snippets/currency-adapter.generated.ts @@ -43,8 +43,11 @@ const CURRENCY_RULES = [ }, ] -// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses -// (or $#,##0.00;$#,##0.00 — both sections plain, sign rendered explicitly) +// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses. +// Note: when both sections are plain (e.g. `$#,##0.00;$#,##0.00`), Excel +// honors the negative section AS-IS without auto-prepending `-` — the +// format author explicitly opted out of automatic sign. This adapter +// mirrors that behavior. function tryAccountingFormat(value, format) { const sections = format.split(';') if (sections.length !== 2) return undefined @@ -61,8 +64,7 @@ function tryAccountingFormat(value, format) { maximumFractionDigits: fractionDigits, }) const formatted = nf.format(Math.abs(value)) - if (!isNegative) return formatted - return parenMatch ? `(${formatted})` : `-${formatted}` + return isNegative && parenMatch ? `(${formatted})` : formatted } export const customStringifyCurrency = (value, currencyFormat) => { From 3e8e49652c70309b8bbbb69069a3bde91502e62a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 10:46:45 +0000 Subject: [PATCH 32/42] chore: HF-24 retrigger CI for tests-repo dead-code drop (87e72b5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty commit to trigger fetch-tests of the updated `hyperformula-tests:feature/hf-24-stringify-currency` HEAD `87e72b5`, which drops the inline `customStringifyCurrency` adapter copy from `function-text.spec.ts` (~100 LOC including the unused symbol-suffix 3rd rule + localeBySymbol map) and consumes the docs snippet directly via `require('../../../../test-utils/snippets/currency-adapter.generated')`. This closes the codegen MVP loop end-to-end on HF-24: docs/guide/currency-handling.md → snippets:extract → test-utils/snippets/ currency-adapter.generated.ts → required by function-text.spec.ts. snippets:check CI gate prevents drift on either side from this point on. Expected effect on next `prep audit 1665`: O5 (source-of-truth duplication) detection count drops from 13 to ≤2 — only the two `hfInstance` FP detections from compatibility-* docs remain (canonical naming convention across the codebase, not real duplication; tracked as follow-up D in project_hf_24_followups memory). From 85c7733eccae06279539103d0ceb8ff1fe486891 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 11:38:46 +0000 Subject: [PATCH 33/42] HF-24 final pre-flip cleanup: lint + Excel parity + CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three concerns surfaced by `prep ultra` (Opus fresh-eyes review) and the A+C parallel review on HEAD `c3b65382a`. Addressing all three before flip-to-review. **L1 — Docs adapter regex divergence from core guard** (Ultra Low): The first `CURRENCY_RULES` pattern in `docs/guide/currency-handling.md` used `[^\-\]]*` (zero-or-more) for the SYMBOL portion, where the production `LCID_CURRENCY_TAG` in `src/format/format.ts:26` uses `[^\-\]]+` (one-or-more). The looser docs regex would cause copy-paste users to mis-classify Excel's locale-only modifier `[$-409]` (used on date/time formats) as a currency format and route it through the LCID table, producing silent en-US currency formatting on what is meant to be a Polish/English date format. Tightened to `+` and added an explanatory comment so future readers know why the constraint is one-or-more. Regenerated `test-utils/snippets/currency-adapter.generated.ts` via `npm run snippets:extract` so the codegen artifact picks up the fix. **M1 — CHANGELOG framing** (Ultra Medium): The existing `Added a stringifyCurrency config option` line is purely additive framing. But the LCID guards in `defaultStringifyDateTime` and `defaultStringifyDuration` change observable `TEXT` output for **every** LCID-tagged currency format (`[$USD-409] #,##0.00`, `[$€-2] #,##0.00`, etc.) regardless of whether a `stringifyCurrency` callback is configured. Pre-fix: mangled by the date parser (`[$US9-409]`). Post-fix: falls through to `numberFormat`. Strictly an improvement, but upgraders who snapshot-test `TEXT()` output should know to expect it. Added a `Fixed` entry describing the behavioural correction with concrete before/after. **Lint blocker** (A+C parallel review): Added `test-utils/snippets/` to `.eslintignore`. Generated adapter content carries `@ts-nocheck` (necessary — JS body without TypeScript annotations) and has implicit-`any` operands (`'-' + match[2]`) that ESLint's `@typescript-eslint/ban-ts-comment` + `no-unsafe-*` rules flagged as errors on HEAD `c3b65382a` lint(22). Auto-generated content shouldn't be linted; `script/`, `commonjs/`, `dist/`, etc. were already ignored — extending the same policy to `test-utils/snippets/` is consistent. Companion fix on hyperformula-tests `feature/hf-24-stringify-currency` commit `7663f5c` cleans up pre-existing lint errors that became visible after PR #1672 extended lint scope to tests-repo (`Array` → `T[]`, `opt_out` → `optOut`, `as unknown as string` → `as string`). Local verification (2026-05-25): - `npm run compile` clean - `npx tsc -p tsconfig.test.json --noEmit` clean - `npm run snippets:check` exit 0 (generated byte-identical post-regen) - `eslint` on modified files: 0 errors - `npm run test:jest -- --testPathPattern function-text` reports 53/53 pass --- .eslintignore | 1 + CHANGELOG.md | 1 + docs/guide/currency-handling.md | 7 +++++-- test-utils/snippets/currency-adapter.generated.ts | 7 +++++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.eslintignore b/.eslintignore index 03546876e2..bd53198caa 100644 --- a/.eslintignore +++ b/.eslintignore @@ -24,5 +24,6 @@ lib script test-jasmine test-jest +test-utils/snippets typedoc typings diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7d6e5e0e..9fa413b835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed a memory leak in `UndoRedo` where `oldData` entries for evicted undo stack entries were never cleaned up, causing increasing memory usage over time. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Fixed the IRR function returning `#NUM!` error when the initial investment significantly exceeds the sum of returns. [#1628](https://github.com/handsontable/hyperformula/issues/1628) - Fixed the ADDRESS function ignoring `defaultValue` when arguments are syntactically empty (e.g., `=ADDRESS(2,3,,FALSE())`). [#1632](https://github.com/handsontable/hyperformula/issues/1632) +- Fixed the `TEXT` function mangling LCID-tagged currency format strings (e.g. `[$USD-409] #,##0.00`) — pre-fix, the date-time parser greedily consumed letter tokens inside the currency code (`D` in USD, `H` in CHF/HUF, etc.), producing strings like `[$US9-409]`. The default dispatch now short-circuits LCID-tagged currency formats so they fall through to the number formatter (or the user-supplied `stringifyCurrency` callback). Applies to every LCID-tagged currency format regardless of whether `stringifyCurrency` is configured. [#1665](https://github.com/handsontable/hyperformula/pull/1665) ## [3.2.0] - 2026-02-19 diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index ecc35bf061..f13cb63020 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -122,9 +122,12 @@ const LCID_TO_LOCALE = { } const CURRENCY_RULES = [ - // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency + // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency. + // SYMBOL portion requires at least one character (`+`, not `*`) so that + // locale-only modifiers like `[$-409]` (used on date/time formats) are + // NOT misclassified as currency by this adapter. { - pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, + pattern: /^\[\$([^\-\]]+)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, build: (match) => { const lcid = '-' + match[2] const fractionDigits = (match[3] || '.').length - 1 diff --git a/test-utils/snippets/currency-adapter.generated.ts b/test-utils/snippets/currency-adapter.generated.ts index d1b0ef8511..88a63ad349 100644 --- a/test-utils/snippets/currency-adapter.generated.ts +++ b/test-utils/snippets/currency-adapter.generated.ts @@ -16,9 +16,12 @@ const LCID_TO_LOCALE = { } const CURRENCY_RULES = [ - // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency + // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency. + // SYMBOL portion requires at least one character (`+`, not `*`) so that + // locale-only modifiers like `[$-409]` (used on date/time formats) are + // NOT misclassified as currency by this adapter. { - pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, + pattern: /^\[\$([^\-\]]+)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, build: (match) => { const lcid = '-' + match[2] const fractionDigits = (match[3] || '.').length - 1 From 23ffc97ae33ed77b7c9b1d7ea75977477e978975 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 13:14:01 +0000 Subject: [PATCH 34/42] Cleanup: HF-24 two minor refinements from line-by-line audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cosmetic nits surfaced by Marcin's line-by-line file review on 2026-05-25, fixed in one commit since they touch the same iteration. **Nit 1 — `docs/guide/currency-handling.md:89` post-HF-24 dispatch wording** The line previously said "Dates, durations, and unrecognized formats continue through HyperFormula's *existing* dispatch chain." Accurate pre-HF-24, slightly stale post-HF-24 because `stringifyCurrency` now runs FIRST in the dispatch order (the very change this PR ships). Reworded to: "For any format the callback opts out of, HyperFormula proceeds to the next handler in the dispatch chain: the default date / duration formatters, then the built-in number formatter, and finally the raw format string if nothing matched." Concrete and forward-correct. **Nit 2 — Block-level comments leaked into generated test artifact** The generated `test-utils/snippets/currency-adapter.generated.ts` is a byte-for-byte copy of the docs snippet, which means editorial comments useful for a docs reader (e.g. `// Minimal Excel-format-string → Intl.NumberFormat adapter.`, `// [$SYMBOL-LCID] — Excel's locale-tagged currency.`) flow through into the test artifact unchanged. They add verbosity without value for the downstream `import` consumer (jest). Added `stripBlockComments(code)` to `script/extract-doc-snippets.js` that drops pure `// …` lines (entire line is whitespace + comment) and collapses the resulting runs of blank lines. Trailing comments after live code are deliberately left alone — stripping those safely needs a JS tokenizer (to skip `'https://…'`-style string literals), and the block-level strip alone reduces the generated file from 85 to 71 lines on the current snippet, which addresses the noise concern. Regenerated the artifact via `npm run snippets:extract` so the `snippets:check` CI gate stays clean. Verification (2026-05-25): - `npm run compile` clean - `npx tsc -p tsconfig.test.json --noEmit` clean - `npm run snippets:check` exit 0 (regen produces committed bytes) - `npm run test:jest -- --testPathPattern function-text` reports 53/53 pass (no test depends on the stripped comments) --- docs/guide/currency-handling.md | 2 +- script/extract-doc-snippets.js | 36 +++++++++++++++++-- .../snippets/currency-adapter.generated.ts | 14 -------- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index f13cb63020..bacd0093f6 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -86,7 +86,7 @@ const hf = HyperFormula.buildFromArray([ console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" ``` -This callback handles `$`-prefixed formats and falls through (returns `undefined`) for everything else. Dates, durations, and unrecognized formats continue through HyperFormula's existing dispatch chain. +This callback handles `$`-prefixed formats and falls through (returns `undefined`) for everything else. For any format the callback opts out of, HyperFormula proceeds to the next handler in the dispatch chain: the default date / duration formatters, then the built-in number formatter, and finally the raw format string if nothing matched. #### Reference table diff --git a/script/extract-doc-snippets.js b/script/extract-doc-snippets.js index d8af9b08e6..ad2fcb3c16 100755 --- a/script/extract-doc-snippets.js +++ b/script/extract-doc-snippets.js @@ -135,6 +135,37 @@ function * extractSnippets(filePath) { } } +/** + * Strip pure-comment lines from the snippet body. + * + * Editorial `//` comments in the docs snippet (e.g. `// EUR (generic)`, + * `// $#,##0.00 — USD shorthand`) are useful in the published page where + * a reader is studying the code, but they add noise in the generated test + * artifact where a downstream `import` consumer only cares about the + * functional code. We strip lines that contain ONLY whitespace + `// + * comment` (block-level). Trailing `// comment` after live code is left + * alone — that case needs a JS-aware tokenizer to avoid clobbering URL + * literals like `'https://…'`, and the noise reduction from block-level + * stripping alone is already significant. Collapses runs of resulting + * blank lines to a single blank for readability. + */ +function stripBlockComments(code) { + const lines = code.split('\n') + const blockCommentRe = /^\s*\/\/.*$/ + const kept = lines.filter((ln) => !blockCommentRe.test(ln)) + // Collapse 2+ consecutive blank lines into 1 — keeps section breaks but + // avoids the "every comment was here" gaps the strip would otherwise leave. + const out = [] + let prevBlank = false + for (const ln of kept) { + const isBlank = ln.trim() === '' + if (isBlank && prevBlank) continue + out.push(ln) + prevBlank = isBlank + } + return out.join('\n') +} + /** Render the generated file with a stable header banner. */ function render({ name, lang, code, sourceFile, line }) { const banner = [ @@ -151,9 +182,8 @@ function render({ name, lang, code, sourceFile, line }) { '// @ts-nocheck', '', ].join('\n') - // Snippets in docs are JS for readability; the generated `.ts` file consumes - // them as TypeScript. Preserve the original content byte-for-byte. - return banner + code + (code.endsWith('\n') ? '' : '\n') + const body = stripBlockComments(code) + return banner + body + (body.endsWith('\n') ? '' : '\n') } /** Remove `*.generated.ts` files in OUT_DIR that aren't in `keep`. */ diff --git a/test-utils/snippets/currency-adapter.generated.ts b/test-utils/snippets/currency-adapter.generated.ts index 88a63ad349..5ebb1ac3f4 100644 --- a/test-utils/snippets/currency-adapter.generated.ts +++ b/test-utils/snippets/currency-adapter.generated.ts @@ -4,8 +4,6 @@ // CI fails if this file drifts from the source. // @ts-nocheck -// Minimal Excel-format-string → Intl.NumberFormat adapter. -// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. const LCID_TO_LOCALE = { '-409': { locale: 'en-US', currency: 'USD' }, // USD @@ -16,10 +14,6 @@ const LCID_TO_LOCALE = { } const CURRENCY_RULES = [ - // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency. - // SYMBOL portion requires at least one character (`+`, not `*`) so that - // locale-only modifiers like `[$-409]` (used on date/time formats) are - // NOT misclassified as currency by this adapter. { pattern: /^\[\$([^\-\]]+)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, build: (match) => { @@ -34,7 +28,6 @@ const CURRENCY_RULES = [ }) }, }, - // $#,##0.00 — USD shorthand { pattern: /^\$#,##0(\.0+)?$/, build: (match) => new Intl.NumberFormat('en-US', { @@ -46,11 +39,6 @@ const CURRENCY_RULES = [ }, ] -// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses. -// Note: when both sections are plain (e.g. `$#,##0.00;$#,##0.00`), Excel -// honors the negative section AS-IS without auto-prepending `-` — the -// format author explicitly opted out of automatic sign. This adapter -// mirrors that behavior. function tryAccountingFormat(value, format) { const sections = format.split(';') if (sections.length !== 2) return undefined @@ -79,7 +67,5 @@ export const customStringifyCurrency = (value, currencyFormat) => { const match = rule.pattern.exec(currencyFormat) if (match) return rule.build(match).format(value) } - // Not a recognized currency format — let HyperFormula fall through - // to the built-in number formatter. return undefined } From 1a40b261c10e0217872b16059b4764fc72bfccf8 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 14:50:27 +0000 Subject: [PATCH 35/42] HF-24 brutal-honesty cleanup: CHANGELOG section + codegen docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the brutal-honesty self-review surfaced after CI went green: **Finding 2 — CHANGELOG entries under a RELEASED version.** Per Sequba's policy stated on PR #1645 (2026-04-03): *"Release notes are updated later (during the release). During the day-to-day development we only need to keep the CHANGELOG up-to-date"* — meaning in-progress PRs add entries to `[Unreleased]`, not to the most recent shipped version. `[3.3.0]` is dated 2026-05-20 (frozen, released). Moving both the existing `Added stringifyCurrency` line (carried over from an earlier commit in this branch) and the new `Fixed` entry for the LCID guard regression from `[3.3.0]` to `[Unreleased]`. Editing released sections rewrites history; that's what `[Unreleased]` is for. **Finding 1a — `byte-identical` codegen contract claim was stale.** The first codegen commit (`6a441b310`) claimed "byte-identical regen byte-identical with the docs snippet" and the script docstring said "writes the code (verbatim)". The recent `stripBlockComments` change made that no longer true: 40+ lines of editorial block comments present in the docs source are dropped from the generated artifact. The drift gate (`snippets:check`) is unchanged (`generated matches its own regeneration`) but the **contract description was a lie**. Rewrote the script docstring to say what actually happens: generated is "functionally equivalent, not byte-identical"; what's gated is "generated matches its own regeneration", not "generated matches docs body". Future engineers reading the script no longer hit the "wait, this doesn't match docs — is this a bug?" moment. No code-behaviour changes — both fixes are documentation accuracy. --- CHANGELOG.md | 7 +++++-- script/extract-doc-snippets.js | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa413b835..d875046a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) +- Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) + +### Fixed + +- Fixed the `TEXT` function mangling LCID-tagged currency format strings (e.g. `[$USD-409] #,##0.00`) — pre-fix, the date-time parser greedily consumed letter tokens inside the currency code (`D` in USD, `H` in CHF/HUF, etc.), producing strings like `[$US9-409]`. The default dispatch now short-circuits LCID-tagged currency formats so they fall through to the number formatter (or the user-supplied `stringifyCurrency` callback). Applies to every LCID-tagged currency format regardless of whether `stringifyCurrency` is configured. [#1665](https://github.com/handsontable/hyperformula/pull/1665) ## [3.3.0] - 2026-05-20 @@ -20,7 +25,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added `maxPendingLazyTransformations` configuration option to control memory usage by limiting accumulated transformations before cleanup. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Added a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) - Added a new function: SEQUENCE. [#1645](https://github.com/handsontable/hyperformula/pull/1645) -- Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) ### Fixed @@ -28,7 +32,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed a memory leak in `UndoRedo` where `oldData` entries for evicted undo stack entries were never cleaned up, causing increasing memory usage over time. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Fixed the IRR function returning `#NUM!` error when the initial investment significantly exceeds the sum of returns. [#1628](https://github.com/handsontable/hyperformula/issues/1628) - Fixed the ADDRESS function ignoring `defaultValue` when arguments are syntactically empty (e.g., `=ADDRESS(2,3,,FALSE())`). [#1632](https://github.com/handsontable/hyperformula/issues/1632) -- Fixed the `TEXT` function mangling LCID-tagged currency format strings (e.g. `[$USD-409] #,##0.00`) — pre-fix, the date-time parser greedily consumed letter tokens inside the currency code (`D` in USD, `H` in CHF/HUF, etc.), producing strings like `[$US9-409]`. The default dispatch now short-circuits LCID-tagged currency formats so they fall through to the number formatter (or the user-supplied `stringifyCurrency` callback). Applies to every LCID-tagged currency format regardless of whether `stringifyCurrency` is configured. [#1665](https://github.com/handsontable/hyperformula/pull/1665) ## [3.2.0] - 2026-02-19 diff --git a/script/extract-doc-snippets.js b/script/extract-doc-snippets.js index ad2fcb3c16..3f7a2adae6 100755 --- a/script/extract-doc-snippets.js +++ b/script/extract-doc-snippets.js @@ -11,11 +11,21 @@ * ``` * * - * writes the code (verbatim) to `test-utils/snippets/.generated.ts` with - * a header banner naming the source file. Tests then `import { … }` from the + * writes the code to `test-utils/snippets/.generated.ts` with a header + * banner naming the source file. Tests then `import { … }` from the * generated file instead of re-defining the snippet inline; CI gates drift via * `npm run snippets:extract && git diff --exit-code -- test-utils/snippets/`. * + * **Generated content vs docs source.** The generated `.ts` is functionally + * equivalent to the docs snippet but NOT byte-identical: `stripBlockComments` + * removes lines whose entire content is a `//` comment (e.g. editorial + * section dividers) before writing. Trailing `// comment` after live code is + * preserved. The docs page keeps the educational comments for human readers; + * the generated artifact keeps only the runnable surface for the import + * consumer. Both regenerate from the same source so the drift gate still + * applies — what's gated is "generated matches its OWN regeneration", not + * "generated matches docs body byte-for-byte". + * * The script intentionally has zero npm deps so it runs in the same Node we * use for `compile` without adding to package.json. * From b91d12544f2040acd33e758caf73557593541b7d Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 25 May 2026 15:28:12 +0000 Subject: [PATCH 36/42] HF-24 retrigger CI to pick up tests-repo lint fix 6ebdbf8 handsontable/hyperformula-tests@6ebdbf8 moves the eslint-disable-next-line annotation inside the try-block so it covers the actual require() call (was on the const declaration two lines up, which left the require unprotected and tripped lint on b018aed68 CI). This empty commit re-runs the cross-repo CI matrix on the same main-repo HEAD against the updated tests-repo HEAD. No source changes here. From 139d76afd996c297c9a3e74dc62953313d2ef636 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 26 May 2026 03:06:43 +0000 Subject: [PATCH 37/42] HF-24 retrigger CI for tests-repo lint+xdescribe fix (8c87bbe) handsontable/hyperformula-tests@8c87bbe defers the generated-adapter access into a beforeAll hook so the standalone-clone fallback path no longer crashes Karma/Jasmine. Empirically verified by hiding the generated file and re-running Jest: 20 passed + 53 skipped, no TypeError. Re-runs the cross-repo CI matrix on the same HF-main HEAD against the updated tests-repo HEAD. No source changes here. From cbdd9fd1fe8df35d2eeb7e53658a68dda7904711 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 27 May 2026 05:01:13 +0000 Subject: [PATCH 38/42] =?UTF-8?q?docs(HF-24):=20address=20review=20threads?= =?UTF-8?q?=20=E2=80=94=20LCID=20Excel=20behavior,=20double-quote=20escapi?= =?UTF-8?q?ng,=20list-of-differences=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list-of-differences.md: reword TEXT function row to Kuba's suggested phrasing - currency-handling.md: add paragraph explaining Excel's native LCID resolution - known-limitations.md: explain Excel's double-quote escape behavior before stating HF limitation Co-Authored-By: Claude Sonnet 4.6 --- docs/guide/currency-handling.md | 2 ++ docs/guide/known-limitations.md | 2 +- docs/guide/list-of-differences.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index bacd0093f6..29fcac2489 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -220,6 +220,8 @@ The output values above contain non-breaking spaces (U+00A0 or U+202F depending Excel can mark a currency format with a [Microsoft Locale Identifier](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) (LCID) so the symbol carries locale context. The syntax is `[$SYMBOL-LCID]` followed by the number template — for example `[$zł-415] #,##0.00` means *"Polish złoty, hex LCID `415` = `pl-PL`"*, and `[$€-2] #,##0.00` means *"euro, generic"*. The adapter above parses the LCID to pick the matching `Intl.NumberFormat` locale and ISO 4217 currency code. +**Excel resolves LCID tags natively** — no extra configuration is required. For example, `[$€-2] #,##0.00` automatically uses European grouping and decimal separators and produces `1.234,50 €`; `[$zł-415] #,##0.00` uses `pl-PL` and produces `1 234,50 zł`. HyperFormula's built-in formatter does not resolve LCID tags; the adapter above replicates that behavior via `Intl.NumberFormat`. + #### When to swap in a library The adapter above covers a small but representative subset of Excel currency format strings (LCID-tagged, USD shorthand, accounting two-section) in under one page of code, with a fall-through path for everything else. If you need: diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index e947586ac4..f8d0f2d0c0 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,7 +38,7 @@ you can't compare the arguments in a formula like this: * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. -* The TEXT function does not accept embedded double-quote literals in the format string (e.g., `=TEXT(A1, "#,##0.00 ""zł""")` fails at parse time). Use Excel's LCID-tagged form — `[$SYMBOL-LCID]` where LCID is a hex [Microsoft Locale ID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid), e.g. `[$zł-415] #,##0.00` for Polish złoty — or supply a custom [`stringifyCurrency`](currency-handling.md) callback that handles such formats outside the parser. For locale-specific patterns like the Polish `"1234,50 zł"` (decimal comma), the callback is required because the built-in number formatter always emits `.` as the decimal separator. +* The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — so `#,##0.00 ""zł""` is equivalent to `#,##0.00 "zł"` (a number pattern with `zł` as a quoted literal suffix) and `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. HyperFormula's parser does not support this escape sequence and fails at parse time. Use Excel's LCID-tagged form — `[$SYMBOL-LCID]` where LCID is a hex [Microsoft Locale ID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid), e.g. `[$zł-415] #,##0.00` for Polish złoty — or supply a custom [`stringifyCurrency`](currency-handling.md) callback that handles such formats outside the parser. For locale-specific patterns like the Polish `"1234,50 zł"` (decimal comma), the callback is required because the built-in number formatter always emits `.` as the decimal separator. ### OFFSET function diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index a4f75c6a7f..aa0e684fe1 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -34,7 +34,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | Applying a scalar value to a function taking range | COLUMNS(A1) | `CellRangeExpected` error. | Treats the element as length-1 range. Returns 1 for the example. | Same as Google Sheets. | | Coercion of explicit arguments | VARP(2, 3, 4, TRUE(), FALSE(), "1",) | 1.9592, based on the behavior of Microsoft Excel. | GoogleSheets implementation is not consistent with the standard (see also `VAR.S`, `STDEV.P`, and `STDEV.S` function.) | 1.9592 | | Ranges created with `:` | A1:A2

A$1:$A$2

A:C

1:2

Sheet1!A1:A2 | Allowed ranges consist of two addresses (A1:B5), columns (A:C) or rows (3:5).
They cannot be mixed or contain named expressions. | Everything allowed. | Same as Google Sheets. | -| Formatting inside the TEXT function | TEXT(A1,"dd-mm-yy")

TEXT(A1,"###.###”) | Not all formatting options are supported,
e.g., only some date formatting options: (`hh`, `mm`, `ss`, `am`, `pm`, `a`, `p`, `dd`, `yy`, and `yyyy`). Plug in [`stringifyDateTime`](compatibility-with-microsoft-excel.md#date-and-time-formats) and [`stringifyCurrency`](currency-handling.md) for full coverage. | A wide variety of options for string formatting is supported. | Same as Google Sheets. | +| Formatting inside the TEXT function | TEXT(A1,"dd-mm-yy")

TEXT(A1,"###.###”) | To support all date, time and currency formats, set [`stringifyDateTime`](compatibility-with-microsoft-excel.md#date-and-time-formats) and [`stringifyCurrency`](currency-handling.md) configuration options. | A wide variety of options for string formatting is supported. | Same as Google Sheets. | | Cell references inside inline arrays | ={A1, A2} | The array's value is calculated but not updated when the cells' values change. | The array's value is calculated and updated when the cells' values change. | ERROR: invalid array | | SPLIT function | =SPLIT("Lorem ipsum dolor", 0) | This function works differently from Google Sheets version but should be sufficient to achieve the same functionality in most scenarios. Read SPLIT function description on [the Built-in Functions page](built-in-functions.md#text). | Different syntax and return value. | No such function. | | DATEVALUE function | =DATEVALUE("25/02/1991") | Type of the returned value: `CellValueDetailedType.NUMBER_DATE` (compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard) | Cell auto-formatted as **regular number** | Cell auto-formatted as **regular number** | From cb2fa8fa5fd581695f1aa0419776c259c65a61ca Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 27 May 2026 09:29:07 +0000 Subject: [PATCH 39/42] Docs: HF-24 add .md extension to bare slug links in built-in-functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness 04-link-validation flagged two relative links missing the .md extension: localizing-functions → localizing-functions.md and custom-functions → custom-functions.md. VuePress resolves both forms at build time, but adding .md is consistent with all other links in the file and removes the harness warning. --- docs/guide/built-in-functions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index ee027e0e29..cc535dbcf7 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -28,7 +28,7 @@ spreadsheet software. That is because a spreadsheet is probably the most universal software ever created. We wanted the same flexibility for HyperFormula but without the constraints of the spreadsheet UI. -Each of HyperFormula's built-in function names is available in [17 languages](localizing-functions.md#list-of-supported-languages) and [custom language packs](localizing-functions) can be added. +Each of HyperFormula's built-in function names is available in [17 languages](localizing-functions.md#list-of-supported-languages) and [custom language packs](localizing-functions.md) can be added. The latest version of HyperFormula has an extensive collection of **{{ $page.functionsCount }}** functions grouped into categories: @@ -50,7 +50,7 @@ The latest version of HyperFormula has an extensive collection of _Some categories such as compatibility and cube are yet to be supported._ ::: tip -You can modify the built-in functions or create your own, by adding a [custom function](custom-functions). +You can modify the built-in functions or create your own, by adding a [custom function](custom-functions.md). ::: ## List of available functions From cd024e7a0a4f6dbd8c18e1ba121c71a0748e9161 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 4 Jun 2026 04:43:18 +0000 Subject: [PATCH 40/42] fix(HF-24): generate doc snippets at test time; changelog & docs polish Addresses review on #1665. Snippets: - Stop committing test-utils/snippets/*.generated.ts; regenerate them from the docs before every test run instead (docs stay the single source of truth, no duplicated snippet code in the repo). - package.json: drop snippets:check (the git-diff drift gate); add pretest:ci, pretest:jest and pretest:browser hooks that run snippets:extract, so the artifacts are generated by the CI test jobs and locally before jest/karma. - .gitignore: ignore test-utils/snippets/*.generated.ts. - lint.yml: remove the snippets drift check (no longer part of linting). - extract-doc-snippets.js: update docstring/banner to the regenerate-on-test model. Docs/changelog: - CHANGELOG: move the LCID-tagged currency-format entry to Added (new capability). - known-limitations.md, currency-handling.md: tighten wording. --- .github/workflows/lint.yml | 5 -- .gitignore | 4 ++ CHANGELOG.md | 5 +- docs/guide/currency-handling.md | 2 +- docs/guide/known-limitations.md | 2 +- package.json | 4 +- script/extract-doc-snippets.js | 15 ++-- .../snippets/currency-adapter.generated.ts | 71 ------------------- 8 files changed, 18 insertions(+), 90 deletions(-) delete mode 100644 test-utils/snippets/currency-adapter.generated.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 807c652563..936ef53fa2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -46,8 +46,3 @@ jobs: - name: Run linter run: npm run lint - - - name: Check docs snippets are in sync with extracted source-of-truth - # After lint so an extractor crash doesn't mask lint failures - # downstream consumers care about. - run: npm run snippets:check diff --git a/.gitignore b/.gitignore index 98181e71c4..e4c1d80892 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ dev*.html .DS_Store /test/hyperformula-tests/ + +# Doc snippets are regenerated from the docs before every test run +# (see the `snippets:extract` / `pretest:*` scripts), so they are never committed. +/test-utils/snippets/*.generated.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d875046a92..07af73229f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) - Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) - -### Fixed - -- Fixed the `TEXT` function mangling LCID-tagged currency format strings (e.g. `[$USD-409] #,##0.00`) — pre-fix, the date-time parser greedily consumed letter tokens inside the currency code (`D` in USD, `H` in CHF/HUF, etc.), producing strings like `[$US9-409]`. The default dispatch now short-circuits LCID-tagged currency formats so they fall through to the number formatter (or the user-supplied `stringifyCurrency` callback). Applies to every LCID-tagged currency format regardless of whether `stringifyCurrency` is configured. [#1665](https://github.com/handsontable/hyperformula/pull/1665) +- Added support for LCID-tagged currency format strings (e.g. `[$USD-409] #,##0.00`) in the `TEXT` function. Previously the date-time parser greedily consumed letter tokens inside the currency code (`D` in USD, `H` in CHF/HUF, etc.), producing corrupted output like `[$US9-409]`; such formats now fall through to the number formatter (or a user-supplied `stringifyCurrency` callback), regardless of whether `stringifyCurrency` is configured. [#1665](https://github.com/handsontable/hyperformula/pull/1665) ## [3.3.0] - 2026-05-20 diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md index 29fcac2489..3962ae3055 100644 --- a/docs/guide/currency-handling.md +++ b/docs/guide/currency-handling.md @@ -55,7 +55,7 @@ const hf = HyperFormula.buildFromArray([[1234.5, '=TEXT(A1, "0.00 zł")']]); console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "1234.50 zł" ``` -Configure `stringifyCurrency` when your formula corpus needs: +Configure `stringifyCurrency` when your formula corpus needs more advanced currency formats. E.g.: - thousands grouping (`"$#,##0.00"`), - non-`$` symbols with grouping (`"[$€-2] #,##0.00"`, `"[$zł-415] #,##0.00"`), diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index f8d0f2d0c0..6da2b3ae49 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,7 +38,7 @@ you can't compare the arguments in a formula like this: * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. -* The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — so `#,##0.00 ""zł""` is equivalent to `#,##0.00 "zł"` (a number pattern with `zł` as a quoted literal suffix) and `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. HyperFormula's parser does not support this escape sequence and fails at parse time. Use Excel's LCID-tagged form — `[$SYMBOL-LCID]` where LCID is a hex [Microsoft Locale ID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid), e.g. `[$zł-415] #,##0.00` for Polish złoty — or supply a custom [`stringifyCurrency`](currency-handling.md) callback that handles such formats outside the parser. For locale-specific patterns like the Polish `"1234,50 zł"` (decimal comma), the callback is required because the built-in number formatter always emits `.` as the decimal separator. +* The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. ### OFFSET function diff --git a/package.json b/package.json index 3884a6f94e..b3c7fa5598 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "docs:code-examples:generate-all-js": "bash docs/code-examples-generator.sh --generateAll", "docs:code-examples:format-all-ts": "bash docs/code-examples-generator.sh --formatAllTsExamples", "snippets:extract": "node script/extract-doc-snippets.js", - "snippets:check": "node script/extract-doc-snippets.js && git diff --exit-code -- test-utils/snippets/", "bundle-all": "cross-env HF_COMPILE=1 npm-run-all clean compile bundle:** verify-bundles", "bundle:es": "(node script/if-ne-env.js HF_COMPILE=1 || npm run compile) && cross-env-shell BABEL_ENV=es env-cmd -f ht.config.js babel lib --out-file-extension .mjs --out-dir es", "bundle:cjs": "(node script/if-ne-env.js HF_COMPILE=1 || npm run compile) && cross-env-shell BABEL_ENV=commonjs env-cmd -f ht.config.js babel lib --out-dir commonjs", @@ -79,6 +78,7 @@ "verify:typings": "tsc --noEmit", "test": "npm-run-all lint test:jest test:browser", "test:setup-private": "bash test/fetch-tests.sh", + "pretest:jest": "npm run snippets:extract", "test:jest": "cross-env NODE_ICU_DATA=node_modules/full-icu jest", "test:watch": "npm run test:jest -- --watch", "test:tmp": "npm run test:jest -- --watch function-irr", @@ -86,7 +86,9 @@ "test:logMemory": "npm run test:jest -- --runInBand --logHeapUsage", "test:performance": "npm run benchmark:basic && npm run benchmark:cruds", "test:compatibility": "bash test/compatibility/test-compatibility.sh", + "pretest:ci": "npm run snippets:extract", "test:ci": "cross-env NODE_ICU_DATA=node_modules/full-icu node --expose-gc ./node_modules/jest/bin/jest --forceExit", + "pretest:browser": "npm run snippets:extract", "test:browser": "cross-env-shell BABEL_ENV=dist env-cmd -f ht.config.js karma start", "test:browser.debug": "cross-env-shell BABEL_ENV=dist NODE_ENV=debug env-cmd -f ht.config.js karma start", "typedoc:build-api": "cross-env NODE_OPTIONS=--openssl-legacy-provider typedoc --options .typedoc.md.ts", diff --git a/script/extract-doc-snippets.js b/script/extract-doc-snippets.js index 3f7a2adae6..cbcb8f4ba0 100755 --- a/script/extract-doc-snippets.js +++ b/script/extract-doc-snippets.js @@ -13,8 +13,11 @@ * * writes the code to `test-utils/snippets/.generated.ts` with a header * banner naming the source file. Tests then `import { … }` from the - * generated file instead of re-defining the snippet inline; CI gates drift via - * `npm run snippets:extract && git diff --exit-code -- test-utils/snippets/`. + * generated file instead of re-defining the snippet inline. The generated + * files are NOT committed: they are regenerated from the docs before every + * test run via the `pretest:*` hooks in package.json, which keeps the docs as + * the single source of truth and avoids duplicating the snippet code in the + * repository. * * **Generated content vs docs source.** The generated `.ts` is functionally * equivalent to the docs snippet but NOT byte-identical: `stripBlockComments` @@ -22,9 +25,7 @@ * section dividers) before writing. Trailing `// comment` after live code is * preserved. The docs page keeps the educational comments for human readers; * the generated artifact keeps only the runnable surface for the import - * consumer. Both regenerate from the same source so the drift gate still - * applies — what's gated is "generated matches its OWN regeneration", not - * "generated matches docs body byte-for-byte". + * consumer. * * The script intentionally has zero npm deps so it runs in the same Node we * use for `compile` without adding to package.json. @@ -181,8 +182,8 @@ function render({ name, lang, code, sourceFile, line }) { const banner = [ '// Auto-generated by script/extract-doc-snippets.js — DO NOT EDIT.', `// Source: ${sourceFile}:${line} (snippet:${name})`, - '// Edit the source markdown then run `npm run snippets:extract`.', - '// CI fails if this file drifts from the source.', + '// Edit the source markdown — this file is regenerated before every test', + '// run (the `pretest:*` hooks) and is not committed to the repository.', '', // Docs snippets are written in JS without TypeScript annotations (they // need to copy-paste runnable for the reader). The .ts extension lets diff --git a/test-utils/snippets/currency-adapter.generated.ts b/test-utils/snippets/currency-adapter.generated.ts deleted file mode 100644 index 5ebb1ac3f4..0000000000 --- a/test-utils/snippets/currency-adapter.generated.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Auto-generated by script/extract-doc-snippets.js — DO NOT EDIT. -// Source: docs/guide/currency-handling.md:111 (snippet:currency-adapter) -// Edit the source markdown then run `npm run snippets:extract`. -// CI fails if this file drifts from the source. - -// @ts-nocheck - -const LCID_TO_LOCALE = { - '-409': { locale: 'en-US', currency: 'USD' }, // USD - '-2': { locale: 'de-DE', currency: 'EUR' }, // EUR (generic) - '-411': { locale: 'ja-JP', currency: 'JPY' }, // JPY - '-415': { locale: 'pl-PL', currency: 'PLN' }, // PLN - '-809': { locale: 'en-GB', currency: 'GBP' }, // GBP -} - -const CURRENCY_RULES = [ - { - pattern: /^\[\$([^\-\]]+)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, - build: (match) => { - const lcid = '-' + match[2] - const fractionDigits = (match[3] || '.').length - 1 - const entry = LCID_TO_LOCALE[lcid] || { locale: 'en-US', currency: 'USD' } - return new Intl.NumberFormat(entry.locale, { - style: 'currency', - currency: entry.currency, - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }) - }, - }, - { - pattern: /^\$#,##0(\.0+)?$/, - build: (match) => new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: (match[1] || '.').length - 1, - maximumFractionDigits: (match[1] || '.').length - 1, - }), - }, -] - -function tryAccountingFormat(value, format) { - const sections = format.split(';') - if (sections.length !== 2) return undefined - const isNegative = value < 0 - const section = sections[isNegative ? 1 : 0] - const parenMatch = /^\(\$#,##0(\.0+)?\)$/.exec(section) - const plainMatch = /^\$#,##0(\.0+)?$/.exec(section) - if (!parenMatch && !plainMatch) return undefined - const fractionDigits = ((parenMatch || plainMatch)[1] || '.').length - 1 - const nf = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }) - const formatted = nf.format(Math.abs(value)) - return isNegative && parenMatch ? `(${formatted})` : formatted -} - -export const customStringifyCurrency = (value, currencyFormat) => { - if (typeof currencyFormat !== 'string') return undefined - const accounting = tryAccountingFormat(value, currencyFormat) - if (accounting !== undefined) return accounting - - for (const rule of CURRENCY_RULES) { - const match = rule.pattern.exec(currencyFormat) - if (match) return rule.build(match).format(value) - } - return undefined -} From 24b2a201d8612526eb213358bed9afaffa788791 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 4 Jun 2026 09:45:21 +0000 Subject: [PATCH 41/42] ci(HF-24): note snippet pre-generation in test workflow The unit-tests and browser-tests jobs regenerate the doc snippets via the `pretest:ci` / `pretest:browser` npm hooks before running the tests; add a comment at each "Run tests" step so the generation is discoverable in the workflow without duplicating it as a separate step. --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9486ae9250..c8ab6f4c2d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,8 @@ jobs: - name: Install dependencies run: npm ci + # Doc snippets are regenerated before the tests by the `pretest:ci` npm + # hook (runs `snippets:extract`); the generated files are not committed. - name: Run tests run: npm run test:ci -- --coverage @@ -82,5 +84,7 @@ jobs: - name: Install dependencies run: npm ci + # Doc snippets are regenerated before the tests by the `pretest:browser` + # npm hook (runs `snippets:extract`); the generated files are not committed. - name: Run tests run: npm run test:browser From 260f316bf3198c7a5fd15978e6c959fb395c57c9 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 15 Jun 2026 03:06:19 +0000 Subject: [PATCH 42/42] fix(HF-24): run snippets:extract inside test scripts, drop unreliable pretest hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Kuba's review on #1665 (inline threads + 2026-06-09 dev sync): the `pretest:jest` / `pretest:ci` / `pretest:browser` npm hooks did not reliably run `snippets:extract` before the tests — the top-level `test` script invokes the test scripts via `npm-run-all`, which does not fire npm `pre*` lifecycle hooks, so the generated snippet artifacts could be missing and tests would error. Per Kuba's suggestion, prepend `npm run snippets:extract &&` directly to `test:jest`, `test:ci`, and `test:browser` so generation is bulletproof on every invocation path (CI and local), and remove the redundant `pretest:*` hooks. - package.json: inline `snippets:extract` into test:jest / test:ci / test:browser; remove pretest:jest / pretest:ci / pretest:browser. - .github/workflows/test.yml: update the unit- and browser-test comments to reference the inline `test:*` mechanism instead of the removed pretest hooks. - CHANGELOG.md: shorten the LCID-tagged currency entry to Kuba's suggested one-liner. - .gitignore, script/extract-doc-snippets.js: refresh stale comments that referenced the removed `pretest:*` hooks. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 8 ++++---- .gitignore | 2 +- CHANGELOG.md | 2 +- package.json | 9 +++------ script/extract-doc-snippets.js | 8 ++++---- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8ab6f4c2d..5b5bab88e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,8 +44,8 @@ jobs: - name: Install dependencies run: npm ci - # Doc snippets are regenerated before the tests by the `pretest:ci` npm - # hook (runs `snippets:extract`); the generated files are not committed. + # `test:ci` regenerates the doc snippets (runs `snippets:extract`) before + # running the tests; the generated files are not committed. - name: Run tests run: npm run test:ci -- --coverage @@ -84,7 +84,7 @@ jobs: - name: Install dependencies run: npm ci - # Doc snippets are regenerated before the tests by the `pretest:browser` - # npm hook (runs `snippets:extract`); the generated files are not committed. + # `test:browser` regenerates the doc snippets (runs `snippets:extract`) + # before running the tests; the generated files are not committed. - name: Run tests run: npm run test:browser diff --git a/.gitignore b/.gitignore index e4c1d80892..f63fec1732 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,5 @@ dev*.html /test/hyperformula-tests/ # Doc snippets are regenerated from the docs before every test run -# (see the `snippets:extract` / `pretest:*` scripts), so they are never committed. +# (the `test:*` scripts run `snippets:extract` first), so they are never committed. /test-utils/snippets/*.generated.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 07af73229f..9ef13cc160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) - Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) -- Added support for LCID-tagged currency format strings (e.g. `[$USD-409] #,##0.00`) in the `TEXT` function. Previously the date-time parser greedily consumed letter tokens inside the currency code (`D` in USD, `H` in CHF/HUF, etc.), producing corrupted output like `[$US9-409]`; such formats now fall through to the number formatter (or a user-supplied `stringifyCurrency` callback), regardless of whether `stringifyCurrency` is configured. [#1665](https://github.com/handsontable/hyperformula/pull/1665) +- Added support for LCID-tagged currency format strings (e.g. `[$USD-409] #,##0.00`) in the `TEXT` function. [#1665](https://github.com/handsontable/hyperformula/pull/1665) ## [3.3.0] - 2026-05-20 diff --git a/package.json b/package.json index b3c7fa5598..cd9767e34c 100644 --- a/package.json +++ b/package.json @@ -78,18 +78,15 @@ "verify:typings": "tsc --noEmit", "test": "npm-run-all lint test:jest test:browser", "test:setup-private": "bash test/fetch-tests.sh", - "pretest:jest": "npm run snippets:extract", - "test:jest": "cross-env NODE_ICU_DATA=node_modules/full-icu jest", + "test:jest": "npm run snippets:extract && cross-env NODE_ICU_DATA=node_modules/full-icu jest", "test:watch": "npm run test:jest -- --watch", "test:tmp": "npm run test:jest -- --watch function-irr", "test:coverage": "npm run test:jest -- --coverage", "test:logMemory": "npm run test:jest -- --runInBand --logHeapUsage", "test:performance": "npm run benchmark:basic && npm run benchmark:cruds", "test:compatibility": "bash test/compatibility/test-compatibility.sh", - "pretest:ci": "npm run snippets:extract", - "test:ci": "cross-env NODE_ICU_DATA=node_modules/full-icu node --expose-gc ./node_modules/jest/bin/jest --forceExit", - "pretest:browser": "npm run snippets:extract", - "test:browser": "cross-env-shell BABEL_ENV=dist env-cmd -f ht.config.js karma start", + "test:ci": "npm run snippets:extract && cross-env NODE_ICU_DATA=node_modules/full-icu node --expose-gc ./node_modules/jest/bin/jest --forceExit", + "test:browser": "npm run snippets:extract && cross-env-shell BABEL_ENV=dist env-cmd -f ht.config.js karma start", "test:browser.debug": "cross-env-shell BABEL_ENV=dist NODE_ENV=debug env-cmd -f ht.config.js karma start", "typedoc:build-api": "cross-env NODE_OPTIONS=--openssl-legacy-provider typedoc --options .typedoc.md.ts", "benchmark:basic": "npm run tsnode test/hyperformula-tests/performance/run-basic-benchmark.ts", diff --git a/script/extract-doc-snippets.js b/script/extract-doc-snippets.js index cbcb8f4ba0..3fc85f34f5 100755 --- a/script/extract-doc-snippets.js +++ b/script/extract-doc-snippets.js @@ -15,9 +15,9 @@ * banner naming the source file. Tests then `import { … }` from the * generated file instead of re-defining the snippet inline. The generated * files are NOT committed: they are regenerated from the docs before every - * test run via the `pretest:*` hooks in package.json, which keeps the docs as - * the single source of truth and avoids duplicating the snippet code in the - * repository. + * test run by the `test:*` scripts in package.json (each runs `snippets:extract` + * first), which keeps the docs as the single source of truth and avoids + * duplicating the snippet code in the repository. * * **Generated content vs docs source.** The generated `.ts` is functionally * equivalent to the docs snippet but NOT byte-identical: `stripBlockComments` @@ -183,7 +183,7 @@ function render({ name, lang, code, sourceFile, line }) { '// Auto-generated by script/extract-doc-snippets.js — DO NOT EDIT.', `// Source: ${sourceFile}:${line} (snippet:${name})`, '// Edit the source markdown — this file is regenerated before every test', - '// run (the `pretest:*` hooks) and is not committed to the repository.', + '// run (the `test:*` scripts run `snippets:extract`) and is not committed.', '', // Docs snippets are written in JS without TypeScript annotations (they // need to copy-paste runnable for the reader). The .ts extension lets