@technomoron/mail-magic 1.0.5 → 1.0.8
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/.do-realease.sh +40 -1
- package/.env-dist +9 -0
- package/.vscode/extensions.json +1 -13
- package/.vscode/settings.json +13 -114
- package/CHANGES +25 -0
- package/README.md +6 -0
- package/TUTORIAL.MD +57 -55
- package/dist/api/assets.js +77 -0
- package/dist/api/forms.js +31 -4
- package/dist/api/mailer.js +34 -8
- package/dist/index.js +5 -1
- package/dist/models/form.js +11 -1
- package/dist/models/init.js +43 -31
- package/dist/models/txmail.js +13 -4
- package/dist/store/envloader.js +12 -20
- package/dist/store/store.js +1 -0
- package/dist/util.js +23 -0
- package/eslint.config.mjs +133 -41
- package/lintconfig.cjs +81 -0
- package/package.json +30 -20
- package/src/api/assets.ts +92 -0
- package/src/api/forms.ts +33 -5
- package/src/api/mailer.ts +36 -9
- package/src/index.ts +5 -1
- package/src/models/form.ts +12 -1
- package/src/models/init.ts +46 -43
- package/src/models/txmail.ts +14 -6
- package/src/store/envloader.ts +12 -20
- package/src/store/store.ts +2 -0
- package/src/util.ts +26 -0
- package/tests/fixtures/certs/test.crt +19 -0
- package/tests/fixtures/certs/test.key +28 -0
- package/tests/helpers/test-setup.ts +316 -0
- package/tests/mail-magic.test.ts +154 -0
- package/vitest.config.ts +11 -0
package/dist/api/mailer.js
CHANGED
|
@@ -15,7 +15,7 @@ export class MailerAPI extends ApiModule {
|
|
|
15
15
|
if (parsed) {
|
|
16
16
|
return parsed.address;
|
|
17
17
|
}
|
|
18
|
-
return
|
|
18
|
+
return undefined;
|
|
19
19
|
}
|
|
20
20
|
//
|
|
21
21
|
// Validate a set of email addresses. Return arrays of invalid
|
|
@@ -51,6 +51,9 @@ export class MailerAPI extends ApiModule {
|
|
|
51
51
|
if (!dbdomain) {
|
|
52
52
|
throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
|
|
53
53
|
}
|
|
54
|
+
if (dbdomain.user_id !== user.user_id) {
|
|
55
|
+
throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
|
|
56
|
+
}
|
|
54
57
|
apireq.domain = dbdomain;
|
|
55
58
|
apireq.locale = locale || 'en';
|
|
56
59
|
apireq.user = user;
|
|
@@ -85,7 +88,7 @@ export class MailerAPI extends ApiModule {
|
|
|
85
88
|
const [templateRecord, created] = await api_txmail.upsert(data, {
|
|
86
89
|
returning: true
|
|
87
90
|
});
|
|
88
|
-
|
|
91
|
+
this.server.storage.print_debug(`Template upserted: ${templateRecord.name} (created=${created})`);
|
|
89
92
|
}
|
|
90
93
|
catch (error) {
|
|
91
94
|
throw new ApiError({
|
|
@@ -98,7 +101,7 @@ export class MailerAPI extends ApiModule {
|
|
|
98
101
|
// Send a template using posted arguments.
|
|
99
102
|
async post_send(apireq) {
|
|
100
103
|
await this.assert_domain_and_user(apireq);
|
|
101
|
-
const { name, rcpt, domain = '', locale = '', vars = {} } = apireq.req.body;
|
|
104
|
+
const { name, rcpt, domain = '', locale = '', vars = {}, replyTo, reply_to, headers } = apireq.req.body;
|
|
102
105
|
if (!name || !rcpt || !domain) {
|
|
103
106
|
throw new ApiError({ code: 400, message: 'name/rcpt/domain required' });
|
|
104
107
|
}
|
|
@@ -107,7 +110,7 @@ export class MailerAPI extends ApiModule {
|
|
|
107
110
|
try {
|
|
108
111
|
parsedVars = JSON.parse(vars);
|
|
109
112
|
}
|
|
110
|
-
catch
|
|
113
|
+
catch {
|
|
111
114
|
throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
|
|
112
115
|
}
|
|
113
116
|
}
|
|
@@ -118,7 +121,7 @@ export class MailerAPI extends ApiModule {
|
|
|
118
121
|
throw new ApiError({ code: 400, message: 'Invalid email address(es): ' + invalid.join(',') });
|
|
119
122
|
}
|
|
120
123
|
let template = null;
|
|
121
|
-
const deflocale =
|
|
124
|
+
const deflocale = this.server.storage.deflocale || '';
|
|
122
125
|
const domain_id = apireq.domain.domain_id;
|
|
123
126
|
try {
|
|
124
127
|
template =
|
|
@@ -159,8 +162,29 @@ export class MailerAPI extends ApiModule {
|
|
|
159
162
|
for (const file of rawFiles) {
|
|
160
163
|
attachmentMap[file.fieldname] = file.originalname;
|
|
161
164
|
}
|
|
162
|
-
|
|
165
|
+
this.server.storage.print_debug(`Template vars: ${JSON.stringify({ vars, thevars }, undefined, 2)}`);
|
|
163
166
|
const meta = buildRequestMeta(apireq.req);
|
|
167
|
+
const replyToValue = (replyTo || reply_to);
|
|
168
|
+
let normalizedReplyTo;
|
|
169
|
+
if (replyToValue) {
|
|
170
|
+
normalizedReplyTo = this.validateEmail(replyToValue);
|
|
171
|
+
if (!normalizedReplyTo) {
|
|
172
|
+
throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
let normalizedHeaders;
|
|
176
|
+
if (headers !== undefined) {
|
|
177
|
+
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
|
|
178
|
+
throw new ApiError({ code: 400, message: 'headers must be a key/value object' });
|
|
179
|
+
}
|
|
180
|
+
normalizedHeaders = {};
|
|
181
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
182
|
+
if (typeof value !== 'string') {
|
|
183
|
+
throw new ApiError({ code: 400, message: `headers.${key} must be a string` });
|
|
184
|
+
}
|
|
185
|
+
normalizedHeaders[key] = value;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
164
188
|
try {
|
|
165
189
|
const env = new nunjucks.Environment(null, { autoescape: false });
|
|
166
190
|
const compiled = nunjucks.compile(template.template, env);
|
|
@@ -180,9 +204,11 @@ export class MailerAPI extends ApiModule {
|
|
|
180
204
|
subject: template.subject || apireq.req.body.subject || '',
|
|
181
205
|
html,
|
|
182
206
|
text,
|
|
183
|
-
attachments
|
|
207
|
+
attachments,
|
|
208
|
+
...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {}),
|
|
209
|
+
...(normalizedHeaders ? { headers: normalizedHeaders } : {})
|
|
184
210
|
};
|
|
185
|
-
await
|
|
211
|
+
await this.server.storage.transport.sendMail(sendargs);
|
|
186
212
|
}
|
|
187
213
|
return [200, { Status: 'OK', Message: 'Emails sent successfully' }];
|
|
188
214
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { AssetAPI } from './api/assets.js';
|
|
2
3
|
import { FormAPI } from './api/forms.js';
|
|
3
4
|
import { MailerAPI } from './api/mailer.js';
|
|
4
5
|
import { mailApiServer } from './server.js';
|
|
@@ -10,13 +11,16 @@ function buildServerConfig(store, overrides) {
|
|
|
10
11
|
apiPort: env.API_PORT,
|
|
11
12
|
uploadPath: env.UPLOAD_PATH,
|
|
12
13
|
debug: env.DEBUG,
|
|
14
|
+
apiBasePath: '',
|
|
15
|
+
swaggerEnabled: env.SWAGGER_ENABLED,
|
|
16
|
+
swaggerPath: env.SWAGGER_PATH,
|
|
13
17
|
...overrides
|
|
14
18
|
};
|
|
15
19
|
}
|
|
16
20
|
export async function createMailMagicServer(overrides = {}) {
|
|
17
21
|
const store = await new mailStore().init();
|
|
18
22
|
const config = buildServerConfig(store, overrides);
|
|
19
|
-
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI());
|
|
23
|
+
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
20
24
|
return { server, store, env: store.env };
|
|
21
25
|
}
|
|
22
26
|
export async function startMailMagicServer(overrides = {}) {
|
package/dist/models/form.js
CHANGED
|
@@ -128,6 +128,16 @@ export async function init_api_form(api_db) {
|
|
|
128
128
|
});
|
|
129
129
|
return api_form;
|
|
130
130
|
}
|
|
131
|
+
function assertSafeRelativePath(filename, label) {
|
|
132
|
+
const normalized = path.normalize(filename);
|
|
133
|
+
if (path.isAbsolute(normalized)) {
|
|
134
|
+
throw new Error(`${label} path must be relative`);
|
|
135
|
+
}
|
|
136
|
+
if (normalized.split(path.sep).includes('..')) {
|
|
137
|
+
throw new Error(`${label} path cannot include '..' segments`);
|
|
138
|
+
}
|
|
139
|
+
return normalized;
|
|
140
|
+
}
|
|
131
141
|
export async function upsert_form(record) {
|
|
132
142
|
const { user, domain } = await user_and_domain(record.domain_id);
|
|
133
143
|
const idname = normalizeSlug(user.idname);
|
|
@@ -150,7 +160,7 @@ export async function upsert_form(record) {
|
|
|
150
160
|
if (!record.filename.endsWith('.njk')) {
|
|
151
161
|
record.filename += '.njk';
|
|
152
162
|
}
|
|
153
|
-
record.filename =
|
|
163
|
+
record.filename = assertSafeRelativePath(record.filename, 'Form filename');
|
|
154
164
|
let instance = null;
|
|
155
165
|
instance = await api_form.findByPk(record.form_id);
|
|
156
166
|
if (instance) {
|
package/dist/models/init.js
CHANGED
|
@@ -14,34 +14,42 @@ const init_data_schema = z.object({
|
|
|
14
14
|
form: z.array(api_form_schema).default([])
|
|
15
15
|
});
|
|
16
16
|
/**
|
|
17
|
-
* Resolve an asset file within ./config/<
|
|
17
|
+
* Resolve an asset file within ./config/<domain>/assets
|
|
18
18
|
*/
|
|
19
|
-
function resolveAsset(basePath,
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
searchPaths.push(path.join(domainName, type, locale));
|
|
19
|
+
function resolveAsset(basePath, domainName, assetName) {
|
|
20
|
+
const assetsRoot = path.join(basePath, domainName, 'assets');
|
|
21
|
+
if (!fs.existsSync(assetsRoot)) {
|
|
22
|
+
return null;
|
|
24
23
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
24
|
+
const resolvedRoot = fs.realpathSync(assetsRoot);
|
|
25
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
26
|
+
const candidate = path.resolve(assetsRoot, assetName);
|
|
27
|
+
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
|
|
28
|
+
return null;
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (fs.existsSync(candidate)) {
|
|
35
|
-
return candidate;
|
|
36
|
-
}
|
|
30
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
31
|
+
if (!realCandidate.startsWith(normalizedRoot)) {
|
|
32
|
+
return null;
|
|
37
33
|
}
|
|
38
|
-
return
|
|
34
|
+
return realCandidate;
|
|
35
|
+
}
|
|
36
|
+
function buildAssetUrl(baseUrl, route, domainName, assetPath) {
|
|
37
|
+
const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
38
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
39
|
+
const encodedDomain = encodeURIComponent(domainName);
|
|
40
|
+
const encodedPath = assetPath
|
|
41
|
+
.split('/')
|
|
42
|
+
.filter((segment) => segment.length > 0)
|
|
43
|
+
.map((segment) => encodeURIComponent(segment))
|
|
44
|
+
.join('/');
|
|
45
|
+
const trailing = encodedPath ? `/${encodedPath}` : '';
|
|
46
|
+
return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
|
|
39
47
|
}
|
|
40
48
|
function extractAndReplaceAssets(html, opts) {
|
|
41
49
|
const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
|
|
42
50
|
const assets = [];
|
|
43
51
|
const replacedHtml = html.replace(regex, (_m, relPath, inlineFlag) => {
|
|
44
|
-
const fullPath = resolveAsset(opts.basePath, opts.
|
|
52
|
+
const fullPath = resolveAsset(opts.basePath, opts.domainName, relPath);
|
|
45
53
|
if (!fullPath) {
|
|
46
54
|
throw new Error(`Missing asset "${relPath}"`);
|
|
47
55
|
}
|
|
@@ -52,13 +60,17 @@ function extractAndReplaceAssets(html, opts) {
|
|
|
52
60
|
cid: isInline ? relPath : undefined
|
|
53
61
|
};
|
|
54
62
|
assets.push(storedFile);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
if (isInline) {
|
|
64
|
+
return `src="cid:${relPath}"`;
|
|
65
|
+
}
|
|
66
|
+
const domainAssetsRoot = path.join(opts.basePath, opts.domainName, 'assets');
|
|
67
|
+
const relativeToAssets = path.relative(domainAssetsRoot, fullPath);
|
|
68
|
+
if (!relativeToAssets || relativeToAssets.startsWith('..')) {
|
|
69
|
+
throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
|
|
70
|
+
}
|
|
71
|
+
const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
|
|
72
|
+
const assetUrl = buildAssetUrl(opts.apiUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
|
|
73
|
+
return `src="${assetUrl}"`;
|
|
62
74
|
});
|
|
63
75
|
return { html: replacedHtml, assets };
|
|
64
76
|
}
|
|
@@ -69,8 +81,10 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
69
81
|
if (filename.startsWith(prefix)) {
|
|
70
82
|
relFile = filename.slice(prefix.length);
|
|
71
83
|
}
|
|
72
|
-
const
|
|
73
|
-
|
|
84
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
85
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
86
|
+
const absPath = path.resolve(resolvedRoot, pathname || '', relFile);
|
|
87
|
+
if (!absPath.startsWith(normalizedRoot)) {
|
|
74
88
|
throw new Error(`Invalid template path "${filename}"`);
|
|
75
89
|
}
|
|
76
90
|
if (!fs.existsSync(absPath)) {
|
|
@@ -90,11 +104,9 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
90
104
|
const merged = processor.flattenNoAssets(templateKey);
|
|
91
105
|
const { html, assets } = extractAndReplaceAssets(merged, {
|
|
92
106
|
basePath: baseConfigPath,
|
|
93
|
-
type,
|
|
94
107
|
domainName: domain.name,
|
|
95
|
-
locale,
|
|
96
108
|
apiUrl: store.env.API_URL,
|
|
97
|
-
|
|
109
|
+
assetRoute: store.env.ASSET_ROUTE
|
|
98
110
|
});
|
|
99
111
|
return { html, assets };
|
|
100
112
|
}
|
package/dist/models/txmail.js
CHANGED
|
@@ -23,6 +23,16 @@ export const api_txmail_schema = z.object({
|
|
|
23
23
|
});
|
|
24
24
|
export class api_txmail extends Model {
|
|
25
25
|
}
|
|
26
|
+
function assertSafeRelativePath(filename, label) {
|
|
27
|
+
const normalized = path.normalize(filename);
|
|
28
|
+
if (path.isAbsolute(normalized)) {
|
|
29
|
+
throw new Error(`${label} path must be relative`);
|
|
30
|
+
}
|
|
31
|
+
if (normalized.split(path.sep).includes('..')) {
|
|
32
|
+
throw new Error(`${label} path cannot include '..' segments`);
|
|
33
|
+
}
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
26
36
|
export async function upsert_txmail(record) {
|
|
27
37
|
const { user, domain } = await user_and_domain(record.domain_id);
|
|
28
38
|
const idname = normalizeSlug(user.idname);
|
|
@@ -45,7 +55,7 @@ export async function upsert_txmail(record) {
|
|
|
45
55
|
if (!record.filename.endsWith('.njk')) {
|
|
46
56
|
record.filename += '.njk';
|
|
47
57
|
}
|
|
48
|
-
record.filename =
|
|
58
|
+
record.filename = assertSafeRelativePath(record.filename, 'Template filename');
|
|
49
59
|
const [instance] = await api_txmail.upsert(record);
|
|
50
60
|
return instance;
|
|
51
61
|
}
|
|
@@ -91,7 +101,7 @@ export async function init_api_txmail(api_db) {
|
|
|
91
101
|
unique: false
|
|
92
102
|
},
|
|
93
103
|
template: {
|
|
94
|
-
type: DataTypes.
|
|
104
|
+
type: DataTypes.TEXT,
|
|
95
105
|
allowNull: false,
|
|
96
106
|
defaultValue: ''
|
|
97
107
|
},
|
|
@@ -145,7 +155,6 @@ export async function init_api_txmail(api_db) {
|
|
|
145
155
|
});
|
|
146
156
|
api_txmail.addHook('beforeValidate', async (template) => {
|
|
147
157
|
const { user, domain } = await user_and_domain(template.domain_id);
|
|
148
|
-
console.log('HERE');
|
|
149
158
|
const dname = normalizeSlug(domain.name);
|
|
150
159
|
const name = normalizeSlug(template.name);
|
|
151
160
|
const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
|
|
@@ -160,7 +169,7 @@ export async function init_api_txmail(api_db) {
|
|
|
160
169
|
if (!template.filename.endsWith('.njk')) {
|
|
161
170
|
template.filename += '.njk';
|
|
162
171
|
}
|
|
163
|
-
|
|
172
|
+
template.filename = assertSafeRelativePath(template.filename, 'Template filename');
|
|
164
173
|
});
|
|
165
174
|
return api_txmail;
|
|
166
175
|
}
|
package/dist/store/envloader.js
CHANGED
|
@@ -28,31 +28,23 @@ export const envOptions = defineEnvOptions({
|
|
|
28
28
|
description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
|
|
29
29
|
default: 'http://localhost:3776'
|
|
30
30
|
},
|
|
31
|
-
|
|
32
|
-
description: '
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
/*
|
|
36
|
-
SWAGGER_ENABLE: {
|
|
37
|
-
description: 'Enable Swagger API docs',
|
|
38
|
-
default: 'false',
|
|
39
|
-
type: 'boolean'
|
|
31
|
+
SWAGGER_ENABLED: {
|
|
32
|
+
description: 'Enable the Swagger/OpenAPI endpoint',
|
|
33
|
+
type: 'boolean',
|
|
34
|
+
default: false
|
|
40
35
|
},
|
|
41
36
|
SWAGGER_PATH: {
|
|
42
|
-
description: 'Path
|
|
43
|
-
default: '
|
|
37
|
+
description: 'Path to expose the Swagger/OpenAPI spec (default: /api/swagger when enabled)',
|
|
38
|
+
default: ''
|
|
44
39
|
},
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
description: 'Secret key for generating JWT access tokens',
|
|
49
|
-
required: true
|
|
40
|
+
ASSET_ROUTE: {
|
|
41
|
+
description: 'Route prefix exposed for config assets',
|
|
42
|
+
default: '/asset'
|
|
50
43
|
},
|
|
51
|
-
|
|
52
|
-
description: '
|
|
53
|
-
|
|
44
|
+
CONFIG_PATH: {
|
|
45
|
+
description: 'Path to directory where config files are located',
|
|
46
|
+
default: './config/'
|
|
54
47
|
},
|
|
55
|
-
*/
|
|
56
48
|
DB_USER: {
|
|
57
49
|
description: 'Database username for API database'
|
|
58
50
|
},
|
package/dist/store/store.js
CHANGED
package/dist/util.js
CHANGED
|
@@ -92,3 +92,26 @@ export function buildRequestMeta(rawReq) {
|
|
|
92
92
|
ip_chain: uniqueIps
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
+
export function decodeComponent(value) {
|
|
96
|
+
if (!value) {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return decodeURIComponent(value);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function sendFileAsync(res, file) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
res.sendFile(file, (err) => {
|
|
109
|
+
if (err) {
|
|
110
|
+
reject(err);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
resolve();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
package/eslint.config.mjs
CHANGED
|
@@ -1,9 +1,45 @@
|
|
|
1
|
+
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
|
1
2
|
import tsParser from '@typescript-eslint/parser';
|
|
2
|
-
import
|
|
3
|
+
import eslintConfigPrettier from 'eslint-config-prettier/flat';
|
|
3
4
|
import pluginImport from 'eslint-plugin-import';
|
|
4
|
-
import pluginPrettier from 'eslint-plugin-prettier';
|
|
5
|
-
import pluginVue from 'eslint-plugin-vue';
|
|
6
5
|
import jsoncParser from 'jsonc-eslint-parser';
|
|
6
|
+
const TS_FILE_GLOBS = ['**/*.{ts,tsx,mts,cts,vue}'];
|
|
7
|
+
const TS_PLUGIN_FILE_GLOBS = ['**/*.{ts,tsx,mts,cts,js,mjs,cjs,vue}'];
|
|
8
|
+
const VUE_FILE_GLOBS = ['**/*.vue'];
|
|
9
|
+
|
|
10
|
+
const { hasVueSupport, pluginVue, vueTypeScriptConfigs } = await loadVueSupport();
|
|
11
|
+
const scopedVueTypeScriptConfigs = hasVueSupport
|
|
12
|
+
? scopeVueConfigs(vueTypeScriptConfigs).map(stripTypeScriptPlugin)
|
|
13
|
+
: [];
|
|
14
|
+
const vueSpecificBlocks = hasVueSupport
|
|
15
|
+
? [
|
|
16
|
+
...scopedVueTypeScriptConfigs,
|
|
17
|
+
{
|
|
18
|
+
files: VUE_FILE_GLOBS,
|
|
19
|
+
plugins: {
|
|
20
|
+
vue: pluginVue
|
|
21
|
+
},
|
|
22
|
+
rules: {
|
|
23
|
+
'vue/html-indent': 'off', // Let Prettier handle indentation
|
|
24
|
+
'vue/max-attributes-per-line': 'off', // Let Prettier handle line breaks
|
|
25
|
+
'vue/first-attribute-linebreak': 'off', // Let Prettier handle attribute positioning
|
|
26
|
+
'vue/singleline-html-element-content-newline': 'off',
|
|
27
|
+
'vue/html-self-closing': [
|
|
28
|
+
'error',
|
|
29
|
+
{
|
|
30
|
+
html: {
|
|
31
|
+
void: 'always',
|
|
32
|
+
normal: 'always',
|
|
33
|
+
component: 'always'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
'vue/multi-word-component-names': 'off', // Disable multi-word name restriction
|
|
38
|
+
'vue/attribute-hyphenation': ['error', 'always']
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
: [];
|
|
7
43
|
|
|
8
44
|
export default [
|
|
9
45
|
{
|
|
@@ -12,54 +48,31 @@ export default [
|
|
|
12
48
|
'dist',
|
|
13
49
|
'.output',
|
|
14
50
|
'.nuxt',
|
|
51
|
+
'.netlify',
|
|
52
|
+
'node_modules/.netlify',
|
|
53
|
+
'4000/.nuxt',
|
|
15
54
|
'coverage',
|
|
16
55
|
'**/*.d.ts',
|
|
56
|
+
'configure-eslint.cjs',
|
|
17
57
|
'configure-eslint.js',
|
|
18
58
|
'*.config.js',
|
|
19
|
-
'*.config.ts',
|
|
20
59
|
'public'
|
|
21
60
|
]
|
|
22
61
|
},
|
|
23
|
-
...defineConfigWithVueTs(vueTsConfigs.recommended),
|
|
24
62
|
{
|
|
25
|
-
files:
|
|
63
|
+
files: TS_PLUGIN_FILE_GLOBS,
|
|
26
64
|
plugins: {
|
|
27
|
-
|
|
28
|
-
prettier: pluginPrettier
|
|
29
|
-
},
|
|
30
|
-
rules: {
|
|
31
|
-
'prettier/prettier': 'error', // Enforce Prettier rules
|
|
32
|
-
'vue/html-indent': 'off', // Let Prettier handle indentation
|
|
33
|
-
'vue/max-attributes-per-line': 'off', // Let Prettier handle line breaks
|
|
34
|
-
'vue/first-attribute-linebreak': 'off', // Let Prettier handle attribute positioning
|
|
35
|
-
'vue/singleline-html-element-content-newline': 'off',
|
|
36
|
-
'vue/html-self-closing': [
|
|
37
|
-
'error',
|
|
38
|
-
{
|
|
39
|
-
html: {
|
|
40
|
-
void: 'always',
|
|
41
|
-
normal: 'always',
|
|
42
|
-
component: 'always'
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
],
|
|
46
|
-
'vue/multi-word-component-names': 'off', // Disable multi-word name restriction
|
|
47
|
-
'vue/attribute-hyphenation': ['error', 'always']
|
|
65
|
+
'@typescript-eslint': tsPlugin
|
|
48
66
|
}
|
|
49
67
|
},
|
|
68
|
+
...vueSpecificBlocks,
|
|
50
69
|
{
|
|
51
|
-
files: ['
|
|
70
|
+
files: ['**/*.json'],
|
|
52
71
|
languageOptions: {
|
|
53
72
|
parser: jsoncParser
|
|
54
73
|
},
|
|
55
|
-
plugins: {
|
|
56
|
-
prettier: pluginPrettier
|
|
57
|
-
},
|
|
58
74
|
rules: {
|
|
59
|
-
quotes: ['error', 'double']
|
|
60
|
-
'prettier/prettier': 'error',
|
|
61
|
-
'@typescript-eslint/no-unused-expressions': 'off',
|
|
62
|
-
'@typescript-eslint/no-unused-vars': 'off'
|
|
75
|
+
quotes: ['error', 'double'] // Enforce double quotes in JSON
|
|
63
76
|
}
|
|
64
77
|
},
|
|
65
78
|
{
|
|
@@ -79,15 +92,9 @@ export default [
|
|
|
79
92
|
}
|
|
80
93
|
},
|
|
81
94
|
plugins: {
|
|
82
|
-
prettier: pluginPrettier,
|
|
83
95
|
import: pluginImport
|
|
84
96
|
},
|
|
85
97
|
rules: {
|
|
86
|
-
indent: ['error', 'tab', { SwitchCase: 1 }], // Use tabs for JS/TS
|
|
87
|
-
quotes: ['warn', 'single', { avoidEscape: true }], // Prefer single quotes
|
|
88
|
-
semi: ['error', 'always'], // Enforce semicolons
|
|
89
|
-
'comma-dangle': 'off', // Disable trailing commas
|
|
90
|
-
'prettier/prettier': 'error', // Enforce Prettier rules
|
|
91
98
|
'import/order': [
|
|
92
99
|
'error',
|
|
93
100
|
{
|
|
@@ -100,5 +107,90 @@ export default [
|
|
|
100
107
|
'@typescript-eslint/no-unused-vars': ['warn'],
|
|
101
108
|
'@typescript-eslint/no-require-imports': 'off'
|
|
102
109
|
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
...eslintConfigPrettier
|
|
103
113
|
}
|
|
104
114
|
];
|
|
115
|
+
|
|
116
|
+
async function loadVueSupport() {
|
|
117
|
+
try {
|
|
118
|
+
const [vuePluginModule, vueConfigModule] = await Promise.all([
|
|
119
|
+
import('eslint-plugin-vue'),
|
|
120
|
+
import('@vue/eslint-config-typescript')
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const pluginVue = unwrapDefault(vuePluginModule);
|
|
124
|
+
const { defineConfigWithVueTs, vueTsConfigs } = vueConfigModule;
|
|
125
|
+
const configs = defineConfigWithVueTs(vueTsConfigs.recommended);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
hasVueSupport: Boolean(pluginVue && configs.length),
|
|
129
|
+
pluginVue,
|
|
130
|
+
vueTypeScriptConfigs: configs
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (isModuleNotFoundError(error)) {
|
|
134
|
+
return {
|
|
135
|
+
hasVueSupport: false,
|
|
136
|
+
pluginVue: null,
|
|
137
|
+
vueTypeScriptConfigs: []
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function scopeVueConfigs(configs) {
|
|
146
|
+
return configs.map((config) => {
|
|
147
|
+
const files = config.files ?? [];
|
|
148
|
+
const referencesOnlyVueFiles = files.length > 0 && files.every((pattern) => pattern.includes('.vue'));
|
|
149
|
+
const hasVuePlugin = Boolean(config.plugins?.vue);
|
|
150
|
+
|
|
151
|
+
if (hasVuePlugin || referencesOnlyVueFiles) {
|
|
152
|
+
return {
|
|
153
|
+
...config,
|
|
154
|
+
files: VUE_FILE_GLOBS
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
...config,
|
|
160
|
+
files: TS_FILE_GLOBS
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function stripTypeScriptPlugin(config) {
|
|
166
|
+
const { plugins = {}, ...rest } = config;
|
|
167
|
+
|
|
168
|
+
if (!plugins['@typescript-eslint']) {
|
|
169
|
+
return config;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const otherPlugins = { ...plugins };
|
|
173
|
+
delete otherPlugins['@typescript-eslint'];
|
|
174
|
+
const hasOtherPlugins = Object.keys(otherPlugins).length > 0;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...rest,
|
|
178
|
+
...(hasOtherPlugins ? { plugins: otherPlugins } : {})
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function unwrapDefault(module) {
|
|
183
|
+
return module?.default ?? module;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isModuleNotFoundError(error) {
|
|
187
|
+
if (!error) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return typeof error.message === 'string' && error.message.includes('Cannot find module');
|
|
196
|
+
}
|