importmapify 1.1.0 → 1.2.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
+ | `--config path` | `-C` | Config file path; skips discovery | auto-discovered |
43
+ | `--no-config` | | Skip config file discovery | off |
42
44
  | `--check` | | Exit 1 if the output file is stale, without writing it | off |
43
45
  | `--stdout` | | Print the map instead of writing it | off |
44
46
 
@@ -52,6 +54,28 @@ npx importmapify \
52
54
 
53
55
  Run `npx importmapify --help` for the full reference, or `npx importmapify --completions bash` for shell completions.
54
56
 
57
+ ### Config file
58
+
59
+ The CLI auto-discovers a config file in the project root and uses its default export as the base options. Discovery
60
+ order (first match wins): `importmapify.config.{mjs,cjs,js,ts,mts,cts}`, then `.importmapify.{mjs,cjs,js,ts,mts,cts}`.
61
+
62
+ ```js
63
+ // importmapify.config.mjs
64
+ export default {
65
+ packages: { dreamcli: 'jsr:@kjanat/dreamcli@^3' },
66
+ extensions: ['ts'],
67
+ };
68
+ ```
69
+
70
+ Then `npx importmapify` reads it automatically. Point at a specific file with `--config <path>`, or skip discovery with
71
+ `--no-config`.
72
+
73
+ Explicitly-passed flags override the config, which overrides built-in defaults: `--import`/`--package`/`--scope` merge
74
+ onto the config's records with flag keys winning; `--condition`/`--ext` replace; `--root`/`--manifest`/`--out` win when
75
+ passed. Configs are imported natively; a TypeScript config needs a runtime that strips types (Bun, Deno, or Node
76
+
77
+ > = 22.6), so on older Node use a `.mjs`/`.cjs`/`.js` config.
78
+
55
79
  ## Library
56
80
 
57
81
  ```ts
@@ -97,9 +121,8 @@ resolved against `root` and accepts a relative path, an absolute path, or a `fil
97
121
  automatically against `out`'s directory, so a nested `out` (for example `.cache/maps/import_map.json`) still produces
98
122
  targets that resolve correctly from the map's own location.
99
123
 
100
- `defineConfig` returns its argument unchanged; it exists only to type an exported config object without a manual
101
- annotation. Under `isolatedDeclarations`, an exported binding still needs its own annotation, so there `defineConfig`
102
- helps only for a config used locally rather than exported.
124
+ `defineConfig` returns its argument unchanged; it exists only to type a config object for export and reuse without a
125
+ manual annotation.
103
126
 
104
127
  ### Recipes
105
128
 
@@ -113,10 +136,10 @@ writeImportMap({
113
136
  root: import.meta.dirname,
114
137
  packages: {
115
138
  dreamcli: 'jsr:@kjanat/dreamcli@^3',
116
- chalk: 'npm:chalk@5',
139
+ ansispeck: 'npm:ansispeck@^0.2',
117
140
  },
118
141
  });
