importmapify 1.4.0 → 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
 
@@ -126,15 +128,17 @@ 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` extends `Partial<WriteImportMapOptions>` with an optional `hooks` field the shape a config file's default
137
- export and `defineConfig` take, with every field optional. An omitted `root` defaults to the config file's own
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
138
142
  directory; the CLI supplies the remaining defaults.
139
143
 
140
144
  `defineConfig` returns its argument unchanged; it exists only to type a config object for export and reuse without a
@@ -172,6 +176,17 @@ import { createImportMap } from 'importmapify';
172
176
  createImportMap({ root: import.meta.dirname, extensions: ['ts', 'tsx'] });
173
177
  ```
174
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
+
175
190
  Build one conformant pair directly with `packageEntries`:
176
191
 
177
192
  ```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.4.0",
4
+ "version": "1.5.0",
5
5
  "exports": "./src/mod.ts",
6
6
  "publish": {
7
7
  "include": [
package/dist/mod.d.mts CHANGED
@@ -1,67 +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'`
33
223
  */
34
- readonly out?: string | URL;
224
+ readonly indent?: string | number;
35
225
  }
36
- /** Resolved paths shared by every generation hook. */
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
+ */
37
236
  interface HookContext {
38
- /** Absolute project root that gets scanned. */
237
+ /**
238
+ * Absolute project root that gets scanned.
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * const root = '/home/me/project';
243
+ * ```
244
+ */
39
245
  readonly root: string;
40
- /** Absolute output path the map resolves against. */
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
+ */
41
254
  readonly out: string;
42
255
  }
43
256
  /**
44
257
  * Lifecycle hooks the CLI runs around import map generation, modeled on tsdown's hooks.
45
258
  *
46
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
+ * ```
47
273
  */
48
274
  interface ImportMapHooks {
49
- /** Runs before the filesystem is scanned. Build pattern targets here so they exist when patterns expand. */
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
+ */
50
283
  readonly "generate:before": (context: HookContext) => void | Promise<void>;
51
- /** Runs after the map is generated and emitted. */
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
+ */
52
292
  readonly "generate:done": (context: HookContext & {
53
293
  readonly map: ImportMapDocument;
54
294
  }) => void | Promise<void>;
55
295
  }
56
296
  /**
57
- * An importmapify config file: the shape a config file's default export and {@linkcode defineConfig} take.
297
+ * An importmapify config file: the shape a config file's default export and `defineConfig` take.
58
298
  * Every field is optional; the config loader and CLI supply {@linkcode CreateImportMapOptions.root | root}
59
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
+ * ```
60
311
  */
