diff --git a/.changeset/internal-exports-blocker.md b/.changeset/internal-exports-blocker.md new file mode 100644 index 0000000000..07de6d1f0c --- /dev/null +++ b/.changeset/internal-exports-blocker.md @@ -0,0 +1,14 @@ +--- +'@fuzdev/gro': minor +--- + +feat: block `internal/` directories from generated package exports + +Each `internal/` directory under `src/lib` (any depth) gets a null exports +entry from `gro sync` — `"./internal/*": null`, `"./domain/internal/*": +null` — Node's explicit-exclusion form, best-matching the directory's +subpaths ahead of the broader wildcards. Internal modules ship in dist for +public modules to import but can't be imported by consumers, and internal +files no longer count toward which wildcard export patterns are emitted. +`svelte-docinfo` honors the same signal in exports discovery and excludes +`internal/` directories from analysis at any depth by default. diff --git a/.changeset/library-cache-version.md b/.changeset/library-cache-version.md new file mode 100644 index 0000000000..b9659938a7 --- /dev/null +++ b/.changeset/library-cache-version.md @@ -0,0 +1,14 @@ +--- +'@fuzdev/gro': minor +--- + +fix: self-invalidate the library cache when svelte-docinfo changes + +The `.gro/library.json` cache stores svelte-docinfo's module array verbatim, +keyed by the analyzed repo's commit hash — which doesn't move when the +_analyzer's_ svelte-docinfo changes its output shape, so caches written +before the 0.6 `intersects` → `externalTypes` rename kept serving the old +field at an unchanged clean commit. Each record now stamps the installed +svelte-docinfo version and any mismatch is stale. `LIBRARY_CACHE_VERSION` +moves to 2 for the added field, and the `svelte-docinfo` peer range tightens +to `>=0.6.0`. diff --git a/CLAUDE.md b/CLAUDE.md index c9e1a889b5..83b2253568 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,8 @@ Key responsibilities: - Plugin system for dev/build workflows - library metadata loading (`library_load.ts`) - analyzes TypeScript/Svelte source via `svelte-docinfo` to produce `LibraryJson` consumed by fuz_ui's API - documentation system, cached in `.gro/` keyed by git commit + documentation system, cached in `.gro/` keyed by git commit and revalidated + against the cache format version and the installed `svelte-docinfo` version ## Core systems @@ -321,7 +322,14 @@ interface GroConfig { map_package_json: Runs during `gro sync` to auto-generate `"exports"` field in package.json using wildcard patterns for files in `src/lib/`. Return `null` -to opt out. +to opt out. Each `internal/` directory (any depth) gets a null exports entry +(`"./internal/*": null`, `"./domain/internal/*": null` — Node's +explicit-exclusion form; exports keys allow one `*`, hence one key per +directory), so internal modules ship in dist for public modules to import +but can't be imported by consumers (the `internal/` convention; +`svelte-docinfo` honors the same signal in discovery and excludes +`internal/` from analysis at any depth by default), and internal files +don't count toward which wildcard patterns are emitted. Example config: diff --git a/package-lock.json b/package-lock.json index ac8f5caeb3..a7810d7ff9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "magic-string": "^0.30.21", "svelte": "^5.55.4", "svelte-check": "^4.4.6", - "svelte-docinfo": "^0.5.4", + "svelte-docinfo": "^0.6.0", "svelte2tsx": "^0.7.52", "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", @@ -68,7 +68,7 @@ "@sveltejs/kit": "^2", "esbuild": "^0.28.0", "svelte": "^5", - "svelte-docinfo": ">=0.4.1", + "svelte-docinfo": ">=0.6.0", "typescript": "^5", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "^3 || ^4", @@ -4027,9 +4027,9 @@ } }, "node_modules/svelte-docinfo": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/svelte-docinfo/-/svelte-docinfo-0.5.4.tgz", - "integrity": "sha512-9XONWxu//kRmThbJBpDuNu3jSP6lSPgMy4T9G2GYHWWrHDiowJFn8ov5acvhBvAogBinBATylXk1TsvyQlI0iQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/svelte-docinfo/-/svelte-docinfo-0.6.0.tgz", + "integrity": "sha512-j6N5JyQpCEl8L22Q4jDgA9/sMHDLKilQ5zBnDtcgbwlUf0hW56RiPacozepjap0ud040s82p6DrMdxkAC4gEcA==", "dev": true, "license": "MIT", "dependencies": { @@ -4037,8 +4037,7 @@ "commander": "^14.0.3", "es-module-lexer": "^2.0.0", "picomatch": "^4.0.4", - "tinyglobby": "^0.2.15", - "typescript": "^5.9.3" + "tinyglobby": "^0.2.15" }, "bin": { "svelte-docinfo": "dist/main.js" @@ -4046,6 +4045,7 @@ "peerDependencies": { "svelte": "^5.0.0", "svelte2tsx": "^0.7.30", + "typescript": "^5.9.3", "zod": "^4" } }, diff --git a/package.json b/package.json index d3c4e3d890..8a28bc17e7 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@sveltejs/kit": "^2", "esbuild": "^0.28.0", "svelte": "^5", - "svelte-docinfo": ">=0.4.1", + "svelte-docinfo": ">=0.6.0", "typescript": "^5", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "^3 || ^4", @@ -109,7 +109,7 @@ "magic-string": "^0.30.21", "svelte": "^5.55.4", "svelte-check": "^4.4.6", - "svelte-docinfo": "^0.5.4", + "svelte-docinfo": "^0.6.0", "svelte2tsx": "^0.7.52", "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", diff --git a/src/docs/config.md b/src/docs/config.md index be16540890..89ecb0d24a 100644 --- a/src/docs/config.md +++ b/src/docs/config.md @@ -149,8 +149,12 @@ The motivation is to streamline package publishing by supplementing [`@sveltejs/package`](https://svelte.dev/docs/kit/packaging). By default `package_json.exports` uses subpath wildcard patterns to include everything from `$lib/` -except for some ignored files like tests and markdown, -and you can provide your own `map_package_json` hook to +except for some ignored files like tests and markdown. +Each `internal/` directory (any depth) gets a null exports entry (`"./internal/*": null`), +so internal modules ship in dist for public modules to import +but can't be imported by consumers, +and internal files don't count toward which wildcard patterns are emitted. +You can provide your own `map_package_json` hook to mutate and return the `package_json`, return a new one, or return `null` to opt out of transforming it completely. diff --git a/src/docs/gro_plugin_sveltekit_library.md b/src/docs/gro_plugin_sveltekit_library.md index c82c65af73..0c9c88acc2 100644 --- a/src/docs/gro_plugin_sveltekit_library.md +++ b/src/docs/gro_plugin_sveltekit_library.md @@ -72,6 +72,9 @@ and the [SvelteKit packaging docs](https://svelte.dev/docs/kit/packaging#options When this plugin is active, `gro sync` auto-generates `package.json` `"exports"` using wildcard subpath patterns for `.js`, `.ts`, `.svelte`, `.json`, and `.css` files in `src/lib/`. +Each `internal/` directory (any depth) gets a null exports entry (`"./internal/*": null`) +that blocks consumer imports while its files still ship in `dist/`, +and internal files don't count toward which wildcard patterns are emitted. Customize via [`map_package_json` in the config](config.md#map_package_json). For the full publishing workflow, see [publish.md](publish.md). diff --git a/src/lib/library_load.ts b/src/lib/library_load.ts index eb62cf15b4..92e3ae0a17 100644 --- a/src/lib/library_load.ts +++ b/src/lib/library_load.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { styleText as st } from 'node:util'; import { analyzeFromFiles } from 'svelte-docinfo'; @@ -26,9 +27,23 @@ export const LIBRARY_CACHE_FILENAME = 'library.json'; * this whenever `LibraryCache`'s shape changes (e.g. the `LibraryJson` / * `PkgJson` split, then slimming `LibraryJson` to the raw `pkg_json`/`source_json` * pair) to self-invalidate stale caches across the ecosystem rather than serve - * old-shaped data at an unchanged commit. + * old-shaped data at an unchanged commit. Upstream analyzer changes need no + * bump: each record also stamps `SVELTE_DOCINFO_VERSION` and self-invalidates + * when the installed `svelte-docinfo` moves. */ -export const LIBRARY_CACHE_VERSION = 1; +export const LIBRARY_CACHE_VERSION = 2; + +const require = createRequire(import.meta.url); + +/** + * The installed `svelte-docinfo` version — read from the same copy that + * `analyzeFromFiles` resolves to. Stamped into each cache record and compared + * on read, so caches analyzed by a different `svelte-docinfo` self-invalidate: + * `source_json.modules` is cached verbatim, and the cache key (the analyzed + * repo's commit hash) doesn't move when the *analyzer's* dependency changes + * its output shape. + */ +export const SVELTE_DOCINFO_VERSION: string = require('svelte-docinfo/package.json').version; /** * Result of loading a repo's library metadata: the curated `LibraryJson` @@ -46,11 +61,13 @@ export interface LibraryLoadResult { /** * On-disk shape of the `.gro/library.json` cache file. * The `hash` is the git-based cache key the result was computed at; `version` - * is the `LIBRARY_CACHE_VERSION` it was written under. + * is the `LIBRARY_CACHE_VERSION` it was written under; + * `svelte_docinfo_version` is the `SVELTE_DOCINFO_VERSION` that analyzed it. */ export interface LibraryCache extends LibraryLoadResult { hash: string; version: number; + svelte_docinfo_version: string; } export interface LibraryLoadOptions { @@ -79,10 +96,12 @@ export const library_cache_key = async (repo_dir: string): Promise => { try { await mkdir(dirname(cache_path), { recursive: true }); - const data: LibraryCache = { hash: key, version: LIBRARY_CACHE_VERSION, ...result }; + const data: LibraryCache = { + hash: key, + version: LIBRARY_CACHE_VERSION, + svelte_docinfo_version: SVELTE_DOCINFO_VERSION, + ...result + }; await writeFile(cache_path, JSON.stringify(data, null, '\t') + '\n', 'utf-8'); log?.debug('library cache written', st('dim', `(${cache_path})`)); } catch (error) { diff --git a/src/lib/package_json.ts b/src/lib/package_json.ts index e39f1f6543..dc56130f34 100644 --- a/src/lib/package_json.ts +++ b/src/lib/package_json.ts @@ -132,17 +132,55 @@ export const package_json_update = async ( const is_index = (path: string): boolean => path === 'index.ts' || path === 'index.js'; +/** + * The `internal/` convention: modules under an `internal/` directory ship in + * dist so public modules can import them, but they're not part of the public + * surface. Each internal directory gets a `"./…/internal/*": null` exports + * entry blocking consumer imports (Node's explicit-exclusion form — the null + * key best-matches the directory's subpaths ahead of the broader wildcards; + * exports keys allow a single `*`, hence one key per directory rather than an + * any-depth pattern). Tooling honors the same signal: `svelte-docinfo` skips + * null-blocked subpaths during exports discovery and excludes `internal/` + * directories from analysis at any depth by default. + * + * Returns the blocking key for the path's outermost `internal` directory + * segment (which covers any nested ones), or `null` for a public path — a + * file merely *named* `internal` is public. + */ +const internal_export_key = (path: string): string | null => { + const segments = path.split('/'); + const index = segments.indexOf('internal'); + if (index === -1 || index === segments.length - 1) return null; + return './' + segments.slice(0, index + 1).join('/') + '/*'; +}; + export const package_json_to_exports = (paths: Array): PackageJsonExports => { - const has_index = paths.some(is_index); - const has_js = paths.some((p) => TS_MATCHER.test(p) || JS_MATCHER.test(p)); - const has_svelte = paths.some((p) => SVELTE_MATCHER.test(p)); - const has_json = paths.some((p) => JSON_MATCHER.test(p)); - const has_css = paths.some((p) => CSS_MATCHER.test(p)); + // wildcard flags come from the public files only — internal files can't + // justify a wildcard whose internal subpaths the null keys then block + const internal_keys: Set = new Set(); + const public_paths: Array = []; + for (const path of paths) { + const internal_key = internal_export_key(path); + if (internal_key === null) { + public_paths.push(path); + } else { + internal_keys.add(internal_key); + } + } + const has_index = public_paths.some(is_index); + const has_js = public_paths.some((p) => TS_MATCHER.test(p) || JS_MATCHER.test(p)); + const has_svelte = public_paths.some((p) => SVELTE_MATCHER.test(p)); + const has_json = public_paths.some((p) => JSON_MATCHER.test(p)); + const has_css = public_paths.some((p) => CSS_MATCHER.test(p)); const exports: PackageJsonExports = { './package.json': './package.json' }; + for (const internal_key of [...internal_keys].sort()) { + exports[internal_key] = null; + } + if (has_index) { exports['.'] = { types: IMPORT_PREFIX + 'index.d.ts', diff --git a/src/test/library_load.test.ts b/src/test/library_load.test.ts index f609b8a0c5..3352fd37a0 100644 --- a/src/test/library_load.test.ts +++ b/src/test/library_load.test.ts @@ -1,5 +1,4 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; -import type { LibraryJson } from '@fuzdev/fuz_util/library_json.ts'; import type { PackageJson } from '@fuzdev/fuz_util/package_json.ts'; // Mock the git helpers so the cache-key/staleness logic can be tested in @@ -21,6 +20,15 @@ vi.mock('@fuzdev/fuz_util/fs.js', () => ({ fs_exists: vi.fn() })); +// Mock the analyzer and the `LibraryJson` assembly so `library_load_from_repo` +// can be tested end-to-end without a real svelte-docinfo analysis. +vi.mock('svelte-docinfo', () => ({ + analyzeFromFiles: vi.fn() +})); +vi.mock('@fuzdev/fuz_util/library_json.js', () => ({ + library_json_from_modules: vi.fn() +})); + import { git_current_commit_hash, git_check_workspace, @@ -28,12 +36,16 @@ import { } from '@fuzdev/fuz_util/git.ts'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { fs_exists } from '@fuzdev/fuz_util/fs.ts'; +import { analyzeFromFiles } from 'svelte-docinfo'; +import { library_json_from_modules, type LibraryJson } from '@fuzdev/fuz_util/library_json.ts'; import { LIBRARY_CACHE_VERSION, + SVELTE_DOCINFO_VERSION, library_cache_key, library_cache_read, - library_cache_write + library_cache_write, + library_load_from_repo } from '$lib/library_load.ts'; const mocked_commit = vi.mocked(git_current_commit_hash); @@ -43,6 +55,8 @@ const mocked_read = vi.mocked(readFile); const mocked_write = vi.mocked(writeFile); const mocked_mkdir = vi.mocked(mkdir); const mocked_exists = vi.mocked(fs_exists); +const mocked_analyze = vi.mocked(analyzeFromFiles); +const mocked_library_json_from_modules = vi.mocked(library_json_from_modules); // A minimal stand-in for the analyzed library metadata - only the cache // round-trip is under test here, not the shape of `LibraryJson`. @@ -50,6 +64,13 @@ const fake_library = { name: 'example', version: '1.0.0' } as unknown as Library const fake_package_json = { name: 'example', version: '1.0.0' } as unknown as PackageJson; const fake_result = { library_json: fake_library, package_json: fake_package_json }; +describe('SVELTE_DOCINFO_VERSION', () => { + test('resolves the installed svelte-docinfo version', () => { + expect(typeof SVELTE_DOCINFO_VERSION).toBe('string'); + expect(SVELTE_DOCINFO_VERSION.length).toBeGreaterThan(0); + }); +}); + describe('library_cache_key', () => { beforeEach(() => { vi.clearAllMocks(); @@ -111,7 +132,12 @@ describe('library_cache_read', () => { test('returns the cached result on a hash match', async () => { mocked_exists.mockResolvedValue(true); mocked_read.mockResolvedValue( - JSON.stringify({ hash: 'abc123', version: LIBRARY_CACHE_VERSION, ...fake_result }) + JSON.stringify({ + hash: 'abc123', + version: LIBRARY_CACHE_VERSION, + svelte_docinfo_version: SVELTE_DOCINFO_VERSION, + ...fake_result + }) ); const result = await library_cache_read('/repo/.gro/library.json', 'abc123'); @@ -121,7 +147,40 @@ describe('library_cache_read', () => { test('returns null when the cache version is stale', async () => { mocked_exists.mockResolvedValue(true); mocked_read.mockResolvedValue( - JSON.stringify({ hash: 'abc123', version: LIBRARY_CACHE_VERSION - 1, ...fake_result }) + JSON.stringify({ + hash: 'abc123', + version: LIBRARY_CACHE_VERSION - 1, + svelte_docinfo_version: SVELTE_DOCINFO_VERSION, + ...fake_result + }) + ); + + const result = await library_cache_read('/repo/.gro/library.json', 'abc123'); + expect(result).toBeNull(); + }); + + // The analyzer-side upgrade case: the analyzed repo's commit (the cache key) + // doesn't move when the *analyzer's* svelte-docinfo changes output shape, so + // the stamped version is what invalidates. + test('returns null when the cache was analyzed by a different svelte-docinfo version', async () => { + mocked_exists.mockResolvedValue(true); + mocked_read.mockResolvedValue( + JSON.stringify({ + hash: 'abc123', + version: LIBRARY_CACHE_VERSION, + svelte_docinfo_version: '0.0.0-other', + ...fake_result + }) + ); + + const result = await library_cache_read('/repo/.gro/library.json', 'abc123'); + expect(result).toBeNull(); + }); + + test('returns null when the cache has no svelte-docinfo version stamp', async () => { + mocked_exists.mockResolvedValue(true); + mocked_read.mockResolvedValue( + JSON.stringify({ hash: 'abc123', version: LIBRARY_CACHE_VERSION, ...fake_result }) ); const result = await library_cache_read('/repo/.gro/library.json', 'abc123'); @@ -188,6 +247,7 @@ describe('library_cache_write', () => { expect(JSON.parse(contents as string)).toEqual({ hash: 'abc123', version: LIBRARY_CACHE_VERSION, + svelte_docinfo_version: SVELTE_DOCINFO_VERSION, ...fake_result }); }); @@ -212,3 +272,85 @@ describe('library_cache_write', () => { expect(mocked_write).not.toHaveBeenCalled(); }); }); + +describe('library_load_from_repo', () => { + const valid_package_json_contents = JSON.stringify({ name: 'example', version: '1.0.0' }); + const fresh_cache_contents = JSON.stringify({ + hash: 'abc123', + version: LIBRARY_CACHE_VERSION, + svelte_docinfo_version: SVELTE_DOCINFO_VERSION, + ...fake_result + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocked_commit.mockResolvedValue('abc123'); + mocked_workspace.mockResolvedValue({} as any); + mocked_is_clean.mockReturnValue(true); + mocked_analyze.mockResolvedValue({ modules: [] } as any); + mocked_library_json_from_modules.mockReturnValue(fake_library); + mocked_mkdir.mockResolvedValue(undefined); + mocked_write.mockResolvedValue(undefined); + }); + + test('returns the cached result without analyzing on a cache hit', async () => { + mocked_exists.mockResolvedValue(true); + mocked_read.mockResolvedValue(fresh_cache_contents); + + const result = await library_load_from_repo('/repo'); + expect(result).toEqual(fake_result); + expect(mocked_analyze).not.toHaveBeenCalled(); + expect(mocked_write).not.toHaveBeenCalled(); + }); + + // The end-to-end behavior of the version stamps: a cache analyzed by a + // different svelte-docinfo re-analyzes and rewrites with the current stamp. + test('re-analyzes and rewrites when the svelte-docinfo stamp is stale', async () => { + mocked_exists.mockResolvedValue(true); + mocked_read.mockImplementation(async (path) => + (path as string).endsWith('library.json') + ? JSON.stringify({ + hash: 'abc123', + version: LIBRARY_CACHE_VERSION, + svelte_docinfo_version: '0.0.0-other', + ...fake_result + }) + : valid_package_json_contents + ); + + const result = await library_load_from_repo('/repo'); + expect(mocked_analyze).toHaveBeenCalledWith({ projectRoot: '/repo' }); + expect(result.library_json).toBe(fake_library); + const [path, contents] = mocked_write.mock.calls[0]!; + expect(path).toBe('/repo/.gro/library.json'); + expect(JSON.parse(contents as string).svelte_docinfo_version).toBe(SVELTE_DOCINFO_VERSION); + }); + + test('`cache: false` skips the cache read but still writes the result', async () => { + mocked_read.mockResolvedValue(valid_package_json_contents); + + await library_load_from_repo('/repo', { cache: false }); + expect(mocked_exists).not.toHaveBeenCalled(); + expect(mocked_analyze).toHaveBeenCalled(); + expect(mocked_write).toHaveBeenCalled(); + }); + + test('a dirty tree analyzes fresh and skips both cache read and write', async () => { + mocked_is_clean.mockReturnValue(false); + mocked_read.mockResolvedValue(valid_package_json_contents); + + await library_load_from_repo('/repo'); + expect(mocked_exists).not.toHaveBeenCalled(); + expect(mocked_analyze).toHaveBeenCalled(); + expect(mocked_write).not.toHaveBeenCalled(); + }); + + // An empty name passes the `PackageJson` schema (`z.string()`), so this + // exercises gro's own guard rather than zod validation. + test('throws on an empty package.json name', async () => { + mocked_is_clean.mockReturnValue(false); + mocked_read.mockResolvedValue(JSON.stringify({ name: '', version: '1.0.0' })); + + await expect(library_load_from_repo('/repo')).rejects.toThrow(/name/); + }); +}); diff --git a/src/test/package_json.test.ts b/src/test/package_json.test.ts index 137934e4f5..db2d85b067 100644 --- a/src/test/package_json.test.ts +++ b/src/test/package_json.test.ts @@ -55,41 +55,98 @@ test('package_json_serialize fails with bad data', () => { expect(err).toBeTruthy(); }); -test('package_json_to_exports', () => { - expect(package_json_to_exports(['a/b.ts'])).toEqual({ - './package.json': './package.json', - './*.js': { - default: './dist/*.js', - types: './dist/*.d.ts' - }, - './*.ts': { - default: './dist/*.js', - types: './dist/*.d.ts' - } +describe('package_json_to_exports', () => { + test('emits wildcard exports for js paths', () => { + expect(package_json_to_exports(['a/b.ts'])).toEqual({ + './package.json': './package.json', + './*.js': { + default: './dist/*.js', + types: './dist/*.d.ts' + }, + './*.ts': { + default: './dist/*.js', + types: './dist/*.d.ts' + } + }); }); - expect(package_json_to_exports(['*.svelte', '*.ts', '*.json', 'index.ts'])).toEqual({ - '.': { - default: './dist/index.js', - types: './dist/index.d.ts' - }, - './package.json': './package.json', - './*.json': { - default: './dist/*.json', - types: './dist/*.json.d.ts' - }, - './*.svelte': { - svelte: './dist/*.svelte', - default: './dist/*.svelte', - types: './dist/*.svelte.d.ts' - }, - './*.js': { - default: './dist/*.js', - types: './dist/*.d.ts' - }, - './*.ts': { - default: './dist/*.js', - types: './dist/*.d.ts' - } + + // the internal/ convention: each internal directory (any depth) emits a + // null blocker, and internal files don't count toward the wildcard flags + // (the internal-only .svelte file adds no ./*.svelte wildcard) + test('blocks internal dirs and excludes their files from the wildcard flags', () => { + expect( + package_json_to_exports([ + 'a/b.ts', + 'internal/c.ts', + 'internal/D.svelte', + 'domain/internal/e.ts', + 'internal', // a file named internal is public + 'x/internal', // nested or not + 'internal.ts' // dotted names don't match the dir convention + ]) + ).toEqual({ + './package.json': './package.json', + './domain/internal/*': null, + './internal/*': null, + './*.js': { + default: './dist/*.js', + types: './dist/*.d.ts' + }, + './*.ts': { + default: './dist/*.js', + types: './dist/*.d.ts' + } + }); + }); + + test('collapses nested internal dirs to the outermost blocker, which covers them', () => { + expect(package_json_to_exports(['a/b.ts', 'internal/x/internal/y.ts'])).toEqual({ + './package.json': './package.json', + './internal/*': null, + './*.js': { + default: './dist/*.js', + types: './dist/*.d.ts' + }, + './*.ts': { + default: './dist/*.js', + types: './dist/*.d.ts' + } + }); + }); + + test('emits blockers but no wildcards for an internal-only library', () => { + expect(package_json_to_exports(['internal/a.ts', 'domain/internal/B.svelte'])).toEqual({ + './package.json': './package.json', + './domain/internal/*': null, + './internal/*': null + }); + }); + + test('emits the index export and per-type wildcards', () => { + expect(package_json_to_exports(['*.svelte', '*.ts', '*.json', 'index.ts'])).toEqual({ + '.': { + default: './dist/index.js', + types: './dist/index.d.ts' + }, + './package.json': './package.json', + './*.json': { + default: './dist/*.json', + types: './dist/*.json.d.ts' + }, + './*.svelte': { + svelte: './dist/*.svelte', + default: './dist/*.svelte', + types: './dist/*.svelte.d.ts' + }, + './*.js': { + default: './dist/*.js', + types: './dist/*.d.ts' + }, + './*.ts': { + default: './dist/*.js', + types: './dist/*.d.ts' + } + }); }); });