importmapify 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,8 @@ npx importmapify --root . --out import_map.json
39
39
  | `--scope prefix::key=value` | `-s` | Scoped import override; repeatable | none |
40
40
  | `--condition name` | `-c` | Condition tried when a target is a conditional object; repeatable | `import`, `default` |
41
41
  | `--ext name` | `-e` | Restrict pattern matches to these file extensions; repeatable | all files |
42
+ | `--filter regex` | `-f` | Regular expression a pattern target path must match; repeatable | all files |
43
+ | `--indent value` | | Indentation as a number of spaces or the word `tab` | tab |
42
44
  | `--config path` | `-C` | Config file path; skips discovery | auto-discovered |
43
45
  | `--no-config` | | Skip config file discovery | off |
44
46
  | `--check` | | Exit 1 if the output file is stale, without writing it | off |
@@ -84,9 +86,9 @@ Then `npx importmapify` reads it automatically. Point at a specific file with `-
84
86
  `--no-config`.
85
87
 
86
88
  Explicitly-passed flags override the config, which overrides built-in defaults: `--import`/`--package`/`--scope` merge
87
- onto the config's records with flag keys winning; `--condition`/`--ext` replace; `--root`/`--manifest`/`--out` win when
88
- passed. Configs are imported natively; a TypeScript config needs a type-stripping runtime (Bun, Deno, or Node \>= 22.6);
89
- on older Node use a `.mjs`/`.cjs`/`.js` config.
89
+ onto the config's records with flag keys winning; `--condition`/`--ext`/`--filter` replace;
90
+ `--root`/`--manifest`/`--out` win when passed. Configs are imported natively; a TypeScript config needs a type-stripping
91
+ runtime (Bun, Deno, or Node \>= 22.6); on older Node use a `.mjs`/`.cjs`/`.js` config.
90
92
 
91
93
  ## Library
92
94
 
@@ -109,7 +111,7 @@ const out = writeImportMap({
109
111
 
110
112
  | Export | Signature | Purpose |
111
113
  | ----------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
112
- | `defineConfig` | `(config: Config) => Config` | Identity helper that types a config object for export and reuse. |
114
+ | `defineConfig` | `<T extends Config>(config: T) => T` | Identity helper that types a config object for export and reuse. |
113
115
  | `createImportMap` | `(options: CreateImportMapOptions) => ImportMapDocument` | Build the import map in memory. |
114
116
  | `formatImportMap` | `(map: ImportMapDocument) => string` | Serialize to the canonical sorted, tab-indented JSON text. |
115
117
  | `writeImportMap` | `(options: WriteImportMapOptions) => string` | Build, serialize, and write to disk; returns the written path. |
@@ -126,18 +128,27 @@ const out = writeImportMap({
126
128
  | `additionalImports` | Extra entries merged in after manifest expansion and packages; these win on key collision. | none |
127
129
  | `scopes` | Scope prefixes mapped to scope-specific import overrides. | none |
128
130
  | `relativeTo` | Directory the written targets are rebased against. | `root` |
129
- | `extensions` | File extensions, with or without a leading dot, that pattern targets may match. | all files |
131
+ | `extensions` | Extension whitelist limiting which pattern targets are kept. | all files |
132
+ | `filter` | `RegExp`/predicate matchers a pattern target path must pass; combines with `extensions`. | all files |
130
133
 
131
- `WriteImportMapOptions` extends the above with an optional `out`, defaulting to `import_map.json` like the CLI. It is
132
- resolved against `root` and accepts a relative path, an absolute path, or a `file://` URL. `writeImportMap` rebases
133
- automatically against `out`'s directory, so a nested `out` (for example `.cache/maps/import_map.json`) still produces
134
- targets that resolve correctly from the map's own location.
134
+ `WriteImportMapOptions` extends the above with an optional `out`, defaulting to `import_map.json` like the CLI, and an
135
+ optional `indent` with `JSON.stringify` space semantics (a number of spaces or a literal string), defaulting to a tab.
136
+ `out` is resolved against `root` and accepts a relative path, an absolute path, or a `file://` URL. `writeImportMap`
137
+ rebases automatically against `out`'s directory, so a nested `out` (for example `.cache/maps/import_map.json`) still
138
+ produces targets that resolve correctly from the map's own location.
135
139
 
136
- `Config` is an alias for `WriteImportMapOptions` the shape a config file's default export and `defineConfig` take.
140
+ `Config` extends `Partial<WriteImportMapOptions>` with an optional `hooks` field. It is the shape a config file's
141
+ default export and `defineConfig` take, with every field optional. An omitted `root` defaults to the config file's own
142
+ directory; the CLI supplies the remaining defaults.
137
143
 
138
144
  `defineConfig` returns its argument unchanged; it exists only to type a config object for export and reuse without a
139
145
  manual annotation.
140
146
 
147
+ `hooks` are lifecycle functions the CLI runs around generation, modeled on tsdown's hooks. `generate:before` runs before
148
+ the filesystem is scanned, so building generated targets there keeps them out of a stale map; `generate:done` runs after
149
+ the map is emitted. Each receives `{ root, out }` (`generate:done` also gets the finished `map`) and may be async. The
150
+ synchronous library functions ignore `hooks`.
151
+
141
152
  ### Recipes
142
153
 
143
154
  Add dependencies with `packages`. Each entry expands to the bare specifier plus its conformant trailing-slash entry
@@ -165,6 +176,17 @@ import { createImportMap } from 'importmapify';
165
176
  createImportMap({ root: import.meta.dirname, extensions: ['ts', 'tsx'] });
