Skip to content

Commit 9e30a1c

Browse files
authored
Merge pull request #510 from xyz-tools/codex/493-package-exports
Define the 3.0 ESM package exports and shared build
2 parents 4fd8b83 + b036b99 commit 9e30a1c

11 files changed

Lines changed: 186 additions & 28 deletions

File tree

File renamed without changes.

.github/workflows/run-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
- run: npm ci
3636
- name: Install three.js 0.185.1
3737
run: npm install three@0.185.1 @types/three@0.185.4 --no-save
38-
- run: npm run build
38+
- run: npm run test:packaging
3939
- run: npm run test:coverage
4040
- run: npm run typeCheck
4141
- run: npm run lint

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Run the full check suite:
103103
- `npm run typeCheck` for typescript typings
104104
- `npm run lint` for code style and formatting
105105
- `npm run build` for a production build
106+
- `npm run test:packaging` to pack/install a clean consumer and check exports, shared chunks, and TypeScript resolution
106107
- or most of it together: `npm run check` (test + typeCheck + lint — note it does **not** run `build` or coverage)
107108

108109
To auto-fix simple issues: `npm run lint:fix` or `npm run prettier:fix`.

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ Click to see the [full-fledged demo](https://gcode-preview.web.app/):
3737

3838
GCode Preview depends on [three.js](https://threejs.org/) and supports `three` `>=0.166.0 <0.186.0`.
3939

40+
### Module format (3.0)
41+
42+
The package is ESM-only. Import from `gcode-preview`; the only other public
43+
package path is `gcode-preview/package.json`. Deep imports such as
44+
`gcode-preview/dist/gcode-preview.es.js` are no longer supported, and the
45+
legacy UMD build / `GCodePreview` browser global has been removed.
46+
47+
TypeScript supports `node16`, `nodenext`, and `bundler` module resolution.
48+
Legacy `moduleResolution: "node"` can still resolve the root import through
49+
`types`; no `typesVersions` mapping is needed for the current root-only API.
50+
51+
For native browser modules, map `three` in an import map and load
52+
`dist/gcode-preview.es.js` as a module. When self-hosting, copy the **entire
53+
`dist` directory**, including any chunks. The debug GUI (`lil-gui`) is bundled,
54+
so it needs no separate install or import-map entry. The GUI remains synchronous;
55+
bundling adds roughly 9 KB gzipped even when `devMode` is disabled.
56+
4057
### Quick start
4158

4259
```js
File renamed without changes.

demo/index.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,6 @@ <h1 class="text-center m-3 mb-5">GCode Preview
276276
{
277277
"imports": {
278278
"three": "./lib/three/build/three.module.min.js",
279-
"lil-gui": "./lib/lil-gui/dist/lil-gui.esm.min.js",
280279
"vue": "./js/vue.esm-browser.prod.js",
281280
"gcode-preview": "./dist/gcode-preview.es.js"
282281
}

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@
1818
"@rollup/plugin-node-resolve": "^16.0.1",
1919
"@types/node": "^24.0.10",
2020
"@types/three": "^0.185.4",
21-
"@webgpu/types": "^0.1.64",
2221
"@typescript-eslint/eslint-plugin": "^8.36.0",
2322
"@typescript-eslint/parser": "^8.36.0",
2423
"@vitest/coverage-v8": "^4.1.11",
24+
"@webgpu/types": "^0.1.64",
2525
"concurrently": "^9.2.0",
2626
"copyfiles": "^2.4.1",
2727
"eslint": "^8.57.1",
2828
"happy-dom": "^20.0.0",
29+
"lil-gui": "^0.20.0",
2930
"live-server": "^1.2.1 <1.2.2",
3031
"prettier": "^3.6.2",
3132
"rollup": "^4.44.2",
@@ -43,9 +44,9 @@
4344
"demo": "npm run build && npm run live-server",
4445
"live-server": "live-server demo --watch=.,js,../dist --mount=/lib:node_modules --mount=/dist:dist",
4546
"typeCheck": "tsc --noEmit",
46-
"lint": "prettier --check . && eslint -c .eslintrc.js .",
47+
"lint": "prettier --check . && eslint -c .eslintrc.cjs .",
4748
"lint:fix": "npm run prettier:fix && npm run eslint:fix",
48-
"eslint:fix": "eslint -c .eslintrc.js . --fix",
49+
"eslint:fix": "eslint -c .eslintrc.cjs . --fix",
4950
"prettier:fix": "prettier --plugin-search-dir . --write .",
5051
"test": "vitest run",
5152
"test:watch": "vitest --watch",
@@ -55,17 +56,26 @@
5556
"version:minor": "npm version minor",
5657
"version:alpha": "npm version prerelease --preid=alpha",
5758
"predeploy": "npm run build && npm run copy-deps && npm run copy-dist",
58-
"copy-deps": "copyfiles -f node_modules/three/build/three.module.min.js demo/lib/three/build && copyfiles -f node_modules/three/build/three.core.min.js demo/lib/three/build && copyfiles -f node_modules/lil-gui/dist/lil-gui.esm.min.js demo/lib/lil-gui/dist",
59-
"copy-dist": "copyfiles -f dist/gcode-preview.es.js demo/dist",
59+
"copy-deps": "copyfiles -f node_modules/three/build/three.module.min.js demo/lib/three/build && copyfiles -f node_modules/three/build/three.core.min.js demo/lib/three/build",
60+
"copy-dist": "copyfiles -u 1 \"dist/**/*.js\" demo/dist",
6061
"deploy:preview": "live-server demo",
6162
"typedoc": "typedoc",
6263
"typedoc:watch": "typedoc --watch",
6364
"check": "npm run test && npm run typeCheck && npm run lint && echo '😅 All checks passed!'",
6465
"evals": "node --no-warnings --experimental-strip-types evals/run.ts",
65-
"evals:grade": "node --no-warnings --experimental-strip-types evals/grade.ts"
66+
"evals:grade": "node --no-warnings --experimental-strip-types evals/grade.ts",
67+
"test:packaging": "npm run build && node scripts/test-packaging.mjs"
6668
},
6769
"dependencies": {
68-
"lil-gui": "^0.20.0",
6970
"three": ">=0.166.0 <0.186.0"
70-
}
71+
},
72+
"type": "module",
73+
"exports": {
74+
".": {
75+
"types": "./dist/gcode-preview.d.ts",
76+
"import": "./dist/gcode-preview.es.js"
77+
},
78+
"./package.json": "./package.json"
79+
},
80+
"sideEffects": false
7181
}

rollup.config.mjs

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,29 @@ import pkg from './package.json' with { type: 'json' };
33
import esbuild from 'rollup-plugin-esbuild';
44
import dts from 'rollup-plugin-dts';
55
import { nodeResolve } from '@rollup/plugin-node-resolve';
6+
import { rmSync } from 'node:fs';
67

78
const isProd = process.env.NODE_ENV !== 'development';
9+
// Add future public entry points here so Rollup can share their implementation.
10+
const input = { 'gcode-preview': 'src/gcode-preview.ts' };
11+
const external = Object.keys(pkg.dependencies);
812
const config = [
913
{
10-
input: 'src/gcode-preview.ts', // our source file
11-
output: [
12-
{
13-
file: pkg.main,
14-
format: 'es' // the preferred format
15-
},
14+
input,
15+
output: {
16+
dir: 'dist',
17+
format: 'es',
18+
entryFileNames: '[name].es.js',
19+
chunkFileNames: 'chunks/[name]-[hash].js'
20+
},
21+
external,
22+
plugins: [
1623
{
17-
file: 'dist/gcode-preview.js',
18-
format: 'umd', // deprecated. might not work at some point
19-
name: 'GCodePreview', // the global which can be used in a browser
20-
globals: {
21-
three: 'THREE'
24+
name: 'clean-dist',
25+
buildStart() {
26+
rmSync(new URL('./dist', import.meta.url), { recursive: true, force: true });
2227
}
23-
}
24-
],
25-
external: [...Object.keys(pkg.dependencies || {})],
26-
plugins: [
28+
},
2729
nodeResolve(),
2830
esbuild({
2931
minify: isProd
@@ -35,8 +37,9 @@ const config = [
3537
if (isProd) {
3638
console.log('Building type definitions');
3739
config.push({
38-
input: 'src/gcode-preview.ts',
39-
output: { file: 'dist/gcode-preview.d.ts', format: 'es' },
40+
input,
41+
output: { dir: 'dist', entryFileNames: '[name].d.ts', chunkFileNames: 'chunks/[name]-[hash].d.ts', format: 'es' },
42+
external,
4043
plugins: [dts()]
4144
});
4245
}

scripts/test-packaging.mjs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/* eslint-env node */
2+
import assert from 'node:assert/strict';
3+
import { execFileSync } from 'node:child_process';
4+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5+
import { tmpdir } from 'node:os';
6+
import { dirname, join } from 'node:path';
7+
import { fileURLToPath } from 'node:url';
8+
import { rollup } from 'rollup';
9+
import configs from '../rollup.config.mjs';
10+
11+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
12+
const consumer = mkdtempSync(join(tmpdir(), 'gcode-preview-packaging-'));
13+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
14+
const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8'));
15+
const log = join(consumer, 'packaging.log');
16+
17+
function run(command, args, cwd = consumer) {
18+
console.log(`Packaging: ${command} ${args.join(' ')}`);
19+
try {
20+
const output = execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
21+
writeFileSync(log, output, { flag: 'a' });
22+
return output;
23+
} catch (error) {
24+
writeFileSync(log, `${error.stdout}\n${error.stderr}`, { flag: 'a' });
25+
throw error;
26+
}
27+
}
28+
29+
try {
30+
// Exercise sharing before the standalone parser becomes a public entry point (#492).
31+
for (const { output, ...config } of configs) {
32+
console.log(`Packaging: verify shared ${output.entryFileNames} chunks`);
33+
const bundle = await rollup({
34+
...config,
35+
input: { ...config.input, parser: 'src/parser/gcode-parser.ts' },
36+
plugins: config.plugins.filter((plugin) => plugin.name !== 'clean-dist')
37+
});
38+
try {
39+
const { output: chunks } = await bundle.generate(output);
40+
const owners = chunks.filter(
41+
(chunk) => chunk.type === 'chunk' && join(root, 'src/parser/gcode-parser.ts') in chunk.modules
42+
);
43+
assert.equal(owners.length, 1, 'parser implementation must not be duplicated');
44+
assert(
45+
chunks.some((chunk) => chunk.type === 'chunk' && chunk.imports.includes(owners[0].fileName)),
46+
'the preview entry must reuse the parser chunk'
47+
);
48+
assert.equal(chunks.filter((chunk) => chunk.type === 'chunk' && chunk.isEntry).length, 2);
49+
assert(owners[0].fileName.endsWith(output.entryFileNames.endsWith('.d.ts') ? '.d.ts' : '.js'));
50+
} finally {
51+
await bundle.close();
52+
}
53+
}
54+
const [packed] = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', consumer], root));
55+
assert(!packed.files.some(({ path }) => path === 'dist/gcode-preview.js'), 'UMD build must not ship');
56+
writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
57+
run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', join(consumer, packed.filename)]);
58+
writeFileSync(
59+
join(consumer, 'smoke.mjs'),
60+
`import assert from 'node:assert/strict';
61+
import { createRequire } from 'node:module';
62+
import { GCodePreview, Parser, Job, SceneManager } from 'gcode-preview';
63+
const require = createRequire(import.meta.url);
64+
const pkg = require('gcode-preview/package.json');
65+
assert.equal(pkg.type, 'module');
66+
assert.equal(pkg.sideEffects, false);
67+
assert.equal(pkg.dependencies['lil-gui'], undefined);
68+
assert.equal(typeof GCodePreview, 'function');
69+
assert.equal(typeof Job, 'function');
70+
assert.equal(typeof SceneManager, 'function');
71+
assert.equal(new Parser().parseCommand('G1 X42').params.x, 42);
72+
assert.throws(() => require.resolve('lil-gui'), { code: 'MODULE_NOT_FOUND' });
73+
for (const path of ['dist/gcode-preview.es.js', 'dist/gcode-preview.d.ts', 'src/gcode-preview', 'parser']) {
74+
await assert.rejects(import('gcode-preview/' + path), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' });
75+
}
76+
`
77+
);
78+
run(process.execPath, ['smoke.mjs']);
79+
80+
// Install the declaration dependencies explicitly, as a TypeScript consumer would.
81+
run('npm', [
82+
'install',
83+
'--ignore-scripts',
84+
'--no-audit',
85+
'--no-fund',
86+
`typescript@${lock.packages['node_modules/typescript'].version}`,
87+
`@types/three@${pkg.devDependencies['@types/three']}`,
88+
`@webgpu/types@${pkg.devDependencies['@webgpu/types']}`
89+
]);
90+
writeFileSync(
91+
join(consumer, 'consumer.ts'),
92+
`import { GCodePreview, Parser, type GCodePreviewOptions } from 'gcode-preview';
93+
const options: GCodePreviewOptions = { canvas: document.createElement('canvas') };
94+
new GCodePreview(options).processGCode('G1 X42');
95+
const x: number | undefined = new Parser().parseCommand('G1 X42')?.params.x;
96+
void x;
97+
`
98+
);
99+
for (const [moduleResolution, module] of [
100+
['node', 'esnext'],
101+
['node16', 'node16'],
102+
['nodenext', 'nodenext'],
103+
['bundler', 'esnext']
104+
]) {
105+
console.log(`Packaging: typecheck with moduleResolution=${moduleResolution}`);
106+
writeFileSync(
107+
join(consumer, 'tsconfig.json'),
108+
JSON.stringify({
109+
compilerOptions: {
110+
moduleResolution,
111+
module,
112+
target: 'es2020',
113+
strict: true,
114+
noEmit: true,
115+
types: ['@webgpu/types']
116+
},
117+
files: ['consumer.ts']
118+
})
119+
);
120+
run(process.execPath, ['node_modules/typescript/bin/tsc', '-p', 'tsconfig.json']);
121+
}
122+
console.log('Packaging smoke test passed (runtime, exports, and four TypeScript resolution modes).');
123+
rmSync(consumer, { recursive: true, force: true });
124+
} catch (error) {
125+
console.error(`Packaging fixture and command log retained at ${consumer}`);
126+
throw error;
127+
}

0 commit comments

Comments
 (0)