119
- // dreamcli, dreamcli/ -> jsr:/@kjanat/dreamcli@^3/, chalk, chalk/ -> npm:/chalk@5/
142
+ // dreamcli, dreamcli/ -> jsr:/@kjanat/dreamcli@^3/, ansispeck, ansispeck/ -> npm:/ansispeck@^0.2/
120
143
  ```
121
144
 
122
145
  Restrict pattern expansion to importable files with `extensions`, so a bare `./src/*` skips `.md`, `.json`, and other
@@ -142,7 +165,7 @@ Type a reusable config with `defineConfig` and share it across calls:
142
165
  ```ts
143
166
  import { createImportMap, defineConfig, writeImportMap } from 'importmapify';
144
167
 
145
- const config = defineConfig({ root: import.meta.dirname, packages: { chalk: 'npm:chalk@5' } });
168
+ const config = defineConfig({ root: import.meta.dirname, packages: { ansispeck: 'npm:ansispeck@^0.2' } });
146
169
  const map = createImportMap(config);
147
170
  const written = writeImportMap(config);
148
171
  ```
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.1.0",
4
+ "version": "1.2.0",
5
5
  "exports": "./src/mod.ts",
6
6
  "publish": {
7
7
  "include": [
package/dist/mod.mjs CHANGED
@@ -3,7 +3,7 @@ import { CLIError, cli, command, flag } from "dreamcli";
3
3
  import fs, { existsSync, readFileSync } from "node:fs";
4
4
  import path, { dirname } from "node:path";
5
5
  import { cwd } from "node:process";
6
- import { fileURLToPath } from "node:url";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  //#region src/expand.ts
8
8
  function escapeRegExp(value) {
9
9
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -116,6 +116,129 @@ function rebaseTarget(root, relativeTo, target) {
116
116
  return relative.startsWith(".") ? relative : `./${relative}`;
117
117
  }
118
118
  //#endregion
119
+ //#region src/config.ts
120
+ const CONFIG_BASENAMES = ["importmapify.config", ".importmapify"];
121
+ const CONFIG_EXTENSIONS = [
122
+ "mjs",
123
+ "cjs",
124
+ "js",
125
+ "ts",
126
+ "mts",
127
+ "cts"
128
+ ];
129
+ /**
130
+ * Find the first config file directly under `root`.
131
+ *
132
+ * Bases are tried in order (`importmapify.config`, then `.importmapify`), each across
133
+ * `.mjs`, `.cjs`, `.js`, then the TypeScript equivalents. The first existing file wins.
134
+ *
135
+ * @param root Directory to search.
136
+ * @returns The config file path, or `undefined` when none exists.
137
+ */
138
+ function discoverConfig(root) {
139
+ for (const base of CONFIG_BASENAMES) for (const ext of CONFIG_EXTENSIONS) {
140
+ const candidate = path.join(root, `${base}.${ext}`);
141
+ if (fs.existsSync(candidate)) return candidate;
142
+ }
143
+ }
144
+ /**
145
+ * Import a config file and return its default-exported object.
146
+ *
147
+ * Uses the runtime's native module loader, so a `.ts` config requires a runtime that
148
+ * strips types (Bun, Deno, or Node >= 22.6).
149
+ *
150
+ * @param file Config file path.
151
+ * @returns The default export, validated as a plain object.
152
+ * @throws When the file cannot be imported or its default export is not a plain object.
153
+ */
154
+ async function loadConfig(file) {
155
+ let module;
156
+ try {
157
+ module = await import(pathToFileURL(file).href);
158
+ } catch (cause) {
159
+ throw new Error(`Cannot load config at ${file}; a .ts config needs a runtime that strips types (Bun, Deno, or Node >= 22.6)`, { cause });
160
+ }
161
+ const config = isRecord(module) ? module.default : void 0;
162
+ if (!isRecord(config)) throw new Error(`Config at ${file} must have a default export object`);
163
+ return config;
164
+ }
165
+ function asString(value) {
166
+ return typeof value === "string" ? value : void 0;
167
+ }
168
+ function asPath(value) {
169
+ return typeof value === "string" || value instanceof URL ? value : void 0;
170
+ }
171
+ function asStringArray(value) {
172
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
173
+ }
174
+ function asStringRecord(value) {
175
+ if (!isRecord(value)) return;
176
+ const record = {};
177
+ for (const [key, item] of Object.entries(value)) {
178
+ if (typeof item !== "string") return;
179
+ record[key] = item;
180
+ }
181
+ return record;
182
+ }
183
+ function asScopes(value) {
184
+ if (!isRecord(value)) return;
185
+ const scopes = {};
186
+ for (const [prefix, entries] of Object.entries(value)) {
187
+ const record = asStringRecord(entries);
188
+ if (record === void 0) return;
189
+ scopes[prefix] = record;
190
+ }
191
+ return scopes;
192
+ }
193
+ /**
194
+ * Parse a raw config object into typed import map options.
195
+ *
196
+ * Only known fields are read; a relative string `root` is resolved against the config
197
+ * file's own directory so a config can anchor itself with `root: '.'`.
198
+ *
199
+ * @param config Raw default-exported config object.
200
+ * @param configDir Directory containing the config file.
201
+ * @returns The subset of {@link WriteImportMapOptions} the config declares.
202
+ */
203
+ function configToOptions(config, configDir) {
204
+ const result = {};
205
+ const root = asPath(config.root);
206
+ if (root !== void 0) result.root = typeof root === "string" && !path.isAbsolute(root) && !root.startsWith("file://") ? path.resolve(configDir, root) : root;
207
+ const manifest = asString(config.manifest);
208
+ if (manifest !== void 0) result.manifest = manifest;
209
+ const out = asPath(config.out);
210
+ if (out !== void 0) result.out = out;
211
+ const relativeTo = asPath(config.relativeTo);
212
+ if (relativeTo !== void 0) result.relativeTo = relativeTo;
213
+ const conditions = asStringArray(config.conditions);
214
+ if (conditions !== void 0) result.conditions = conditions;
215
+ const extensions = asStringArray(config.extensions);
216
+ if (extensions !== void 0) result.extensions = extensions;
217
+ const packages = asStringRecord(config.packages);
218
+ if (packages !== void 0) result.packages = packages;
219
+ const additionalImports = asStringRecord(config.additionalImports);
220
+ if (additionalImports !== void 0) result.additionalImports = additionalImports;
221
+ const scopes = asScopes(config.scopes);
222
+ if (scopes !== void 0) result.scopes = scopes;
223
+ return result;
224
+ }
225
+ /**
226
+ * Merge scope maps, with override entries winning per scope-prefix key.
227
+ *
228
+ * @param base Scopes from the config file.
229
+ * @param overrides Scopes from CLI flags.
230
+ * @returns Combined scopes.
231
+ */
232
+ function mergeScopes(base, overrides) {
233
+ const result = {};
234
+ for (const [prefix, entries] of Object.entries(base ?? {})) result[prefix] = { ...entries };
235
+ for (const [prefix, entries] of Object.entries(overrides)) result[prefix] = {
236
+ ...result[prefix],
237
+ ...entries
238
+ };
239
+ return result;
240
+ }
241
+ //#endregion
119
242
  //#region src/map.ts
