wikilint 2.4.5 → 2.5.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/dist/base.d.ts CHANGED
@@ -10,18 +10,28 @@ export interface Config {
10
10
  readonly variants: string[];
11
11
  readonly excludes?: string[];
12
12
  }
13
- export type Severity = 'error' | 'warning';
14
- export type Rule = 'bold-header' | 'format-leakage' | 'fostered-content' | 'h1' | 'illegal-attr' | 'insecure-style' | 'invalid-gallery' | 'invalid-imagemap' | 'invalid-invoke' | 'lonely-apos' | 'lonely-bracket' | 'lonely-http' | 'nested-link' | 'no-arg' | 'no-duplicate' | 'no-ignored' | 'obsolete-attr' | 'obsolete-tag' | 'parsing-order' | 'pipe-like' | 'table-layout' | 'tag-like' | 'unbalanced-header' | 'unclosed-comment' | 'unclosed-quote' | 'unclosed-table' | 'unescaped' | 'unknown-page' | 'unmatched-tag' | 'unterminated-url' | 'url-encoding' | 'var-anchor' | 'void-ext';
13
+ export declare namespace LintError {
14
+ type Severity = 'error' | 'warning';
15
+ type Rule = 'bold-header' | 'format-leakage' | 'fostered-content' | 'h1' | 'illegal-attr' | 'insecure-style' | 'invalid-gallery' | 'invalid-imagemap' | 'invalid-invoke' | 'lonely-apos' | 'lonely-bracket' | 'lonely-http' | 'nested-link' | 'no-arg' | 'no-duplicate' | 'no-ignored' | 'obsolete-attr' | 'obsolete-tag' | 'parsing-order' | 'pipe-like' | 'table-layout' | 'tag-like' | 'unbalanced-header' | 'unclosed-comment' | 'unclosed-quote' | 'unclosed-table' | 'unescaped' | 'unknown-page' | 'unmatched-tag' | 'unterminated-url' | 'url-encoding' | 'var-anchor' | 'void-ext';
16
+ interface Fix {
17
+ readonly range: [number, number];
18
+ text: string;
19
+ }
20
+ }
15
21
  export interface LintError {
16
- readonly rule: Rule;
17
- readonly message: string;
18
- readonly severity: Severity;
19
- readonly startIndex: number;
20
- readonly endIndex: number;
21
- readonly startLine: number;
22
- readonly startCol: number;
23
- readonly endLine: number;
24
- readonly endCol: number;
22
+ rule: LintError.Rule;
23
+ message: string;
24
+ severity: LintError.Severity;
25
+ startIndex: number;
26
+ endIndex: number;
27
+ startLine: number;
28
+ startCol: number;
29
+ endLine: number;
30
+ endCol: number;
31
+ fix?: LintError.Fix;
32
+ suggestions?: (LintError.Fix & {
33
+ desc: string;
34
+ })[];
25
35
  }
26
36
  /** 类似Node */
