importmapify 1.0.1 → 1.1.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
@@ -29,22 +29,24 @@ npm install importmapify
29
29
  npx importmapify --root . --out import_map.json
30
30
  ```
31
31
 
32
- | Flag | Alias | Meaning | Default |
33
- | --------------------------- | ----- | ----------------------------------------------------------------- | ------------------- |
34
- | `--root` | `-r` | Project root containing the manifest | current directory |
35
- | `--manifest` | `-m` | Manifest path, relative to root | `package.json` |
36
- | `--out` | `-o` | Output path, relative to root | `import_map.json` |
37
- | `--import key=value` | `-i` | Extra import entry; repeatable | none |
38
- | `--scope prefix::key=value` | `-s` | Scoped import override; repeatable | none |
39
- | `--condition name` | `-c` | Condition tried when a target is a conditional object; repeatable | `import`, `default` |
40
- | `--check` | | Exit 1 if the output file is stale, without writing it | off |
41
- | `--stdout` | | Print the map instead of writing it | off |
32
+ | Flag | Alias | Meaning | Default |
33
+ | --------------------------- | ----- | ------------------------------------------------------------------------- | ------------------- |
34
+ | `--root` | `-r` | Project root containing the manifest | current directory |
35
+ | `--manifest` | `-m` | Manifest path, relative to root | `package.json` |
36
+ | `--out` | `-o` | Output path resolved against root; relative, absolute, or `file://` URL | `import_map.json` |
37
+ | `--import key=value` | `-i` | Extra import entry; repeatable | none |
38
+ | `--package name=target` | `-p` | Package expanded to a conformant bare and trailing-slash pair; repeatable | none |
39
+ | `--scope prefix::key=value` | `-s` | Scoped import override; repeatable | none |
40
+ | `--condition name` | `-c` | Condition tried when a target is a conditional object; repeatable | `import`, `default` |
41
+ | `--ext name` | `-e` | Restrict pattern matches to these file extensions; repeatable | all files |
42
+ | `--check` | | Exit 1 if the output file is stale, without writing it | off |
43
+ | `--stdout` | | Print the map instead of writing it | off |
42
44
 
43
45
  Add global and test-scoped dependencies from the CLI:
44
46
 
45
47
  ```sh
46
48
  npx importmapify \
47
- --import 'dreamcli=jsr:@kjanat/dreamcli@^3' \
49
+ --package 'dreamcli=jsr:@kjanat/dreamcli@^3' \
48
50
  --scope './tests/::dreamcli/testkit=jsr:@kjanat/dreamcli@^3/testkit'
49
51
  ```
50
52
 
@@ -69,11 +71,13 @@ const out = writeImportMap({
69
71
  });
70
72
  ```
71
73
 
72
- | Export | Signature | Purpose |
73
- | ----------------- | -------------------------------------------------------- | -------------------------------------------------------------- |
74
- | `createImportMap` | `(options: CreateImportMapOptions) => ImportMapDocument` | Build the import map in memory. |
75
- | `formatImportMap` | `(map: ImportMapDocument) => string` | Serialize to the canonical sorted, tab-indented JSON text. |
76
- | `writeImportMap` | `(options: WriteImportMapOptions) => string` | Build, serialize, and write to disk; returns the written path. |
74
+ | Export | Signature | Purpose |
75
+ | ----------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------- |
76
+ | `defineConfig` | `(options: WriteImportMapOptions) => WriteImportMapOptions` | Identity helper that types a config object for export and reuse. |
77
+ | `createImportMap` | `(options: CreateImportMapOptions) => ImportMapDocument` | Build the import map in memory. |
78
+ | `formatImportMap` | `(map: ImportMapDocument) => string` | Serialize to the canonical sorted, tab-indented JSON text. |
79
+ | `writeImportMap` | `(options: WriteImportMapOptions) => string` | Build, serialize, and write to disk; returns the written path. |
80
+ | `packageEntries` | `(name: string, target: string) => Record<string, string>` | Build the bare and trailing-slash entry pair a package needs to resolve its subpaths. |
77
81
 
78
82
  `CreateImportMapOptions`:
79
83
 
@@ -82,14 +86,78 @@ const out = writeImportMap({
82
86
  | `root` | Base directory for the manifest, source targets, and rebasing. | required |
83
87
  | `manifest` | Manifest path, relative to `root`. | `package.json` |
84
88
  | `conditions` | Condition names tried, in order, against conditional targets (`{"import": ..., "default": ...}`). | `['import', 'default']` |
85
- | `additionalImports` | Extra entries merged in after manifest expansion; these win on key collision. | none |
89
+ | `packages` | Package specifiers mapped to targets, each expanded to a conformant bare and trailing-slash pair. | none |
90
+ | `additionalImports` | Extra entries merged in after manifest expansion and packages; these win on key collision. | none |
86
91
  | `scopes` | Scope prefixes mapped to scope-specific import overrides. | none |
87
92
  | `relativeTo` | Directory the written targets are rebased against. | `root` |
93
+ | `extensions` | File extensions, with or without a leading dot, that pattern targets may match. | all files |
88
94
 
89
- `WriteImportMapOptions` extends the above with `out`, the output path relative to `root`. `writeImportMap` rebases
95
+ `WriteImportMapOptions` extends the above with an optional `out`, defaulting to `import_map.json` like the CLI. It is
96
+ resolved against `root` and accepts a relative path, an absolute path, or a `file://` URL. `writeImportMap` rebases
90
97
  automatically against `out`'s directory, so a nested `out` (for example `.cache/maps/import_map.json`) still produces
