diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml index 847fd9751..7ff3ba050 100644 --- a/.github/workflows/javascript.yml +++ b/.github/workflows/javascript.yml @@ -115,6 +115,21 @@ jobs: # npm install # npm run test-ci + generate_messages_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: 20 + cache-dependency-path: ./package-lock.json + cache: 'npm' + - name: Test generate-messages script + run: | + npm install + node scripts/test-generate-messages.js + unit_tests: runs-on: ubuntu-latest strategy: diff --git a/message_generator.ts b/message_generator.ts deleted file mode 100644 index 5571f84e7..000000000 --- a/message_generator.ts +++ /dev/null @@ -1,36 +0,0 @@ -import path from 'path'; -import { writeFile } from 'fs/promises'; - -const generate = async () => { - const inp = process.argv.slice(2); - for(const filePath of inp) { - console.log('generating messages for: ', filePath); - const parsedPath = path.parse(filePath); - const fileName = parsedPath.name; - const dirName = parsedPath.dir; - const ext = parsedPath.ext; - - const genFilePath = path.join(dirName, `${fileName}.gen${ext}`); - console.log('generated file path: ', genFilePath); - const exports = await import(filePath); - const messages : Array = []; - - let genOut = ''; - - Object.keys(exports).forEach((key, i) => { - if (key === 'messages' || key === '__platforms') return; - genOut += `export const ${key} = '${i}';\n`; - messages.push(exports[key]) - }); - - genOut += `export const messages = ${JSON.stringify(messages, null, 2)};` - await writeFile(genFilePath, genOut, 'utf-8'); - } -} - -generate().then(() => { - console.log('successfully generated messages'); -}).catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/package-lock.json b/package-lock.json index 7af8d336f..3812dc53d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "eslint-plugin-local-rules": "^3.0.2", "eslint-plugin-prettier": "^3.1.2", "happy-dom": "^20.0.11", - "jiti": "^2.4.1", "minimatch": "^9.0.5", "mocha": "^10.2.0", "mocha-lcov-reporter": "^1.3.0", @@ -7840,6 +7839,8 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } diff --git a/package.json b/package.json index 84ce000b4..f4f5fad6c 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "coveralls": "nyc --reporter=lcov npm test", "prepare": "npm run build", "prepublishOnly": "npm test", - "genmsg": "jiti message_generator ./lib/message/error_message.ts ./lib/message/log_message.ts", + "genmsg": "./scripts/generate-messages.js ./lib/message/error_message.ts ./lib/message/log_message.ts", "test-typescript": "node ./scripts/run-ts-example.js", "test-commonjs": "node ./scripts/run-cjs-example.js" }, @@ -126,7 +126,6 @@ "eslint-plugin-local-rules": "^3.0.2", "eslint-plugin-prettier": "^3.1.2", "happy-dom": "^20.0.11", - "jiti": "^2.4.1", "minimatch": "^9.0.5", "mocha": "^10.2.0", "mocha-lcov-reporter": "^1.3.0", diff --git a/scripts/generate-messages.js b/scripts/generate-messages.js new file mode 100755 index 000000000..bd347fc01 --- /dev/null +++ b/scripts/generate-messages.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const ts = require('typescript'); +const fs = require('fs'); +const path = require('path'); + +function extractMessages(filePath) { + const source = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + ); + + const exports = []; + + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + + const hasExport = statement.modifiers?.some( + (m) => m.kind === ts.SyntaxKind.ExportKeyword, + ); + if (!hasExport) continue; + + for (const decl of statement.declarationList.declarations) { + const name = decl.name.getText(sourceFile); + if (name === 'messages' || name === '__platforms') continue; + if (!decl.initializer || !ts.isStringLiteral(decl.initializer)) continue; + + exports.push({ name, value: decl.initializer.text }); + } + } + + return exports; +} + +function buildOutputAst(exports) { + const { factory } = ts; + const statements = []; + + exports.forEach((exp, i) => { + statements.push( + factory.createVariableStatement( + [factory.createModifier(ts.SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [factory.createVariableDeclaration(exp.name, undefined, undefined, factory.createStringLiteral(String(i)))], + ts.NodeFlags.Const, + ), + ), + ); + }); + + statements.push( + factory.createVariableStatement( + [factory.createModifier(ts.SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + 'messages', + undefined, + undefined, + factory.createArrayLiteralExpression( + exports.map((exp) => factory.createStringLiteral(exp.value)), + true, + ), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + + return factory.createSourceFile(statements, factory.createToken(ts.SyntaxKind.EndOfFileToken), ts.NodeFlags.None); +} + +function generate(filePath) { + const absPath = path.resolve(filePath); + const parsed = path.parse(absPath); + const genPath = path.join(parsed.dir, `${parsed.name}.gen${parsed.ext}`); + + console.log('generating messages for:', absPath); + + const exports = extractMessages(absPath); + const outputAst = buildOutputAst(exports); + + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + const output = printer.printFile(outputAst); + + fs.writeFileSync(genPath, output, 'utf-8'); + console.log('generated file path:', genPath); +} + +const files = process.argv.slice(2); + +if (files.length === 0) { + console.error('Usage: generate-messages.js [file2.ts] ...'); + process.exit(1); +} + +for (const file of files) { + generate(file); +} + +console.log('successfully generated messages'); diff --git a/scripts/test-generate-messages.js b/scripts/test-generate-messages.js new file mode 100644 index 000000000..93f8edd60 --- /dev/null +++ b/scripts/test-generate-messages.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const ts = require('typescript'); + +const SCRIPT = path.resolve(__dirname, 'generate-messages.js'); + +const FILE1_EXPECTED = [ + { name: 'HELLO', value: 'Hello, %s!' }, + { name: 'GOODBYE', value: 'Goodbye, %s!' }, + { name: 'WARNING', value: 'Warning: %s' }, +]; + +const FILE2_EXPECTED = [ + { name: 'ERR_NOT_FOUND', value: 'Resource %s not found' }, + { name: 'ERR_TIMEOUT', value: 'Request timed out after %s ms' }, + { name: 'ERR_INVALID', value: 'Invalid input: %s' }, + { name: 'ERR_PERMISSION', value: 'Permission denied for %s' }, +]; + +function buildFileContent(expected, { includeMessages = true, includePlatforms = false } = {}) { + let content = expected.map((e) => `export const ${e.name} = '${e.value}';`).join('\n'); + if (includeMessages) content += `\n\nexport const messages: string[] = [];`; + if (includePlatforms) content += `\nexport const __platforms: string[] = ['__universal__'];`; + return content; +} + +function compileAndLoad(tsPath) { + const source = fs.readFileSync(tsPath, 'utf-8'); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }); + const jsPath = tsPath.replace(/\.ts$/, '.js'); + fs.writeFileSync(jsPath, outputText, 'utf-8'); + return require(jsPath); +} + +function runTest() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gen-msg-test-')); + + try { + const file1 = path.join(tmpDir, 'file1.ts'); + const file2 = path.join(tmpDir, 'file2.ts'); + fs.writeFileSync(file1, buildFileContent(FILE1_EXPECTED, { includePlatforms: true }), 'utf-8'); + fs.writeFileSync(file2, buildFileContent(FILE2_EXPECTED), 'utf-8'); + + execFileSync(process.execPath, [SCRIPT, file1, file2], { stdio: 'pipe' }); + + const genFile1 = path.join(tmpDir, 'file1.gen.ts'); + const genFile2 = path.join(tmpDir, 'file2.gen.ts'); + assert.ok(fs.existsSync(genFile1), 'file1.gen.ts should be created'); + assert.ok(fs.existsSync(genFile2), 'file2.gen.ts should be created'); + + for (const [expected, genPath, label] of [ + [FILE1_EXPECTED, genFile1, 'file1'], + [FILE2_EXPECTED, genFile2, 'file2'], + ]) { + const mod = compileAndLoad(genPath); + + assert.ok(Array.isArray(mod.messages), `${label}: messages should be an array`); + assert.equal(mod.messages.length, expected.length, + `${label}: messages array length should be ${expected.length}, got ${mod.messages.length}`); + + assert.equal(mod.__platforms, undefined, `${label}: __platforms should not be exported`); + + const constantCount = Object.keys(mod).filter((k) => k !== 'messages').length; + assert.equal(constantCount, expected.length, + `${label}: should have ${expected.length} constants, got ${constantCount}`); + + for (const exp of expected) { + assert.ok(exp.name in mod, + `${label}: constant "${exp.name}" should be present`); + + const index = Number(mod[exp.name]); + assert.equal(mod.messages[index], exp.value, + `${label}: messages[${index}] should be "${exp.value}", got "${mod.messages[index]}"`); + } + } + + console.log('All tests passed.'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +runTest();