@transclude/core 0.1.1 → 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/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The Hono app both servers start from.
2
2
  //
3
3
  // One function rather than two `new Hono()` calls, because the order things are
4
- // registered in *is* the behaviour: a guard registered after the static handler
4
+ // registered in *is* the behavior: a guard registered after the static handler
5
5
  // does not guard a prerendered page. Two copies of that order is two servers
6
6
  // that disagree, which has happened twice in this codebase already.
7
7
 
@@ -83,7 +83,7 @@ export function baseApp(options = {}) {
83
83
  if (csrfOption) app.use('*', csrf(csrfOption === true ? undefined : csrfOption));
84
84
 
85
85
  /**
86
- * The one security header with no judgement in it.
86
+ * The one security header with no judgment in it.
87
87
  *
88
88
  * Without it a browser may sniff a response's bytes and decide the declared
89
89
  * type was wrong, so a text file an app serves can be read as HTML and a
package/src/sitemap.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // else (a route with no `paths`, an endpoint, an error page) is not a page a
7
7
  // crawler can reach by guessing, so it is left out.
8
8
 
9
+ import { urlFor } from './document.js';
10
+
9
11
  /** The protocol's cap for one file. Past it the response is an index of files. */
10
12
  const LIMIT = 50000;
11
13
 
@@ -56,9 +58,7 @@ export async function sitemapEntries(manifest, pages, { entries = [], exclude =
56
58
  if (typeof page?.paths !== 'function') continue;
57
59
 
58
60
  for (const params of (await page.paths()) ?? []) {
59
- found.push({
60
- path: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
61
- });
61
+ found.push({ path: urlFor(route, params) });
62
62
  }
63
63
  }
64
64
 
@@ -41,7 +41,7 @@ const TYPES = {
41
41
  * @property {number} onDisk left on disk because the budget ran out
42
42
  * @property {number} encoded how many have a precompressed variant
43
43
  * @property {Map<string, Entry|{ file: string }>} entries for the build, which
44
- * serialises these for a runtime with no filesystem
44
+ * serializes these for a runtime with no filesystem
45
45
  * @property {(pathname: string) => Entry|null} get
46
46
  */
47
47
 
@@ -94,7 +94,7 @@ function load(dir, urlFor, { maxBytes = DEFAULT_MAX_BYTES } = {}) {
94
94
  count: entries.size,
95
95
  bytes,
96
96
  onDisk,
97
- /** For the build, which serialises these for runtimes with no filesystem. */
97
+ /** For the build, which serializes these for runtimes with no filesystem. */
98
98
  entries,
99
99
  encoded: [...entries.values()].filter((e) => e.encodings?.size).length,
100
100
 
@@ -139,7 +139,10 @@ function read(file) {
139
139
  * @returns {string} a quoted ETag
140
140
  */
141
141
  export function etagOf(body) {
142
- return `"${createHash('sha1').update(body).digest('base64url').slice(0, 20)}"`;
142
+ const digest = createHash('sha1').update(body).digest('base64url');
143
+ // Twenty characters of base64url is 120 bits, which is far more than a cache
144
+ // key needs and short enough to read in a header.
145
+ return `"${digest.slice(0, 20)}"`;
143
146
  }
144
147
 
145
148
  function variantSize(file) {
package/src/typecheck.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // and rewriting is where source mapping breaks down.
7
7
  //
8
8
  // JavaScript rather than TypeScript because a JSDoc `@type` in the author's own
9
- // `<script props>` is honoured in a .js file and silently ignored in a .ts one.
9
+ // `<script props>` is honored in a .js file and silently ignored in a .ts one.
10
10
  //
11
11
  // Shims are self-contained: route contexts and component props are inlined as
12
12
  // type literals rather than imported. transclude-env.d.ts is written *from* the shims,
@@ -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
  }