91
98
  targets that resolve correctly from the map's own location.
92
99
 
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.
103
+
104
+ ### Recipes
105
+
106
+ Add dependencies with `packages`. Each entry expands to the bare specifier plus its conformant trailing-slash entry
107
+ (`jsr:/` or `npm:/`), which Deno needs to resolve subpaths:
108
+
109
+ ```ts
110
+ import { writeImportMap } from 'importmapify';
111
+
112
+ writeImportMap({
113
+ root: import.meta.dirname,
114
+ packages: {
115
+ dreamcli: 'jsr:@kjanat/dreamcli@^3',
116
+ chalk: 'npm:chalk@5',
117
+ },
118
+ });
119
+ // dreamcli, dreamcli/ -> jsr:/@kjanat/dreamcli@^3/, chalk, chalk/ -> npm:/chalk@5/
120
+ ```
121
+
122
+ Restrict pattern expansion to importable files with `extensions`, so a bare `./src/*` skips `.md`, `.json`, and other
123
+ non-modules:
124
+
125
+ ```ts
126
+ import { createImportMap } from 'importmapify';
127
+
128
+ createImportMap({ root: import.meta.dirname, extensions: ['ts', 'tsx'] });
129
+ ```
130
+
131
+ Build one conformant pair directly with `packageEntries`:
132
+
133
+ ```ts
134
+ import { packageEntries } from 'importmapify';
135
+
136
+ packageEntries('@std/async', 'jsr:@std/async@^1.0.0');
137
+ // { '@std/async': 'jsr:@std/async@^1.0.0', '@std/async/': 'jsr:/@std/async@^1.0.0/' }
138
+ ```
139
+
140
+ Type a reusable config with `defineConfig` and share it across calls:
141
+
142
+ ```ts
143
+ import { createImportMap, defineConfig, writeImportMap } from 'importmapify';
144
+
145
+ const config = defineConfig({ root: import.meta.dirname, packages: { chalk: 'npm:chalk@5' } });
146
+ const map = createImportMap(config);
147
+ const written = writeImportMap(config);
148
+ ```
149
+
150
+ `root`, `relativeTo`, and `out` accept a path or a `file://` URL, so `import.meta.url` needs no `.pathname`:
151
+
152
+ ```ts
153
+ import { writeImportMap } from 'importmapify';
154
+
155
+ writeImportMap({
156
+ root: new URL('..', import.meta.url),
157
+ out: new URL('./import_map.json', import.meta.url),
158
+ });
159
+ ```
160
+
93
161
  ### Project-local generator
94
162
 
95
163
  A checked-in wrapper keeps project-specific dependency and scope mappings in code:
