diff --git a/node/README.md b/node/README.md index 5530742e3..3e71c6b5e 100644 --- a/node/README.md +++ b/node/README.md @@ -298,5 +298,14 @@ npm install npm test ``` +The `OpenCCConfig` TypeScript declarations in `opencc.d.ts`, +`opencc.d.cts`, and `opencc.d.mts` are generated from +`data/config/opencc_config.schema.json`. Regenerate them after changing the +schema: + +```bash +npm run generate:node-config-types +``` + To build prebuilt native addons for publishing, see [`node/PUBLISHING.md`](./PUBLISHING.md). diff --git a/node/opencc.d.cts b/node/opencc.d.cts index ee28b3c21..7d0b7eafe 100644 --- a/node/opencc.d.cts +++ b/node/opencc.d.cts @@ -1,4 +1,41 @@ -type OpenCCConfig = Record; +type OpenCCDict = OpenCCFileDict | OpenCCInlineDict | OpenCCGroupDict; +type OpenCCSegmentation = OpenCCMmsegSegmentation | OpenCCPluginSegmentation; +type OpenCCPluginSegmentation = { + type: string; + resources?: Record; + [key: string]: string | Record | undefined; +}; + +interface OpenCCConfig { + name: string; + normalization?: [OpenCCConversion, ...OpenCCConversion[]]; + segmentation?: OpenCCSegmentation; + conversion_chain: [OpenCCConversion, ...OpenCCConversion[]]; +} +interface OpenCCConversion { + dict: OpenCCDict; +} +interface OpenCCFileDict { + type: "text" | "ocd" | "ocd2"; + file: string; + may_output_tofu?: boolean; +} +interface OpenCCInlineDict { + type: "inline"; + entries: { + [k: string]: string; + }; +} +interface OpenCCGroupDict { + type: "group"; + match_policy: "short_circuit" | "union"; + dicts: [OpenCCDict, ...OpenCCDict[]]; + may_output_tofu?: boolean; +} +interface OpenCCMmsegSegmentation { + type: "mmseg"; + dict: OpenCCDict; +} interface OpenCCOptions { includeTofuRiskDictionaries?: boolean; diff --git a/node/opencc.d.mts b/node/opencc.d.mts index e9b880f77..1a88202c6 100644 --- a/node/opencc.d.mts +++ b/node/opencc.d.mts @@ -1,4 +1,41 @@ -type OpenCCConfig = Record; +type OpenCCDict = OpenCCFileDict | OpenCCInlineDict | OpenCCGroupDict; +type OpenCCSegmentation = OpenCCMmsegSegmentation | OpenCCPluginSegmentation; +type OpenCCPluginSegmentation = { + type: string; + resources?: Record; + [key: string]: string | Record | undefined; +}; + +interface OpenCCConfig { + name: string; + normalization?: [OpenCCConversion, ...OpenCCConversion[]]; + segmentation?: OpenCCSegmentation; + conversion_chain: [OpenCCConversion, ...OpenCCConversion[]]; +} +interface OpenCCConversion { + dict: OpenCCDict; +} +interface OpenCCFileDict { + type: "text" | "ocd" | "ocd2"; + file: string; + may_output_tofu?: boolean; +} +interface OpenCCInlineDict { + type: "inline"; + entries: { + [k: string]: string; + }; +} +interface OpenCCGroupDict { + type: "group"; + match_policy: "short_circuit" | "union"; + dicts: [OpenCCDict, ...OpenCCDict[]]; + may_output_tofu?: boolean; +} +interface OpenCCMmsegSegmentation { + type: "mmseg"; + dict: OpenCCDict; +} interface OpenCCOptions { includeTofuRiskDictionaries?: boolean; diff --git a/node/opencc.d.ts b/node/opencc.d.ts index ec4c12ca8..a365146d0 100644 --- a/node/opencc.d.ts +++ b/node/opencc.d.ts @@ -1,4 +1,41 @@ -type OpenCCConfig = Record; +type OpenCCDict = OpenCCFileDict | OpenCCInlineDict | OpenCCGroupDict; +type OpenCCSegmentation = OpenCCMmsegSegmentation | OpenCCPluginSegmentation; +type OpenCCPluginSegmentation = { + type: string; + resources?: Record; + [key: string]: string | Record | undefined; +}; + +interface OpenCCConfig { + name: string; + normalization?: [OpenCCConversion, ...OpenCCConversion[]]; + segmentation?: OpenCCSegmentation; + conversion_chain: [OpenCCConversion, ...OpenCCConversion[]]; +} +interface OpenCCConversion { + dict: OpenCCDict; +} +interface OpenCCFileDict { + type: "text" | "ocd" | "ocd2"; + file: string; + may_output_tofu?: boolean; +} +interface OpenCCInlineDict { + type: "inline"; + entries: { + [k: string]: string; + }; +} +interface OpenCCGroupDict { + type: "group"; + match_policy: "short_circuit" | "union"; + dicts: [OpenCCDict, ...OpenCCDict[]]; + may_output_tofu?: boolean; +} +interface OpenCCMmsegSegmentation { + type: "mmseg"; + dict: OpenCCDict; +} interface OpenCCOptions { includeTofuRiskDictionaries?: boolean; diff --git a/package-lock.json b/package-lock.json index a7d10c2bc..8898f220c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "opencc": "node/cli.js" }, "devDependencies": { + "json-schema-to-typescript": "^15.0.4", "prebuildify": "^6.0.1" }, "engines": { @@ -30,6 +31,24 @@ "@opencc/opencc-win32-x64": "1.4.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -42,6 +61,13 @@ "node": ">=18.0.0" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@opencc/opencc-darwin-arm64": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@opencc/opencc-darwin-arm64/-/opencc-darwin-arm64-1.4.0.tgz", @@ -94,6 +120,20 @@ "win32" ] }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -103,6 +143,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -251,6 +298,83 @@ "dev": true, "license": "ISC" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -448,6 +572,22 @@ "prebuildify": "bin.js" } }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", diff --git a/package.json b/package.json index 1cc5be762..76e3b59f8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "scripts": { "test": "node --test --test-reporter=spec node/test.js && node node/esm-test.mjs", + "generate:node-config-types": "node scripts/generate-node-opencc-config-types.js", "prebuild": "prebuildify --napi --strip --postinstall \"node scripts/prepare-node-prebuild-artifacts.js\"", "prepare:scoped-packages": "node scripts/prepare-node-scoped-packages.js", "prepack": "node scripts/verify-node-main-package.js", @@ -63,6 +64,7 @@ "prebuilds/assets/*.ocd2", "scripts/prepare-node-prebuild-artifacts.js", "scripts/install.js", + "scripts/generate-node-opencc-config-types.js", "scripts/prepare-node-scoped-packages.js", "scripts/verify-node-main-package.js", "scripts/verify-node-scoped-packages-published.js", @@ -148,6 +150,7 @@ "Traditional Chinese" ], "devDependencies": { + "json-schema-to-typescript": "^15.0.4", "prebuildify": "^6.0.1" }, "dependencies": { diff --git a/scripts/generate-node-opencc-config-types.js b/scripts/generate-node-opencc-config-types.js new file mode 100644 index 000000000..74207b18f --- /dev/null +++ b/scripts/generate-node-opencc-config-types.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { compile } = require('json-schema-to-typescript'); + +const rootDir = + process.env.OPENCC_CONFIG_TYPES_ROOT || path.resolve(__dirname, '..'); +const schemaPath = path.join(rootDir, 'data/config/opencc_config.schema.json'); +const declarationFiles = [ + 'node/opencc.d.ts', + 'node/opencc.d.cts', + 'node/opencc.d.mts', +].map((file) => path.join(rootDir, file)); + +const definitionTitles = { + segmentation: 'OpenCCSegmentation', + mmseg_segmentation: 'OpenCCMmsegSegmentation', + plugin_segmentation: 'OpenCCPluginSegmentation', + conversion: 'OpenCCConversion', + dict: 'OpenCCDict', + file_dict: 'OpenCCFileDict', + inline_dict: 'OpenCCInlineDict', + group_dict: 'OpenCCGroupDict', +}; + +function loadSchema() { + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); + schema.title = 'OpenCCConfig'; + + for (const [name, title] of Object.entries(definitionTitles)) { + schema.definitions[name].title = title; + } + + schema.definitions.plugin_segmentation.tsType = [ + '{', + 'type: string;', + 'resources?: Record;', + '[key: string]: string | Record | undefined;', + '}', + ].join(' '); + + return schema; +} + +function normalizeGeneratedTypes(types) { + return types + .trim() + .replace(/^[ \t]*\/\*\*[\s\S]*?\*\/\n/gm, '') + .replace(/^export /gm, '') + .replace(/^\n+|\n+$/g, ''); +} + +async function generatedBlock() { + const generatedTypes = await compile(loadSchema(), 'OpenCCConfig', { + bannerComment: '', + maxItems: -1, + unreachableDefinitions: true, + }); + + return normalizeGeneratedTypes(generatedTypes); +} + +function replaceGeneratedBlock(filePath, block) { + const source = fs.readFileSync(filePath, 'utf8'); + + const optionsStart = source.indexOf('\ninterface OpenCCOptions {'); + if (optionsStart !== -1) { + return `${block}\n${source.slice(optionsStart)}`; + } + + const originalConfigType = 'type OpenCCConfig = Record;'; + if (source.startsWith(originalConfigType)) { + return `${block}${source.slice(originalConfigType.length)}`; + } + + throw new Error(`Unable to locate generated block in ${filePath}`); +} + +async function main() { + const block = await generatedBlock(); + let hasDiff = false; + + for (const filePath of declarationFiles) { + const next = replaceGeneratedBlock(filePath, block); + const current = fs.readFileSync(filePath, 'utf8'); + if (next !== current) { + hasDiff = true; + if (process.argv.includes('--check')) { + console.error(`${path.relative(rootDir, filePath)} is not up to date.`); + } else { + fs.writeFileSync(filePath, next); + } + } + } + + if (process.argv.includes('--check') && hasDiff) { + process.exit(1); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});