importmapify 1.1.1 → 1.3.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
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.1",
4
+ "version": "1.3.0",
5
5
  "exports": "./src/mod.ts",
6
6
  "publish": {
7
7
  "include": [
@@ -51,6 +51,7 @@
51
51
  "lock": false,
52
52
  "jsrDepsInNodeModules": false,
53
53
  "minimumDependencyAge": {
54
+ "age": 0,
54
55
  "exclude": ["jsr:@kjanat/*", "npm:@kjanat/*"]
55
56
  }
56
57
  }
package/dist/mod.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { bold, cyan, detectHyperlinkSupport } from "ansispeck";
2
3
  import { CLIError, cli, command, flag } from "dreamcli";
3
4
  import fs, { existsSync, readFileSync } from "node:fs";
4
5
  import path, { dirname } from "node:path";
5
6
  import { cwd } from "node:process";
6
- import { fileURLToPath } from "node:url";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  //#region src/expand.ts
8
9
  function escapeRegExp(value) {
9
10
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -116,6 +117,129 @@ function rebaseTarget(root, relativeTo, target) {
116
117
  return relative.startsWith(".") ? relative : `./${relative}`;
117
118
  }
118
119
  //#endregion
120
+ //#region src/config.ts
121
+ const CONFIG_BASENAMES = ["importmapify.config", ".importmapify"];
122
+ const CONFIG_EXTENSIONS = [
123
+ "mjs",
124
+ "cjs",
125
+ "js",
126
+ "ts",
127
+ "mts",
128
+ "cts"
129
+ ];
130
+ /**
131
+ * Find the first config file directly under `root`.
132
+ *
133
+ * Bases are tried in order (`importmapify.config`, then `.importmapify`), each across
134
+ * `.mjs`, `.cjs`, `.js`, then the TypeScript equivalents. The first existing file wins.
135
+ *
136
+ * @param root Directory to search.
137
+ * @returns The config file path, or `undefined` when none exists.
138
+ */
139
+ function discoverConfig(root) {
140
+ for (const base of CONFIG_BASENAMES) for (const ext of CONFIG_EXTENSIONS) {
141
+ const candidate = path.join(root, `${base}.${ext}`);
142
+ if (fs.existsSync(candidate)) return candidate;
143
+ }
144
+ }
145
+ /**
146
+ * Import a config file and return its default-exported object.
147
+ *
148
+ * Uses the runtime's native module loader, so a `.ts` config requires a runtime that
149
+ * strips types (Bun, Deno, or Node >= 22.6).
150
+ *
151
+ * @param file Config file path.
152
+ * @returns The default export, validated as a plain object.
153
+ * @throws When the file cannot be imported or its default export is not a plain object.
154
+ */
155
+ async function loadConfig(file) {
156
+ let module;
157
+ try {
158
+ module = await import(pathToFileURL(file).href);
159
+ } catch (cause) {
160
+ throw new Error(`Cannot load config at ${file}; a .ts config needs a runtime that strips types (Bun, Deno, or Node >= 22.6)`, { cause });
161
+ }
162
+ const config = isRecord(module) ? module.default : void 0;
163
+ if (!isRecord(config)) throw new Error(`Config at ${file} must have a default export object`);
164
+ return config;
165
+ }
166
+ function asString(value) {
167
+ return typeof value === "string" ? value : void 0;
168
+ }
169
+ function asPath(value) {
170
+ return typeof value === "string" || value instanceof URL ? value : void 0;
171
+ }
172
+ function asStringArray(value) {
173
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
174
+ }
175
+ function asStringRecord(value) {
176
+ if (!isRecord(value)) return;
177
+ const record = {};
178
+ for (const [key, item] of Object.entries(value)) {
179
+ if (typeof item !== "string") return;
180
+ record[key] = item;
181
+ }
182
+ return record;
183
+ }
184
+ function asScopes(value) {
185
+ if (!isRecord(value)) return;
186
+ const scopes = {};
187
+ for (const [prefix, entries] of Object.entries(value)) {
188
+ const record = asStringRecord(entries);
189
+ if (record === void 0) return;
190
+ scopes[prefix] = record;
191
+ }
192
+ return scopes;
193
+ }
194
+ /**
195
+ * Parse a raw config object into typed import map options.
196
+ *
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: '.'`.
199
+ *
200
+ * @param config Raw default-exported config object.
201
+ * @param configDir Directory containing the config file.
202
+ * @returns The subset of {@link WriteImportMapOptions} the config declares.
203
+ */
204
+ function configToOptions(config, configDir) {
205
+ const result = {};
206
+ 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;
208
+ const manifest = asString(config.manifest);
209
+ if (manifest !== void 0) result.manifest = manifest;
210
+ const out = asPath(config.out);
211
+ if (out !== void 0) result.out = out;
212
+ const relativeTo = asPath(config.relativeTo);
213
+ if (relativeTo !== void 0) result.relativeTo = relativeTo;
214
+ const conditions = asStringArray(config.conditions);
215
+ if (conditions !== void 0) result.conditions = conditions;
216
+ const extensions = asStringArray(config.extensions);
217
+ if (extensions !== void 0) result.extensions = extensions;
218
+ const packages = asStringRecord(config.packages);
219
+ if (packages !== void 0) result.packages = packages;
220
+ const additionalImports = asStringRecord(config.additionalImports);
221
+ if (additionalImports !== void 0) result.additionalImports = additionalImports;
222
+ const scopes = asScopes(config.scopes);
223
+ if (scopes !== void 0) result.scopes = scopes;
224
+ return result;
225
+ }
226
+ /**
227
+ * Merge scope maps, with override entries winning per scope-prefix key.
228
+ *
229
+ * @param base Scopes from the config file.
230
+ * @param overrides Scopes from CLI flags.
231
+ * @returns Combined scopes.
232
+ */
233
+ function mergeScopes(base, overrides) {
234
+ const result = {};
235
+ for (const [prefix, entries] of Object.entries(base ?? {})) result[prefix] = { ...entries };
236
+ for (const [prefix, entries] of Object.entries(overrides)) result[prefix] = {
237
+ ...result[prefix],
238
+ ...entries
239
+ };
240
+ return result;
241
+ }
242
+ //#endregion
119
243
  //#region src/map.ts
120
244
  const DEFAULT_CONDITIONS = ["import", "default"];
121
245
  const DEFAULT_OUT = "import_map.json";
@@ -355,6 +479,12 @@ function defineConfig(options) {
355
479
  }
356
480
  //#endregion
357
481
  //#region src/cli.ts
482
+ const EXAMPLE_TOKEN = /(?:'[^']*'|"[^"]*"|\S)+/g;
483
+ function highlightExample(example) {
484
+ const tokens = example.match(EXAMPLE_TOKEN);
485
+ if (tokens === null) return example;
486
+ return tokens.map((token, index) => index === 0 ? bold(token) : token.startsWith("-") ? cyan(token) : token).join(" ");
487
+ }
358
488
  function parseKeyValue(raw, flagName, code) {
359
489
  const eq = raw.indexOf("=");
360
490
  if (eq === -1) throw new CLIError(`--${flagName} expects key=value, got "${raw}"`, {
@@ -394,47 +524,89 @@ function buildScopes(raws) {
394
524
  }
395
525
  return scopes;
396
526
  }
527
+ async function loadConfigOptions(searchRoot, configFlag, noConfig) {
528
+ if (noConfig) return {};
529
+ const file = configFlag ?? discoverConfig(searchRoot);
530
+ if (file === void 0) return {};
531
+ if (!existsSync(file)) throw new CLIError(`Config file not found: ${file}`, {
532
+ code: "config-not-found",
533
+ exitCode: 2
534
+ });
535
+ try {
536
+ return configToOptions(await loadConfig(file), dirname(file));
537
+ } catch (cause) {
538
+ throw new CLIError(cause instanceof Error ? cause.message : `Cannot load config at ${file}`, {
539
+ code: "config-load-failed",
540
+ exitCode: 2,
541
+ cause
542
+ });
543
+ }
544
+ }
545
+ /** Explicitly-passed flag value wins; otherwise the config value, otherwise the flag default. */
546
+ function preferExplicit(explicit, flagValue, configValue) {
547
+ return explicit ? flagValue : configValue ?? flagValue;
548
+ }
549
+ /** A non-empty flag array wins over the config value. */
550
+ function preferArray(flagValue, configValue) {
551
+ return flagValue !== void 0 && flagValue.length > 0 ? flagValue : configValue;
552
+ }
553
+ /** Resolve final import map options by layering explicit flags over a discovered config over defaults. */
554
+ async function resolveGenerateOptions(flags) {
555
+ const base = await loadConfigOptions(flags.root, flags.config, flags["no-config"] ?? false);
556
+ const conditions = preferArray(flags.condition, base.conditions);
557
+ const extensions = preferArray(flags.ext, base.extensions);
558
+ const options = {
559
+ root: preferExplicit(flags.root !== cwd(), flags.root, base.root),
560
+ manifest: preferExplicit(flags.manifest !== "package.json", flags.manifest, base.manifest),
561
+ out: preferExplicit(flags.out !== DEFAULT_OUT, flags.out, base.out),
562
+ packages: {
563
+ ...base.packages,
564
+ ...parseEntries(flags.package ?? [], "package", "invalid-package-flag")
565
+ },
566
+ additionalImports: {
567
+ ...base.additionalImports,
568
+ ...parseEntries(flags.import ?? [], "import", "invalid-import-flag")
569
+ },
570
+ scopes: mergeScopes(base.scopes, buildScopes(flags.scope ?? []))
571
+ };
572
+ if (base.relativeTo !== void 0) options.relativeTo = base.relativeTo;
573
+ if (conditions !== void 0) options.conditions = conditions;
574
+ if (extensions !== void 0) options.extensions = extensions;
575
+ return options;
576
+ }
397
577
  /** DreamCLI command that writes, checks, or prints an expanded Deno import map. */
398
578
  const generateCommand = command("generate").description("Expand package.json subpath-pattern imports into a Deno import map.").flag("root", flag.path({
399
579
  mustExist: true,
400
580
  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 }) => {
402
- const { log, error, setExitCode } = out;
403
- const { check, stdout, root, manifest, condition: cdts, out: of, import: imf, package: pkf, ext: exf, scope: scf } = flags;
581
+ }).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 }) => {
582
+ const { log, warn, error, setExitCode } = out;
583
+ const { check, stdout, quiet } = flags;
404
584
  if (check && stdout) throw new CLIError("--check and --stdout are mutually exclusive", {
405
585
  code: "conflicting-flags",
406
586
  exitCode: 2
407
587
  });
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
588
+ const options = await resolveGenerateOptions(flags);
589
+ const outPath = resolveOut(options.root, options.out ?? "import_map.json");
590
+ const createOptions = {
591
+ ...options,
592
+ relativeTo: options.relativeTo ?? dirname(outPath)
419
593
  };
420
594
  if (stdout) {
421
- log(formatImportMap(createImportMap(options)));
595
+ log(formatImportMap(createImportMap(createOptions)));
422
596
  return;
423
597
  }
424
598
  if (check) {
425
- const expected = formatImportMap(createImportMap(options));
599
+ const expected = formatImportMap(createImportMap(createOptions));
426
600
  if ((existsSync(outPath) ? readFileSync(outPath, "utf8") : void 0) !== expected) {
427
601
  error(`${outPath} is stale`);
428
602
  setExitCode(1);
429
603
  return;
430
604
  }
431
- log(`${outPath} is up to date`);
605
+ if (!quiet) warn(`${outPath} is up to date`);
432
606
  return;
433
607
  }
434
- log(`Wrote ${writeImportMap({
435
- ...options,
436
- out: of
437
- })}`);
608
+ const written = writeImportMap(options);
609
+ if (!quiet) warn(`Wrote ${written}`);
438
610
  });
439
611
  //#endregion
440
612
  //#region src/mod.ts
@@ -459,6 +631,12 @@ const cli$1 = cli("importmapify").manifest({
459
631
  from: import.meta.url,
460
632
  files: ["package.json", "deno.json"]
461
633
  }).links().default(generateCommand).completions({ as: "flag" });
462
- if (import.meta.main) cli$1.run();
634
+ if (import.meta.main) cli$1.run({ help: {
635
+ hyperlinks: detectHyperlinkSupport(),
636
+ theme: (c) => ({
637
+ headerName: (input) => c.bold(c.underline(input)),
638
+ headerVersion: (input) => c.dim(c.underline(input))
639
+ })
640
+ } });
463
641
  //#endregion
464
642
  export { createImportMap, defineConfig, formatImportMap, packageEntries, writeImportMap };
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.1",
3
+ "version": "1.3.0",
4
4
  "description": "Expand package.json subpath-pattern imports into explicit Deno import map entries.",
5
5
  "keywords": [
6
6
  "deno",
@@ -65,6 +65,7 @@
65
65
  "volta:bump": "volta pin node@latest npm@11; node --version>.node-version; run bd"
66
66
  },
67
67
  "dependencies": {
68
+ "ansispeck": "^0.2.0",
68
69
  "dreamcli": "npm:@kjanat/dreamcli@^3.0.0-rc.11"
69
70
  },
70
71
  "devDependencies": {