diff --git a/package.json b/package.json index bffef344..46e8b34c 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,14 @@ "description": "Utilities for Google Apps Script™ projects.", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist" ], @@ -21,7 +29,7 @@ "lint:fix": "eslint --fix .", "test": "vitest run", "test:cov": "vitest run --coverage", - "build": "rm -rf dist/* && tsc -b --force", + "build": "rm -rf dist/* && tsc -b --force && node scripts/fix-esm-specifiers.mjs dist", "build:gas": "npm run build && node scripts/gas/bundle.mjs scripts/gas/Library.js", "docs:generate": "node scripts/docs/generate-topics.mjs", "docs:check": "node scripts/docs/generate-topics.mjs --check", diff --git a/scripts/fix-esm-specifiers.mjs b/scripts/fix-esm-specifiers.mjs new file mode 100644 index 00000000..31e0b279 --- /dev/null +++ b/scripts/fix-esm-specifiers.mjs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Rewrites extensionless relative import specifiers in the compiled output to fully specified ones, + * so that `dist` can be loaded by Node.js ESM without a bundler: + * + * - `"./foo"` → `"./foo.js"` when `foo.js` (or `foo.d.ts` for declarations) exists; + * - `"./bar"` → `"./bar/index.js"` when `bar` is a directory with an index file. + * + * Bundlers (Vite, Rollup, esbuild) accept both forms, so the Apps Script bundling is unaffected. + * + * Usage: `node scripts/fix-esm-specifiers.mjs [dir]` (default: `dist`). + */ + +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +const root = resolve(process.argv[2] ?? "dist"); + +/** + * Matches relative specifiers in static imports/exports, side-effect imports and dynamic imports. + * Group 1 is the opening part, group 2 the quote, group 3 the specifier. + */ +const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(\.{1,2}\/[^"']*?)\2/g; + +/** + * Lists all `.js` and `.d.ts` files below a directory. + * + * @param {string} dir Directory to scan. + * @returns {string[]} Absolute file paths. + */ +function listFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + + if (entry.isDirectory()) { + return listFiles(path); + } + + return /\.(js|d\.ts)$/.test(entry.name) ? [path] : []; + }); +} + +/** + * Returns the fully specified form of a relative specifier, or the specifier unchanged. + * + * @param {string} fromFile File that contains the specifier. + * @param {string} specifier Relative specifier, e.g. `./controller`. + * @returns {string} Fully specified specifier, e.g. `./controller/index.js`. + */ +function fullySpecify(fromFile, specifier) { + if (/\.(m?js|cjs|json)$/.test(specifier)) { + return specifier; + } + + const base = resolve(dirname(fromFile), specifier); + + const isDeclaration = fromFile.endsWith(".d.ts"); + + const ext = isDeclaration ? ".d.ts" : ".js"; + + if (existsSync(base + ext)) { + return `${specifier}.js`; + } + + const isDirectory = existsSync(base) && statSync(base).isDirectory(); + + if (isDirectory && existsSync(join(base, `index${ext}`))) { + const directory = specifier.replace(/\/$/, ""); + + return `${directory}/index.js`; + } + + return specifier; +} + +if (!existsSync(root)) { + console.error(`fix-esm-specifiers: ${root} does not exist`); + process.exit(1); +} + +let changed = 0; + +const unresolved = []; + +for (const file of listFiles(root)) { + const source = readFileSync(file, "utf8"); + + const output = source + .split("\n") + .map((line) => + // Leave comment lines alone: JSDoc examples contain imports of files that do not exist. + /^\s*(\*|\/\/|\/\*)/.test(line) + ? line + : line.replace(SPECIFIER, (match, head, quote, specifier) => { + const next = fullySpecify(file, specifier); + + if (next === specifier && !/\.(m?js|cjs|json)$/.test(specifier)) { + unresolved.push(`${file}: ${specifier}`); + } + + return `${head}${quote}${next}${quote}`; + }) + ) + .join("\n"); + + if (output !== source) { + writeFileSync(file, output); + changed++; + } +} + +if (unresolved.length > 0) { + const list = unresolved.join("\n "); + + console.error(`fix-esm-specifiers: unresolved relative specifiers:\n ${list}`); + process.exit(1); +} + +console.log(`fix-esm-specifiers: updated ${changed} file(s) in ${root}`); diff --git a/scripts/gas/bundle.mjs b/scripts/gas/bundle.mjs index 5f69aaac..957e8a61 100644 --- a/scripts/gas/bundle.mjs +++ b/scripts/gas/bundle.mjs @@ -70,6 +70,20 @@ const files = walk(DIST) .filter((p) => !p.endsWith(`${sep}index.js`)) .sort(); +/** + * Undoes scripts/fix-esm-specifiers.mjs, so that a specifier names a module + * the way the TypeScript sources write it: `./a/index.js` was `./a`, and + * `./b.js` was `./b`. `./index.js` and `../index.js` stay files (`./index`), + * since the sources never import a bare `.` or `..`. + */ +function bare(spec) { + if (/\/[^./][^/]*\/index\.js$/.test(spec)) { + return spec.replace(/\/index\.js$/, ""); + } + + return spec.replace(/\.js$/, ""); +} + /** * Maps an import specifier to the emitted files it actually pulls in. */ @@ -78,7 +92,7 @@ function resolveSpec(src, spec) { return []; } - const target = resolve(dirname(src), spec); + const target = resolve(dirname(src), bare(spec)); const direct = `${target}.js`; @@ -108,7 +122,7 @@ function resolveSpec(src, spec) { seen.add(current); for (const match of readFileSync(current, "utf8").matchAll(FROM_ANY)) { - const next = resolve(dirname(current), match[1]); + const next = resolve(dirname(current), bare(match[1])); if (exists(`${next}.js`)) { out.push(`${next}.js`); diff --git a/tsconfig.json b/tsconfig.json index 84be0420..3ec4a2e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,9 +42,10 @@ /** * Create sourcemaps for d.ts files. + * Off: the package ships `dist` without `src`, so the maps would point at files that are not there. * @see https://www.typescriptlang.org/tsconfig#declarationMap */ - "declarationMap": true, + "declarationMap": false, /** * Emit design-type metadata for decorated declarations in source files. @@ -158,9 +159,10 @@ /** * Create source map files for emitted JavaScript files. + * Off: the package ships `dist` without `src`, so the maps would point at files that are not there. * @see https://www.typescriptlang.org/tsconfig#sourceMap */ - "sourceMap": true, + "sourceMap": false, /** * Enable all strict type-checking options.