@aljazmc/config 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aljazmc
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 CHANGED
@@ -1,3 +1,25 @@
1
- # Temporary Holding Version
1
+ # > config
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared, dependency-free configuration loader, file finder and glob matcher, bundled
4
+ into every `@aljazmc` package and published for their types.
5
+
6
+ Each package ships its defaults in `src/config.ts`. On run, `<package>.config.ts`
7
+ (or `.js`) in the current directory, if present, overrides them key by key.
8
+
9
+ ```js
10
+ // testero.config.js
11
+ export default {
12
+ ignore: [
13
+ "node_modules", // name: any file or directory called node_modules
14
+ "packages/testero/tests", // path: relative to root, with its contents
15
+ "*.log", // globs: * and ? within a segment, ** across segments, {a,b} alternatives
16
+ "packages/*/bin",
17
+ "**/tmp/*.ts",
18
+ ],
19
+ };
20
+ ```
21
+
22
+ `config.files()` lists the files that are not ignored, and `config.sources()`
23
+ reads the text ones. `Includes` matches `includes` globs like formatero and lintero use them, where
24
+ the last matching glob decides and `!` excludes. `config.custom` holds what the
25
+ config file set, before the defaults were merged in.
package/bin/index.js ADDED
@@ -0,0 +1,210 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // shared/config/src/config.ts
5
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ // shared/config/src/pattern.ts
10
+ var Pattern = class _Pattern {
11
+ static {
12
+ __name(this, "Pattern");
13
+ }
14
+ #anchored;
15
+ #regex;
16
+ constructor(source) {
17
+ const glob = source.replace(/^\.?\/+|\/+$/g, "");
18
+ this.#anchored = glob.includes("/");
19
+ this.#regex = new RegExp(`^${_Pattern.#toRegex(glob)}$`);
20
+ }
21
+ matches(path) {
22
+ const segments = path.split("/");
23
+ return segments.some(
24
+ (segment, index) => this.#regex.test(
25
+ this.#anchored ? segments.slice(0, index + 1).join("/") : segment
26
+ )
27
+ );
28
+ }
29
+ /** Translates `**`, `*`, `?` and `{a,b}` alternatives, other characters match themselves. */
30
+ static #toRegex(glob) {
31
+ const tokens = {
32
+ "**/": "(?:.*/)?",
33
+ "**": ".*",
34
+ "*": "[^/]*",
35
+ "?": "[^/]"
36
+ };
37
+ let regex = "";
38
+ let braces = 0;
39
+ for (const [token] of glob.matchAll(/\*\*\/|\*\*|[\s\S]/g)) {
40
+ if (token in tokens) regex += tokens[token] ?? token;
41
+ else if (token === "{") {
42
+ braces++;
43
+ regex += "(?:";
44
+ } else if (token === "}" && braces > 0) {
45
+ braces--;
46
+ regex += ")";
47
+ } else if (token === "," && braces > 0) regex += "|";
48
+ else regex += token.replace(/[.+^${}()|[\]\\]/g, "\\$&");
49
+ }
50
+ return regex;
51
+ }
52
+ };
53
+
54
+ // shared/config/src/ignore.ts
55
+ var Ignore = class {
56
+ static {
57
+ __name(this, "Ignore");
58
+ }
59
+ #patterns;
60
+ constructor(sources) {
61
+ this.#patterns = sources.map((source) => new Pattern(source));
62
+ }
63
+ matches(path) {
64
+ const normalized = path.replaceAll("\\", "/").replace(/^\.\//, "");
65
+ return this.#patterns.some((pattern) => pattern.matches(normalized));
66
+ }
67
+ };
68
+
69
+ // shared/config/src/config.ts
70
+ var Config = class _Config {
71
+ static {
72
+ __name(this, "Config");
73
+ }
74
+ static extensions = ["ts", "js"];
75
+ root;
76
+ ignore;
77
+ rules;
78
+ /** What the config file set, before the defaults were merged in. */
79
+ custom;
80
+ constructor(options, root = process.cwd(), custom = {}) {
81
+ this.root = root;
82
+ this.ignore = new Ignore(options.ignore);
83
+ this.rules = options.rules;
84
+ this.custom = custom;
85
+ }
86
+ static async load(name, defaults, root = process.cwd()) {
87
+ for (const extension of _Config.extensions) {
88
+ const file = join(root, `${name}.config.${extension}`);
89
+ if (existsSync(file)) {
90
+ const { default: options } = await import(pathToFileURL(file).href);
91
+ return new _Config(
92
+ _Config.merge(defaults, options),
93
+ root,
94
+ options
95
+ );
96
+ }
97
+ }
98
+ return new _Config(defaults, root);
99
+ }
100
+ /** Handles `--config`, writing the defaults to a TypeScript config file, or with `--config js` a JavaScript one. Returns whether it was given. */
101
+ static init(name, defaults, argv = process.argv.slice(2), root = process.cwd()) {
102
+ const index = argv.indexOf("--config");
103
+ if (index === -1) return false;
104
+ const next = argv[index + 1];
105
+ const extension = next === void 0 || next.startsWith("-") ? "ts" : next;
106
+ try {
107
+ console.log(
108
+ `created: ${_Config.export(name, defaults, extension, root)}`
109
+ );
110
+ } catch (error) {
111
+ console.error(error instanceof Error ? error.message : error);
112
+ process.exitCode = 1;
113
+ }
114
+ return true;
115
+ }
116
+ /** Writes the defaults to `<name>.config.<extension>` in the root, unless a config file is there already. */
117
+ static export(name, defaults, extension = "ts", root = process.cwd()) {
118
+ if (!_Config.extensions.includes(extension))
119
+ throw new Error(
120
+ `Unknown config file type ${extension}, use ts or js.`
121
+ );
122
+ for (const existing of _Config.extensions) {
123
+ const file2 = `${name}.config.${existing}`;
124
+ if (existsSync(join(root, file2)))
125
+ throw new Error(`${file2} already exists.`);
126
+ }
127
+ const file = `${name}.config.${extension}`;
128
+ writeFileSync(
129
+ join(root, file),
130
+ `export default ${_Config.#literal(defaults)};
131
+ `
132
+ );
133
+ return file;
134
+ }
135
+ /** A value as JavaScript source, objects one property per line, short arrays on one line. */
136
+ static #literal(value, indent = "") {
137
+ const inner = `${indent} `;
138
+ const lines = /* @__PURE__ */ __name((items, open, close) => items.length === 0 ? `${open}${close}` : `${open}
139
+ ${items.map((item) => `${inner}${item},
140
+ `).join("")}${indent}${close}`, "lines");
141
+ if (Array.isArray(value)) {
142
+ const items = value.map((item) => _Config.#literal(item, inner));
143
+ const line = `[${items.join(", ")}]`;
144
+ return line.length <= 60 && !line.includes("\n") ? line : lines(items, "[", "]");
145
+ }
146
+ if (value?.constructor === Object)
147
+ return lines(
148
+ Object.entries(value).map(
149
+ ([key, item]) => `${/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key)}: ${_Config.#literal(item, inner)}`
150
+ ),
151
+ "{",
152
+ "}"
153
+ );
154
+ return JSON.stringify(value);
155
+ }
156
+ /** Deep merges plain objects, anything else in override replaces base. */
157
+ static merge(base, override) {
158
+ const plain = /* @__PURE__ */ __name((value) => value?.constructor === Object, "plain");
159
+ if (!plain(base) || !plain(override)) return override ?? base;
160
+ const merged = { ...base };
161
+ for (const [key, value] of Object.entries(override))
162
+ merged[key] = _Config.merge(base[key], value);
163
+ return merged;
164
+ }
165
+ *files(directory = "") {
166
+ const entries = readdirSync(join(this.root, directory), {
167
+ withFileTypes: true
168
+ });
169
+ for (const entry of entries) {
170
+ const path = directory ? `${directory}/${entry.name}` : entry.name;
171
+ if (this.ignore.matches(path)) continue;
172
+ if (entry.isDirectory()) yield* this.files(path);
173
+ else yield path;
174
+ }
175
+ }
176
+ /** Text files that are not ignored and that accepts takes, binaries are skipped. */
177
+ *sources(accepts = () => true) {
178
+ for (const file of this.files()) {
179
+ if (!accepts(file)) continue;
180
+ const path = join(this.root, file);
181
+ const text = readFileSync(path, "utf8");
182
+ if (!text.includes("\0")) yield { file, path, text };
183
+ }
184
+ }
185
+ };
186
+
187
+ // shared/config/src/includes.ts
188
+ var Includes = class {
189
+ static {
190
+ __name(this, "Includes");
191
+ }
192
+ #globs;
193
+ constructor(globs) {
194
+ this.#globs = globs.map((glob) => ({
195
+ pattern: new Pattern(glob.replace(/^!/, "")),
196
+ included: !glob.startsWith("!")
197
+ }));
198
+ }
199
+ matches(file) {
200
+ let included = false;
201
+ for (const { pattern, included: positive } of this.#globs)
202
+ if (pattern.matches(file)) included = positive;
203
+ return included;
204
+ }
205
+ };
206
+ export {
207
+ Includes,
208
+ Pattern,
209
+ Config as default
210
+ };
@@ -0,0 +1,31 @@
1
+ import Ignore from "./ignore.js";
2
+ export interface Options<Rules extends object = object> {
3
+ ignore: string[];
4
+ rules: Rules;
5
+ }
6
+ /** A text file found by a tool, with its path relative to the root. */
7
+ export interface Source {
8
+ file: string;
9
+ path: string;
10
+ text: string;
11
+ }
12
+ export default class Config<Rules extends object = object> {
13
+ #private;
14
+ static readonly extensions: string[];
15
+ readonly root: string;
16
+ readonly ignore: Ignore;
17
+ readonly rules: Rules;
18
+ /** What the config file set, before the defaults were merged in. */
19
+ readonly custom: Partial<Options<Rules>>;
20
+ constructor(options: Options<Rules>, root?: string, custom?: Partial<Options<Rules>>);
21
+ static load<Rules extends object>(name: string, defaults: Options<Rules>, root?: string): Promise<Config<Rules>>;
22
+ /** Handles `--config`, writing the defaults to a TypeScript config file, or with `--config js` a JavaScript one. Returns whether it was given. */
23
+ static init(name: string, defaults: Options, argv?: string[], root?: string): boolean;
24
+ /** Writes the defaults to `<name>.config.<extension>` in the root, unless a config file is there already. */
25
+ static export(name: string, defaults: Options, extension?: string, root?: string): string;
26
+ /** Deep merges plain objects, anything else in override replaces base. */
27
+ static merge<T>(base: T, override: unknown): T;
28
+ files(directory?: string): Generator<string>;
29
+ /** Text files that are not ignored and that accepts takes, binaries are skipped. */
30
+ sources(accepts?: (file: string) => boolean): Generator<Source>;
31
+ }
@@ -0,0 +1,5 @@
1
+ export default class Ignore {
2
+ #private;
3
+ constructor(sources: readonly string[]);
4
+ matches(path: string): boolean;
5
+ }
@@ -0,0 +1,6 @@
1
+ /** Biome style `includes`: globs in order, the last one matching a file decides, `!` excludes. */
2
+ export default class Includes {
3
+ #private;
4
+ constructor(globs: readonly string[]);
5
+ matches(file: string): boolean;
6
+ }
@@ -0,0 +1,4 @@
1
+ export type { Options, Source } from "./config.js";
2
+ export { default } from "./config.js";
3
+ export { default as Includes } from "./includes.js";
4
+ export { default as Pattern } from "./pattern.js";
@@ -0,0 +1,5 @@
1
+ export default class Pattern {
2
+ #private;
3
+ constructor(source: string);
4
+ matches(path: string): boolean;
5
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@aljazmc/config",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "description": "Shared, dependency-free configuration loader, file finder and glob matcher for @aljazmc packages.",
5
+ "author": "aljazmc",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/aljazmc/aljazmc.git",
11
+ "directory": "shared/config"
12
+ },
13
+ "engines": {
14
+ "node": "^22.18.0 || ^24.2.0 || ^25 || ^26"
15
+ },
16
+ "types": "./bin/types/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./bin/types/index.d.ts",
20
+ "default": "./bin/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "publishConfig": {
25
+ "exports": {
26
+ ".": {
27
+ "types": "./bin/types/index.d.ts",
28
+ "default": "./bin/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ }
32
+ },
33
+ "devDependencies": {
34
+ "@jest/globals": "^30.5.2",
35
+ "@types/node": "^26.6.5"
36
+ }
6
37
  }