Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand All @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions scripts/fix-esm-specifiers.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
18 changes: 16 additions & 2 deletions scripts/gas/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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`;

Expand Down Expand Up @@ -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`);
Expand Down
6 changes: 4 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading