@mui/internal-code-infra 0.0.4-canary.1 → 0.0.4-canary.100

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.
Files changed (146) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +22 -8
  3. package/build/babel-config.d.mts +11 -3
  4. package/build/brokenLinksChecker/crawlWorker.d.mts +1 -0
  5. package/build/brokenLinksChecker/index.d.mts +192 -2
  6. package/build/changelog/fetchChangelogs.d.mts +3 -0
  7. package/build/changelog/types.d.ts +3 -1
  8. package/build/cli/cmdArgosPush.d.mts +8 -2
  9. package/build/cli/cmdBuild.d.mts +48 -2
  10. package/build/cli/cmdCopyFiles.d.mts +14 -2
  11. package/build/cli/cmdExtractErrorCodes.d.mts +2 -2
  12. package/build/cli/cmdGenerateChangelog.d.mts +17 -2
  13. package/build/cli/cmdGithubAuth.d.mts +2 -2
  14. package/build/cli/cmdListWorkspaces.d.mts +16 -2
  15. package/build/cli/cmdNetlifyIgnore.d.mts +12 -2
  16. package/build/cli/cmdPublish.d.mts +24 -4
  17. package/build/cli/cmdPublishCanary.d.mts +21 -2
  18. package/build/cli/cmdPublishNewPackage.d.mts +10 -2
  19. package/build/cli/cmdRenameScope.d.mts +18 -0
  20. package/build/cli/cmdSetVersionOverrides.d.mts +5 -2
  21. package/build/cli/cmdVale.d.mts +46 -0
  22. package/build/cli/cmdValidateBuiltTypes.d.mts +2 -2
  23. package/build/eslint/baseConfig.d.mts +3 -1
  24. package/build/eslint/mui/rules/disallow-react-api-in-server-components.d.mts +2 -2
  25. package/build/eslint/mui/rules/docgen-ignore-before-comment.d.mts +2 -2
  26. package/build/eslint/mui/rules/no-floating-cleanup.d.mts +5 -0
  27. package/build/eslint/mui/rules/no-guarded-throw.d.mts +31 -0
  28. package/build/eslint/mui/rules/no-presentation-role.d.mts +5 -0
  29. package/build/eslint/mui/rules/no-restricted-resolved-imports.d.mts +8 -2
  30. package/build/eslint/mui/rules/nodeEnvUtils.d.mts +18 -0
  31. package/build/remark/config.d.mts +43 -0
  32. package/build/remark/createLintTester.d.mts +10 -0
  33. package/build/remark/firstBlockHeading.d.mts +4 -0
  34. package/build/remark/gitDiff.d.mts +2 -0
  35. package/build/remark/noSpaceInLinks.d.mts +2 -0
  36. package/build/remark/straightQuotes.d.mts +2 -0
  37. package/build/remark/tableAlignment.d.mts +2 -0
  38. package/build/remark/terminalLanguage.d.mts +2 -0
  39. package/build/utils/babel.d.mts +7 -1
  40. package/build/utils/build.d.mts +59 -37
  41. package/build/utils/extractErrorCodes.d.mts +9 -0
  42. package/build/utils/git.d.mts +16 -0
  43. package/build/utils/github.d.mts +13 -1
  44. package/build/utils/pnpm.d.mts +200 -7
  45. package/build/utils/testUtils.d.mts +28 -0
  46. package/build/utils/typescript.d.mts +6 -16
  47. package/package.json +83 -56
  48. package/src/babel-config.mjs +9 -3
  49. package/src/brokenLinksChecker/crawlWorker.mjs +240 -0
  50. package/src/brokenLinksChecker/index.mjs +263 -188
  51. package/src/build-env.d.ts +13 -0
  52. package/src/changelog/fetchChangelogs.mjs +8 -2
  53. package/src/changelog/renderChangelog.mjs +1 -1
  54. package/src/changelog/types.ts +3 -1
  55. package/src/cli/cmdBuild.mjs +51 -85
  56. package/src/cli/cmdListWorkspaces.mjs +16 -3
  57. package/src/cli/cmdNetlifyIgnore.mjs +34 -93
  58. package/src/cli/cmdPublish.mjs +105 -36
  59. package/src/cli/cmdPublishCanary.mjs +145 -109
  60. package/src/cli/cmdPublishNewPackage.mjs +31 -8
  61. package/src/cli/cmdRenameScope.mjs +76 -0
  62. package/src/cli/cmdSetVersionOverrides.mjs +8 -9
  63. package/src/cli/cmdVale.mjs +513 -0
  64. package/src/cli/index.mjs +4 -0
  65. package/src/cli/packageJson.d.ts +2 -3
  66. package/src/eslint/baseConfig.mjs +51 -19
  67. package/src/eslint/docsConfig.mjs +2 -1
  68. package/src/eslint/mui/config.mjs +24 -4
  69. package/src/eslint/mui/index.mjs +6 -0
  70. package/src/eslint/mui/rules/no-floating-cleanup.mjs +187 -0
  71. package/src/eslint/mui/rules/no-guarded-throw.mjs +115 -0
  72. package/src/eslint/mui/rules/no-presentation-role.mjs +60 -0
  73. package/src/eslint/mui/rules/nodeEnvUtils.mjs +52 -0
  74. package/src/eslint/mui/rules/require-dev-wrapper.mjs +25 -40
  75. package/src/estree-typescript.d.ts +1 -1
  76. package/src/remark/config.mjs +157 -0
  77. package/src/remark/createLintTester.mjs +19 -0
  78. package/src/remark/firstBlockHeading.mjs +87 -0
  79. package/src/remark/gitDiff.mjs +43 -0
  80. package/src/remark/noSpaceInLinks.mjs +42 -0
  81. package/src/remark/straightQuotes.mjs +31 -0
  82. package/src/remark/tableAlignment.mjs +23 -0
  83. package/src/remark/terminalLanguage.mjs +19 -0
  84. package/src/stylelint/index.mjs +2 -2
  85. package/src/untyped-plugins.d.ts +14 -14
  86. package/src/utils/build.mjs +604 -270
  87. package/src/utils/git.mjs +27 -7
  88. package/src/utils/pnpm.mjs +549 -26
  89. package/src/utils/testUtils.mjs +53 -0
  90. package/src/utils/typescript.mjs +23 -42
  91. package/vale/.vale.ini +1 -0
  92. package/vale/styles/MUI/CorrectReferenceAllCases.yml +42 -0
  93. package/vale/styles/MUI/CorrectRererenceCased.yml +14 -0
  94. package/vale/styles/MUI/GoogleLatin.yml +11 -0
  95. package/vale/styles/MUI/MuiBrandName.yml +22 -0
  96. package/vale/styles/MUI/NoBritish.yml +112 -0
  97. package/vale/styles/MUI/NoCompanyName.yml +17 -0
  98. package/build/markdownlint/duplicate-h1.d.mts +0 -56
  99. package/build/markdownlint/git-diff.d.mts +0 -11
  100. package/build/markdownlint/index.d.mts +0 -52
  101. package/build/markdownlint/straight-quotes.d.mts +0 -11
  102. package/build/markdownlint/table-alignment.d.mts +0 -11
  103. package/build/markdownlint/terminal-language.d.mts +0 -11
  104. package/src/brokenLinksChecker/__fixtures__/static-site/broken-links.html +0 -20
  105. package/src/brokenLinksChecker/__fixtures__/static-site/broken-targets.html +0 -22
  106. package/src/brokenLinksChecker/__fixtures__/static-site/example.md +0 -20
  107. package/src/brokenLinksChecker/__fixtures__/static-site/external-links.html +0 -21
  108. package/src/brokenLinksChecker/__fixtures__/static-site/ignored-page.html +0 -17
  109. package/src/brokenLinksChecker/__fixtures__/static-site/index.html +0 -28
  110. package/src/brokenLinksChecker/__fixtures__/static-site/known-targets.json +0 -5
  111. package/src/brokenLinksChecker/__fixtures__/static-site/nested/page.html +0 -21
  112. package/src/brokenLinksChecker/__fixtures__/static-site/orphaned-page.html +0 -20
  113. package/src/brokenLinksChecker/__fixtures__/static-site/page-with-api-links.html +0 -20
  114. package/src/brokenLinksChecker/__fixtures__/static-site/page-with-custom-targets.html +0 -24
  115. package/src/brokenLinksChecker/__fixtures__/static-site/page-with-ignored-content.html +0 -28
  116. package/src/brokenLinksChecker/__fixtures__/static-site/page-with-known-target-links.html +0 -19
  117. package/src/brokenLinksChecker/__fixtures__/static-site/unclosed-tags.html +0 -1
  118. package/src/brokenLinksChecker/__fixtures__/static-site/valid.html +0 -20
  119. package/src/brokenLinksChecker/__fixtures__/static-site/with-anchors.html +0 -31
  120. package/src/brokenLinksChecker/index.test.ts +0 -261
  121. package/src/changelog/categorizeCommits.test.ts +0 -319
  122. package/src/changelog/filterCommits.test.ts +0 -363
  123. package/src/changelog/parseCommitLabels.test.ts +0 -509
  124. package/src/changelog/renderChangelog.test.ts +0 -378
  125. package/src/changelog/sortSections.test.ts +0 -199
  126. package/src/eslint/mui/rules/add-undef-to-optional.test.mjs +0 -361
  127. package/src/eslint/mui/rules/consistent-production-guard.test.mjs +0 -162
  128. package/src/eslint/mui/rules/disallow-active-elements-as-key-event-target.test.mjs +0 -66
  129. package/src/eslint/mui/rules/disallow-react-api-in-server-components.test.mjs +0 -305
  130. package/src/eslint/mui/rules/docgen-ignore-before-comment.test.mjs +0 -52
  131. package/src/eslint/mui/rules/flatten-parentheses.test.mjs +0 -245
  132. package/src/eslint/mui/rules/mui-name-matches-component-name.test.mjs +0 -247
  133. package/src/eslint/mui/rules/no-empty-box.test.mjs +0 -40
  134. package/src/eslint/mui/rules/no-styled-box.test.mjs +0 -73
  135. package/src/eslint/mui/rules/require-dev-wrapper.test.mjs +0 -265
  136. package/src/eslint/mui/rules/rules-of-use-theme-variants.test.mjs +0 -149
  137. package/src/eslint/mui/rules/straight-quotes.test.mjs +0 -67
  138. package/src/markdownlint/duplicate-h1.mjs +0 -69
  139. package/src/markdownlint/git-diff.mjs +0 -31
  140. package/src/markdownlint/index.mjs +0 -62
  141. package/src/markdownlint/straight-quotes.mjs +0 -26
  142. package/src/markdownlint/table-alignment.mjs +0 -42
  143. package/src/markdownlint/terminal-language.mjs +0 -19
  144. package/src/utils/build.test.mjs +0 -705
  145. package/src/utils/template.test.mjs +0 -133
  146. package/src/utils/typescript.test.mjs +0 -380
