@theholocron/datapad 5.0.0-alpha.20 → 5.0.0-alpha.201

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
@@ -43,13 +43,23 @@ export const defineConfig = createDefineConfig<TasksConfig>();
43
43
 
44
44
  ## API
45
45
 
46
- ### `loadConfigFile<T>({ cwd, name, extensions? })`
47
-
48
- Loads the first `<name>.config.<ext>` found in `cwd`. Probe order is
49
- **TS-first** — `.ts` → `.js` → `.mjs` → `.cjs` → `.json` — overridable via
50
- `extensions`. Returns `{ config, filepath } | null`. `null` means "no such
51
- file"; a file that exists but cannot be parsed / loaded / has no default
52
- export throws `ConfigFileError`.
46
+ ### `loadConfigFile<T>({ cwd, name, extensions?, walkUp? })`
47
+
48
+ Loads the first `<name>.config.<ext>` found starting at `cwd`. Probe order
49
+ is **TS-first** — `.ts` → `.js` → `.mjs` → `.cjs` → `.json` — overridable
50
+ via `extensions`. Returns `{ config, filepath } | null`. `null` means "no
51
+ such file anywhere searched"; a file that exists but cannot be parsed /
52
+ loaded / has no default export throws `ConfigFileError`.
53
+
54
+ `walkUp: true` (off by default) adds the "nearest config" search
55
+ `cosmiconfig`/`postcss`/`stylelint`/ESLint's flat config all do: when `cwd`
56
+ has no match, check its parent, then its parent's parent, and so on —
57
+ stopping at the first directory containing a `.git` entry (that directory
58
+ is still searched before stopping) or the filesystem root. Never wanders
59
+ past a repo boundary into an unrelated enclosing directory. `loadLayered`
60
+ passes its own `walkUp` through to both the dedicated-file and
61
+ fallback-file lookups. Built on `@theholocron/fs-utils`'s generic
62
+ `findUpward`/`hasGitEntry` — not reimplemented here.
53
63
 
54
64
  ### `loadConfigFromContent<T>({ dir, content, name, extension })`
55
65
 
@@ -59,7 +69,8 @@ the exact same internals `loadConfigFile` uses for a file it discovered
59
69
  itself. `dir` matters: it's what upward `node_modules` resolution sees, so
60
70
  content that does `import { defineConfig } from "@theholocron/cli"` only
61
71
  resolves if `dir` sits under a tree where that package is a real