166
177
  ```
167
178
 
179
+ `filter` takes `RegExp` or predicate matchers tested against each candidate target path; a target is kept only when
180
+ every matcher accepts it. Combined with `extensions`, this drops hashed build chunks a `dist/*` pattern would otherwise
181
+ enumerate:
182
+
183
+ ```ts
184
+ import { createImportMap } from 'importmapify';
185
+
186
+ // Keep .js targets whose path is not an internal chunk like ./dist/internal-qo9O8jzH.js.
187
+ createImportMap({ root: import.meta.dirname, extensions: ['js'], filter: [/^(?!.*internal)/] });
188
+ ```
189
+
168
190
  Build one conformant pair directly with `packageEntries`:
169
191
 
170
192
  ```ts
@@ -184,6 +206,20 @@ const map = createImportMap(config);
184
206
  const written = writeImportMap(config);
185
207
  ```
186
208
 
209
+ Build before scanning with a `generate:before` hook, so a map that includes build output does not drop entries when it
210
+ runs before the build:
211
+
212
+ ```ts
213
+ import { execSync } from 'node:child_process';
214
+ import { defineConfig } from 'importmapify';
215
+
216
+ export default defineConfig({
217
+ hooks: {
218
+ 'generate:before': () => execSync('bun run build', { stdio: 'inherit' }),
219
+ },
220
+ });
221
+ ```
222
+
187
223
  `root`, `relativeTo`, and `out` accept a path or a `file://` URL, so `import.meta.url` needs no `.pathname`:
188
224
 
189
225
  ```ts
package/deno.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json",
3
3
  "name": "@kjanat/importmapify",
4
- "version": "1.3.1",
4
+ "version": "1.5.0",
5
5
  "exports": "./src/mod.ts",
6
6
  "publish": {
7
7
  "include": [
package/dist/mod.d.mts CHANGED
@@ -1,40 +1,327 @@
1
- //#region src/map.d.ts
2
- /** A deterministic Deno import map generated from package import entries. */
1
+ //#region src/types.d.ts
2
+ /**
3
+ * A deterministic Deno import map generated from package import entries, following the
4
+ * {@link https://html.spec.whatwg.org/multipage/webappapis.html#import-maps | Import Maps Standard}.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import type { ImportMapDocument } from 'jsr:@kjanat/importmapify';
9
+ *
10
+ * const doc: ImportMapDocument = {
11
+ * imports: { '#lib/bytes': './src/lib/bytes.ts' },
12
+ * scopes: { './tests/': { '#lib/bytes': './tests/stub/bytes.ts' } },
13
+ * };
14
+ * ```
15
+ */
3
16
  interface ImportMapDocument {
4
- /** Exact specifier-to-target mappings, sorted by specifier. */
17
+ /**
18
+ * Exact specifier-to-target mappings, sorted by specifier, as in Deno's
19
+ * {@link https://docs.deno.com/runtime/reference/deno_json/#custom-path-mappings | custom path mappings}.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * const imports = { '#lib/bytes': './src/lib/bytes.ts' };
24
+ * ```
25
+ */
5
26
  readonly imports: Readonly<Record<string, string>>;
6
- /** Scope prefixes mapped to sorted, scope-specific import overrides. */
27
+ /**
28
+ * Scope prefixes mapped to sorted, scope-specific import overrides, as in Deno's
29
+ * {@link https://docs.deno.com/runtime/reference/deno_json/#scoped-mappings | scoped mappings}.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const scopes = { './tests/': { '#lib/bytes': './tests/stub/bytes.ts' } };
34
+ * ```
35
+ */
7
36
  readonly scopes?: Readonly<Record<string, Readonly<Record<string, string>>>>;
8
37
  }