@@ -11,25 +11,44 @@ import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
11
11
 
12
12
  import { getRepositoryInfo } from '../utils/git.mjs';
13
13
  import { toPosixPath } from '../utils/path.mjs';
14
- import { getWorkspacePackages } from '../utils/pnpm.mjs';
14
+ import { getPackagesNeedingManualPublish, getWorkspacePackages } from '../utils/pnpm.mjs';
15
15
 
16
16
  /**
17
17
  * @typedef {Object} Args
18
18
  * @property {boolean} [dryRun] If true, will only log the commands without executing them
19
+ * @property {string} [otp] 6 digit auth code to forward to npm for two-factor authentication
19
20
  */
20
21
 
21
22
  export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
22
23
  command: 'publish-new-package [pkg...]',
23
24
  describe: 'Publish new empty package(s) to the npm registry.',
24
25
  builder: (yargs) =>
25
- yargs.option('dryRun', {
26
- type: 'boolean',
27
- default: false,
28
- description: 'If true, will only log the commands without executing them.',
29
- }),
26
+ yargs
27
+ .option('dryRun', {
28
+ type: 'boolean',
29
+ default: false,
30
+ description: 'If true, will only log the commands without executing them.',
31
+ })
32
+ .option('otp', {
33
+ type: 'string',
34
+ description: '6 digit auth code to forward to npm for two-factor authentication.',
35
+ coerce: (value) => {
36
+ if (value === undefined) {
37
+ return value;
38
+ }
39
+
40
+ if (!/^\d{6}$/.test(value)) {
41
+ throw new Error('The --otp option must be a 6 digit number.');
42
+ }
43
+
44
+ return value;
45
+ },
46
+ }),
30
47
  async handler(args) {
31
48
  console.log(`🔍 Detecting new packages to publish in workspace...`);
32
- const newPackages = await getWorkspacePackages({ nonPublishedOnly: true });
49
+ const newPackages = await getPackagesNeedingManualPublish(
50
+ await getWorkspacePackages({ publicOnly: true }),
51
+ );
33
52
 
34
53
  if (!newPackages.length) {
35
54
  console.log('No new packages to publish.');
@@ -62,7 +81,7 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
62
81
  version: '0.0.1',
63
82
  repository: {
64
83
  type: 'git',
65
- url: `git+https://github.com/${repo.owner}/${repo.remoteName}.git`,
84
+ url: `git+https://github.com/${repo.owner}/${repo.repo}.git`,
66
85
  directory: toPosixPath(path.relative(workspaceDir, pkg.path)),
67
86
  },
68
87
  };
@@ -78,8 +97,12 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
78
97
  if (args.dryRun) {
79
98
  publishArgs.push('--dry-run');
80
99
  }
100
+ if (args.otp) {
101
+ publishArgs.push('--otp', args.otp);
102
+ }
81
103
  await $({
82
104
  cwd: newPkgDir,
105
+ stdio: 'inherit',
83
106
  })`npm publish --access public --tag=canary ${publishArgs}`;
84
107
  console.log(
85
108
  `✅ ${args.dryRun ? '[Dry run] ' : ''}Published ${chalk.bold(`${pkg.name}@${packageJson.version}`)} to npm registry.`,
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ /* eslint-disable no-console */
4
+
5
+ import chalk from 'chalk';
6
+
7
+ import { getWorkspacePackages, renameWorkspaceScope } from '../utils/pnpm.mjs';
8
+
9
+ /**
10
+ * A scope is a single `@`-prefixed segment. Letting a `/` through would build
11
+ * names like `@acme/private/pkg`, which only npm rejects, long after every
12
+ * matching manifest has been rewritten.
13
+ */
14
+ const SCOPE_PATTERN = /^@[a-z0-9-~][a-z0-9-._~]*$/;
15
+
16
+ /**
17
+ * @typedef {Object} Args
18
+ * @property {[string, string]} alias Source and target scope
19
+ */
20
+
21
+ /**
22
+ * @param {string} alias
23
+ * @returns {[string, string]}
24
+ */
25
+ export function parseAlias(alias) {
26
+ const parts = alias.split(':');
27
+ const [from, to] = parts;
28
+ if (parts.length !== 2 || !SCOPE_PATTERN.test(from) || !SCOPE_PATTERN.test(to)) {
29
+ throw new Error(
30
+ `Invalid scope mapping "${alias}". Expected exactly two npm scopes separated by a colon, e.g. "@acme:@acme-private".`,
31
+ );
32
+ }
33
+ if (from === to) {
34
+ throw new Error(`Invalid scope mapping "${alias}". The source and target scope are the same.`);
35
+ }
36
+ return [from, to];
37
+ }
38
+
39
+ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
40
+ command: 'rename-scope <alias>',
41
+ describe: 'Move publishable workspace packages to a different npm scope',
42
+ builder: (yargs) =>
43
+ yargs
44
+ .positional('alias', {
45
+ type: 'string',
46
+ describe: 'Scope mapping written as "@from:@to"',
47
+ coerce: parseAlias,
48
+ })
49
+ .example(
50
+ '$0 rename-scope @acme:@acme-private',
51
+ 'Publish the workspace @acme packages under @acme-private',
52
+ ),
53
+ handler: async (argv) => {
54
+ const [from, to] = argv.alias;
55
+ const packages = await getWorkspacePackages();
56
+ const renamed = await renameWorkspaceScope(packages, from, to);
57
+
58
+ // Matching nothing means the mapping is wrong or stale. Carrying on would
59
+ // publish under the original scope, so fail instead of reporting success.
60
+ if (renamed.size === 0) {
61
+ throw new Error(
62
+ `No publishable workspace packages found in ${from}. Check the scope mapping "${from}:${to}", or the workspace may already have been renamed.`,
63
+ );
64
+ }
65
+
66
+ for (const [oldName, newName] of renamed) {
67
+ console.log(`📦 ${oldName} → ${chalk.bold(newName)}`);
68
+ }
69
+
70
+ console.log(
71
+ chalk.yellow(
72
+ '\n⚠️ Workspace manifests were rewritten in place and are not restored. Discard them before committing.',
73
+ ),
74
+ );
75
+ },
76
+ });
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from 'node:fs/promises';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
3
  import * as semver from 'semver';