62
- dependency. Callers own creating and cleaning up `dir`. Returns
72
+ dependency — [`withConfigSandbox`](#withconfigsandbox-nodemodules-root-prefix--run) prepares
73
+ one. Callers own creating and cleaning up `dir`. Returns
63
74
  `{ config, filepath }`; throws `ConfigFileError` the same way
64
75
  `loadConfigFile` does on a parse/load failure.
65
76
 
@@ -67,7 +78,26 @@ Built for `@theholocron/sentinel`'s `validateConfig()` — it fetches a
67
78
  repo's `holocron.config.*` from GitHub's API, not a local checkout, but
68
79
  still needs the fetched content to execute as a real module.
69
80
 
70
- ### `loadLayered<T>({ cwd, name, fallback?, extensions? })`
81
+ ### `readConfigSource({ name, fetch, extensions? })`
82
+
83
+ `loadConfigFile`'s extension probe for a config that lives somewhere other
84
+ than the local disk. Asks `fetch(path)` for `<name>.config.<ext>` in
85
+ extension-priority order (`fetch` returns the content, or `undefined` when
86
+ the file isn't there, and throws for any other failure) and returns the first
87
+ hit as `{ path, extension, content }`, or `undefined`. Hand the result to
88
+ `loadConfigFromContent`.
89
+
90
+ ### `withConfigSandbox({ nodeModules, root?, prefix? }, run)`
91
+
92
+ Runs `run(dir)` with a throwaway directory ready to be
93
+ `loadConfigFromContent`'s `dir`: `node_modules` symlinked to
94
+ `options.nodeModules` (so the config's package imports resolve) and a
95
+ `{"type":"module"}` `package.json` (so `tsx` doesn't default the files to
96
+ CommonJS and `require()` a pure-ESM dependency). Created under the OS temp dir
97
+ by default — read-only deployments can't write inside a package — and removed
98
+ afterwards, also when `run` throws.
99
+
100
+ ### `loadLayered<T>({ cwd, name, fallback?, extensions?, walkUp?, coerce? })`
71
101
 
72
102
  `<name>.config.*` layered over the `[fallback.key]` of
73
103
  `<fallback.file>.config.*`. Either, both, or neither may be present; the
@@ -75,11 +105,27 @@ dedicated file wins on conflict (`vitest`-over-`vite`). Returns
75
105
  `{ config, filepath, sources } | null` — `sources` lists the absolute
76
106
  paths actually merged, lowest priority first.
77
107
 
108
+ - `fallback.preloaded` — the `<fallback.file>.config.*` the caller has
109
+ already loaded (`null`: looked for, not there), used as is instead of loading
110
+ it again.
111
+ - `coerce(raw)` — turns each layer's raw value into the shape being merged
112
+ before the merge (a bare array standing for `{ tasks: [...] }`, say);
113
+ returning `undefined` treats the layer as absent.
114
+
115
+ ### `mergeLayers<T>(layers)`
116
+
117
+ The merge `loadLayered` uses, for layers loaded some other way: deep-merges
118
+ `layers` lowest priority first, skipping `undefined`, and returns a copy — or
119
+ `undefined` when there is nothing to merge.
120
+
78
121
  ### `mergeConfig<T>(base, override)`
79
122
 
80
123
  Deep-merge, `vite`-style: plain objects merge recursively, arrays
81
124
  concatenate (`base` first), `undefined` in `override` is skipped, every
82
- other `override` value replaces `base`. Neither input is mutated.
125
+ other `override` value replaces `base`. Neither input is mutated. A
126
+ re-export of `@theholocron/object-utils`'s `deepMerge` under this
127
+ package's established name — the merge logic itself has no awareness of
128
+ configs at all, so it lives there instead of being duplicated here.
83
129
 
84
130
  ### `createDefineConfig<T>()`
85
131
 
package/dist/index.d.mts CHANGED
@@ -1,4 +1,9 @@
1
- //#region src/define.d.ts
1
+ import { deepMerge as mergeConfig } from "@theholocron/object-utils";
2
+ //#region src/default-extensions/default-extensions.d.ts
3
+ /** Default extension probe order, highest priority first. */
4
+ declare const DEFAULT_EXTENSIONS: readonly ["ts", "js", "mjs", "cjs", "json"];
5
+ //#endregion
6
+ //#region src/define/define.d.ts
2
7
  /**
3
8
  * Build a typed identity `defineConfig` for a specific config shape.
4
9
  *
@@ -13,7 +18,7 @@
13
18
  */
14
19
  declare function createDefineConfig<T>(): <C extends T>(config: C) => C;
15
20
  //#endregion
16
- //#region src/errors.d.ts
21
+ //#region src/errors/errors.d.ts
17
22
  /**
18
23
  * Thrown when a config file is found but cannot be loaded — malformed
19
24
  * JSON, a syntax error in a `.ts` / `.js` module, or no usable default
@@ -29,63 +34,40 @@ declare class ConfigFileError extends Error {
29
34
  filepath?: string | undefined);
30
35
  }
31
36
  //#endregion
32
- //#region src/load.d.ts
33
- /**
34
- * Discover and load `<name>.config.*` files, or load one from content that
35
- * didn't come from a file at all ({@link loadConfigFromContent}).
36
- * Holocron-agnostic — no schema, no validation, no defaults. Consumers
37
- * layer those on top.
38
- *
39
- * Probe order is **TS-first**: `.ts` → `.js` → `.mjs` → `.cjs` → `.json`.
40
- * TS is loaded through `tsx`'s `tsImport` (a runtime dependency) so a
41
- * typed `defineConfig` file works with no build step.
42
- */
43
- /** Default extension probe order, highest priority first. */
44
- declare const DEFAULT_EXTENSIONS: readonly ["ts", "js", "mjs", "cjs", "json"];
37
+ //#region src/load-config-file/load-config-file.type.d.ts
45
38
  interface LoadConfigFileOptions {
46
- /** Directory to look in. */
39
+ /** Directory to start looking in. */
47
40
  cwd: string;
48
41
  /** Base name — `"holocron"` resolves `holocron.config.{ts,js,mjs,cjs,json}`. */
49
42
  name: string;
50
43
  /** Override the extension probe order. */
51
44
  extensions?: readonly string[];
45
+ /**
46
+ * Walk upward through ancestor directories when `cwd` itself has no
47
+ * match, stopping at the first directory containing a `.git` entry (that
48
+ * directory is still searched before stopping) or the filesystem root.
49
+ * Off by default — exact-`cwd`-only, unchanged behavior for any existing
50
+ * caller that doesn't pass this.
51
+ */
52
+ walkUp?: boolean;
52
53
  }
53
54
  interface Loaded<T> {
54
55
  config: T;
55
56
  /** Absolute path of the file the value came from. */
56
57
  filepath: string;
57
58
  }
59
+ //#endregion
60
+ //#region src/load-config-file/load-config-file.d.ts
58
61
  /**
59
- * Load the first `<name>.config.<ext>` that exists in `cwd`. Returns
60
- * `null` when none is present; throws {@link ConfigFileError} when a file
61
- * exists but cannot be loaded.
62
+ * Load the first `<name>.config.<ext>` found starting at `cwd` — just
63
+ * `cwd` itself by default, or walking up through ancestor directories when
64
+ * `walkUp` is set (see the module doc comment for the stop conditions).
65
+ * Returns `null` when none is present anywhere searched; throws
66
+ * {@link ConfigFileError} when a file exists but cannot be loaded.
62
67
  */
63
68
  declare function loadConfigFile<T>(opts: LoadConfigFileOptions): Promise<Loaded<T> | null>;
64
- interface LoadLayeredOptions extends LoadConfigFileOptions {
65
- /**
66
- * When no dedicated `<name>.config.*` exists — or in addition to it —
67
- * read `<file>.config.*` and take its `[key]`. The dedicated file (if
68
- * any) is merged on top.
69
- */
70
- fallback?: {
71
- file: string;
72
- key: string;
73
- };
74
- }
75
- interface LayeredResult<T> {
76
- config: T;
77
- /** The dedicated file path if present, else the fallback file path, else `null`. */
78
- filepath: string | null;
79
- /** Absolute paths actually merged, lowest priority first. */
80
- sources: string[];
81
- }
82
- /**
83
- * `<name>.config.*` layered over the `[fallback.key]` of
84
- * `<fallback.file>.config.*`. Either, both, or neither may be present;
85
- * the dedicated file wins on conflict (vitest-over-vite). Returns `null`
86
- * when neither source resolves.
87
- */
88
- declare function loadLayered<T>(opts: LoadLayeredOptions): Promise<LayeredResult<T> | null>;
69
+ //#endregion
70
+ //#region src/load-config-from-content/load-config-from-content.type.d.ts
89
71
  interface LoadConfigFromContentOptions {
90
72
  /**
91
73
  * Directory to write `<name>.config.<extension>` into before loading —
@@ -103,6 +85,8 @@ interface LoadConfigFromContentOptions {
103
85
  /** Which of `DEFAULT_EXTENSIONS` `content` is — determines how it's interpreted (ts/js/mjs/cjs parsed as a module, json as `JSON.parse`). */
104
86
  extension: string;
105
87
  }
88
+ //#endregion
89
+ //#region src/load-config-from-content/load-config-from-content.d.ts
106
90
  /**
107
91
  * Load config from content that didn't come from a file already on disk —
108
92
  * writes it to `<dir>/<name>.config.<extension>` first, then loads it
@@ -115,17 +99,131 @@ interface LoadConfigFromContentOptions {
115
99
  */
116
100
  declare function loadConfigFromContent<T>(opts: LoadConfigFromContentOptions): Promise<Loaded<T>>;
117
101
  //#endregion
118
- //#region src/merge.d.ts
102
+ //#region src/load-layered/load-layered.type.d.ts
103
+ interface LoadLayeredOptions<T = unknown> extends LoadConfigFileOptions {
104
+ /**
105
+ * When no dedicated `<name>.config.*` exists — or in addition to it —
106
+ * read `<file>.config.*` and take its `[key]`. The dedicated file (if
107
+ * any) is merged on top.
108
+ */
109
+ fallback?: {
110
+ file: string;
111
+ key: string;
112
+ /**
113
+ * The `<file>.config.*` already loaded by the caller (`null`: it was
114
+ * looked for and isn't there). Used as is instead of loading it again;
115
+ * leave it `undefined` to have `loadLayered` find and load it.
116
+ */
117
+ preloaded?: Loaded<object> | null;
118
+ };
119
+ /**
120
+ * Turns each layer's raw value — the dedicated file's default export, and
121
+ * the fallback's `[key]` — into the shape being merged, before the merge.
122
+ * Return `undefined` to treat the layer as absent. For configs that accept
123
+ * shorthand (a bare array standing for `{ tasks: [...] }`).
124
+ */
125
+ coerce?: (raw: unknown) => T | undefined;
126
+ }
127
+ interface LayeredResult<T> {
128
+ config: T;
129
+ /** The dedicated file path if present, else the fallback file path, else `null`. */
130
+ filepath: string | null;
131
+ /** Absolute paths actually merged, lowest priority first. */
132
+ sources: string[];
133
+ }
134
+ //#endregion
135
+ //#region src/load-layered/load-layered.d.ts
136
+ /**
137
+ * `<name>.config.*` layered over the `[fallback.key]` of
138
+ * `<fallback.file>.config.*`. Either, both, or neither may be present;
139
+ * the dedicated file wins on conflict (vitest-over-vite). Returns `null`
140
+ * when neither source resolves.
141
+ */
142
+ declare function loadLayered<T>(opts: LoadLayeredOptions<T>): Promise<LayeredResult<T> | null>;
143
+ //#endregion
144
+ //#region src/merge-layers/merge-layers.d.ts
145
+ /**
146
+ * Deep-merges config layers, lowest priority first (a later layer wins on
147
+ * scalars; arrays concatenate; nested objects merge). `undefined` layers are
148
+ * skipped. Returns `undefined` when there is no layer to merge. The result is
149
+ * a copy for object layers — it never aliases a layer.
150
+ *
151
+ * The one merge `loadLayered` and every caller layering configs it loaded
152
+ * some other way (content from an API, say) use, so layering means the same
153
+ * thing everywhere.
154
+ */
155
+ declare function mergeLayers<T>(layers: readonly (T | undefined)[]): T | undefined;
156
+ //#endregion
157
+ //#region src/read-config-source/read-config-source.type.d.ts
158
+ /** A config file's content fetched from somewhere other than the local disk. */
159
+ interface ConfigSource {
160
+ /** The file name probed, e.g. `holocron.config.ts` — relative, as `fetch` was asked for it. */
161
+ path: string;
162
+ /** Which extension matched — what {@link loadConfigFromContent} needs as `extension`. */
163
+ extension: string;
164
+ /** The raw file content. */
165
+ content: string;
166
+ }
167
+ interface ReadConfigSourceOptions {
168
+ /** Base name — `"holocron"` probes `holocron.config.{ts,js,mjs,cjs,json}`. */
169
+ name: string;
170
+ /**
171
+ * Fetches one file by relative path (`holocron.config.ts`): its content, or
172
+ * `undefined` when it doesn't exist. Throw for anything else (a network
173
+ * failure, a permission error) — that propagates rather than reading as "absent".
174
+ */
175
+ fetch: (path: string) => Promise<string | undefined>;
176
+ /** Override the extension probe order. */
177
+ extensions?: readonly string[];
178
+ }
179
+ //#endregion
180
+ //#region src/read-config-source/read-config-source.d.ts
181
+ /**
182
+ * {@link probeExtensions} for a config that lives somewhere other than the
183
+ * local disk (a GitHub API, a git blob): probes `<name>.config.<ext>` in
184
+ * extension-priority order through `options.fetch`, returning the first file
185
+ * that exists, or `undefined` when none does. Pass the result to
186
+ * `loadConfigFromContent` to execute it exactly as a local file would be.
187
+ */
188
+ declare function readConfigSource(options: ReadConfigSourceOptions): Promise<ConfigSource | undefined>;
189
+ //#endregion
190
+ //#region src/with-config-sandbox/with-config-sandbox.type.d.ts
191
+ interface ConfigSandboxOptions {
192
+ /**
193
+ * A real `node_modules` directory to make visible from the sandbox. A config
194
+ * that does `import { defineConfig } from "@theholocron/cli"` only resolves
195
+ * if the directory it sits in has that package reachable by Node's upward
196
+ * `node_modules` search, so the sandbox gets a symlink to this one (not a
197
+ * copy — nothing is duplicated).
198
+ */
199
+ nodeModules: string;
200
+ /** Where to create the sandbox. Defaults to the OS temp dir. */
201
+ root?: string;
202
+ /** Prefix for the sandbox directory's name. */
203
+ prefix?: string;
204
+ }
205
+ //#endregion
206
+ //#region src/with-config-sandbox/with-config-sandbox.d.ts
119
207
  /**
120
- * Deep-merge two config objects, `vite`-style:
208
+ * Runs `run` with a throwaway directory prepared for
209
+ * {@link loadConfigFromContent}'s `dir`, and removes it afterwards (also when
210
+ * `run` throws).
211
+ *
212
+ * The directory is made for a TS/JS config that imports packages:
121
213
  *
122
- * - plain objects merge recursively
123
- * - arrays concatenate (`base` first, then `override`)
124
- * - an `undefined` value in `override` is skipped (keeps `base`)
125
- * - every other `override` value replaces `base`
214
+ * - `node_modules` is a symlink to `options.nodeModules`, so those imports
215
+ * resolve as if the config sat next to that directory.
216
+ * - A `package.json` of `{"type":"module"}` is written into it. Node decides
217
+ * whether a `.ts`/`.js` file is ESM or CommonJS from the nearest ancestor
218
+ * `package.json`'s `type`, defaulting to CommonJS — and `tsx`'s loader,
219
+ * correctly honouring that, would then `require()` a pure-ESM dependency
220
+ * such as `@theholocron/cli`'s `dist/index.mjs`, which Node rejects outright
221
+ * ("require() of ES Module … not supported").
126
222
  *
127
- * Neither input is mutated.
223
+ * The directory lives under the OS temp dir by default rather than inside any
224
+ * package: read-only deployments (Vercel's Node.js Functions ship the bundle
225
+ * read-only under `/var/task`) can't create a directory there.
128
226
  */
129
- declare function mergeConfig<T>(base: T, override: unknown): T;
227
+ declare function withConfigSandbox<R>(options: ConfigSandboxOptions, run: (dir: string) => Promise<R>): Promise<R>;
130
228
  //#endregion
131
- export { ConfigFileError, DEFAULT_EXTENSIONS, type LayeredResult, type LoadConfigFileOptions, type LoadConfigFromContentOptions, type LoadLayeredOptions, type Loaded, createDefineConfig, loadConfigFile, loadConfigFromContent, loadLayered, mergeConfig };
229
+ export { ConfigFileError, type ConfigSandboxOptions, type ConfigSource, DEFAULT_EXTENSIONS, type LayeredResult, type LoadConfigFileOptions, type LoadConfigFromContentOptions, type LoadLayeredOptions, type Loaded, type ReadConfigSourceOptions, createDefineConfig, loadConfigFile, loadConfigFromContent, loadLayered, mergeConfig, mergeLayers, readConfigSource, withConfigSandbox };
package/dist/index.mjs CHANGED
@@ -1,7 +1,21 @@
1
- import { readFile, stat, writeFile } from "node:fs/promises";
1
+ import { findUpward, hasGitEntry } from "@theholocron/fs-utils";
2
2
  import { join } from "node:path";
3
+ import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
3
4
  import { pathToFileURL } from "node:url";
4
- //#region src/define.ts
5
+ import { errorMessage } from "@theholocron/misc-utils";
6
+ import { deepMerge, deepMerge as mergeConfig } from "@theholocron/object-utils";
7
+ import { tmpdir } from "node:os";
8
+ //#region src/default-extensions/default-extensions.ts
9
+ /** Default extension probe order, highest priority first. */
10
+ const DEFAULT_EXTENSIONS = [
11
+ "ts",
12
+ "js",
13
+ "mjs",
14
+ "cjs",
15
+ "json"
16
+ ];
17
+ //#endregion
18
+ //#region src/define/define.ts
5
19
  /**
6
20
  * Build a typed identity `defineConfig` for a specific config shape.
7
21
  *
@@ -18,7 +32,7 @@ function createDefineConfig() {
18
32
  return (config) => config;
19
33
  }
20
34
  //#endregion
21
- //#region src/errors.ts
35
+ //#region src/errors/errors.ts
22
36
  /**
23
37
  * Thrown when a config file is found but cannot be loaded — malformed
24
38
  * JSON, a syntax error in a `.ts` / `.js` module, or no usable default
@@ -34,36 +48,93 @@ var ConfigFileError = class extends Error {
34
48
  }
35
49
  };
36
50
  //#endregion
37
- //#region src/merge.ts
51
+ //#region src/is-file/is-file.ts
52
+ async function isFile(path) {
53
+ try {
54
+ return (await stat(path)).isFile();
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+ //#endregion
60
+ //#region src/load-file/load-file.ts
38
61
  /**
39
- * Deep-merge two config objects, `vite`-style:
40
- *
41
- * - plain objects merge recursively
42
- * - arrays concatenate (`base` first, then `override`)
43
- * - an `undefined` value in `override` is skipped (keeps `base`)
44
- * - every other `override` value replaces `base`
45
- *
46
- * Neither input is mutated.
62
+ * The actual file-loading mechanics `load.ts`'s exported functions share:
63
+ * JSON parsing, dynamic `import()` (TS via `tsx`'s persistent ESM loader,
64
+ * JS/MJS/CJS natively), and unwrapping the CJS/ESM default-export
65
+ * double-wrap `tsx`'s transform produces. Kept separate from `load.ts`'s
66
+ * own orchestration (which directory, which extension order, upward
67
+ * search) — this file only knows "given one exact filepath and
68
+ * extension, load it," always throwing {@link ConfigFileError} on failure.
47
69
  */
48
- function mergeConfig(base, override) {
49
- if (!isPlainObject(base) || !isPlainObject(override)) return override === void 0 ? base : override;
50
- const out = { ...base };
51
- for (const [key, value] of Object.entries(override)) {
52
- if (value === void 0) continue;
53
- const prev = out[key];
54
- if (Array.isArray(prev) && Array.isArray(value)) out[key] = [...prev, ...value];
55
- else if (isPlainObject(prev) && isPlainObject(value)) out[key] = mergeConfig(prev, value);
56
- else out[key] = value;
70
+ async function loadFile(filepath, ext) {
71
+ if (ext === "json") return loadJson(filepath);
72
+ return extractDefault(filepath, await importModule(filepath, ext));
73
+ }
74
+ async function loadJson(filepath) {
75
+ const text = await readFile(filepath, "utf8");
76
+ try {
77
+ return JSON.parse(text);
78
+ } catch (err) {
79
+ throw new ConfigFileError(`${filepath} is not valid JSON: ${errorMessage(err)}`, filepath);
80
+ }
81
+ }
82
+ let tsRegistered;
83
+ async function registerTsx() {
84
+ const { register } = await import("tsx/esm/api");
85
+ register();
86
+ }
87
+ /**
88
+ * Import `filepath`. For `.ts`, `tsx`'s ESM loader is registered first, once
89
+ * per process (lazily — only when a `.ts` file is actually loaded).
90
+ * `register()` is used over `tsImport()` because a single run may load
91
+ * several TS files (`loadLayered` reads a dedicated file *and* a parent
92
+ * file) and `tsImport`'s one-off register/unregister cycle is not reentrant.
93
+ */
94
+ async function importModule(filepath, ext) {
95
+ if (ext === "ts") {
96
+ tsRegistered ??= registerTsx();
97
+ await tsRegistered;
57
98
  }
58
- return out;
99
+ try {
100
+ return await import(pathToFileURL(filepath).href);
101
+ } catch (err) {
102
+ throw new ConfigFileError(`could not load ${filepath}: ${errorMessage(err)}`, filepath);
103
+ }
104
+ }
105
+ /**
106
+ * Unwrap `export default`. `tsx` CJS-transforms `export default x` into
107
+ * `exports.default = x`, which dynamic import wraps as
108
+ * `{ default: { __esModule: true, default: x } }` — strip the extra layer
109
+ * when present so ESM and CJS outputs both resolve.
110
+ */
111
+ function extractDefault(filepath, mod) {
112
+ const outer = mod.default;
113
+ const raw = outer?.__esModule === true ? outer.default : outer;
114
+ if (raw === void 0 || raw === null) throw new ConfigFileError(`${filepath} must have a default export (use \`export default defineConfig({…})\`)`, filepath);
115
+ return raw;
59
116
  }
60
- function isPlainObject(value) {
61
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
62
- const proto = Object.getPrototypeOf(value);
63
- return proto === Object.prototype || proto === null;
117
+ //#endregion
118
+ //#region src/probe-extensions/probe-extensions.ts
119
+ /**
120
+ * Probe `dir` for `<name>.config.<ext>` in extension-priority order,
121
+ * loading and returning the first match. The one piece of per-directory
122
+ * orchestration `loadConfigFile` needs on both its non-walking and
123
+ * walking paths — pulled out so it can be unit-tested directly instead of
124
+ * only through `loadConfigFile`'s full probe/walk behavior.
125
+ */
126
+ async function probeExtensions(dir, name, extensions) {
127
+ for (const ext of extensions) {
128
+ const filepath = join(dir, `${name}.config.${ext}`);
129
+ if (!await isFile(filepath)) continue;
130
+ return {
131
+ config: await loadFile(filepath, ext),
132
+ filepath
133
+ };
134
+ }
64
135
  }
65
136
  //#endregion
66
- //#region src/load.ts
137
+ //#region src/load-config-file/load-config-file.ts
67
138
  /**
68
139
  * Discover and load `<name>.config.*` files, or load one from content that
69
140
  * didn't come from a file at all ({@link loadConfigFromContent}).
@@ -73,62 +144,33 @@ function isPlainObject(value) {
73
144
  * Probe order is **TS-first**: `.ts` → `.js` → `.mjs` → `.cjs` → `.json`.
74
145
  * TS is loaded through `tsx`'s `tsImport` (a runtime dependency) so a
75
146
  * typed `defineConfig` file works with no build step.
147
+ *
148
+ * `walkUp` (off by default) adds the "nearest config" search
149
+ * cosmiconfig/postcss/stylelint/ESLint's flat config all do: when `cwd`
150
+ * itself has no match, check its parent, then its parent's parent, and so
151
+ * on — stopping at the first directory containing a `.git` entry (checked
152
+ * after that directory's own config probe, so the project root itself is
153
+ * still searched) or the filesystem root. Never wanders past a repo
154
+ * boundary into an unrelated enclosing directory. Built on
155
+ * `@theholocron/fs-utils`'s `findUpward`/`hasGitEntry` — the same
156
+ * upward-walk shape `@theholocron/sentinel`'s own `findPackageRoot` needs,
157
+ * only with a different predicate.
76
158
  */
77
- /** Default extension probe order, highest priority first. */
78
- const DEFAULT_EXTENSIONS = [
79
- "ts",
80
- "js",
81
- "mjs",
82
- "cjs",
83
- "json"
84
- ];
85
159
  /**
86
- * Load the first `<name>.config.<ext>` that exists in `cwd`. Returns
87
- * `null` when none is present; throws {@link ConfigFileError} when a file
88
- * exists but cannot be loaded.
160
+ * Load the first `<name>.config.<ext>` found starting at `cwd` — just
161
+ * `cwd` itself by default, or walking up through ancestor directories when
162
+ * `walkUp` is set (see the module doc comment for the stop conditions).
163
+ * Returns `null` when none is present anywhere searched; throws
164
+ * {@link ConfigFileError} when a file exists but cannot be loaded.
89
165
  */
90
166
  async function loadConfigFile(opts) {
91
167
  const extensions = opts.extensions ?? DEFAULT_EXTENSIONS;
92
- for (const ext of extensions) {
93
- const filepath = join(opts.cwd, `${opts.name}.config.${ext}`);
94
- if (!await isFile(filepath)) continue;
95
- return {
96
- config: await loadFile(filepath, ext),
97
- filepath
98
- };
99
- }
100
- return null;
101
- }
102
- /**
103
- * `<name>.config.*` layered over the `[fallback.key]` of
104
- * `<fallback.file>.config.*`. Either, both, or neither may be present;
105
- * the dedicated file wins on conflict (vitest-over-vite). Returns `null`
106
- * when neither source resolves.
107
- */
108
- async function loadLayered(opts) {
109
- const dedicated = await loadConfigFile(opts);
110
- let base;
111
- let baseFile = null;
112
- if (opts.fallback) {
113
- const parent = await loadConfigFile({
114
- cwd: opts.cwd,
115
- name: opts.fallback.file,
116
- extensions: opts.extensions
117
- });
118
- if (parent && parent.config[opts.fallback.key] !== void 0) {
119
- base = parent.config[opts.fallback.key];
120
- baseFile = parent.filepath;
121
- }
122
- }
123
- if (!dedicated && base === void 0) return null;
124
- const config = mergeConfig(base, dedicated?.config);
125
- const sources = [baseFile, dedicated?.filepath ?? null].filter((s) => s !== null);
126
- return {
127
- config,
128
- filepath: dedicated?.filepath ?? baseFile,
129
- sources
130
- };
168
+ const checkDir = (dir) => probeExtensions(dir, opts.name, extensions);
169
+ if (!opts.walkUp) return await checkDir(opts.cwd) ?? null;
170
+ return await findUpward(opts.cwd, checkDir, hasGitEntry) ?? null;
131
171
  }
172
+ //#endregion
173
+ //#region src/load-config-from-content/load-config-from-content.ts
132
174
  /**
133
175
  * Load config from content that didn't come from a file already on disk —
134
176
  * writes it to `<dir>/<name>.config.<extension>` first, then loads it
@@ -147,65 +189,120 @@ async function loadConfigFromContent(opts) {
147
189
  filepath
148
190
  };
149
191
  }
150
- async function loadFile(filepath, ext) {
151
- if (ext === "json") return loadJson(filepath);
152
- return extractDefault(filepath, ext === "ts" ? await importTs(filepath) : await importModule(filepath));
192
+ //#endregion
193
+ //#region src/merge-layers/merge-layers.ts
194
+ /**
195
+ * Deep-merges config layers, lowest priority first (a later layer wins on
196
+ * scalars; arrays concatenate; nested objects merge). `undefined` layers are
197
+ * skipped. Returns `undefined` when there is no layer to merge. The result is
198
+ * a copy for object layers — it never aliases a layer.
199
+ *
200
+ * The one merge `loadLayered` and every caller layering configs it loaded
201
+ * some other way (content from an API, say) use, so layering means the same
202
+ * thing everywhere.
203
+ */
204
+ function mergeLayers(layers) {
205
+ const present = layers.filter((layer) => layer !== void 0);
206
+ if (present.length === 0) return void 0;
207
+ return present.reduce((acc, layer) => deepMerge(acc, layer), {});
153
208
  }
154
- async function loadJson(filepath) {
155
- const text = await readFile(filepath, "utf8");
156
- try {
157
- return JSON.parse(text);
158
- } catch (err) {
159
- throw new ConfigFileError(`${filepath} is not valid JSON: ${message(err)}`, filepath);
209
+ //#endregion
210
+ //#region src/load-layered/load-layered.ts
211
+ /**
212
+ * `<name>.config.*` layered over the `[fallback.key]` of
213
+ * `<fallback.file>.config.*`. Either, both, or neither may be present;
214
+ * the dedicated file wins on conflict (vitest-over-vite). Returns `null`
215
+ * when neither source resolves.
216
+ */
217
+ async function loadLayered(opts) {
218
+ const coerce = opts.coerce ?? ((raw) => raw);
219
+ const dedicated = await loadConfigFile(opts);
220
+ let top;
221
+ let topFile = null;
222
+ if (dedicated) {
223
+ top = coerce(dedicated.config);
224
+ if (top !== void 0) topFile = dedicated.filepath;
160
225
  }
161
- }
162
- async function importModule(filepath) {
163
- try {
164
- return await import(pathToFileURL(filepath).href);
165
- } catch (err) {
166
- throw new ConfigFileError(`could not load ${filepath}: ${message(err)}`, filepath);
226
+ let base;
227
+ let baseFile = null;
228
+ if (opts.fallback) {
229
+ const parent = opts.fallback.preloaded !== void 0 ? opts.fallback.preloaded : await loadConfigFile({
230
+ cwd: opts.cwd,
231
+ name: opts.fallback.file,
232
+ extensions: opts.extensions,
233
+ walkUp: opts.walkUp
234
+ });
235
+ const raw = (parent?.config)?.[opts.fallback.key];
236
+ if (parent && raw !== void 0) {
237
+ base = coerce(raw);
238
+ if (base !== void 0) baseFile = parent.filepath;
239
+ }
167
240
  }
241
+ const config = mergeLayers([base, top]);
242
+ if (config === void 0) return null;
243
+ const sources = [baseFile, topFile].filter((s) => s !== null);
244
+ return {
245
+ config,
246
+ filepath: topFile ?? baseFile,
247
+ sources
248
+ };
168
249
  }
169
- let tsRegistered;
170
- async function registerTsx() {
171
- const { register } = await import("tsx/esm/api");
172
- register();
173
- }
250
+ //#endregion
251
+ //#region src/read-config-source/read-config-source.ts
174
252
  /**
175
- * Load a `.ts` config. `tsx`'s ESM loader is registered once per process
176
- * (lazily — only when a `.ts` config is actually loaded). `register()` is
177
- * used over `tsImport()` because a single run may load several TS configs
178
- * (`loadLayered` reads a dedicated file *and* a parent file) and
179
- * `tsImport`'s one-off register/unregister cycle is not reentrant.
253
+ * {@link probeExtensions} for a config that lives somewhere other than the
254
+ * local disk (a GitHub API, a git blob): probes `<name>.config.<ext>` in
255
+ * extension-priority order through `options.fetch`, returning the first file
256
+ * that exists, or `undefined` when none does. Pass the result to
257
+ * `loadConfigFromContent` to execute it exactly as a local file would be.
180
258
  */
181
- async function importTs(filepath) {
182
- tsRegistered ??= registerTsx();
183
- await tsRegistered;
184
- try {
185
- return await import(pathToFileURL(filepath).href);
186
- } catch (err) {
187
- throw new ConfigFileError(`could not load ${filepath}: ${message(err)}`, filepath);
259
+ async function readConfigSource(options) {
260
+ for (const extension of options.extensions ?? DEFAULT_EXTENSIONS) {
261
+ const path = `${options.name}.config.${extension}`;
262
+ const content = await options.fetch(path);
263
+ if (content !== void 0) return {
264
+ path,
265
+ extension,
266
+ content
267
+ };
188
268
  }
189
269
  }
270
+ //#endregion
271
+ //#region src/with-config-sandbox/with-config-sandbox.ts
190
272
  /**
191
- * Unwrap `export default`. `tsx` CJS-transforms `export default x` into
192
- * `exports.default = x`, which dynamic import wraps as
193
- * `{ default: { __esModule: true, default: x } }` — strip the extra layer
194
- * when present so ESM and CJS outputs both resolve.
273
+ * Runs `run` with a throwaway directory prepared for
274
+ * {@link loadConfigFromContent}'s `dir`, and removes it afterwards (also when
275
+ * `run` throws).
276
+ *
277
+ * The directory is made for a TS/JS config that imports packages:
278
+ *
279
+ * - `node_modules` is a symlink to `options.nodeModules`, so those imports
280
+ * resolve as if the config sat next to that directory.
281
+ * - A `package.json` of `{"type":"module"}` is written into it. Node decides
282
+ * whether a `.ts`/`.js` file is ESM or CommonJS from the nearest ancestor
283
+ * `package.json`'s `type`, defaulting to CommonJS — and `tsx`'s loader,
284
+ * correctly honouring that, would then `require()` a pure-ESM dependency
285
+ * such as `@theholocron/cli`'s `dist/index.mjs`, which Node rejects outright
286
+ * ("require() of ES Module … not supported").
287
+ *
288
+ * The directory lives under the OS temp dir by default rather than inside any
289
+ * package: read-only deployments (Vercel's Node.js Functions ship the bundle
290
+ * read-only under `/var/task`) can't create a directory there.
195
291
  */
196
- function extractDefault(filepath, mod) {
197
- const outer = mod.default;
198
- const raw = outer?.__esModule === true ? outer.default : outer;
199
- if (raw === void 0 || raw === null) throw new ConfigFileError(`${filepath} must have a default export (use \`export default defineConfig({…})\`)`, filepath);
200
- return raw;
201
- }
202
- async function isFile(path) {
292
+ async function withConfigSandbox(options, run) {
293
+ const root = options.root ?? tmpdir();
294
+ await mkdir(root, { recursive: true });
295
+ const dir = await mkdtemp(join(root, options.prefix ?? "datapad-"));
203
296
  try {
204
- return (await stat(path)).isFile();
205
- } catch {
206
- return false;
297
+ await symlink(options.nodeModules, join(dir, "node_modules"), "dir");
298
+ await writeFile(join(dir, "package.json"), JSON.stringify({ type: "module" }), "utf8");
299
+ return await run(dir);
300
+ } finally {
301
+ await rm(dir, {
302
+ recursive: true,
303
+ force: true
304
+ });
207
305
  }
208
306
  }
209
- const message = (err) => err instanceof Error ? err.message : String(err);
210
307
  //#endregion
211
- export { ConfigFileError, DEFAULT_EXTENSIONS, createDefineConfig, loadConfigFile, loadConfigFromContent, loadLayered, mergeConfig };
308
+ export { ConfigFileError, DEFAULT_EXTENSIONS, createDefineConfig, loadConfigFile, loadConfigFromContent, loadLayered, mergeConfig, mergeLayers, readConfigSource, withConfigSandbox };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/datapad",
3
- "version": "5.0.0-alpha.20",
3
+ "version": "5.0.0-alpha.201",
4
4
  "description": "Generic config-file loading for Holocron — discover, load (JSON/JS/TS/ESM/CJS), and merge <name>.config.* files with a typed defineConfig.",
5
5
  "keywords": [
6
6
  "config",
@@ -32,13 +32,16 @@
32
32
  "dist"
33
33
  ],
34
34
  "dependencies": {
35
- "tsx": "4.23.12"
35
+ "@theholocron/fs-utils": "^1.16.0",
36
+ "@theholocron/misc-utils": "^1.16.0",
37
+ "@theholocron/object-utils": "^1.16.0",
38
+ "tsx": "4.23.15"
36
39
  },
37
40
  "devDependencies": {
38
- "@theholocron/eslint-config": "^8.0.0",
39
- "@theholocron/tsconfig": "^8.0.0",
40
- "@theholocron/tsdown-config": "^8.0.0",
41
- "@theholocron/vitest-config": "^8.0.0",
41
+ "@theholocron/eslint-config": "^8.8.2",
42
+ "@theholocron/tsconfig": "^8.8.2",
43
+ "@theholocron/tsdown-config": "^8.8.2",
44
+ "@theholocron/vitest-config": "^8.8.2",
42
45
  "@types/node": "^26",
43
46
  "@vitest/coverage-v8": "^4.1.11",
44
47
  "@vitest/eslint-plugin": "^1.6.27",