@aljazmc/config 0.3.1 → 0.3.2
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 +36 -12
- package/bin/index.js +143 -28
- package/bin/types/config.d.ts +16 -1
- package/bin/types/ignore.d.ts +2 -1
- package/bin/types/includes.d.ts +3 -2
- package/bin/types/pattern.d.ts +13 -2
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -1,25 +1,49 @@
|
|
|
1
1
|
# > config
|
|
2
2
|
|
|
3
|
-
Shared, dependency-free configuration loader, file finder and glob
|
|
4
|
-
into every `@aljazmc` package and published for their
|
|
3
|
+
Shared, dependency-free configuration loader, file finder and glob
|
|
4
|
+
matcher, bundled into every `@aljazmc` package and published for their
|
|
5
|
+
types. Each package ships its defaults, and `<package>.config.ts` (or
|
|
6
|
+
`.js`) in the current directory overrides them key by key.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
## > Features
|
|
9
|
+
|
|
10
|
+
| Export / member | Description |
|
|
11
|
+
| ------------------ | --------------------------------------------------------------- |
|
|
12
|
+
| `config.custom` | What the config file set, before the defaults were merged in. |
|
|
13
|
+
| `config.files()` | Lists the files that are not ignored. |
|
|
14
|
+
| `config.sources()` | Reads the text files that are not ignored. |
|
|
15
|
+
| `Includes` | Matches `includes` globs: the last match decides, `!` excludes. |
|
|
16
|
+
| `Pattern` | Matches a single glob. |
|
|
17
|
+
|
|
18
|
+
## > Ignore patterns
|
|
8
19
|
|
|
9
20
|
```js
|
|
10
21
|
// testero.config.js
|
|
11
22
|
export default {
|
|
12
23
|
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
24
|
"**/tmp/*.ts",
|
|
25
|
+
"*.log",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"packages/*/bin",
|
|
28
|
+
"packages/testero/tests",
|
|
18
29
|
],
|
|
19
30
|
};
|
|
20
31
|
```
|
|
21
32
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
| Pattern | Matches |
|
|
34
|
+
| -------- | ------------------------------------------------------- |
|
|
35
|
+
| `**` | Any number of segments. |
|
|
36
|
+
| `*`, `?` | Any characters, or one character, within a segment. |
|
|
37
|
+
| `/name` | `name` at the root only, like `./name`. |
|
|
38
|
+
| `name/` | Folders only, with their contents. |
|
|
39
|
+
| `{a,b}` | Alternatives. |
|
|
40
|
+
| name | Any file or folder with that name, like `node_modules`. |
|
|
41
|
+
| path | A path relative to the root, with its contents. |
|
|
42
|
+
|
|
43
|
+
Symlinks are followed. Binary files are skipped. Broken symlinks,
|
|
44
|
+
unreadable folders and files that are not UTF-8 are skipped with a
|
|
45
|
+
warning.
|
|
46
|
+
|
|
47
|
+
## > License
|
|
48
|
+
|
|
49
|
+
MIT, see [LICENSE](LICENSE).
|
package/bin/index.js
CHANGED
|
@@ -2,7 +2,14 @@ var __defProp = Object.defineProperty;
|
|
|
2
2
|
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
3
|
|
|
4
4
|
// shared/config/src/config.ts
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync
|
|
12
|
+
} from "node:fs";
|
|
6
13
|
import { join } from "node:path";
|
|
7
14
|
import { pathToFileURL } from "node:url";
|
|
8
15
|
|
|
@@ -12,22 +19,33 @@ var Pattern = class _Pattern {
|
|
|
12
19
|
__name(this, "Pattern");
|
|
13
20
|
}
|
|
14
21
|
#anchored;
|
|
22
|
+
#directories;
|
|
15
23
|
#regex;
|
|
16
|
-
|
|
24
|
+
/** With the name of the setting the glob comes from, for its errors. */
|
|
25
|
+
constructor(source, setting = "glob") {
|
|
26
|
+
this.#directories = /[^/]\/+$/.test(source);
|
|
17
27
|
const glob = source.replace(/^\.?\/+|\/+$/g, "");
|
|
18
|
-
this.#anchored = glob.includes("/");
|
|
19
|
-
this.#regex = new RegExp(
|
|
28
|
+
this.#anchored = glob.includes("/") || /^\.?\//.test(source);
|
|
29
|
+
this.#regex = new RegExp(
|
|
30
|
+
`^${_Pattern.#toRegex(glob, source, setting)}$`
|
|
31
|
+
);
|
|
20
32
|
}
|
|
21
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Whether a path, or a folder it is in, matches. Whether the path itself
|
|
35
|
+
* is a folder matters to globs ending with `/`.
|
|
36
|
+
*/
|
|
37
|
+
matches(path, directory = false) {
|
|
22
38
|
const segments = path.split("/");
|
|
23
|
-
return segments.some(
|
|
24
|
-
(
|
|
39
|
+
return segments.some((segment, index) => {
|
|
40
|
+
if (this.#directories && index === segments.length - 1 && !directory)
|
|
41
|
+
return false;
|
|
42
|
+
return this.#regex.test(
|
|
25
43
|
this.#anchored ? segments.slice(0, index + 1).join("/") : segment
|
|
26
|
-
)
|
|
27
|
-
);
|
|
44
|
+
);
|
|
45
|
+
});
|
|
28
46
|
}
|
|
29
47
|
/** Translates `**`, `*`, `?` and `{a,b}` alternatives, other characters match themselves. */
|
|
30
|
-
static #toRegex(glob) {
|
|
48
|
+
static #toRegex(glob, source, setting) {
|
|
31
49
|
const tokens = {
|
|
32
50
|
"**/": "(?:.*/)?",
|
|
33
51
|
"**": ".*",
|
|
@@ -37,7 +55,7 @@ var Pattern = class _Pattern {
|
|
|
37
55
|
let regex = "";
|
|
38
56
|
let braces = 0;
|
|
39
57
|
for (const [token] of glob.matchAll(/\*\*\/|\*\*|[\s\S]/g)) {
|
|
40
|
-
if (token
|
|
58
|
+
if (Object.hasOwn(tokens, token)) regex += tokens[token];
|
|
41
59
|
else if (token === "{") {
|
|
42
60
|
braces++;
|
|
43
61
|
regex += "(?:";
|
|
@@ -47,6 +65,10 @@ var Pattern = class _Pattern {
|
|
|
47
65
|
} else if (token === "," && braces > 0) regex += "|";
|
|
48
66
|
else regex += token.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
49
67
|
}
|
|
68
|
+
if (braces > 0)
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Invalid ${setting} ${JSON.stringify(source)}: a { is not closed by a }.`
|
|
71
|
+
);
|
|
50
72
|
return regex;
|
|
51
73
|
}
|
|
52
74
|
};
|
|
@@ -58,11 +80,14 @@ var Ignore = class {
|
|
|
58
80
|
}
|
|
59
81
|
#patterns;
|
|
60
82
|
constructor(sources) {
|
|
61
|
-
this.#patterns = sources.map((source) => new Pattern(source));
|
|
83
|
+
this.#patterns = sources.map((source) => new Pattern(source, "ignore"));
|
|
62
84
|
}
|
|
63
|
-
|
|
85
|
+
/** Whether a path is ignored, globs ending with `/` only ignore folders. */
|
|
86
|
+
matches(path, directory = false) {
|
|
64
87
|
const normalized = path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
65
|
-
return this.#patterns.some(
|
|
88
|
+
return this.#patterns.some(
|
|
89
|
+
(pattern) => pattern.matches(normalized, directory)
|
|
90
|
+
);
|
|
66
91
|
}
|
|
67
92
|
};
|
|
68
93
|
|
|
@@ -77,6 +102,11 @@ var Config = class _Config {
|
|
|
77
102
|
rules;
|
|
78
103
|
/** What the config file set, before the defaults were merged in. */
|
|
79
104
|
custom;
|
|
105
|
+
/**
|
|
106
|
+
* Reports files and folders that are left out, like a broken symlink, an
|
|
107
|
+
* unreadable folder or a file that is not UTF-8, as a warning by default.
|
|
108
|
+
*/
|
|
109
|
+
warn = /* @__PURE__ */ __name((message) => console.log(`warn: ${message}`), "warn");
|
|
80
110
|
constructor(options, root = process.cwd(), custom = {}) {
|
|
81
111
|
this.root = root;
|
|
82
112
|
this.ignore = new Ignore(options.ignore);
|
|
@@ -88,6 +118,7 @@ var Config = class _Config {
|
|
|
88
118
|
const file = join(root, `${name}.config.${extension}`);
|
|
89
119
|
if (existsSync(file)) {
|
|
90
120
|
const { default: options } = await import(pathToFileURL(file).href);
|
|
121
|
+
_Config.#check(`${name}.config.${extension}`, options);
|
|
91
122
|
return new _Config(
|
|
92
123
|
_Config.merge(defaults, options),
|
|
93
124
|
root,
|
|
@@ -97,12 +128,26 @@ var Config = class _Config {
|
|
|
97
128
|
}
|
|
98
129
|
return new _Config(defaults, root);
|
|
99
130
|
}
|
|
131
|
+
/** Fails clearly on a config file that does not export the shape of the defaults. */
|
|
132
|
+
static #check(file, options) {
|
|
133
|
+
if (options === void 0) return;
|
|
134
|
+
if (options?.constructor !== Object)
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${file} must export default an object, like { ignore: [], rules: {} }, not ${Array.isArray(options) ? "an array" : typeof options}.`
|
|
137
|
+
);
|
|
138
|
+
const { ignore } = options;
|
|
139
|
+
if (ignore !== void 0 && !(Array.isArray(ignore) && ignore.every((item) => typeof item === "string")))
|
|
140
|
+
throw new Error(`${file}: ignore must be a list of strings.`);
|
|
141
|
+
}
|
|
100
142
|
/** Handles `--config`, writing the defaults to a TypeScript config file, or with `--config js` a JavaScript one. Returns whether it was given. */
|
|
101
143
|
static init(name, defaults, argv = process.argv.slice(2), root = process.cwd()) {
|
|
102
|
-
const index = argv.
|
|
144
|
+
const index = argv.findIndex(
|
|
145
|
+
(arg) => arg === "--config" || arg.startsWith("--config=")
|
|
146
|
+
);
|
|
103
147
|
if (index === -1) return false;
|
|
104
|
-
const
|
|
105
|
-
const
|
|
148
|
+
const [, inline] = argv[index].split(/=(.*)/s);
|
|
149
|
+
const next = inline ?? argv[index + 1];
|
|
150
|
+
const extension = next === void 0 || next === "" || next.startsWith("-") ? "ts" : next;
|
|
106
151
|
try {
|
|
107
152
|
console.log(
|
|
108
153
|
`created: ${_Config.export(name, defaults, extension, root)}`
|
|
@@ -162,24 +207,93 @@ ${items.map((item) => `${inner}${item},
|
|
|
162
207
|
merged[key] = _Config.merge(base[key], value);
|
|
163
208
|
return merged;
|
|
164
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* The files that are not ignored, relative to the root. Symlinks are
|
|
212
|
+
* followed, a folder by every path to it, but not into a folder it is in,
|
|
213
|
+
* which would never end; broken symlinks and unreadable folders are
|
|
214
|
+
* reported and left out, like pipes and other special files.
|
|
215
|
+
*/
|
|
165
216
|
*files(directory = "") {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
217
|
+
yield* this.#walk(directory, /* @__PURE__ */ new Set());
|
|
218
|
+
}
|
|
219
|
+
*#walk(directory, ancestors) {
|
|
220
|
+
const absolute = join(this.root, directory);
|
|
221
|
+
let entries;
|
|
222
|
+
let real;
|
|
223
|
+
try {
|
|
224
|
+
real = realpathSync(absolute);
|
|
225
|
+
if (ancestors.has(real)) return;
|
|
226
|
+
entries = readdirSync(absolute, { withFileTypes: true });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.warn(
|
|
229
|
+
`${directory || "."}: the folder cannot be read (${_Config.#reason(error)}), left out.`
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
ancestors.add(real);
|
|
169
234
|
for (const entry of entries) {
|
|
170
235
|
const path = directory ? `${directory}/${entry.name}` : entry.name;
|
|
171
|
-
|
|
172
|
-
if (entry.
|
|
173
|
-
|
|
236
|
+
let kind = entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "other";
|
|
237
|
+
if (entry.isSymbolicLink())
|
|
238
|
+
try {
|
|
239
|
+
const target = statSync(join(this.root, path));
|
|
240
|
+
kind = target.isDirectory() ? "directory" : target.isFile() ? "file" : "other";
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (!this.ignore.matches(path))
|
|
243
|
+
this.warn(
|
|
244
|
+
`${path}: a broken symlink (${_Config.#reason(error)}), left out.`
|
|
245
|
+
);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (this.ignore.matches(path, kind === "directory")) continue;
|
|
249
|
+
if (kind === "directory") yield* this.#walk(path, ancestors);
|
|
250
|
+
else if (kind === "file") yield path;
|
|
174
251
|
}
|
|
252
|
+
ancestors.delete(real);
|
|
175
253
|
}
|
|
176
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Text files that are not ignored and that accepts takes. Binaries, with
|
|
256
|
+
* a NUL byte, are skipped; files that are not UTF-8 are reported and
|
|
257
|
+
* skipped, as writing them back as UTF-8 would change their text.
|
|
258
|
+
*/
|
|
177
259
|
*sources(accepts = () => true) {
|
|
260
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
178
261
|
for (const file of this.files()) {
|
|
179
262
|
if (!accepts(file)) continue;
|
|
180
263
|
const path = join(this.root, file);
|
|
181
|
-
|
|
182
|
-
|
|
264
|
+
let bytes;
|
|
265
|
+
try {
|
|
266
|
+
bytes = readFileSync(path);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
this.warn(
|
|
269
|
+
`${file}: the file cannot be read (${_Config.#reason(error)}), left out.`
|
|
270
|
+
);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (bytes.includes(0)) continue;
|
|
274
|
+
let text;
|
|
275
|
+
try {
|
|
276
|
+
text = utf8.decode(bytes);
|
|
277
|
+
} catch {
|
|
278
|
+
this.warn(`${file}: not UTF-8, left out.`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
yield { file, path, text };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Why a file system call failed, in words. */
|
|
285
|
+
static #reason(error) {
|
|
286
|
+
const code = error?.code;
|
|
287
|
+
switch (code) {
|
|
288
|
+
case "ENOENT":
|
|
289
|
+
return "its target does not exist";
|
|
290
|
+
case "EACCES":
|
|
291
|
+
case "EPERM":
|
|
292
|
+
return "no permission";
|
|
293
|
+
case "ELOOP":
|
|
294
|
+
return "a loop of symlinks";
|
|
295
|
+
default:
|
|
296
|
+
return error instanceof Error ? error.message : String(error);
|
|
183
297
|
}
|
|
184
298
|
}
|
|
185
299
|
};
|
|
@@ -190,9 +304,10 @@ var Includes = class {
|
|
|
190
304
|
__name(this, "Includes");
|
|
191
305
|
}
|
|
192
306
|
#globs;
|
|
193
|
-
|
|
307
|
+
/** With the name of the setting the globs come from, for their errors. */
|
|
308
|
+
constructor(globs, setting = "includes") {
|
|
194
309
|
this.#globs = globs.map((glob) => ({
|
|
195
|
-
pattern: new Pattern(glob.replace(/^!/, "")),
|
|
310
|
+
pattern: new Pattern(glob.replace(/^!/, ""), setting),
|
|
196
311
|
included: !glob.startsWith("!")
|
|
197
312
|
}));
|
|
198
313
|
}
|
package/bin/types/config.d.ts
CHANGED
|
@@ -17,6 +17,11 @@ export default class Config<Rules extends object = object> {
|
|
|
17
17
|
readonly rules: Rules;
|
|
18
18
|
/** What the config file set, before the defaults were merged in. */
|
|
19
19
|
readonly custom: Partial<Options<Rules>>;
|
|
20
|
+
/**
|
|
21
|
+
* Reports files and folders that are left out, like a broken symlink, an
|
|
22
|
+
* unreadable folder or a file that is not UTF-8, as a warning by default.
|
|
23
|
+
*/
|
|
24
|
+
warn: (message: string) => void;
|
|
20
25
|
constructor(options: Options<Rules>, root?: string, custom?: Partial<Options<Rules>>);
|
|
21
26
|
static load<Rules extends object>(name: string, defaults: Options<Rules>, root?: string): Promise<Config<Rules>>;
|
|
22
27
|
/** Handles `--config`, writing the defaults to a TypeScript config file, or with `--config js` a JavaScript one. Returns whether it was given. */
|
|
@@ -25,7 +30,17 @@ export default class Config<Rules extends object = object> {
|
|
|
25
30
|
static export(name: string, defaults: Options, extension?: string, root?: string): string;
|
|
26
31
|
/** Deep merges plain objects, anything else in override replaces base. */
|
|
27
32
|
static merge<T>(base: T, override: unknown): T;
|
|
33
|
+
/**
|
|
34
|
+
* The files that are not ignored, relative to the root. Symlinks are
|
|
35
|
+
* followed, a folder by every path to it, but not into a folder it is in,
|
|
36
|
+
* which would never end; broken symlinks and unreadable folders are
|
|
37
|
+
* reported and left out, like pipes and other special files.
|
|
38
|
+
*/
|
|
28
39
|
files(directory?: string): Generator<string>;
|
|
29
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Text files that are not ignored and that accepts takes. Binaries, with
|
|
42
|
+
* a NUL byte, are skipped; files that are not UTF-8 are reported and
|
|
43
|
+
* skipped, as writing them back as UTF-8 would change their text.
|
|
44
|
+
*/
|
|
30
45
|
sources(accepts?: (file: string) => boolean): Generator<Source>;
|
|
31
46
|
}
|
package/bin/types/ignore.d.ts
CHANGED
package/bin/types/includes.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** `includes`: globs in order, the last one matching a file decides, `!` excludes. */
|
|
2
2
|
export default class Includes {
|
|
3
3
|
#private;
|
|
4
|
-
|
|
4
|
+
/** With the name of the setting the globs come from, for their errors. */
|
|
5
|
+
constructor(globs: readonly string[], setting?: string);
|
|
5
6
|
matches(file: string): boolean;
|
|
6
7
|
}
|
package/bin/types/pattern.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A glob of paths relative to the root, like `.gitignore`'s: one without a
|
|
3
|
+
* `/` matches a file or folder of that name anywhere, one with a `/` a path
|
|
4
|
+
* from the root, also when it starts with `/` or `./`, and one that ends
|
|
5
|
+
* with `/` only folders. A folder that matches also matches its contents.
|
|
6
|
+
*/
|
|
1
7
|
export default class Pattern {
|
|
2
8
|
#private;
|
|
3
|
-
|
|
4
|
-
|
|
9
|
+
/** With the name of the setting the glob comes from, for its errors. */
|
|
10
|
+
constructor(source: string, setting?: string);
|
|
11
|
+
/**
|
|
12
|
+
* Whether a path, or a folder it is in, matches. Whether the path itself
|
|
13
|
+
* is a folder matters to globs ending with `/`.
|
|
14
|
+
*/
|
|
15
|
+
matches(path: string, directory?: boolean): boolean;
|
|
5
16
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aljazmc/config",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Shared, dependency-free configuration loader, file finder and glob matcher for @aljazmc packages.",
|
|
5
5
|
"author": "aljazmc",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@jest/globals": "^30.5.2",
|
|
35
34
|
"@types/node": "^26.6.6"
|
|
36
35
|
}
|
|
37
36
|
}
|