Skip to content

Commit b65652f

Browse files
authored
Merge pull request #10 from MeAkib/fix/zero-dependencies
fix: ship with zero runtime dependencies, and check it
2 parents 6e59e65 + 018b52f commit b65652f

6 files changed

Lines changed: 120 additions & 5 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ jobs:
3838
- name: Build the library
3939
run: npm run build:lib
4040

41+
# 'Zero dependencies' is checkable in ten seconds on Bundlephobia,
42+
# so it has to be exactly true. 0.0.2 shipped declaring tslib.
43+
- name: No runtime dependencies
44+
run: node scripts/verify-deps.mjs
45+
4146
# Also exercises the command Vercel runs, and the llms.txt sync step.
4247
- name: Build the demo
4348
run: npm run build

‎package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"prebuild": "node scripts/sync-llms.mjs",
88
"start": "npm run build:lib && ng serve demo",
99
"build": "npm run build:lib && ng build demo",
10-
"build:lib": "ng build masonry-angular",
10+
"build:lib": "ng build masonry-angular && node scripts/strip-tslib.mjs",
1111
"watch:lib": "ng build masonry-angular --watch",
1212
"test": "ng test masonry-angular --watch=false",
1313
"test:watch": "ng test masonry-angular --watch",
@@ -21,7 +21,8 @@
2121
"verify:ssr": "npm run build:lib && node scripts/verify-ssr.mjs",
2222
"verify:compat": "node scripts/verify-compat.mjs",
2323
"verify:docs": "npm run build:lib && node scripts/verify-docs.mjs",
24-
"postbuild": "node scripts/inject-meta.mjs"
24+
"postbuild": "node scripts/inject-meta.mjs",
25+
"verify:deps": "npm run build:lib && node scripts/verify-deps.mjs"
2526
},
2627
"packageManager": "npm@11.12.1",
2728
"engines": {

‎projects/masonry-angular/package.json‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,5 @@
4545
"peerDependencies": {
4646
"@angular/common": ">=17.1.0",
4747
"@angular/core": ">=17.1.0"
48-
},
49-
"dependencies": {
50-
"tslib": "^2.3.0"
5148
}
5249
}

‎projects/masonry-angular/tsconfig.lib.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
"outDir": "../../out-tsc/lib",
77
"declaration": true,
88
"declarationMap": true,
9+
/* Inline any TypeScript helper instead of importing it from tslib: a library
10+
that claims no dependencies must not grow one when some syntax needs a
11+
helper. scripts/verify-deps.mjs checks the built output. */
12+
"importHelpers": false,
913
"types": []
1014
},
1115
"include": ["src/**/*.ts", "testing/**/*.ts"],

‎scripts/strip-tslib.mjs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Removes the `tslib` dependency ng-packagr writes into the built package.json.
3+
*
4+
* ng-packagr adds `tslib` to `dependencies` unconditionally when a library does
5+
* not declare it, reading the version from @angular/compiler — there is no
6+
* option to turn that off, so leaving it out of the source package.json cannot
7+
* work. For most Angular libraries that is harmless, because their compiled
8+
* code imports tslib's helpers.
9+
*
10+
* This one does not. The library tsconfig sets `importHelpers: false`, so any
11+
* helper TypeScript needs is inlined, and the built code imports nothing but
12+
* @angular/core. Declaring tslib anyway is metadata that does not match the
13+
* code: Bundlephobia reported "1 dependency" for 0.0.2 beside a README that
14+
* said none.
15+
*
16+
* Stripping it is safe only because it is checked. `npm run verify:deps` fails
17+
* if the built code imports any module that is not a declared peer, so the day
18+
* a build does need tslib, CI says so instead of consumers finding out.
19+
*
20+
* Runs as part of `npm run build:lib`, so `npm run release` publishes the
21+
* corrected file.
22+
*/
23+
import { readFileSync, writeFileSync } from 'node:fs';
24+
import { dirname, join, resolve } from 'node:path';
25+
import { fileURLToPath } from 'node:url';
26+
27+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
28+
const PKG = join(ROOT, 'dist/masonry-angular/package.json');
29+
30+
const pkg = JSON.parse(readFileSync(PKG, 'utf8'));
31+
32+
if (pkg.dependencies?.tslib) {
33+
delete pkg.dependencies.tslib;
34+
if (Object.keys(pkg.dependencies).length === 0) delete pkg.dependencies;
35+
writeFileSync(PKG, JSON.stringify(pkg, null, 2) + '\n');
36+
console.log('strip-tslib — removed the tslib dependency ng-packagr added.');
37+
} else {
38+
console.log('strip-tslib — nothing to remove.');
39+
}

