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
14 changes: 14 additions & 0 deletions .changeset/internal-exports-blocker.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions .changeset/library-cache-version.md
Original file line number Diff line number Diff line change
@@ -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`.
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions src/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions src/docs/gro_plugin_sveltekit_library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
50 changes: 39 additions & 11 deletions src/lib/library_load.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`
Expand All @@ -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 {
Expand Down Expand Up @@ -79,10 +96,12 @@ export const library_cache_key = async (repo_dir: string): Promise<string | null
/**
* Reads and validates the `.gro/library.json` cache at `cache_path`.
*
* Returns the cached `{library_json, package_json}` only when the file exists
* and its stored `hash` matches `key`. Returns `null` on every miss - absent,
* stale (different `hash`), or unreadable/corrupt - signalling the caller to
* re-analyze.
* Returns the cached `{library_json, package_json}` only when the file exists,
* its stored `hash` matches `key`, and its `version` and
* `svelte_docinfo_version` stamps match the current `LIBRARY_CACHE_VERSION`
* and `SVELTE_DOCINFO_VERSION`. Returns `null` on every miss - absent, stale
* (different `hash` or stamps), or unreadable/corrupt - signalling the caller
* to re-analyze.
*
* @param cache_path - absolute path to the cache file
* @param key - the expected cache key (a clean git commit hash)
Expand All @@ -97,7 +116,11 @@ export const library_cache_read = async (
try {
const contents = await readFile(cache_path, 'utf-8');
const parsed: LibraryCache = JSON.parse(contents);
if (parsed.hash === key && parsed.version === LIBRARY_CACHE_VERSION) {
if (
parsed.hash === key &&
parsed.version === LIBRARY_CACHE_VERSION &&
parsed.svelte_docinfo_version === SVELTE_DOCINFO_VERSION
) {
log?.debug('library cache hit', st('dim', `(${cache_path} @ ${key})`));
return { library_json: parsed.library_json, package_json: parsed.package_json };
}
Expand All @@ -111,8 +134,8 @@ export const library_cache_read = async (

/**
* Writes `result` to the `.gro/library.json` cache at `cache_path`, keyed by
* `key` and stamped with the current `LIBRARY_CACHE_VERSION`, creating the
* parent directory as needed.
* `key` and stamped with the current `LIBRARY_CACHE_VERSION` and
* `SVELTE_DOCINFO_VERSION`, creating the parent directory as needed.
*
* Best effort: caching is optional, so write failures are logged as a warning
* and swallowed rather than thrown.
Expand All @@ -129,7 +152,12 @@ export const library_cache_write = async (
): Promise<void> => {
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) {
Expand Down
48 changes: 43 additions & 5 deletions src/lib/package_json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): 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<string> = new Set();
const public_paths: Array<string> = [];
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',
Expand Down
Loading