Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions LANGUAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,9 @@ toCurrency(value, { optionName: value })

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`negativeWord`|cardinal|`string`|`ناقص`|Custom word for negative numbers|
|`gender`|ordinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|ordinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|

### Australian English (`en-AU`)

Expand All @@ -138,7 +138,7 @@ toCurrency(value, { optionName: value })

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`andWord`|cardinal|`string`|`ו`|Custom conjunction word|

### Brazilian Portuguese (`pt-BR`)
Expand Down Expand Up @@ -182,7 +182,7 @@ toCurrency(value, { optionName: value })

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|

### Dutch (Netherlands) (`nl-NL`)

Expand Down Expand Up @@ -275,8 +275,8 @@ toCurrency(value, { optionName: value })

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|ordinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`gender`|ordinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`and`|currency|`boolean`|`true`|Use "con" between euros and cents|

### French (Belgium) (`fr-BE`)
Expand Down Expand Up @@ -315,61 +315,61 @@ toCurrency(value, { optionName: value })

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Gender for numbers < 1000|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Gender for numbers < 1000|

### Lithuanian (Lithuania) (`lt-LT`)

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Gender for numbers < 1000|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Gender for numbers < 1000|

### Mexican Spanish (`es-MX`)

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|ordinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`gender`|ordinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`and`|currency|`boolean`|`true`|Use "con" between pesos and centavos|

### Polish (Poland) (`pl-PL`)

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Gender for numbers < 1000|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Gender for numbers < 1000|

### Romanian (Romania) (`ro-RO`)

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Gender for numbers|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Gender for numbers|

### Russian (Russia) (`ru-RU`)

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`and`|currency|`boolean`|`true`|Use "и" between rubles and kopecks|

### Serbian (Cyrillic, Serbia) (`sr-Cyrl-RS`)

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`and`|currency|`boolean`|`true`|Use "и" between dinars and para|

### Serbian (Latin, Serbia) (`sr-Latn-RS`)

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`and`|currency|`boolean`|`true`|Use "i" between dinars and para|

### Spanish (United States) (`es-US`)

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|ordinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`gender`|ordinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
|`and`|currency|`boolean`|`true`|Use "con" between dollars and cents|

### Turkish (Türkiye) (`tr-TR`)
Expand All @@ -382,4 +382,4 @@ toCurrency(value, { optionName: value })

|Option|Form|Type|Default|Description|
|------|----|----|-------|-----------|
|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender|
|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|
148 changes: 80 additions & 68 deletions scripts/generate-languages-md.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
*/

import { writeFileSync, readdirSync } from 'node:fs'
import ts from 'typescript'
import { resolve } from 'node:path'
import { API } from 'typescript/unstable/sync'
import { isFunctionDeclaration, isIdentifier } from 'typescript/unstable/ast'
import { getExportedForms } from '../test/helpers/language-helpers.js'
import { getLanguageName } from '../test/helpers/language-naming.js'

