@mintlify/cli 4.0.1732 → 4.0.1734

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/src/cli.tsx CHANGED
@@ -56,6 +56,7 @@ import { sendPublishPrompt, signup } from './signup.js';
56
56
  import { status, getCliSubdomains } from './status.js';
57
57
  import { trackTelemetryPreferenceChange } from './telemetry/track.js';
58
58
  import { update } from './update.js';
59
+ import { runLintStep, splitList } from './validate/command.js';
59
60
  import { renderWelcome } from './welcome.js';
60
61
  import { workflowsBuilder } from './workflows/index.js';
61
62
 
@@ -379,9 +380,32 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
379
380
  default: false,
380
381
  description: 'Disable OpenAPI file generation',
381
382
  })
383
+ .option('files', {
384
+ type: 'string',
385
+ array: true,
386
+ description:
387
+ 'Limit page checks to these files or globs; site-wide checks and the build still cover the whole site',
388
+ })
389
+ .option('enable', {
390
+ type: 'string',
391
+ array: true,
392
+ coerce: splitList,
393
+ description:
394
+ 'Turn on rules or groups (comma-separated), for example after --disable all',
395
+ })
396
+ .option('disable', {
397
+ type: 'string',
398
+ array: true,
399
+ coerce: splitList,
400
+ description:
401
+ 'Turn off rules or groups (comma-separated), or all; parse errors are still reported',
402
+ })
382
403
  .usage('usage: mintlify validate [options]')
383
404
  .example('mintlify validate', 'validate the build'),
