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
5 changes: 5 additions & 0 deletions .changeset/humble-paths-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fuzdev/gro': minor
---

feat: rewrite `.ts` to `.js` in dist files
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ Builtin plugins:
- `gro_plugin_sveltekit_app` - runs `vite dev` or `vite build` for SvelteKit
frontends ([docs](src/docs/gro_plugin_sveltekit_app.md))
- `gro_plugin_sveltekit_library` - runs `svelte-package` to publish from
`src/lib/` ([docs](src/docs/gro_plugin_sveltekit_library.md))
`src/lib/`, then rewrites relative `.ts`→`.js` import specifiers in the
emitted `.d.ts`/`.svelte` ([docs](src/docs/gro_plugin_sveltekit_library.md))
- `gro_plugin_server` - runs Node servers with auto-restart on changes

### Configuration
Expand Down
9 changes: 8 additions & 1 deletion src/docs/gro_plugin_sveltekit_library.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ npm i -D @sveltejs/package
## behavior

In production (`gro build`), runs `svelte-package` during `setup`
to compile `src/lib/` into `dist/`.
to compile `src/lib/` into `dist/`, then rewrites relative `.ts` import
specifiers to `.js` (and `.svelte.ts` to `.svelte.js`) in the emitted `.d.ts`
declarations and `.svelte` `<script>` blocks
(the two outputs `svelte-package` leaves on `.ts`)
so the published `dist` resolves for consumers without
`allowImportingTsExtensions`. Bare `@fuzdev/*.ts` specifiers (resolved by the
package `exports` `.js`/`.ts` mirror), `.svelte` component imports, and
already-`.js` specifiers are left untouched.

In development (`gro dev`), does nothing — `svelte-package` is a build-time tool.

Expand Down
118 changes: 118 additions & 0 deletions src/lib/dist_rewrite_imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {fs_search} from '@fuzdev/fuz_util/fs.js';
import {map_concurrent} from '@fuzdev/fuz_util/async.js';
import type {Logger} from '@fuzdev/fuz_util/log.js';
import {readFile, writeFile} from 'node:fs/promises';

import {SVELTE_SCRIPT_MATCHER, SVELTEKIT_DIST_DIRNAME} from './constants.ts';

/*

Post-`svelte-package` pass that rewrites relative `.ts` import specifiers to `.js`
in the published `dist` output.

The ecosystem writes import specifiers with the real source extension
(`./foo.ts`, `./bar.svelte.ts`) and relies on emit-only rewrites to make `dist`
resolve for external consumers. `tsc`'s `rewriteRelativeImportExtensions` handles
the `.ts`/`.svelte.ts` → `dist/*.js` emit, but two outputs are left untouched:

- `.svelte` files, which `svelte-package` ships verbatim (source `<script>` and
its relative specifiers intact), and
- `.d.ts` / `*.svelte.d.ts` declaration files, whose specifiers `svelte-package`'s
declaration emit leaves as `.ts`.

This pass closes both gaps so a flagless external consumer (no
`allowImportingTsExtensions`) resolves the package's types and runtime.

Only **relative** specifiers (`./`, `../`) are rewritten. Bare `@fuzdev/…ts`
specifiers are left alone — the package `exports` `.js`/`.ts` mirror resolves them
in both source and dist. `.svelte` component imports and specifiers already ending
in `.js` are likewise untouched.

This is intentionally parse-light; the long-term home for the rewrite is tsv.

*/

