@theholocron/datapad 3.64.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Newton Koumantzelis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # `@theholocron/datapad`
2
+
3
+ Generic config-file loading for Holocron. Discover `<name>.config.*`, load
4
+ it (JSON / JS / TS / ESM / CJS — typed configs via [`tsx`](https://tsx.is),
5
+ no build step), layer a dedicated file over a key of a parent file,
6
+ deep-merge, and hand back a plain object.
7
+
8
+ Holocron-agnostic: no schema, no validation, no defaults. Consumers
9
+ (`@theholocron/cli` for `holocron.config.*`, `@theholocron/astromech` for
10
+ `astromech.config.*`) build those on top.
11
+
12
+ > A datapad is a handheld device for reading and holding data. This reads
13
+ > and holds config files.
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ pnpm add @theholocron/datapad
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```ts
24
+ import { loadConfigFile, loadLayered, mergeConfig, createDefineConfig } from "@theholocron/datapad";
25
+
26
+ // one file: holocron.config.{ts,js,mjs,cjs,json}
27
+ const found = await loadConfigFile<MyConfig>({ cwd, name: "holocron" });
28
+ // → { config, filepath } | null
29
+
30
+ // a dedicated file layered over the `tasks` key of a parent file
31
+ const layered = await loadLayered<TasksConfig>({
32
+ cwd,
33
+ name: "astromech",
34
+ fallback: { file: "holocron", key: "tasks" },
35
+ });
36
+ // → { config, filepath, sources } | null (dedicated wins on conflict)
37
+
38
+ const merged = mergeConfig(defaults, layered?.config); // vite-style deep merge
39
+
40
+ // a typed identity defineConfig for a specific shape
41
+ export const defineConfig = createDefineConfig<TasksConfig>();
42
+ ```
43
+
44
+ ## API
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`.
53
+
54
+ ### `loadLayered<T>({ cwd, name, fallback?, extensions? })`
55
+
56
+ `<name>.config.*` layered over the `[fallback.key]` of
57
+ `<fallback.file>.config.*`. Either, both, or neither may be present; the
58
+ dedicated file wins on conflict (`vitest`-over-`vite`). Returns
59
+ `{ config, filepath, sources } | null` — `sources` lists the absolute
60
+ paths actually merged, lowest priority first.
61
+
62
+ ### `mergeConfig<T>(base, override)`
63
+
64
+ Deep-merge, `vite`-style: plain objects merge recursively, arrays
65
+ concatenate (`base` first), `undefined` in `override` is skipped, every
66
+ other `override` value replaces `base`. Neither input is mutated.
67
+
68
+ ### `createDefineConfig<T>()`
69
+
70
+ Returns a typed identity function — call once per config shape and
71
+ re-export it so config files get autocomplete with zero runtime cost.
72
+
73
+ ### `ConfigFileError`
74
+
75
+ Thrown when a file is found but unusable. Carries the offending
76
+ `filepath`. "Not found" is `null`, never an error.
77
+
78
+ ## TypeScript configs
79
+
80
+ `.ts` files load through `tsx`'s ESM loader, registered once per process
81
+ on first use. A single run may load several TS configs (`loadLayered`
82
+ reads a dedicated file _and_ a parent file), so the persistent
83
+ `register()` is used rather than the one-off `tsImport()`.
84
+
85
+ ## Development
86
+
87
+ | Script | Description |
88
+ | -------------------- | ----------------------- |
89
+ | `pnpm build` | Bundle with tsdown |
90
+ | `pnpm test` | Run the vitest suite |
91
+ | `pnpm test:coverage` | Run tests with coverage |
92
+ | `pnpm typecheck` | `tsc --noEmit` |
93
+ | `pnpm lint` | ESLint |
94
+
95
+ ## Releases
96
+
97
+ Automated via semantic-release. See [CHANGELOG.md](../../CHANGELOG.md).
98
+
99
+ ## Documentation
100
+
101
+ <https://theholocron.github.io/holocron/>
@@ -0,0 +1,101 @@
1
+ //#region src/define.d.ts
2
+ /**
3
+ * Build a typed identity `defineConfig` for a specific config shape.
4
+ *
5
+ * Each consumer (`@theholocron/cli`, `@theholocron/astromech`) calls this
6
+ * once with its own config type and re-exports the result, so config
7
+ * files get autocomplete and type-checking with no runtime cost:
8
+ *
9
+ * ```ts
10
+ * // @theholocron/astromech/config
11
+ * export const defineConfig = createDefineConfig<TasksConfig>();
12
+ * ```
13
+ */
14
+ declare function createDefineConfig<T>(): <C extends T>(config: C) => C;
15
+ //#endregion
16
+ //#region src/errors.d.ts
17
+ /**
18
+ * Thrown when a config file is found but cannot be loaded — malformed
19
+ * JSON, a syntax error in a `.ts` / `.js` module, or no usable default
20
+ * export. "Not found" is never an error: {@link loadConfigFile} returns
21
+ * `null` for that.
22
+ */
23
+ declare class ConfigFileError extends Error {
24
+ /** Absolute path of the offending file, when known. */
25
+ readonly filepath?: string | undefined;
26
+ name: string;
27
+ constructor(message: string,
28
+ /** Absolute path of the offending file, when known. */
29
+ filepath?: string | undefined);
30
+ }
31
+ //#endregion
32
+ //#region src/load.d.ts
33
+ /**
34
+ * Discover and load `<name>.config.*` files. Holocron-agnostic — no
35
+ * schema, no validation, no defaults. Consumers layer those on top.
36
+ *
37
+ * Probe order is **TS-first**: `.ts` → `.js` → `.mjs` → `.cjs` → `.json`.
38
+ * TS is loaded through `tsx`'s `tsImport` (a runtime dependency) so a
39
+ * typed `defineConfig` file works with no build step.
40
+ */
41
+ /** Default extension probe order, highest priority first. */
42
+ declare const DEFAULT_EXTENSIONS: readonly ["ts", "js", "mjs", "cjs", "json"];
43
+ interface LoadConfigFileOptions {
44
+ /** Directory to look in. */
45
+ cwd: string;
46
+ /** Base name — `"holocron"` resolves `holocron.config.{ts,js,mjs,cjs,json}`. */
47
+ name: string;
48
+ /** Override the extension probe order. */
49
+ extensions?: readonly string[];
50
+ }
51
+ interface Loaded<T> {
52
+ config: T;
53
+ /** Absolute path of the file the value came from. */
54
+ filepath: string;
55
+ }
56
+ /**
57
+ * Load the first `<name>.config.<ext>` that exists in `cwd`. Returns
58
+ * `null` when none is present; throws {@link ConfigFileError} when a file
59
+ * exists but cannot be loaded.
60
+ */
61
+ declare function loadConfigFile<T>(opts: LoadConfigFileOptions): Promise<Loaded<T> | null>;
62
+ interface LoadLayeredOptions extends LoadConfigFileOptions {
63
+ /**
64
+ * When no dedicated `<name>.config.*` exists — or in addition to it —
65
+ * read `<file>.config.*` and take its `[key]`. The dedicated file (if
66
+ * any) is merged on top.
67
+ */
68
+ fallback?: {
69
+ file: string;
70
+ key: string;
71
+ };
72
+ }
73
+ interface LayeredResult<T> {
74
+ config: T;
75
+ /** The dedicated file path if present, else the fallback file path, else `null`. */
76
+ filepath: string | null;
77
+ /** Absolute paths actually merged, lowest priority first. */
78
+ sources: string[];
79
+ }
80
+ /**
81
+ * `<name>.config.*` layered over the `[fallback.key]` of
82
+ * `<fallback.file>.config.*`. Either, both, or neither may be present;
83
+ * the dedicated file wins on conflict (vitest-over-vite). Returns `null`
84
+ * when neither source resolves.
85
+ */
86
+ declare function loadLayered<T>(opts: LoadLayeredOptions): Promise<LayeredResult<T> | null>;
87
+ //#endregion
88
+ //#region src/merge.d.ts
89
+ /**
90
+ * Deep-merge two config objects, `vite`-style:
91
+ *
92
+ * - plain objects merge recursively
93
+ * - arrays concatenate (`base` first, then `override`)
94
+ * - an `undefined` value in `override` is skipped (keeps `base`)
95
+ * - every other `override` value replaces `base`
96
+ *
97
+ * Neither input is mutated.
98
+ */
99
+ declare function mergeConfig<T>(base: T, override: unknown): T;
100
+ //#endregion
101
+ export { ConfigFileError, DEFAULT_EXTENSIONS, type LayeredResult, type LoadConfigFileOptions, type LoadLayeredOptions, type Loaded, createDefineConfig, loadConfigFile, loadLayered, mergeConfig };
package/dist/index.mjs ADDED
@@ -0,0 +1,191 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ //#region src/define.ts
5
+ /**
6
+ * Build a typed identity `defineConfig` for a specific config shape.
7
+ *
8
+ * Each consumer (`@theholocron/cli`, `@theholocron/astromech`) calls this
9
+ * once with its own config type and re-exports the result, so config
10
+ * files get autocomplete and type-checking with no runtime cost:
11
+ *
12
+ * ```ts
13
+ * // @theholocron/astromech/config
14
+ * export const defineConfig = createDefineConfig<TasksConfig>();
15
+ * ```
16
+ */
17
+ function createDefineConfig() {
18
+ return (config) => config;
19
+ }
20
+ //#endregion
21
+ //#region src/errors.ts
22
+ /**
23
+ * Thrown when a config file is found but cannot be loaded — malformed
24
+ * JSON, a syntax error in a `.ts` / `.js` module, or no usable default
25
+ * export. "Not found" is never an error: {@link loadConfigFile} returns
26
+ * `null` for that.
27
+ */
28
+ var ConfigFileError = class extends Error {
29
+ filepath;
30
+ name = "ConfigFileError";
31
+ constructor(message, filepath) {
32
+ super(message);
33
+ this.filepath = filepath;
34
+ }
35
+ };
36
+ //#endregion
37
+ //#region src/merge.ts
38
+ /**
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.
47
+ */
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;
57
+ }
58
+ return out;
59
+ }
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;
64
+ }
65
+ //#endregion
66
+ //#region src/load.ts
67
+ /**
68
+ * Discover and load `<name>.config.*` files. Holocron-agnostic — no
69
+ * schema, no validation, no defaults. Consumers layer those on top.
70
+ *
71
+ * Probe order is **TS-first**: `.ts` → `.js` → `.mjs` → `.cjs` → `.json`.
72
+ * TS is loaded through `tsx`'s `tsImport` (a runtime dependency) so a
73
+ * typed `defineConfig` file works with no build step.
74
+ */
75
+ /** Default extension probe order, highest priority first. */
76
+ const DEFAULT_EXTENSIONS = [
77
+ "ts",
78
+ "js",
79
+ "mjs",
80
+ "cjs",
81
+ "json"
82
+ ];
83
+ /**
84
+ * Load the first `<name>.config.<ext>` that exists in `cwd`. Returns
85
+ * `null` when none is present; throws {@link ConfigFileError} when a file
86
+ * exists but cannot be loaded.
87
+ */
88
+ async function loadConfigFile(opts) {
89
+ const extensions = opts.extensions ?? DEFAULT_EXTENSIONS;
90
+ for (const ext of extensions) {
91
+ const filepath = join(opts.cwd, `${opts.name}.config.${ext}`);
92
+ if (!await isFile(filepath)) continue;
93
+ return {
94
+ config: await loadFile(filepath, ext),
95
+ filepath
96
+ };
97
+ }
98
+ return null;
99
+ }
100
+ /**
101
+ * `<name>.config.*` layered over the `[fallback.key]` of
102
+ * `<fallback.file>.config.*`. Either, both, or neither may be present;
103
+ * the dedicated file wins on conflict (vitest-over-vite). Returns `null`
104
+ * when neither source resolves.
105
+ */
106
+ async function loadLayered(opts) {
107
+ const dedicated = await loadConfigFile(opts);
108
+ let base;
109
+ let baseFile = null;
110
+ if (opts.fallback) {
111
+ const parent = await loadConfigFile({
112
+ cwd: opts.cwd,
113
+ name: opts.fallback.file,
114
+ extensions: opts.extensions
115
+ });
116
+ if (parent && parent.config[opts.fallback.key] !== void 0) {
117
+ base = parent.config[opts.fallback.key];
118
+ baseFile = parent.filepath;
119
+ }
120
+ }
121
+ if (!dedicated && base === void 0) return null;
122
+ const config = mergeConfig(base, dedicated?.config);
123
+ const sources = [baseFile, dedicated?.filepath ?? null].filter((s) => s !== null);
124
+ return {
125
+ config,
126
+ filepath: dedicated?.filepath ?? baseFile,
127
+ sources
128
+ };
129
+ }
130
+ async function loadFile(filepath, ext) {
131
+ if (ext === "json") return loadJson(filepath);
132
+ return extractDefault(filepath, ext === "ts" ? await importTs(filepath) : await importModule(filepath));
133
+ }
134
+ async function loadJson(filepath) {
135
+ const text = await readFile(filepath, "utf8");
136
+ try {
137
+ return JSON.parse(text);
138
+ } catch (err) {
139
+ throw new ConfigFileError(`${filepath} is not valid JSON: ${message(err)}`, filepath);
140
+ }
141
+ }
142
+ async function importModule(filepath) {
143
+ try {
144
+ return await import(pathToFileURL(filepath).href);
145
+ } catch (err) {
146
+ throw new ConfigFileError(`could not load ${filepath}: ${message(err)}`, filepath);
147
+ }
148
+ }
149
+ let tsRegistered;
150
+ async function registerTsx() {
151
+ const { register } = await import("tsx/esm/api");
152
+ register();
153
+ }
154
+ /**
155
+ * Load a `.ts` config. `tsx`'s ESM loader is registered once per process
156
+ * (lazily — only when a `.ts` config is actually loaded). `register()` is
157
+ * used over `tsImport()` because a single run may load several TS configs
158
+ * (`loadLayered` reads a dedicated file *and* a parent file) and
159
+ * `tsImport`'s one-off register/unregister cycle is not reentrant.
160
+ */
161
+ async function importTs(filepath) {
162
+ tsRegistered ??= registerTsx();
163
+ await tsRegistered;
164
+ try {
165
+ return await import(pathToFileURL(filepath).href);
166
+ } catch (err) {
167
+ throw new ConfigFileError(`could not load ${filepath}: ${message(err)}`, filepath);
168
+ }
169
+ }
170
+ /**
171
+ * Unwrap `export default`. `tsx` CJS-transforms `export default x` into
172
+ * `exports.default = x`, which dynamic import wraps as
173
+ * `{ default: { __esModule: true, default: x } }` — strip the extra layer
174
+ * when present so ESM and CJS outputs both resolve.
175
+ */
176
+ function extractDefault(filepath, mod) {
177
+ const outer = mod.default;
178
+ const raw = outer?.__esModule === true ? outer.default : outer;
179
+ if (raw === void 0 || raw === null) throw new ConfigFileError(`${filepath} must have a default export (use \`export default defineConfig({…})\`)`, filepath);
180
+ return raw;
181
+ }
182
+ async function isFile(path) {
183
+ try {
184
+ return (await stat(path)).isFile();
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+ const message = (err) => err instanceof Error ? err.message : String(err);
190
+ //#endregion
191
+ export { ConfigFileError, DEFAULT_EXTENSIONS, createDefineConfig, loadConfigFile, loadLayered, mergeConfig };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@theholocron/datapad",
3
+ "version": "3.64.0",
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
+ "keywords": [
6
+ "config",
7
+ "config-loader",
8
+ "defineconfig",
9
+ "holocron",
10
+ "theholocron",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://github.com/theholocron/holocron/tree/main/packages/datapad#readme",
14
+ "bugs": "https://github.com/theholocron/holocron/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/theholocron/holocron.git",
18
+ "directory": "packages/datapad"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Newton Koumantzelis",
22
+ "sideEffects": false,
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.mts",
27
+ "import": "./dist/index.mjs",
28
+ "default": "./dist/index.mjs"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "dependencies": {
35
+ "tsx": "4.23.12"
36
+ },
37
+ "devDependencies": {
38
+ "@theholocron/eslint-config": "^7.32.1",
39
+ "@theholocron/tsconfig": "^7.32.1",
40
+ "@theholocron/tsdown-config": "^7.32.1",
41
+ "@theholocron/vitest-config": "^7.32.1",
42
+ "@types/node": "^26",
43
+ "@vitest/coverage-v8": "^4.1.11",
44
+ "@vitest/eslint-plugin": "^1.6.27",
45
+ "eslint": "^10.8.1",
46
+ "eslint-plugin-n": "^18.3.0",
47
+ "globals": "^17.11.0",
48
+ "tsdown": "^0.22.14",
49
+ "typescript": "^5.9.3",
50
+ "vitest": "^4.1.11"
51
+ },
52
+ "engines": {
53
+ "node": ">=22"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "scripts": {
59
+ "build": "tsdown",
60
+ "lint": "eslint .",
61
+ "typecheck": "tsc --noEmit",
62
+ "test": "vitest run",
63
+ "test:watch": "vitest",
64
+ "test:coverage": "vitest run --coverage"
65
+ }
66
+ }