27
37
  export interface AstNode {
package/dist/bin/cli.js CHANGED
@@ -3,10 +3,12 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const fs_1 = require("fs");
5
5
  const path_1 = require("path");
6
+ const chalk = require("chalk");
6
7
  const Parser = require("../index");
7
8
  const man = `
8
9
  Available options:
9
10
  -c, --config <path or preset config> Choose parser's configuration
11
+ --fix Automatically fix problems
10
12
  -h, --help Print available options
11
13
  -i, --include Parse for inclusion
12
14
  -l, --lang Choose i18n language
@@ -14,8 +16,8 @@ Available options:
14
16
  -s, --strict Exit when there is an error or warning
15
17
  Override -q or --quiet
16
18
  -v, --version Print package version
17
- `, preset = new Set(['default', 'zhwiki', 'moegirl', 'llwiki']), { argv } = process, files = [];
18
- let include = false, quiet = false, strict = false, exit = false, nErr = 0, nWarn = 0, option, config, lang;
19
+ `, preset = new Set((0, fs_1.readdirSync)('./config').filter(file => file.endsWith('.json')).map(file => file.slice(0, -5))), { argv } = process, files = [];
20
+ let include = false, quiet = false, strict = false, exit = false, fixing = false, nErr = 0, nWarn = 0, nFixableErr = 0, nFixableWarn = 0, option, config, lang;
19
21
  /**
20
22
  * throw if `-c` or `--config` option is incorrect
21
23
  * @throws `Error` unrecognized config input
@@ -33,12 +35,12 @@ const throwOnConfig = () => {
33
35
  * @param n number of items
34
36
  * @param word item name
35
37
  */
36
- const plural = (n, word) => `${n} ${word}${n > 1 ? 's' : ''}`;
38
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
37
39
  /**
38
40
  * color the severity
39
41
  * @param severity problem severity
40
42
  */
41
- const coloredSeverity = (severity) => `\x1B[${severity === 'error' ? 31 : 33}m${severity.padEnd(7)}\x1B[0m`;
43
+ const coloredSeverity = (severity) => chalk[severity === 'error' ? 'red' : 'yellow'](severity.padEnd(7));
42
44
  for (let i = 2; i < argv.length; i++) {
43
45
  option = argv[i];
44
46
  switch (option) {
@@ -47,6 +49,9 @@ for (let i = 2; i < argv.length; i++) {
47
49
  config = argv[++i];
48
50
  throwOnConfig();
49
51
  break;
52
+ case '--fix':
53
+ fixing = true;
54
+ break;
50
55
  case '-h':
51
56
  case '--help':
52
57
  console.log(man);
@@ -105,28 +110,46 @@ if (quiet && strict) {
105
110
  console.error('-s or --strict will override -q or --quiet\n');
106
111
  }
107
112
  for (const file of files) {
108
- const wikitext = (0, fs_1.readFileSync)(file, 'utf8');
109
- let problems = Parser.parse(wikitext, include).lint();
110
- const errors = problems.filter(({ severity }) => severity === 'error'), { length: nLocalErr } = errors, nLocalWarn = problems.length - nLocalErr;
113
+ let wikitext = (0, fs_1.readFileSync)(file, 'utf8'), problems = Parser.parse(wikitext, include).lint();
114
+ if (fixing && problems.some(({ fix }) => fix)) {
115
+ // 倒序修复,跳过嵌套的修复
116
+ const fixable = problems.map(({ fix }) => fix).filter(Boolean)
117
+ .sort(({ range: [aFrom, aTo] }, { range: [bFrom, bTo] }) => aTo === bTo ? bFrom - aFrom : bTo - aTo);
118
+ let start = Infinity;
119
+ for (const { range: [from, to], text } of fixable) {
120
+ if (to <= start) {
121
+ wikitext = `${wikitext.slice(0, from)}${text}${wikitext.slice(to)}`;
122
+ start = from;
123
+ }
124
+ }
125
+ void fs_1.promises.writeFile(file, wikitext);
126
+ problems = Parser.parse(wikitext, include).lint();
127
+ }
128
+ const errors = problems.filter(({ severity }) => severity === 'error'), fixable = problems.filter(({ fix }) => fix), nLocalErr = errors.length, nLocalWarn = problems.length - nLocalErr, nLocalFixableErr = fixable.filter(({ severity }) => severity === 'error').length, nLocalFixableWarn = fixable.length - nLocalFixableErr;
111
129
  if (quiet) {
112
130
  problems = errors;
113
131
  }
114
132
  else {
115
133
  nWarn += nLocalWarn;
134
+ nFixableWarn += nLocalFixableWarn;
116
135
  }
117
136
  if (problems.length > 0) {
118
- console.error('\x1B[4m%s\x1B[0m', (0, path_1.resolve)(file));
119
- const { length: maxLineChars } = String(Math.max(...problems.map(({ startLine }) => startLine))), { length: maxColChars } = String(Math.max(...problems.map(({ startCol }) => startCol))), maxMessageChars = Math.max(...problems.map(({ message: { length } }) => length));
137
+ console.error(`\n${chalk.underline('%s')}`, (0, path_1.resolve)(file));
138
+ const maxLineChars = String(Math.max(...problems.map(({ startLine }) => startLine))).length, maxColChars = String(Math.max(...problems.map(({ startCol }) => startCol))).length, maxMessageChars = Math.max(...problems.map(({ message: { length } }) => length));
120
139
  for (const { rule, message, severity, startLine, startCol } of problems) {
121
- console.error(` \x1B[37m%s:%s\x1B[0m %s %s \x1B[37m%s\x1B[0m`, String(startLine).padStart(maxLineChars), String(startCol).padEnd(maxColChars), coloredSeverity(severity), message.padEnd(maxMessageChars), rule);
140
+ console.error(` ${chalk.dim('%s:%s')} %s %s ${chalk.dim('%s')}`, String(startLine).padStart(maxLineChars), String(startCol).padEnd(maxColChars), coloredSeverity(severity), message.padEnd(maxMessageChars), rule);
122
141
  }
123
- console.error();
124
142
  }
125
143
  nErr += nLocalErr;
144
+ nFixableErr += nLocalFixableErr;
126
145
  exit ||= Boolean(nLocalErr || strict && nLocalWarn);
127
146
  }
128
147
  if (nErr || nWarn) {
129
- console.error('\x1B[1;31m%s\x1B[0m\n', `✖ ${plural(nErr + nWarn, 'problem')} (${plural(nErr, 'error')}, ${plural(nWarn, 'warning')})`);
148
+ console.error(chalk.red.bold('%s'), `\n✖ ${plural(nErr + nWarn, 'problem')} (${plural(nErr, 'error')}, ${plural(nWarn, 'warning')})`);
149
+ if (nFixableErr || nFixableWarn) {
150
+ console.error(chalk.red.bold('%s'), ` ${plural(nFixableErr, 'error')} and ${plural(nFixableWarn, 'warning')} potentially fixable with the \`--fix\` option.`);
151
+ }
152
+ console.error();
130
153
  }
131
154
  if (exit) {
132
155
  process.exitCode = 1;
package/dist/lib/text.js CHANGED
@@ -80,8 +80,11 @@ class AstText extends node_1.AstNode {
80
80
  */
81
81
  lint(start = this.getAbsoluteIndex()) {
82
82
  const { data, parentNode, nextSibling, previousSibling } = this;
83
+ if (!parentNode) {
84
+ return [];
85
+ }
83
86
  const { NowikiToken } = require('../src/nowiki');
84
- const { type, name } = parentNode, nowiki = name === 'nowiki' || name === 'pre';
87
+ const { type, name } = parentNode, nowiki = name === 'nowiki' || name === 'pre', isHtmlAttrVal = type === 'attr-value' && parentNode.parentNode.type !== 'ext-attr';
85
88
  let errorRegex;
86
89
  if (type === 'ext-inner' && (name === 'pre' || parentNode instanceof NowikiToken)) {
87
90
  errorRegex = new RegExp(`<\\s*(?:\\/\\s*)${nowiki ? '' : '?'}(${name})\\b`, 'giu');
@@ -89,7 +92,7 @@ class AstText extends node_1.AstNode {
89
92
  else if (type === 'free-ext-link'
90
93
  || type === 'ext-link-url'
91
94
  || type === 'image-parameter' && name === 'link'
92
- || type === 'attr-value') {
95
+ || isHtmlAttrVal) {
93
96
  errorRegex = errorSyntaxUrl;
94
97
  }
95
98
  else {
@@ -121,14 +124,15 @@ class AstText extends node_1.AstNode {
121
124
  else if (char === ']' && (index || length > 1)) {
122
125
  errorRegex.lastIndex--;
123
126
  }
124
- const startIndex = start + index, endIndex = startIndex + length, rootStr = String(root), nextChar = rootStr[endIndex], previousChar = rootStr[startIndex - 1], severity = length > 1 && (char !== '<' || !nowiki && /[\s/>]/u.test(nextChar ?? ''))
127
+ const startIndex = start + index, endIndex = startIndex + length, rootStr = String(root), nextChar = rootStr[endIndex], previousChar = rootStr[startIndex - 1], severity = length > 1 && !(char === '<' && (nowiki || !/[\s/>]/u.test(nextChar ?? ''))
128
+ || isHtmlAttrVal && (char === '[' || char === ']'))
125
129
  || char === '{' && (nextChar === char || previousChar === '-')
126
130
  || char === '}' && (previousChar === char || nextChar === '-')
127
131
  || char === '[' && (nextChar === char
128
132
  || type === 'ext-link-text'
129
- || !data.slice(index + 1).trim() && nextType === 'free-ext-link')
133
+ || nextType === 'free-ext-link' && !data.slice(index + 1).trim())
130
134
  || char === ']' && (previousChar === char
131
- || !data.slice(0, index).trim() && previousType === 'free-ext-link')
135
+ || previousType === 'free-ext-link' && !data.slice(0, index).includes(']'))
132
136
  ? 'error'
133
137
  : 'warning';
134
138
  const leftBracket = char === '{' || char === '[', rightBracket = char === ']' || char === '}';
@@ -149,8 +153,7 @@ class AstText extends node_1.AstNode {
149
153
  }
150
154
  }
151
155
  }
152
- const lines = data.slice(0, index).split('\n'), startLine = lines.length + top - 1, line = lines[lines.length - 1], startCol = lines.length === 1 ? left + line.length : line.length;
153
- errors.push({
156
+ const lines = data.slice(0, index).split('\n'), startLine = lines.length + top - 1, line = lines[lines.length - 1], startCol = lines.length === 1 ? left + line.length : line.length, e = {
154
157
  rule: ruleMap[char],
155
158
  message: index_1.default.msg('lonely "$1"', char === 'h' ? error : char),
156
159
  severity,
@@ -160,7 +163,45 @@ class AstText extends node_1.AstNode {
160
163
  endLine: startLine,
161
164
  startCol,
162
165
  endCol: startCol + length,
163
- });
166
+ };
167
+ if (char === '<') {
168
+ e.suggestions = [
169
+ {
170
+ desc: 'escape',
171
+ range: [startIndex, startIndex + 1],
172
+ text: '&lt;',
173
+ },
174
+ ];
175
+ }
176
+ else if (char === 'h'
177
+ && !(type === 'ext-link-text' || type === 'link-text')
178
+ && /[\p{L}\d_]/u.test(previousChar || '')) {
179
+ e.suggestions = [
180
+ {
181
+ desc: 'whitespace',
182
+ range: [startIndex, startIndex],
183
+ text: ' ',
184
+ },
185
+ ];
186
+ }
187
+ else if (char === '[' && type === 'ext-link-text') {
188
+ const i = parentNode.getAbsoluteIndex() + String(parentNode).length;
189
+ e.suggestions = [
190
+ {
191
+ desc: 'escape',
192
+ range: [i, i + 1],
193
+ text: '&#93;',
194
+ },
195
+ ];
196
+ }
197
+ else if (char === ']' && previousType === 'free-ext-link' && severity === 'error') {
198
+ const i = start - String(previousSibling).length;
199
+ e.fix = {
200
+ range: [i, i],
201
+ text: '[',
202
+ };
203
+ }
204
+ errors.push(e);
164
205
  }
165
206
  return errors;
166
207
  }
package/dist/src/arg.js CHANGED
@@ -55,22 +55,40 @@ class ArgToken extends index_2.Token {
55
55
  }
56
56
  /** @override */
57
57
  lint(start = this.getAbsoluteIndex()) {
58
+ const { childNodes: [argName, argDefault, ...rest] } = this;
58
59
  if (!this.getAttribute('include')) {
59
- return [(0, lint_1.generateForSelf)(this, { start }, 'no-arg', 'unexpected template argument')];
60
+ const e = (0, lint_1.generateForSelf)(this, { start }, 'no-arg', 'unexpected template argument');
61
+ if (argDefault) {
62
+ e.fix = {
63
+ range: [start, e.endIndex],
64
+ text: argDefault.text(),
65
+ };
66
+ }
67
+ return [e];
60
68
  }
61
- const { childNodes: [argName, argDefault, ...rest] } = this, errors = argName.lint(start + 3);
69
+ const errors = argName.lint(start + 3);
62
70
  if (argDefault) {
63
71
  errors.push(...argDefault.lint(start + 4 + String(argName).length));
64
72
  }
65
73
  if (rest.length > 0) {
66
74
  const rect = { start, ...this.getRootNode().posFromIndex(start) };
67
75
  errors.push(...rest.map(child => {
68
- const error = (0, lint_1.generateForChild)(child, rect, 'no-ignored', 'invisible content inside triple braces');
69
- return {
70
- ...error,
71
- startIndex: error.startIndex - 1,
72
- startCol: error.startCol - 1,
73
- };
76
+ const e = (0, lint_1.generateForChild)(child, rect, 'no-ignored', 'invisible content inside triple braces');
77
+ e.startIndex--;
78
+ e.startCol--;
79
+ e.suggestions = [
80
+ {
81
+ desc: 'remove',
82
+ range: [e.startIndex, e.endIndex],
83
+ text: '',
84
+ },
85
+ {
86
+ desc: 'escape',
87
+ range: [e.startIndex, e.startIndex + 1],
88
+ text: '{{!}}',
89
+ },
90
+ ];
91
+ return e;
74
92
  }));
75
93
  }
76
94
  return errors;
@@ -261,11 +261,24 @@ class AttributeToken extends index_2.Token {
261
261
  const root = this.getRootNode();
262
262
  rect = { start, ...root.posFromIndex(start) };
263
263
  const e = (0, lint_1.generateForChild)(lastChild, rect, 'unclosed-quote', index_1.default.msg('unclosed $1', 'quotes'), 'warning');
264
- errors.push({
265
- ...e,
266
- startIndex: e.startIndex - 1,
267
- startCol: e.startCol - 1,
268
- });
264
+ e.startIndex--;
265
+ e.startCol--;
266
+ const fix = {
267
+ range: [e.endIndex, e.endIndex],
268
+ text: this.#quotes[0],
269
+ };
270
+ if (lastChild.childNodes.some(child => child.type === 'text' && /\s/u.test(child.text()))) {
271
+ e.suggestions = [
272
+ {
273
+ desc: 'quote',
274
+ ...fix,
275
+ },
276
+ ];
277
+ }
278
+ else {
279
+ e.fix = fix;
280
+ }
281
+ errors.push(e);
269
282
  }
270
283
  const attrs = extAttrs[tag];
271
284
  if (attrs && !attrs.has(name)
@@ -286,7 +299,20 @@ class AttributeToken extends index_2.Token {
286
299
  }
287
300
  else if (name === 'tabindex' && typeof value === 'string' && value.trim() !== '0') {
288
301
  rect ??= { start, ...this.getRootNode().posFromIndex(start) };
289
- errors.push((0, lint_1.generateForChild)(lastChild, rect, 'illegal-attr', 'nonzero tabindex'));
302
+ const e = (0, lint_1.generateForChild)(lastChild, rect, 'illegal-attr', 'nonzero tabindex');
303
+ e.suggestions = [
304
+ {
305
+ desc: 'remove',
306
+ range: [start, e.endIndex],
307
+ text: '',
308
+ },
309
+ {
310
+ desc: '0 tabindex',
311
+ range: [e.startIndex, e.endIndex],
312
+ text: '0',
313
+ },
314
+ ];
315
+ errors.push(e);
290
316
  }
291
317
  return errors;
292
318
  }
@@ -100,13 +100,23 @@ class AttributesToken extends index_2.Token {
100
100
  let rect;
101
101
  if (parentNode?.type === 'html' && parentNode.closing && this.text().trim()) {
102
102
  rect = { start, ...this.getRootNode().posFromIndex(start) };
103
- errors.push((0, lint_1.generateForSelf)(this, rect, 'no-ignored', 'attributes of a closing tag'));
103
+ const e = (0, lint_1.generateForSelf)(this, rect, 'no-ignored', 'attributes of a closing tag');
104
+ e.fix = { range: [start, e.endIndex], text: '' };
105
+ errors.push(e);
104
106
  }
105
107
  for (let i = 0; i < length; i++) {
106
108
  const attr = childNodes[i];
107
109
  if (attr instanceof atom_1.AtomToken && attr.text().trim()) {
108
110
  rect ??= { start, ...this.getRootNode().posFromIndex(start) };
109
- errors.push((0, lint_1.generateForChild)(attr, rect, 'no-ignored', 'containing invalid attribute'));
111
+ const e = (0, lint_1.generateForChild)(attr, rect, 'no-ignored', 'containing invalid attribute');
112
+ e.suggestions = [
113
+ {
114
+ desc: 'remove',
115
+ range: [e.startIndex, e.endIndex],
116
+ text: ' ',
117
+ },
118
+ ];
119
+ errors.push(e);
110
120
  }
111
121
  else if (attr instanceof attribute_1.AttributeToken) {
112
122
  const { name } = attr;
@@ -56,7 +56,23 @@ class ConverterFlagsToken extends index_2.Token {
56
56
  && !variantFlags.has(flag)
57
57
  && !unknownFlags.has(flag)
58
58
  && (variantFlags.size > 0 || !validFlags.has(flag))) {
59
- errors.push((0, lint_1.generateForChild)(child, rect, 'no-ignored', 'invalid conversion flag'));
59
+ const e = (0, lint_1.generateForChild)(child, rect, 'no-ignored', 'invalid conversion flag');
60
+ if (variantFlags.size === 0 && definedFlags.has(flag.toUpperCase())) {
61
+ e.fix = {
62
+ range: [e.startIndex, e.endIndex],
63
+ text: flag.toUpperCase(),
64
+ };
65
+ }
66
+ else {
67
+ e.suggestions = [
68
+ {
69
+ desc: 'remove',
70
+ range: [e.startIndex, e.endIndex],
71
+ text: '',
72
+ },
73
+ ];
74
+ }
75
+ errors.push(e);
60
76
  }
61
77
  }
62
78
  return errors;
@@ -67,6 +67,18 @@ class GalleryToken extends index_2.Token {
67
67
  endLine: startLine,
68
68
  startCol,
69
69
  endCol: startCol + length,
70
+ suggestions: [
71
+ {
72
+ desc: 'remove',
73
+ range: [start, start + length],
74
+ text: '',
75
+ },
76
+ {
77
+ desc: 'comment',
78
+ range: [start, start + length],
79
+ text: `<!--${str}-->`,
80
+ },
81
+ ],
70
82
  });
71
83
  }
72
84
  else if (child.type !== 'noinclude' && child.type !== 'text') {
package/dist/src/html.js CHANGED
@@ -114,6 +114,24 @@ class HtmlToken extends index_2.Token {
114
114
  if (ancestor && magicWords.has(ancestor.name)) {
115
115
  error.severity = 'warning';
116
116
  }
117
+ else {
118
+ error.suggestions = [
119
+ {
120
+ desc: 'remove',
121
+ range: [start, error.endIndex],
122
+ text: '',
123
+ },
124
+ ];
125
+ }
126
+ }
127
+ else if (msg === 'tag that is both closing and self-closing') {
128
+ const { html: [, , voidTags] } = this.getAttribute('config');
129
+ if (voidTags.includes(this.name)) {
130
+ error.fix = {
131
+ range: [start + 1, start + 2],
132
+ text: '',
133
+ };
134
+ }
117
135
  }
118
136
  errors.push(error);
119
137
  }
@@ -131,7 +149,7 @@ class HtmlToken extends index_2.Token {
131
149
  refError ??= (0, lint_1.generateForSelf)(this, { start }, 'h1', '');
132
150
  errors.push({
133
151
  ...refError,
134
- rule: 'format-leakage',
152
+ rule: 'bold-header',
135
153
  message: index_1.default.msg('bold in section header'),
136
154
  severity: 'warning',
137
155
  });
@@ -102,7 +102,12 @@ class ImageParameterToken extends index_2.Token {
102
102
  lint(start = this.getAbsoluteIndex()) {
103
103
  const errors = super.lint(start), { link, name } = this;
104
104
  if (name === 'invalid') {
105
- errors.push((0, lint_1.generateForSelf)(this, { start }, 'invalid-gallery', 'invalid gallery image parameter'));
105
+ const e = (0, lint_1.generateForSelf)(this, { start }, 'invalid-gallery', 'invalid gallery image parameter');
106
+ e.fix = {
107
+ range: [start, start + e.endIndex],
108
+ text: '',
109
+ };
110
+ errors.push(e);
106
111
  }
107
112
  else if (typeof link === 'object' && link.encoded) {
108
113
  errors.push((0, lint_1.generateForSelf)(this, { start }, 'url-encoding', 'unnecessary URL encoding in an internal link'));
@@ -82,14 +82,34 @@ class LinkBaseToken extends index_2.Token {
82
82
  rect ??= { start, ...this.getRootNode().posFromIndex(start) };
83
83
  errors.push((0, lint_1.generateForChild)(target, rect, 'url-encoding', 'unnecessary URL encoding in an internal link'));
84
84
  }
85
- if ((linkType === 'link' || linkType === 'category')
86
- && linkText?.childNodes.some(({ type, data }) => type === 'text' && data.includes('|'))) {
87
- rect ??= { start, ...this.getRootNode().posFromIndex(start) };
88
- errors.push((0, lint_1.generateForChild)(linkText, rect, 'pipe-like', 'additional "|" in the link text', 'warning'));
85
+ if (linkType === 'link' || linkType === 'category') {
86
+ const textNode = linkText?.childNodes.find((c) => c.type === 'text' && c.data.includes('|'));
87
+ if (textNode) {
88
+ rect ??= { start, ...this.getRootNode().posFromIndex(start) };
89
+ const e = (0, lint_1.generateForChild)(linkText, rect, 'pipe-like', 'additional "|" in the link text', 'warning');
90
+ e.suggestions = [
91
+ {
92
+ desc: 'escape',
93
+ range: [
94
+ e.startIndex + textNode.getRelativeIndex(),
95
+ e.startIndex + textNode.getRelativeIndex() + textNode.data.length,
96
+ ],
97
+ text: textNode.data.replace(/\|/gu, '&#124;'),
98
+ },
99
+ ];
100
+ errors.push(e);
101
+ }
89
102
  }
90
- else if (linkType !== 'link' && fragment !== undefined) {
103
+ if (linkType !== 'link' && fragment !== undefined) {
91
104
  rect ??= { start, ...this.getRootNode().posFromIndex(start) };
92
- errors.push((0, lint_1.generateForChild)(target, rect, 'no-ignored', 'useless fragment'));
105
+ const e = (0, lint_1.generateForChild)(target, rect, 'no-ignored', 'useless fragment'), textNode = target.childNodes.find((c) => c.type === 'text' && c.data.includes('#'));
106
+ if (textNode) {
107
+ e.fix = {
108
+ range: [e.startIndex + textNode.getRelativeIndex() + textNode.data.indexOf('#'), e.endIndex],
109
+ text: '',
110
+ };
111
+ }
112
+ errors.push(e);
93
113
  }
94
114
  return errors;
95
115
  }
@@ -30,17 +30,41 @@ class MagicLinkToken extends index_2.Token {
30
30
  const refError = (0, lint_1.generateForChild)(child, rect, 'unterminated-url', '', 'warning');
31
31
  regexGlobal.lastIndex = 0;
32
32
  for (let mt = regexGlobal.exec(data); mt; mt = regexGlobal.exec(data)) {
33
- const { index, 0: s } = mt, lines = data.slice(0, index).split('\n'), top = lines.length, left = lines[top - 1].length, startIndex = refError.startIndex + index, startLine = refError.startLine + top - 1, startCol = top === 1 ? refError.startCol + left : left;
34
- errors.push({
33
+ const { index, 0: s } = mt, lines = data.slice(0, index).split('\n'), top = lines.length, left = lines[top - 1].length, startIndex = refError.startIndex + index, startLine = refError.startLine + top - 1, startCol = top === 1 ? refError.startCol + left : left, pipe = s.startsWith('|');
34
+ const e = {
35
35
  ...refError,
36
- message: index_1.default.msg('$1 in URL', s.startsWith('|') ? '"|"' : 'full-width punctuation'),
36
+ message: index_1.default.msg('$1 in URL', pipe ? '"|"' : 'full-width punctuation'),
37
37
  startIndex,
38
38
  endIndex: startIndex + s.length,
39
39
  startLine,
40
40
  endLine: startLine,
41
41
  startCol,
42
42
  endCol: startCol + s.length,
43
- });
43
+ };
44
+ if (!pipe) {
45
+ e.suggestions = [
46
+ {
47
+ desc: 'whitespace',
48
+ range: [startIndex, startIndex],
49
+ text: ' ',
50
+ },
51
+ {
52
+ desc: 'escape',
53
+ range: [startIndex, e.endIndex],
54
+ text: encodeURI(s),
55
+ },
56
+ ];
57
+ }
58
+ else if (s.length === 1) {
59
+ e.suggestions = [
60
+ {
61
+ desc: 'whitespace',
62
+ range: [startIndex, startIndex + 1],
63
+ text: ' ',
64
+ },
65
+ ];
66
+ }
67
+ errors.push(e);
44
68
  }
45
69
  }
46
70
  return errors;
@@ -50,7 +50,20 @@ class NestedToken extends index_2.Token {
50
50
  return str && !/^<!--.*-->$/su.test(str);
51
51
  }).map(child => {
52
52
  rect ??= { start, ...this.getRootNode().posFromIndex(start) };
53
- return (0, lint_1.generateForChild)(child, rect, 'no-ignored', index_1.default.msg('invalid content in <$1>', this.name));
53
+ const e = (0, lint_1.generateForChild)(child, rect, 'no-ignored', index_1.default.msg('invalid content in <$1>', this.name));
54
+ e.suggestions = [
55
+ {
56
+ desc: 'remove',
57
+ range: [e.startIndex, e.endIndex],
58
+ text: '',
59
+ },
60
+ {
61
+ desc: 'comment',
62
+ range: [e.startIndex, e.startIndex],
63
+ text: `<!--${String(child)}-->`,
64
+ },
65
+ ];
66
+ return e;
54
67
  }),
55
68
  ];
56
69
  }
@@ -20,9 +20,15 @@ class CommentToken extends (0, hidden_1.hiddenToken)(base_1.NowikiBaseToken) {
20
20
  }
21
21
  /** @override */
22
22
  lint(start = this.getAbsoluteIndex()) {
23
- return this.closed
24
- ? []
25
- : [(0, lint_1.generateForSelf)(this, { start }, 'unclosed-comment', index_1.default.msg('unclosed $1', 'HTML comment'))];
23
+ if (this.closed) {
24
+ return [];
25
+ }
26
+ const e = (0, lint_1.generateForSelf)(this, { start }, 'unclosed-comment', index_1.default.msg('unclosed $1', 'HTML comment'));
27
+ e.fix = {
28
+ range: [e.endIndex, e.endIndex],
29
+ text: '-->',
30
+ };
31
+ return [e];
26
32
  }
27
33
  /** @private */
28
34
  toString() {
@@ -10,9 +10,15 @@ class NowikiToken extends base_1.NowikiBaseToken {
10
10
  /** @override */
11
11
  lint(start = this.getAbsoluteIndex()) {
12
12
  const { name, firstChild: { data } } = this;
13
- return (name === 'templatestyles' || name === 'section') && data
14
- ? [(0, lint_1.generateForSelf)(this, { start }, 'void-ext', index_1.default.msg('nothing should be in <$1>', name))]
15
- : super.lint(start);
13
+ if ((name === 'templatestyles' || name === 'section') && data) {
14
+ const e = (0, lint_1.generateForSelf)(this, { start }, 'void-ext', index_1.default.msg('nothing should be in <$1>', name));
15
+ e.fix = {
16
+ range: [start - 1, e.endIndex + name.length + 3],
17
+ text: '/>',
18
+ };
19
+ return [e];
20
+ }
21
+ return super.lint(start);
16
22
  }
17
23
  }
18
24
  exports.NowikiToken = NowikiToken;
@@ -29,6 +29,13 @@ class QuoteToken extends base_1.NowikiBaseToken {
29
29
  startCol: endCol - length,
30
30
  endLine,
31
31
  endCol,
32
+ suggestions: [
33
+ {
34
+ desc: 'escape',
35
+ range: [startIndex, endIndex],
36
+ text: '&apos;'.repeat(length),
37
+ },
38
+ ],
32
39
  });
33
40
  }
34
41
  if (nextSibling?.type === 'text' && nextSibling.data.startsWith(`'`)) {
@@ -41,6 +48,13 @@ class QuoteToken extends base_1.NowikiBaseToken {
41
48
  startLine,
42
49
  startCol,
43
50
  endCol: startCol + length,
51
+ suggestions: [
52
+ {
53
+ desc: 'escape',
54
+ range: [startIndex, endIndex],
55
+ text: '&apos;'.repeat(length),
56
+ },
57
+ ],
44
58
  });
45
59
  }
46
60
  if (bold && this.closest('heading-title')) {
@@ -39,7 +39,15 @@ class ParamTagToken extends index_2.Token {
39
39
  return str && !(i >= 0 ? /^[a-z]+(?:\[\])?\s*(?:=|$)/iu : /^[a-z]+(?:\[\])?\s*=/iu).test(str);
40
40
  }).map(child => {
41
41
  rect ??= { start, ...this.getRootNode().posFromIndex(start) };
42
- return (0, lint_1.generateForChild)(child, rect, 'no-ignored', index_1.default.msg('invalid parameter of <$1>', this.name));
42
+ const e = (0, lint_1.generateForChild)(child, rect, 'no-ignored', index_1.default.msg('invalid parameter of <$1>', this.name));
43
+ e.suggestions = [
44
+ {
45
+ desc: 'remove',
46
+ range: [e.startIndex, e.endIndex],
47
+ text: '',
48
+ },
49
+ ];
50
+ return e;
43
51
  });
44
52
  }
45
53
  }
@@ -61,14 +61,16 @@ class ParameterToken extends index_2.Token {
61
61
  const errors = super.lint(start), { firstChild } = this, link = new RegExp(`https?://${string_1.extUrlCharFirst}${string_1.extUrlChar}$`, 'iu').exec(firstChild.text())?.[0];
62
62
  if (link && new URL(link).search) {
63
63
  const e = (0, lint_1.generateForChild)(firstChild, { start }, 'unescaped', 'unescaped query string in an anonymous parameter');
64
- errors.push({
65
- ...e,
66
- startIndex: e.endIndex,
67
- endIndex: e.endIndex + 1,
68
- startLine: e.endLine,
69
- startCol: e.endCol,
70
- endCol: e.endCol + 1,
71
- });
64
+ e.startIndex = e.endIndex;
65
+ e.startLine = e.endLine;
66
+ e.startCol = e.endCol;
67
+ e.endIndex++;
68
+ e.endCol++;
69
+ e.fix = {
70
+ range: [e.startIndex, e.endIndex],
71
+ text: '{{=}}',
72
+ };
73
+ errors.push(e);
72
74
  }
73
75
  return errors;
74
76
  }
@@ -81,7 +81,24 @@ class TdToken extends base_1.TableBaseToken {
81
81
  if (child.type === 'text') {
82
82
  const { data } = child;
83
83
  if (data.includes('|')) {
84
- errors.push((0, lint_1.generateForChild)(child, { start }, 'pipe-like', 'additional "|" in a table cell', data.includes('||') ? 'error' : 'warning'));
84
+ const isError = data.includes('||'), e = (0, lint_1.generateForChild)(child, { start }, 'pipe-like', 'additional "|" in a table cell', isError ? 'error' : 'warning');
85
+ if (isError) {
86
+ const syntax = { caption: '|+', td: '|', th: '!' }[this.subtype];
87
+ e.fix = {
88
+ range: [e.startIndex, e.endIndex],
89
+ text: data.replace(/\|\|/gu, `\n${syntax}`),
90
+ };
91
+ }
92
+ else {
93
+ e.suggestions = [
94
+ {
95
+ desc: 'escape',
96
+ range: [e.startIndex, e.endIndex],
97
+ text: data.replace(/\|/gu, '&#124;'),
98
+ },
99
+ ];
100
+ }
101
+ errors.push(e);
85
102
  }
86
103
  }
87
104
  }
@@ -27,13 +27,11 @@ class TrBaseToken extends base_1.TableBaseToken {
27
27
  catch { }
28
28
  }
29
29
  const error = (0, lint_1.generateForChild)(inter, { start }, 'fostered-content', 'content to be moved out from the table');
30
- errors.push({
31
- ...error,
32
- severity: first.type === 'template' ? 'warning' : 'error',
33
- startIndex: error.startIndex + 1,
34
- startLine: error.startLine + 1,
35
- startCol: 0,
36
- });
30
+ error.severity = first.type === 'template' ? 'warning' : 'error';
31
+ error.startIndex++;
32
+ error.startLine++;
33
+ error.startCol = 0;
34
+ errors.push(error);
37
35
  return errors;
38
36
  }
39
37
  }
@@ -22,9 +22,15 @@ class IncludeToken extends (0, hidden_1.hiddenToken)(index_2.TagPairToken) {
22
22
  }
23
23
  /** @override */
24
24
  lint(start = this.getAbsoluteIndex()) {
25
- return this.closed
26
- ? []
27
- : [(0, lint_1.generateForSelf)(this, { start }, 'unclosed-comment', index_1.default.msg('unclosed $1', `<${this.name}>`))];
25
+ if (this.closed) {
26
+ return [];
27
+ }
28
+ const e = (0, lint_1.generateForSelf)(this, { start }, 'unclosed-comment', index_1.default.msg('unclosed $1', `<${this.name}>`));
29
+ e.fix = {
30
+ range: [e.endIndex, e.endIndex],
31
+ text: `</${this.name}>`,
32
+ };
33
+ return [e];
28
34
  }
29
35
  }
30
36
  exports.IncludeToken = IncludeToken;
@@ -166,7 +166,14 @@ class TranscludeToken extends index_2.Token {
166
166
  const title = this.#getTitle();
167
167
  if (title.fragment !== undefined) {
168
168
  rect = { start, ...this.getRootNode().posFromIndex(start) };
169
- errors.push((0, lint_1.generateForChild)(childNodes[type === 'template' ? 0 : 1], rect, 'no-ignored', 'useless fragment'));
169
+ const child = childNodes[type === 'template' ? 0 : 1], e = (0, lint_1.generateForChild)(child, rect, 'no-ignored', 'useless fragment'), textNode = child.childNodes.find((c) => c.type === 'text' && c.data.includes('#'));
170
+ if (textNode) {
171
+ e.fix = {
172
+ range: [e.startIndex + textNode.getRelativeIndex() + textNode.data.indexOf('#'), e.endIndex],
173
+ text: '',
174
+ };
175
+ }
176
+ errors.push(e);
170
177
  }
171
178
  if (!title.valid) {
172
179
  rect ??= { start, ...this.getRootNode().posFromIndex(start) };
package/dist/util/diff.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.info = exports.error = exports.diff = exports.cmd = void 0;
4
4
  const fs = require("fs/promises");
5
5
  const child_process_1 = require("child_process");
6
+ const chalk = require("chalk");
6
7
  process.on('unhandledRejection', e => {
7
8
  console.error(e);
8
9
  });
@@ -72,11 +73,11 @@ const diff = async (oldStr, newStr, uid = -1) => {
72
73
  exports.diff = diff;
73
74
  /** @implements */
74
75
  const error = (msg, ...args) => {
75
- console.error('\x1B[31m%s\x1B[0m', msg, ...args);
76
+ console.error(chalk.red(msg), ...args);
76
77
  };
77
78
  exports.error = error;
78
79
  /** @implements */
79
80
  const info = (msg, ...args) => {
80
- console.info('\x1B[32m%s\x1B[0m', msg, ...args);
81
+ console.info(chalk.green(msg), ...args);
81
82
  };
82
83
  exports.info = info;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wikilint",
3
- "version": "2.4.5",
3
+ "version": "2.5.0",
4
4
  "description": "A Node.js linter for MediaWiki markup",
5
5
  "keywords": [
6
6
  "mediawiki",
@@ -51,6 +51,7 @@
51
51
  "@typescript-eslint/eslint-plugin": "^6.19.1",
52
52
  "@typescript-eslint/parser": "^6.19.1",
53
53
  "ajv-cli": "^5.0.0",
54
+ "chalk": "^4.1.2",
54
55
  "eslint": "^8.56.0",
55
56
  "eslint-plugin-es-x": "^7.5.0",
56
57
  "eslint-plugin-eslint-comments": "^3.2.0",