@@ -102,7 +170,7 @@ import { writeImportMap } from 'importmapify';
102
170
  const output = writeImportMap({
103
171
  root: new URL('..', import.meta.url).pathname,
104
172
  out: 'import_map.json',
105
- additionalImports: {
173
+ packages: {
106
174
  dreamcli: 'jsr:@kjanat/dreamcli@^3',
107
175
  },
108
176
  scopes: {
@@ -161,15 +229,13 @@ the generated map is:
161
229
  "imports": {
162
230
  "#config": "./src/config.ts",
163
231
  "#lib/bytes": "./src/lib/bytes.ts",
164
- "#lib/bytes.ts": "./src/lib/bytes.ts",
165
- "#lib/codecs/hex": "./src/lib/codecs/hex.ts",
166
- "#lib/codecs/hex.ts": "./src/lib/codecs/hex.ts"
232
+ "#lib/codecs/hex": "./src/lib/codecs/hex.ts"
167
233
  }
168
234
  }
169
235
  ```
170
236
 
171
- A key with its own suffix, such as `#lib/*.js` targeting `./src/lib/*.ts`, produces both the renamed specifier and the
172
- real filename: `#lib/bytes.js` and `#lib/bytes.ts`, both pointing at `./src/lib/bytes.ts`.
237
+ Each matched file yields one specifier, the wildcard substituted into the key. A key with its own suffix, such as
238
+ `#lib/*.js` targeting `./src/lib/*.ts`, produces the renamed specifier `#lib/bytes.js` pointing at `./src/lib/bytes.ts`.
173
239
 
174
240
  ## Scope and constraints
175
241
 
@@ -180,7 +246,9 @@ real filename: `#lib/bytes.js` and `#lib/bytes.ts`, both pointing at `./src/lib/
180
246
  missing directory produces no entries.
181
247
  - Relative targets (`./...`, `../...`) are rebased against `relativeTo`. Bare specifiers, `npm:`/`jsr:`/`node:`
182
248
  specifiers, and absolute URLs pass through unchanged.
183
- - If exact and expanded entries produce the same key, the later manifest entry wins.
249
+ - When two manifest entries produce the same key, the more specific one wins, matching Node's subpath resolution: an
250
+ exact key beats a pattern, and a longer prefix before `*` beats a shorter one. Declaration order does not matter.
251
+ `additionalImports` and `packages` still override manifest entries.
184
252
  - Relative scope prefixes and targets are rebased against `relativeTo`; trailing scope slashes are preserved.
185
253
  - Import entries, scope prefixes, and scoped entries are sorted by UTF-16 code unit for stable diffs.
186
254
 
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.0.1",
4
+ "version": "1.1.0",
5
5
  "exports": "./src/mod.ts",
6
6
  "publish": {
7
7
  "include": [
@@ -21,7 +21,7 @@
21
21
  "tasks": {
22
22
  "build": {
23
23
  "description": "build for npm distribution",
24
- "command": "bunx tsdown",
24
+ "command": "bun run build",
25
25
  "files": ["src/**", "package.json", "bun.lock"],
26
26
  "output": ["dist/**", "deno.json"]
27
27
  },
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "importmapify": {
34
34
  "description": "generate importmap for deno",
35
- "command": "deno run --no-config -A scripts/generate-importmap.ts",
35
+ "command": "bun scripts/generate-importmap.ts",
36
36
  "files": ["package.json", "scripts/generate-importmap.ts", "src/**"],
37
37
  "output": ["import_map.json"]
38
38
  }
package/dist/mod.d.mts CHANGED
@@ -8,23 +8,30 @@ interface ImportMapDocument {
8
8
  }
9
9
  /** Options for creating an import map without writing it to disk. */
10
10
  interface CreateImportMapOptions {
11
- /** Project directory containing the package manifest. */
12
- readonly root: string;
11
+ /** Project directory containing the package manifest, as a path or `file://` URL. */
12
+ readonly root: string | URL;
13
13
  /** Manifest path relative to {@link root}. Defaults to `package.json`. */
14
14
  readonly manifest?: string;
15
15
  /** Conditional import keys to try in order. Defaults to `import`, then `default`. */
16
16
  readonly conditions?: readonly string[];
17
- /** Explicit entries merged after manifest imports, overriding duplicate keys. */
17
+ /** Package specifiers mapped to targets, each expanded to a conformant bare and trailing-slash pair. */
18
+ readonly packages?: Readonly<Record<string, string>>;
19
+ /** Explicit entries merged after manifest imports and packages, overriding duplicate keys. */
18
20
  readonly additionalImports?: Readonly<Record<string, string>>;
19
21
  /** Scope-specific import overrides keyed by scope prefix. */
20
22
  readonly scopes?: Readonly<Record<string, Readonly<Record<string, string>>>>;
21
- /** Directory against which relative targets are rebased. Defaults to {@link root}. */
22
- readonly relativeTo?: 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. */
26
+ readonly extensions?: readonly string[];
23
27
  }
24
28
  /** Options for creating and writing an import map. */
25
29
  interface WriteImportMapOptions extends CreateImportMapOptions {
26
- /** Output path relative to {@link CreateImportMapOptions.root | root}. */
27
- readonly out: string;
30
+ /**
31
+ * Output path, resolved against {@link CreateImportMapOptions.root | root}. Accepts a relative path, an
32
+ * absolute path, a `file://` URL string, or a {@link URL}. Defaults to `import_map.json`.
33
+ */
34
+ readonly out?: string | URL;
28
35
  }
29
36
  /**
30
37
  * Expand a package's exact and patterned `imports` into a Deno import map.
@@ -32,8 +39,9 @@ interface WriteImportMapOptions extends CreateImportMapOptions {
32
39
  * Pattern targets are matched against files below {@link CreateImportMapOptions.root | root}.
33
40
  * Conditional targets use the configured condition order, and additional imports are applied last.
34
41
  *
35
- * @example Generate entries for the current project.
42
+ * @example
36
43
  * ```ts
44
+ * // Generate entries for the current project.
37
45
  * import { createImportMap } from 'jsr:@kjanat/importmapify';
38
46
  *
39
47
  * const map = createImportMap({
@@ -51,8 +59,9 @@ declare function createImportMap(options: CreateImportMapOptions): ImportMapDocu
51
59
  /**
52
60
  * Serialize an import map as stable, tab-indented JSON with a trailing newline.
53
61
  *
54
- * @example Format an import map for stdout.
62
+ * @example
55
63
  * ```ts
64
+ * // Format an import map for stdout.
56
65
  * import { formatImportMap } from 'jsr:@kjanat/importmapify';
57
66
  *
58
67
  * const text = formatImportMap({
@@ -71,21 +80,58 @@ declare function formatImportMap(map: ImportMapDocument): string;
71
80
  *
72
81
  * Relative targets are automatically rebased from the project root to the output directory.
73
82
  *
74
- * @example Write the conventional Deno import map file.
83
+ * @example
75
84
  * ```ts
85
+ * // Write the conventional Deno import map file.
76
86
  * import { writeImportMap } from 'jsr:@kjanat/importmapify';
77
87
  *
78
- * const output = writeImportMap({
79
- * root: Deno.cwd(),
80
- * out: 'import_map.json',
81
- * });
88
+ * const output = writeImportMap({ root: Deno.cwd() });
82
89
  *
83
90
  * console.log(`Wrote ${output}`);
84
91
  * ```
85
92
  *
86
- * @param options Creation options plus the output path.
93
+ * @param options Creation options plus the optional output path.
87
94
  * @returns The absolute path of the written file.
88
95
  */
89
96
  declare function writeImportMap(options: WriteImportMapOptions): string;
97
+ /**
98
+ * Build the two import map entries a package needs to resolve both itself and its subpaths.
99
+ *
100
+ * Deno's `importMap` resolution requires a trailing-slash entry for subpath imports, and a
101
+ * `jsr:` or `npm:` trailing-slash target only resolves in the `jsr:/` or `npm:/` form.
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * import { packageEntries } from 'jsr:@kjanat/importmapify';
106
+ *
107
+ * packageEntries('@std/async', 'jsr:@std/async@^1.0.0');
108
+ * // { '@std/async': 'jsr:@std/async@^1.0.0', '@std/async/': 'jsr:/@std/async@^1.0.0/' }
109
+ * ```
110
+ *
111
+ * @param name Bare package specifier.
112
+ * @param target Exact target for {@link name}.
113
+ * @returns The bare entry and its trailing-slash subpath entry.
114
+ */
115
+ declare function packageEntries(name: string, target: string): Record<string, string>;
116
+ /**
117
+ * Type an import map configuration for export and reuse, then pass it to {@link createImportMap} or
118
+ * {@link writeImportMap}. Returns its input unchanged; it exists only for inference and autocomplete.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * import { defineConfig, writeImportMap } from 'jsr:@kjanat/importmapify';
123
+ *
124
+ * export const config = defineConfig({
125
+ * root: import.meta.dirname,
126
+ * packages: { dreamcli: 'jsr:@kjanat/dreamcli@^3' },
127
+ * });
128
+ *
129
+ * writeImportMap(config);
130
+ * ```
131
+ *
132
+ * @param options Import map configuration, with an optional `out`.
133
+ * @returns The same `options` value, typed as {@link WriteImportMapOptions}.
134
+ */
135
+ declare function defineConfig(options: WriteImportMapOptions): WriteImportMapOptions;
90
136
  //#endregion
91
- export { type CreateImportMapOptions, type ImportMapDocument, type WriteImportMapOptions, createImportMap, formatImportMap, writeImportMap };
137
+ export { type CreateImportMapOptions, type ImportMapDocument, type WriteImportMapOptions, createImportMap, defineConfig, formatImportMap, packageEntries, writeImportMap };
package/dist/mod.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { CLIError, cli, command, flag } from "dreamcli";
3
3
  import fs, { existsSync, readFileSync } from "node:fs";
4
- import path, { dirname, join } from "node:path";
4
+ import path, { dirname } from "node:path";
5
5
  import { cwd } from "node:process";
6
+ import { fileURLToPath } from "node:url";
6
7
  //#region src/expand.ts
7
8
  function escapeRegExp(value) {
8
9
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -61,10 +62,7 @@ function expandPattern(pattern, files) {
61
62
  for (const file of files) {
62
63
  const target = `${targetBase}${file}`;
63
64
  const wildcard = matcher.exec(target)?.groups?.wildcard;
64
- if (wildcard !== void 0) {
65
- imports[`${pattern.keyPrefix}${wildcard}${pattern.keySuffix}`] = target;
66
- imports[`${pattern.keyPrefix}${file}`] = target;
67
- }
65
+ if (wildcard !== void 0) imports[`${pattern.keyPrefix}${wildcard}${pattern.keySuffix}`] = target;
68
66
  }
69
67
  return imports;
70
68
  }
@@ -120,6 +118,15 @@ function rebaseTarget(root, relativeTo, target) {
120
118
  //#endregion
121
119
  //#region src/map.ts
122
120
  const DEFAULT_CONDITIONS = ["import", "default"];
121
+ const DEFAULT_OUT = "import_map.json";
122
+ const SCHEME_PREFIX = /^(jsr|npm):(?!\/)/;
123
+ function toPath(value) {
124
+ if (typeof value === "string") return value.startsWith("file://") ? fileURLToPath(value) : value;
125
+ return fileURLToPath(value.href);
126
+ }
127
+ function resolveOut(root, out) {
128
+ return path.resolve(toPath(root), toPath(out));
129
+ }
123
130
  function filesUnder(dir, prefix = "") {
124
131
  if (!fs.existsSync(dir)) return [];
125
132
  const files = [];
@@ -146,12 +153,58 @@ function readManifest(manifestPath) {
146
153
  if (!isRecord(parsed)) throw new Error(`Manifest at ${manifestPath} must be a JSON object`);
147
154
  return parsed;
148
155
  }
149
- function expandManifestImport(root, key, rawValue, conditions) {
156
+ function extensionFilter(extensions) {
157
+ const allowed = new Set(extensions.map((ext) => ext.startsWith(".") ? ext : `.${ext}`));
158
+ return (file) => allowed.has(path.extname(file));
159
+ }
160
+ function expandManifestImport(root, key, rawValue, { conditions, extensions }) {
150
161
  const value = typeof rawValue === "string" ? rawValue : resolveCondition(rawValue, conditions);
151
162
  if (value === void 0) return {};
152
163
  const pattern = parsePattern(key, value);
153
164
  if (pattern === void 0) return { [key]: value };
154
- return expandPattern(pattern, filesUnder(path.join(root, pattern.targetDirectory)));
165
+ const files = filesUnder(path.join(root, pattern.targetDirectory));
166
+ return expandPattern(pattern, extensions.length > 0 ? files.filter(extensionFilter(extensions)) : files);
167
+ }
168
+ function keyBaseLength(key) {
169
+ const star = key.indexOf("*");
170
+ return star === -1 ? key.length : star + 1;
171
+ }
172
+ /**
173
+ * Order two manifest import keys by Node's subpath specificity: an exact key beats a pattern, a longer
174
+ * prefix before `*` beats a shorter one, and a longer key breaks a remaining tie. Negative means `a` wins.
175
+ */
176
+ function keySpecificity(a, b) {
177
+ const aExact = !a.includes("*");
178
+ if (aExact !== !b.includes("*")) return aExact ? -1 : 1;
179
+ const baseDelta = keyBaseLength(b) - keyBaseLength(a);
180
+ return baseDelta === 0 ? b.length - a.length : baseDelta;
181
+ }
182
+ function expandManifest(root, relativeTo, manifestImports, expansion) {
183
+ const imports = {};
184
+ const source = {};
185
+ for (const [key, rawValue] of Object.entries(manifestImports)) for (const [specifier, target] of Object.entries(expandManifestImport(root, key, rawValue, expansion))) {
186
+ const incumbent = source[specifier];
187
+ if (incumbent === void 0 || keySpecificity(key, incumbent) < 0) {
188
+ imports[specifier] = rebaseTarget(root, relativeTo, target);
189
+ source[specifier] = key;
190
+ }
191
+ }
192
+ return imports;
193
+ }
194
+ function collectAdditional(options) {
195
+ const entries = {};
196
+ for (const [name, target] of Object.entries(options.packages ?? {})) Object.assign(entries, packageEntries(name, target));
197
+ Object.assign(entries, options.additionalImports ?? {});
198
+ return entries;
199
+ }
200
+ function buildScopes$1(root, relativeTo, rawScopes) {
201
+ const scopes = {};
202
+ for (const [scope, mappings] of Object.entries(rawScopes)) {
203
+ const rebasedMappings = {};
204
+ for (const [key, value] of Object.entries(mappings)) rebasedMappings[key] = rebaseTarget(root, relativeTo, value);
205
+ scopes[rebaseScopePrefix(root, relativeTo, scope)] = sortEntries(rebasedMappings);
206
+ }
207
+ return scopes;
155
208
  }
156
209
  function sortEntries(entries) {
157
210
  return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
@@ -166,8 +219,9 @@ function rebaseScopePrefix(root, relativeTo, scope) {
166
219
  * Pattern targets are matched against files below {@link CreateImportMapOptions.root | root}.
167
220
  * Conditional targets use the configured condition order, and additional imports are applied last.
168
221
  *
169
- * @example Generate entries for the current project.
222
+ * @example
170
223
  * ```ts
224
+ * // Generate entries for the current project.
171
225
  * import { createImportMap } from 'jsr:@kjanat/importmapify';
172
226
  *
173
227
  * const map = createImportMap({
@@ -182,22 +236,17 @@ function rebaseScopePrefix(root, relativeTo, scope) {
182
236
  * @returns A sorted import map document.
183
237
  */
184
238
  function createImportMap(options) {
185
- const manifest = readManifest(path.join(options.root, options.manifest ?? "package.json"));
239
+ const root = toPath(options.root);
240
+ const manifest = readManifest(path.join(root, options.manifest ?? "package.json"));
186
241
  const manifestImports = isRecord(manifest.imports) ? manifest.imports : {};
187
242
  const conditions = options.conditions !== void 0 && options.conditions.length > 0 ? options.conditions : DEFAULT_CONDITIONS;
188
- const relativeTo = options.relativeTo ?? options.root;
189
- const imports = {};
190
- for (const [key, rawValue] of Object.entries(manifestImports)) {
191
- const expanded = expandManifestImport(options.root, key, rawValue, conditions);
192
- for (const [specifier, target] of Object.entries(expanded)) imports[specifier] = rebaseTarget(options.root, relativeTo, target);
193
- }
194
- for (const [key, value] of Object.entries(options.additionalImports ?? {})) imports[key] = rebaseTarget(options.root, relativeTo, value);
195
- const scopes = {};
196
- for (const [scope, mappings] of Object.entries(options.scopes ?? {})) {
197
- const rebasedMappings = {};
198
- for (const [key, value] of Object.entries(mappings)) rebasedMappings[key] = rebaseTarget(options.root, relativeTo, value);
199
- scopes[rebaseScopePrefix(options.root, relativeTo, scope)] = sortEntries(rebasedMappings);
200
- }
243
+ const relativeTo = options.relativeTo === void 0 ? root : toPath(options.relativeTo);
244
+ const imports = expandManifest(root, relativeTo, manifestImports, {
245
+ conditions,
246
+ extensions: options.extensions ?? []
247
+ });
248
+ for (const [key, value] of Object.entries(collectAdditional(options))) imports[key] = rebaseTarget(root, relativeTo, value);
249
+ const scopes = buildScopes$1(root, relativeTo, options.scopes ?? {});
201
250
  const sortedImports = sortEntries(imports);
202
251
  const sortedScopes = Object.fromEntries(Object.entries(scopes).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
203
252
  return Object.keys(sortedScopes).length === 0 ? { imports: sortedImports } : {
@@ -208,8 +257,9 @@ function createImportMap(options) {
208
257
  /**
209
258
  * Serialize an import map as stable, tab-indented JSON with a trailing newline.
210
259
  *
211
- * @example Format an import map for stdout.
260
+ * @example
212
261
  * ```ts
262
+ * // Format an import map for stdout.
213
263
  * import { formatImportMap } from 'jsr:@kjanat/importmapify';
214
264
  *
215
265
  * const text = formatImportMap({
@@ -230,23 +280,21 @@ function formatImportMap(map) {
230
280
  *
231
281
  * Relative targets are automatically rebased from the project root to the output directory.
232
282
  *
233
- * @example Write the conventional Deno import map file.
283
+ * @example
234
284
  * ```ts
285
+ * // Write the conventional Deno import map file.
235
286
  * import { writeImportMap } from 'jsr:@kjanat/importmapify';
236
287
  *
237
- * const output = writeImportMap({
238
- * root: Deno.cwd(),
239
- * out: 'import_map.json',
240
- * });
288
+ * const output = writeImportMap({ root: Deno.cwd() });
241
289
  *
242
290
  * console.log(`Wrote ${output}`);
243
291
  * ```
244
292
  *
245
- * @param options Creation options plus the output path.
293
+ * @param options Creation options plus the optional output path.
246
294
  * @returns The absolute path of the written file.
247
295
  */
248
296
  function writeImportMap(options) {
249
- const out = path.join(options.root, options.out);
297
+ const out = resolveOut(options.root, options.out ?? "import_map.json");
250
298
  const relativeTo = options.relativeTo ?? path.dirname(out);
251
299
  const map = createImportMap({
252
300
  ...options,
@@ -256,12 +304,61 @@ function writeImportMap(options) {
256
304
  fs.writeFileSync(out, formatImportMap(map));
257
305
  return out;
258
306
  }
307
+ function directoryTarget(target) {
308
+ return (target.endsWith("/") ? target : `${target}/`).replace(SCHEME_PREFIX, "$1:/");
309
+ }
310
+ /**
311
+ * Build the two import map entries a package needs to resolve both itself and its subpaths.
312
+ *
313
+ * Deno's `importMap` resolution requires a trailing-slash entry for subpath imports, and a
314
+ * `jsr:` or `npm:` trailing-slash target only resolves in the `jsr:/` or `npm:/` form.
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * import { packageEntries } from 'jsr:@kjanat/importmapify';
319
+ *
320
+ * packageEntries('@std/async', 'jsr:@std/async@^1.0.0');
321
+ * // { '@std/async': 'jsr:@std/async@^1.0.0', '@std/async/': 'jsr:/@std/async@^1.0.0/' }
322
+ * ```
323
+ *
324
+ * @param name Bare package specifier.
325
+ * @param target Exact target for {@link name}.
326
+ * @returns The bare entry and its trailing-slash subpath entry.
327
+ */
328
+ function packageEntries(name, target) {
329
+ return {
330
+ [name]: target,
331
+ [`${name}/`]: directoryTarget(target)
332
+ };
333
+ }
334
+ /**
335
+ * Type an import map configuration for export and reuse, then pass it to {@link createImportMap} or
336
+ * {@link writeImportMap}. Returns its input unchanged; it exists only for inference and autocomplete.
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * import { defineConfig, writeImportMap } from 'jsr:@kjanat/importmapify';
341
+ *
342
+ * export const config = defineConfig({
343
+ * root: import.meta.dirname,
344
+ * packages: { dreamcli: 'jsr:@kjanat/dreamcli@^3' },
345
+ * });
346
+ *
347
+ * writeImportMap(config);
348
+ * ```
349
+ *
350
+ * @param options Import map configuration, with an optional `out`.
351
+ * @returns The same `options` value, typed as {@link WriteImportMapOptions}.
352
+ */
353
+ function defineConfig(options) {
354
+ return options;
355
+ }
259
356
  //#endregion
260
357
  //#region src/cli.ts
261
- function parseKeyValue(raw) {
358
+ function parseKeyValue(raw, flagName, code) {
262
359
  const eq = raw.indexOf("=");
263
- if (eq === -1) throw new CLIError(`--import expects key=value, got "${raw}"`, {
264
- code: "invalid-import-flag",
360
+ if (eq === -1) throw new CLIError(`--${flagName} expects key=value, got "${raw}"`, {
361
+ code,
265
362
  exitCode: 2
266
363
  });
267
364
  return [raw.slice(0, eq), raw.slice(eq + 1)];
@@ -279,34 +376,46 @@ function parseScope(raw) {
279
376
  raw.slice(eq + 1)
280
377
  ];
281
378
  }
379
+ function parseEntries(raws, flagName, code) {
380
+ const entries = {};
381
+ for (const raw of raws) {
382
+ const [key, value] = parseKeyValue(raw, flagName, code);
383
+ entries[key] = value;
384
+ }
385
+ return entries;
386
+ }
387
+ function buildScopes(raws) {
388
+ const scopes = {};
389
+ for (const raw of raws) {
390
+ const [scope, key, value] = parseScope(raw);
391
+ const mappings = scopes[scope];
392
+ if (mappings === void 0) scopes[scope] = { [key]: value };
393
+ else mappings[key] = value;
394
+ }
395
+ return scopes;
396
+ }
282
397
  /** DreamCLI command that writes, checks, or prints an expanded Deno import map. */
283
398
  const generateCommand = command("generate").description("Expand package.json subpath-pattern imports into a Deno import map.").flag("root", flag.path({
284
399
  mustExist: true,
285
400
  type: "directory"
286
- }).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("import_map.json").describe("Output path, relative to root.").alias("o")).flag("import", flag.array(flag.string()).describe("Additional import entry as key=value. Repeatable.").alias("i")).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 --import '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 }) => {
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 }) => {
287
402
  const { log, error, setExitCode } = out;
288
- const { check, stdout, root, manifest, condition: cdts, out: of, import: imf, scope: scf } = flags;
403
+ const { check, stdout, root, manifest, condition: cdts, out: of, import: imf, package: pkf, ext: exf, scope: scf } = flags;
289
404
  if (check && stdout) throw new CLIError("--check and --stdout are mutually exclusive", {
290
405
  code: "conflicting-flags",
291
406
  exitCode: 2
292
407
  });
293
- const outPath = join(root, of);
408
+ const outPath = resolveOut(root, of);
294
409
  const relativeTo = dirname(outPath);
295
- const additionalImports = Object.fromEntries((imf ?? []).map(parseKeyValue));
296
- const scopes = {};
297
- for (const raw of scf ?? []) {
298
- const [scope, key, value] = parseScope(raw);
299
- const mappings = scopes[scope];
300
- if (mappings === void 0) scopes[scope] = { [key]: value };
301
- else mappings[key] = value;
302
- }
303
410
  const options = {
304
411
  root,
305
412
  manifest,
306
413
  conditions: cdts,
307
- additionalImports,
308
- scopes,
309
- relativeTo
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
310
419
  };
311
420
  if (stdout) {
312
421
  log(formatImportMap(createImportMap(options)));
@@ -335,8 +444,9 @@ const generateCommand = command("generate").description("Expand package.json sub
335
444
  * Use the library API to create, format, or write deterministic import maps.
336
445
  * This module also runs the `importmapify` CLI when executed directly.
337
446
  *
338
- * @example Create an import map from the current package.
447
+ * @example
339
448
  * ```ts
449
+ * // Create an import map from the current package.
340
450
  * import { createImportMap } from 'jsr:@kjanat/importmapify';
341
451
  *
342
452
  * const importMap = createImportMap({ root: Deno.cwd() });
@@ -351,4 +461,4 @@ const cli$1 = cli("importmapify").manifest({
351
461
  }).links().default(generateCommand).completions({ as: "flag" });
352
462
  if (import.meta.main) cli$1.run();
353
463
  //#endregion
354
- export { createImportMap, formatImportMap, writeImportMap };
464
+ export { createImportMap, defineConfig, formatImportMap, packageEntries, writeImportMap };
package/import_map.json CHANGED
@@ -2,19 +2,25 @@
2
2
  "imports": {
3
3
  "#deno": "./deno.json",
4
4
  "#pkg": "./package.json",
5
- "#src/cli.ts": "./src/cli.ts",
6
- "#src/expand.ts": "./src/expand.ts",
7
- "#src/map.ts": "./src/map.ts",
8
- "#src/mod.ts": "./src/mod.ts",
9
- "@types/bun": "npm:bun-types@^1.3.14",
10
- "bun": "npm:bun-types@^1.3.14",
11
- "bun:test": "npm:bun-types@^1.3.14/test.d.ts",
12
- "dreamcli": "jsr:@kjanat/dreamcli@^3.0.0-rc.11",
13
- "dreamcli/": "jsr:@kjanat/dreamcli@^3.0.0-rc.11/"
5
+ "#src/cli": "./src/cli.ts",
6
+ "#src/expand": "./src/expand.ts",
7
+ "#src/map": "./src/map.ts",
8
+ "#src/mod": "./src/mod.ts",
9
+ "@types/bun": "npm:@types/bun@1.3.14",
10
+ "ansispeck": "jsr:@kjanat/ansispeck@0.2.0",
11
+ "ansispeck/": "jsr:/@kjanat/ansispeck@0.2.0/",
12
+ "bun": "npm:bun-types@1.3.14",
13
+ "bun:test": "npm:bun-types@1.3.14/test.d.ts",
14
+ "dreamcli": "jsr:@kjanat/dreamcli@3.0.0-rc.11",
15
+ "dreamcli/": "jsr:/@kjanat/dreamcli@3.0.0-rc.11/",
16
+ "sort-package-json": "npm:sort-package-json@4.0.0",
17
+ "sort-package-json/": "npm:/sort-package-json@4.0.0/",
18
+ "tsdown": "npm:tsdown@0.22.8",
19
+ "tsdown/": "npm:/tsdown@0.22.8/"
14
20
  },
15
21
  "scopes": {
16
22
  "./tests/": {
17
- "dreamcli/testkit": "jsr:@kjanat/dreamcli@^3.0.0-rc.11/testkit"
23
+ "dreamcli/testkit": "jsr:@kjanat/dreamcli@3.0.0-rc.11/testkit"
18
24
  }
19
25
  }
20
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "importmapify",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Expand package.json subpath-pattern imports into explicit Deno import map entries.",
5
5
  "keywords": [
6
6
  "deno",
@@ -30,7 +30,7 @@
30
30
  "imports": {
31
31
  "#pkg": "./package.json",
32
32
  "#deno": "./deno.json",
33
- "#src/*": "./src/*"
33
+ "#src/*": "./src/*.ts"
34
34
  },
35
35
  "exports": {
36
36
  ".": {
@@ -52,17 +52,17 @@
52
52
  "fmt": "biome check --fix 2>/dev/null; dprint fmt",
53
53
  "jsr:publish": "bunx jsr publish --allow-dirty",
54
54
  "lint": "biome lint",
55
- "prepack": "bun --bun bd -l error && bun scripts/generate-importmap.ts",
56
- "prepublishOnly": "[ -n \"${GITHUB_ACTIONS:-}\" ] || { printf '%s\\n' 'manual npm publish blocked; use release workflow for provenance' >&2; exit 1; }",
57
- "runbin": "node \"$(jq -r '.bin.[]' package.json)\"",
58
- "test": "bun test --coverage",
59
- "test:coverage": "bun test --coverage",
60
- "test:smoke": "node scripts/smoke.mjs",
61
- "test:watch": "bun test --watch",
62
- "typecheck": "bunx @typescript/native --noEmit",
55
+ "prepack": "run -q build -l error",
56
+ "prepare": "./scripts/generate-importmap.ts",
57
+ "runbin": "node \"$(jq -r '.bin.importmapify' package.json)\"",
58
+ "test": "bun test",
59
+ "test:coverage": "run -q test --coverage",
60
+ "test:smoke": "./scripts/smoke.mjs",
61
+ "test:watch": "run -q test --watch",
62
+ "typecheck": "run @typescript/native --noEmit",
63
63
  "url:jsr": "printf 'url=https://jsr.io/%s@%s\n' \"$(jq -r '.name' deno.json)\" \"$(jq -r '.version' deno.json)\"",
64
64
  "url:npm": "printf 'url=https://npm.im/package/%s/v/%s\n' \"$(jq -r '.name' package.json)\" \"$(jq -r '.version' package.json)\"",
65
- "volta:bump": "volta pin node@latest npm@11; node --version>.node-version; bun bd"
65
+ "volta:bump": "volta pin node@latest npm@11; node --version>.node-version; run bd"
66
66
  },
67
67
  "dependencies": {
68
68
  "dreamcli": "npm:@kjanat/dreamcli@^3.0.0-rc.11"
@@ -76,7 +76,9 @@
76
76
  "biome": "npm:@biomejs/biome@^2.5.4",
77
77
  "dprint": "^0.55.2",
78
78
  "publint": "^0.3.21",
79
+ "runner-run": "^0.20.0",
79
80
  "sort-package-json": "^4.0.0",
81
+ "tombi": "^1.2.1",
80
82
  "tsdown": "^0.22.7",
81
83
  "typescript": ">=6,<7",
82
84
  "unplugin-unused": "^0.5.7"