@transclude/core 0.13.0 → 0.15.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/bin/check.js +10 -27
- package/bin/dev.js +15 -8
- package/bin/serve.js +6 -1
- package/package.json +5 -3
- package/src/app.js +29 -12
- package/src/compiler/expr.js +0 -8
- package/src/cookies.js +17 -2
- package/src/defaults.js +13 -0
- package/src/drain.js +63 -0
- package/src/lookup.js +1 -1
- package/src/proxy.js +20 -5
- package/src/rewrite.js +7 -7
- package/src/typecheck.js +253 -69
package/bin/check.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import
|
|
7
|
-
import { createChecker, positionAt } from '../src/typecheck.js';
|
|
6
|
+
import { checkAlone, createChecker, positionAt } from '../src/typecheck.js';
|
|
8
7
|
import { emitTypes } from '../src/compiler/types.js';
|
|
9
8
|
import { loadProject } from '../src/project.js';
|
|
10
9
|
import { isMarkdown } from '../src/markdown.js';
|
|
@@ -23,34 +22,15 @@ if (!fs.existsSync(types) || fs.readFileSync(types, 'utf8') !== next) {
|
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
// Nothing downstream reads this file, so nothing else would notice it being
|
|
26
|
-
// wrong. Parse what we just wrote, or a bad identifier ships silently.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
// project shipped a file naming `__Cookies` and declaring it nowhere. An editor
|
|
31
|
-
// missed it too, because a jsconfig.json implies the same flag.
|
|
32
|
-
//
|
|
33
|
-
// `types: []` keeps it to this file: whatever `@types` a project happens to have
|
|
34
|
-
// installed is not what is being checked here, and one of them failing to
|
|
35
|
-
// resolve its own dependency would read as our file being broken.
|
|
36
|
-
const emitted = ts.createProgram([types], {
|
|
37
|
-
noEmit: true,
|
|
38
|
-
skipLibCheck: false,
|
|
39
|
-
types: [],
|
|
40
|
-
target: ts.ScriptTarget.ESNext,
|
|
41
|
-
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
|
|
42
|
-
});
|
|
43
|
-
const broken = [
|
|
44
|
-
...emitted.getSyntacticDiagnostics(),
|
|
45
|
-
...emitted.getSemanticDiagnostics(),
|
|
46
|
-
];
|
|
25
|
+
// wrong. Parse what we just wrote, or a bad identifier ships silently. The
|
|
26
|
+
// guard itself lives in `checkAlone`, where the reasons for its options are,
|
|
27
|
+
// and where `test/types.test.js` reads the same answers.
|
|
28
|
+
const broken = checkAlone(types);
|
|
47
29
|
if (broken.length) {
|
|
48
30
|
console.error(`\n${path.relative(root, types)} is not valid TypeScript:`);
|
|
49
31
|
for (const diagnostic of broken.slice(0, 5)) {
|
|
50
|
-
const at =
|
|
51
|
-
console.error(
|
|
52
|
-
` ${at ? `line ${at.line + 1}: ` : ''}${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`,
|
|
53
|
-
);
|
|
32
|
+
const at = positionAt(next, diagnostic.offset);
|
|
33
|
+
console.error(` line ${at.line}: ${diagnostic.message}`);
|
|
54
34
|
}
|
|
55
35
|
if (broken.length > 5) console.error(` …and ${broken.length - 5} more`);
|
|
56
36
|
process.exit(1);
|
|
@@ -97,6 +77,9 @@ for (const file of files) {
|
|
|
97
77
|
}
|
|
98
78
|
}
|
|
99
79
|
|
|
80
|
+
// The compiler is a child process. Closed here, or the exit waits on it.
|
|
81
|
+
checker.dispose();
|
|
82
|
+
|
|
100
83
|
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
101
84
|
|
|
102
85
|
if (errors + warnings) {
|
package/bin/dev.js
CHANGED
|
@@ -264,17 +264,24 @@ const handleAction = async (route, c) => {
|
|
|
264
264
|
: sendFragment(route, c, region, extra);
|
|
265
265
|
};
|
|
266
266
|
|
|
267
|
-
const onError = (c, err) => {
|
|
267
|
+
const onError = (c, err, at = null) => {
|
|
268
268
|
// Before anything reads the stack: Vite's transform means the raw one points
|
|
269
269
|
// at generated code, and a reporter given that is worse than none.
|
|
270
270
|
vite.ssrFixStacktrace(err);
|
|
271
271
|
console.error(err);
|
|
272
272
|
|
|
273
|
-
// The same seam production has, so a reporter is
|
|
274
|
-
// one looking at it rather than first on a live
|
|
273
|
+
// The same seam production has, with the same shape, so a reporter is
|
|
274
|
+
// exercised while you are the one looking at it rather than first on a live
|
|
275
|
+
// site. A field dev left null would read as a production bug later.
|
|
275
276
|
if (typeof config.onError === 'function') {
|
|
276
277
|
try {
|
|
277
|
-
config.onError(err, {
|
|
278
|
+
config.onError(err, {
|
|
279
|
+
request: c.req.raw,
|
|
280
|
+
url: c.req.url,
|
|
281
|
+
method: c.req.method,
|
|
282
|
+
route: at ? { id: at.route.id, pattern: at.route.pattern, params: c.req.param() } : null,
|
|
283
|
+
phase: at?.phase ?? null,
|
|
284
|
+
});
|
|
278
285
|
} catch (failed) {
|
|
279
286
|
console.error('[transclude] onError itself threw:', failed);
|
|
280
287
|
}
|
|
@@ -389,7 +396,7 @@ async function buildApp() {
|
|
|
389
396
|
const fragment = fragmentOf(c);
|
|
390
397
|
return fragment === null ? await renderPage(route, c) : await sendFragment(route, c, fragment);
|
|
391
398
|
} catch (err) {
|
|
392
|
-
return onError(c, err);
|
|
399
|
+
return onError(c, err, { route, phase: fragmentOf(c) === null ? 'page' : 'fragment' });
|
|
393
400
|
}
|
|
394
401
|
});
|
|
395
402
|
|
|
@@ -400,7 +407,7 @@ async function buildApp() {
|
|
|
400
407
|
try {
|
|
401
408
|
return await handleAction(route, c);
|
|
402
409
|
} catch (err) {
|
|
403
|
-
return onError(c, err);
|
|
410
|
+
return onError(c, err, { route, phase: 'action' });
|
|
404
411
|
}
|
|
405
412
|
});
|
|
406
413
|
}
|
|
@@ -422,7 +429,7 @@ async function buildApp() {
|
|
|
422
429
|
Allow: endpointMethods(mod).join(', '),
|
|
423
430
|
});
|
|
424
431
|
} catch (err) {
|
|
425
|
-
return onError(c, err);
|
|
432
|
+
return onError(c, err, { route, phase: 'endpoint' });
|
|
426
433
|
}
|
|
427
434
|
});
|
|
428
435
|
}
|
|
@@ -432,7 +439,7 @@ async function buildApp() {
|
|
|
432
439
|
try {
|
|
433
440
|
return await renderPage(notFound, c, 404);
|
|
434
441
|
} catch (err) {
|
|
435
|
-
return onError(c, err);
|
|
442
|
+
return onError(c, err, { route: notFound, phase: 'page' });
|
|
436
443
|
}
|
|
437
444
|
});
|
|
438
445
|
|
package/bin/serve.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Node adapter. The app is in src/production.js; this listens with it.
|
|
3
3
|
|
|
4
4
|
import { serve } from '@hono/node-server';
|
|
5
|
+
import { drainOn } from '../src/drain.js';
|
|
5
6
|
import { app, noBuild, port, summary } from '../src/production.js';
|
|
6
7
|
|
|
7
8
|
if (noBuild) {
|
|
@@ -9,4 +10,8 @@ if (noBuild) {
|
|
|
9
10
|
process.exit(1);
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
serve({ fetch: app.fetch, port }, ({ port }) => summary(port));
|
|
13
|
+
const server = serve({ fetch: app.fetch, port }, ({ port }) => summary(port));
|
|
14
|
+
|
|
15
|
+
// A container sends SIGTERM and waits. Node's default is to die on the spot,
|
|
16
|
+
// which cuts a render that was halfway through answering.
|
|
17
|
+
drainOn(server);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "An HTML-first server framework. A page is an .html file, the directory tree is the route table, and any fragment of a page is a URL of its own. Runs on Node, Bun, Deno and workerd, and ships no client JavaScript by default.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"html",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"exports": {
|
|
39
39
|
".": "./src/plugin.js",
|
|
40
40
|
"./app": "./src/app.js",
|
|
41
|
+
"./compiler": "./src/compiler/index.js",
|
|
41
42
|
"./cookies": "./src/cookies.js",
|
|
42
43
|
"./document": "./src/document.js",
|
|
43
44
|
"./production": "./src/production.js",
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
],
|
|
58
59
|
"scripts": {
|
|
59
60
|
"test": "node --test \"test/**/*.test.js\"",
|
|
61
|
+
"crap": "node scripts/crap.js",
|
|
60
62
|
"test:examples": "npm test --prefix examples/showcase && npm test --prefix examples/todomvc && npm test --prefix examples/blog && npm test --prefix examples/search && npm test --prefix examples/htmx && npm test --prefix examples/includes && npm test --prefix examples/auth && npm test --prefix examples/live && npm test --prefix examples/elements && npm test --prefix examples/markdown && npm test --prefix examples/atlas",
|
|
61
63
|
"test:www": "npm test --prefix www",
|
|
62
64
|
"showcase": "npm run dev --prefix examples/showcase",
|
|
@@ -82,12 +84,12 @@
|
|
|
82
84
|
"parse5": "^8.0.1"
|
|
83
85
|
},
|
|
84
86
|
"peerDependencies": {
|
|
85
|
-
"typescript": "^
|
|
87
|
+
"typescript": "^7.0",
|
|
86
88
|
"vite": "^8"
|
|
87
89
|
},
|
|
88
90
|
"devDependencies": {
|
|
89
91
|
"@types/node": "^22.20.1",
|
|
90
|
-
"typescript": "
|
|
92
|
+
"typescript": "7.0.2",
|
|
91
93
|
"vite": "^8.1.5"
|
|
92
94
|
}
|
|
93
95
|
}
|
package/src/app.js
CHANGED
|
@@ -35,6 +35,11 @@ import { withDefaults } from './defaults.js';
|
|
|
35
35
|
|
|
36
36
|
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
|
37
37
|
const REVALIDATE = 'public, max-age=0, must-revalidate';
|
|
38
|
+
// What a personal render says instead. `public` is an explicit grant, and a
|
|
39
|
+
// page that read a cookie is one visitor's. A conforming shared cache would
|
|
40
|
+
// revalidate and miss on the ETag anyway; this is for the CDN whose edge rule
|
|
41
|
+
// skips revalidation and would hand one visitor's page to the next.
|
|
42
|
+
const PERSONAL = 'private, no-cache';
|
|
38
43
|
|
|
39
44
|
// One per process rather than one per render. It holds no state between calls.
|
|
40
45
|
const encoder = new TextEncoder();
|
|
@@ -220,7 +225,7 @@ export function createApp({
|
|
|
220
225
|
revalidateTag: cache.revalidateTag,
|
|
221
226
|
// Reported through `report`, so work that fails after the reader is gone
|
|
222
227
|
// is not quieter than work that fails in front of them.
|
|
223
|
-
after: afterFor(c, (error) => report(error, c)),
|
|
228
|
+
after: afterFor(c, (error) => report(error, c, { route, phase: 'after' })),
|
|
224
229
|
...extra,
|
|
225
230
|
};
|
|
226
231
|
};
|
|
@@ -316,7 +321,7 @@ export function createApp({
|
|
|
316
321
|
if (html === null) return c.text(`no fragment "${region}"`, 404);
|
|
317
322
|
return sendRendered(c, html, ctx);
|
|
318
323
|
} catch (err) {
|
|
319
|
-
return internalError(c, err);
|
|
324
|
+
return internalError(c, err, { route, phase: 'fragment' });
|
|
320
325
|
}
|
|
321
326
|
});
|
|
322
327
|
|
|
@@ -369,7 +374,7 @@ export function createApp({
|
|
|
369
374
|
if (html instanceof Response) return withEnvelope(html, ctx);
|
|
370
375
|
return sendRendered(c, html, ctx);
|
|
371
376
|
} catch (err) {
|
|
372
|
-
return internalError(c, err);
|
|
377
|
+
return internalError(c, err, { route, phase: 'action' });
|
|
373
378
|
}
|
|
374
379
|
});
|
|
375
380
|
}
|
|
@@ -391,7 +396,7 @@ export function createApp({
|
|
|
391
396
|
Allow: endpointMethods(mod).join(', '),
|
|
392
397
|
});
|
|
393
398
|
} catch (err) {
|
|
394
|
-
return internalError(c, err);
|
|
399
|
+
return internalError(c, err, { route, phase: 'endpoint' });
|
|
395
400
|
}
|
|
396
401
|
});
|
|
397
402
|
}
|
|
@@ -450,7 +455,7 @@ export function createApp({
|
|
|
450
455
|
// workerd stops the rebuild when this response is sent, and the entry it
|
|
451
456
|
// leaves in the in-flight map answers every later request with a dead
|
|
452
457
|
// promise.
|
|
453
|
-
const after = afterFor(c, (error) => report(error, c));
|
|
458
|
+
const after = afterFor(c, (error) => report(error, c, { route, phase: 'revalidate' }));
|
|
454
459
|
const html = await cache.read(cacheKey(c.req.url), window, render, after);
|
|
455
460
|
|
|
456
461
|
// A miss rendered through the cache, and that render can answer with a
|
|
@@ -462,7 +467,7 @@ export function createApp({
|
|
|
462
467
|
const ctx = last ? last.ctx : contextFor(route, c);
|
|
463
468
|
return sendRendered(c, html, ctx, preload);
|
|
464
469
|
} catch (err) {
|
|
465
|
-
return internalError(c, err);
|
|
470
|
+
return internalError(c, err, { route, phase: 'page' });
|
|
466
471
|
}
|
|
467
472
|
});
|
|
468
473
|
}
|
|
@@ -475,19 +480,28 @@ export function createApp({
|
|
|
475
480
|
* `console.error` is the default and not much of one: a real site sends this
|
|
476
481
|
* to something that can page a person. `onError` is that seam, and it is given
|
|
477
482
|
* the request as well, because an error with no URL and no method is most of
|
|
478
|
-
* the way to useless.
|
|
483
|
+
* the way to useless. `route` and `phase` say where: the reader starts at the
|
|
484
|
+
* loader of `people/[slug]` with `slug: 'ada'` rather than at a URL to
|
|
485
|
+
* re-derive that from. The phases are page, fragment, action, endpoint,
|
|
486
|
+
* after and revalidate.
|
|
479
487
|
*
|
|
480
488
|
* It is called inside a `try`. A reporter that throws would otherwise replace
|
|
481
489
|
* the error being reported, which is the one failure mode a reporting hook
|
|
482
490
|
* must not have.
|
|
483
491
|
*/
|
|
484
|
-
function report(err, c) {
|
|
492
|
+
function report(err, c, at = null) {
|
|
485
493
|
if (typeof config.onError !== 'function') {
|
|
486
494
|
console.error(err);
|
|
487
495
|
return;
|
|
488
496
|
}
|
|
489
497
|
try {
|
|
490
|
-
config.onError(err, {
|
|
498
|
+
config.onError(err, {
|
|
499
|
+
request: c.req.raw,
|
|
500
|
+
url: c.req.url,
|
|
501
|
+
method: c.req.method,
|
|
502
|
+
route: at ? { id: at.route.id, pattern: at.route.pattern, params: c.req.param() } : null,
|
|
503
|
+
phase: at?.phase ?? null,
|
|
504
|
+
});
|
|
491
505
|
} catch (failed) {
|
|
492
506
|
console.error(err);
|
|
493
507
|
console.error('[transclude] onError itself threw:', failed);
|
|
@@ -495,8 +509,8 @@ export function createApp({
|
|
|
495
509
|
}
|
|
496
510
|
|
|
497
511
|
/** Every `catch` above. One place decides what a failed request looks like. */
|
|
498
|
-
function internalError(c, err) {
|
|
499
|
-
report(err, c);
|
|
512
|
+
function internalError(c, err, at = null) {
|
|
513
|
+
report(err, c, at);
|
|
500
514
|
// No ETag and no Cache-Control: nothing about a failure should be stored or
|
|
501
515
|
// revalidated, and the same bytes would be sent for an unrelated one next time.
|
|
502
516
|
if (!errorPage) return c.text('Internal error', 500);
|
|
@@ -529,7 +543,10 @@ export function createApp({
|
|
|
529
543
|
const etag = encoding ? `${base.slice(0, -1)}-${encoding}"` : base;
|
|
530
544
|
|
|
531
545
|
c.header('Vary', varyOn);
|
|
532
|
-
|
|
546
|
+
// The same test that gates the held-page store. A shareable render is
|
|
547
|
+
// anyone's; a personal one has to say so, or a cache told `public` would
|
|
548
|
+
// be within its rights to believe it.
|
|
549
|
+
c.header('Cache-Control', ctx && !isShareable(html, ctx) ? PERSONAL : REVALIDATE);
|
|
533
550
|
c.header('ETag', etag);
|
|
534
551
|
|
|
535
552
|
// Whatever the loaders put on `ctx.response`, after the defaults above so a
|
package/src/compiler/expr.js
CHANGED
|
@@ -55,14 +55,6 @@ export class Scope {
|
|
|
55
55
|
}
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
// Used for the shadowing warning: does an *enclosing* scope already bind this?
|
|
60
|
-
outerHas(name) {
|
|
61
|
-
for (let s = this.parent; s; s = s.parent) {
|
|
62
|
-
if (s.vars.has(name)) return true;
|
|
63
|
-
}
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
58
|
}
|
|
67
59
|
|
|
68
60
|
/**
|
package/src/cookies.js
CHANGED
|
@@ -45,7 +45,9 @@ export function cookiesOf(request, response, secret = null) {
|
|
|
45
45
|
// `typeof` along the way said `string`, and the config carried it all the
|
|
46
46
|
// way here. The only thing that said otherwise was the length. Reading
|
|
47
47
|
// "needs a secret" while looking at a secret that is plainly set sends you
|
|
48
|
-
// hunting through the wiring instead of the value.
|
|
48
|
+
// hunting through the wiring instead of the value. `withDefaults` refuses
|
|
49
|
+
// the empty string at boot now; this stays for a `cookiesOf` reached
|
|
50
|
+
// without it.
|
|
49
51
|
if (typeof secret === 'string') {
|
|
50
52
|
throw new Error(
|
|
51
53
|
`[transclude] ${what} needs a secret, and \`cookieSecret\` is set to an ` +
|
|
@@ -168,5 +170,18 @@ function overTls(request) {
|
|
|
168
170
|
* turns the whole thing off. Set it yourself to override either way.
|
|
169
171
|
*/
|
|
170
172
|
function withDefaults(options, request) {
|
|
171
|
-
|
|
173
|
+
const merged = { path: '/', httpOnly: true, sameSite: 'Lax', secure: overTls(request), ...options };
|
|
174
|
+
|
|
175
|
+
// Every browser drops this pair, silently, so writing it is never right.
|
|
176
|
+
// `None` is for a cookie sent cross-site, and those are Secure-only
|
|
177
|
+
// everywhere. Refused here rather than left to the browser, because a cookie
|
|
178
|
+
// that never arrives reads exactly like a bug somewhere else.
|
|
179
|
+
if (String(merged.sameSite).toLowerCase() === 'none' && !merged.secure) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`[transclude] a cookie with \`sameSite: 'None'\` needs \`secure: true\`. Every ` +
|
|
182
|
+
`browser drops the pair without it, silently. Set both, or use 'Lax'.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return merged;
|
|
172
187
|
}
|
package/src/defaults.js
CHANGED
|
@@ -97,6 +97,19 @@ export function withDefaults(config = {}) {
|
|
|
97
97
|
|
|
98
98
|
const merged = { ...DEFAULTS, ...config };
|
|
99
99
|
|
|
100
|
+
// Set but empty is refused at boot rather than at the first signed cookie,
|
|
101
|
+
// because that first read happens in production, at request time, days after
|
|
102
|
+
// the deploy that broke it. It happened: `wrangler secret put` took a blank
|
|
103
|
+
// line, so the binding existed and carried nothing. `null` stays fine, since
|
|
104
|
+
// that is how an app says it signs nothing.
|
|
105
|
+
if (merged.cookieSecret === '') {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`[transclude] \`cookieSecret\` is an empty string. Whatever supplies it handed ` +
|
|
108
|
+
`over nothing: on a worker that is usually a \`wrangler secret put\` that took ` +
|
|
109
|
+
`a blank line. Set a real secret, or \`null\` for none.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
// Refused here because there are four places that render a page and only two of
|
|
101
114
|
// them could fall back to a request's origin. Left to the render, `canonical`
|
|
102
115
|
// would work in dev and throw in the build, which is the dev-and-production
|
package/src/drain.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Finishing what is in flight when the platform says stop.
|
|
2
|
+
//
|
|
3
|
+
// A container sends SIGTERM and waits a moment before SIGKILL. Node's default
|
|
4
|
+
// for SIGTERM is to die on the spot, so a render halfway through its loader
|
|
5
|
+
// answers nobody, and an action may have happened with its response cut on the
|
|
6
|
+
// wire. Draining instead refuses new connections, finishes what is running,
|
|
7
|
+
// and leaves.
|
|
8
|
+
//
|
|
9
|
+
// No imports. `process` and the timers are globals, and the server arrives as
|
|
10
|
+
// an argument.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Exit cleanly on a stop signal, once the work in flight is done.
|
|
14
|
+
*
|
|
15
|
+
* `close` stops the listener and waits for every open connection. A keep-alive
|
|
16
|
+
* connection counts as open with no request on it, so idle ones are swept
|
|
17
|
+
* while the close waits; without the sweep, the first browser that ever
|
|
18
|
+
* connected would hold the wait to the cap. The cap is for a render that
|
|
19
|
+
* hangs: past it, every connection is cut and the exit code says the drain was
|
|
20
|
+
* not clean. Both timers are unref'd, so neither keeps a finished process
|
|
21
|
+
* alive.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} server what `serve` returned: a `node:http` server
|
|
24
|
+
* @param {{ signals?: string[], grace?: number, sweep?: number, exit?: Function }} [options]
|
|
25
|
+
* @returns {() => void} the drain itself, so a test can run one without a signal
|
|
26
|
+
*/
|
|
27
|
+
export function drainOn(server, options = {}) {
|
|
28
|
+
const {
|
|
29
|
+
signals = ['SIGTERM', 'SIGINT'],
|
|
30
|
+
grace = 10_000,
|
|
31
|
+
sweep = 500,
|
|
32
|
+
exit = (code) => process.exit(code),
|
|
33
|
+
} = options;
|
|
34
|
+
|
|
35
|
+
// The cap and the close both want to be the exit. First one wins.
|
|
36
|
+
let left = false;
|
|
37
|
+
const leave = (code) => {
|
|
38
|
+
if (left) return;
|
|
39
|
+
left = true;
|
|
40
|
+
exit(code);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const drain = () => {
|
|
44
|
+
const idle = setInterval(() => server.closeIdleConnections?.(), sweep);
|
|
45
|
+
idle.unref?.();
|
|
46
|
+
|
|
47
|
+
const cap = setTimeout(() => {
|
|
48
|
+
server.closeAllConnections?.();
|
|
49
|
+
leave(1);
|
|
50
|
+
}, grace);
|
|
51
|
+
cap.unref?.();
|
|
52
|
+
|
|
53
|
+
server.close(() => {
|
|
54
|
+
clearInterval(idle);
|
|
55
|
+
clearTimeout(cap);
|
|
56
|
+
leave(0);
|
|
57
|
+
});
|
|
58
|
+
server.closeIdleConnections?.();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const signal of signals) process.once(signal, drain);
|
|
62
|
+
return drain;
|
|
63
|
+
}
|
package/src/lookup.js
CHANGED
|
@@ -24,7 +24,7 @@ import { blockedAddress } from './address.js';
|
|
|
24
24
|
* this is defense behind it.
|
|
25
25
|
*
|
|
26
26
|
* @param {{ resolver?: object }} [deps] injected so a test needs no DNS
|
|
27
|
-
* @returns {(hostname: string) => Promise<string
|
|
27
|
+
* @returns {(hostname: string) => Promise<string|null>} why the name is refused, or null
|
|
28
28
|
*/
|
|
29
29
|
export function nodeLookup({ resolver = dns } = {}) {
|
|
30
30
|
return async (hostname) => {
|
package/src/proxy.js
CHANGED
|
@@ -33,13 +33,26 @@ const DEFAULTS = {
|
|
|
33
33
|
|
|
34
34
|
const STYLE_MODES = new Set(['keep', 'strip']);
|
|
35
35
|
|
|
36
|
+
/** Every key `proxy` may set. `lookup` has no default: absent means the runtime's. */
|
|
37
|
+
const KEYS = new Set([...Object.keys(DEFAULTS), 'lookup']);
|
|
38
|
+
|
|
36
39
|
/**
|
|
37
|
-
* Defaults filled in, and the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
+
* Defaults filled in, and what the author wrote checked. A misspelled `maxage`
|
|
41
|
+
* would fall back to the default and say nothing, which reads exactly like the
|
|
42
|
+
* setting working. That is the failure the config's own keys refuse by name,
|
|
43
|
+
* one level up, and these keys get the same treatment.
|
|
40
44
|
*/
|
|
41
45
|
function settings(options) {
|
|
42
46
|
const config = { ...DEFAULTS, ...options };
|
|
47
|
+
|
|
48
|
+
const unknown = Object.keys(options ?? {}).filter((key) => !KEYS.has(key));
|
|
49
|
+
if (unknown.length) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[transclude] \`proxy\` sets ${unknown.join(', ')}, which nothing reads. ` +
|
|
52
|
+
`The keys are ${[...KEYS].sort().join(', ')}.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
if (!STYLE_MODES.has(config.styles)) {
|
|
44
57
|
throw new Error(
|
|
45
58
|
`[transclude] proxy.styles is ${JSON.stringify(config.styles)}. It is 'keep' or 'strip'.`,
|
|
@@ -289,10 +302,12 @@ export function proxyHandler(options = {}, deps = {}) {
|
|
|
289
302
|
try {
|
|
290
303
|
const entry = await readForeign(url, config, { ...deps, store });
|
|
291
304
|
|
|
292
|
-
// No id is a question about the document rather than a piece of it
|
|
305
|
+
// No id is a question about the document rather than a piece of it, so
|
|
306
|
+
// the answer also says what the cleaning took out. The list was already
|
|
307
|
+
// kept for exactly this; nothing read it until here.
|
|
293
308
|
if (!id) {
|
|
294
309
|
const { listFragments } = await import('./extract.js');
|
|
295
|
-
return json(200, { url, fragments: listFragments(entry.doc) });
|
|
310
|
+
return json(200, { url, fragments: listFragments(entry.doc), removed: entry.removed });
|
|
296
311
|
}
|
|
297
312
|
|
|
298
313
|
const found = resolveFragment(entry.doc, id);
|
package/src/rewrite.js
CHANGED
|
@@ -81,15 +81,15 @@ export function sanitize(root, { styles = 'keep' } = {}) {
|
|
|
81
81
|
removed.push('@style');
|
|
82
82
|
return false;
|
|
83
83
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
for (const attr of child.attrs) {
|
|
84
|
+
// Removed rather than emptied. An empty value still means something:
|
|
85
|
+
// `href=""` names the page the fragment lands in, and `action=""`
|
|
86
|
+
// submits to it, neither of which the source wrote.
|
|
88
87
|
if (!allowedUrl(child, attr)) {
|
|
89
88
|
removed.push(`@${attr.name}`);
|
|
90
|
-
|
|
89
|
+
return false;
|
|
91
90
|
}
|
|
92
|
-
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
93
|
|
|
94
94
|
visit(child);
|
|
95
95
|
}
|
|
@@ -100,7 +100,7 @@ export function sanitize(root, { styles = 'keep' } = {}) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Whether a URL-bearing attribute may
|
|
103
|
+
* Whether a URL-bearing attribute may stay.
|
|
104
104
|
*
|
|
105
105
|
* `javascript:` is refused everywhere. `data:` is refused everywhere except an
|
|
106
106
|
* image source, where it is ordinary and cannot navigate anything.
|
package/src/typecheck.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Type checking and type extraction, both by TypeScript
|
|
1
|
+
// Type checking and type extraction, both by TypeScript 7: the Go compiler as
|
|
2
|
+
// a child process, driven through its API over a synchronous channel.
|
|
2
3
|
//
|
|
3
4
|
// Shims live in memory at `<file>.html.js`, never on disk. Naming them after the
|
|
4
5
|
// source file is what makes their relative imports resolve the way the author
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
|
|
15
16
|
import fs from 'node:fs';
|
|
16
17
|
import path from 'node:path';
|
|
17
|
-
import
|
|
18
|
+
import { version as tsVersion } from 'typescript';
|
|
18
19
|
import { AMBIENT_NAMES } from './compiler/ambient.js';
|
|
19
20
|
import { buildEndpointShim, buildShim, originalOffset } from './compiler/shim.js';
|
|
20
21
|
import { splitBlocks, readFlags } from './compiler/index.js';
|
|
@@ -22,6 +23,67 @@ import { resolveRoutesDir, scanRoutes } from './routes.js';
|
|
|
22
23
|
// Aliased: this file has its own `sourceOf`, which is the one that reads disk.
|
|
23
24
|
import { MARKDOWN_EXT, sourceOf as htmlFrom } from './markdown.js';
|
|
24
25
|
|
|
26
|
+
// The version is checked before the API is imported, because the import is what
|
|
27
|
+
// fails on the wrong version: `typescript/unstable/sync` is a 7.x export, and a
|
|
28
|
+
// resolution error names a package path rather than the fix.
|
|
29
|
+
if (!/^7\./.test(tsVersion)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[transclude] transclude-check drives TypeScript 7 and this project has ${tsVersion}. ` +
|
|
32
|
+
`Install it: npm install -D typescript@7`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The 7.x API: a Go compiler as a child process, spoken to synchronously. It
|
|
37
|
+
// is exported under `unstable`, which is the API's own warning, so the import
|
|
38
|
+
// and the shape are both checked rather than trusted. A 7.x minor may move the
|
|
39
|
+
// subpath, which fails loudly with the wrong name, or rename a flag, which
|
|
40
|
+
// does not fail at all: an undefined bit ORs into TYPE_FORMAT as nothing and
|
|
41
|
+
// types print wrong without a word. Either way the refusal names what moved
|
|
42
|
+
// and the version that held still.
|
|
43
|
+
const TESTED = '7.0.2';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The unstable module, or the refusal naming what moved.
|
|
47
|
+
*
|
|
48
|
+
* Exported for its test, which is the only way to falsify a failure that needs
|
|
49
|
+
* a TypeScript that does not exist yet.
|
|
50
|
+
*
|
|
51
|
+
* @param {object|null} unstable what importing `typescript/unstable/sync` gave
|
|
52
|
+
* @param {string} version the TypeScript that gave it
|
|
53
|
+
* @returns {object} the module, once its shape holds
|
|
54
|
+
* @throws when the subpath or a name this file drives is gone
|
|
55
|
+
*/
|
|
56
|
+
export function refuseMovedAPI(unstable, version) {
|
|
57
|
+
const missing = ['API', 'DiagnosticCategory', 'NodeBuilderFlags'].filter(
|
|
58
|
+
(name) => !unstable?.[name],
|
|
59
|
+
);
|
|
60
|
+
for (const flag of [
|
|
61
|
+
'NoTruncation',
|
|
62
|
+
'InTypeAlias',
|
|
63
|
+
'UseFullyQualifiedType',
|
|
64
|
+
'UseSingleQuotesForStringLiteralType',
|
|
65
|
+
]) {
|
|
66
|
+
// Only once the enum itself is there: a missing enum already says enough.
|
|
67
|
+
if (unstable?.NodeBuilderFlags && typeof unstable.NodeBuilderFlags[flag] !== 'number') {
|
|
68
|
+
missing.push(`NodeBuilderFlags.${flag}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (missing.length) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`[transclude] TypeScript ${version} moved the unstable API this checker drives: ` +
|
|
75
|
+
`${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} gone. ` +
|
|
76
|
+
`Pin the version that held still: npm install -D typescript@${TESTED}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return unstable;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { API, DiagnosticCategory, NodeBuilderFlags } = refuseMovedAPI(
|
|
83
|
+
await import('typescript/unstable/sync').catch(() => null),
|
|
84
|
+
tsVersion,
|
|
85
|
+
);
|
|
86
|
+
|
|
25
87
|
/**
|
|
26
88
|
* Annotations are optional, so `noImplicitAny` is off: an unannotated parameter
|
|
27
89
|
* is `any` rather than an error, and the author writes plain modern JavaScript.
|
|
@@ -32,31 +94,40 @@ import { MARKDOWN_EXT, sourceOf as htmlFrom } from './markdown.js';
|
|
|
32
94
|
* `strictNullChecks` stays on: `querySelector` really can return null, and that
|
|
33
95
|
* is a bug rather than a matter of taste. `strict: true` in the config turns the
|
|
34
96
|
* rest on for anyone who wants it.
|
|
97
|
+
*
|
|
98
|
+
* Written as tsconfig JSON rather than option objects, because the 7.x API
|
|
99
|
+
* loads a project from a config file. Ours never exists: the filesystem the
|
|
100
|
+
* compiler is given serves it from memory, next to the shims.
|
|
35
101
|
*/
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
102
|
+
const configJson = (strict, files) =>
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
compilerOptions: {
|
|
105
|
+
target: 'esnext',
|
|
106
|
+
module: 'esnext',
|
|
107
|
+
moduleResolution: 'bundler',
|
|
108
|
+
lib: ['esnext', 'dom'],
|
|
109
|
+
strict,
|
|
110
|
+
strictNullChecks: true,
|
|
111
|
+
noImplicitAny: strict,
|
|
112
|
+
noEmit: true,
|
|
113
|
+
skipLibCheck: true,
|
|
114
|
+
allowJs: true,
|
|
115
|
+
checkJs: true,
|
|
116
|
+
types: [],
|
|
117
|
+
},
|
|
118
|
+
files,
|
|
119
|
+
});
|
|
50
120
|
|
|
51
121
|
// `UseFullyQualifiedType` is what makes a name the app declared resolvable
|
|
52
122
|
// somewhere else. Without it a `@typedef {…} Post` in the app prints as `Post`,
|
|
53
123
|
// which means something in the file it came from and nothing in
|
|
54
|
-
// transclude-env.d.ts, where it landed as an undeclared name.
|
|
124
|
+
// transclude-env.d.ts, where it landed as an undeclared name. These were
|
|
125
|
+
// `TypeFormatFlags` before 7; the four names survived the move.
|
|
55
126
|
const TYPE_FORMAT =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
127
|
+
NodeBuilderFlags.NoTruncation |
|
|
128
|
+
NodeBuilderFlags.InTypeAlias |
|
|
129
|
+
NodeBuilderFlags.UseFullyQualifiedType |
|
|
130
|
+
NodeBuilderFlags.UseSingleQuotesForStringLiteralType;
|
|
60
131
|
|
|
61
132
|
const LAYOUT_FILE = '_layout.html';
|
|
62
133
|
|
|
@@ -83,7 +154,8 @@ const LAYOUT_FILE = '_layout.html';
|
|
|
83
154
|
* @param {{ root: string, appDir: string, routesDir: string, elementsDir: string,
|
|
84
155
|
* strict?: boolean, markdown?: ((source: string, file: string) => string)|null }} options
|
|
85
156
|
* @returns {{ files: Function, sourceFor: Function, update: Function,
|
|
86
|
-
* rebuild: Function, check: Function, quickInfo: Function, describe: Function
|
|
157
|
+
* rebuild: Function, check: Function, quickInfo: Function, describe: Function,
|
|
158
|
+
* dispose: Function }}
|
|
87
159
|
*/
|
|
88
160
|
export function createChecker({
|
|
89
161
|
root,
|
|
@@ -94,38 +166,63 @@ export function createChecker({
|
|
|
94
166
|
markdown = null,
|
|
95
167
|
}) {
|
|
96
168
|
const app = path.resolve(root, appDir);
|
|
97
|
-
const options = compilerOptions(Boolean(strict));
|
|
98
169
|
const shims = new Map();
|
|
99
|
-
const versions = new Map();
|
|
100
170
|
const overlays = new Map();
|
|
101
171
|
|
|
102
172
|
const shimPath = (file) => `${file}.js`;
|
|
103
173
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
174
|
+
// The project file the compiler is asked to open. It never touches disk: the
|
|
175
|
+
// filesystem below serves it from memory, regenerated whenever the shim set
|
|
176
|
+
// changes, because its `files` list is the shim list.
|
|
177
|
+
const configPath = path.join(root, '.transclude-check.tsconfig.json');
|
|
178
|
+
|
|
179
|
+
// The compiler, a child process. It sees the real filesystem except where a
|
|
180
|
+
// callback answers first: the config and the shims come from these maps, and
|
|
181
|
+
// `undefined` means "ask the disk", which is how the app's own imports and
|
|
182
|
+
// the libs resolve without this file listing them.
|
|
183
|
+
const api = new API({
|
|
184
|
+
cwd: root,
|
|
185
|
+
fs: {
|
|
186
|
+
fileExists: (name) => (name === configPath || shims.has(name) ? true : undefined),
|
|
187
|
+
readFile: (name) => {
|
|
188
|
+
if (name === configPath) return configJson(Boolean(strict), [...shims.keys()]);
|
|
189
|
+
return shims.get(name)?.code;
|
|
190
|
+
},
|
|
112
191
|
},
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// One snapshot at a time, rebuilt lazily. `install` records what changed and
|
|
195
|
+
// the next question re-snapshots with exactly those invalidations, so a
|
|
196
|
+
// build's forty installs cost one program rather than forty.
|
|
197
|
+
let snapshot = null;
|
|
198
|
+
const dirty = { changed: new Set(), created: new Set() };
|
|
199
|
+
|
|
200
|
+
const current = () => {
|
|
201
|
+
if (snapshot && !dirty.changed.size && !dirty.created.size) return snapshot;
|
|
202
|
+
|
|
203
|
+
const fileChanges = snapshot
|
|
204
|
+
? { changed: [...dirty.changed, configPath], created: [...dirty.created] }
|
|
205
|
+
: undefined;
|
|
206
|
+
snapshot?.dispose();
|
|
207
|
+
// The open is ref-counted and persists across snapshots, so the project is
|
|
208
|
+
// named once and invalidated after.
|
|
209
|
+
snapshot = api.updateSnapshot({ openProjects: [configPath], fileChanges });
|
|
210
|
+
dirty.changed.clear();
|
|
211
|
+
dirty.created.clear();
|
|
212
|
+
|
|
213
|
+
const project = snapshot.getProject(configPath);
|
|
214
|
+
if (!project) throw new Error('[transclude] the compiler did not open the shim project');
|
|
215
|
+
return snapshot;
|
|
121
216
|
};
|
|
122
217
|
|
|
123
|
-
const
|
|
218
|
+
const projectOf = () => current().getProject(configPath);
|
|
219
|
+
const programOf = () => projectOf().program;
|
|
220
|
+
const checkerOf = () => projectOf().checker;
|
|
124
221
|
|
|
125
222
|
const install = (file, built) => {
|
|
126
223
|
const name = shimPath(file);
|
|
224
|
+
(shims.has(name) ? dirty.changed : dirty.created).add(name);
|
|
127
225
|
shims.set(name, built);
|
|
128
|
-
versions.set(name, (versions.get(name) ?? 0) + 1);
|
|
129
226
|
return built;
|
|
130
227
|
};
|
|
131
228
|
|
|
@@ -136,27 +233,27 @@ export function createChecker({
|
|
|
136
233
|
|
|
137
234
|
/** The type of one of a shim's marker exports. What tsc made of the file. */
|
|
138
235
|
const exportTypeOf = (file, name) => {
|
|
139
|
-
const
|
|
140
|
-
const source = program?.getSourceFile(shimPath(file));
|
|
236
|
+
const source = programOf().getSourceFile(shimPath(file));
|
|
141
237
|
if (!source) return 'unknown';
|
|
142
238
|
|
|
143
|
-
const checker =
|
|
239
|
+
const checker = checkerOf();
|
|
144
240
|
const moduleSymbol = checker.getSymbolAtLocation(source);
|
|
145
241
|
const data =
|
|
146
242
|
moduleSymbol &&
|
|
147
|
-
checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.
|
|
243
|
+
checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === name);
|
|
148
244
|
if (!data) return 'unknown';
|
|
149
245
|
|
|
150
|
-
const type = checker.
|
|
151
|
-
const text = checker.typeToString(type, undefined, TYPE_FORMAT);
|
|
246
|
+
const type = checker.getTypeOfSymbol(data);
|
|
247
|
+
const text = type ? checker.typeToString(type, undefined, TYPE_FORMAT) : 'unknown';
|
|
152
248
|
return text === 'any' ? 'unknown' : text;
|
|
153
249
|
};
|
|
154
250
|
|
|
155
|
-
// `UseFullyQualifiedType` prints a named type as `import(
|
|
251
|
+
// `UseFullyQualifiedType` prints a named type as `import('/abs/file').Name`.
|
|
156
252
|
// Inside a shim that resolves and is what keeps a prop structurally checked.
|
|
157
253
|
// In transclude-env.d.ts it does not: a shim path is `<file>.js` for an .html
|
|
158
254
|
// file nobody can import, and an absolute path would name this machine.
|
|
159
|
-
|
|
255
|
+
// Either quote: 5.x printed double and 7 prints single.
|
|
256
|
+
const QUALIFIED = /import\((["'])([^"']+)\1\)\.([A-Za-z_$][\w$]*)/g;
|
|
160
257
|
|
|
161
258
|
/**
|
|
162
259
|
* The type a name stands for, expanded. `InTypeAlias` is what stops tsc
|
|
@@ -169,14 +266,14 @@ export function createChecker({
|
|
|
169
266
|
const already = into.byKey.get(key);
|
|
170
267
|
if (already) return already;
|
|
171
268
|
|
|
172
|
-
const program =
|
|
269
|
+
const program = programOf();
|
|
173
270
|
// tsc prints the path with no extension, and a shim is the source it names
|
|
174
271
|
// plus `.js`.
|
|
175
|
-
const source = program
|
|
176
|
-
const checker =
|
|
177
|
-
const moduleSymbol = source && checker
|
|
272
|
+
const source = program.getSourceFile(file) ?? program.getSourceFile(`${file}.js`);
|
|
273
|
+
const checker = checkerOf();
|
|
274
|
+
const moduleSymbol = source && checker.getSymbolAtLocation(source);
|
|
178
275
|
const symbol =
|
|
179
|
-
moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((s) => s.
|
|
276
|
+
moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((s) => s.name === name);
|
|
180
277
|
// Two files can each declare a `Post`, and one name cannot mean both.
|
|
181
278
|
let display = name;
|
|
182
279
|
for (let n = 2; into.text.has(display); n++) display = `${name}_${n}`;
|
|
@@ -195,7 +292,7 @@ export function createChecker({
|
|
|
195
292
|
* same shapes from `ambient.js`; anything else is the app's and is expanded.
|
|
196
293
|
*/
|
|
197
294
|
const resolveNames = (type, into) =>
|
|
198
|
-
type.replace(QUALIFIED, (_, file, name) =>
|
|
295
|
+
type.replace(QUALIFIED, (_, quote, file, name) =>
|
|
199
296
|
AMBIENT_NAMES.has(name) ? name : expand(file, name, into),
|
|
200
297
|
);
|
|
201
298
|
|
|
@@ -455,6 +552,17 @@ export function createChecker({
|
|
|
455
552
|
project = build();
|
|
456
553
|
},
|
|
457
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Stops the compiler. It is a child process, so a caller that finishes,
|
|
557
|
+
* like `bin/check.js`, closes it rather than leaving the exit to wait on
|
|
558
|
+
* an orphan. The editor's server never calls this: it dies with the editor.
|
|
559
|
+
*/
|
|
560
|
+
dispose() {
|
|
561
|
+
snapshot?.dispose();
|
|
562
|
+
snapshot = null;
|
|
563
|
+
api.close();
|
|
564
|
+
},
|
|
565
|
+
|
|
458
566
|
check(file) {
|
|
459
567
|
const shim = refresh(file);
|
|
460
568
|
const name = shimPath(file);
|
|
@@ -472,12 +580,13 @@ export function createChecker({
|
|
|
472
580
|
}));
|
|
473
581
|
}
|
|
474
582
|
|
|
583
|
+
const program = programOf();
|
|
475
584
|
const out = [];
|
|
476
585
|
for (const diagnostic of [
|
|
477
|
-
...
|
|
478
|
-
...
|
|
586
|
+
...program.getSyntacticDiagnostics(name),
|
|
587
|
+
...program.getSemanticDiagnostics(name),
|
|
479
588
|
]) {
|
|
480
|
-
const offset = originalOffset(shim.chunks, diagnostic.
|
|
589
|
+
const offset = originalOffset(shim.chunks, diagnostic.pos ?? 0);
|
|
481
590
|
// A diagnostic with no home is one about generated scaffolding. Dropping
|
|
482
591
|
// it is right, but it means anything that can carry a diagnostic has to be
|
|
483
592
|
// mapped, or it disappears without a word.
|
|
@@ -486,10 +595,10 @@ export function createChecker({
|
|
|
486
595
|
out.push({
|
|
487
596
|
file,
|
|
488
597
|
offset,
|
|
489
|
-
length: diagnostic.
|
|
598
|
+
length: Math.max(1, (diagnostic.end ?? 0) - (diagnostic.pos ?? 0)),
|
|
490
599
|
code: diagnostic.code,
|
|
491
|
-
message:
|
|
492
|
-
severity: diagnostic.category ===
|
|
600
|
+
message: flatten(diagnostic),
|
|
601
|
+
severity: diagnostic.category === DiagnosticCategory.Error ? 'error' : 'warning',
|
|
493
602
|
});
|
|
494
603
|
}
|
|
495
604
|
return out.sort((a, b) => a.offset - b.offset);
|
|
@@ -506,15 +615,27 @@ export function createChecker({
|
|
|
506
615
|
);
|
|
507
616
|
if (!target) return null;
|
|
508
617
|
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
618
|
+
const name = shimPath(file);
|
|
619
|
+
const position = target.start + (offset - target.source);
|
|
620
|
+
const checker = checkerOf();
|
|
621
|
+
|
|
622
|
+
// Assembled rather than asked for: the 7.x API has no quick-info call, and
|
|
623
|
+
// the symbol plus its printed type is what the old one's display parts
|
|
624
|
+
// said. Documentation rides on the JSDoc tags when the symbol carries any.
|
|
625
|
+
const symbol = checker.getSymbolAtPosition(name, position);
|
|
626
|
+
const type = symbol
|
|
627
|
+
? checker.getTypeOfSymbol(symbol)
|
|
628
|
+
: checker.getTypeAtPosition(name, position);
|
|
629
|
+
if (!type) return null;
|
|
630
|
+
|
|
631
|
+
const printed = checker.typeToString(type, undefined, NodeBuilderFlags.NoTruncation);
|
|
632
|
+
const tags = symbol?.getJsDocTags?.(checker) ?? [];
|
|
514
633
|
|
|
515
634
|
return {
|
|
516
|
-
text:
|
|
517
|
-
documentation:
|
|
635
|
+
text: symbol ? `${symbol.name}: ${printed}` : printed,
|
|
636
|
+
documentation: tags
|
|
637
|
+
.map((tag) => [tag.name, tag.text?.map((part) => part.text).join('')].filter(Boolean).join(' '))
|
|
638
|
+
.join('\n'),
|
|
518
639
|
};
|
|
519
640
|
},
|
|
520
641
|
|
|
@@ -573,6 +694,69 @@ export function createChecker({
|
|
|
573
694
|
};
|
|
574
695
|
}
|
|
575
696
|
|
|
697
|
+
/**
|
|
698
|
+
* Diagnostics for one TypeScript file, compiled alone.
|
|
699
|
+
*
|
|
700
|
+
* The guard `bin/check.js` runs over the emitted transclude-env.d.ts, and what
|
|
701
|
+
* `test/types.test.js` asserts against, so the two cannot disagree about what
|
|
702
|
+
* the file is allowed to name. `skipLibCheck` is off on purpose: a .d.ts is
|
|
703
|
+
* the one kind of file that flag skips, and with it on this guard checked
|
|
704
|
+
* nothing at all. `types: []` keeps the compile to this file, so a project's
|
|
705
|
+
* own `@types` failing to resolve does not read as our file being broken.
|
|
706
|
+
*
|
|
707
|
+
* @param {string} file an absolute path to a .ts or .d.ts on disk
|
|
708
|
+
* @returns {Array<{ offset: number, message: string }>}
|
|
709
|
+
*/
|
|
710
|
+
export function checkAlone(file) {
|
|
711
|
+
const dir = path.dirname(file);
|
|
712
|
+
const configPath = path.join(dir, '.transclude-alone.tsconfig.json');
|
|
713
|
+
const api = new API({
|
|
714
|
+
cwd: dir,
|
|
715
|
+
fs: {
|
|
716
|
+
fileExists: (name) => (name === configPath ? true : undefined),
|
|
717
|
+
readFile: (name) =>
|
|
718
|
+
name === configPath
|
|
719
|
+
? JSON.stringify({
|
|
720
|
+
compilerOptions: {
|
|
721
|
+
noEmit: true,
|
|
722
|
+
skipLibCheck: false,
|
|
723
|
+
types: [],
|
|
724
|
+
target: 'esnext',
|
|
725
|
+
lib: ['esnext', 'dom'],
|
|
726
|
+
},
|
|
727
|
+
files: [path.basename(file)],
|
|
728
|
+
})
|
|
729
|
+
: undefined,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
try {
|
|
734
|
+
const snapshot = api.updateSnapshot({ openProjects: [configPath] });
|
|
735
|
+
const program = snapshot.getProject(configPath).program;
|
|
736
|
+
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()].map(
|
|
737
|
+
(diagnostic) => ({ offset: diagnostic.pos, message: flatten(diagnostic) }),
|
|
738
|
+
);
|
|
739
|
+
} finally {
|
|
740
|
+
api.close();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* A diagnostic's text with its chained reasons behind it, space-joined.
|
|
746
|
+
*
|
|
747
|
+
* The reasons are the useful half: "not assignable" without the "because" is a
|
|
748
|
+
* verdict with no evidence. 5.x flattened chains before handing them over; 7
|
|
749
|
+
* sends them structured, so the joining moved here.
|
|
750
|
+
*
|
|
751
|
+
* @param {{ text: string, messageChain?: readonly object[] }} diagnostic
|
|
752
|
+
* @returns {string}
|
|
753
|
+
*/
|
|
754
|
+
function flatten(diagnostic) {
|
|
755
|
+
const parts = [String(diagnostic.text)];
|
|
756
|
+
for (const chained of diagnostic.messageChain ?? []) parts.push(flatten(chained));
|
|
757
|
+
return parts.join(' ');
|
|
758
|
+
}
|
|
759
|
+
|
|
576
760
|
/**
|
|
577
761
|
* Line and column for an offset, for anything that reports to a human.
|
|
578
762
|
*
|