9
- /** Options for creating an import map without writing it to disk. */
38
+ /**
39
+ * A filesystem location as a path string or `file://` URL, accepted wherever an option names a
40
+ * directory or file.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * import type { PathOrUrl } from 'jsr:@kjanat/importmapify';
45
+ *
46
+ * const root: PathOrUrl = new URL('..', import.meta.url);
47
+ * ```
48
+ */
49
+ type PathOrUrl = string | URL;
50
+ /**
51
+ * A matcher for {@link CreateImportMapOptions.filter | filter}, tested against a candidate target
52
+ * path such as `./dist/internal-qo9O8jzH.js`. A target is kept only when every matcher accepts it.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import type { TargetFilter } from 'jsr:@kjanat/importmapify';
57
+ *
58
+ * // Drop hashed internal chunks like ./dist/internal-qo9O8jzH.js.
59
+ * const filter: readonly TargetFilter[] = [/^(?!.*internal)/];
60
+ * ```
61
+ */
62
+ type TargetFilter = RegExp | ((target: string) => boolean);
63
+ /**
64
+ * Options for creating an import map without writing it to disk.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * import { createImportMap, type CreateImportMapOptions } from 'jsr:@kjanat/importmapify';
69
+ *
70
+ * const options: CreateImportMapOptions = {
71
+ * root: import.meta.dirname,
72
+ * conditions: ['deno', 'import', 'default'],
73
+ * extensions: ['ts', 'tsx'],
74
+ * };
75
+ *
76
+ * createImportMap(options);
77
+ * ```
78
+ */
10
79
  interface CreateImportMapOptions {
11
- /** Project directory containing the package manifest, as a path or `file://` URL. */
12
- readonly root: string | URL;
13
- /** Manifest path relative to {@link root}. Defaults to `package.json`. */
80
+ /**
81
+ * Project directory containing the package {@linkcode manifest}, as a path or `file://` URL.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const root = import.meta.dirname;
86
+ * ```
87
+ */
88
+ readonly root: PathOrUrl;
89
+ /**
90
+ * Manifest path relative to {@link root}. Defaults to `package.json`.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * const manifest = 'deno.json';
95
+ * ```
96
+ * @defaultValue `'package.json'`
97
+ */
14
98
  readonly manifest?: string;
15
- /** Conditional import keys to try in order. Defaults to `import`, then `default`. */
99
+ /**
100
+ * Conditional import keys to try in order. Defaults to `import`, then `default`.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * const conditions = ['deno', 'import', 'default'];
105
+ * ```
106
+ * @defaultValue `['import', 'default']`
107
+ */
16
108
  readonly conditions?: readonly string[];
17
- /** Package specifiers mapped to targets, each expanded to a conformant bare and trailing-slash pair. */
109
+ /**
110
+ * Package specifiers mapped to targets, each expanded to a conformant bare and trailing-slash pair.
111
+ * Defaults to none.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const packages = { dreamcli: 'jsr:@kjanat/dreamcli@^3' };
116
+ * ```
117
+ * @defaultValue `{}`
118
+ */
18
119
  readonly packages?: Readonly<Record<string, string>>;
19
- /** Explicit entries merged after manifest imports and packages, overriding duplicate keys. */
120
+ /**
121
+ * Explicit entries merged after manifest imports and packages, overriding duplicate keys. Defaults to none.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const additionalImports = { '#config': './src/config.ts' };
126
+ * ```
127
+ * @defaultValue `{}`
128
+ */
20
129
  readonly additionalImports?: Readonly<Record<string, string>>;
21
- /** Scope-specific import overrides keyed by scope prefix. */
130
+ /**
131
+ * Scope-specific import overrides keyed by scope prefix, following Deno's
132
+ * {@link https://docs.deno.com/runtime/reference/deno_json/#scoped-mappings | scoped mappings}. Defaults to none.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * const scopes = { './tests/': { '#lib/bytes': './tests/stub/bytes.ts' } };
137
+ * ```
138
+ * @defaultValue `{}`
139
+ */
22
140
  readonly scopes?: Readonly<Record<string, Readonly<Record<string, string>>>>;
23
- /** Directory against which relative targets are rebased, as a path or `file://` URL. Defaults to {@link root}. */
24
- readonly relativeTo?: string | URL;
25
- /** File extensions, with or without a leading dot, that pattern targets may match. Unset matches every file. */
141
+ /**
142
+ * Directory the generated import map will be read from, as a path or `file://` URL.
143
+ *
144
+ * Manifest targets are written relative to {@link root}, but Deno resolves an import map's relative targets
145
+ * from the location of the map file. Each relative target is rewritten to be relative to this directory
146
+ * instead, so it still points at the right file once the map moves. `writeImportMap` and the CLI set
147
+ * this automatically to the output file's directory; set it yourself only when placing an in-memory map
148
+ * somewhere other than {@link root}. Defaults to {@link root}.
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * // Map root is /proj, target is ./src/bytes.ts, map will live in /proj/.cache.
153
+ * const relativeTo = '/proj/.cache';
154
+ * // The emitted target becomes ../src/bytes.ts, which resolves back to /proj/src/bytes.ts.
155
+ * ```
156
+ * @defaultValue {@link root}
157
+ */
158
+ readonly relativeTo?: PathOrUrl;
159
+ /**
160
+ * Extension whitelist limiting which pattern targets are kept, with or without leading dots.
161
+ * Combines with {@link filter}. Unset keeps every extension.
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * const extensions = ['ts', 'tsx'];
166
+ * ```
167
+ * @defaultValue `[]`
168
+ */
26
169
  readonly extensions?: readonly string[];
170
+ /**
171
+ * Matchers a candidate pattern target must pass, each tested against the target path. A target is kept
172
+ * only when every matcher accepts it. Combines with {@link extensions}. Unset keeps every target.
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * // Keep .js targets that are not hashed internal chunks.
177
+ * const options = { extensions: ['js'], filter: [/^(?!.*internal)/] };
178
+ * ```
179
+ * @defaultValue `[]`
180
+ */
181
+ readonly filter?: readonly TargetFilter[];
27
182
  }