384
405
  async (argv) => {
406
+ const lintPassed = await runLintStep(argv);
407
+ if (!lintPassed) return;
408
+
385
409
  if (!argv['disable-openapi']) {
386
410
  let openApiFilenames: string[] = [];
387
411
 
@@ -66,6 +66,8 @@ async function walk(baseDir: string, dir = ''): Promise<string[]> {
66
66
  return files;
67
67
  }
68
68
 
69
+ export class NoMatchingFilesError extends Error {}
70
+
69
71
  export async function resolveExplicitFiles(baseDir: string, args: string[]): Promise<string[]> {
70
72
  let allFiles: string[] | null = null;
71
73
  const matched: string[] = [];
@@ -90,7 +92,7 @@ export async function resolveExplicitFiles(baseDir: string, args: string[]): Pro
90
92
  }
91
93
 
92
94
  if (unmatched.length > 0) {
93
- throw new Error(
95
+ throw new NoMatchingFilesError(
94
96
  `No matching files for: ${unmatched.join(', ')} (globs match .md and .mdx files)`
95
97
  );
96
98
  }
@@ -0,0 +1,71 @@
1
+ import { addLog, clearLogs, ErrorLog, SpinnerLog } from '@mintlify/previewing';
2
+ import path from 'node:path';
3
+
4
+ import { CMD_EXEC_PATH, terminate } from '../helpers.js';
5
+ import { NoMatchingFilesError, resolveExplicitFiles } from '../resolveFiles.js';
6
+ import { checkSite, selectRules, UsageError } from './engine.js';
7
+ import { logDiagnostics } from './output.js';
8
+
9
+ export type ValidateArgs = {
10
+ 'local-schema': boolean;
11
+ 'client-version'?: string;
12
+ groups?: (string | number)[];
13
+ files?: (string | number)[];
14
+ enable?: string[];
15
+ disable?: string[];
16
+ };
17
+
18
+ export const EXIT = { passed: 0, failed: 1, crashed: 2 } as const;
19
+
20
+ export const splitList = (values: string[]) =>
21
+ values.flatMap((value) =>
22
+ value
23
+ .split(',')
24
+ .map((item) => item.trim())
25
+ .filter(Boolean)
26
+ );
27
+
28
+ const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
29
+
30
+ const resolveSelection = async (baseDir: string, values: (string | number)[] | undefined) => {
31
+ const args = (values ?? []).map(String).filter(Boolean);
32
+ if (args.length === 0) return undefined;
33
+ const files = await resolveExplicitFiles(CMD_EXEC_PATH, args);
34
+ return new Set(
35
+ files.map((file) =>
36
+ path.relative(baseDir, path.resolve(CMD_EXEC_PATH, file)).split(path.sep).join('/')
37
+ )
38
+ );
39
+ };
40
+
41
+ export const runLintStep = async (
42
+ argv: ValidateArgs,
43
+ baseDir = CMD_EXEC_PATH
44
+ ): Promise<boolean> => {
45
+ try {
46
+ const rules = selectRules({
47
+ enable: argv.enable,
48
+ disable: argv.disable ?? [],
49
+ });
50
+ const selected = await resolveSelection(baseDir, argv.files);
51
+ addLog(<SpinnerLog message="validating..." />);
52
+
53
+ const { diagnostics } = await checkSite(baseDir, rules, {
54
+ selected,
55
+ localSchema: argv['local-schema'],
56
+ });
57
+ clearLogs();
58
+ const failed = logDiagnostics(diagnostics);
59
+ if (failed) {
60
+ await terminate(EXIT.failed);
61
+ return false;
62
+ }
63
+ return true;
64
+ } catch (error) {
65
+ const usage = error instanceof UsageError || error instanceof NoMatchingFilesError;
66
+ clearLogs();
67
+ addLog(<ErrorLog message={errorMessage(error)} />);
68
+ await terminate(usage ? EXIT.failed : EXIT.crashed);
69
+ return false;
70
+ }
71
+ };
@@ -0,0 +1,93 @@
1
+ import { checkFiles } from './files.js';
2
+ import { RULES } from './rules/index.js';
3
+ import { PARSE_ERROR } from './rules/parseError.js';
4
+ import { loadSite } from './site.js';
5
+ import {
6
+ GROUPS,
7
+ type Diagnostic,
8
+ type DiagnosticSource,
9
+ type FileRule,
10
+ type Finding,
11
+ type Rule,
12
+ type Site,
13
+ } from './types.js';
14
+
15
+ export class UsageError extends Error {}
16
+
17
+ const ruleList = () => RULES.map(({ id }) => id).join(', ');
18
+
19
+ const matchToken = (token: string): Rule[] => {
20
+ if (token === 'all') return RULES;
21
+ const rule = RULES.find(({ id }) => id === token);
22
+ if (rule) return [rule];
23
+ if ((GROUPS as string[]).includes(token)) {
24
+ return RULES.filter(({ group }) => group === token);
25
+ }
26
+ throw new UsageError(
27
+ `unknown rule or group "${token}". Groups: all, ${GROUPS.join(', ')}. Rules: ${ruleList()}`
28
+ );
29
+ };
30
+
31
+ /** Every rule, minus --disable, plus --enable. parse-error is the engine's own, not selectable. */
32
+ export const selectRules = ({
33
+ enable = [],
34
+ disable = [],
35
+ }: {
36
+ enable?: string[];
37
+ disable?: string[];
38
+ }): Rule[] => {
39
+ if ([...disable, ...enable].includes(PARSE_ERROR.id)) {
40
+ throw new UsageError(`${PARSE_ERROR.id} always runs and can't be disabled`);
41
+ }
42
+ const selected = new Set(RULES);
43
+ for (const token of disable) for (const rule of matchToken(token)) selected.delete(rule);
44
+ for (const token of enable) for (const rule of matchToken(token)) selected.add(rule);
45
+ return RULES.filter((rule) => selected.has(rule));
46
+ };
47
+
48
+ const toDiagnostic = (source: DiagnosticSource, finding: Finding): Diagnostic => ({
49
+ ...finding,
50
+ rule: source.id,
51
+ group: source.group,
52
+ severity: finding.severity ?? source.severity,
53
+ });
54
+
55
+ const RULE_ORDER = new Map(RULES.map(({ id }, index) => [id, index]));
56
+
57
+ const sortDiagnostics = (diagnostics: Diagnostic[]) =>
58
+ diagnostics.sort(
59
+ (a, b) =>
60
+ (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) ||
61
+ (a.line ?? 0) - (b.line ?? 0) ||
62
+ (a.column ?? 0) - (b.column ?? 0) ||
63
+ (RULE_ORDER.get(a.rule) ?? 0) - (RULE_ORDER.get(b.rule) ?? 0)
64
+ );
65
+
66
+ /** Runs every selected rule that doesn't need the build. */
67
+ export const runChecks = async (site: Site, rules: Rule[]) => {
68
+ const fileRules = rules.filter((rule): rule is FileRule => rule.scope === 'file');
69
+ const diagnostics = await checkFiles(site, fileRules);
70
+
71
+ for (const rule of rules) {
72
+ if (rule.scope !== 'site' || rule.needsBuild) continue;
73
+ for (const finding of await rule.check(site)) diagnostics.push(toDiagnostic(rule, finding));
74
+ }
75
+ return sortDiagnostics(diagnostics);
76
+ };
77
+
78
+ /** Loads the site and runs the selected rules. */
79
+ export const checkSite = async (
80
+ baseDir: string,
81
+ rules: Rule[],
82
+ options: { selected?: ReadonlySet<string>; localSchema: boolean }
83
+ ) => {
84
+ const site = await loadSite(baseDir, options);
85
+ const { selected } = options;
86
+ if (selected && !site.files.some(({ file }) => selected.has(file))) {
87
+ throw new UsageError(
88
+ `none of the given files are pages or snippets of this site: ${[...selected].join(', ')}`
89
+ );
90
+ }
91
+ const diagnostics = await runChecks(site, rules);
92
+ return { site, diagnostics };
93
+ };
@@ -0,0 +1,212 @@
1
+ import { getAST, getFileCategory, parseFrontmatter, replaceVariables } from '@mintlify/common';
2
+ import {
3
+ categorizeMdxFiles,
4
+ getErrorPosition,
5
+ getMintIgnore,
6
+ listContentFiles,
7
+ } from '@mintlify/prebuild';
8
+ import type { Root } from 'mdast';
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+
12
+ import { applyEdits, parseContent, relocate, selectEdits, unmapEdit } from './mdx.js';
13
+ import { PARSE_ERROR } from './rules/parseError.js';
14
+ import type {
15
+ Diagnostic,
16
+ Edit,
17
+ FileContext,
18
+ FileFinding,
19
+ FileKind,
20
+ FileRule,
21
+ Site,
22
+ SiteFile,
23
+ } from './types.js';
24
+
25
+ const CONCURRENCY = 16;
26
+
27
+ const mapLimit = async <T, R>(items: T[], task: (item: T) => Promise<R>): Promise<R[]> => {
28
+ const results = new Array<R>(items.length);
29
+ let next = 0;
30
+ await Promise.all(
31
+ Array.from({ length: Math.min(CONCURRENCY, items.length) }, async () => {
32
+ while (next < items.length) {
33
+ const index = next++;
34
+ results[index] = await task(items[index] as T);
35
+ }
36
+ })
37
+ );
38
+ return results;
39
+ };
40
+
41
+ const CHECKED_CATEGORIES = new Set<string | null>(['page', 'snippet', 'snippet-v2']);
42
+
43
+ export const readSiteFiles = async (baseDir: string): Promise<SiteFile[]> => {
44
+ const { mdxCandidates: candidates } = await listContentFiles(
45
+ baseDir,
46
+ await getMintIgnore(baseDir)
47
+ );
48
+ const { snippets, snippetsV2 } = await categorizeMdxFiles(baseDir, candidates);
49
+ const snippetFiles = new Set([...snippets, ...snippetsV2]);
50
+ return candidates
51
+ .filter((name) => /\.mdx?$/.test(name) && CHECKED_CATEGORIES.has(getFileCategory(name)))
52
+ .map((name) => ({
53
+ file: name.replace(/^\//, ''),
54
+ kind: (snippetFiles.has(name) ? 'snippet' : 'page') as FileKind,
55
+ }));
56
+ };
57
+
58
+ const readFrontmatter = (content: string): Record<string, unknown> => {
59
+ try {
60
+ const { attributes } = parseFrontmatter<unknown>(content);
61
+ return attributes && typeof attributes === 'object' ? { ...attributes } : {};
62
+ } catch {
63
+ return {};
64
+ }
65
+ };
66
+
67
+ type ParseError = { line?: number; column?: number; message: string };
68
+
69
+ const REASON_POSITION = /\s*\(\d+:\d+(?:-\d+:\d+)?\)$/;
70
+
71
+ export const toParseError = (error: unknown): ParseError => {
72
+ const reason = (error as { reason?: unknown } | null)?.reason;
73
+ const fallback = error instanceof Error ? error.message : 'could not parse this file';
74
+ const text = typeof reason === 'string' ? reason : fallback;
75
+ const position = error && typeof error === 'object' ? getErrorPosition(error) : null;
76
+ return {
77
+ ...(position?.line != null && { line: position.line }),
78
+ ...(position?.column != null && { column: position.column }),
79
+ message: text.replace(REASON_POSITION, ''),
80
+ };
81
+ };
82
+
83
+ const parseErrorDiagnostic = (file: string, error: ParseError): Diagnostic => ({
84
+ ...error,
85
+ rule: PARSE_ERROR.id,
86
+ group: PARSE_ERROR.group,
87
+ severity: PARSE_ERROR.severity,
88
+ file,
89
+ message: `${error.message} (other checks were skipped for this file)`,
90
+ });
91
+
92
+ type Parsed = { context: FileContext; approximate: boolean } | { error: ParseError };
93
+
94
+ const tryParseContent = (content: string): Root | undefined => {
95
+ try {
96
+ return parseContent(content);
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ };
101
+
102
+ const parseFile = (file: string, content: string, baseDir: string): Parsed => {
103
+ const context = { file, content, baseDir, frontmatter: readFrontmatter(content) };
104
+ const tree = tryParseContent(content);
105
+ if (tree) return { context: { ...context, tree }, approximate: false };
106
+ try {
107
+ return { context: { ...context, tree: getAST(content, file) }, approximate: true };
108
+ } catch (error) {
109
+ return { error: toParseError(error) };
110
+ }
111
+ };
112
+
113
+ const EMPTY_TREE = { type: 'root' as const, children: [] };
114
+
115
+ const SOURCE_STAGE = 1;
116
+ const AFTER_RAW_STAGE = 2;
117
+
118
+ type Stage = { content: string; edits: Edit[] };
119
+
120
+ const VARIABLE = /\{\{\s*([\w.\-]+)\s*\}\}/g;
121
+
122
+ const variableEdits = (content: string, variables: Record<string, string> = {}): Edit[] =>
123
+ [...content.matchAll(VARIABLE)]
124
+ .filter((match) => match[1] !== undefined && variables[match[1]] !== undefined)
125
+ .map((match) => ({
126
+ start: match.index,
127
+ end: match.index + match[0].length,
128
+ text: replaceVariables(match[0], variables),
129
+ }));
130
+
131
+ const toDiagnostic = (
132
+ rule: FileRule,
133
+ file: string,
134
+ { edit, ...finding }: FileFinding,
135
+ stages: Stage[],
136
+ stage: number
137
+ ): Diagnostic => {
138
+ const original = edit && unmapEdit(stages, stage, edit);
139
+ return {
140
+ ...finding,
141
+ ...relocate(stages, stage, finding),
142
+ rule: rule.id,
143
+ group: rule.group,
144
+ severity: rule.severity,
145
+ file,
146
+ ...(original && { fix: { file, ...original } }),
147
+ };
148
+ };
149
+
150
+ const checkFile = (
151
+ allRules: FileRule[],
152
+ { file, kind }: SiteFile,
153
+ content: string,
154
+ baseDir: string,
155
+ variables?: Record<string, string>
156
+ ): Diagnostic[] => {
157
+ const rules = kind === 'snippet' ? allRules.filter((rule) => !rule.pagesOnly) : allRules;
158
+ const raw = rules.filter((rule) => rule.raw);
159
+ const treeRules = rules.filter((rule) => !rule.raw);
160
+ const varEdits = kind === 'snippet' ? variableEdits(content, variables) : [];
161
+ const source = applyEdits(content, varEdits);
162
+ const rawContext = { file, content: source, baseDir, frontmatter: {}, tree: EMPTY_TREE };
163
+
164
+ const rawFound = raw.flatMap((rule) =>
165
+ rule.check(rawContext).map((finding) => ({ rule, finding }))
166
+ );
167
+ const rawEdits = selectEdits(
168
+ rawFound.flatMap(({ finding }) => (finding.edit ? [finding.edit] : []))
169
+ );
170
+ const afterRaw = applyEdits(source, rawEdits);
171
+ const stages: Stage[] = [
172
+ { content, edits: varEdits },
173
+ { content: source, edits: rawEdits },
174
+ { content: afterRaw, edits: [] },
175
+ ];
176
+ const diagnostics = rawFound.map(({ rule, finding }) =>
177
+ toDiagnostic(rule, file, finding, stages, SOURCE_STAGE)
178
+ );
179
+
180
+ const parsed = parseFile(file, afterRaw, baseDir);
181
+ if ('error' in parsed) {
182
+ diagnostics.unshift(
183
+ parseErrorDiagnostic(file, {
184
+ ...parsed.error,
185
+ ...relocate(stages, AFTER_RAW_STAGE, parsed.error),
186
+ })
187
+ );
188
+ return diagnostics;
189
+ }
190
+ for (const rule of treeRules) {
191
+ for (const finding of rule.check(parsed.context)) {
192
+ const found = parsed.approximate ? { ...finding, edit: undefined } : finding;
193
+ diagnostics.push(toDiagnostic(rule, file, found, stages, AFTER_RAW_STAGE));
194
+ }
195
+ }
196
+ return diagnostics;
197
+ };
198
+
199
+ export const checkFiles = async (site: Site, rules: FileRule[]): Promise<Diagnostic[]> => {
200
+ const targets = site.files.filter(({ file }) => !site.selected || site.selected.has(file));
201
+ const results = await mapLimit(targets, async (entry) => {
202
+ let content: string;
203
+ try {
204
+ content = await fs.readFile(path.join(site.baseDir, entry.file), 'utf-8');
205
+ } catch (error) {
206
+ const message = error instanceof Error ? error.message : 'could not read this file';
207
+ return [parseErrorDiagnostic(entry.file, { message })];
208
+ }
209
+ return checkFile(rules, entry, content, site.baseDir, site.variables);
210
+ });
211
+ return results.flat();
212
+ };
@@ -0,0 +1,252 @@
1
+ import {
2
+ coreRemark,
3
+ FRONTMATTER_RE,
4
+ getCodeRanges,
5
+ HEADING_ID_RE,
6
+ JSXParser,
7
+ } from '@mintlify/common';
8
+ import type { Root } from 'mdast';
9
+
10
+ import type { Edit } from './types.js';
11
+
12
+ const HEADING_ID = new RegExp(HEADING_ID_RE.source, 'gm');
13
+ const ESM_START = /^(?:import|export)(?=[\s{*])/;
14
+ const JSX_TAG = /<[A-Za-z][\w.:-]*(?:"[^"]*"|'[^']*'|\{[^{}]*\}|[^<>"'{}])*>/g;
15
+ const ATTR_VALUE = /"[^"]*"|'[^']*'|\{[^{}]*\}/g;
16
+
17
+ export const hasCustomHeadingId = (line: string) => /\{\s*#[^}]+?\s*\}\s*$/.test(line);
18
+
19
+ export const parseContent = (content: string): Root => {
20
+ const padded = content.replace(
21
+ HEADING_ID,
22
+ (match, indent: string, hashes: string, text: string) => {
23
+ const end = match.indexOf(text, indent.length + hashes.length) + text.length;
24
+ return match.slice(0, end).padEnd(match.length, ' ');
25
+ }
26
+ );
27
+ return coreRemark().parse(padded);
28
+ };
29
+
30
+ type Range = [number, number];
31
+
32
+ const blankRanges = (content: string, ranges: Range[]) => {
33
+ let result = '';
34
+ let cursor = 0;
35
+ for (const [from, to] of [...ranges].sort((a, b) => a[0] - b[0])) {
36
+ const start = Math.max(from, cursor);
37
+ if (to <= start) continue;
38
+ result += content.slice(cursor, start) + content.slice(start, to).replace(/[^\n]/g, ' ');
39
+ cursor = to;
40
+ }
41
+ return result + content.slice(cursor);
42
+ };
43
+
44
+ const maskWith = (content: string, maskers: ((content: string) => Range[])[]) =>
45
+ maskers.reduce((text, ranges) => blankRanges(text, ranges(text)), content);
46
+
47
+ const isCompleteEsm = (text: string) => {
48
+ try {
49
+ JSXParser.parse(text, { ecmaVersion: 'latest', sourceType: 'module' });
50
+ return true;
51
+ } catch (error) {
52
+ const pos = (error as { pos?: unknown }).pos;
53
+ return typeof pos !== 'number' || pos < text.trimEnd().length;
54
+ }
55
+ };
56
+
57
+ const frontmatterRanges = (content: string): Range[] => {
58
+ const match = FRONTMATTER_RE.exec(content);
59
+ return match ? [[match.index, match.index + match[0].length]] : [];
60
+ };
61
+
62
+ const esmRanges = (content: string, codeRanges: Range[]): Range[] => {
63
+ let esm: string[] | undefined;
64
+ let previousBlank = true;
65
+ const ranges: Range[] = [];
66
+ let offset = 0;
67
+ for (const line of content.split('\n')) {
68
+ const isBlank = line.trim() === '';
69
+ const wasBlank = previousBlank;
70
+ previousBlank = isBlank;
71
+ if (esm) {
72
+ if (isBlank && isCompleteEsm(esm.join('\n'))) esm = undefined;
73
+ else {
74
+ esm.push(line);
75
+ ranges.push([offset, offset + line.length]);
76
+ }
77
+ } else if (
78
+ wasBlank &&
79
+ ESM_START.test(line) &&
80
+ !codeRanges.some(([start, end]) => start <= offset && offset < end)
81
+ ) {
82
+ esm = [line];
83
+ ranges.push([offset, offset + line.length]);
84
+ }
85
+ offset += line.length + 1;
86
+ }
87
+ return ranges;
88
+ };
89
+
90
+ export const maskFrontmatter = (content: string): string =>
91
+ blankRanges(content, frontmatterRanges(content));
92
+
93
+ export const maskCode = (content: string, { keepEsm = false } = {}): string => {
94
+ const codeRanges = getCodeRanges(content);
95
+ return blankRanges(content, [
96
+ ...frontmatterRanges(content),
97
+ ...codeRanges,
98
+ ...(keepEsm ? [] : esmRanges(content, codeRanges)),
99
+ ]);
100
+ };
101
+
102
+ const OPENS = new Set(['{', '${']);
103
+
104
+ const maskExpression = (content: string, start: number): { ranges: Range[]; end: number } => {
105
+ const ranges: Range[] = [];
106
+ let depth = 0;
107
+ try {
108
+ const tokens = JSXParser.tokenizer(content.slice(start), {
109
+ ecmaVersion: 'latest',
110
+ onComment: (block, _text, from, to) =>
111
+ ranges.push([start + from + 2, start + to - (block ? 2 : 0)]),
112
+ });
113
+ for (const token of tokens) {
114
+ const label = token.type.label;
115
+ if (label === 'template') ranges.push([start + token.start, start + token.end]);
116
+ else if (label === 'string') ranges.push([start + token.start + 1, start + token.end - 1]);
117
+ else if (label === 'regexp') {
118
+ const raw = content.slice(start + token.start, start + token.end);
119
+ ranges.push([start + token.start + 1, start + token.start + raw.lastIndexOf('/')]);
120
+ } else if (OPENS.has(label)) depth++;
121
+ else if (label === '}' && depth-- === 0) return { ranges, end: start + token.end };
122
+ }
123
+ } catch (error) {
124
+ if (!(error instanceof SyntaxError && 'pos' in error && typeof error.pos === 'number')) {
125
+ return { ranges, end: content.length };
126
+ }
127
+ const pos = start + error.pos;
128
+ if (!error.message.startsWith('Unterminated string')) return { ranges, end: pos };
129
+ const lineEnd = content.indexOf('\n', pos);
130
+ ranges.push([pos + 1, lineEnd === -1 ? content.length : lineEnd]);
131
+ }
132
+ return { ranges, end: content.length };
133
+ };
134
+
135
+ const expressionRanges = (content: string): Range[] => {
136
+ const ranges: Range[] = [];
137
+ for (let index = 0; index < content.length; index++) {
138
+ const char = content[index];
139
+ if (char === '\\') index++;
140
+ else if (char === '{') {
141
+ const expression = maskExpression(content, index + 1);
142
+ ranges.push(...expression.ranges);
143
+ index = expression.end - 1;
144
+ }
145
+ }
146
+ return ranges;
147
+ };
148
+
149
+ const attributeRanges = (content: string): Range[] =>
150
+ [...content.matchAll(JSX_TAG)].flatMap(({ index, 0: tag }) =>
151
+ [...tag.matchAll(ATTR_VALUE)].map(
152
+ ({ index: at, 0: value }): Range => [index + at + 1, index + at + value.length - 1]
153
+ )
154
+ );
155
+
156
+ const escapedTagRanges = (content: string): Range[] =>
157
+ [...content.matchAll(/\\[\s\S]/g)].flatMap(({ index, 0: escape }): Range[] =>
158
+ escape === '\\<' ? [[index + 1, index + 2]] : []
159
+ );
160
+
161
+ export const maskNonMarkup = (content: string): string =>
162
+ maskWith(maskCode(content), [attributeRanges, escapedTagRanges, expressionRanges]);
163
+
164
+ export const selectEdits = <T extends Edit>(edits: T[]): T[] => {
165
+ const ordered = edits
166
+ .map((edit, index) => ({ edit, index }))
167
+ .sort((a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.index - a.index);
168
+ const selected: T[] = [];
169
+ let floor = Infinity;
170
+ for (const { edit } of ordered) {
171
+ if (edit.end > floor) continue;
172
+ selected.push(edit);
173
+ floor = edit.start;
174
+ }
175
+ return selected;
176
+ };
177
+
178
+ export const applyEdits = (content: string, edits: Edit[]): string =>
179
+ selectEdits(edits).reduce(
180
+ (result, edit) => result.slice(0, edit.start) + edit.text + result.slice(edit.end),
181
+ content
182
+ );
183
+
184
+ const toOffset = (content: string, line: number, column = 1) => {
185
+ let offset = 0;
186
+ for (let current = 1; current < line; current++) {
187
+ const next = content.indexOf('\n', offset);
188
+ if (next === -1) break;
189
+ offset = next + 1;
190
+ }
191
+ return offset + column - 1;
192
+ };
193
+
194
+ export const toPosition = (content: string, offset: number) => {
195
+ const before = content.slice(0, offset).split('\n');
196
+ return { line: before.length, column: (before.pop()?.length ?? 0) + 1 };
197
+ };
198
+
199
+ const byStart = (a: Edit, b: Edit) => a.start - b.start;
200
+
201
+ /** Maps an offset in edited content back to the content before the edits, or undefined inside an edit. */
202
+ function toOriginal(edits: Edit[], offset: number, inside: 'snap'): number;
203
+ function toOriginal(edits: Edit[], offset: number, inside: 'reject'): number | undefined;
204
+ function toOriginal(edits: Edit[], offset: number, inside: 'snap' | 'reject') {
205
+ let delta = 0;
206
+ for (const edit of edits) {
207
+ const start = edit.start + delta;
208
+ if (inside === 'snap' ? offset < start : offset <= start) break;
209
+ if (offset < start + edit.text.length) return inside === 'snap' ? edit.start : undefined;
210
+ delta += edit.text.length - (edit.end - edit.start);
211
+ }
212
+ return offset - delta;
213
+ }
214
+
215
+ /** Maps an edit made against a later stage back to the original content, or undefined when it touches an earlier fix. */
216
+ export const unmapEdit = (
217
+ stages: { content: string; edits: Edit[] }[],
218
+ index: number,
219
+ edit: Edit
220
+ ): Edit | undefined => {
221
+ let { start, end } = edit;
222
+ for (const { edits } of stages.slice(0, index).reverse()) {
223
+ const sorted = [...edits].sort(byStart);
224
+ const mappedStart = toOriginal(sorted, start, 'reject');
225
+ const mappedEnd = toOriginal(sorted, end, 'reject');
226
+ if (mappedStart === undefined || mappedEnd === undefined) return undefined;
227
+ if (sorted.some((earlier) => earlier.start < mappedEnd && earlier.end > mappedStart)) {
228
+ return undefined;
229
+ }
230
+ start = mappedStart;
231
+ end = mappedEnd;
232
+ }
233
+ return { start, end, text: edit.text };
234
+ };
235
+
236
+ export const relocate = (
237
+ stages: { content: string; edits: Edit[] }[],
238
+ index: number,
239
+ position: { line?: number; column?: number }
240
+ ) => {
241
+ const stage = stages[index];
242
+ const original = stages[0];
243
+ if (!stage || !original || position.line === undefined) return {};
244
+ const offset = stages
245
+ .slice(0, index)
246
+ .reduceRight(
247
+ (current, { edits }) => toOriginal([...edits].sort(byStart), current, 'snap'),
248
+ toOffset(stage.content, position.line, position.column)
249
+ );
250
+ const mapped = toPosition(original.content, offset);
251
+ return position.column === undefined ? { line: mapped.line } : mapped;
252
+ };