61
312
  interface Config extends Partial<WriteImportMapOptions> {
62
- /** Lifecycle hooks the CLI runs around generation. Ignored by {@link writeImportMap} and {@link createImportMap}. */
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
+ * ```
320
+ */
63
321
  readonly hooks?: Partial<ImportMapHooks>;
64
322
  }
323
+ //#endregion
324
+ //#region src/map.d.ts
65
325
  /**
66
326
  * Expand a package's exact and patterned `imports` into a Deno import map.
67
327
  *
@@ -86,24 +346,23 @@ interface Config extends Partial<WriteImportMapOptions> {
86
346
  */
87
347
  declare function createImportMap(options: CreateImportMapOptions): ImportMapDocument;
88
348
  /**
89
- * 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.
90
350
  *
91
351
  * @example
92
352
  * ```ts
93
- * // Format an import map for stdout.
353
+ * // Format an import map for stdout, indented with two spaces.
94
354
  * import { formatImportMap } from 'jsr:@kjanat/importmapify';
95
355
  *
96
- * const text = formatImportMap({
97
- * imports: { '#config': './src/config.ts' },
98
- * });
356
+ * const text = formatImportMap({ imports: { '#config': './src/config.ts' } }, 2);
99
357
  *
100
358
  * console.log(text);
101
359
  * ```
102
360
  *
103
361
  * @param map Import map to serialize.
362
+ * @param indent Indentation with `JSON.stringify` space semantics; defaults to a tab.
104
363
  * @returns Formatted JSON ready to print or write.
105
364
  */
106
- declare function formatImportMap(map: ImportMapDocument): string;
365
+ declare function formatImportMap(map: ImportMapDocument, indent?: string | number): string;
107
366
  /**
108
367
  * Create an import map and write it to disk, creating parent directories as needed.
109
368
  *
@@ -158,10 +417,23 @@ declare function packageEntries(name: string, target: string): Record<string, st
158
417
  * writeImportMap(config);
159
418
  * ```
160
419
  *
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
+ *
161
433
  * @param config Import map configuration; every field is optional.
162
- * @returns The same `config` value with its exact type preserved, so a config that includes `root` stays
434
+ * @returns The same {@linkcode config} value with its exact type preserved, so a config that includes {@linkcode CreateImportMapOptions.root} stays
163
435
  * assignable to {@link writeImportMap} while one that omits it is still a valid config file.
164
436
  */
165
437
  declare function defineConfig<T extends Config>(config: T): T;
166
438
  //#endregion
167
- export { type Config, type CreateImportMapOptions, type HookContext, type ImportMapDocument, type ImportMapHooks, 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
@@ -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 = {};
@@ -223,12 +232,16 @@ function configToOptions(config, configDir) {
223
232
  if (manifest !== void 0) result.manifest = manifest;
224
233
  const out = asPath(config.out);
225
234
  if (out !== void 0) result.out = out;
235
+ const indent = asIndent(config.indent);
236
+ if (indent !== void 0) result.indent = indent;
226
237
  const relativeTo = asPath(config.relativeTo);
227
238
  if (relativeTo !== void 0) result.relativeTo = relativeTo;
228
239
  const conditions = asStringArray(config.conditions);
229
240
  if (conditions !== void 0) result.conditions = conditions;
230
241
  const extensions = asStringArray(config.extensions);
231
242
  if (extensions !== void 0) result.extensions = extensions;
243
+ const filter = asTargetFilters(config.filter);
244
+ if (filter !== void 0) result.filter = filter;
232
245
  const packages = asStringRecord(config.packages);
233
246
  if (packages !== void 0) result.packages = packages;
234
247
  const additionalImports = asStringRecord(config.additionalImports);
@@ -293,17 +306,28 @@ function readManifest(manifestPath) {
293
306
  if (!isRecord(parsed)) throw new Error(`Manifest at ${manifestPath} must be a JSON object`);
294
307
  return parsed;
295
308
  }
296
- 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) {
297
317
  const allowed = new Set(extensions.map((ext) => ext.startsWith(".") ? ext : `.${ext}`));
298
- return (file) => allowed.has(path.extname(file));
318
+ return (target) => (allowed.size === 0 || allowed.has(path.extname(target))) && filters.every((matcher) => matcherKeeps(matcher, target));
299
319
  }
300
- function expandManifestImport(root, key, rawValue, { conditions, extensions }) {
320
+ function expandManifestImport(root, key, rawValue, { conditions, extensions, filter }) {
301
321
  const value = typeof rawValue === "string" ? rawValue : resolveCondition(rawValue, conditions);
302
322
  if (value === void 0) return {};
303
323
  const pattern = parsePattern(key, value);
304
324
  if (pattern === void 0) return { [key]: value };
305
- const files = filesUnder(path.join(root, pattern.targetDirectory));
306
- 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;
307
331
  }
308
332
  function keyBaseLength(key) {
309
333
  const star = key.indexOf("*");
@@ -383,7 +407,8 @@ function createImportMap(options) {
383
407
  const relativeTo = options.relativeTo === void 0 ? root : toPath(options.relativeTo);
384
408
  const imports = expandManifest(root, relativeTo, manifestImports, {
385
409
  conditions,
386
- extensions: options.extensions ?? []
410
+ extensions: options.extensions ?? [],
411
+ filter: options.filter ?? []
387
412
  });
388
413
  for (const [key, value] of Object.entries(collectAdditional(options))) imports[key] = rebaseTarget(root, relativeTo, value);
389
414
  const scopes = buildScopes$1(root, relativeTo, options.scopes ?? {});
@@ -395,25 +420,24 @@ function createImportMap(options) {
395
420
  };
396
421
  }
397
422
  /**
398
- * 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.
399
424
  *
400
425
  * @example
401
426
  * ```ts
402
- * // Format an import map for stdout.
427
+ * // Format an import map for stdout, indented with two spaces.
403
428
  * import { formatImportMap } from 'jsr:@kjanat/importmapify';
404
429
  *
405
- * const text = formatImportMap({
406
- * imports: { '#config': './src/config.ts' },
407
- * });
430
+ * const text = formatImportMap({ imports: { '#config': './src/config.ts' } }, 2);
408
431
  *
409
432
  * console.log(text);
410
433
  * ```
411
434
  *
412
435
  * @param map Import map to serialize.
436
+ * @param indent Indentation with `JSON.stringify` space semantics; defaults to a tab.
413
437
  * @returns Formatted JSON ready to print or write.
414
438
  */
415
- function formatImportMap(map) {
416
- return `${JSON.stringify(map, null, " ")}\n`;
439
+ function formatImportMap(map, indent = " ") {
440
+ return `${JSON.stringify(map, null, indent)}\n`;
417
441
  }
418
442
  /**
419
443
  * Create an import map and write it to disk, creating parent directories as needed.
@@ -441,7 +465,7 @@ function writeImportMap(options) {
441
465
  relativeTo
442
466
  });
443
467
  fs.mkdirSync(path.dirname(out), { recursive: true });
444
- fs.writeFileSync(out, formatImportMap(map));
468
+ fs.writeFileSync(out, formatImportMap(map, options.indent));
445
469
  return out;
446
470
  }
447
471
  function directoryTarget(target) {
@@ -487,8 +511,21 @@ function packageEntries(name, target) {
487
511
  * writeImportMap(config);
488
512
  * ```
489
513
  *
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
+ *
490
527
  * @param config Import map configuration; every field is optional.
491
- * @returns The same `config` value with its exact type preserved, so a config that includes `root` stays
528
+ * @returns The same {@linkcode config} value with its exact type preserved, so a config that includes {@linkcode CreateImportMapOptions.root} stays
492
529
  * assignable to {@link writeImportMap} while one that omits it is still a valid config file.
493
530
  */
494
531
  function defineConfig(config) {
@@ -497,7 +534,8 @@ function defineConfig(config) {
497
534
  //#endregion
498
535
  //#region src/cli.ts
499
536
  const EXAMPLE_TOKEN = /(?:'[^']*'|"[^"]*"|\S)+/g;
500
- 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");
501
539
  function highlightExample(example) {
502
540
  if (JSON_MODE) return example;
503
541
  const tokens = example.match(EXAMPLE_TOKEN);
@@ -569,11 +607,34 @@ function preferExplicit(explicit, flagValue, configValue) {
569
607
  function preferArray(flagValue, configValue) {
570
608
  return flagValue !== void 0 && flagValue.length > 0 ? flagValue : configValue;
571
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
+ }
572
631
  /** Resolve final import map options by layering explicit flags over a discovered config over defaults. */
573
632
  async function resolveGenerateOptions(flags) {
574
633
  const base = await loadConfigOptions(flags.root, flags.config, flags["no-config"] ?? false);
575
634
  const conditions = preferArray(flags.condition, base.conditions);
576
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);
577
638
  const options = {
578
639
  root: preferExplicit(flags.root !== cwd(), flags.root, base.root),
579
640
  manifest: preferExplicit(flags.manifest !== "package.json", flags.manifest, base.manifest),
@@ -589,8 +650,10 @@ async function resolveGenerateOptions(flags) {
589
650
  scopes: mergeScopes(base.scopes, buildScopes(flags.scope ?? []))
590
651
  };
591
652
  if (base.relativeTo !== void 0) options.relativeTo = base.relativeTo;
653
+ if (indent !== void 0) options.indent = indent;
592
654
  if (conditions !== void 0) options.conditions = conditions;
593
655
  if (extensions !== void 0) options.extensions = extensions;
656
+ if (filter !== void 0) options.filter = filter;
594
657
  if (base.hooks !== void 0) options.hooks = base.hooks;
595
658
  return options;
596
659
  }
@@ -598,7 +661,7 @@ async function resolveGenerateOptions(flags) {
598
661
  const generateCommand = command("generate").description("Expand package.json subpath-pattern imports into a Deno import map.").flag("root", flag.path({
599
662
  mustExist: true,
600
663
  type: "directory"
601
- }).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 }) => {
602
665
  const { log, warn, error, setExitCode } = out;
603
666
  const { check, stdout, quiet } = flags;
604
667
  if (check && stdout) throw new CLIError("--check and --stdout are mutually exclusive", {
@@ -617,7 +680,7 @@ const generateCommand = command("generate").description("Expand package.json sub
617
680
  };
618
681
  await options.hooks?.["generate:before"]?.(hookContext);
619
682
  const map = createImportMap(createOptions);
620
- const text = formatImportMap(map);
683
+ const text = formatImportMap(map, options.indent);
621
684
  if (stdout) log(text);
622
685
  else if (check) {
623
686
  if ((existsSync(outPath) ? readFileSync(outPath, "utf8") : void 0) !== text) {
@@ -639,7 +702,8 @@ const generateCommand = command("generate").description("Expand package.json sub
639
702
  /**
640
703
  * Expand package import patterns into explicit Deno import map entries.
641
704
  *
642
- * 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}.
643
707
  * This module also runs the `importmapify` CLI when executed directly.
644
708
  *
645
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.4.0",
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",