@transclude/core 0.2.0 → 0.3.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/src/typecheck.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import ts from 'typescript';
18
+ import { AMBIENT_NAMES } from './compiler/ambient.js';
18
19
  import { buildEndpointShim, buildShim, originalOffset } from './compiler/shim.js';
19
20
  import { splitBlocks, readFlags } from './compiler/index.js';
20
21
  import { resolveRoutesDir, scanRoutes } from './routes.js';
@@ -45,14 +46,38 @@ const compilerOptions = (strict) => ({
45
46
  types: [],
46
47
  });
47
48
 
49
+ // `UseFullyQualifiedType` is what makes a name the app declared resolvable
50
+ // somewhere else. Without it a `@typedef {…} Post` in the app prints as `Post`,
51
+ // which means something in the file it came from and nothing in
52
+ // transclude-env.d.ts, where it landed as an undeclared name.
48
53
  const TYPE_FORMAT =
49
54
  ts.TypeFormatFlags.NoTruncation |
50
55
  ts.TypeFormatFlags.InTypeAlias |
56
+ ts.TypeFormatFlags.UseFullyQualifiedType |
51
57
  ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType;
52
58
 
53
59
  const LAYOUT_FILE = '_layout.html';
54
60
 
55
61
  /**
62
+ * The checker, and everything it needs held in one closure.
63
+ *
64
+ * This is long and stays long: every helper below reads the language service,
65
+ * the shim map or the resolved project, and handing each of them five arguments
66
+ * instead would be more to read rather than less. It is a list of small named
67
+ * functions, in this order:
68
+ *
69
+ * the language service host, install, sourceOf
70
+ * reading a type back exportTypeOf, expand, resolveNames, and the four
71
+ * `…TypeOf` shorthands
72
+ * finding the project elementFiles, layoutFiles, ancestorsOf, chainFor
73
+ * what a shim is given contextLiteral, endpointLiteral, mergeTypes
74
+ * building them build, refresh
75
+ * what callers use the returned object
76
+ *
77
+ * `build` is the one to read first. It compiles every shim in dependency order,
78
+ * which is the only order that resolves: an element depends on nothing, a layout
79
+ * on the layouts above it, a page on its whole chain.
80
+ *
56
81
  * @param {{ root: string, appDir: string, routesDir: string, elementsDir: string,
57
82
  * strict?: boolean }} options
58
83
  * @returns {{ files: Function, update: Function, rebuild: Function,
@@ -121,6 +146,53 @@ export function createChecker({
121
146
  return text === 'any' ? 'unknown' : text;
122
147
  };
123
148
 
149
+ // `UseFullyQualifiedType` prints a named type as `import("/abs/file").Name`.
150
+ // Inside a shim that resolves and is what keeps a prop structurally checked.
151
+ // In transclude-env.d.ts it does not: a shim path is `<file>.js` for an .html
152
+ // file nobody can import, and an absolute path would name this machine.
153
+ const QUALIFIED = /import\("([^"]+)"\)\.([A-Za-z_$][\w$]*)/g;
154
+
155
+ /**
156
+ * The type a name stands for, expanded. `InTypeAlias` is what stops tsc
157
+ * printing the alias for the type it was asked to print, so this returns the
158
+ * shape rather than the name again. A type naming itself terminates: the
159
+ * placeholder is in place before the expansion is resolved.
160
+ */
161
+ const expand = (file, name, into) => {
162
+ const key = `${file}\0${name}`;
163
+ const already = into.byKey.get(key);
164
+ if (already) return already;
165
+
166
+ const program = service.getProgram();
167
+ // tsc prints the path with no extension, and a shim is the source it names
168
+ // plus `.js`.
169
+ const source = program?.getSourceFile(file) ?? program?.getSourceFile(`${file}.js`);
170
+ const checker = program?.getTypeChecker();
171
+ const moduleSymbol = source && checker?.getSymbolAtLocation(source);
172
+ const symbol =
173
+ moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((s) => s.getName() === name);
174
+ // Two files can each declare a `Post`, and one name cannot mean both.
175
+ let display = name;
176
+ for (let n = 2; into.text.has(display); n++) display = `${name}_${n}`;
177
+ if (!symbol) return display;
178
+
179
+ into.byKey.set(key, display);
180
+ into.text.set(display, '');
181
+ const declared = checker.getDeclaredTypeOfSymbol(symbol);
182
+ into.text.set(display, resolveNames(checker.typeToString(declared, undefined, TYPE_FORMAT), into));
183
+ return display;
184
+ };
185
+
186
+ /**
187
+ * Every qualified name in a type string, replaced by a bare one. What the
188
+ * compiler declares for itself is left to the emitted file, which writes the
189
+ * same shapes from `ambient.js`; anything else is the app's and is expanded.
190
+ */
191
+ const resolveNames = (type, into) =>
192
+ type.replace(QUALIFIED, (_, file, name) =>
193
+ AMBIENT_NAMES.has(name) ? name : expand(file, name, into),
194
+ );
195
+
124
196
  const dataTypeOf = (file) => exportTypeOf(file, '__data');