7
4
  import { $ } from 'execa';
8
- import { resolveVersion, findDependencyVersionFromSpec } from '../utils/pnpm.mjs';
5
+ import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
6
+ import {
7
+ resolveVersion,
8
+ findDependencyVersionFromSpec,
9
+ writeOverridesToWorkspace,
10
+ } from '../utils/pnpm.mjs';
9
11
 
10
12
  /**
11
13
  * @typedef {Object} Args
@@ -103,11 +105,8 @@ async function handler(args) {
103
105
  // eslint-disable-next-line no-console
104
106
  console.log(`Using overrides: ${JSON.stringify(overrides, null, 2)}`);
105
107
 
106
- const packageJsonPath = path.resolve(process.cwd(), 'package.json');
107
- const packageJson = JSON.parse(await fs.readFile(packageJsonPath, { encoding: 'utf8' }));
108
- packageJson.resolutions ??= {};
109
- Object.assign(packageJson.resolutions, overrides);
110
- await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}${os.EOL}`);
108
+ const workspaceDir = (await findWorkspaceDir(process.cwd())) ?? process.cwd();
109
+ await writeOverridesToWorkspace(workspaceDir, overrides);
111
110
 
112
111
  await $({ stdio: 'inherit' })`pnpm dedupe`;
113
112
  }
@@ -0,0 +1,513 @@
1
+ /* eslint-disable no-console */
2
+ import * as crypto from 'node:crypto';
3
+ import * as fs from 'node:fs/promises';
4
+ import { createWriteStream } from 'node:fs';
5
+ import * as os from 'node:os';
6
+ import * as path from 'node:path';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
9
+ import { $ } from 'execa';
10
+ import { mapConcurrently } from '../utils/build.mjs';
11
+
12
+ const LATEST_RELEASE_URL = 'https://api.github.com/repos/vale-cli/vale/releases/latest';
13
+
14
+ /**
15
+ * Fetches the latest vale release version tag from GitHub.
16
+ * @returns {Promise<string>}
17
+ */
18
+ async function fetchLatestVersion() {
19
+ const response = await fetchOrThrow(LATEST_RELEASE_URL);
20
+ const data = /** @type {{ tag_name: string }} */ (await response.json());
21
+ // tag_name is like "v3.14.1", strip the leading "v"
22
+ return data.tag_name.replace(/^v/, '');
23
+ }
24
+
25
+ /**
26
+ * @typedef {'Linux' | 'macOS' | 'Windows'} ValeOS
27
+ * @typedef {'64-bit' | 'arm64'} ValeArch
28
+ */
29
+
30
+ /**
31
+ * Detects the OS name used in vale release filenames.
32
+ * @returns {ValeOS}
33
+ */
34
+ function detectOS() {
35
+ const platform = os.platform();
36
+ if (platform === 'linux') {
37
+ return 'Linux';
38
+ }
39
+ if (platform === 'darwin') {
40
+ return 'macOS';
41
+ }
42
+ if (platform === 'win32') {
43
+ return 'Windows';
44
+ }
45
+ throw new Error(
46
+ `Unsupported platform: ${platform}. Vale only supports Linux, macOS, and Windows.`,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Detects the CPU architecture used in vale release filenames.
52
+ * Exits with an error for 32-bit systems.
53
+ * @returns {ValeArch}
54
+ */
55
+ function detectArch() {
56
+ const arch = os.arch();
57
+ if (arch === 'arm64') {
58
+ return 'arm64';
59
+ }
60
+ if (arch === 'x64') {
61
+ return '64-bit';
62
+ }
63
+ throw new Error(
64
+ `Unsupported architecture: ${arch}. Vale requires a 64-bit system (x64 or arm64).`,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Returns the archive filename for the current platform.
70
+ * @param {ValeOS} valeOS
71
+ * @param {ValeArch} valeArch
72
+ * @returns {{ filename: string; isZip: boolean }}
73
+ */
74
+ /**
75
+ * Returns the base GitHub release download URL for a given vale version.
76
+ * @param {string} version
77
+ * @returns {string}
78
+ */
79
+ function getReleasesBase(version) {
80
+ return `https://github.com/vale-cli/vale/releases/download/v${version}`;
81
+ }
82
+
83
+ /**
84
+ * Returns the archive filename for the current platform.
85
+ * @param {string} version
86
+ * @param {ValeOS} valeOS
87
+ * @param {ValeArch} valeArch
88
+ * @returns {{ filename: string; isZip: boolean }}
89
+ */
90
+ function getArchiveInfo(version, valeOS, valeArch) {
91
+ const isZip = valeOS === 'Windows';
92
+ const ext = isZip ? '.zip' : '.tar.gz';
93
+ const filename = `vale_${version}_${valeOS}_${valeArch}${ext}`;
94
+ return { filename, isZip };
95
+ }
96
+
97
+ /**
98
+ * Fetches a URL and returns the Response, throwing on non-OK status.
99
+ * @param {string} url
100
+ * @returns {Promise<Response>}
101
+ */
102
+ async function fetchOrThrow(url) {
103
+ const response = await fetch(url);
104
+ if (!response.ok) {
105
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
106
+ }
107
+ return response;
108
+ }
109
+
110
+ /**
111
+ * Downloads the checksums file and parses it into a map of filename -> sha256 hex.
112
+ * @param {string} version
113
+ * @returns {Promise<Map<string, string>>}
114
+ */
115
+ async function fetchChecksums(version) {
116
+ const checksumsUrl = `${getReleasesBase(version)}/vale_${version}_checksums.txt`;
117
+ const response = await fetchOrThrow(checksumsUrl);
118
+ const text = await response.text();
119
+ /** @type {Map<string, string>} */
120
+ const map = new Map();
121
+ for (const line of text.trim().split('\n')) {
122
+ const [hash, name] = line.trim().split(/\s+/);
123
+ if (hash && name) {
124
+ map.set(name, hash);
125
+ }
126
+ }
127
+ return map;
128
+ }
129
+
130
+ /**
131
+ * Computes the SHA-256 hex digest of a file.
132
+ * @param {string} filePath
133
+ * @returns {Promise<string>}
134
+ */
135
+ async function sha256File(filePath) {
136
+ const hash = crypto.createHash('sha256');
137
+ const fileHandle = await fs.open(filePath, 'r');
138
+ const stream = fileHandle.createReadStream();
139
+ await pipeline(stream, hash);
140
+ return hash.digest('hex');
141
+ }
142
+
143
+ /**
144
+ * Downloads a URL to a local file path, showing progress.
145
+ * @param {string} url
146
+ * @param {string} destPath
147
+ * @param {string} version
148
+ * @returns {Promise<void>}
149
+ */
150
+ async function downloadFile(url, destPath, version) {
151
+ const response = await fetchOrThrow(url);
152
+ const totalBytes = Number(response.headers.get('content-length') ?? 0);
153
+ let downloadedBytes = 0;
154
+
155
+ if (!response.body) {
156
+ throw new Error(`No response body for ${url}`);
157
+ }
158
+
159
+ const writeStream = createWriteStream(destPath);
160
+
161
+ // Pipe the web ReadableStream into a Node.js WriteStream, tracking progress
162
+ const nodeReadable = /** @type {import('node:stream').Readable} */ (
163
+ /** @type {unknown} */ (response.body)
164
+ );
165
+
166
+ await pipeline(
167
+ nodeReadable,
168
+ async function* trackProgress(source) {
169
+ for await (const chunk of source) {
170
+ const bytes = /** @type {Buffer} */ (chunk);
171
+ downloadedBytes += bytes.length;
172
+ if (totalBytes > 0) {
173
+ const pct = ((downloadedBytes / totalBytes) * 100).toFixed(1);
174
+ process.stdout.write(`\rDownloading vale v${version}... ${pct}%`);
175
+ }
176
+ yield bytes;
177
+ }
178
+ },
179
+ writeStream,
180
+ );
181
+
182
+ if (totalBytes > 0) {
183
+ process.stdout.write('\n');
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Extracts a .tar.gz archive to a destination directory using the built-in tar command.
189
+ * @param {string} archivePath
190
+ * @param {string} destDir
191
+ * @returns {Promise<void>}
192
+ */
193
+ async function extractTarGz(archivePath, destDir) {
194
+ await $({ stdio: 'inherit' })`tar -xzf ${archivePath} -C ${destDir}`;
195
+ }
196
+
197
+ /**
198
+ * Extracts a .zip archive to a destination directory using the built-in unzip command.
199
+ * @param {string} archivePath
200
+ * @param {string} destDir
201
+ * @returns {Promise<void>}
202
+ */
203
+ async function extractZip(archivePath, destDir) {
204
+ await $({ stdio: 'inherit' })`unzip -o ${archivePath} -d ${destDir}`;
205
+ }
206
+
207
+ /**
208
+ * Returns the path to the vale binary, downloading it first if necessary.
209
+ * The binary is cached under `node_modules/.cache/mui-vale/<version>/`.
210
+ * @param {string} version
211
+ * @returns {Promise<string>}
212
+ */
213
+ async function ensureValeBinary(version) {
214
+ const valeOS = detectOS();
215
+ const valeArch = detectArch();
216
+ const { filename, isZip } = getArchiveInfo(version, valeOS, valeArch);
217
+
218
+ const binaryName = valeOS === 'Windows' ? 'vale.exe' : 'vale';
219
+ const workspaceDir = await findWorkspaceDir(process.cwd());
220
+ if (!workspaceDir) {
221
+ throw new Error('Could not find pnpm workspace root');
222
+ }
223
+ const cacheDir = path.join(workspaceDir, 'node_modules', '.cache', 'mui-vale', version);
224
+ const binaryPath = path.join(cacheDir, binaryName);
225
+
226
+ // Return cached binary if it already exists
227
+ const binaryExists = await fs
228
+ .stat(binaryPath)
229
+ .then((s) => s.isFile())
230
+ .catch(() => false);
231
+ if (binaryExists) {
232
+ return binaryPath;
233
+ }
234
+
235
+ console.log(`Vale v${version} not found in cache. Downloading...`);
236
+
237
+ // Fetch checksums first
238
+ const checksums = await fetchChecksums(version);
239
+ const expectedChecksum = checksums.get(filename);
240
+ if (!expectedChecksum) {
241
+ throw new Error(`No checksum found for ${filename} in checksums file.`);
242
+ }
243
+
244
+ await fs.mkdir(cacheDir, { recursive: true });
245
+
246
+ const archivePath = path.join(cacheDir, filename);
247
+
248
+ try {
249
+ const downloadUrl = `${getReleasesBase(version)}/${filename}`;
250
+ await downloadFile(downloadUrl, archivePath, version);
251
+
252
+ // Verify checksum
253
+ console.log('Verifying checksum...');
254
+ const actualChecksum = await sha256File(archivePath);
255
+ if (actualChecksum !== expectedChecksum) {
256
+ throw new Error(
257
+ `Checksum mismatch for ${filename}.\n Expected: ${expectedChecksum}\n Got: ${actualChecksum}`,
258
+ );
259
+ }
260
+ console.log('Checksum verified.');
261
+
262
+ // Extract archive
263
+ console.log(`Extracting ${filename}...`);
264
+ if (isZip) {
265
+ await extractZip(archivePath, cacheDir);
266
+ } else {
267
+ await extractTarGz(archivePath, cacheDir);
268
+ }
269
+
270
+ // Make the binary executable on Unix
271
+ if (valeOS !== 'Windows') {
272
+ await fs.chmod(binaryPath, 0o755);
273
+ }
274
+
275
+ console.log(`Vale v${version} ready at ${binaryPath}`);
276
+ } finally {
277
+ // Clean up the downloaded archive regardless of success or failure
278
+ await fs.rm(archivePath, { force: true });
279
+ }
280
+
281
+ return binaryPath;
282
+ }
283
+
284
+ /**
285
+ * Runs the vale binary, forwarding all extra args and inheriting stdio.
286
+ * @param {string} binaryPath
287
+ * @param {string[]} valeArgs
288
+ * @returns {Promise<void>}
289
+ */
290
+ async function runVale(binaryPath, valeArgs) {
291
+ const result = await $({ stdio: 'inherit', reject: false })`${binaryPath} ${valeArgs}`;
292
+ process.exitCode = result.exitCode;
293
+ }
294
+
295
+ /**
296
+ * Runs vale with JSON output and captures the results.
297
+ * @param {string} binaryPath
298
+ * @param {string[]} valeArgs
299
+ * @returns {Promise<Record<string, Array<{ Action: { Name: string; Params: string[] | null }; Span: [number, number]; Check: string; Message: string; Severity: string; Match: string; Line: number }>>>}
300
+ */
301
+ async function runValeJSON(binaryPath, valeArgs) {
302
+ const result = await $({ reject: false })`${binaryPath} --output JSON ${valeArgs}`;
303
+ try {
304
+ return JSON.parse(result.stdout);
305
+ } catch {
306
+ throw new Error(`Failed to parse vale JSON output: ${result.stdout}`);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Extracts the replacement text from a vale alert.
312
+ * Returns null if no replacement can be determined.
313
+ * @param {{ Action: { Name: string; Params: string[] | null }; Message: string }} alert
314
+ * @returns {string | null}
315
+ */
316
+ export function getReplacementText(alert) {
317
+ // If vale provides an explicit action with replacement params, use that
318
+ if (alert.Action.Name === 'replace' && alert.Action.Params && alert.Action.Params.length > 0) {
319
+ return alert.Action.Params[0];
320
+ }
321
+
322
+ // Otherwise, try to extract from the message pattern.
323
+ // Vale messages follow patterns like:
324
+ // "Use 'X' instead of 'Y'"
325
+ // "Use the US spelling 'X' instead of the British 'Y'"
326
+ // "Use a non-breaking space ... ('X' instead of 'Y')"
327
+ const match = alert.Message.match(/'([^']+)'\s+instead\s+of\s+.*?'([^']+)'/);
328
+ if (match) {
329
+ return match[1];
330
+ }
331
+
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * Applies auto-fixes from vale alerts to the source files.
337
+ * Processes alerts in reverse order within each line to preserve column positions.
338
+ * @param {Record<string, Array<{ Action: { Name: string; Params: string[] | null }; Span: [number, number]; Check: string; Message: string; Severity: string; Match: string; Line: number }>>} results
339
+ * @param {'all' | 'error'} fixLevel
340
+ * @returns {Promise<{ fixed: number; skipped: number }>}
341
+ */
342
+ export async function applyFixes(results, fixLevel) {
343
+ const entries = Object.entries(results);
344
+
345
+ const perFileResults = await mapConcurrently(
346
+ entries,
347
+ async ([filePath, alerts]) => {
348
+ const content = await fs.readFile(filePath, 'utf-8');
349
+ const lines = content.split('\n');
350
+
351
+ // Filter alerts by severity and whether we can determine a replacement
352
+ const fixableAlerts = alerts.filter((alert) => {
353
+ if (fixLevel === 'error' && alert.Severity !== 'error') {
354
+ return false;
355
+ }
356
+ return getReplacementText(alert) !== null;
357
+ });
358
+
359
+ if (fixableAlerts.length === 0) {
360
+ return { fixed: 0, skipped: alerts.length };
361
+ }
362
+
363
+ // Deduplicate alerts at the same location (same line + span), keeping the first one
364
+ /** @type {Map<string, typeof fixableAlerts[0]>} */
365
+ const uniqueAlerts = new Map();
366
+ for (const alert of fixableAlerts) {
367
+ const key = `${alert.Line}:${alert.Span[0]}:${alert.Span[1]}`;
368
+ if (!uniqueAlerts.has(key)) {
369
+ uniqueAlerts.set(key, alert);
370
+ }
371
+ }
372
+
373
+ // Sort by line ascending, then by span start descending (so we apply right-to-left)
374
+ const sortedAlerts = [...uniqueAlerts.values()].sort((a, b) => {
375
+ if (a.Line !== b.Line) {
376
+ return a.Line - b.Line;
377
+ }
378
+ return b.Span[0] - a.Span[0];
379
+ });
380
+
381
+ // Group alerts by line number
382
+ /** @type {Map<number, typeof sortedAlerts>} */
383
+ const alertsByLine = new Map();
384
+ for (const alert of sortedAlerts) {
385
+ const lineAlerts = alertsByLine.get(alert.Line) ?? [];
386
+ lineAlerts.push(alert);
387
+ alertsByLine.set(alert.Line, lineAlerts);
388
+ }
389
+
390
+ // Apply fixes line by line, right-to-left within each line
391
+ let fileFixed = 0;
392
+ for (const [lineNum, lineAlerts] of alertsByLine) {
393
+ let line = lines[lineNum - 1]; // Line is 1-based
394
+ for (const alert of lineAlerts) {
395
+ const replacement = /** @type {string} */ (getReplacementText(alert));
396
+ // Span is 1-based [start, end] inclusive
397
+ const start = alert.Span[0] - 1;
398
+ const end = alert.Span[1];
399
+ line = line.slice(0, start) + replacement + line.slice(end);
400
+ fileFixed += 1;
401
+ }
402
+ lines[lineNum - 1] = line;
403
+ }
404
+
405
+ await fs.writeFile(filePath, lines.join('\n'), 'utf-8');
406
+
407
+ return { fixed: fileFixed, skipped: alerts.length - fixableAlerts.length };
408
+ },
409
+ 10,
410
+ );
411
+
412
+ let fixed = 0;
413
+ let skipped = 0;
414
+ for (const result of perFileResults) {
415
+ if (result instanceof Error) {
416
+ throw result;
417
+ }
418
+ fixed += result.fixed;
419
+ skipped += result.skipped;
420
+ }
421
+
422
+ return { fixed, skipped };
423
+ }
424
+
425
+ /**
426
+ * @typedef {{ 'vale-version': string; 'auto-fix': 'all' | 'error' | undefined; }} Args
427
+ */
428
+
429
+ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
430
+ command: 'vale',
431
+ describe:
432
+ 'Download and run vale (a prose linter). All arguments are forwarded to the vale binary.',
433
+ builder: (yargs) => {
434
+ return (
435
+ yargs
436
+ // because `version` is taken over yargs itself
437
+ .option('vale-version', {
438
+ type: 'string',
439
+ demandOption: true,
440
+ description: 'The vale version to use (e.g. "3.14.1" or "latest").',
441
+ default: 'latest',
442
+ })
443
+ .option('auto-fix', {
444
+ type: 'string',
445
+ choices: ['all', 'error'],
446
+ description:
447
+ 'Automatically apply fixes suggested by vale. "all" fixes both errors and warnings, "error" fixes only errors.',
448
+ })
449
+ .example('$0 vale --vale-version 3.14.1 docs/', 'Lint all files in the docs/ directory')
450
+ .example(
451
+ '$0 vale --vale-version 3.14.1 --auto-fix=all docs/',
452
+ 'Lint and auto-fix all issues in docs/',
453
+ )
454
+ .strict(false)
455
+ );
456
+ },
457
+ handler: async (args) => {
458
+ const version = args.valeVersion === 'latest' ? await fetchLatestVersion() : args.valeVersion;
459
+ const binaryPath = await ensureValeBinary(version);
460
+
461
+ // Collect everything from process.argv that follows the "vale" token,
462
+ // excluding our own flags and their values.
463
+ const argvAfterVale = process.argv.slice(process.argv.indexOf('vale') + 1);
464
+ const valeArgs = [];
465
+ for (let i = 0; i < argvAfterVale.length; i += 1) {
466
+ const arg = argvAfterVale[i];
467
+ if (arg === '--vale-version' || arg.startsWith('--vale-version=')) {
468
+ // consumed by this wrapper; skip the next arg if it's the value
469
+ if (
470
+ arg === '--vale-version' &&
471
+ argvAfterVale[i + 1] &&
472
+ !argvAfterVale[i + 1].startsWith('--')
473
+ ) {
474
+ i += 1;
475
+ }
476
+ } else if (arg === '--auto-fix' || arg.startsWith('--auto-fix=')) {
477
+ // consumed by this wrapper; skip the next arg if it's the value
478
+ if (
479
+ arg === '--auto-fix' &&
480
+ argvAfterVale[i + 1] &&
481
+ !argvAfterVale[i + 1].startsWith('--')
482
+ ) {
483
+ i += 1;
484
+ }
485
+ } else {
486
+ valeArgs.push(arg);
487
+ }
488
+ }
489
+
490
+ if (args.autoFix) {
491
+ const fixLevel = /** @type {'all' | 'error'} */ (args.autoFix);
492
+ const results = await runValeJSON(binaryPath, valeArgs);
493
+ const totalAlerts = Object.values(results).reduce((sum, alerts) => sum + alerts.length, 0);
494
+
495
+ if (totalAlerts === 0) {
496
+ console.log('No issues found by vale.');
497
+ return;
498
+ }
499
+
500
+ const { fixed, skipped } = await applyFixes(results, fixLevel);
501
+ console.log(`Auto-fix complete: ${fixed} fixed, ${skipped} skipped.`);
502
+
503
+ if (fixed > 0) {
504
+ // Re-run vale to show remaining issues
505
+ console.log('\nRemaining issues after auto-fix:');
506
+ await runVale(binaryPath, valeArgs);
507
+ }
508
+ return;
509
+ }
510
+
511
+ await runVale(binaryPath, valeArgs);
512
+ },
513
+ });