Expand Down Expand Up @@ -78,16 +80,16 @@ let optionsIndex = new Map()
* markdown renderer expects: a string-literal union becomes
* `('a'|'b')`, everything else uses its plain type name (`boolean`, `string`).
*
* @param {import('typescript').TypeChecker} checker
* @param {import('typescript').Type} propType
* @param {import('typescript/unstable/sync').Checker} checker
* @param {import('typescript/unstable/sync').Type} propType
* @returns {string}
*/
function toDocType(checker, propType) {
// Optional props arrive as `T | undefined`; drop the undefined first.
const type = propType.getNonNullableType()
const parts = type.isUnion() ? type.types : [type]
const type = checker.getNonNullableType(propType) ?? propType
const parts = type.isUnionType() ? (type.getTypes() ?? []) : [type]

if (parts.length > 0 && parts.every(t => t.isStringLiteral())) {
if (parts.length > 0 && parts.every(t => t.isStringLiteralType())) {
const literals = parts.map(t => `'${t.value}'`)
return parts.length > 1 ? `(${literals.join('|')})` : literals[0]
}
Expand All @@ -101,80 +103,90 @@ function toDocType(checker, propType) {
* checker (the same view TypeScript exposes to consumers), so the docs can't
* drift from comment formatting the way the old regex scrape could.
*
* Uses `typescript/unstable/sync`, the native-compiler ("tsgo") API that
* replaced the classic `ts.createProgram` surface in typescript@7 — the
* client spawns the bundled tsgo binary as a subprocess and talks to it
* per-request, so the `API` instance is closed once extraction is done.
*
* @param {string[]} codes Language codes
* @param {Map<string, object>} mods Code -> module namespace (for `<form>Defaults` exports)
* @returns {Map<string, Map<string, OptionInfo[]>>}
*/
function buildOptionsIndex(codes, mods) {
const program = ts.createProgram(
codes.map(code => `./src/${code}.js`),
{
allowJs: true,
checkJs: false,
noEmit: true,
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
},
)
const checker = program.getTypeChecker()
const index = new Map()

for (const code of codes) {
const sourceFile = program.getSourceFile(`./src/${code}.js`)
if (!sourceFile) {
throw new Error(`Could not load source for "${code}" (./src/${code}.js) — cannot extract options`)
const api = new API({ cwd: process.cwd() })
try {
// src/tsconfig.json is the project this repo already maintains for
// checkJs coverage of src/**/*.js (see that file's own comment) — reusing
// it means compiler options can't drift between editor/CI type-checking
// and this doc generator.
const configFileName = resolve('src/tsconfig.json')
api.parseConfigFile(configFileName)
const openFiles = codes.map(code => resolve('src', `${code}.js`))
const snapshot = api.updateSnapshot({ openFiles })
const project = snapshot.getProject(configFileName)
if (!project) {
throw new Error(`Could not load project "${configFileName}" — cannot extract options`)
}
const byFunction = new Map()
const checker = project.checker
const index = new Map()

ts.forEachChild(sourceFile, (node) => {
if (!ts.isFunctionDeclaration(node) || !node.name) return
const fnName = node.name.text
if (!(fnName in FORM_FUNCTIONS)) return

const optionsParam = node.parameters.find(
p => ts.isIdentifier(p.name) && p.name.text === 'options',
)
if (!optionsParam) return

let type = checker.getTypeAtLocation(optionsParam)
if (type.isUnion()) {
type = type.types.find(t => !(t.flags & ts.TypeFlags.Undefined)) ?? type
for (const code of codes) {
const sourceFile = project.program.getSourceFile(resolve('src', `${code}.js`))
if (!sourceFile) {
throw new Error(`Could not load source for "${code}" (src/${code}.js) — cannot extract options`)
}

// Defaults come from the options contract's `<form>Defaults` export —
// imported, the single source of truth. A form taking options without it
// is a contract violation (the gate enforces this too), so fail loudly
// rather than scrape JSDoc or the function body.
const formDefaults = /** @type {Record<string, unknown> | undefined} */ (
mods.get(code)?.[`${FORM_FUNCTIONS[fnName]}Defaults`]
)
if (formDefaults === undefined) {
throw new Error(`${code} ${fnName}() accepts options but doesn't export ${FORM_FUNCTIONS[fnName]}Defaults — every options-taking form must declare its contract`)
}
const options = (type.getProperties?.() ?? []).map((prop) => {
const name = prop.getName()
const description = ts
.displayPartsToString(prop.getDocumentationComment(checker))
.trim()
.replace(/^-\s*/, '')
.trim()
return {
name,
type: toDocType(checker, checker.getTypeOfSymbolAtLocation(prop, optionsParam)),
defaultValue: Object.hasOwn(formDefaults, name) ? String(formDefaults[name]) : undefined,
description,
form: FORM_FUNCTIONS[fnName],
const byFunction = new Map()

for (const node of sourceFile.statements) {
if (!isFunctionDeclaration(node) || !node.name) continue
const fnName = node.name.text
if (!(fnName in FORM_FUNCTIONS)) continue

const optionsParam = node.parameters.find(
p => isIdentifier(p.name) && p.name.text === 'options',
)
if (!optionsParam) continue

const rawType = checker.getTypeAtLocation(optionsParam)
const type = (rawType && checker.getNonNullableType(rawType)) ?? rawType

// Defaults come from the options contract's `<form>Defaults` export —
// imported, the single source of truth. A form taking options without it
// is a contract violation (the gate enforces this too), so fail loudly
// rather than scrape JSDoc or the function body.
const formDefaults = /** @type {Record<string, unknown> | undefined} */ (
mods.get(code)?.[`${FORM_FUNCTIONS[fnName]}Defaults`]
)
if (formDefaults === undefined) {
throw new Error(`${code} ${fnName}() accepts options but doesn't export ${FORM_FUNCTIONS[fnName]}Defaults — every options-taking form must declare its contract`)
}
})
const options = checker.getPropertiesOfType(type).map((prop) => {
const name = prop.name
const description = prop
.getDocumentationComment(checker)
.trim()
.replace(/^-\s*/, '')
.trim()
return {
name,
type: toDocType(checker, checker.getTypeOfSymbolAtLocation(prop, optionsParam)),
defaultValue: Object.hasOwn(formDefaults, name) ? String(formDefaults[name]) : undefined,
description,
form: FORM_FUNCTIONS[fnName],
}
})

if (options.length > 0) byFunction.set(fnName, options)
}

if (options.length > 0) byFunction.set(fnName, options)
})
index.set(code, byFunction)
}

index.set(code, byFunction)
return index
}
finally {
api.close()
}

return index
}

/**
Expand Down
12 changes: 8 additions & 4 deletions src/fa-IR.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,19 +229,23 @@ function toOrdinal(value) {
/**
* Converts a numeric value to Persian currency words (Rial).
*
* Iranian Rial has no subunit in modern usage.
* (Historically dinar was 1/100 rial, but not used today)
* Iranian Rial has no everyday minor unit (the dinar was historically 1/100
* rial), so a fractional amount throws RangeError rather than being silently
* discarded.
* @param {number | string | bigint} value - The currency amount to convert
* @returns {string} The amount in Persian currency words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @throws {RangeError} If the amount has a fractional part
* @example
* toCurrency(42) // 'چهل و دو ریال'
* toCurrency(1000) // 'هزار ریال'
* toCurrency(-5) // 'منفى پنج ریال'
*/
function toCurrency(value) {
const { isNegative, dollars: rial } = parseCurrencyValue(value)
const { isNegative, dollars: rial, cents } = parseCurrencyValue(value)
if (cents !== 0n) {
throw new RangeError('IRR has no minor unit — fractional amounts aren\'t representable')
}

let result = ''
if (isNegative) {
Expand Down
11 changes: 7 additions & 4 deletions src/id-ID.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,20 +287,23 @@ function toOrdinal(value) {
/**
* Converts a numeric value to Indonesian currency words (Rupiah).
*
* Indonesian Rupiah has no subunit in modern usage (sen are historical).
* Amounts are rounded to whole rupiah.
* Indonesian Rupiah has no everyday minor unit (sen are historical), so a
* fractional amount throws RangeError rather than being silently discarded.
* @param {number | string | bigint} value - The currency amount to convert
* @returns {string} The amount in Indonesian currency words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @throws {RangeError} If the amount has a fractional part
* @example
* toCurrency(42) // 'empat puluh dua rupiah'
* toCurrency(1000) // 'seribu rupiah'
* toCurrency(-5) // 'min lima rupiah'
*/
function toCurrency(value) {
const { isNegative, dollars: rupiah } = parseCurrencyValue(value)
const { isNegative, dollars: rupiah, cents } = parseCurrencyValue(value)
checkMax(rupiah, currencyMax)
if (cents !== 0n) {
throw new RangeError('IDR has no minor unit — fractional amounts aren\'t representable')
}

let result = ''
if (isNegative) {
Expand Down
Loading