‎scripts/verify-deps.mjs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Checks that the published package has no runtime dependencies.
3+
*
4+
* "Zero dependencies" is a claim a stranger can verify in ten seconds on
5+
* Bundlephobia, so it has to be exactly true. It was not: ng-packagr's default
6+
* template declares `tslib`, and version 0.0.2 shipped declaring it even though
7+
* the built code never imported it. Bundlephobia reported "1 dependency" beside
8+
* a README that said none.
9+
*
10+
* Two things can make the claim false, so this checks both:
11+
*
12+
* 1. package.json declares something under `dependencies`.
13+
* 2. The built code imports a module that is not a declared peer — which is
14+
* how a dependency sneaks in without anyone adding it on purpose, for
15+
* instance a TypeScript helper imported from tslib.
16+
*
17+
* Run it after `npm run build:lib`:
18+
*
19+
* npm run verify:deps
20+
*/
21+
import { readFileSync, readdirSync } from 'node:fs';
22+
import { dirname, join, resolve } from 'node:path';
23+
import { fileURLToPath } from 'node:url';
24+
25+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
26+
const DIST = join(ROOT, 'dist/masonry-angular');
27+
28+
const pkg = JSON.parse(readFileSync(join(DIST, 'package.json'), 'utf8'));
29+
const peers = Object.keys(pkg.peerDependencies ?? {});
30+
const problems = [];
31+
32+
// 1. Declared dependencies.
33+
const declared = Object.keys(pkg.dependencies ?? {});
34+
if (declared.length > 0) {
35+
problems.push(`package.json declares dependencies: ${declared.join(', ')}`);
36+
}
37+
38+
// 2. What the shipped code actually imports.
39+
const fesm = join(DIST, 'fesm2022');
40+
const imported = new Map();
41+
for (const file of readdirSync(fesm).filter((f) => f.endsWith('.mjs'))) {
42+
const code = readFileSync(join(fesm, file), 'utf8');
43+
for (const [, spec] of code.matchAll(/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g)) {
44+
if (spec.startsWith('.')) continue; // internal
45+
// `@scope/name/sub` and `name/sub` both belong to their package.
46+
const name = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0];
47+
if (name === pkg.name) continue; // a secondary entry point importing the primary
48+
if (!imported.has(name)) imported.set(name, new Set());
49+
imported.get(name).add(file);
50+
}
51+
}
52+
53+
for (const [name, files] of imported) {
54+
if (!peers.includes(name)) {
55+
problems.push(`${[...files].join(', ')} imports '${name}', which is not a declared peer`);
56+
}
57+
}
58+
59+
console.log('verify:deps — the published package must have no runtime dependencies.\n');
60+
console.log(` declared dependencies ${declared.length ? declared.join(', ') : 'none'}`);
61+
console.log(` modules imported ${[...imported.keys()].join(', ') || 'none'}`);
62+
console.log(` declared peers ${peers.join(', ')}`);
63+
64+
if (problems.length > 0) {
65+
console.log('\nFAIL');
66+
for (const p of problems) console.log(` - ${p}`);
67+
process.exit(1);
68+
}
69+
console.log('\nOK — every import is a declared peer, and nothing else is required.');

0 commit comments

Comments
 (0)