@sheplu/editorconfig 0.8.7 → 0.9.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/README.md +69 -5
- package/index.js +270 -45
- package/package.json +17 -3
- package/src/check.js +175 -0
- package/{.editorconfig → src/templates/base.js} +2 -1
- package/src/templates/css.js +4 -0
- package/src/templates/custom-template.js +123 -0
- package/src/templates/dockerfile.js +4 -0
- package/src/templates/go.js +5 -0
- package/src/templates/html.js +4 -0
- package/src/templates/index.js +277 -0
- package/src/templates/javascript.js +6 -0
- package/src/templates/json.js +4 -0
- package/src/templates/makefile.js +5 -0
- package/src/templates/markdown.js +5 -0
- package/src/templates/python.js +5 -0
- package/src/templates/rust.js +5 -0
- package/src/templates/shell.js +4 -0
- package/src/templates/template-source.js +24 -0
- package/src/templates/terraform.js +4 -0
- package/src/templates/toml.js +4 -0
- package/src/templates/yaml.js +4 -0
- package/src/utils/fetch.js +104 -0
- package/src/utils/url.js +23 -0
- package/.github/workflows/npm-release.yaml +0 -42
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ A small CLI to manage a **consistent `.editorconfig`** across your projects.
|
|
|
4
4
|
|
|
5
5
|
- ✅ Generate a sane default `.editorconfig` in seconds
|
|
6
6
|
- ✅ Check if your existing file matches the target setup
|
|
7
|
-
-
|
|
8
|
-
-
|
|
7
|
+
- ✅ Confirm before overwriting an existing `.editorconfig` (or pass `--overwrite` to skip the prompt)
|
|
8
|
+
- ✅ Override the built-in template with a team-shared file via `--template` (local path or `https://` URL)
|
|
9
9
|
|
|
10
10
|
## Why?
|
|
11
11
|
|
|
@@ -49,8 +49,8 @@ npx @sheplu/editorconfig --mode=write
|
|
|
49
49
|
This will:
|
|
50
50
|
|
|
51
51
|
- Create a `.editorconfig` file if it doesn’t exist
|
|
52
|
-
-
|
|
53
|
-
-
|
|
52
|
+
- Write the default template
|
|
53
|
+
- Ask before overwriting an existing file (or fail in non-interactive contexts unless `--overwrite` is passed)
|
|
54
54
|
|
|
55
55
|
## Current Features
|
|
56
56
|
|
|
@@ -62,6 +62,23 @@ npx @sheplu/editorconfig --mode=write
|
|
|
62
62
|
|
|
63
63
|
Creates a base `.editorconfig` file in the current directory.
|
|
64
64
|
|
|
65
|
+
If a `.editorconfig` already exists at the target path, the CLI will:
|
|
66
|
+
|
|
67
|
+
- Prompt for confirmation in an interactive terminal (`y` / `yes` to overwrite, anything else keeps the file).
|
|
68
|
+
- Exit non-zero in non-interactive contexts (CI, redirected stdin) so a script never silently destroys an existing config.
|
|
69
|
+
|
|
70
|
+
Pass `--overwrite` (or `-o`) to bypass the prompt and force a rewrite:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx @sheplu/editorconfig --mode=write --overwrite
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
You can also point at a custom path:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx @sheplu/editorconfig --mode=write --path=path/to/.editorconfig
|
|
80
|
+
```
|
|
81
|
+
|
|
65
82
|
Typical content (example):
|
|
66
83
|
|
|
67
84
|
```ini
|
|
@@ -96,6 +113,36 @@ This command will:
|
|
|
96
113
|
- `0` if everything matches
|
|
97
114
|
- `1` if differences are found
|
|
98
115
|
|
|
116
|
+
### `--template` (custom team template)
|
|
117
|
+
|
|
118
|
+
Both `write` and `check` accept a `--template` (or `-t`) flag pointing at a custom `.editorconfig`-syntax file. Sections in that file override the built-in defaults; languages it doesn't redefine still come from the built-ins. This lets a team host a single source of truth and reference it from every repo.
|
|
119
|
+
|
|
120
|
+
The flag accepts either a local path or an `https://` URL:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# local file
|
|
124
|
+
npx @sheplu/editorconfig --mode=write --template=./team.editorconfig
|
|
125
|
+
|
|
126
|
+
# remote URL
|
|
127
|
+
npx @sheplu/editorconfig --mode=check --template=https://raw.githubusercontent.com/example/team-config/main/.editorconfig
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The custom template must follow the same semantics as the built-ins:
|
|
131
|
+
|
|
132
|
+
- Use only known section headers (`[*]`, `[*.md]`, `[*.{js,jsx,...}]`, etc.). Unknown headers (like `[*.proto]`) are rejected.
|
|
133
|
+
- Include `root = true` in the preamble whenever the template redefines `[*]`.
|
|
134
|
+
- Each header may appear at most once.
|
|
135
|
+
|
|
136
|
+
URL fetching is constrained for safety: `https://` only (`http://` is rejected before any network call), redirects must stay on https (max 5 hops), 10-second timeout, 1 MB response cap. Templates are fetched on every invocation — there is no local cache.
|
|
137
|
+
|
|
138
|
+
### `--help`
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npx @sheplu/editorconfig --help
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Prints the full usage, the available commands, and every supported option.
|
|
145
|
+
|
|
99
146
|
## Planned / Upcoming Features
|
|
100
147
|
|
|
101
148
|
### 1. Interactive update / replace
|
|
@@ -115,10 +162,27 @@ npx @sheplu/editorconfig --mode=diff
|
|
|
115
162
|
- [ ] Add diff logic and `diff` command
|
|
116
163
|
- [ ] Add interactive `fix` / `update` command
|
|
117
164
|
- [ ] Expose presets or configuration options
|
|
118
|
-
- [ ] Add tests and CI examples
|
|
119
165
|
|
|
120
166
|
Additional properties can be found on the [editorconfig wiki](https://github.com/editorconfig/editorconfig/wiki/editorconfig-properties).
|
|
121
167
|
|
|
168
|
+
## Tests
|
|
169
|
+
|
|
170
|
+
Tests live under `test/` and are split by scope:
|
|
171
|
+
|
|
172
|
+
- `test/unit/` — fast, in-process tests of exported functions (no spawning, no I/O beyond a tmp dir).
|
|
173
|
+
- `test/integration/` — full CLI runs. The interactive prompt path is exercised in a real pseudoterminal via [`node-pty`](https://github.com/microsoft/node-pty); the rest go through `child_process.spawnSync` with a closed stdin.
|
|
174
|
+
|
|
175
|
+
Run them with:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npm test # everything
|
|
179
|
+
npm run test:unit # unit only — runs in ~50ms
|
|
180
|
+
npm run test:integration # integration only — spawns the CLI
|
|
181
|
+
npm run lint # oxlint
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
CI runs lint, audit, and the full suite on every PR across Node 24 / 26.
|
|
185
|
+
|
|
122
186
|
## Documentation
|
|
123
187
|
|
|
124
188
|
[Editorconfig documentation](https://github.com/editorconfig/editorconfig/wiki/editorconfig-properties)
|
package/index.js
CHANGED
|
@@ -1,60 +1,285 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
4
5
|
import { parseArgs } from 'node:util';
|
|
6
|
+
import {
|
|
7
|
+
ALIASES,
|
|
8
|
+
AVAILABLE_LANGUAGES,
|
|
9
|
+
composeEditorConfig,
|
|
10
|
+
EMPTY_OVERRIDES,
|
|
11
|
+
} from './src/templates/index.js';
|
|
12
|
+
import { loadCustomTemplate } from './src/templates/custom-template.js';
|
|
13
|
+
import { compareEditorConfig, NO_LANGUAGE_FILTER, runCheck } from './src/check.js';
|
|
5
14
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
[
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
export { compareEditorConfig };
|
|
16
|
+
|
|
17
|
+
export function createEditorConfig(path = '.editorconfig', languages = [], overrides = EMPTY_OVERRIDES) {
|
|
18
|
+
writeFileSync(path, composeEditorConfig(languages, overrides), 'utf8');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const options = {
|
|
22
|
+
mode: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
short: 'm',
|
|
25
|
+
},
|
|
26
|
+
path: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
short: 'p',
|
|
29
|
+
},
|
|
30
|
+
languages: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
short: 'l',
|
|
33
|
+
},
|
|
34
|
+
help: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
short: 'h',
|
|
37
|
+
},
|
|
38
|
+
overwrite: {
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
short: 'o',
|
|
41
|
+
},
|
|
42
|
+
strict: {
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
short: 's',
|
|
45
|
+
},
|
|
46
|
+
template: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
short: 't',
|
|
49
|
+
},
|
|
23
50
|
};
|
|
24
51
|
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
export const NOT_PROVIDED = Symbol('languages-not-provided');
|
|
53
|
+
|
|
54
|
+
export function parseLanguages(raw) {
|
|
55
|
+
if (typeof raw !== 'string') {
|
|
56
|
+
return NOT_PROVIDED;
|
|
29
57
|
}
|
|
30
|
-
|
|
31
|
-
|
|
58
|
+
return raw
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((token) => token.trim().toLowerCase())
|
|
61
|
+
.filter((token) => token.length > 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatAliasList() {
|
|
65
|
+
const grouped = new Map();
|
|
66
|
+
for (const [alias, target] of Object.entries(ALIASES)) {
|
|
67
|
+
const list = grouped.get(target) ?? [];
|
|
68
|
+
list.push(alias);
|
|
69
|
+
grouped.set(target, list);
|
|
32
70
|
}
|
|
71
|
+
return [...grouped.entries()]
|
|
72
|
+
.map(([target, aliases]) => `${aliases.join(', ')} -> ${target}`)
|
|
73
|
+
.join('; ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function printHelp() {
|
|
77
|
+
console.log(`Usage: editorconfig --mode=<command> [--path=<path>] [--languages=<list>] [--template=<path|url>]
|
|
78
|
+
|
|
79
|
+
Commands:
|
|
80
|
+
write Create a .editorconfig file with the selected language sections
|
|
81
|
+
check Validate per-section against the canonical templates
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
-m, --mode Command to run (write | check)
|
|
85
|
+
-p, --path Path to the .editorconfig file (default: .editorconfig)
|
|
86
|
+
-l, --languages Comma-separated language sections (write: which to emit; check: required set)
|
|
87
|
+
-o, --overwrite Overwrite an existing .editorconfig without confirmation
|
|
88
|
+
-s, --strict Treat unknown section headers as failures (check only)
|
|
89
|
+
-t, --template Path or https URL to a custom .editorconfig-syntax file whose sections override the built-in ones
|
|
90
|
+
-h, --help Show this help message
|
|
91
|
+
|
|
92
|
+
Languages: ${AVAILABLE_LANGUAGES.join(', ')}
|
|
93
|
+
Aliases: ${formatAliasList()}
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
editorconfig --mode=write --languages=js,md
|
|
97
|
+
editorconfig --mode=write --languages= # base only
|
|
98
|
+
editorconfig --mode=write # interactive in TTY, base only otherwise
|
|
99
|
+
editorconfig --mode=check # validate sections present in the file
|
|
100
|
+
editorconfig --mode=check --languages=js,md # require exactly base + js + md
|
|
101
|
+
editorconfig --mode=check --strict # fail on any unknown section header
|
|
102
|
+
editorconfig --mode=write --template=./team.editorconfig
|
|
103
|
+
editorconfig --mode=check --template=./team.editorconfig
|
|
104
|
+
editorconfig --mode=check --template=https://team.example.com/.editorconfig`);
|
|
33
105
|
};
|
|
34
106
|
|
|
35
|
-
function
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
107
|
+
function resolvePromptAnswer(answer) {
|
|
108
|
+
const tokens = answer
|
|
109
|
+
.split(',')
|
|
110
|
+
.map((token) => token.trim().toLowerCase())
|
|
111
|
+
.filter((token) => token.length > 0);
|
|
112
|
+
return tokens.map((token) => {
|
|
113
|
+
const asNumber = Number.parseInt(token, 10);
|
|
114
|
+
if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= AVAILABLE_LANGUAGES.length) {
|
|
115
|
+
return AVAILABLE_LANGUAGES[asNumber - 1];
|
|
116
|
+
}
|
|
117
|
+
return token;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function promptLanguages() {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
124
|
+
const numbered = AVAILABLE_LANGUAGES
|
|
125
|
+
.map((name, index) => ` ${index + 1}. ${name}`)
|
|
126
|
+
.join('\n');
|
|
127
|
+
console.log(`Available language sections:\n${numbered}`);
|
|
128
|
+
rl.question('Languages? [comma-separated names or indices, blank=base only] ', (answer) => {
|
|
129
|
+
rl.close();
|
|
130
|
+
resolve(resolvePromptAnswer(answer));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveLanguages(parsedLanguages) {
|
|
136
|
+
if (parsedLanguages !== NOT_PROVIDED) {
|
|
137
|
+
return Promise.resolve(parsedLanguages);
|
|
138
|
+
}
|
|
139
|
+
if (process.stdin.isTTY) {
|
|
140
|
+
return promptLanguages();
|
|
141
|
+
}
|
|
142
|
+
return Promise.resolve([]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function confirmOverwrite(path) {
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
148
|
+
rl.question(`\`${path}\` already exists. Overwrite? [y/N] `, (answer) => {
|
|
149
|
+
rl.close();
|
|
150
|
+
resolve(/^y(es)?$/iu.test(answer.trim()));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function handleExistingTarget(path, languages, overrides) {
|
|
156
|
+
if (!process.stdin.isTTY) {
|
|
157
|
+
console.error(`\`${path}\` already exists. Use --overwrite to replace it.`);
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const confirmed = await confirmOverwrite(path);
|
|
162
|
+
if (confirmed) {
|
|
163
|
+
createEditorConfig(path, languages, overrides);
|
|
54
164
|
}
|
|
55
165
|
else {
|
|
56
|
-
console.
|
|
166
|
+
console.log(`Skipped: \`${path}\` was not modified.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function runWrite({ path, overwrite, parsedLanguages, overrides }) {
|
|
171
|
+
const languages = await resolveLanguages(parsedLanguages);
|
|
172
|
+
if (!existsSync(path) || overwrite) {
|
|
173
|
+
createEditorConfig(path, languages, overrides);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await handleExistingTarget(path, languages, overrides);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function dispatchCheck({ path, languages, strict, overrides }) {
|
|
180
|
+
let filter = languages;
|
|
181
|
+
if (filter === NOT_PROVIDED) {
|
|
182
|
+
filter = NO_LANGUAGE_FILTER;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
runCheck({ path, parsedLanguages: filter, strict, overrides });
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
console.error(error.message);
|
|
189
|
+
process.exitCode = 1;
|
|
57
190
|
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function dispatchWrite({ path, overwrite, languages, overrides }) {
|
|
194
|
+
try {
|
|
195
|
+
await runWrite({ path, overwrite, parsedLanguages: languages, overrides });
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
console.error(error.message);
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function runCommand({ mode, path, overwrite, languages, strict, overrides }) {
|
|
204
|
+
if (mode === 'write') {
|
|
205
|
+
await dispatchWrite({ path, overwrite, languages, overrides });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (mode === 'check') {
|
|
209
|
+
dispatchCheck({ path, languages, strict, overrides });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
console.error('invalid command');
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function formatCliError(error) {
|
|
217
|
+
if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
|
|
218
|
+
return `${error.message.split('.')[0]}. See --help.`;
|
|
219
|
+
}
|
|
220
|
+
return error.message;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseCliArgs(args) {
|
|
224
|
+
try {
|
|
225
|
+
return parseArgs({ args, options });
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
console.error(formatCliError(error));
|
|
229
|
+
process.exitCode = 1;
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const OVERRIDES_FAILED = Symbol('overrides-failed');
|
|
235
|
+
|
|
236
|
+
async function resolveOverrides(templateValue) {
|
|
237
|
+
if (typeof templateValue !== 'string') {
|
|
238
|
+
return EMPTY_OVERRIDES;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
return await loadCustomTemplate(templateValue);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
console.error(error.message);
|
|
245
|
+
process.exitCode = 1;
|
|
246
|
+
return OVERRIDES_FAILED;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function dispatchValues(values) {
|
|
251
|
+
const overrides = await resolveOverrides(values.template);
|
|
252
|
+
if (overrides === OVERRIDES_FAILED) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await runCommand({
|
|
256
|
+
mode: values.mode,
|
|
257
|
+
path: values.path || '.editorconfig',
|
|
258
|
+
overwrite: values.overwrite,
|
|
259
|
+
languages: parseLanguages(values.languages),
|
|
260
|
+
strict: values.strict,
|
|
261
|
+
overrides,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function main() {
|
|
266
|
+
const parsed = parseCliArgs(process.argv.slice(2));
|
|
267
|
+
if (!parsed) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (parsed.values.help) {
|
|
271
|
+
printHelp();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await dispatchValues(parsed.values);
|
|
58
275
|
};
|
|
59
276
|
|
|
60
|
-
|
|
277
|
+
if (process.argv[1] === import.meta.filename) {
|
|
278
|
+
try {
|
|
279
|
+
await main();
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
console.error(error.message);
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sheplu/editorconfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"src/"
|
|
9
|
+
],
|
|
6
10
|
"scripts": {
|
|
7
|
-
"
|
|
11
|
+
"lint": "oxlint",
|
|
12
|
+
"test": "node --test",
|
|
13
|
+
"test:unit": "node --test 'test/unit/*.test.js'",
|
|
14
|
+
"test:integration": "node --test 'test/integration/*.test.js'"
|
|
8
15
|
},
|
|
9
16
|
"bin": {
|
|
10
17
|
"@sheplu/editorconfig": "index.js"
|
|
@@ -24,8 +31,15 @@
|
|
|
24
31
|
"author": "Jean Burellier",
|
|
25
32
|
"license": "MIT",
|
|
26
33
|
"type": "module",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=24"
|
|
36
|
+
},
|
|
27
37
|
"bugs": {
|
|
28
38
|
"url": "https://github.com/sheplu/editorconfig/issues"
|
|
29
39
|
},
|
|
30
|
-
"homepage": "https://github.com/sheplu/editorconfig#readme"
|
|
40
|
+
"homepage": "https://github.com/sheplu/editorconfig#readme",
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"node-pty": "1.0.0",
|
|
43
|
+
"oxlint": "^1.64.0"
|
|
44
|
+
}
|
|
31
45
|
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
BASE_SECTION_HEADER,
|
|
4
|
+
compareSection,
|
|
5
|
+
EMPTY_OVERRIDES,
|
|
6
|
+
expectedBodyForLanguage,
|
|
7
|
+
headerToLanguage,
|
|
8
|
+
languageToHeader,
|
|
9
|
+
parseSections,
|
|
10
|
+
resolveLanguageNames,
|
|
11
|
+
} from './templates/index.js';
|
|
12
|
+
|
|
13
|
+
export const NO_LANGUAGE_FILTER = Symbol('check-no-language-filter');
|
|
14
|
+
|
|
15
|
+
const STATUS_GLYPH = {
|
|
16
|
+
match: '✅',
|
|
17
|
+
mismatch: '❌',
|
|
18
|
+
missing: '❌',
|
|
19
|
+
'no-root': '❌',
|
|
20
|
+
unknown: '⚠️ ',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const STATUS_DETAIL = {
|
|
24
|
+
match: '',
|
|
25
|
+
mismatch: 'section body does not match',
|
|
26
|
+
missing: 'missing',
|
|
27
|
+
'no-root': "missing 'root = true' before sections",
|
|
28
|
+
unknown: 'unknown header (not validated)',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function languageForHeader(header) {
|
|
32
|
+
if (header === BASE_SECTION_HEADER) {
|
|
33
|
+
return 'base';
|
|
34
|
+
}
|
|
35
|
+
return headerToLanguage(header);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkSection(section, overrides) {
|
|
39
|
+
const language = languageForHeader(section.header);
|
|
40
|
+
if (!language) {
|
|
41
|
+
return { header: section.header, status: 'unknown' };
|
|
42
|
+
}
|
|
43
|
+
const expected = expectedBodyForLanguage(language, overrides);
|
|
44
|
+
const { ok } = compareSection(section.body, expected);
|
|
45
|
+
if (ok) {
|
|
46
|
+
return { header: section.header, status: 'match' };
|
|
47
|
+
}
|
|
48
|
+
return { header: section.header, status: 'mismatch' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildExpectedHeaders(parsedLanguages) {
|
|
52
|
+
const resolved = resolveLanguageNames(parsedLanguages);
|
|
53
|
+
return [BASE_SECTION_HEADER, ...resolved.map((name) => languageToHeader(name))];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildResults({ parsedLanguages, parsed, overrides }) {
|
|
57
|
+
const results = parsed.sections.map((section) => checkSection(section, overrides));
|
|
58
|
+
if (parsedLanguages !== NO_LANGUAGE_FILTER) {
|
|
59
|
+
const present = new Set(parsed.sections.map((section) => section.header));
|
|
60
|
+
const expectedHeaders = buildExpectedHeaders(parsedLanguages);
|
|
61
|
+
for (const header of expectedHeaders) {
|
|
62
|
+
if (!present.has(header)) {
|
|
63
|
+
results.push({ header, status: 'missing' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildBaseIssues(parsed) {
|
|
71
|
+
const issues = [];
|
|
72
|
+
const hasBase = parsed.sections.some((section) => section.header === BASE_SECTION_HEADER);
|
|
73
|
+
if (!hasBase) {
|
|
74
|
+
issues.push({ header: BASE_SECTION_HEADER, status: 'missing' });
|
|
75
|
+
}
|
|
76
|
+
else if (!parsed.hasRoot) {
|
|
77
|
+
issues.push({ header: BASE_SECTION_HEADER, status: 'no-root' });
|
|
78
|
+
}
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function compareEditorConfig(path = '.editorconfig', parsedLanguages = NO_LANGUAGE_FILTER, overrides = EMPTY_OVERRIDES) {
|
|
83
|
+
if (!existsSync(path)) {
|
|
84
|
+
throw new Error(`'${path}' does not exist`);
|
|
85
|
+
}
|
|
86
|
+
const text = readFileSync(path, 'utf8');
|
|
87
|
+
const parsed = parseSections(text);
|
|
88
|
+
const baseIssues = buildBaseIssues(parsed);
|
|
89
|
+
const results = buildResults({ parsedLanguages, parsed, overrides });
|
|
90
|
+
return { baseIssues, results };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatLine({ header, status }) {
|
|
94
|
+
const glyph = STATUS_GLYPH[status];
|
|
95
|
+
const detail = STATUS_DETAIL[status];
|
|
96
|
+
if (detail) {
|
|
97
|
+
return ` ${glyph} ${header} ${detail}`;
|
|
98
|
+
}
|
|
99
|
+
return ` ${glyph} ${header}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function reportIsFailing({ baseIssues, results }, strict) {
|
|
103
|
+
const failing = [...baseIssues, ...results].some((entry) =>
|
|
104
|
+
entry.status === 'mismatch' || entry.status === 'missing' || entry.status === 'no-root',
|
|
105
|
+
);
|
|
106
|
+
const hasUnknown = results.some((entry) => entry.status === 'unknown');
|
|
107
|
+
return failing || (strict && hasUnknown);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function printReport(path, report) {
|
|
111
|
+
console.log(`Checking ${path}`);
|
|
112
|
+
console.log('');
|
|
113
|
+
for (const issue of report.baseIssues) {
|
|
114
|
+
console.log(formatLine(issue));
|
|
115
|
+
}
|
|
116
|
+
for (const entry of report.results) {
|
|
117
|
+
console.log(formatLine(entry));
|
|
118
|
+
}
|
|
119
|
+
console.log('');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function summarize(report) {
|
|
123
|
+
const lines = [...report.baseIssues, ...report.results];
|
|
124
|
+
const total = lines.length;
|
|
125
|
+
const matched = lines.filter((entry) => entry.status === 'match').length;
|
|
126
|
+
const failed = lines.filter((entry) =>
|
|
127
|
+
entry.status === 'mismatch' || entry.status === 'missing' || entry.status === 'no-root',
|
|
128
|
+
).length;
|
|
129
|
+
const unknown = lines.filter((entry) => entry.status === 'unknown').length;
|
|
130
|
+
return { total, matched, failed, unknown };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pluralize(count, singular) {
|
|
134
|
+
if (count === 1) {
|
|
135
|
+
return singular;
|
|
136
|
+
}
|
|
137
|
+
return `${singular}s`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatFailureSummary({ total, failed, unknown }) {
|
|
141
|
+
const reasons = [];
|
|
142
|
+
if (failed > 0) {
|
|
143
|
+
reasons.push(`${failed} of ${total} ${pluralize(total, 'section')} did not match`);
|
|
144
|
+
}
|
|
145
|
+
if (unknown > 0) {
|
|
146
|
+
reasons.push(`${unknown} unknown ${pluralize(unknown, 'header')}`);
|
|
147
|
+
}
|
|
148
|
+
return `❌ FAIL — ${reasons.join('; ')}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatPassSummary({ matched, unknown }) {
|
|
152
|
+
const matchedLabel = `${matched} ${pluralize(matched, 'section')} matched`;
|
|
153
|
+
if (unknown > 0) {
|
|
154
|
+
return `✅ PASS — ${matchedLabel} (${unknown} unknown ${pluralize(unknown, 'header')} ignored)`;
|
|
155
|
+
}
|
|
156
|
+
return `✅ PASS — ${matchedLabel}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatSummary(report, isFailure) {
|
|
160
|
+
const counts = summarize(report);
|
|
161
|
+
if (isFailure) {
|
|
162
|
+
return formatFailureSummary(counts);
|
|
163
|
+
}
|
|
164
|
+
return formatPassSummary(counts);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function runCheck({ path, parsedLanguages, strict, overrides }) {
|
|
168
|
+
const report = compareEditorConfig(path, parsedLanguages, overrides);
|
|
169
|
+
printReport(path, report);
|
|
170
|
+
const failed = reportIsFailing(report, strict);
|
|
171
|
+
console.log(formatSummary(report, failed));
|
|
172
|
+
if (failed) {
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AVAILABLE_LANGUAGES,
|
|
3
|
+
BASE_SECTION_HEADER,
|
|
4
|
+
headerToLanguage,
|
|
5
|
+
languageToHeader,
|
|
6
|
+
parseSections,
|
|
7
|
+
} from './index.js';
|
|
8
|
+
import { readTemplateText } from './template-source.js';
|
|
9
|
+
|
|
10
|
+
const HEADER_LINE = /^\[.*\]$/u;
|
|
11
|
+
const NO_HEADER = '';
|
|
12
|
+
|
|
13
|
+
function knownHeaders() {
|
|
14
|
+
return [BASE_SECTION_HEADER, ...AVAILABLE_LANGUAGES.map((name) => languageToHeader(name))];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function languageForHeader(header) {
|
|
18
|
+
if (header === BASE_SECTION_HEADER) {
|
|
19
|
+
return 'base';
|
|
20
|
+
}
|
|
21
|
+
return headerToLanguage(header);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function trimTrailingBlankLines(lines) {
|
|
25
|
+
while (lines.length > 0 && lines.at(-1).trim() === '') {
|
|
26
|
+
lines.pop();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function flushBlock(state, blocks) {
|
|
31
|
+
if (state.header === NO_HEADER) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
trimTrailingBlankLines(state.lines);
|
|
35
|
+
blocks.set(state.header, [state.header, ...state.lines].join('\n'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function processLine(state, blocks, line) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (HEADER_LINE.test(trimmed)) {
|
|
41
|
+
flushBlock(state, blocks);
|
|
42
|
+
state.header = trimmed;
|
|
43
|
+
state.lines = [];
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (state.header !== NO_HEADER) {
|
|
47
|
+
state.lines.push(line);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractRawSections(text) {
|
|
52
|
+
const normalized = text.replaceAll(/\r\n?/gu, '\n');
|
|
53
|
+
const blocks = new Map();
|
|
54
|
+
const state = { header: NO_HEADER, lines: [] };
|
|
55
|
+
for (const line of normalized.split('\n')) {
|
|
56
|
+
processLine(state, blocks, line);
|
|
57
|
+
}
|
|
58
|
+
flushBlock(state, blocks);
|
|
59
|
+
return blocks;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rejectUnknownHeader(path, header) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`custom template '${path}' has unknown header '${header}'. Allowed: ${knownHeaders().join(', ')}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rejectDuplicate(path, header) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`custom template '${path}' declares header '${header}' more than once`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ingestSection({ path, section, accumulator }) {
|
|
75
|
+
const language = languageForHeader(section.header);
|
|
76
|
+
if (!language) {
|
|
77
|
+
rejectUnknownHeader(path, section.header);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (accumulator.seen.has(language)) {
|
|
81
|
+
rejectDuplicate(path, section.header);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
accumulator.seen.add(language);
|
|
85
|
+
accumulator.bodies.set(language, section.body);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildBodies(path, sections) {
|
|
89
|
+
const accumulator = { bodies: new Map(), seen: new Set() };
|
|
90
|
+
for (const section of sections) {
|
|
91
|
+
ingestSection({ path, section, accumulator });
|
|
92
|
+
}
|
|
93
|
+
return accumulator.bodies;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rawSectionFor(language, rawBlocks) {
|
|
97
|
+
if (language === 'base') {
|
|
98
|
+
return `root = true\n\n${rawBlocks.get(BASE_SECTION_HEADER)}`;
|
|
99
|
+
}
|
|
100
|
+
return rawBlocks.get(languageToHeader(language));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildRawSections(bodies, text) {
|
|
104
|
+
const rawBlocks = extractRawSections(text);
|
|
105
|
+
const rawSections = new Map();
|
|
106
|
+
for (const language of bodies.keys()) {
|
|
107
|
+
rawSections.set(language, rawSectionFor(language, rawBlocks));
|
|
108
|
+
}
|
|
109
|
+
return rawSections;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function loadCustomTemplate(input) {
|
|
113
|
+
const text = await readTemplateText(input);
|
|
114
|
+
const parsed = parseSections(text);
|
|
115
|
+
const bodies = buildBodies(input, parsed.sections);
|
|
116
|
+
if (bodies.has('base') && !parsed.hasRoot) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`custom template '${input}' redefines [*] but is missing 'root = true' in the preamble`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const rawSections = buildRawSections(bodies, text);
|
|
122
|
+
return { bodies, rawSections, hasRoot: parsed.hasRoot };
|
|
123
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { base } from './base.js';
|
|
2
|
+
import { javascript } from './javascript.js';
|
|
3
|
+
import { yaml } from './yaml.js';
|
|
4
|
+
import { markdown } from './markdown.js';
|
|
5
|
+
import { python } from './python.js';
|
|
6
|
+
import { go } from './go.js';
|
|
7
|
+
import { rust } from './rust.js';
|
|
8
|
+
import { terraform } from './terraform.js';
|
|
9
|
+
import { json } from './json.js';
|
|
10
|
+
import { toml } from './toml.js';
|
|
11
|
+
import { shell } from './shell.js';
|
|
12
|
+
import { makefile } from './makefile.js';
|
|
13
|
+
import { dockerfile } from './dockerfile.js';
|
|
14
|
+
import { html } from './html.js';
|
|
15
|
+
import { css } from './css.js';
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
base,
|
|
19
|
+
javascript,
|
|
20
|
+
yaml,
|
|
21
|
+
markdown,
|
|
22
|
+
python,
|
|
23
|
+
go,
|
|
24
|
+
rust,
|
|
25
|
+
terraform,
|
|
26
|
+
json,
|
|
27
|
+
toml,
|
|
28
|
+
shell,
|
|
29
|
+
makefile,
|
|
30
|
+
dockerfile,
|
|
31
|
+
html,
|
|
32
|
+
css,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const AVAILABLE_LANGUAGES = [
|
|
36
|
+
'javascript',
|
|
37
|
+
'yaml',
|
|
38
|
+
'markdown',
|
|
39
|
+
'python',
|
|
40
|
+
'go',
|
|
41
|
+
'rust',
|
|
42
|
+
'terraform',
|
|
43
|
+
'json',
|
|
44
|
+
'toml',
|
|
45
|
+
'shell',
|
|
46
|
+
'makefile',
|
|
47
|
+
'dockerfile',
|
|
48
|
+
'html',
|
|
49
|
+
'css',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export const ALIASES = {
|
|
53
|
+
js: 'javascript',
|
|
54
|
+
jsx: 'javascript',
|
|
55
|
+
ts: 'javascript',
|
|
56
|
+
tsx: 'javascript',
|
|
57
|
+
mjs: 'javascript',
|
|
58
|
+
cjs: 'javascript',
|
|
59
|
+
yml: 'yaml',
|
|
60
|
+
md: 'markdown',
|
|
61
|
+
py: 'python',
|
|
62
|
+
rs: 'rust',
|
|
63
|
+
tf: 'terraform',
|
|
64
|
+
tfvars: 'terraform',
|
|
65
|
+
sh: 'shell',
|
|
66
|
+
bash: 'shell',
|
|
67
|
+
zsh: 'shell',
|
|
68
|
+
mk: 'makefile',
|
|
69
|
+
htm: 'html',
|
|
70
|
+
scss: 'css',
|
|
71
|
+
sass: 'css',
|
|
72
|
+
less: 'css',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const LANGUAGE_TEMPLATES = {
|
|
76
|
+
javascript,
|
|
77
|
+
yaml,
|
|
78
|
+
markdown,
|
|
79
|
+
python,
|
|
80
|
+
go,
|
|
81
|
+
rust,
|
|
82
|
+
terraform,
|
|
83
|
+
json,
|
|
84
|
+
toml,
|
|
85
|
+
shell,
|
|
86
|
+
makefile,
|
|
87
|
+
dockerfile,
|
|
88
|
+
html,
|
|
89
|
+
css,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function joinSections(sections) {
|
|
93
|
+
return `${sections
|
|
94
|
+
.map((section) => section.replace(/\n+$/u, ''))
|
|
95
|
+
.join('\n\n')}\n`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const EMPTY_OVERRIDES = Object.freeze({
|
|
99
|
+
bodies: new Map(),
|
|
100
|
+
rawSections: new Map(),
|
|
101
|
+
hasRoot: false,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function pickSection(name, builtin, overrides) {
|
|
105
|
+
if (overrides.rawSections.has(name)) {
|
|
106
|
+
return overrides.rawSections.get(name);
|
|
107
|
+
}
|
|
108
|
+
return builtin;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function composeEditorConfig(languageNames = [], overrides = EMPTY_OVERRIDES) {
|
|
112
|
+
const resolved = new Set();
|
|
113
|
+
for (const raw of languageNames) {
|
|
114
|
+
const name = ALIASES[raw] ?? raw;
|
|
115
|
+
if (!Object.hasOwn(LANGUAGE_TEMPLATES, name)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`unknown language: '${raw}'. Available: ${AVAILABLE_LANGUAGES.join(', ')}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
resolved.add(name);
|
|
121
|
+
}
|
|
122
|
+
const ordered = AVAILABLE_LANGUAGES.filter((name) => resolved.has(name));
|
|
123
|
+
const sections = [
|
|
124
|
+
pickSection('base', base, overrides),
|
|
125
|
+
...ordered.map((name) => pickSection(name, LANGUAGE_TEMPLATES[name], overrides)),
|
|
126
|
+
];
|
|
127
|
+
return joinSections(sections);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const editorconfigContent = composeEditorConfig(AVAILABLE_LANGUAGES);
|
|
131
|
+
|
|
132
|
+
const BASE_HEADER = '[*]';
|
|
133
|
+
|
|
134
|
+
function extractHeader(template) {
|
|
135
|
+
return template.split('\n', 1)[0];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const HEADER_TO_LANGUAGE = new Map(
|
|
139
|
+
AVAILABLE_LANGUAGES.map((name) => [extractHeader(LANGUAGE_TEMPLATES[name]), name]),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
export function headerToLanguage(header) {
|
|
143
|
+
return HEADER_TO_LANGUAGE.get(header);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isIgnoredLine(line) {
|
|
147
|
+
if (line === '' || line.startsWith('#') || line.startsWith(';')) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (line.startsWith('[') && line.endsWith(']')) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyKeyValue(body, line) {
|
|
157
|
+
const equalsAt = line.indexOf('=');
|
|
158
|
+
if (equalsAt === -1) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const key = line.slice(0, equalsAt).trim().toLowerCase();
|
|
162
|
+
if (key.length === 0) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
body.set(key, line.slice(equalsAt + 1).trim());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function parseSection(block) {
|
|
169
|
+
const body = new Map();
|
|
170
|
+
for (const rawLine of block.split('\n')) {
|
|
171
|
+
const line = rawLine.trim();
|
|
172
|
+
if (!isIgnoredLine(line)) {
|
|
173
|
+
applyKeyValue(body, line);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return body;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function finishSection({ header, lines }) {
|
|
180
|
+
return {
|
|
181
|
+
header,
|
|
182
|
+
body: parseSection(lines.join('\n')),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function processLine(state, line) {
|
|
187
|
+
const trimmed = line.trim();
|
|
188
|
+
if (/^\[.*\]$/u.test(trimmed)) {
|
|
189
|
+
if (state.current) {
|
|
190
|
+
state.sections.push(finishSection(state.current));
|
|
191
|
+
}
|
|
192
|
+
state.current = { header: trimmed, lines: [] };
|
|
193
|
+
}
|
|
194
|
+
else if (state.current) {
|
|
195
|
+
state.current.lines.push(line);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
state.preamble.push(line);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function splitSections(lines) {
|
|
203
|
+
const state = { sections: [], preamble: [], current: false };
|
|
204
|
+
for (const line of lines) {
|
|
205
|
+
processLine(state, line);
|
|
206
|
+
}
|
|
207
|
+
if (state.current) {
|
|
208
|
+
state.sections.push(finishSection(state.current));
|
|
209
|
+
}
|
|
210
|
+
return { sections: state.sections, preamble: state.preamble };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function parseSections(text) {
|
|
214
|
+
const normalized = text.replaceAll(/\r\n?/gu, '\n');
|
|
215
|
+
const { sections, preamble } = splitSections(normalized.split('\n'));
|
|
216
|
+
const hasRoot = preamble.some((line) => /^\s*root\s*=\s*true\s*$/iu.test(line));
|
|
217
|
+
return { hasRoot, sections };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function compareSection(actualBody, expectedBody) {
|
|
221
|
+
if (actualBody.size !== expectedBody.size) {
|
|
222
|
+
return { ok: false };
|
|
223
|
+
}
|
|
224
|
+
for (const [key, value] of expectedBody) {
|
|
225
|
+
if (!actualBody.has(key) || actualBody.get(key) !== value) {
|
|
226
|
+
return { ok: false };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { ok: true };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function bodyAfterHeader(template) {
|
|
233
|
+
const headerEnd = template.indexOf('\n');
|
|
234
|
+
return template.slice(headerEnd + 1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function bodyAfterFirstHeader(template) {
|
|
238
|
+
const lines = template.split('\n');
|
|
239
|
+
const headerIndex = lines.findIndex((line) => /^\[.*\]$/u.test(line.trim()));
|
|
240
|
+
if (headerIndex === -1) {
|
|
241
|
+
return template;
|
|
242
|
+
}
|
|
243
|
+
return lines.slice(headerIndex + 1).join('\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function expectedBodyForLanguage(language, overrides = EMPTY_OVERRIDES) {
|
|
247
|
+
if (overrides.bodies.has(language)) {
|
|
248
|
+
return overrides.bodies.get(language);
|
|
249
|
+
}
|
|
250
|
+
if (language === 'base') {
|
|
251
|
+
return parseSection(bodyAfterFirstHeader(base));
|
|
252
|
+
}
|
|
253
|
+
return parseSection(bodyAfterHeader(LANGUAGE_TEMPLATES[language]));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function resolveLanguageNames(languageNames) {
|
|
257
|
+
const resolved = new Set();
|
|
258
|
+
for (const raw of languageNames) {
|
|
259
|
+
const name = ALIASES[raw] ?? raw;
|
|
260
|
+
if (!Object.hasOwn(LANGUAGE_TEMPLATES, name)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`unknown language: '${raw}'. Available: ${AVAILABLE_LANGUAGES.join(', ')}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
resolved.add(name);
|
|
266
|
+
}
|
|
267
|
+
return AVAILABLE_LANGUAGES.filter((name) => resolved.has(name));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function languageToHeader(language) {
|
|
271
|
+
if (language === 'base') {
|
|
272
|
+
return BASE_HEADER;
|
|
273
|
+
}
|
|
274
|
+
return extractHeader(LANGUAGE_TEMPLATES[language]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const BASE_SECTION_HEADER = BASE_HEADER;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { fetchTemplate } from '../utils/fetch.js';
|
|
3
|
+
import { isHttpScheme, tryParseUrl } from '../utils/url.js';
|
|
4
|
+
|
|
5
|
+
function readLocalFile(path) {
|
|
6
|
+
if (!existsSync(path)) {
|
|
7
|
+
throw new Error(`custom template '${path}' does not exist`);
|
|
8
|
+
}
|
|
9
|
+
return readFileSync(path, 'utf8');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readTemplateText(input) {
|
|
13
|
+
if (typeof input !== 'string' || input.trim() === '') {
|
|
14
|
+
throw new Error('--template requires a path or URL to a custom template');
|
|
15
|
+
}
|
|
16
|
+
const url = tryParseUrl(input);
|
|
17
|
+
if (url && isHttpScheme(url)) {
|
|
18
|
+
if (url.protocol !== 'https:') {
|
|
19
|
+
throw new Error(`--template URL must use https (got '${url.protocol.replace(':', '')}')`);
|
|
20
|
+
}
|
|
21
|
+
return await fetchTemplate(url.href);
|
|
22
|
+
}
|
|
23
|
+
return readLocalFile(input);
|
|
24
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { parseRedirectLocation } from './url.js';
|
|
2
|
+
|
|
3
|
+
const TIMEOUT_MS = 10_000;
|
|
4
|
+
const MAX_BYTES = 1_048_576;
|
|
5
|
+
const MAX_REDIRECTS = 5;
|
|
6
|
+
|
|
7
|
+
function ignoreCancelError() {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isRedirect(status) {
|
|
12
|
+
return status >= 300 && status < 400;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function rejectOversized(url) {
|
|
16
|
+
throw new Error(`template body for '${url}' exceeds 1 MB limit`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function checkContentLength(response, url) {
|
|
20
|
+
const header = response.headers.get('content-length');
|
|
21
|
+
if (header === null) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const bytes = Number.parseInt(header, 10);
|
|
25
|
+
if (Number.isFinite(bytes) && bytes > MAX_BYTES) {
|
|
26
|
+
rejectOversized(url);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function decodeChunks(chunks) {
|
|
31
|
+
return new TextDecoder('utf-8').decode(Buffer.concat(chunks));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function appendChunk({ state, value, reader, url }) {
|
|
35
|
+
state.total += value.byteLength;
|
|
36
|
+
if (state.total > MAX_BYTES) {
|
|
37
|
+
// We're about to throw rejectOversized; swallow any cancel() rejection.
|
|
38
|
+
reader.cancel().catch(ignoreCancelError);
|
|
39
|
+
rejectOversized(url);
|
|
40
|
+
}
|
|
41
|
+
state.chunks.push(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function consumeStream(reader, url) {
|
|
45
|
+
const state = { chunks: [], total: 0 };
|
|
46
|
+
for (;;) {
|
|
47
|
+
// eslint-disable-next-line no-await-in-loop
|
|
48
|
+
const { value, done } = await reader.read();
|
|
49
|
+
if (done) {
|
|
50
|
+
return decodeChunks(state.chunks);
|
|
51
|
+
}
|
|
52
|
+
appendChunk({ state, value, reader, url });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readBoundedBody(response, url) {
|
|
57
|
+
checkContentLength(response, url);
|
|
58
|
+
return await consumeStream(response.body.getReader(), url);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveRedirect(response, currentUrl) {
|
|
62
|
+
const location = response.headers.get('location');
|
|
63
|
+
return parseRedirectLocation(location, currentUrl);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function performFetch(initialUrl, signal) {
|
|
67
|
+
let url = initialUrl;
|
|
68
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
69
|
+
// eslint-disable-next-line no-await-in-loop
|
|
70
|
+
const response = await fetch(url, { signal, redirect: 'manual' });
|
|
71
|
+
if (response.ok) {
|
|
72
|
+
return readBoundedBody(response, url);
|
|
73
|
+
}
|
|
74
|
+
if (!isRedirect(response.status)) {
|
|
75
|
+
throw new Error(`failed to fetch '${url}': HTTP ${response.status}`);
|
|
76
|
+
}
|
|
77
|
+
url = resolveRedirect(response, url);
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`failed to fetch '${initialUrl}': too many redirects (max ${MAX_REDIRECTS})`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function describeFetchError(url, error) {
|
|
83
|
+
if (error.name === 'AbortError') {
|
|
84
|
+
return new Error(`failed to fetch '${url}': request timed out after ${TIMEOUT_MS / 1000}s`);
|
|
85
|
+
}
|
|
86
|
+
if (/^failed to fetch |^template body /u.test(error.message)) {
|
|
87
|
+
return error;
|
|
88
|
+
}
|
|
89
|
+
return new Error(`failed to fetch '${url}': ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function fetchTemplate(url) {
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
95
|
+
try {
|
|
96
|
+
return await performFetch(url, controller.signal);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
throw describeFetchError(url, error);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/utils/url.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function tryParseUrl(input) {
|
|
2
|
+
try {
|
|
3
|
+
return new URL(input);
|
|
4
|
+
}
|
|
5
|
+
catch {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isHttpScheme(url) {
|
|
11
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseRedirectLocation(location, currentUrl) {
|
|
15
|
+
if (typeof location !== 'string' || location.length === 0) {
|
|
16
|
+
throw new Error(`failed to fetch '${currentUrl}': redirect missing Location header`);
|
|
17
|
+
}
|
|
18
|
+
const next = new URL(location, currentUrl);
|
|
19
|
+
if (next.protocol !== 'https:') {
|
|
20
|
+
throw new Error(`failed to fetch '${currentUrl}': refused redirect to non-https '${next.href}'`);
|
|
21
|
+
}
|
|
22
|
+
return next.href;
|
|
23
|
+
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
name: Publish package
|
|
2
|
-
on:
|
|
3
|
-
release:
|
|
4
|
-
types: [created]
|
|
5
|
-
|
|
6
|
-
jobs:
|
|
7
|
-
publish:
|
|
8
|
-
runs-on: ubuntu-latest
|
|
9
|
-
environment: prod
|
|
10
|
-
permissions:
|
|
11
|
-
contents: read
|
|
12
|
-
id-token: write
|
|
13
|
-
|
|
14
|
-
steps:
|
|
15
|
-
- name: Checkout
|
|
16
|
-
uses: actions/checkout@v5
|
|
17
|
-
|
|
18
|
-
- name: Setup Node.js
|
|
19
|
-
uses: actions/setup-node@v6
|
|
20
|
-
with:
|
|
21
|
-
node-version: 24
|
|
22
|
-
registry-url: https://registry.npmjs.org
|
|
23
|
-
|
|
24
|
-
- name: NPM install
|
|
25
|
-
run: |
|
|
26
|
-
npm install
|
|
27
|
-
|
|
28
|
-
- name: Version
|
|
29
|
-
if: github.event_name == 'release' && github.event.action == 'created'
|
|
30
|
-
run: |
|
|
31
|
-
VERSION=${{ github.event.release.tag_name }}
|
|
32
|
-
VERSION=${VERSION:1}
|
|
33
|
-
CURRENT_VERSION=$(npm pkg get version | tr -d '"')
|
|
34
|
-
if [ "$CURRENT_VERSION" != "$VERSION" ]; then
|
|
35
|
-
npm version $VERSION --no-git-tag-version
|
|
36
|
-
else
|
|
37
|
-
echo "Version already set to $VERSION, skipping npm version command"
|
|
38
|
-
fi
|
|
39
|
-
|
|
40
|
-
- name: publish
|
|
41
|
-
run: |
|
|
42
|
-
npm publish --access public
|