diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f812ed9..31075c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,11 @@ jobs: - name: Build the library run: npm run build:lib + # 'Zero dependencies' is checkable in ten seconds on Bundlephobia, + # so it has to be exactly true. 0.0.2 shipped declaring tslib. + - name: No runtime dependencies + run: node scripts/verify-deps.mjs + # Also exercises the command Vercel runs, and the llms.txt sync step. - name: Build the demo run: npm run build diff --git a/package.json b/package.json index 91c5c3c..1fb4d4a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "prebuild": "node scripts/sync-llms.mjs", "start": "npm run build:lib && ng serve demo", "build": "npm run build:lib && ng build demo", - "build:lib": "ng build masonry-angular", + "build:lib": "ng build masonry-angular && node scripts/strip-tslib.mjs", "watch:lib": "ng build masonry-angular --watch", "test": "ng test masonry-angular --watch=false", "test:watch": "ng test masonry-angular --watch", @@ -21,7 +21,8 @@ "verify:ssr": "npm run build:lib && node scripts/verify-ssr.mjs", "verify:compat": "node scripts/verify-compat.mjs", "verify:docs": "npm run build:lib && node scripts/verify-docs.mjs", - "postbuild": "node scripts/inject-meta.mjs" + "postbuild": "node scripts/inject-meta.mjs", + "verify:deps": "npm run build:lib && node scripts/verify-deps.mjs" }, "packageManager": "npm@11.12.1", "engines": { diff --git a/projects/masonry-angular/package.json b/projects/masonry-angular/package.json index 6256234..593ee11 100644 --- a/projects/masonry-angular/package.json +++ b/projects/masonry-angular/package.json @@ -45,8 +45,5 @@ "peerDependencies": { "@angular/common": ">=17.1.0", "@angular/core": ">=17.1.0" - }, - "dependencies": { - "tslib": "^2.3.0" } } diff --git a/projects/masonry-angular/tsconfig.lib.json b/projects/masonry-angular/tsconfig.lib.json index 88b89d6..e37fc40 100644 --- a/projects/masonry-angular/tsconfig.lib.json +++ b/projects/masonry-angular/tsconfig.lib.json @@ -6,6 +6,10 @@ "outDir": "../../out-tsc/lib", "declaration": true, "declarationMap": true, + /* Inline any TypeScript helper instead of importing it from tslib: a library + that claims no dependencies must not grow one when some syntax needs a + helper. scripts/verify-deps.mjs checks the built output. */ + "importHelpers": false, "types": [] }, "include": ["src/**/*.ts", "testing/**/*.ts"], diff --git a/scripts/strip-tslib.mjs b/scripts/strip-tslib.mjs new file mode 100644 index 0000000..d2849bc --- /dev/null +++ b/scripts/strip-tslib.mjs @@ -0,0 +1,39 @@ +/** + * Removes the `tslib` dependency ng-packagr writes into the built package.json. + * + * ng-packagr adds `tslib` to `dependencies` unconditionally when a library does + * not declare it, reading the version from @angular/compiler — there is no + * option to turn that off, so leaving it out of the source package.json cannot + * work. For most Angular libraries that is harmless, because their compiled + * code imports tslib's helpers. + * + * This one does not. The library tsconfig sets `importHelpers: false`, so any + * helper TypeScript needs is inlined, and the built code imports nothing but + * @angular/core. Declaring tslib anyway is metadata that does not match the + * code: Bundlephobia reported "1 dependency" for 0.0.2 beside a README that + * said none. + * + * Stripping it is safe only because it is checked. `npm run verify:deps` fails + * if the built code imports any module that is not a declared peer, so the day + * a build does need tslib, CI says so instead of consumers finding out. + * + * Runs as part of `npm run build:lib`, so `npm run release` publishes the + * corrected file. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PKG = join(ROOT, 'dist/masonry-angular/package.json'); + +const pkg = JSON.parse(readFileSync(PKG, 'utf8')); + +if (pkg.dependencies?.tslib) { + delete pkg.dependencies.tslib; + if (Object.keys(pkg.dependencies).length === 0) delete pkg.dependencies; + writeFileSync(PKG, JSON.stringify(pkg, null, 2) + '\n'); + console.log('strip-tslib — removed the tslib dependency ng-packagr added.'); +} else { + console.log('strip-tslib — nothing to remove.'); +} diff --git a/scripts/verify-deps.mjs b/scripts/verify-deps.mjs new file mode 100644 index 0000000..8b9aac2 --- /dev/null +++ b/scripts/verify-deps.mjs @@ -0,0 +1,69 @@ +/** + * Checks that the published package has no runtime dependencies. + * + * "Zero dependencies" is a claim a stranger can verify in ten seconds on + * Bundlephobia, so it has to be exactly true. It was not: ng-packagr's default + * template declares `tslib`, and version 0.0.2 shipped declaring it even though + * the built code never imported it. Bundlephobia reported "1 dependency" beside + * a README that said none. + * + * Two things can make the claim false, so this checks both: + * + * 1. package.json declares something under `dependencies`. + * 2. The built code imports a module that is not a declared peer — which is + * how a dependency sneaks in without anyone adding it on purpose, for + * instance a TypeScript helper imported from tslib. + * + * Run it after `npm run build:lib`: + * + * npm run verify:deps + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DIST = join(ROOT, 'dist/masonry-angular'); + +const pkg = JSON.parse(readFileSync(join(DIST, 'package.json'), 'utf8')); +const peers = Object.keys(pkg.peerDependencies ?? {}); +const problems = []; + +// 1. Declared dependencies. +const declared = Object.keys(pkg.dependencies ?? {}); +if (declared.length > 0) { + problems.push(`package.json declares dependencies: ${declared.join(', ')}`); +} + +// 2. What the shipped code actually imports. +const fesm = join(DIST, 'fesm2022'); +const imported = new Map(); +for (const file of readdirSync(fesm).filter((f) => f.endsWith('.mjs'))) { + const code = readFileSync(join(fesm, file), 'utf8'); + for (const [, spec] of code.matchAll(/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g)) { + if (spec.startsWith('.')) continue; // internal + // `@scope/name/sub` and `name/sub` both belong to their package. + const name = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]; + if (name === pkg.name) continue; // a secondary entry point importing the primary + if (!imported.has(name)) imported.set(name, new Set()); + imported.get(name).add(file); + } +} + +for (const [name, files] of imported) { + if (!peers.includes(name)) { + problems.push(`${[...files].join(', ')} imports '${name}', which is not a declared peer`); + } +} + +console.log('verify:deps — the published package must have no runtime dependencies.\n'); +console.log(` declared dependencies ${declared.length ? declared.join(', ') : 'none'}`); +console.log(` modules imported ${[...imported.keys()].join(', ') || 'none'}`); +console.log(` declared peers ${peers.join(', ')}`); + +if (problems.length > 0) { + console.log('\nFAIL'); + for (const p of problems) console.log(` - ${p}`); + process.exit(1); +} +console.log('\nOK — every import is a declared peer, and nothing else is required.');