28
- /** Options for creating and writing an import map. */
183
+ /**
184
+ * Options for creating and writing an import map.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * import { writeImportMap, type WriteImportMapOptions } from 'jsr:@kjanat/importmapify';
189
+ *
190
+ * const options: WriteImportMapOptions = {
191
+ * root: import.meta.dirname,
192
+ * out: '.cache/maps/import_map.json',
193
+ * };
194
+ *
195
+ * writeImportMap(options);
196
+ * ```
197
+ */
29
198
  interface WriteImportMapOptions extends CreateImportMapOptions {
30
199
  /**
31
200
  * Output path, resolved against {@link CreateImportMapOptions.root | root}. Accepts a relative path, an
32
201
  * absolute path, a `file://` URL string, or a {@link URL}. Defaults to `import_map.json`.
202
+ *
203
+ * Deno loads the written file through the `importMap` field in `deno.json` or the `--import-map` flag; see
204
+ * {@link https://docs.deno.com/runtime/fundamentals/modules/#differentiating-between-imports-or-importmap-in-deno.json-and---import-map-option | Deno: imports vs importMap}.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * const out = '.cache/maps/import_map.json';
209
+ * ```
210
+ * @defaultValue `'import_map.json'`
211
+ */
212
+ readonly out?: PathOrUrl;
213
+ /**
214
+ * Indentation for the serialized map, with the semantics of `JSON.stringify`'s
215
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#space | space parameter}:
216
+ * a number of spaces per level or a literal indent string. Defaults to a tab.
217
+ *
218
+ * @example
219
+ * ```ts
220
+ * const indent = 2;
221
+ * ```
222
+ * @defaultValue `'\t'`
223
+ */
224
+ readonly indent?: string | number;
225
+ }
226
+ /**
227
+ * Resolved paths shared by every generation hook.
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * import type { HookContext } from 'jsr:@kjanat/importmapify';
232
+ *
233
+ * const logRoot = (ctx: HookContext) => console.log(`scanning ${ctx.root}, writing ${ctx.out}`);
234
+ * ```
235
+ */
236
+ interface HookContext {
237
+ /**
238
+ * Absolute project root that gets scanned.
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * const root = '/home/me/project';
243
+ * ```
244
+ */
245
+ readonly root: string;
246
+ /**
247
+ * Absolute output path the map resolves against.
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * const out = '/home/me/project/import_map.json';
252
+ * ```
253
+ */
254
+ readonly out: string;
255
+ }
256
+ /**
257
+ * Lifecycle hooks the CLI runs around import map generation, modeled on tsdown's hooks.
258
+ *
259
+ * Each hook may be async; the CLI awaits it. The synchronous library functions ignore hooks.
260
+ *
261
+ * @example
262
+ * ```ts
263
+ * // Build generated targets before the scan so they land in the map.
264
+ * import { execSync } from 'node:child_process';
265
+ * import { defineConfig } from 'jsr:@kjanat/importmapify';
266
+ *
267
+ * export default defineConfig({
268
+ * hooks: {
269
+ * 'generate:before': () => execSync('deno task build', { stdio: 'inherit' }),
270
+ * },
271
+ * });
272
+ * ```
273
+ */
274
+ interface ImportMapHooks {
275
+ /**
276
+ * Runs before the filesystem is scanned. Build pattern targets here so they exist when patterns expand.
277
+ *
278
+ * @example
279
+ * ```ts
280
+ * const onBefore = (ctx) => console.log('building targets under', ctx.root);
281
+ * ```
282
+ */
283
+ readonly "generate:before": (context: HookContext) => void | Promise<void>;
284
+ /**
285
+ * Runs after the map is generated and emitted.
286
+ *
287
+ * @example
288
+ * ```ts
289
+ * const onDone = (ctx) => console.log('mapped', Object.keys(ctx.map.imports).length, 'imports');
290
+ * ```
291
+ */
292
+ readonly "generate:done": (context: HookContext & {
293
+ readonly map: ImportMapDocument;
294
+ }) => void | Promise<void>;
295
+ }
296
+ /**
297
+ * An importmapify config file: the shape a config file's default export and `defineConfig` take.
298
+ * Every field is optional; the config loader and CLI supply {@linkcode CreateImportMapOptions.root | root}
299
+ * and the remaining defaults.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * // importmapify.config.ts; root is omitted, so it defaults to this file's directory.
304
+ * import { defineConfig } from 'jsr:@kjanat/importmapify';
305
+ *
306
+ * export default defineConfig({
307
+ * packages: { dreamcli: 'jsr:@kjanat/dreamcli@^3' },
308
+ * extensions: ['ts'],
309
+ * });
310
+ * ```
311
+ */
312
+ interface Config extends Partial<WriteImportMapOptions> {
313
+ /**
314
+ * Lifecycle hooks the CLI runs around generation. Ignored by `writeImportMap` and `createImportMap`.
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * const hooks = { 'generate:before': (ctx) => console.log(ctx.root) };
319
+ * ```
33
320
  */
34
- readonly out?: string | URL;
321
+ readonly hooks?: Partial<ImportMapHooks>;
35
322
  }
36
- /** An importmapify config: the shape a config file's default export and {@linkcode defineConfig} take. */
37
- type Config = WriteImportMapOptions;
323
+ //#endregion
324
+ //#region src/map.d.ts
38
325
  /**
39
326
  * Expand a package's exact and patterned `imports` into a Deno import map.
40
327
  *
@@ -59,24 +346,23 @@ type Config = WriteImportMapOptions;
59
346
  */
60
347
  declare function createImportMap(options: CreateImportMapOptions): ImportMapDocument;
61
348
  /**
62
- * Serialize an import map as stable, tab-indented JSON with a trailing newline.
349
+ * Serialize an import map as stable JSON with a trailing newline.
63
350
  *
64
351
  * @example
65
352
  * ```ts
66
- * // Format an import map for stdout.
353
+ * // Format an import map for stdout, indented with two spaces.
67
354
  * import { formatImportMap } from 'jsr:@kjanat/importmapify';
68
355
  *
69
- * const text = formatImportMap({
70
- * imports: { '#config': './src/config.ts' },
71
- * });
356
+ * const text = formatImportMap({ imports: { '#config': './src/config.ts' } }, 2);
72
357
  *
73
358
  * console.log(text);
74
359
  * ```
75
360
  *
76
361
  * @param map Import map to serialize.
362
+ * @param indent Indentation with `JSON.stringify` space semantics; defaults to a tab.
77
363
  * @returns Formatted JSON ready to print or write.
78
364
  */
