@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 +56 -10
- package/dist/index.d.mts +151 -53
- package/dist/index.mjs +222 -125
- package/package.json +9 -6
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
|
|
49
|
-
**TS-first** — `.ts` → `.js` → `.mjs` → `.cjs` → `.json` — overridable
|
|
50
|
-
`extensions`. Returns `{ config, filepath } | null`. `null` means "no
|
|
51
|
-
file"; a file that exists but cannot be parsed /
|
|
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
|
|
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
|
-
### `
|
|
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
|
-
|
|
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
|
|
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>`
|
|
60
|
-
* `
|
|
61
|
-
*
|
|
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
|
-
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
123
|
-
*
|
|
124
|
-
* -
|
|
125
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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/
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* -
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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>`
|
|
87
|
-
* `
|
|
88
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* `
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* `
|
|
194
|
-
*
|
|
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
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
return
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
39
|
-
"@theholocron/tsconfig": "^8.
|
|
40
|
-
"@theholocron/tsdown-config": "^8.
|
|
41
|
-
"@theholocron/vitest-config": "^8.
|
|
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",
|