Skip to content

Commit 6a441b3

Browse files
Tools: HF-24 snippet codegen — close O5 docs↔test source-of-truth gap
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 b81d4af) 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 `<!-- snippet:NAME -->` ... `<!-- /snippet:NAME -->` block (fenced code inside), writes verbatim to test-utils/snippets/<NAME>.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 '../../../../<repo>/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.
1 parent d11cfd9 commit 6a441b3

4 files changed

Lines changed: 225 additions & 0 deletions

File tree

docs/guide/currency-handling.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ If your callback throws, HyperFormula propagates the exception. Wrap your format
108108

109109
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.
110110

111+
<!-- snippet:currency-adapter -->
111112
```javascript
112113
// Minimal Excel-format-string → Intl.NumberFormat adapter.
113114
// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats.
@@ -182,6 +183,7 @@ export const customStringifyCurrency = (value, currencyFormat) => {
182183
return undefined
183184
}
184185
```
186+
<!-- /snippet:currency-adapter -->
185187

186188
Then plug it into your [configuration options](configuration-options.md):
187189

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
"docs:code-examples:generate-js": "bash docs/code-examples-generator.sh",
6161
"docs:code-examples:generate-all-js": "bash docs/code-examples-generator.sh --generateAll",
6262
"docs:code-examples:format-all-ts": "bash docs/code-examples-generator.sh --formatAllTsExamples",
63+
"snippets:extract": "node script/extract-doc-snippets.js",
64+
"snippets:check": "node script/extract-doc-snippets.js && git diff --exit-code -- test-utils/snippets/",
6365
"bundle-all": "cross-env HF_COMPILE=1 npm-run-all clean compile bundle:** verify-bundles",
6466
"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",
6567
"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",