79
- declare function formatImportMap(map: ImportMapDocument): string;
365
+ declare function formatImportMap(map: ImportMapDocument, indent?: string | number): string;
80
366
  /**
81
367
  * Create an import map and write it to disk, creating parent directories as needed.
82
368
  *
@@ -131,9 +417,23 @@ declare function packageEntries(name: string, target: string): Record<string, st
131
417
  * writeImportMap(config);
132
418
  * ```
133
419
  *
134
- * @param config Import map configuration, with an optional `out`.
135
- * @returns The same `config` value, typed as {@link Config}.
420
+ * @example
421
+ * ```ts
422
+ * // Config file with a build hook: generate:before runs before the CLI scans.
423
+ * import { execSync } from 'node:child_process';
424
+ * import { defineConfig } from 'jsr:@kjanat/importmapify';
425
+ *
426
+ * export default defineConfig({
427
+ * hooks: {
428
+ * 'generate:before': () => execSync('deno task build', { stdio: 'inherit' }),
429
+ * },
430
+ * });
431
+ * ```
432
+ *
433
+ * @param config Import map configuration; every field is optional.
434
+ * @returns The same {@linkcode config} value with its exact type preserved, so a config that includes {@linkcode CreateImportMapOptions.root} stays
435
+ * assignable to {@link writeImportMap} while one that omits it is still a valid config file.
136
436
  */
137
- declare function defineConfig(config: Config): Config;
437
+ declare function defineConfig<T extends Config>(config: T): T;
138
438
  //#endregion
139
- export { type Config, type CreateImportMapOptions, type ImportMapDocument, type WriteImportMapOptions, createImportMap, defineConfig, formatImportMap, packageEntries, writeImportMap };
439
+ export { type Config, type CreateImportMapOptions, type HookContext, type ImportMapDocument, type ImportMapHooks, type PathOrUrl, type TargetFilter, type WriteImportMapOptions, createImportMap, defineConfig, formatImportMap, packageEntries, writeImportMap };
package/dist/mod.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { bold, cyan, detectHyperlinkSupport } from "ansispeck";
3
3
  import { CLIError, cli, command, flag } from "dreamcli";
4
- import fs, { existsSync, readFileSync } from "node:fs";
5
- import path, { dirname } from "node:path";
4
+ import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import path, { dirname, resolve } from "node:path";
6
6
  import { argv, cwd } from "node:process";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  //#region src/expand.ts
