diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index a46038432..49f725c7d 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -25,6 +25,8 @@ jobs: include: - os: macos-14 node-version: 24 + - os: macos-15-intel + node-version: 24 - os: windows-2022 node-version: 24 steps: @@ -66,3 +68,50 @@ jobs: node/build/** prebuilds/** **/npm-debug.log + + # Install the packed npm tarball without prebuilt binaries, so the + # node-gyp source build path is exercised exactly as it runs on platforms + # without a scoped binary package (issue #1409). + pack-source-install: + name: Source build from npm tarball (${{ matrix.os }}) + permissions: + contents: read + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, macos-15-intel] + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Use Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + - run: npm ci + - name: Build prebuild assets + run: npm run prebuild + - name: Pack npm tarball + shell: bash + run: | + mkdir -p dist/npm-artifacts + npm pack --pack-destination dist/npm-artifacts + - name: Install tarball from source and smoke test + shell: bash + run: | + tarball="$(ls "$PWD"/dist/npm-artifacts/opencc-*.tgz)" + install_dir="$(mktemp -d)" + cd "$install_dir" + npm init -y + npm install --omit=optional --foreground-scripts "$tarball" + test -f node_modules/opencc/build/Release/opencc.node + node -e ' + const assert = require("assert"); + const OpenCC = require("opencc"); + const converter = new OpenCC("s2t.json"); + const converted = converter.convertSync("汉字"); + assert.strictEqual(converted, "漢字"); + console.log("Source-built opencc converts 汉字 ->", converted); + ' diff --git a/.github/workflows/release-npm-binaries.yml b/.github/workflows/release-npm-binaries.yml index b79968d2e..9fa6494ca 100644 --- a/.github/workflows/release-npm-binaries.yml +++ b/.github/workflows/release-npm-binaries.yml @@ -32,6 +32,9 @@ jobs: - target: darwin-arm64 runner: macos-15 library: libopencc-jieba.dylib + - target: darwin-x64 + runner: macos-15-intel + library: libopencc-jieba.dylib - target: linux-arm64 runner: ubuntu-24.04-arm library: libopencc-jieba.so @@ -194,6 +197,8 @@ jobs: include: - target: darwin-arm64 runner: macos-15 + - target: darwin-x64 + runner: macos-15-intel - target: linux-arm64 runner: ubuntu-24.04-arm - target: linux-x64 diff --git a/package.json b/package.json index f99c78ef3..1cc5be762 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "src/Common.hpp", "src/Config.cpp", "src/Config.hpp", + "src/ConfigBasedConverter.hpp", "src/Conversion.cpp", "src/Conversion.hpp", "src/ConversionChain.cpp", @@ -98,9 +99,12 @@ "src/MaxMatchSegmentation.cpp", "src/MaxMatchSegmentation.hpp", "src/opencc.h", + "src/opencc_config_schema.inc", "src/Optional.hpp", "src/PhraseExtract.cpp", "src/PhraseExtract.hpp", + "src/PipelineConverter.cpp", + "src/PipelineConverter.hpp", "src/plugin/OpenCCPlugin.h", "src/PluginSegmentation.cpp", "src/PluginSegmentation.hpp", @@ -117,6 +121,8 @@ "src/SerializedValues.hpp", "src/SimpleConverter.cpp", "src/SimpleConverter.hpp", + "src/SingleStageConverter.cpp", + "src/SingleStageConverter.hpp", "src/TextDict.cpp", "src/TextDict.hpp", "src/UTF8StringSlice.cpp", diff --git a/scripts/verify-node-main-package.js b/scripts/verify-node-main-package.js index 554ab5a0c..742fa9933 100644 --- a/scripts/verify-node-main-package.js +++ b/scripts/verify-node-main-package.js @@ -35,6 +35,97 @@ function fail(message) { process.exitCode = 1; } +// The npm tarball must contain every file the node-gyp source build compiles +// or includes, so that installs without a prebuilt binary (issue #1409) can +// fall back to building from source. Walk the gyp source list and its +// #include closure and check each file against the "files" whitelist. +function parseGypSourcesAndIncludeDirs() { + const gypiPath = path.join(packageRoot, 'node', 'node_opencc.gypi'); + const gypi = fs.readFileSync(gypiPath, 'utf8'); + const sources = []; + const includeDirs = []; + for (const match of gypi.matchAll(/"\.\.(\/[^"]*)?"/g)) { + const relativePath = match[1] ? match[1].slice(1) : '.'; + if (/\.(cpp|cc)$/.test(relativePath)) { + sources.push(relativePath); + } else if ( + fs.existsSync(path.join(packageRoot, relativePath)) && + fs.statSync(path.join(packageRoot, relativePath)).isDirectory() + ) { + includeDirs.push(relativePath); + } + } + return { sources, includeDirs }; +} + +function collectSourceBuildClosure() { + const { sources, includeDirs } = parseGypSourcesAndIncludeDirs(); + const closure = new Set(); + const queue = [...sources]; + while (queue.length > 0) { + const relativePath = queue.pop(); + if (closure.has(relativePath)) { + continue; + } + const filePath = path.join(packageRoot, relativePath); + if (!fs.existsSync(filePath)) { + fail(`Source build references a file missing from the repository: ${relativePath}`); + continue; + } + closure.add(relativePath); + const content = fs.readFileSync(filePath, 'utf8'); + for (const match of content.matchAll(/^\s*#\s*include\s+"([^"]+)"/gm)) { + const include = match[1]; + const candidates = [ + path.posix.join(path.posix.dirname(relativePath), include), + ...includeDirs.map((dir) => path.posix.normalize(path.posix.join(dir, include))), + ]; + for (const candidate of candidates) { + if (fs.existsSync(path.join(packageRoot, candidate))) { + queue.push(candidate); + break; + } + } + } + } + return closure; +} + +function isPackedByFilesWhitelist(relativePath, filesList) { + for (const pattern of filesList) { + if (pattern === relativePath) { + return true; + } + if (!pattern.includes('*') && relativePath.startsWith(`${pattern.replace(/\/$/, '')}/`)) { + return true; + } + if (pattern.includes('*')) { + const regex = new RegExp( + `^${pattern.split('*').map((part) => + part.replace(/[.+?^${}()|[\]\\]/g, '\\$&') + ).join('[^/]*')}$` + ); + if (regex.test(relativePath)) { + return true; + } + } + } + return false; +} + +function verifySourceBuildFilesPacked() { + const filesList = packageJson.files || []; + const missing = [...collectSourceBuildClosure()] + .filter((relativePath) => !isPackedByFilesWhitelist(relativePath, filesList)) + .sort(); + for (const relativePath of missing) { + fail( + `Source build requires ${relativePath} but it is not covered by ` + + 'the "files" whitelist in package.json' + ); + } +} + for (const relativePath of requiredFiles) { const filePath = path.join(packageRoot, relativePath); if (!fs.existsSync(filePath)) { @@ -50,6 +141,8 @@ for (const packageName of expectedOptionalPackages) { } } +verifySourceBuildFilesPacked(); + if (process.exitCode) { console.error( 'opencc package is not ready. Run npm run prebuild before publishing.'