120
243
  const DEFAULT_CONDITIONS = ["import", "default"];
121
244
  const DEFAULT_OUT = "import_map.json";
@@ -394,35 +517,79 @@ function buildScopes(raws) {
394
517
  }
395
518
  return scopes;
396
519
  }
520
+ async function loadConfigOptions(searchRoot, configFlag, noConfig) {
521
+ if (noConfig) return {};
522
+ const file = configFlag ?? discoverConfig(searchRoot);
523
+ if (file === void 0) return {};
524
+ if (!existsSync(file)) throw new CLIError(`Config file not found: ${file}`, {
525
+ code: "config-not-found",
526
+ exitCode: 2
527
+ });
528
+ try {
529
+ return configToOptions(await loadConfig(file), dirname(file));
530
+ } catch (cause) {
531
+ throw new CLIError(cause instanceof Error ? cause.message : `Cannot load config at ${file}`, {
532
+ code: "config-load-failed",
533
+ exitCode: 2,
534
+ cause
535
+ });
536
+ }
537
+ }
538
+ /** Explicitly-passed flag value wins; otherwise the config value, otherwise the flag default. */
539
+ function preferExplicit(explicit, flagValue, configValue) {
540
+ return explicit ? flagValue : configValue ?? flagValue;
541
+ }
542
+ /** A non-empty flag array wins over the config value. */
543
+ function preferArray(flagValue, configValue) {
544
+ return flagValue !== void 0 && flagValue.length > 0 ? flagValue : configValue;
545
+ }
546
+ /** Resolve final import map options by layering explicit flags over a discovered config over defaults. */
547
+ async function resolveGenerateOptions(flags) {
548
+ const base = await loadConfigOptions(flags.root, flags.config, flags["no-config"] ?? false);
549
+ const conditions = preferArray(flags.condition, base.conditions);
550
+ const extensions = preferArray(flags.ext, base.extensions);
551
+ const options = {
552
+ root: preferExplicit(flags.root !== cwd(), flags.root, base.root),
553
+ manifest: preferExplicit(flags.manifest !== "package.json", flags.manifest, base.manifest),
554
+ out: preferExplicit(flags.out !== DEFAULT_OUT, flags.out, base.out),
555
+ packages: {
556
+ ...base.packages,
557
+ ...parseEntries(flags.package ?? [], "package", "invalid-package-flag")
558
+ },
559
+ additionalImports: {
560
+ ...base.additionalImports,
561
+ ...parseEntries(flags.import ?? [], "import", "invalid-import-flag")
562
+ },
563
+ scopes: mergeScopes(base.scopes, buildScopes(flags.scope ?? []))
564
+ };
565
+ if (base.relativeTo !== void 0) options.relativeTo = base.relativeTo;
566
+ if (conditions !== void 0) options.conditions = conditions;
567
+ if (extensions !== void 0) options.extensions = extensions;
568
+ return options;
569
+ }
397
570
  /** DreamCLI command that writes, checks, or prints an expanded Deno import map. */