@@ -169,9 +169,18 @@ function asString(value) {
169
169
  function asPath(value) {
170
170
  return typeof value === "string" || value instanceof URL ? value : void 0;
171
171
  }
172
+ function asIndent(value) {
173
+ return typeof value === "string" || typeof value === "number" ? value : void 0;
174
+ }
172
175
  function asStringArray(value) {
173
176
  return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
174
177
  }
178
+ function isTargetFilter(value) {
179
+ return value instanceof RegExp || typeof value === "function";
180
+ }
181
+ function asTargetFilters(value) {
182
+ return Array.isArray(value) && value.every(isTargetFilter) ? value : void 0;
183
+ }
175
184
  function asStringRecord(value) {
176
185
  if (!isRecord(value)) return;
177
186
  const record = {};
@@ -191,36 +200,56 @@ function asScopes(value) {
191
200
  }
192
201
  return scopes;
193
202
  }
203
+ function isHook(value) {
204
+ return typeof value === "function";
205
+ }
206
+ function asHooks(value) {
207
+ if (!isRecord(value)) return;
208
+ const hooks = {};
209
+ const before = value["generate:before"];
210
+ if (isHook(before)) hooks["generate:before"] = before;
211
+ const done = value["generate:done"];
212
+ if (isHook(done)) hooks["generate:done"] = done;
213
+ return hooks["generate:before"] === void 0 && hooks["generate:done"] === void 0 ? void 0 : hooks;
214
+ }
194
215
  /**
195
216
  * Parse a raw config object into typed import map options.
196
217
  *
197
- * Only known fields are read; a relative string `root` is resolved against the config
198
- * file's own directory so a config can anchor itself with `root: '.'`.
218
+ * Only known fields are read. An omitted `root` defaults to the config file's own directory; a relative
219
+ * string `root` resolves against it, so a config can anchor itself with `root: '.'`.
199
220
  *
200
221
  * @param config Raw default-exported config object.
201
222
  * @param configDir Directory containing the config file.
202
- * @returns The subset of {@link WriteImportMapOptions} the config declares.
223
+ * @returns The subset of {@link WriteImportMapOptions} the config declares, plus any hooks.
203
224
  */
204
225
  function configToOptions(config, configDir) {
205
226
  const result = {};
206
227
  const root = asPath(config.root);
207
- if (root !== void 0) result.root = typeof root === "string" && !path.isAbsolute(root) && !root.startsWith("file://") ? path.resolve(configDir, root) : root;
228
+ if (root === void 0) result.root = configDir;
229
+ else if (typeof root === "string" && !path.isAbsolute(root) && !root.startsWith("file://")) result.root = path.resolve(configDir, root);
230
+ else result.root = root;
208
231
  const manifest = asString(config.manifest);
209
232
  if (manifest !== void 0) result.manifest = manifest;
210
233
  const out = asPath(config.out);
211
234
  if (out !== void 0) result.out = out;
235
+ const indent = asIndent(config.indent);
236
+ if (indent !== void 0) result.indent = indent;
212
237
  const relativeTo = asPath(config.relativeTo);
213
238
  if (relativeTo !== void 0) result.relativeTo = relativeTo;
214
239
  const conditions = asStringArray(config.conditions);
215
240
  if (conditions !== void 0) result.conditions = conditions;
216
241
  const extensions = asStringArray(config.extensions);
217
242
  if (extensions !== void 0) result.extensions = extensions;
243
+ const filter = asTargetFilters(config.filter);
244
+ if (filter !== void 0) result.filter = filter;
218
245
  const packages = asStringRecord(config.packages);
219
246
  if (packages !== void 0) result.packages = packages;
220
247
  const additionalImports = asStringRecord(config.additionalImports);
221
248
  if (additionalImports !== void 0) result.additionalImports = additionalImports;
222
249
  const scopes = asScopes(config.scopes);
223
250
  if (scopes !== void 0) result.scopes = scopes;
251
+ const hooks = asHooks(config.hooks);
252
+ if (hooks !== void 0) result.hooks = hooks;
224
253
  return result;
225
254
  }
226
255
  /**
@@ -277,17 +306,28 @@ function readManifest(manifestPath) {
277
306
  if (!isRecord(parsed)) throw new Error(`Manifest at ${manifestPath} must be a JSON object`);
278
307
  return parsed;
279
308
  }
280
- function extensionFilter(extensions) {
309
+ function matcherKeeps(matcher, target) {
310
+ return matcher instanceof RegExp ? matcher.test(target) : matcher(target);
311
+ }
312
+ /**
313
+ * Build a predicate deciding whether a candidate target survives: its extension must be in the
314
+ * whitelist (if any extensions are given), and every {@link TargetFilter} must also accept it.
315
+ */
316
+ function targetFilter(extensions, filters) {
281
317
  const allowed = new Set(extensions.map((ext) => ext.startsWith(".") ? ext : `.${ext}`));
282
- return (file) => allowed.has(path.extname(file));
318
+ return (target) => (allowed.size === 0 || allowed.has(path.extname(target))) && filters.every((matcher) => matcherKeeps(matcher, target));
283
319
  }
284
- function expandManifestImport(root, key, rawValue, { conditions, extensions }) {
320
+ function expandManifestImport(root, key, rawValue, { conditions, extensions, filter }) {
285
321
  const value = typeof rawValue === "string" ? rawValue : resolveCondition(rawValue, conditions);
286
322
  if (value === void 0) return {};
287
323
  const pattern = parsePattern(key, value);
288
324
  if (pattern === void 0) return { [key]: value };
289
- const files = filesUnder(path.join(root, pattern.targetDirectory));
290
- return expandPattern(pattern, extensions.length > 0 ? files.filter(extensionFilter(extensions)) : files);
325
+ const expanded = expandPattern(pattern, filesUnder(path.join(root, pattern.targetDirectory)));
326
+ if (extensions.length === 0 && filter.length === 0) return expanded;
327
+ const keep = targetFilter(extensions, filter);
328
+ const filtered = {};
329
+ for (const [specifier, target] of Object.entries(expanded)) if (keep(target)) filtered[specifier] = target;
330
+ return filtered;
291
331
  }
292
332
  function keyBaseLength(key) {
293
333
  const star = key.indexOf("*");
@@ -367,7 +407,8 @@ function createImportMap(options) {
367
407
  const relativeTo = options.relativeTo === void 0 ? root : toPath(options.relativeTo);
368
408
  const imports = expandManifest(root, relativeTo, manifestImports, {
369
409
  conditions,
370
- extensions: options.extensions ?? []
410
+ extensions: options.extensions ?? [],
411
+ filter: options.filter ?? []
371
412
  });
372
413
  for (const [key, value] of Object.entries(collectAdditional(options))) imports[key] = rebaseTarget(root, relativeTo, value);
373
414
  const scopes = buildScopes$1(root, relativeTo, options.scopes ?? {});
@@ -379,25 +420,24 @@ function createImportMap(options) {
379
420
  };
380
421
  }
381
422
  /**
382
- * Serialize an import map as stable, tab-indented JSON with a trailing newline.
423
+ * Serialize an import map as stable JSON with a trailing newline.
383
424
  *
384
425
  * @example
385
426
  * ```ts
386
- * // Format an import map for stdout.
427
+ * // Format an import map for stdout, indented with two spaces.
387
428
  * import { formatImportMap } from 'jsr:@kjanat/importmapify';
388
429
  *
389
- * const text = formatImportMap({
390
- * imports: { '#config': './src/config.ts' },
391
- * });
430
+ * const text = formatImportMap({ imports: { '#config': './src/config.ts' } }, 2);
392
431
  *
393
432
  * console.log(text);
394
433
  * ```
395
434
  *
396
435
  * @param map Import map to serialize.
436
+ * @param indent Indentation with `JSON.stringify` space semantics; defaults to a tab.
397
437
  * @returns Formatted JSON ready to print or write.
398
438
  */
399
- function formatImportMap(map) {
400
- return `${JSON.stringify(map, null, " ")}\n`;
439
+ function formatImportMap(map, indent = " ") {
440
+ return `${JSON.stringify(map, null, indent)}\n`;
401
441
  }
402
442
  /**
403
443
  * Create an import map and write it to disk, creating parent directories as needed.
@@ -425,7 +465,7 @@ function writeImportMap(options) {
425
465
  relativeTo
426
466
  });
427
467
  fs.mkdirSync(path.dirname(out), { recursive: true });
428
- fs.writeFileSync(out, formatImportMap(map));
468
+ fs.writeFileSync(out, formatImportMap(map, options.indent));
429
469
  return out;
430
470
  }
431
471
  function directoryTarget(target) {
@@ -471,8 +511,22 @@ function packageEntries(name, target) {
471
511
  * writeImportMap(config);
472
512
  * ```
473
513
  *
474
- * @param config Import map configuration, with an optional `out`.
475
- * @returns The same `config` value, typed as {@link Config}.
514
+ * @example
515
+ * ```ts
516
+ * // Config file with a build hook: generate:before runs before the CLI scans.
517
+ * import { execSync } from 'node:child_process';
518
+ * import { defineConfig } from 'jsr:@kjanat/importmapify';
519
+ *
520
+ * export default defineConfig({
521
+ * hooks: {
522
+ * 'generate:before': () => execSync('deno task build', { stdio: 'inherit' }),
523
+ * },
524
+ * });
525
+ * ```
526
+ *
527
+ * @param config Import map configuration; every field is optional.
528
+ * @returns The same {@linkcode config} value with its exact type preserved, so a config that includes {@linkcode CreateImportMapOptions.root} stays
529
+ * assignable to {@link writeImportMap} while one that omits it is still a valid config file.
476
530
  */
477
531
  function defineConfig(config) {
478
532
  return config;
@@ -480,7 +534,8 @@ function defineConfig(config) {
480
534
  //#endregion
481
535
  //#region src/cli.ts
482
536
  const EXAMPLE_TOKEN = /(?:'[^']*'|"[^"]*"|\S)+/g;
483
- const JSON_MODE = argv.includes("--json");
537
+ const SPACE_COUNT = /^\d+$/;
538
+ const JSON_MODE = (argv.includes("--") ? argv.slice(0, argv.indexOf("--")) : argv).includes("--json");
484
539
  function highlightExample(example) {
485
540
  if (JSON_MODE) return example;
486
541
  const tokens = example.match(EXAMPLE_TOKEN);
@@ -552,11 +607,34 @@ function preferExplicit(explicit, flagValue, configValue) {
552
607
  function preferArray(flagValue, configValue) {
553
608
  return flagValue !== void 0 && flagValue.length > 0 ? flagValue : configValue;
554
609
  }
610
+ function parseIndent(raw) {
611
+ if (raw === "tab") return " ";
612
+ if (SPACE_COUNT.test(raw)) return Number(raw);
613
+ throw new CLIError(`--indent expects a number of spaces or "tab", got "${raw}"`, {
614
+ code: "invalid-indent-flag",
615
+ exitCode: 2
616
+ });
617
+ }
618
+ function compileFilters(raws) {
619
+ return raws.map((raw) => {
620
+ try {
621
+ return new RegExp(raw);
622
+ } catch (cause) {
623
+ throw new CLIError(`--filter expects a valid regular expression, got "${raw}"`, {
624
+ code: "invalid-filter-flag",
625
+ exitCode: 2,
626
+ cause
627
+ });
628
+ }
629
+ });
630
+ }
555
631
  /** Resolve final import map options by layering explicit flags over a discovered config over defaults. */
556
632
  async function resolveGenerateOptions(flags) {
557
633
  const base = await loadConfigOptions(flags.root, flags.config, flags["no-config"] ?? false);
558
634
  const conditions = preferArray(flags.condition, base.conditions);
559
635
  const extensions = preferArray(flags.ext, base.extensions);
636
+ const filter = preferArray(flags.filter === void 0 ? void 0 : compileFilters(flags.filter), base.filter);
637
+ const indent = flags.indent === void 0 ? base.indent : parseIndent(flags.indent);
560
638
  const options = {
561
639
  root: preferExplicit(flags.root !== cwd(), flags.root, base.root),
562
640
  manifest: preferExplicit(flags.manifest !== "package.json", flags.manifest, base.manifest),
@@ -572,15 +650,18 @@ async function resolveGenerateOptions(flags) {
572
650
  scopes: mergeScopes(base.scopes, buildScopes(flags.scope ?? []))
573
651
  };
574
652
  if (base.relativeTo !== void 0) options.relativeTo = base.relativeTo;
653
+ if (indent !== void 0) options.indent = indent;
575
654
  if (conditions !== void 0) options.conditions = conditions;
576
655
  if (extensions !== void 0) options.extensions = extensions;
656
+ if (filter !== void 0) options.filter = filter;
657
+ if (base.hooks !== void 0) options.hooks = base.hooks;
577
658
  return options;
578
659
  }
579
660
  /** DreamCLI command that writes, checks, or prints an expanded Deno import map. */
580
661
  const generateCommand = command("generate").description("Expand package.json subpath-pattern imports into a Deno import map.").flag("root", flag.path({
581
662
  mustExist: true,
582
663
  type: "directory"
583
- }).default(cwd()).describe("Project root containing the manifest.").alias("r")).flag("manifest", flag.string().default("package.json").describe("Manifest path, relative to root.").alias("m")).flag("out", flag.string().default(DEFAULT_OUT).describe("Output path resolved against root; relative, absolute, or file:// URL.").alias("o")).flag("import", flag.array(flag.string()).describe("Additional import entry as key=value. Repeatable.").alias("i")).flag("package", flag.array(flag.string()).describe("Package as name=target, expanded to a conformant bare and trailing-slash pair. Repeatable.").alias("p")).flag("ext", flag.array(flag.string()).describe("Restrict pattern matches to these file extensions. Repeatable.").alias("e")).flag("scope", flag.array(flag.string()).describe("Scoped import as prefix::key=value. Repeatable.").alias("s")).flag("condition", flag.array(flag.string()).describe("Condition to try when a target is a conditional object. Repeatable.").alias("c")).flag("config", flag.string().describe("Config file path; skips discovery.").alias("C")).flag("no-config", flag.boolean().describe("Skip config file discovery.")).flag("check", flag.boolean().describe("Exit 1 if the output file is stale instead of writing it.")).flag("stdout", flag.boolean().describe("Print the import map to stdout instead of writing it.")).flag("quiet", flag.boolean().describe("Suppress the confirmation message on stderr.").alias("q")).example(highlightExample("importmapify --stdout"), "Print the generated import map").example(highlightExample(`importmapify --package 'dreamcli=jsr:@kjanat/dreamcli@^3' --scope './tests/::dreamcli/testkit=jsr:@kjanat/dreamcli@^3/testkit'`), "Add global and test-scoped dependencies").example(highlightExample("importmapify --check"), "Fail when the generated file is stale").action(async ({ flags, out }) => {
664
+ }).default(cwd()).describe("Project root containing the manifest.").alias("r")).flag("manifest", flag.string().default("package.json").describe("Manifest path, relative to root.").alias("m")).flag("out", flag.string().default(DEFAULT_OUT).describe("Output path resolved against root; relative, absolute, or file:// URL.").alias("o")).flag("indent", flag.string().describe("Indentation as a number of spaces or the word \"tab\".")).flag("import", flag.array(flag.string()).describe("Additional import entry as key=value. Repeatable.").alias("i")).flag("package", flag.array(flag.string()).describe("Package as name=target, expanded to a conformant bare and trailing-slash pair. Repeatable.").alias("p")).flag("ext", flag.array(flag.string()).describe("Restrict pattern matches to these file extensions. Repeatable.").alias("e")).flag("filter", flag.array(flag.string()).describe("Regular expression a pattern target path must match. Repeatable.").alias("f")).flag("scope", flag.array(flag.string()).describe("Scoped import as prefix::key=value. Repeatable.").alias("s")).flag("condition", flag.array(flag.string()).describe("Condition to try when a target is a conditional object. Repeatable.").alias("c")).flag("config", flag.string().describe("Config file path; skips discovery.").alias("C")).flag("no-config", flag.boolean().describe("Skip config file discovery.")).flag("check", flag.boolean().describe("Exit 1 if the output file is stale instead of writing it.")).flag("stdout", flag.boolean().describe("Print the import map to stdout instead of writing it.")).flag("quiet", flag.boolean().describe("Suppress the confirmation message on stderr.").alias("q")).example(highlightExample("importmapify --stdout"), "Print the generated import map").example(highlightExample(`importmapify --package 'dreamcli=jsr:@kjanat/dreamcli@^3' --scope './tests/::dreamcli/testkit=jsr:@kjanat/dreamcli@^3/testkit'`), "Add global and test-scoped dependencies").example(highlightExample("importmapify --check"), "Fail when the generated file is stale").action(async ({ flags, out }) => {
584
665
  const { log, warn, error, setExitCode } = out;
585
666
  const { check, stdout, quiet } = flags;
586
667
  if (check && stdout) throw new CLIError("--check and --stdout are mutually exclusive", {
@@ -593,29 +674,36 @@ const generateCommand = command("generate").description("Expand package.json sub
593
674
  ...options,
594
675
  relativeTo: options.relativeTo ?? dirname(outPath)
595
676
  };
596
- if (stdout) {
597
- log(formatImportMap(createImportMap(createOptions)));
598
- return;
599
- }
600
- if (check) {
601
- const expected = formatImportMap(createImportMap(createOptions));
602
- if ((existsSync(outPath) ? readFileSync(outPath, "utf8") : void 0) !== expected) {
677
+ const hookContext = {
678
+ root: resolve(toPath(options.root)),
679
+ out: outPath
680
+ };
681
+ await options.hooks?.["generate:before"]?.(hookContext);
682
+ const map = createImportMap(createOptions);
683
+ const text = formatImportMap(map, options.indent);
684
+ if (stdout) log(text);
685
+ else if (check) {
686
+ if ((existsSync(outPath) ? readFileSync(outPath, "utf8") : void 0) !== text) {
603
687
  error(`${outPath} is stale`);
604
688
  setExitCode(1);
605
- return;
606
- }
607
- if (!quiet) warn(`${outPath} is up to date`);
608
- return;
689
+ } else if (!quiet) warn(`${outPath} is up to date`);
690
+ } else {
691
+ mkdirSync(dirname(outPath), { recursive: true });
692
+ writeFileSync(outPath, text);
693
+ if (!quiet) warn(`Wrote ${outPath}`);
609
694
  }
610
- const written = writeImportMap(options);
611
- if (!quiet) warn(`Wrote ${written}`);
695
+ await options.hooks?.["generate:done"]?.({
696
+ ...hookContext,
697
+ map
698
+ });
612
699
  });
613
700
  //#endregion
614
701
  //#region src/mod.ts
615
702
  /**
616
703
  * Expand package import patterns into explicit Deno import map entries.
617
704
  *
618
- * Use the library API to create, format, or write deterministic import maps.
705
+ * Use the library API to create, format, or write deterministic import maps that conform to the
706
+ * {@link https://html.spec.whatwg.org/multipage/webappapis.html#import-maps | Import Maps Standard}.
619
707
  * This module also runs the `importmapify` CLI when executed directly.
620
708
  *
621
709
  * @example
package/import_map.json CHANGED
@@ -7,6 +7,7 @@
7
7
  "#src/expand": "./src/expand.ts",
8
8
  "#src/map": "./src/map.ts",
9
9
  "#src/mod": "./src/mod.ts",
10
+ "#src/types": "./src/types.ts",
10
11
  "@types/bun": "npm:@types/bun@1.3.14",
11
12
  "ansispeck": "jsr:@kjanat/ansispeck@0.2.0",
12
13
  "ansispeck/": "jsr:/@kjanat/ansispeck@0.2.0/",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "importmapify",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Expand package.json subpath-pattern imports into explicit Deno import map entries.",
5
5
  "keywords": [
6
6
  "deno",