/**
* Matches a relative import/export specifier ending in `.ts` (including the
* `.svelte.ts` double extension), capturing the introducer, the opening quote,
* and the path body so the trailing `.ts` can be swapped for `.js`.
*
* Anchored on a module-specifier introducer (`from`, `import`, `require`) so it
* doesn't touch incidental relative-looking string literals, on a `./`/`../`
* prefix so bare specifiers like `@fuzdev/fuz_util/foo.ts` are left untouched, and
* on a `(?<!\.)` lookbehind so member calls like `arr.from('./x.ts')` are skipped.
*/
const RELATIVE_TS_IMPORT_MATCHER =
/(?<!\.)((?:\bfrom|\bimport|\brequire)\b\s*\(?\s*)(['"])(\.\.?\/[^'"\n]*?)\.ts\2/g;

/**
* Rewrites relative `.ts` (and `.svelte.ts`) import specifiers to `.js` (and
* `.svelte.js`) across a chunk of TypeScript source — a whole `.d.ts` file or a
* `.svelte` `<script>` block.
*/
export const rewrite_relative_ts_imports = (content: string): string =>
content.replace(
RELATIVE_TS_IMPORT_MATCHER,
(_match, intro: string, quote: string, body: string) => `${intro}${quote}${body}.js${quote}`,
);

/**
* Rewrites relative `.ts` import specifiers to `.js` inside each `<script>` block
* of a `.svelte` file, leaving template markup untouched.
*/
export const rewrite_svelte_ts_imports = (content: string): string =>
content.replace(SVELTE_SCRIPT_MATCHER, (full: string, inner: string) => {
const rewritten = rewrite_relative_ts_imports(inner);
if (rewritten === inner) return full;
// replace via a function so `$`-sequences in the script (`$$props`, `$:`,
// `$&`, …) aren't interpreted as `String.prototype.replace` substitution patterns
return full.replace(inner, () => rewritten);
});

export interface RewriteDistImportsResult {
/** Number of files scanned (`.d.ts` and `.svelte`). */
scanned: number;
/** Number of scanned files whose contents changed. */
rewritten: number;
}

/** Bounds open file descriptors while rewriting the dist tree. */
const DIST_REWRITE_CONCURRENCY = 16;

/**
* Walks `dist_dir` and rewrites relative `.ts` import specifiers to `.js` in every
* `.d.ts` declaration file (whole-file) and `.svelte` file (`<script>` blocks
* only). `.js` files are skipped — `tsc`'s `rewriteRelativeImportExtensions`
* already rewrites those — as are source maps.
*
* @param dist_dir - the packaged output directory; defaults to `dist`
* @returns counts of files scanned and rewritten
*/
export const rewrite_dist_imports = async (
dist_dir: string = SVELTEKIT_DIST_DIRNAME,
log?: Logger,
): Promise<RewriteDistImportsResult> => {
const found = await fs_search(dist_dir, {
file_filter: (id) => id.endsWith('.d.ts') || id.endsWith('.svelte'),
});

const changed = await map_concurrent(found, DIST_REWRITE_CONCURRENCY, async ({id, path}) => {
const content = await readFile(id, 'utf8');
const next = id.endsWith('.svelte')
? rewrite_svelte_ts_imports(content)
: rewrite_relative_ts_imports(content);
if (next === content) return false;
await writeFile(id, next);
log?.debug(`rewrote relative .ts import specifiers to .js in ${path}`);
return true;
});
const rewritten = changed.filter(Boolean).length;

if (found.length) {
log?.info(
`rewrote relative .ts→.js import specifiers in ${rewritten}/${found.length} dist files`,
);
}

return {scanned: found.length, rewritten};
};
9 changes: 8 additions & 1 deletion src/lib/gro_plugin_sveltekit_library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type {Plugin} from './plugin.ts';
import {TaskError} from './task.ts';
import {package_json_load} from './package_json.ts';
import {run_svelte_package, type SveltePackageOptions} from './sveltekit_helpers.ts';
import {SVELTE_PACKAGE_CLI} from './constants.ts';
import {SVELTE_PACKAGE_CLI, SVELTEKIT_DIST_DIRNAME} from './constants.ts';
import {rewrite_dist_imports} from './dist_rewrite_imports.ts';

export interface GroPluginSveltekitLibraryOptions {
/**
Expand Down Expand Up @@ -35,6 +36,12 @@ export const gro_plugin_sveltekit_library = ({
log,
config.pm_cli,
);
// `svelte-package` ships `.svelte` source verbatim and leaves relative `.ts`
// specifiers in `.d.ts` declarations; rewrite them to `.js` so the published
// `dist` resolves for external consumers without `allowImportingTsExtensions`.
const output_dir =
svelte_package_options?.output ?? svelte_package_options?.o ?? SVELTEKIT_DIST_DIRNAME;
await rewrite_dist_imports(output_dir, log);
}
},
adapt: async ({log, timings, config}) => {
Expand Down
Loading