@transclude/core 0.1.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/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
package/src/typecheck.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// Type checking and type extraction, both by TypeScript.
|
|
2
|
+
//
|
|
3
|
+
// Shims live in memory at `<file>.html.js`, never on disk. Naming them after the
|
|
4
|
+
// source file is what makes their relative imports resolve the way the author
|
|
5
|
+
// wrote them. A shim in a parallel directory would have to rewrite every import,
|
|
6
|
+
// and rewriting is where source mapping breaks down.
|
|
7
|
+
//
|
|
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.
|
|
10
|
+
//
|
|
11
|
+
// Shims are self-contained: route contexts and component props are inlined as
|
|
12
|
+
// type literals rather than imported. transclude-env.d.ts is written *from* the shims,
|
|
13
|
+
// so it cannot also be an input to them.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import ts from 'typescript';
|
|
18
|
+
import { buildEndpointShim, buildShim, originalOffset } from './compiler/shim.js';
|
|
19
|
+
import { splitBlocks, readFlags } from './compiler/index.js';
|
|
20
|
+
import { resolveRoutesDir, scanRoutes } from './routes.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Annotations are optional, so `noImplicitAny` is off: an unannotated parameter
|
|
24
|
+
* is `any` rather than an error, and the author writes plain modern JavaScript.
|
|
25
|
+
* That would normally allow reading an undeclared property on a type that came
|
|
26
|
+
* from an object literal. The shim gets that back by remapping the keys, so
|
|
27
|
+
* `${user.nmae}` is still an error.
|
|
28
|
+
*
|
|
29
|
+
* `strictNullChecks` stays on: `querySelector` really can return null, and that
|
|
30
|
+
* is a bug rather than a matter of taste. `strict: true` in the config turns the
|
|
31
|
+
* rest on for anyone who wants it.
|
|
32
|
+
*/
|
|
33
|
+
const compilerOptions = (strict) => ({
|
|
34
|
+
target: ts.ScriptTarget.ESNext,
|
|
35
|
+
module: ts.ModuleKind.ESNext,
|
|
36
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
37
|
+
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
|
|
38
|
+
strict,
|
|
39
|
+
strictNullChecks: true,
|
|
40
|
+
noImplicitAny: strict,
|
|
41
|
+
noEmit: true,
|
|
42
|
+
skipLibCheck: true,
|
|
43
|
+
allowJs: true,
|
|
44
|
+
checkJs: true,
|
|
45
|
+
types: [],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const TYPE_FORMAT =
|
|
49
|
+
ts.TypeFormatFlags.NoTruncation |
|
|
50
|
+
ts.TypeFormatFlags.InTypeAlias |
|
|
51
|
+
ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType;
|
|
52
|
+
|
|
53
|
+
const LAYOUT_FILE = '_layout.html';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {{ root: string, appDir: string, routesDir: string, elementsDir: string,
|
|
57
|
+
* strict?: boolean }} options
|
|
58
|
+
* @returns {{ files: Function, update: Function, rebuild: Function,
|
|
59
|
+
* check: Function, quickInfo: Function, describe: Function }}
|
|
60
|
+
*/
|
|
61
|
+
export function createChecker({
|
|
62
|
+
root,
|
|
63
|
+
appDir = 'app',
|
|
64
|
+
elementsDir = 'elements',
|
|
65
|
+
routesDir = 'routes',
|
|
66
|
+
strict = false,
|
|
67
|
+
}) {
|
|
68
|
+
const app = path.resolve(root, appDir);
|
|
69
|
+
const options = compilerOptions(Boolean(strict));
|
|
70
|
+
const shims = new Map();
|
|
71
|
+
const versions = new Map();
|
|
72
|
+
const overlays = new Map();
|
|
73
|
+
|
|
74
|
+
const shimPath = (file) => `${file}.js`;
|
|
75
|
+
|
|
76
|
+
const host = {
|
|
77
|
+
getScriptFileNames: () => [...shims.keys()],
|
|
78
|
+
getScriptVersion: (name) => String(versions.get(name) ?? 0),
|
|
79
|
+
getScriptSnapshot: (name) => {
|
|
80
|
+
const shim = shims.get(name);
|
|
81
|
+
if (shim) return ts.ScriptSnapshot.fromString(shim.code);
|
|
82
|
+
if (!fs.existsSync(name)) return undefined;
|
|
83
|
+
return ts.ScriptSnapshot.fromString(fs.readFileSync(name, 'utf8'));
|
|
84
|
+
},
|
|
85
|
+
getCurrentDirectory: () => root,
|
|
86
|
+
getCompilationSettings: () => options,
|
|
87
|
+
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
|
|
88
|
+
fileExists: (name) => shims.has(name) || ts.sys.fileExists(name),
|
|
89
|
+
readFile: (name) => (shims.has(name) ? shims.get(name).code : ts.sys.readFile(name)),
|
|
90
|
+
readDirectory: ts.sys.readDirectory,
|
|
91
|
+
directoryExists: ts.sys.directoryExists,
|
|
92
|
+
getDirectories: ts.sys.getDirectories,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
96
|
+
|
|
97
|
+
const install = (file, built) => {
|
|
98
|
+
const name = shimPath(file);
|
|
99
|
+
shims.set(name, built);
|
|
100
|
+
versions.set(name, (versions.get(name) ?? 0) + 1);
|
|
101
|
+
return built;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const sourceOf = (file) => overlays.get(file) ?? fs.readFileSync(file, 'utf8');
|
|
105
|
+
|
|
106
|
+
/** The type of one of a shim's marker exports. What tsc made of the file. */
|
|
107
|
+
const exportTypeOf = (file, name) => {
|
|
108
|
+
const program = service.getProgram();
|
|
109
|
+
const source = program?.getSourceFile(shimPath(file));
|
|
110
|
+
if (!source) return 'unknown';
|
|
111
|
+
|
|
112
|
+
const checker = program.getTypeChecker();
|
|
113
|
+
const moduleSymbol = checker.getSymbolAtLocation(source);
|
|
114
|
+
const data =
|
|
115
|
+
moduleSymbol &&
|
|
116
|
+
checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.getName() === name);
|
|
117
|
+
if (!data) return 'unknown';
|
|
118
|
+
|
|
119
|
+
const type = checker.getTypeOfSymbolAtLocation(data, data.valueDeclaration ?? source);
|
|
120
|
+
const text = checker.typeToString(type, undefined, TYPE_FORMAT);
|
|
121
|
+
return text === 'any' ? 'unknown' : text;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const dataTypeOf = (file) => exportTypeOf(file, '__data');
|
|
125
|
+
const propTypeOf = (file) => exportTypeOf(file, '__propTypes');
|
|
126
|
+
const memberTypeOf = (file) => exportTypeOf(file, '__members');
|
|
127
|
+
const stateTypeOf = (file) => exportTypeOf(file, '__state');
|
|
128
|
+
|
|
129
|
+
// ---- the project, in the only order that resolves ------------------------
|
|
130
|
+
|
|
131
|
+
const elementFiles = (dir) =>
|
|
132
|
+
readDirSafe(path.resolve(app, dir))
|
|
133
|
+
.filter((entry) => entry.endsWith('.html') && entry.slice(0, -5).includes('-'))
|
|
134
|
+
.map((entry) => path.resolve(app, dir, entry));
|
|
135
|
+
|
|
136
|
+
const componentFiles = () => elementFiles(elementsDir);
|
|
137
|
+
|
|
138
|
+
// The file decides whether the element has a shadow root, so it also decides
|
|
139
|
+
// what `this.shadowRoot` means inside its <script>. Read with the compiler's
|
|
140
|
+
// own reader, or the types would describe a different element than the one
|
|
141
|
+
// that ships.
|
|
142
|
+
const isShadow = (file) => Boolean(safeFlags(file).shadow);
|
|
143
|
+
|
|
144
|
+
const safeFlags = (file) => {
|
|
145
|
+
try {
|
|
146
|
+
return readFlags(fs.readFileSync(file, 'utf8'), path.basename(file));
|
|
147
|
+
} catch {
|
|
148
|
+
// A file that will not parse is reported by the checker itself. Guessing
|
|
149
|
+
// light here only decides which shim shape it gets while that is fixed.
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const layoutFiles = (dir = resolveRoutesDir(app, routesDir), out = new Map()) => {
|
|
155
|
+
if (!fs.existsSync(dir)) return out;
|
|
156
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
157
|
+
const full = path.join(dir, entry.name);
|
|
158
|
+
if (entry.isDirectory()) layoutFiles(full, out);
|
|
159
|
+
else if (entry.name === LAYOUT_FILE) {
|
|
160
|
+
const relative = path.relative(resolveRoutesDir(app, routesDir), dir);
|
|
161
|
+
out.set(relative ? relative.split(path.sep).join('-') : 'root', full);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const ancestorsOf = (layoutId, layouts) => {
|
|
168
|
+
const parts = layoutId === 'root' ? [] : layoutId.split('-');
|
|
169
|
+
const chain = [];
|
|
170
|
+
for (let i = 0; i <= parts.length; i++) {
|
|
171
|
+
const id = i === 0 ? 'root' : parts.slice(0, i).join('-');
|
|
172
|
+
if (layouts.has(id) && id !== layoutId) chain.push(id);
|
|
173
|
+
}
|
|
174
|
+
return chain;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const chainFor = (rel, layouts) => {
|
|
178
|
+
const dirs = path.dirname(rel).split(path.sep).filter((d) => d && d !== '.');
|
|
179
|
+
const chain = [];
|
|
180
|
+
for (let i = 0; i <= dirs.length; i++) {
|
|
181
|
+
const id = i === 0 ? 'root' : dirs.slice(0, i).join('-');
|
|
182
|
+
if (layouts.has(id)) chain.push(id);
|
|
183
|
+
}
|
|
184
|
+
return chain;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Later wins, which is what a nearer layout should do. */
|
|
188
|
+
const mergeTypes = (types) =>
|
|
189
|
+
types
|
|
190
|
+
.filter((type) => type && type !== 'unknown')
|
|
191
|
+
.reduce(
|
|
192
|
+
(left, right) => (left === '{}' ? right : `Omit<${left}, keyof ${right}> & ${right}`),
|
|
193
|
+
'{}',
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// `request` is the platform's `Request`, not a router's wrapper. Reading a form
|
|
197
|
+
// is `await request.formData()` and nothing to look up. It is null while
|
|
198
|
+
// prerendering, where there is no request to read.
|
|
199
|
+
//
|
|
200
|
+
// `action` is whatever the page's handler for this method returned, so a POST
|
|
201
|
+
// and a GET render through the same loader.
|
|
202
|
+
//
|
|
203
|
+
// `response` is the part of the answer that is not markup. Change it, since the
|
|
204
|
+
// object is shared with every loader in the chain and with the server, or return
|
|
205
|
+
// a `Response` to answer the request yourself and skip rendering.
|
|
206
|
+
//
|
|
207
|
+
// `cookies` reads the request and writes into that same envelope. `__Cookies`
|
|
208
|
+
// is defined by the shim, which is the only thing that reads this string.
|
|
209
|
+
/**
|
|
210
|
+
* What an endpoint's handler is handed. The same route context a loader gets,
|
|
211
|
+
* without the two parts that only exist because a page renders: `layout` is
|
|
212
|
+
* added by the chain walk, and `request` is never null because prerendering
|
|
213
|
+
* never runs an endpoint.
|
|
214
|
+
*/
|
|
215
|
+
const endpointLiteral = (params) =>
|
|
216
|
+
`{ url: string; params: { ${params.map((name) => `${name}: string`).join('; ')} }; ` +
|
|
217
|
+
`route: { id: string; pattern: string; path: string }; ` +
|
|
218
|
+
`request: Request; fragment: string | null; ` +
|
|
219
|
+
`response: { status: number; headers: Headers }; cookies: __Cookies; ` +
|
|
220
|
+
`absolute: (path: string) => string; revalidateTag: (tag: string) => void }`;
|
|
221
|
+
|
|
222
|
+
const contextLiteral = (params, layoutType) =>
|
|
223
|
+
`{ url: string; params: { ${params.map((name) => `${name}: string`).join('; ')} }; ` +
|
|
224
|
+
`route: { id: string; pattern: string; path: string }; ` +
|
|
225
|
+
`layout: ${layoutType}; request: Request | null; fragment: string | null; ` +
|
|
226
|
+
`action: unknown; response: { status: number; headers: Headers }; ` +
|
|
227
|
+
`cookies: __Cookies; htmlAttrs: Record<string, string | boolean | null>; ` +
|
|
228
|
+
`absolute: (path: string) => string; revalidateTag: (tag: string) => void }`;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Builds every shim in dependency order: components depend on nothing, a
|
|
232
|
+
* layout on the layouts above it, a page on its whole chain. Each step asks
|
|
233
|
+
* tsc what the previous one produced.
|
|
234
|
+
*/
|
|
235
|
+
const build = () => {
|
|
236
|
+
// Light and shadow elements take props the same way, so they are checked
|
|
237
|
+
// the same way and their prop types reach whatever renders them.
|
|
238
|
+
const componentProps = new Map();
|
|
239
|
+
const componentMembers = new Map();
|
|
240
|
+
const files = componentFiles();
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
install(file, buildShim(sourceOf(file), { kind: 'component', shadow: isShadow(file) }));
|
|
243
|
+
}
|
|
244
|
+
for (const file of files) {
|
|
245
|
+
const tag = path.basename(file, '.html');
|
|
246
|
+
const blocks = splitBlocks(sourceOf(file));
|
|
247
|
+
componentProps.set(tag, propTypeOf(file));
|
|
248
|
+
// An element with neither block registers nothing, so it has no accessors
|
|
249
|
+
// and no members. Saying otherwise in transclude-env.d.ts would be a claim
|
|
250
|
+
// the browser does not back up.
|
|
251
|
+
// Members live in the client block now, so the shim is what knows whether
|
|
252
|
+
// there are any. An empty `__Members` means the block exported no
|
|
253
|
+
// `prototype`, and having a client block at all is reason enough to upgrade.
|
|
254
|
+
const members = memberTypeOf(file);
|
|
255
|
+
componentMembers.set(tag, {
|
|
256
|
+
members: members && members !== '{}' ? members : null,
|
|
257
|
+
state: blocks.state ? stateTypeOf(file) : null,
|
|
258
|
+
upgrades: Boolean(blocks.state || blocks.client.length),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const layouts = layoutFiles();
|
|
263
|
+
const layoutData = new Map();
|
|
264
|
+
|
|
265
|
+
for (const [id, file] of [...layouts].sort((a, b) => depthOf(a[0]) - depthOf(b[0]))) {
|
|
266
|
+
const above = mergeTypes(ancestorsOf(id, layouts).map((ancestor) => layoutData.get(ancestor)));
|
|
267
|
+
install(
|
|
268
|
+
file,
|
|
269
|
+
buildShim(sourceOf(file), {
|
|
270
|
+
kind: 'layout',
|
|
271
|
+
contextType: contextLiteral([], above),
|
|
272
|
+
componentProps,
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
layoutData.set(id, dataTypeOf(file));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const { routes, endpoints, notFound } = scanRoutes(resolveRoutesDir(app, routesDir));
|
|
279
|
+
|
|
280
|
+
for (const route of endpoints) {
|
|
281
|
+
install(route.file, buildEndpointShim(sourceOf(route.file), {
|
|
282
|
+
contextType: endpointLiteral(route.params),
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const pages = new Map();
|
|
287
|
+
for (const route of [...routes, notFound].filter(Boolean)) {
|
|
288
|
+
const above = mergeTypes(chainFor(route.rel, layouts).map((id) => layoutData.get(id)));
|
|
289
|
+
const context = contextLiteral(route.params, above);
|
|
290
|
+
install(
|
|
291
|
+
route.file,
|
|
292
|
+
buildShim(sourceOf(route.file), { kind: 'page', contextType: context, componentProps }),
|
|
293
|
+
);
|
|
294
|
+
pages.set(route.id, { route, context });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { componentProps, componentMembers, layouts, layoutData, pages };
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
let project = build();
|
|
301
|
+
|
|
302
|
+
const endpointFor = (file) => {
|
|
303
|
+
const { endpoints } = scanRoutes(resolveRoutesDir(app, routesDir));
|
|
304
|
+
return endpoints.find((route) => route.file === file) ?? null;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const contextFor = (file) => {
|
|
308
|
+
if (path.basename(file) === LAYOUT_FILE) {
|
|
309
|
+
const relative = path.relative(resolveRoutesDir(app, routesDir), path.dirname(file));
|
|
310
|
+
const id = relative ? relative.split(path.sep).join('-') : 'root';
|
|
311
|
+
return contextLiteral(
|
|
312
|
+
[],
|
|
313
|
+
mergeTypes(ancestorsOf(id, project.layouts).map((a) => project.layoutData.get(a))),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
for (const { route, context } of project.pages.values()) {
|
|
317
|
+
if (route.file === file) return context;
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const kindOf = (file) => {
|
|
323
|
+
if (file.startsWith(path.resolve(app, elementsDir))) return 'component';
|
|
324
|
+
return path.basename(file) === LAYOUT_FILE ? 'layout' : 'page';
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const refresh = (file) => {
|
|
328
|
+
if (file.endsWith('.js')) {
|
|
329
|
+
const route = endpointFor(file);
|
|
330
|
+
return install(file, buildEndpointShim(sourceOf(file), {
|
|
331
|
+
contextType: endpointLiteral(route?.params ?? []),
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
const kind = kindOf(file);
|
|
335
|
+
return install(
|
|
336
|
+
file,
|
|
337
|
+
buildShim(sourceOf(file), {
|
|
338
|
+
kind,
|
|
339
|
+
shadow: isShadow(file),
|
|
340
|
+
contextType: kind === 'component' ? null : contextFor(file),
|
|
341
|
+
componentProps: project.componentProps,
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
files() {
|
|
348
|
+
const found = componentFiles();
|
|
349
|
+
const dir = resolveRoutesDir(app, routesDir);
|
|
350
|
+
walkHtml(dir, found);
|
|
351
|
+
for (const route of scanRoutes(dir).endpoints) found.push(route.file);
|
|
352
|
+
return found;
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/** Replaces a file's contents without touching disk, for an editor buffer. */
|
|
356
|
+
update(file, source) {
|
|
357
|
+
overlays.set(file, source);
|
|
358
|
+
refresh(file);
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
/** Re-derives every type. Needed after a file is added, renamed or removed. */
|
|
362
|
+
rebuild() {
|
|
363
|
+
project = build();
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
check(file) {
|
|
367
|
+
const shim = refresh(file);
|
|
368
|
+
const name = shimPath(file);
|
|
369
|
+
|
|
370
|
+
// A file that does not parse gets one diagnostic, not a cascade of type
|
|
371
|
+
// errors derived from the half of it that survived.
|
|
372
|
+
if (shim.syntaxErrors?.length) {
|
|
373
|
+
return shim.syntaxErrors.map((error) => ({
|
|
374
|
+
file,
|
|
375
|
+
offset: error.offset,
|
|
376
|
+
length: 1,
|
|
377
|
+
code: 1005,
|
|
378
|
+
message: error.message,
|
|
379
|
+
severity: 'error',
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const out = [];
|
|
384
|
+
for (const diagnostic of [
|
|
385
|
+
...service.getSyntacticDiagnostics(name),
|
|
386
|
+
...service.getSemanticDiagnostics(name),
|
|
387
|
+
]) {
|
|
388
|
+
const offset = originalOffset(shim.chunks, diagnostic.start ?? 0);
|
|
389
|
+
// A diagnostic with no home is one about generated scaffolding. Dropping
|
|
390
|
+
// it is right, but it means anything that can carry a diagnostic has to be
|
|
391
|
+
// mapped, or it disappears without a word.
|
|
392
|
+
if (offset === null) continue;
|
|
393
|
+
|
|
394
|
+
out.push({
|
|
395
|
+
file,
|
|
396
|
+
offset,
|
|
397
|
+
length: diagnostic.length ?? 1,
|
|
398
|
+
code: diagnostic.code,
|
|
399
|
+
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '),
|
|
400
|
+
severity: diagnostic.category === ts.DiagnosticCategory.Error ? 'error' : 'warning',
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
/** Type of the expression at a source offset, for editor hovers. */
|
|
407
|
+
quickInfo(file, offset) {
|
|
408
|
+
const shim = shims.get(shimPath(file)) ?? refresh(file);
|
|
409
|
+
const target = shim.chunks.find(
|
|
410
|
+
(chunk) =>
|
|
411
|
+
chunk.source !== null &&
|
|
412
|
+
offset >= chunk.source &&
|
|
413
|
+
offset < chunk.source + chunk.text.length,
|
|
414
|
+
);
|
|
415
|
+
if (!target) return null;
|
|
416
|
+
|
|
417
|
+
const info = service.getQuickInfoAtPosition(
|
|
418
|
+
shimPath(file),
|
|
419
|
+
target.start + (offset - target.source),
|
|
420
|
+
);
|
|
421
|
+
if (!info) return null;
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
text: ts.displayPartsToString(info.displayParts),
|
|
425
|
+
documentation: ts.displayPartsToString(info.documentation ?? []),
|
|
426
|
+
};
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
/** Everything transclude-env.d.ts is written from. */
|
|
430
|
+
describe() {
|
|
431
|
+
// Light elements are described separately: they have props but no shadow
|
|
432
|
+
// root, so nothing about `this.shadowRoot` applies to them.
|
|
433
|
+
const partialTags = new Set(
|
|
434
|
+
componentFiles()
|
|
435
|
+
.filter((file) => !isShadow(file))
|
|
436
|
+
.map((file) => path.basename(file, '.html')),
|
|
437
|
+
);
|
|
438
|
+
return {
|
|
439
|
+
components: [...project.componentProps]
|
|
440
|
+
.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) })),
|
|
445
|
+
layouts: [...project.layoutData].map(([id, type]) => ({
|
|
446
|
+
id,
|
|
447
|
+
type,
|
|
448
|
+
context: contextFor(project.layouts.get(id)),
|
|
449
|
+
})),
|
|
450
|
+
pages: [...project.pages].map(([id, { route, context }]) => ({
|
|
451
|
+
id,
|
|
452
|
+
params: route.params,
|
|
453
|
+
pattern: route.pattern,
|
|
454
|
+
context,
|
|
455
|
+
type: dataTypeOf(route.file),
|
|
456
|
+
})),
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Line and column for an offset, for anything that reports to a human.
|
|
464
|
+
*
|
|
465
|
+
* @param {string} source
|
|
466
|
+
* @param {number} offset
|
|
467
|
+
* @returns {{ line: number, column: number }} both 1-based, for a message a reader can follow
|
|
468
|
+
*/
|
|
469
|
+
export function positionAt(source, offset) {
|
|
470
|
+
const before = source.slice(0, offset);
|
|
471
|
+
const line = before.split('\n').length;
|
|
472
|
+
const column = offset - (before.lastIndexOf('\n') + 1);
|
|
473
|
+
return { line, column };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function depthOf(layoutId) {
|
|
477
|
+
return layoutId === 'root' ? 0 : layoutId.split('-').length;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function readDirSafe(dir) {
|
|
481
|
+
return fs.existsSync(dir) ? fs.readdirSync(dir) : [];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function walkHtml(dir, out) {
|
|
485
|
+
if (!fs.existsSync(dir)) return out;
|
|
486
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
487
|
+
const full = path.join(dir, entry.name);
|
|
488
|
+
if (entry.isDirectory()) walkHtml(full, out);
|
|
489
|
+
else if (entry.name.endsWith('.html')) out.push(full);
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The half of a worker entry that is not about one app.
|
|
2
|
+
//
|
|
3
|
+
// A worker has no filesystem, so its bytes are imported from a module the build
|
|
4
|
+
// emitted rather than read from a disk, and it has no `node:crypto`, so hashing
|
|
5
|
+
// is WebCrypto and therefore async. Those are runtime facts, so they live here.
|
|
6
|
+
// Which modules to import is an app fact, so that stays in the app's own entry.
|
|
7
|
+
|
|
8
|
+
/** base64 in, bytes out. `atob` is in every runtime that has no `Buffer`. */
|
|
9
|
+
const decode = (base64) => Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A synchronous ETag for bytes that are fixed at build time. Hashing them with
|
|
13
|
+
* WebCrypto would make building the provider async for nothing. Content length
|
|
14
|
+
* plus an FNV-1a pass is a cache key, not a signature.
|
|
15
|
+
*/
|
|
16
|
+
function etagOf(bytes) {
|
|
17
|
+
let hash = 0x811c9dc5;
|
|
18
|
+
for (const byte of bytes) {
|
|
19
|
+
hash ^= byte;
|
|
20
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
21
|
+
}
|
|
22
|
+
return `"${bytes.length.toString(36)}-${hash.toString(36)}"`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The same entry shape the Node server builds from a disk. `encodings` is empty
|
|
27
|
+
* because nothing here is precompressed, and the code that reads it already
|
|
28
|
+
* treats an empty map as "identity is all there is".
|
|
29
|
+
*
|
|
30
|
+
* @param {Record<string, { body: string, type: string }>} map base64 from the build
|
|
31
|
+
* @returns {{ get: (pathname: string) => object|null }} the same entries with
|
|
32
|
+
* real bytes, behind the lookup `createApp` uses
|
|
33
|
+
*/
|
|
34
|
+
export function bytesFrom(map) {
|
|
35
|
+
const built = new Map();
|
|
36
|
+
for (const [url, { type, body }] of Object.entries(map)) {
|
|
37
|
+
const bytes = decode(body);
|
|
38
|
+
built.set(url, { body: bytes, type, etag: etagOf(bytes), encodings: new Map() });
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
get(pathname) {
|
|
42
|
+
// Trailing slashes are the same resource, as they are on the Node side.
|
|
43
|
+
return built.get(pathname.replace(/\/+$/, '') || '/') ?? null;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Public files, as a handler rather than a directory. No byte ranges, because
|
|
50
|
+
* those need a filesystem and this runtime has none.
|
|
51
|
+
*
|
|
52
|
+
* @param {Record<string, object>} map
|
|
53
|
+
* @returns {Function} a Hono handler
|
|
54
|
+
*/
|
|
55
|
+
export function fileHandler(map) {
|
|
56
|
+
const files = bytesFrom(map);
|
|
57
|
+
return async (c, next) => {
|
|
58
|
+
const hit = files.get(c.req.path);
|
|
59
|
+
if (!hit) return next();
|
|
60
|
+
c.header('Content-Type', hit.type);
|
|
61
|
+
c.header('ETag', hit.etag);
|
|
62
|
+
return c.body(hit.body);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A prerendered error page, which is sent as bytes and never revalidated.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ body: string, type: string }|null|undefined} page base64 from the build
|
|
70
|
+
* @returns {object|null} the entry shape `send` expects
|
|
71
|
+
*/
|
|
72
|
+
export const pageEntry = (page) =>
|
|
73
|
+
page && { body: decode(page.body), type: page.type, etag: '"error"', encodings: new Map() };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Rendered responses get the real thing, which on this runtime is async.
|
|
77
|
+
*
|
|
78
|
+
* @param {Uint8Array} body
|
|
79
|
+
* @returns {Promise<string>} a quoted ETag
|
|
80
|
+
*/
|
|
81
|
+
export async function hash(body) {
|
|
82
|
+
const digest = await crypto.subtle.digest('SHA-1', body);
|
|
83
|
+
const bytes = new Uint8Array(digest);
|
|
84
|
+
let base64 = '';
|
|
85
|
+
for (const byte of bytes) base64 += String.fromCharCode(byte);
|
|
86
|
+
return `"${btoa(base64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '').slice(0, 20)}"`;
|
|
87
|
+
}
|