Skip to content

Commit c5bf6ad

Browse files
authored
feat: add ts-window-type for type checking code blocks (#23)
1 parent 2378263 commit c5bf6ad

File tree

4 files changed

+111
-55
lines changed

4 files changed

+111
-55
lines changed

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ detection of code blocks.
4040
Markdown with `tsc`. Type checking can be disabled for specific code blocks
4141
by adding `@ts-nocheck` to the info string, specific lines can be ignored
4242
by adding `@ts-ignore=[<line1>,<line2>]` to the info string, and additional
43-
globals can be defined with `@ts-type={name:type}`.
43+
globals can be defined with `@ts-type={name:type}`. The `Window` object can
44+
be extended with more types using `@ts-window-type={name:type}`.
4445

4546
## License
4647

bin/lint-markdown-ts-check.ts

+92-50
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,59 @@ const DEFAULT_IMPORTS = `${NODE_IMPORTS}; const { ${ELECTRON_MODULES.join(
5050
', ',
5151
)} } = require('electron');`;
5252

53+
async function typeCheckFiles(
54+
tempDir: string,
55+
filenameMapping: Map<string, string>,
56+
filenames: string[],
57+
) {
58+
const tscExec = path.join(require.resolve('typescript'), '..', '..', 'bin', 'tsc');
59+
const args = [
60+
tscExec,
61+
'--noEmit',
62+
'--checkJs',
63+
'--pretty',
64+
path.join(tempDir, 'electron.d.ts'),
65+
path.join(tempDir, 'ambient.d.ts'),
66+
...filenames,
67+
];
68+
const { status, stderr, stdout } = await spawnAsync(process.execPath, args);
69+
70+
if (stderr) {
71+
throw new Error(stderr);
72+
}
73+
74+
// Replace the temp file paths with the original source filename
75+
let correctedOutput = stdout.replace(
76+
new RegExp(
77+
`${path.relative(process.cwd(), tempDir)}${path.sep}`.replace(/\\/g, path.posix.sep),
78+
'g',
79+
),
80+
'',
81+
);
82+
83+
// Strip any @ts-expect-error/@ts-ignore comments we added
84+
correctedOutput = correctedOutput.replace(/ \/\/ @ts-(?:expect-error|ignore)/g, '');
85+
86+
if (correctedOutput.trim()) {
87+
for (const [filename, originalFilename] of filenameMapping.entries()) {
88+
correctedOutput = correctedOutput.replace(
89+
new RegExp(path.basename(filename), 'g'),
90+
originalFilename,
91+
);
92+
}
93+
94+
console.log(correctedOutput);
95+
}
96+
97+
return status;
98+
}
99+
100+
function parseDirectives(directive: string, value: string) {
101+
return findCurlyBracedDirectives(directive, value)
102+
.map((parsed) => parsed.match(/^([^:\r\n\t\f\v ]+):\s?(.+)$/))
103+
.filter((parsed): parsed is RegExpMatchArray => parsed !== null);
104+
}
105+
53106
// TODO(dsanders11): Refactor to make this script general purpose and
54107
// not tied to Electron - will require passing in the list of modules
55108
// as a CLI option, probably a file since there's a lot of info
@@ -59,7 +112,8 @@ async function main(workspaceRoot: string, globs: string[], { ignoreGlobs = [] }
59112

60113
try {
61114
const filenames: string[] = [];
62-
const originalFilenames: Map<string, string> = new Map();
115+
const originalFilenames = new Map<string, string>();
116+
const windowTypeFilenames = new Set<string>();
63117

64118
// Copy over the typings so that a relative path can be used
65119
fs.copyFileSync(path.join(process.cwd(), 'electron.d.ts'), path.join(tempDir, 'electron.d.ts'));
@@ -87,17 +141,19 @@ async function main(workspaceRoot: string, globs: string[], { ignoreGlobs = [] }
87141
?.match(/\B@ts-ignore=\[([\d,]*)\]\B/)?.[1]
88142
.split(',')
89143
.map((line) => parseInt(line));
90-
const tsTypeLines = codeBlock.meta
91-
? findCurlyBracedDirectives('@ts-type', codeBlock.meta)
92-
.map((directive) => directive.match(/^([^:\r\n\t\f\v ]+):\s?(.+)$/))
93-
.filter((directive): directive is RegExpMatchArray => directive !== null)
144+
const tsTypeLines = codeBlock.meta ? parseDirectives('@ts-type', codeBlock.meta) : [];
145+
const tsWindowTypeLines = codeBlock.meta
146+
? parseDirectives('@ts-window-type', codeBlock.meta)
94147
: [];
95148

96-
if (tsNoCheck && (tsExpectErrorLines || tsIgnoreLines || tsTypeLines.length)) {
149+
if (
150+
tsNoCheck &&
151+
(tsExpectErrorLines || tsIgnoreLines || tsTypeLines.length || tsWindowTypeLines.length)
152+
) {
97153
console.log(
98154
`${filepath}:${line}:${
99155
indent + 1
100-
}: Code block has both @ts-nocheck and @ts-expect-error/@ts-ignore/@ts-type, they conflict`,
156+
}: Code block has both @ts-nocheck and @ts-expect-error/@ts-ignore/@ts-type/@ts-window-type, they conflict`,
101157
);
102158
errors = true;
103159
continue;
@@ -131,6 +187,7 @@ async function main(workspaceRoot: string, globs: string[], { ignoreGlobs = [] }
131187
const codeLines = codeBlock.value.split('\n');
132188
let insertedInitialLine = false;
133189
let types = '';
190+
let windowTypes = '';
134191

135192
const insertComment = (comment: string, line: number) => {
136193
// Inserting additional lines will make the tsc output
@@ -194,8 +251,8 @@ async function main(workspaceRoot: string, globs: string[], { ignoreGlobs = [] }
194251
.replace(/\./g, '-')}-${line}.js`,
195252
);
196253

197-
// Blocks can have @ts-type={name:type} in their info string
198-
// (1-based lines) to declare a global variable for a block
254+
// Blocks can have @ts-type={name:type} in their info
255+
// string to declare a global variable for a block
199256
if (tsTypeLines.length) {
200257
// To support this feature, generate a random name for a
201258
// module, generate an ambient module declaration for the
@@ -213,55 +270,40 @@ async function main(workspaceRoot: string, globs: string[], { ignoreGlobs = [] }
213270
.join(',')}} = require('${moduleName}')`;
214271
}
215272

216-
fs.writeFileSync(filename, `// @ts-check\n${imports}\n${blankLines}${code}\n${types}`);
273+
// Blocks can have @ts-window-type={name:type} in their
274+
// info string to extend the Window object for a block
275+
if (tsWindowTypeLines.length) {
276+
const extraTypes = tsWindowTypeLines
277+
.map((type) => ` ${type[1]}: ${type[2]};`)
278+
.join('\n');
279+
// Needs an export {} at the end to make TypeScript happy
280+
windowTypes = `declare global {\n interface Window {\n${extraTypes}\n }\n}\n\nexport {};\n\n`;
281+
fs.writeFileSync(filename.replace(/.js$/, '-window.d.ts'), windowTypes);
282+
windowTypeFilenames.add(filename);
283+
} else {
284+
filenames.push(filename);
285+
}
217286

218-
filenames.push(filename);
287+
fs.writeFileSync(filename, `// @ts-check\n${imports}\n${blankLines}${code}\n${types}`);
219288
originalFilenames.set(filename, filepath);
220289
}
221290
}
222291

223292
fs.writeFileSync(path.join(tempDir, 'ambient.d.ts'), ambientModules);
224293

225-
for (const chunk of chunkFilenames(filenames)) {
226-
const tscExec = path.join(require.resolve('typescript'), '..', '..', 'bin', 'tsc');
227-
const args = [
228-
tscExec,
229-
'--noEmit',
230-
'--checkJs',
231-
'--pretty',
232-
path.join(tempDir, 'electron.d.ts'),
233-
path.join(tempDir, 'ambient.d.ts'),
234-
...chunk,
235-
];
236-
const { status, stderr, stdout } = await spawnAsync(process.execPath, args);
237-
238-
if (stderr) {
239-
throw new Error(stderr);
240-
}
241-
242-
// Replace the temp file paths with the original source filename
243-
let correctedOutput = stdout.replace(
244-
new RegExp(
245-
`${path.relative(process.cwd(), tempDir)}${path.sep}`.replace(/\\/g, path.posix.sep),
246-
'g',
247-
),
248-
'',
249-
);
250-
251-
// Strip any @ts-expect-error/@ts-ignore comments we added
252-
correctedOutput = correctedOutput.replace(/ \/\/ @ts-(?:expect-error|ignore)/g, '');
253-
254-
if (correctedOutput.trim()) {
255-
for (const [filename, originalFilename] of originalFilenames.entries()) {
256-
correctedOutput = correctedOutput.replace(
257-
new RegExp(path.basename(filename), 'g'),
258-
originalFilename,
259-
);
260-
}
261-
262-
console.log(correctedOutput);
263-
}
294+
// Files for code blocks with window types have to be processed separately
295+
// since window types are by nature global, and would bleed between blocks
296+
for (const filename of windowTypeFilenames) {
297+
const status = await typeCheckFiles(tempDir, originalFilenames, [
298+
filename.replace(/.js$/, '-window.d.ts'),
299+
filename,
300+
]);
301+
errors = errors || status !== 0;
302+
}
264303

304+
// For the rest of the files, run them all at once so it doesn't take forever
305+
for (const chunk of chunkFilenames(filenames)) {
306+
const status = await typeCheckFiles(tempDir, originalFilenames, chunk);
265307
errors = errors || status !== 0;
266308
}
267309

tests/__snapshots__/electron-lint-markdown-ts-check.spec.ts.snap

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

33
exports[`electron-lint-markdown-ts-check should type check code blocks 1`] = `
4-
"ts-check.md:49:1: Code block has both @ts-nocheck and @ts-expect-error/@ts-ignore/@ts-type, they conflict
5-
ts-check.md:55:1: Code block has both @ts-nocheck and @ts-expect-error/@ts-ignore/@ts-type, they conflict
4+
"ts-check.md:49:1: Code block has both @ts-nocheck and @ts-expect-error/@ts-ignore/@ts-type/@ts-window-type, they conflict
5+
ts-check.md:55:1: Code block has both @ts-nocheck and @ts-expect-error/@ts-ignore/@ts-type/@ts-window-type, they conflict
66
ts-check.md:128:8 - error TS2339: Property 'myAwesomeAPI' does not exist on type 'Window & typeof globalThis'.
77
88
128 window.myAwesomeAPI()
@@ -28,6 +28,11 @@ ts-check.md:55:1: Code block has both @ts-nocheck and @ts-expect-error/@ts-ignor
2828
157 console.log(\`not true: \${a} < \${b}\`)
2929
   ~
3030
31+
ts-check.md:160:8 - error TS2339: Property 'AwesomeAPI' does not exist on type 'Window & typeof globalThis'.
32+
33+
160 window.AwesomeAPI.bar('baz')
34+
   ~~~~~~~~~~
35+
3136
ts-check.md:4:9 - error TS2339: Property 'foo' does not exist on type 'Console'.
3237
3338
4 console.foo('whoops')
@@ -54,11 +59,11 @@ ts-check.md:55:1: Code block has both @ts-nocheck and @ts-expect-error/@ts-ignor
5459
   ~~~~~~~~~~~~
5560
5661
57-
Found 10 errors in 7 files.
62+
Found 11 errors in 7 files.
5863
5964
Errors Files
6065
1 ts-check.md:128
61-
4 ts-check.md[90m:154[0m
66+
5 ts-check.md[90m:154[0m
6267
1 ts-check.md:4
6368
1 ts-check.md:66
6469
1 ts-check.md:72

tests/fixtures/ts-check.md

+8
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,12 @@ if (a > b) {
156156
} else {
157157
console.log(`not true: ${a} < ${b}`)
158158
}
159+
160+
window.AwesomeAPI.bar('baz')
161+
```
162+
163+
This block defines additional types on window
164+
165+
```js @ts-window-type={AwesomeAPI: { foo: (value: number) => void } }
166+
window.AwesomeAPI.foo(42)
159167
```

0 commit comments

Comments
 (0)