@transclude/core 0.14.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 CHANGED
@@ -3,8 +3,7 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import ts from 'typescript';
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
- // `skipLibCheck` has to be off, and it was on. This is a .d.ts, which is the one
29
- // kind of file that flag skips, so the guard checked nothing at all: every
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 = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.14.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": "^5.9",
87
+ "typescript": "^7.0",
86
88
  "vite": "^8"
87
89
  },
88
90
  "devDependencies": {
89
91
  "@types/node": "^22.20.1",
90
- "typescript": "^5.9.3",
92
+ "typescript": "7.0.2",
91
93
  "vite": "^8.1.5"
92
94
  }
93
95
  }
@@ -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/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[]>} every address the name answers with
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/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 ts from 'typescript';
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 compilerOptions = (strict) => ({
37
- target: ts.ScriptTarget.ESNext,
38
- module: ts.ModuleKind.ESNext,
39
- moduleResolution: ts.ModuleResolutionKind.Bundler,
40
- lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
41
- strict,
42
- strictNullChecks: true,
43
- noImplicitAny: strict,
44
- noEmit: true,
45
- skipLibCheck: true,
46
- allowJs: true,
47
- checkJs: true,
48
- types: [],
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
- ts.TypeFormatFlags.NoTruncation |
57
- ts.TypeFormatFlags.InTypeAlias |
58
- ts.TypeFormatFlags.UseFullyQualifiedType |
59
- ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType;
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
- const host = {
105
- getScriptFileNames: () => [...shims.keys()],
106
- getScriptVersion: (name) => String(versions.get(name) ?? 0),
107
- getScriptSnapshot: (name) => {
108
- const shim = shims.get(name);
109
- if (shim) return ts.ScriptSnapshot.fromString(shim.code);
110
- if (!fs.existsSync(name)) return undefined;
111
- return ts.ScriptSnapshot.fromString(fs.readFileSync(name, 'utf8'));
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
- getCurrentDirectory: () => root,
114
- getCompilationSettings: () => options,
115
- getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
116
- fileExists: (name) => shims.has(name) || ts.sys.fileExists(name),
117
- readFile: (name) => (shims.has(name) ? shims.get(name).code : ts.sys.readFile(name)),
118
- readDirectory: ts.sys.readDirectory,
119
- directoryExists: ts.sys.directoryExists,
120
- getDirectories: ts.sys.getDirectories,
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 service = ts.createLanguageService(host, ts.createDocumentRegistry());
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 program = service.getProgram();
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 = program.getTypeChecker();
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.getName() === name);
243
+ checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === name);
148
244
  if (!data) return 'unknown';
149
245
 
150
- const type = checker.getTypeOfSymbolAtLocation(data, data.valueDeclaration ?? source);
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("/abs/file").Name`.
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
- const QUALIFIED = /import\("([^"]+)"\)\.([A-Za-z_$][\w$]*)/g;
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 = service.getProgram();
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?.getSourceFile(file) ?? program?.getSourceFile(`${file}.js`);
176
- const checker = program?.getTypeChecker();
177
- const moduleSymbol = source && checker?.getSymbolAtLocation(source);
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.getName() === name);
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
- ...service.getSyntacticDiagnostics(name),
478
- ...service.getSemanticDiagnostics(name),
586
+ ...program.getSyntacticDiagnostics(name),
587
+ ...program.getSemanticDiagnostics(name),
479
588
  ]) {
480
- const offset = originalOffset(shim.chunks, diagnostic.start ?? 0);
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.length ?? 1,
598
+ length: Math.max(1, (diagnostic.end ?? 0) - (diagnostic.pos ?? 0)),
490
599
  code: diagnostic.code,
491
- message: ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '),
492
- severity: diagnostic.category === ts.DiagnosticCategory.Error ? 'error' : 'warning',
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 info = service.getQuickInfoAtPosition(
510
- shimPath(file),
511
- target.start + (offset - target.source),
512
- );
513
- if (!info) return null;
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: ts.displayPartsToString(info.displayParts),
517
- documentation: ts.displayPartsToString(info.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
  *