125
197
  const propTypeOf = (file) => exportTypeOf(file, '__propTypes');
126
198
  const memberTypeOf = (file) => exportTypeOf(file, '__members');
@@ -426,8 +498,18 @@ export function createChecker({
426
498
  };
427
499
  },
428
500
 
429
- /** Everything transclude-env.d.ts is written from. */
501
+ /**
502
+ * Everything transclude-env.d.ts is written from.
503
+ *
504
+ * Every type string is passed through `named`, which turns a qualified
505
+ * reference into a bare name and collects what that name is. A type the app
506
+ * declared is reachable from the file it was written in and from nowhere
507
+ * else, so the emitted file carries its own copy rather than an import: one
508
+ * declared in an .html file has no module to be imported from at all.
509
+ */
430
510
  describe() {
511
+ const aliases = { byKey: new Map(), text: new Map() };
512
+ const named = (type) => resolveNames(type, aliases);
431
513
  // Light elements are described separately: they have props but no shadow
432
514
  // root, so nothing about `this.shadowRoot` applies to them.
433
515
  const partialTags = new Set(
@@ -435,26 +517,38 @@ export function createChecker({
435
517
  .filter((file) => !isShadow(file))
436
518
  .map((file) => path.basename(file, '.html')),
437
519
  );
438
- return {
520
+ const element = ([tag, type]) => {
521
+ const { members, state, upgrades } = project.componentMembers.get(tag) ?? {};
522
+ return {
523
+ tag,
524
+ type: named(type),
525
+ upgrades,
526
+ members: members ? named(members) : members,
527
+ state: state ? named(state) : state,
528
+ };
529
+ };
530
+
531
+ const described = {
439
532
  components: [...project.componentProps]
440
533
  .filter(([tag]) => !partialTags.has(tag))
441
- .map(([tag, type]) => ({ tag, type, ...project.componentMembers.get(tag) })),
442
- partials: [...project.componentProps]
443
- .filter(([tag]) => partialTags.has(tag))
444
- .map(([tag, type]) => ({ tag, type, ...project.componentMembers.get(tag) })),
534
+ .map(element),
535
+ partials: [...project.componentProps].filter(([tag]) => partialTags.has(tag)).map(element),
445
536
  layouts: [...project.layoutData].map(([id, type]) => ({
446
537
  id,
447
- type,
448
- context: contextFor(project.layouts.get(id)),
538
+ type: named(type),
539
+ context: named(contextFor(project.layouts.get(id))),
449
540
  })),
450
541
  pages: [...project.pages].map(([id, { route, context }]) => ({
451
542
  id,
452
543
  params: route.params,
453
544
  pattern: route.pattern,
454
- context,
455
- type: dataTypeOf(route.file),
545
+ context: named(context),
546
+ type: named(dataTypeOf(route.file)),
456
547
  })),
457
548
  };
549
+
550
+ // Collected while the above was named, so it is read after, not during.
551
+ return { ...described, types: [...aliases.text].map(([name, type]) => ({ name, type })) };
458
552
  },
459
553
  };
460
554
  }