@backtickjs/compiler 0.0.0-stage → 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 +25 -2
- package/dist/CodeInformation.d.ts +8 -0
- package/dist/CodeInformation.js +2 -0
- package/dist/CodeInformation.js.map +1 -0
- package/dist/SourceRange.d.ts +4 -0
- package/dist/SourceRange.js +2 -0
- package/dist/SourceRange.js.map +1 -0
- package/dist/buildMappings.d.ts +10 -0
- package/dist/buildMappings.js +21 -0
- package/dist/buildMappings.js.map +1 -0
- package/dist/compileModule.d.ts +25 -0
- package/dist/compileModule.js +78 -0
- package/dist/compileModule.js.map +1 -0
- package/dist/compileScript.d.ts +24 -0
- package/dist/compileScript.js +66 -0
- package/dist/compileScript.js.map +1 -0
- package/dist/diagnostics.d.ts +8 -0
- package/dist/diagnostics.js +2 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/emitScript.d.ts +52 -0
- package/dist/emitScript.js +201 -0
- package/dist/emitScript.js.map +1 -0
- package/dist/emitScripts.d.ts +10 -0
- package/dist/emitScripts.js +23 -0
- package/dist/emitScripts.js.map +1 -0
- package/dist/flattenScripts.d.ts +2 -0
- package/dist/flattenScripts.js +10 -0
- package/dist/flattenScripts.js.map +1 -0
- package/dist/hashText.d.ts +1 -0
- package/dist/hashText.js +18 -0
- package/dist/hashText.js.map +1 -0
- package/dist/hostElements.d.ts +3 -0
- package/dist/hostElements.js +28 -0
- package/dist/hostElements.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/isComponentTag.d.ts +1 -0
- package/dist/isComponentTag.js +15 -0
- package/dist/isComponentTag.js.map +1 -0
- package/dist/nodeFactory.d.ts +3 -0
- package/dist/nodeFactory.js +5 -0
- package/dist/nodeFactory.js.map +1 -0
- package/dist/parseFile.d.ts +34 -0
- package/dist/parseFile.js +200 -0
- package/dist/parseFile.js.map +1 -0
- package/dist/printVirtualCode.d.ts +9 -0
- package/dist/printVirtualCode.js +52 -0
- package/dist/printVirtualCode.js.map +1 -0
- package/dist/resolveBindings.d.ts +76 -0
- package/dist/resolveBindings.js +240 -0
- package/dist/resolveBindings.js.map +1 -0
- package/dist/rewriteFile.d.ts +12 -0
- package/dist/rewriteFile.js +22 -0
- package/dist/rewriteFile.js.map +1 -0
- package/dist/rewriteScript.d.ts +27 -0
- package/dist/rewriteScript.js +268 -0
- package/dist/rewriteScript.js.map +1 -0
- package/dist/segmentsToString.d.ts +9 -0
- package/dist/segmentsToString.js +4 -0
- package/dist/segmentsToString.js.map +1 -0
- package/dist/tagRoot.d.ts +2 -0
- package/dist/tagRoot.js +10 -0
- package/dist/tagRoot.js.map +1 -0
- package/dist/transform.d.ts +17 -0
- package/dist/transform.js +61 -0
- package/dist/transform.js.map +1 -0
- package/dist/transpile.d.ts +27 -0
- package/dist/transpile.js +41 -0
- package/dist/transpile.js.map +1 -0
- package/dist/unmangle.d.ts +2 -0
- package/dist/unmangle.js +9 -0
- package/dist/unmangle.js.map +1 -0
- package/dist/virtualScript.d.ts +22 -0
- package/dist/virtualScript.js +305 -0
- package/dist/virtualScript.js.map +1 -0
- package/dist/virtualize.d.ts +8 -0
- package/dist/virtualize.js +10 -0
- package/dist/virtualize.js.map +1 -0
- package/package.json +58 -3
- package/src/CodeInformation.ts +14 -0
- package/src/SourceRange.ts +4 -0
- package/src/buildMappings.ts +31 -0
- package/src/compileModule.ts +132 -0
- package/src/compileScript.ts +97 -0
- package/src/diagnostics.ts +9 -0
- package/src/emitScript.ts +297 -0
- package/src/emitScripts.ts +39 -0
- package/src/flattenScripts.ts +13 -0
- package/src/hashText.ts +17 -0
- package/src/hostElements.ts +34 -0
- package/src/index.ts +20 -0
- package/src/isComponentTag.ts +15 -0
- package/src/nodeFactory.ts +18 -0
- package/src/parseFile.ts +331 -0
- package/src/printVirtualCode.ts +93 -0
- package/src/resolveBindings.ts +417 -0
- package/src/rewriteFile.ts +52 -0
- package/src/rewriteScript.ts +413 -0
- package/src/segmentsToString.ts +16 -0
- package/src/tagRoot.ts +14 -0
- package/src/transform.ts +96 -0
- package/src/transpile.ts +49 -0
- package/src/unmangle.ts +10 -0
- package/src/virtualScript.ts +355 -0
- package/src/virtualize.ts +29 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CodeInformation } from "./CodeInformation.js";
|
|
2
|
+
import type { Segment } from "./segmentsToString.js";
|
|
3
|
+
|
|
4
|
+
export interface SourceMapping {
|
|
5
|
+
sourceOffsets: number[];
|
|
6
|
+
generatedOffsets: number[];
|
|
7
|
+
lengths: number[];
|
|
8
|
+
generatedLengths: number[];
|
|
9
|
+
data?: CodeInformation;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildMappings(segments: Segment[]): SourceMapping[] {
|
|
13
|
+
const mappings: SourceMapping[] = [];
|
|
14
|
+
let generatedOffset = 0;
|
|
15
|
+
for (const segment of segments) {
|
|
16
|
+
if (typeof segment === "string") {
|
|
17
|
+
generatedOffset += segment.length;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const [text, , sourceOffset, sourceLength, data] = segment;
|
|
21
|
+
mappings.push({
|
|
22
|
+
sourceOffsets: [sourceOffset],
|
|
23
|
+
generatedOffsets: [generatedOffset],
|
|
24
|
+
lengths: [sourceLength],
|
|
25
|
+
generatedLengths: [text.length],
|
|
26
|
+
...(data && { data }),
|
|
27
|
+
});
|
|
28
|
+
generatedOffset += text.length;
|
|
29
|
+
}
|
|
30
|
+
return mappings;
|
|
31
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type ts from "typescript";
|
|
2
|
+
import type { Plugin } from "./compileScript.js";
|
|
3
|
+
import { transform } from "./transform.js";
|
|
4
|
+
|
|
5
|
+
// A loader's compile of one file, as a runtime runs it: Node's, Bun's. The
|
|
6
|
+
// loader reads the file and loads what comes back; this does the rest, the
|
|
7
|
+
// same way for every runtime.
|
|
8
|
+
|
|
9
|
+
/** A compile step's module, as Babel's presets are: its default export makes the step. */
|
|
10
|
+
interface PluginModule {
|
|
11
|
+
default: () => Plugin;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The framework's compile steps a project names in its `package.json`, made
|
|
16
|
+
* with the project's own `require`:
|
|
17
|
+
*
|
|
18
|
+
* ```json
|
|
19
|
+
* "backtick": { "plugins": ["@backtickjs/react/plugin"] }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function pluginsFrom(
|
|
23
|
+
packageJson: string,
|
|
24
|
+
require: (specifier: string) => unknown,
|
|
25
|
+
): Plugin[] {
|
|
26
|
+
const { backtick } = JSON.parse(packageJson) as {
|
|
27
|
+
backtick?: { plugins?: readonly string[] };
|
|
28
|
+
};
|
|
29
|
+
return (backtick?.plugins ?? []).map((specifier) =>
|
|
30
|
+
(require(specifier) as PluginModule).default(),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CompileModuleOptions {
|
|
35
|
+
/** The framework's compile steps, as `pluginsFrom` makes them. */
|
|
36
|
+
readonly plugins: readonly Plugin[];
|
|
37
|
+
/** Transforms run after Backtick's, as a runtime needs its own. */
|
|
38
|
+
readonly before?: readonly ts.TransformerFactory<ts.SourceFile>[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A TypeScript file, its `cs` scripts included, as an ES module with its
|
|
43
|
+
* source map inline, compiled with the `tsconfig.json` nearest it, as `tsc`
|
|
44
|
+
* and editors find one, so an app and its server can each have their own.
|
|
45
|
+
* Throws the compiler's errors: a refused script is emitted with `null` where
|
|
46
|
+
* its code was, so it must not load.
|
|
47
|
+
*/
|
|
48
|
+
export function compileModule(
|
|
49
|
+
ts: typeof import("typescript"),
|
|
50
|
+
fileName: string,
|
|
51
|
+
source: string,
|
|
52
|
+
{ plugins, before = [] }: CompileModuleOptions,
|
|
53
|
+
): string {
|
|
54
|
+
const diagnostics: ts.Diagnostic[] = [];
|
|
55
|
+
const { outputText } = ts.transpileModule(source, {
|
|
56
|
+
fileName,
|
|
57
|
+
compilerOptions: compilerOptionsFor(ts, directoryOf(fileName)),
|
|
58
|
+
transformers: {
|
|
59
|
+
before: [
|
|
60
|
+
transform(ts, (diagnostic) => diagnostics.push(diagnostic), {
|
|
61
|
+
plugins,
|
|
62
|
+
}),
|
|
63
|
+
...before,
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const errors = diagnostics.filter(
|
|
69
|
+
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
|
|
70
|
+
);
|
|
71
|
+
if (errors.length > 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
ts.formatDiagnostics(errors, {
|
|
74
|
+
getCanonicalFileName: (name) => name,
|
|
75
|
+
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
|
|
76
|
+
getNewLine: () => "\n",
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return outputText;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Read once per config, however many files share it.
|
|
84
|
+
const optionsByConfig = new Map<string, ts.CompilerOptions>();
|
|
85
|
+
|
|
86
|
+
function compilerOptionsFor(
|
|
87
|
+
ts: typeof import("typescript"),
|
|
88
|
+
directory: string,
|
|
89
|
+
): ts.CompilerOptions {
|
|
90
|
+
const configPath =
|
|
91
|
+
ts.findConfigFile(directory, ts.sys.fileExists, "tsconfig.json") ?? "";
|
|
92
|
+
let options = optionsByConfig.get(configPath);
|
|
93
|
+
if (options === undefined) {
|
|
94
|
+
let fromConfig: ts.CompilerOptions = {};
|
|
95
|
+
if (configPath !== "") {
|
|
96
|
+
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
97
|
+
// Parsed whole, so `extends` is followed, as `tsc` follows it.
|
|
98
|
+
fromConfig = ts.parseJsonConfigFileContent(
|
|
99
|
+
config,
|
|
100
|
+
ts.sys,
|
|
101
|
+
directoryOf(configPath),
|
|
102
|
+
).options;
|
|
103
|
+
}
|
|
104
|
+
// Where `tsc` would write its output says nothing about a file compiled
|
|
105
|
+
// where it loads, but a source map written with it names the file relative
|
|
106
|
+
// to that output, and a runtime resolves the name relative to the file.
|
|
107
|
+
const {
|
|
108
|
+
outDir: _outDir,
|
|
109
|
+
outFile: _outFile,
|
|
110
|
+
rootDir: _rootDir,
|
|
111
|
+
sourceRoot: _sourceRoot,
|
|
112
|
+
mapRoot: _mapRoot,
|
|
113
|
+
...compiling
|
|
114
|
+
} = fromConfig;
|
|
115
|
+
options = {
|
|
116
|
+
...compiling,
|
|
117
|
+
module: ts.ModuleKind.ESNext,
|
|
118
|
+
target: ts.ScriptTarget.ESNext,
|
|
119
|
+
sourceMap: false,
|
|
120
|
+
inlineSourceMap: true,
|
|
121
|
+
inlineSources: true,
|
|
122
|
+
};
|
|
123
|
+
optionsByConfig.set(configPath, options);
|
|
124
|
+
}
|
|
125
|
+
return options;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A path's directory, by `/` or `\`, without Node's `path`.
|
|
129
|
+
function directoryOf(path: string): string {
|
|
130
|
+
const end = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
131
|
+
return end === -1 ? "." : path.slice(0, end);
|
|
132
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import remapping from "@jridgewell/remapping";
|
|
2
|
+
import type ts from "typescript";
|
|
3
|
+
import type { EmittedScript } from "./emitScript.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A framework's compile step: a module in, the module it compiles to out, with
|
|
7
|
+
* its map into the module it was given. Solid's adapter's `solid()` is one.
|
|
8
|
+
* Synchronous, as a TypeScript transformer is.
|
|
9
|
+
*/
|
|
10
|
+
export type Plugin = (
|
|
11
|
+
code: string,
|
|
12
|
+
id: string,
|
|
13
|
+
) => { code: string; map: string };
|
|
14
|
+
|
|
15
|
+
/** A script as a build writes it, a module table's entry. */
|
|
16
|
+
export interface CompiledScript extends EmittedScript {
|
|
17
|
+
/** The modules the code requires, by specifier, as Metro records them. */
|
|
18
|
+
readonly dependencies: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A script as a build writes it: the module whose default export it is,
|
|
23
|
+
* through each plugin in turn, then a module table's entry,
|
|
24
|
+
* `(module, exports, require) => { … }`, as webpack and Metro write one: its
|
|
25
|
+
* body the module as CommonJS, JSX kept where no plugin compiled it, the
|
|
26
|
+
* modules it requires its dependencies, its map chained onto the script's own
|
|
27
|
+
* and naming the host file `sourceName`.
|
|
28
|
+
*/
|
|
29
|
+
export function compileScript(
|
|
30
|
+
ts: typeof import("typescript"),
|
|
31
|
+
script: EmittedScript,
|
|
32
|
+
sourceName: string,
|
|
33
|
+
plugins: readonly Plugin[],
|
|
34
|
+
): CompiledScript {
|
|
35
|
+
let code = `export default (\n${script.code}\n);`;
|
|
36
|
+
const maps = [down(renamed(script.map, sourceName))];
|
|
37
|
+
for (const plugin of plugins) {
|
|
38
|
+
const compiled = plugin(code, `${sourceName}.jsx`);
|
|
39
|
+
code = compiled.code;
|
|
40
|
+
maps.unshift(compiled.map);
|
|
41
|
+
}
|
|
42
|
+
// The module as CommonJS, by TypeScript: its imports `require` calls, its
|
|
43
|
+
// default export `exports.default`, so it runs as a function's body.
|
|
44
|
+
const dependencies: string[] = [];
|
|
45
|
+
const output = ts.transpileModule(code, {
|
|
46
|
+
fileName: "module.jsx",
|
|
47
|
+
compilerOptions: {
|
|
48
|
+
jsx: ts.JsxEmit.Preserve,
|
|
49
|
+
module: ts.ModuleKind.CommonJS,
|
|
50
|
+
target: ts.ScriptTarget.ESNext,
|
|
51
|
+
sourceMap: true,
|
|
52
|
+
},
|
|
53
|
+
transformers: { before: [specifiers(ts, dependencies)] },
|
|
54
|
+
});
|
|
55
|
+
maps.unshift(output.sourceMapText!);
|
|
56
|
+
const body = output.outputText.replace(/\n\/\/# sourceMappingURL=.*$/, "");
|
|
57
|
+
// Chained as an array, not by source name: Babel names a map's source by
|
|
58
|
+
// the id's basename.
|
|
59
|
+
const map = remapping(maps, () => null, { excludeContent: true }).toString();
|
|
60
|
+
return {
|
|
61
|
+
code: `(module, exports, require) => {\n${body}\n}`,
|
|
62
|
+
map: down(map),
|
|
63
|
+
dependencies,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// What a module imports, by specifier, once each, as TypeScript reads it.
|
|
68
|
+
function specifiers(
|
|
69
|
+
ts: typeof import("typescript"),
|
|
70
|
+
into: string[],
|
|
71
|
+
): ts.TransformerFactory<ts.SourceFile> {
|
|
72
|
+
return () => (sourceFile) => {
|
|
73
|
+
for (const statement of sourceFile.statements) {
|
|
74
|
+
if (
|
|
75
|
+
ts.isImportDeclaration(statement) &&
|
|
76
|
+
ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
77
|
+
!into.includes(statement.moduleSpecifier.text)
|
|
78
|
+
) {
|
|
79
|
+
into.push(statement.moduleSpecifier.text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return sourceFile;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A script's map with its one source, the host file, named `sourceName`.
|
|
87
|
+
function renamed(map: string, sourceName: string): string {
|
|
88
|
+
const parsed = JSON.parse(map) as object;
|
|
89
|
+
return JSON.stringify({ ...parsed, sources: [sourceName] });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A map whose code moved down a line, as code does that a wrapper starts on a
|
|
93
|
+
// line of its own.
|
|
94
|
+
function down(map: string): string {
|
|
95
|
+
const parsed = JSON.parse(map) as { mappings: string };
|
|
96
|
+
return JSON.stringify({ ...parsed, mappings: `;${parsed.mappings}` });
|
|
97
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { addMapping, GenMapping, toEncodedMap } from "@jridgewell/gen-mapping";
|
|
2
|
+
import { eachMapping, TraceMap } from "@jridgewell/trace-mapping";
|
|
3
|
+
import type ts from "typescript";
|
|
4
|
+
import type { ClientScript } from "./parseFile.js";
|
|
5
|
+
import type { BindingResolution, ResolvedParam } from "./resolveBindings.js";
|
|
6
|
+
import { tagRoot } from "./tagRoot.js";
|
|
7
|
+
|
|
8
|
+
/** A script as the client runs it, and where its code came from. */
|
|
9
|
+
export interface EmittedScript {
|
|
10
|
+
/**
|
|
11
|
+
* The script as an expression, `($splice0, …) => body`, a parameter per
|
|
12
|
+
* entry of a script's `params` (see `paramName`). Types are gone and
|
|
13
|
+
* everything else, JSX included, is as the script wrote it, for the
|
|
14
|
+
* framework's own compiler to read when a bundle is built. How it is
|
|
15
|
+
* delivered is the bundle's to write.
|
|
16
|
+
*/
|
|
17
|
+
readonly code: string;
|
|
18
|
+
/**
|
|
19
|
+
* The code's source map, into the host file. It carries no
|
|
20
|
+
* `sourcesContent`: the host file is the server's, and a map is shipped.
|
|
21
|
+
*/
|
|
22
|
+
readonly map: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a name the script wrote becomes: one of its entry's parameters, called
|
|
27
|
+
* with the bindings a hole hands over where it has `args`, read as it is where
|
|
28
|
+
* not.
|
|
29
|
+
*/
|
|
30
|
+
export interface Edit {
|
|
31
|
+
readonly param: string;
|
|
32
|
+
readonly args?: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A script parameter's name: its kind and its index in a script's `params`,
|
|
37
|
+
* `$splice0`, `$capture1`. A script cannot bind a name starting with
|
|
38
|
+
* `$`, so none meets one of its own.
|
|
39
|
+
*/
|
|
40
|
+
export function paramName(param: ResolvedParam, index: number): string {
|
|
41
|
+
return `$${param.kind}${index}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A binding key as `resolveBindings` writes it, `<name>$<fileHash>$<n>`, back
|
|
45
|
+
// to the name the script wrote.
|
|
46
|
+
function sourceName(key: string): string {
|
|
47
|
+
return key.replace(/\$[0-9a-z]+\$\d+$/, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The edits a script's text needs, by the host-file offset of what each
|
|
52
|
+
* replaces: every splice it reads, host tags among them, and every binding it
|
|
53
|
+
* captures. What is a reference, and each parameter's number, was decided by
|
|
54
|
+
* `resolveBindings`.
|
|
55
|
+
*/
|
|
56
|
+
export function scriptEdits(
|
|
57
|
+
script: ClientScript,
|
|
58
|
+
bindings: BindingResolution,
|
|
59
|
+
params: readonly ResolvedParam[],
|
|
60
|
+
): Map<number, Edit> {
|
|
61
|
+
const captureParam = new Map(
|
|
62
|
+
params.flatMap((param, index) =>
|
|
63
|
+
param.kind === "capture" ? [[param.key, index] as const] : [],
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
const captures = [...captureParam.keys()];
|
|
67
|
+
const read = (key: string): string => {
|
|
68
|
+
const index = captureParam.get(key);
|
|
69
|
+
return index === undefined ? sourceName(key) : `$capture${index}`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const edits = new Map<number, Edit>();
|
|
73
|
+
const at = (identifier: ts.Identifier) =>
|
|
74
|
+
script.toSourceRange(identifier).start;
|
|
75
|
+
params.forEach((param, index) => {
|
|
76
|
+
if (param.kind === "capture") {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const edit: Edit = {
|
|
80
|
+
param: paramName(param, index),
|
|
81
|
+
args: [...param.bindings, ...captures].map(read),
|
|
82
|
+
};
|
|
83
|
+
for (const ref of param.refs) {
|
|
84
|
+
edits.set(at(ref), edit);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
for (const [identifier, key] of bindings) {
|
|
88
|
+
const index = captureParam.get(key);
|
|
89
|
+
if (
|
|
90
|
+
index !== undefined &&
|
|
91
|
+
identifier.getSourceFile() === script.fileWithPlaceholders
|
|
92
|
+
) {
|
|
93
|
+
edits.set(at(identifier), { param: `$capture${index}` });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return edits;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Emits a script's code by compiling its own text with TypeScript, with
|
|
101
|
+
* `edits` applied.
|
|
102
|
+
*
|
|
103
|
+
* TypeScript is handed the script's text alone, as `parseFile` read it: its
|
|
104
|
+
* template's escapes decoded, every `${…}` hole its placeholder. An edit is
|
|
105
|
+
* found where its name stands in the host file, and the source map is moved
|
|
106
|
+
* there, through the script's own offsets. The script alone, not the host file
|
|
107
|
+
* blanked around it: that would make each script cost what the whole file
|
|
108
|
+
* does.
|
|
109
|
+
*/
|
|
110
|
+
export function emitScript(
|
|
111
|
+
ts: typeof import("typescript"),
|
|
112
|
+
script: ClientScript,
|
|
113
|
+
params: readonly string[],
|
|
114
|
+
edits: ReadonlyMap<number, Edit>,
|
|
115
|
+
): EmittedScript {
|
|
116
|
+
const sourceFile = script.sourceFile;
|
|
117
|
+
|
|
118
|
+
const transformer: ts.TransformerFactory<ts.SourceFile> =
|
|
119
|
+
(context) => (file) => {
|
|
120
|
+
const f = context.factory;
|
|
121
|
+
const editAt = (node: ts.Node) =>
|
|
122
|
+
edits.get(script.toSourceOffset(node.getStart(file)));
|
|
123
|
+
const edited = (edit: Edit, from: ts.Node): ts.Expression => {
|
|
124
|
+
const param = f.createIdentifier(edit.param);
|
|
125
|
+
const node =
|
|
126
|
+
edit.args === undefined
|
|
127
|
+
? param
|
|
128
|
+
: f.createCallExpression(
|
|
129
|
+
param,
|
|
130
|
+
undefined,
|
|
131
|
+
edit.args.map((arg) => f.createIdentifier(arg)),
|
|
132
|
+
);
|
|
133
|
+
return ts.setOriginalNode(ts.setTextRange(node, from), from);
|
|
134
|
+
};
|
|
135
|
+
// A tag's names where the tag reads a splice: they stay as written, the
|
|
136
|
+
// parameter of the arrow below.
|
|
137
|
+
const asWritten = new Set<ts.Node>();
|
|
138
|
+
// A tag that names a splice, `<$Card>`: JSX can't call where a tag goes,
|
|
139
|
+
// so the element is the body of an arrow its tag is a parameter of, called
|
|
140
|
+
// with the splice where the element stands, as any splice is read:
|
|
141
|
+
// `(($Card) => <$Card …/>)($splice0())`. A capture is its value, read as
|
|
142
|
+
// a tag as it is anywhere.
|
|
143
|
+
const hostTag = (
|
|
144
|
+
node: ts.JsxElement | ts.JsxSelfClosingElement,
|
|
145
|
+
): ts.Node | undefined => {
|
|
146
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
147
|
+
const root = tagRoot(ts, opening.tagName);
|
|
148
|
+
const edit = root && editAt(root);
|
|
149
|
+
if (root === undefined || edit?.args === undefined) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
asWritten.add(opening.tagName);
|
|
153
|
+
if (ts.isJsxElement(node)) {
|
|
154
|
+
asWritten.add(node.closingElement.tagName);
|
|
155
|
+
}
|
|
156
|
+
const call = ts.setTextRange(
|
|
157
|
+
f.createCallExpression(
|
|
158
|
+
f.createParenthesizedExpression(
|
|
159
|
+
f.createArrowFunction(
|
|
160
|
+
undefined,
|
|
161
|
+
undefined,
|
|
162
|
+
[f.createParameterDeclaration(undefined, undefined, root.text)],
|
|
163
|
+
undefined,
|
|
164
|
+
f.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
165
|
+
ts.visitEachChild(node, visit, context),
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
undefined,
|
|
169
|
+
[edited(edit, root)],
|
|
170
|
+
),
|
|
171
|
+
node,
|
|
172
|
+
);
|
|
173
|
+
// Among an element's children, an expression is written in braces.
|
|
174
|
+
return ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent)
|
|
175
|
+
? f.createJsxExpression(undefined, call)
|
|
176
|
+
: call;
|
|
177
|
+
};
|
|
178
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
179
|
+
// Types are TypeScript's to strip, after this.
|
|
180
|
+
if (ts.isTypeNode(node) || asWritten.has(node)) {
|
|
181
|
+
return node;
|
|
182
|
+
}
|
|
183
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
184
|
+
const tagged = hostTag(node);
|
|
185
|
+
if (tagged !== undefined) {
|
|
186
|
+
return tagged;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (ts.isIdentifier(node)) {
|
|
190
|
+
const edit = editAt(node);
|
|
191
|
+
return edit === undefined ? node : edited(edit, node);
|
|
192
|
+
}
|
|
193
|
+
// `{ count }` names a key as well as a value.
|
|
194
|
+
if (ts.isShorthandPropertyAssignment(node)) {
|
|
195
|
+
const edit = editAt(node.name);
|
|
196
|
+
if (edit !== undefined) {
|
|
197
|
+
return f.createPropertyAssignment(
|
|
198
|
+
node.name.text,
|
|
199
|
+
edited(edit, node.name),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return ts.visitEachChild(node, visit, context);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const [statement] = file.statements;
|
|
207
|
+
if (statement === undefined) {
|
|
208
|
+
return file;
|
|
209
|
+
}
|
|
210
|
+
// Where the script stands, so the entry maps to it.
|
|
211
|
+
const entry = ts.setTextRange(
|
|
212
|
+
f.createArrowFunction(
|
|
213
|
+
undefined,
|
|
214
|
+
undefined,
|
|
215
|
+
params.map((name) =>
|
|
216
|
+
f.createParameterDeclaration(undefined, undefined, name),
|
|
217
|
+
),
|
|
218
|
+
undefined,
|
|
219
|
+
f.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
220
|
+
ts.isExpressionStatement(statement)
|
|
221
|
+
? ts.visitNode(statement.expression, visit, ts.isExpression)
|
|
222
|
+
: ts.visitNode(statement, visit, ts.isBlock),
|
|
223
|
+
),
|
|
224
|
+
statement,
|
|
225
|
+
);
|
|
226
|
+
return f.updateSourceFile(file, [
|
|
227
|
+
ts.setOriginalNode(f.createExpressionStatement(entry), statement),
|
|
228
|
+
]);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const output = ts.transpileModule(script.textWithPlaceholders, {
|
|
232
|
+
fileName: sourceFile.fileName,
|
|
233
|
+
compilerOptions: {
|
|
234
|
+
target: ts.ScriptTarget.ESNext,
|
|
235
|
+
module: ts.ModuleKind.ESNext,
|
|
236
|
+
jsx: ts.JsxEmit.Preserve,
|
|
237
|
+
sourceMap: true,
|
|
238
|
+
alwaysStrict: false,
|
|
239
|
+
removeComments: true,
|
|
240
|
+
},
|
|
241
|
+
transformers: { before: [transformer] },
|
|
242
|
+
});
|
|
243
|
+
// The file's one expression statement, without its `;` or the comment
|
|
244
|
+
// naming a map file.
|
|
245
|
+
const code = output.outputText
|
|
246
|
+
.replace(/\n\/\/# sourceMappingURL=.*$/, "")
|
|
247
|
+
.replace(/;\s*$/, "");
|
|
248
|
+
return {
|
|
249
|
+
code,
|
|
250
|
+
map: moved(output.sourceMapText!, script),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A map into the script's text, moved to where each position stands in the
|
|
255
|
+
// host file. Named as the host file was, not relative to an output file there
|
|
256
|
+
// is none of, so maps from files in different directories can be combined.
|
|
257
|
+
function moved(map: string, script: ClientScript): string {
|
|
258
|
+
const sourceFile = script.sourceFile;
|
|
259
|
+
const lineStarts = [0];
|
|
260
|
+
for (const line of script.textWithPlaceholders.matchAll(/\r\n|\n|\r/g)) {
|
|
261
|
+
lineStarts.push(line.index + line[0].length);
|
|
262
|
+
}
|
|
263
|
+
// Lines are 1-based here, columns 0-based.
|
|
264
|
+
const original = (line: number, column: number) => {
|
|
265
|
+
const at = sourceFile.getLineAndCharacterOfPosition(
|
|
266
|
+
script.toSourceOffset(lineStarts[line - 1]! + column),
|
|
267
|
+
);
|
|
268
|
+
return { line: at.line + 1, column: at.character };
|
|
269
|
+
};
|
|
270
|
+
const into = new GenMapping({ file: sourceFile.fileName });
|
|
271
|
+
eachMapping(new TraceMap(map), (mapping) => {
|
|
272
|
+
if (mapping.originalLine === null) {
|
|
273
|
+
addMapping(into, {
|
|
274
|
+
generated: {
|
|
275
|
+
line: mapping.generatedLine,
|
|
276
|
+
column: mapping.generatedColumn,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// TypeScript writes no names, so there are none to carry.
|
|
282
|
+
addMapping(into, {
|
|
283
|
+
generated: {
|
|
284
|
+
line: mapping.generatedLine,
|
|
285
|
+
column: mapping.generatedColumn,
|
|
286
|
+
},
|
|
287
|
+
source: sourceFile.fileName,
|
|
288
|
+
original: original(mapping.originalLine, mapping.originalColumn),
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
// TypeScript's own map, with only where it points changed.
|
|
292
|
+
return JSON.stringify({
|
|
293
|
+
...(JSON.parse(map) as object),
|
|
294
|
+
sources: [sourceFile.fileName],
|
|
295
|
+
mappings: toEncodedMap(into).mappings,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CompiledScript,
|
|
3
|
+
compileScript,
|
|
4
|
+
type Plugin,
|
|
5
|
+
} from "./compileScript.js";
|
|
6
|
+
import { parseSourceText } from "./parseFile.js";
|
|
7
|
+
import { rewriteFile } from "./rewriteFile.js";
|
|
8
|
+
|
|
9
|
+
/** A script's emitted code, by where the host wrote it. */
|
|
10
|
+
export interface EmittedScriptAt extends CompiledScript {
|
|
11
|
+
// the offset of its `cs` in the host file
|
|
12
|
+
readonly start: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What each of a file's scripts compiles to for the client, in source order:
|
|
17
|
+
* nested scripts too, each as its own entry.
|
|
18
|
+
*/
|
|
19
|
+
export function emitScripts(
|
|
20
|
+
ts: typeof import("typescript"),
|
|
21
|
+
fileName: string,
|
|
22
|
+
sourceText: string,
|
|
23
|
+
// Its framework's compile steps (see `TransformOptions.plugins`).
|
|
24
|
+
plugins: readonly Plugin[] = [],
|
|
25
|
+
): EmittedScriptAt[] {
|
|
26
|
+
const file = rewriteFile(ts, parseSourceText(ts, fileName, sourceText));
|
|
27
|
+
return Array.from(file.scripts)
|
|
28
|
+
.flatMap(([template, { runtime }]) =>
|
|
29
|
+
runtime === null
|
|
30
|
+
? []
|
|
31
|
+
: [
|
|
32
|
+
{
|
|
33
|
+
start: template.getStart(file.sourceFile),
|
|
34
|
+
...compileScript(ts, runtime.emitted, fileName, plugins),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
.sort((a, b) => a.start - b.start);
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ClientScript } from "./parseFile.js";
|
|
2
|
+
|
|
3
|
+
// Yields every client script in the file, descending through splices
|
|
4
|
+
export function* flattenScripts(
|
|
5
|
+
scripts: ClientScript[],
|
|
6
|
+
): Generator<ClientScript> {
|
|
7
|
+
for (const script of scripts) {
|
|
8
|
+
yield script;
|
|
9
|
+
for (const splice of Object.values(script.splices)) {
|
|
10
|
+
yield* flattenScripts(splice.scripts);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/hashText.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// 64-bit FNV-1a over the file's text: the per-file content hash that makes
|
|
2
|
+
// compiler output globally distinguishable. The compiler runs one file at a
|
|
3
|
+
// time and cannot hand out coordinated identifiers, and a file's path alone
|
|
4
|
+
// can recur across codebases (two packages each compiled against their own
|
|
5
|
+
// root), so both binding keys and script locations carry this hash. At 64
|
|
6
|
+
// bits an accidental collision between different files is out of reach
|
|
7
|
+
// (the birthday bound sits around four billion files), so a collision
|
|
8
|
+
// implies identical file content, where sharing is correct rather than a
|
|
9
|
+
// mixup.
|
|
10
|
+
export function hashText(input: string): string {
|
|
11
|
+
let hash = 0xcbf29ce484222325n;
|
|
12
|
+
for (let i = 0; i < input.length; i++) {
|
|
13
|
+
hash ^= BigInt(input.charCodeAt(i));
|
|
14
|
+
hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn;
|
|
15
|
+
}
|
|
16
|
+
return hash.toString(36);
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type ts from "typescript";
|
|
2
|
+
import type { Diagnostic } from "./diagnostics.js";
|
|
3
|
+
import { isComponentTag } from "./isComponentTag.js";
|
|
4
|
+
|
|
5
|
+
// Each element written in host code: an intrinsic element is drawn by the
|
|
6
|
+
// client, so it belongs in a script. Every JSX node TypeScript parsed is the
|
|
7
|
+
// host's, a splice's included, as a script's JSX is text in its template.
|
|
8
|
+
export function hostElementDiagnostics(
|
|
9
|
+
ts: typeof import("typescript"),
|
|
10
|
+
sourceFile: ts.SourceFile,
|
|
11
|
+
): Diagnostic[] {
|
|
12
|
+
const diagnostics: Diagnostic[] = [];
|
|
13
|
+
const visit = (node: ts.Node): void => {
|
|
14
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
15
|
+
const { tagName } = node;
|
|
16
|
+
const name = tagName.getText(sourceFile);
|
|
17
|
+
// A member read (`ui.button`) is a value, whatever its case.
|
|
18
|
+
const named = ts.isIdentifier(tagName) || ts.isJsxNamespacedName(tagName);
|
|
19
|
+
if (named && !isComponentTag(name)) {
|
|
20
|
+
diagnostics.push({
|
|
21
|
+
range: { start: tagName.getStart(sourceFile), end: tagName.getEnd() },
|
|
22
|
+
message:
|
|
23
|
+
`\`<${name}>\` is drawn by the client, so it belongs in a ` +
|
|
24
|
+
`script: cs\`<${name}>…</${name}>\`.`,
|
|
25
|
+
category: ts.DiagnosticCategory.Error,
|
|
26
|
+
code: 0,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
ts.forEachChild(node, visit);
|
|
31
|
+
};
|
|
32
|
+
visit(sourceFile);
|
|
33
|
+
return diagnostics;
|
|
34
|
+
}
|