Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix: Compile TS to JS during packaging
  • Loading branch information
camdecoster committed Aug 31, 2026
commit d1e6d7dc03644bf99c2882622675413221c6f035
5 changes: 5 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ stackgl_modules/node_modules
tasks
test
topojson

# Exclude the TypeScript files (but not declarations) because Node doesn't
# parse TS when installed in node_modules.
src/**/*.ts
!src/**/*.d.ts
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"test-syntax": "tsx tasks/test_syntax.js && npm run find-strings -- --no-output",
"test-bundle": "node tasks/test_bundle.js",
"test-plain-obj": "node tasks/test_plain_obj.mjs",
"test-node-resolve": "node tasks/test_node_resolve.mjs",
"test": "npm run test-jasmine -- --nowatch && npm run test-bundle && npm run test-image && npm run test-export && npm run test-syntax && npm run lint",
"b64": "python3 test/image/generate_b64_mocks.py && node devtools/test_dashboard/server.mjs",
"mathjax3": "node devtools/test_dashboard/server.mjs --mathjax3",
Expand All @@ -63,7 +64,9 @@
"preversion": "check-node-version --node 22 --npm 10 && npm-link-check && npm ls --prod --all",
"version": "npm run build && git add -A lib dist build src/version.js",
"postversion": "node -e \"console.log('Version bumped and committed. If ok, run: git push && git push --tags')\"",
"postpublish": "node tasks/sync_packages.js"
"postpublish": "node tasks/sync_packages.js",
"prepack": "tsc -b tsconfig.build.json --force",
"postpack": "tsc -b tsconfig.build.json --clean"
},
"dependencies": {
"@plotly/d3": "3.8.2",
Expand Down
78 changes: 78 additions & 0 deletions tasks/test_node_resolve.mjs

@KoolADE85 KoolADE85 Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular reason you write this as a util here?
To me, the file reads like a github action written in javascript. And it's doing some bizarre setup along the way that make the assertions seem a bit artificial.

Meanwhile, what we actually care about:

  1. Install the lib
  2. Import the lib
  3. Assert it worked

And that seems like an ideal case for a GH action or even just a unit test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I liked having the test locally, but it does fit better in GHA. I'll remove this file.

Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { pathToRoot } from './util/constants.js';

// Bundlers resolve a `.ts` extension, so `npm run build` hides a package that
// Node alone cannot load. This test packs the real tarball and loads it the way
// a Node consumer does: `require('plotly.js')` under the CommonJS resolver.
// See https://github.com/plotly/plotly.js/issues/7995.
//
// The package needs a browser, so the load always ends in a DOM error. That is
// the pass condition. Any resolution error is the regression.

// tsc overwrites a hand-written `foo.js` when a `foo.ts` sits beside it, and it
// reports no error. Such a pair is already ambiguous, because esbuild picks the
// `.ts` and the local build silently ignores the `.js`. Fail here instead.
const collisions = fs
.globSync('src/**/*.ts', { cwd: pathToRoot })
.filter((file) => !file.endsWith('.d.ts'))
.filter((file) => fs.existsSync(path.join(pathToRoot, file.replace(/\.ts$/, '.js'))));

if (collisions.length) {
throw new Error(
[
'A TypeScript source shares a basename with a JavaScript file:',
...collisions.map((file) => ' ' + file),
'The pack step would overwrite the JavaScript file. Rename one of the two.'
].join('\n')
);
}

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plotly-node-resolve-'));

try {
console.log('Packing the tarball');
const packed = execFileSync('npm', ['pack', '--pack-destination', tmp, '--silent'], {
cwd: pathToRoot,
encoding: 'utf8'
})
.trim()
.split('\n')
.pop();

// Install the tarball the way npm would, so that `require('plotly.js')`
// goes through the package name, the `main` field, and the published file
// layout.
const pkg = path.join(tmp, 'node_modules', 'plotly.js');

fs.mkdirSync(pkg, { recursive: true });
execFileSync('tar', ['-xzf', path.join(tmp, packed), '-C', pkg, '--strip-components=1']);

// The tarball carries no dependencies. Borrow the ones already installed.
fs.symlinkSync(path.join(pathToRoot, 'node_modules'), path.join(pkg, 'node_modules'), 'dir');

// The probe resolves from `tmp`, which is where the tarball is installed.
// It runs in its own process so that it starts with a clean module registry
// and its own globals.
const probe = path.join(pathToRoot, 'tasks', 'util', 'node_resolve_probe.js');
const result = execFileSync(process.execPath, [probe, tmp], { encoding: 'utf8' }).trim();

// A ReferenceError means every `require` in the graph resolved, and the
// package only then reached for a browser API.
if (result === 'LOADED' || result === 'RUNTIME:ReferenceError') {
console.log('OK: the published package resolves under Node (' + result + ')');
} else {
throw new Error(
[
'The published package does not resolve under Node: ' + result,
'Every src/**/*.ts needs a generated .js sibling in the tarball.',
'See tsconfig.build.json and the prepack script in package.json.'
].join('\n')
);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
26 changes: 26 additions & 0 deletions tasks/util/node_resolve_probe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Loads plotly.js the way a Node consumer does.
//
// Takes the directory holding a `node_modules` with the packed tarball in it.
// `createRequire` bases resolution there, so the require below behaves as if
// this file sat in that directory: it goes through the package name, the `main`
// field, and the published file layout.
//
// plotly.js needs a browser, so even a complete load ends in a DOM error. The
// caller reads the single line this prints on stdout.

const { createRequire } = require('node:module');
const path = require('node:path');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude took me down this path as well while I was testing this out. To me, it feels a bit "one step removed" from a real-world validation. Could we instead write a more idiomatic import Plotly from "plotly.js"; and then assert the same things?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was all an attempt to run without npm install, but it's too complicated. I'll remove it in favor of GHA.


const consumerDir = process.argv[2];
const consumerRequire = createRequire(path.join(consumerDir, 'index.js'));

globalThis.self = globalThis;
globalThis.window = globalThis;

try {
consumerRequire('plotly.js');
console.log('LOADED');
} catch (err) {
console.log(err.code === undefined ? 'RUNTIME:' + err.name : 'CODE:' + err.code);
console.error(err.message.split('\n')[0]);
}
32 changes: 32 additions & 0 deletions tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
// Emit configuration for the published package.
//
// The repository authors a growing share of `src/` in TypeScript, but the
// published package must contain only JavaScript. Node's CommonJS resolver
// never tries a `.ts` extension, and Node refuses to strip types from any
// file below `node_modules`. So the `prepack` script writes a `.js` sibling
// for each `.ts` source, and `postpack` deletes it again.
//
// No `outDir` is set, so each `.js` lands next to its `.ts`. That is what
// makes `require('./mod')` resolve in the tarball.
//
// Build mode drives both scripts. `tsc -b` emits, and `tsc -b --clean`
// removes every generated file. Build mode also writes a state file, which
// `tsBuildInfoFile` parks below `build/`, because `build/` is already
// ignored by both git and npm.
//
// Type errors are not reported here. `npm run typecheck` owns that job and
// reads the whole program, including the JavaScript files.
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"noCheck": true,
"allowJs": false,
"module": "commonjs",
"declaration": false,
"isolatedModules": false,
"tsBuildInfoFile": "build/ts-build-info.json"
},
"include": ["src/**/*.ts"],
"exclude": ["src/types/**"]
}