398
571
  const generateCommand = command("generate").description("Expand package.json subpath-pattern imports into a Deno import map.").flag("root", flag.path({
399
572
  mustExist: true,
400
573
  type: "directory"
401
- }).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("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.")).example("importmapify --stdout", "Print the generated import map").example(`importmapify --package 'dreamcli=jsr:@kjanat/dreamcli@^3' --scope './tests/::dreamcli/testkit=jsr:@kjanat/dreamcli@^3/testkit'`, "Add global and test-scoped dependencies").example("importmapify --check", "Fail when the generated file is stale").action(({ flags, out }) => {
574
+ }).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.")).example("importmapify --stdout", "Print the generated import map").example(`importmapify --package 'dreamcli=jsr:@kjanat/dreamcli@^3' --scope './tests/::dreamcli/testkit=jsr:@kjanat/dreamcli@^3/testkit'`, "Add global and test-scoped dependencies").example("importmapify --check", "Fail when the generated file is stale").action(async ({ flags, out }) => {
402
575
  const { log, error, setExitCode } = out;
403
- const { check, stdout, root, manifest, condition: cdts, out: of, import: imf, package: pkf, ext: exf, scope: scf } = flags;
576
+ const { check, stdout } = flags;
404
577
  if (check && stdout) throw new CLIError("--check and --stdout are mutually exclusive", {
405
578
  code: "conflicting-flags",
406
579
  exitCode: 2
407
580
  });
408
- const outPath = resolveOut(root, of);
409
- const relativeTo = dirname(outPath);
410
- const options = {
411
- root,
412
- manifest,
413
- conditions: cdts,
414
- packages: parseEntries(pkf ?? [], "package", "invalid-package-flag"),
415
- additionalImports: parseEntries(imf ?? [], "import", "invalid-import-flag"),
416
- scopes: buildScopes(scf ?? []),
417
- relativeTo,
418
- extensions: exf
581
+ const options = await resolveGenerateOptions(flags);
582
+ const outPath = resolveOut(options.root, options.out ?? "import_map.json");
583
+ const createOptions = {
584
+ ...options,
585
+ relativeTo: options.relativeTo ?? dirname(outPath)
419
586
  };
420
587
  if (stdout) {
421
- log(formatImportMap(createImportMap(options)));
588
+ log(formatImportMap(createImportMap(createOptions)));
422
589
  return;
423
590
  }
424
591
  if (check) {
425
- const expected = formatImportMap(createImportMap(options));
592
+ const expected = formatImportMap(createImportMap(createOptions));
426
593
  if ((existsSync(outPath) ? readFileSync(outPath, "utf8") : void 0) !== expected) {
427
594
  error(`${outPath} is stale`);
428
595
  setExitCode(1);
@@ -431,10 +598,7 @@ const generateCommand = command("generate").description("Expand package.json sub
431
598
  log(`${outPath} is up to date`);
432
599
  return;
433
600
  }
434
- log(`Wrote ${writeImportMap({
435
- ...options,
436
- out: of
437
- })}`);
601
+ log(`Wrote ${writeImportMap(options)}`);
438
602
  });
439
603
  //#endregion
440
604
  //#region src/mod.ts
package/import_map.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "#deno": "./deno.json",
4
4
  "#pkg": "./package.json",
5
5
  "#src/cli": "./src/cli.ts",
6
+ "#src/config": "./src/config.ts",
6
7
  "#src/expand": "./src/expand.ts",
7
8
  "#src/map": "./src/map.ts",
8
9
  "#src/mod": "./src/mod.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "importmapify",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Expand package.json subpath-pattern imports into explicit Deno import map entries.",
5
5
  "keywords": [
6
6
  "deno",