@sheplu/editorconfig 0.8.7 → 0.10.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.
@@ -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,4 @@
1
+ export const dockerfile = `[{Dockerfile,Dockerfile.*,*.dockerfile}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,5 @@
1
+ export const go = `[*.go]
2
+ indent_style = tab
3
+ indent_size = 4
4
+ tab_width = 4
5
+ `;
@@ -0,0 +1,4 @@
1
+ export const html = `[*.{html,htm}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -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,6 @@
1
+ export const javascript = `[*.{js,jsx,ts,tsx,mjs,cjs}]
2
+ indent_style = tab
3
+ indent_size = 4
4
+ tab_width = 4
5
+ quote_type = single
6
+ `;
@@ -0,0 +1,4 @@
1
+ export const json = `[*.json]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,5 @@
1
+ export const makefile = `[{Makefile,GNUmakefile,makefile,*.mk}]
2
+ indent_style = tab
3
+ indent_size = 4
4
+ tab_width = 4
5
+ `;
@@ -0,0 +1,5 @@
1
+ export const markdown = `[*.md]
2
+ indent_style = space
3
+ indent_size = 2
4
+ trim_trailing_whitespace = false
5
+ `;
@@ -0,0 +1,5 @@
1
+ export const python = `[*.py]
2
+ indent_style = space
3
+ indent_size = 4
4
+ max_line_length = 88
5
+ `;
@@ -0,0 +1,5 @@
1
+ export const rust = `[*.rs]
2
+ indent_style = space
3
+ indent_size = 4
4
+ max_line_length = 100
5
+ `;
@@ -0,0 +1,4 @@
1
+ export const shell = `[*.{sh,bash,zsh}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -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,4 @@
1
+ export const terraform = `[*.{tf,tfvars}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,4 @@
1
+ export const toml = `[*.toml]
2
+ indent_style = space
3
+ indent_size = 4
4
+ `;
@@ -0,0 +1,4 @@
1
+ export const yaml = `[*.{yml,yaml}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,107 @@
1
+ import { parseRedirectLocation } from './url.js';
2
+
3
+ const TIMEOUT_MS = 10_000;
4
+ const MS_PER_SECOND = 1000;
5
+ const MAX_BYTES = 1_048_576;
6
+ const MAX_REDIRECTS = 5;
7
+ const HTTP_REDIRECT_MIN = 300;
8
+ const HTTP_REDIRECT_MAX = 400;
9
+
10
+ function ignoreCancelError() {
11
+ return false;
12
+ }
13
+
14
+ export function isRedirect(status) {
15
+ return status >= HTTP_REDIRECT_MIN && status < HTTP_REDIRECT_MAX;
16
+ }
17
+
18
+ export function rejectOversized(url) {
19
+ throw new Error(`template body for '${url}' exceeds 1 MB limit`);
20
+ }
21
+
22
+ export function checkContentLength(response, url) {
23
+ const header = response.headers.get('content-length');
24
+ if (header === null) {
25
+ return;
26
+ }
27
+ const bytes = Number.parseInt(header, 10);
28
+ if (Number.isFinite(bytes) && bytes > MAX_BYTES) {
29
+ rejectOversized(url);
30
+ }
31
+ }
32
+
33
+ export function decodeChunks(chunks) {
34
+ return new TextDecoder('utf-8').decode(Buffer.concat(chunks));
35
+ }
36
+
37
+ export function appendChunk({ state, value, reader, url }) {
38
+ state.total += value.byteLength;
39
+ if (state.total > MAX_BYTES) {
40
+ // We're about to throw rejectOversized; swallow any cancel() rejection.
41
+ reader.cancel().catch(ignoreCancelError);
42
+ rejectOversized(url);
43
+ }
44
+ state.chunks.push(value);
45
+ }
46
+
47
+ export async function consumeStream(reader, url) {
48
+ const state = { chunks: [], total: 0 };
49
+ for (;;) {
50
+ // eslint-disable-next-line no-await-in-loop
51
+ const { value, done } = await reader.read();
52
+ if (done) {
53
+ return decodeChunks(state.chunks);
54
+ }
55
+ appendChunk({ state, value, reader, url });
56
+ }
57
+ }
58
+
59
+ export async function readBoundedBody(response, url) {
60
+ checkContentLength(response, url);
61
+ return await consumeStream(response.body.getReader(), url);
62
+ }
63
+
64
+ export function resolveRedirect(response, currentUrl) {
65
+ const location = response.headers.get('location');
66
+ return parseRedirectLocation(location, currentUrl);
67
+ }
68
+
69
+ export async function performFetch(initialUrl, signal) {
70
+ let url = initialUrl;
71
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
72
+ // eslint-disable-next-line no-await-in-loop
73
+ const response = await fetch(url, { signal, redirect: 'manual' });
74
+ if (response.ok) {
75
+ return readBoundedBody(response, url);
76
+ }
77
+ if (!isRedirect(response.status)) {
78
+ throw new Error(`failed to fetch '${url}': HTTP ${response.status}`);
79
+ }
80
+ url = resolveRedirect(response, url);
81
+ }
82
+ throw new Error(`failed to fetch '${initialUrl}': too many redirects (max ${MAX_REDIRECTS})`);
83
+ }
84
+
85
+ export function describeFetchError(url, error) {
86
+ if (error.name === 'AbortError') {
87
+ return new Error(`failed to fetch '${url}': request timed out after ${TIMEOUT_MS / MS_PER_SECOND}s`);
88
+ }
89
+ if (/^failed to fetch |^template body /u.test(error.message)) {
90
+ return error;
91
+ }
92
+ return new Error(`failed to fetch '${url}': ${error.message}`);
93
+ }
94
+
95
+ export async function fetchTemplate(url) {
96
+ const controller = new AbortController();
97
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
98
+ try {
99
+ return await performFetch(url, controller.signal);
100
+ }
101
+ catch (error) {
102
+ throw describeFetchError(url, error);
103
+ }
104
+ finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
@@ -0,0 +1,8 @@
1
+ /* eslint-disable no-console */
2
+ export const logger = {
3
+ log: (...args) => console.log(...args),
4
+ info: (...args) => console.info(...args),
5
+ warn: (...args) => console.warn(...args),
6
+ error: (...args) => console.error(...args),
7
+ debug: (...args) => console.debug(...args),
8
+ };
@@ -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