script/extract-doc-snippets.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env node
2+
/**
3+
* extract-doc-snippets.js — extract documented code snippets so tests can
4+
* import the same source-of-truth the docs publish.
5+
*
6+
* Walks `docs/**\/*.md`. For every snippet block of the form
7+
*
8+
* <!-- snippet:NAME -->
9+
* ```<lang>
10+
* // code …
11+
* ```
12+
* <!-- /snippet:NAME -->
13+
*
14+
* writes the code (verbatim) to `test-utils/snippets/<NAME>.generated.ts` with
15+
* a header banner naming the source file. Tests then `import { … }` from the
16+
* generated file instead of re-defining the snippet inline; CI gates drift via
17+
* `npm run snippets:extract && git diff --exit-code -- test-utils/snippets/`.
18+
*
19+
* The script intentionally has zero npm deps so it runs in the same Node we
20+
* use for `compile` without adding to package.json.
21+
*
22+
* Exit codes:
23+
* 0 — snippets extracted successfully
24+
* 1 — duplicate snippet name across files, mismatched markers, or other
25+
* structural error
26+
*/
27+
'use strict'
28+
29+
const fs = require('fs')
30+
const path = require('path')
31+
32+
const REPO_ROOT = path.resolve(__dirname, '..')
33+
const DOCS_DIR = path.join(REPO_ROOT, 'docs')
34+
const OUT_DIR = path.join(REPO_ROOT, 'test-utils', 'snippets')
35+
36+
/** Recursively list every `.md` file under `dir`. */
37+
function listMarkdown(dir) {
38+
const out = []
39+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
40+
const p = path.join(dir, entry.name)
41+
if (entry.isDirectory()) out.push(...listMarkdown(p))
42+
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(p)
43+
}
44+
return out
45+
}
46+
47+
/** Parse a markdown file and yield { name, lang, code, sourceFile, line }. */
48+
function * extractSnippets(filePath) {
49+
const text = fs.readFileSync(filePath, 'utf8')
50+
const lines = text.split('\n')
51+
const openRe = /^<!--\s*snippet:([a-zA-Z][a-zA-Z0-9_-]*)\s*-->\s*$/
52+
const closeRe = /^<!--\s*\/snippet:([a-zA-Z][a-zA-Z0-9_-]*)\s*-->\s*$/
53+
const fenceRe = /^```([a-zA-Z0-9]*)\s*$/
54+
55+
let i = 0
56+
while (i < lines.length) {
57+
const openMatch = openRe.exec(lines[i])
58+
if (!openMatch) { i++; continue }
59+
60+
const name = openMatch[1]
61+
const startLine = i + 1
62+
let j = i + 1
63+
// Skip blank lines between marker and fence.
64+
while (j < lines.length && lines[j].trim() === '') j++
65+
const fenceOpen = fenceRe.exec(lines[j])
66+
if (!fenceOpen) {
67+
throw new Error(`${filePath}:${startLine} — snippet:${name} marker not followed by a fenced code block`)
68+
}
69+
const lang = fenceOpen[1] || 'ts'
70+
const codeStart = j + 1
71+
let k = codeStart
72+
while (k < lines.length && !/^```\s*$/.test(lines[k])) k++
73+
if (k >= lines.length) {
74+
throw new Error(`${filePath}:${startLine} — snippet:${name} fence opened but never closed`)
75+
}
76+
// Find closing snippet marker after the fence close.
77+
let m = k + 1
78+
while (m < lines.length && lines[m].trim() === '') m++
79+
const closeMatch = m < lines.length ? closeRe.exec(lines[m]) : null
80+
if (!closeMatch) {
81+
throw new Error(`${filePath}:${startLine} — snippet:${name} missing closing <!-- /snippet:${name} --> after fence`)
82+
}
83+
if (closeMatch[1] !== name) {
84+
throw new Error(`${filePath}:${m + 1} — close marker /snippet:${closeMatch[1]} does not match open snippet:${name}`)
85+
}
86+
87+
yield {
88+
name,
89+
lang,
90+
code: lines.slice(codeStart, k).join('\n'),
91+
sourceFile: path.relative(REPO_ROOT, filePath),
92+
line: startLine,
93+
}
94+
i = m + 1
95+
}
96+
}
97+
98+
/** Render the generated file with a stable header banner. */
99+
function render({ name, lang, code, sourceFile, line }) {
100+
const banner = [
101+
'// Auto-generated by script/extract-doc-snippets.js — DO NOT EDIT.',
102+
`// Source: ${sourceFile}:${line} (snippet:${name})`,
103+
'// Edit the source markdown then run `npm run snippets:extract`.',
104+
'// CI fails if this file drifts from the source.',
105+
'',
106+
].join('\n')
107+
// Snippets in docs are JS for readability; the generated `.ts` file consumes
108+
// them as TypeScript. Preserve the original content byte-for-byte.
109+
return banner + code + (code.endsWith('\n') ? '' : '\n')
110+
}
111+
112+
function main() {
113+
if (!fs.existsSync(DOCS_DIR)) {
114+
console.error(`extract-doc-snippets: docs dir not found at ${DOCS_DIR}`)
115+
process.exit(1)
116+
}
117+
fs.mkdirSync(OUT_DIR, { recursive: true })
118+
119+
const seen = new Map()
120+
const written = []
121+
for (const file of listMarkdown(DOCS_DIR)) {
122+
for (const snippet of extractSnippets(file)) {
123+
if (seen.has(snippet.name)) {
124+
const prev = seen.get(snippet.name)
125+
console.error(`extract-doc-snippets: duplicate snippet name "${snippet.name}"`)
126+
console.error(` first: ${prev.sourceFile}:${prev.line}`)
127+
console.error(` second: ${snippet.sourceFile}:${snippet.line}`)
128+
process.exit(1)
129+
}
130+
seen.set(snippet.name, snippet)
131+
const outPath = path.join(OUT_DIR, `${snippet.name}.generated.ts`)
132+
fs.writeFileSync(outPath, render(snippet))
133+
written.push(path.relative(REPO_ROOT, outPath))
134+
}
135+
}
136+
137+
if (written.length === 0) {
138+
console.log('extract-doc-snippets: no <!-- snippet:NAME --> blocks found')
139+
return
140+
}
141+
console.log(`extract-doc-snippets: wrote ${written.length} file(s)`)
142+
for (const w of written) console.log(` ${w}`)
143+
}
144+
145+
main()
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Auto-generated by script/extract-doc-snippets.js — DO NOT EDIT.
2+
// Source: docs/guide/currency-handling.md:111 (snippet:currency-adapter)
3+
// Edit the source markdown then run `npm run snippets:extract`.
4+
// CI fails if this file drifts from the source.
5+
// Minimal Excel-format-string → Intl.NumberFormat adapter.
6+
// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats.
7+
8+
const LCID_TO_LOCALE = {
9+
'-409': { locale: 'en-US', currency: 'USD' }, // USD
10+
'-2': { locale: 'de-DE', currency: 'EUR' }, // EUR (generic)
11+
'-411': { locale: 'ja-JP', currency: 'JPY' }, // JPY
12+
'-415': { locale: 'pl-PL', currency: 'PLN' }, // PLN
13+
'-809': { locale: 'en-GB', currency: 'GBP' }, // GBP
14+
}
15+
16+
const CURRENCY_RULES = [
17+
// [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency
18+
{
19+
pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/,
20+
build: (match) => {
21+
const lcid = '-' + match[2]
22+
const fractionDigits = (match[3] || '.').length - 1
23+
const entry = LCID_TO_LOCALE[lcid] || { locale: 'en-US', currency: 'USD' }
24+
return new Intl.NumberFormat(entry.locale, {
25+
style: 'currency',
26+
currency: entry.currency,
27+
minimumFractionDigits: fractionDigits,
28+
maximumFractionDigits: fractionDigits,
29+
})
30+
},
31+
},
32+
// $#,##0.00 — USD shorthand
33+
{
34+
pattern: /^\$#,##0(\.0+)?$/,
35+
build: (match) => new Intl.NumberFormat('en-US', {
36+
style: 'currency',
37+
currency: 'USD',
38+
minimumFractionDigits: (match[1] || '.').length - 1,
39+
maximumFractionDigits: (match[1] || '.').length - 1,
40+
}),
41+
},
42+
]
43+
44+
// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses
45+
function tryAccountingFormat(value, format) {
46+
const sections = format.split(';')
47+
if (sections.length !== 2) return undefined
48+
const isNegative = value < 0
49+
const section = sections[isNegative ? 1 : 0]
50+
const parenMatch = /^\(\$#,##0(\.0+)?\)$/.exec(section)
51+
const plainMatch = /^\$#,##0(\.0+)?$/.exec(section)
52+
if (!parenMatch && !plainMatch) return undefined
53+
const fractionDigits = ((parenMatch || plainMatch)[1] || '.').length - 1
54+
const nf = new Intl.NumberFormat('en-US', {
55+
style: 'currency',
56+
currency: 'USD',
57+
minimumFractionDigits: fractionDigits,
58+
maximumFractionDigits: fractionDigits,
59+
})
60+
const formatted = nf.format(Math.abs(value))
61+
return isNegative && parenMatch ? `(${formatted})` : formatted
62+
}
63+
64+
export const customStringifyCurrency = (value, currencyFormat) => {
65+
if (typeof currencyFormat !== 'string') return undefined
66+
const accounting = tryAccountingFormat(value, currencyFormat)
67+
if (accounting !== undefined) return accounting
68+
69+
for (const rule of CURRENCY_RULES) {
70+
const match = rule.pattern.exec(currencyFormat)
71+
if (match) return rule.build(match).format(value)
72+
}
73+
// Not a recognized currency format — let HyperFormula fall through
74+
// to the built-in number formatter.
75+
return undefined
76+
}

0 commit comments

Comments
 (0)