workstar-compiler 0.1.0 → 0.2.0-beta.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/README.md +12 -1
- package/bin/workstar-compile.mjs +15 -1
- package/dist/src/compat-rules.d.ts +2 -0
- package/dist/src/compat-rules.js +4 -0
- package/dist/src/compat.d.ts +5 -0
- package/dist/src/compat.js +12 -0
- package/dist/src/component-script.d.ts +11 -3
- package/dist/src/component-script.js +93 -24
- package/dist/src/control-elements.d.ts +5 -0
- package/dist/src/control-elements.js +55 -20
- package/dist/src/errors.d.ts +9 -1
- package/dist/src/errors.js +6 -2
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +114 -40
- package/dist/src/project.d.ts +15 -1
- package/dist/src/project.js +56 -4
- package/dist/src/react-compat.d.ts +6 -0
- package/dist/src/react-compat.js +369 -0
- package/dist/src/react-entry-compat.d.ts +2 -0
- package/dist/src/react-entry-compat.js +105 -0
- package/dist/src/react-import-resolution.d.ts +2 -0
- package/dist/src/react-import-resolution.js +15 -0
- package/dist/src/source-map.d.ts +7 -0
- package/dist/src/source-map.js +50 -0
- package/dist/src/source-origin.d.ts +14 -0
- package/dist/src/source-origin.js +41 -0
- package/dist/src/styles.d.ts +5 -0
- package/dist/src/styles.js +41 -18
- package/dist/src/vite.d.ts +4 -0
- package/dist/src/vite.js +150 -13
- package/dist/src/vue-compat.d.ts +2 -0
- package/dist/src/vue-compat.js +168 -0
- package/dist/src/vue-script-compat.d.ts +8 -0
- package/dist/src/vue-script-compat.js +244 -0
- package/package.json +7 -2
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { pathExpression, reject } from './compat-rules.js';
|
|
3
|
+
const voidTags = new Set([
|
|
4
|
+
'area',
|
|
5
|
+
'base',
|
|
6
|
+
'br',
|
|
7
|
+
'col',
|
|
8
|
+
'embed',
|
|
9
|
+
'hr',
|
|
10
|
+
'img',
|
|
11
|
+
'input',
|
|
12
|
+
'link',
|
|
13
|
+
'meta',
|
|
14
|
+
'param',
|
|
15
|
+
'source',
|
|
16
|
+
'track',
|
|
17
|
+
'wbr',
|
|
18
|
+
]);
|
|
19
|
+
const nativeEvents = new Map([
|
|
20
|
+
['onBlur', 'blur'],
|
|
21
|
+
['onClick', 'click'],
|
|
22
|
+
['onDoubleClick', 'dblclick'],
|
|
23
|
+
['onFocus', 'focus'],
|
|
24
|
+
['onInput', 'input'],
|
|
25
|
+
['onKeyDown', 'keydown'],
|
|
26
|
+
['onKeyUp', 'keyup'],
|
|
27
|
+
['onMouseEnter', 'mouseenter'],
|
|
28
|
+
['onMouseLeave', 'mouseleave'],
|
|
29
|
+
['onPointerDown', 'pointerdown'],
|
|
30
|
+
['onPointerMove', 'pointermove'],
|
|
31
|
+
['onPointerUp', 'pointerup'],
|
|
32
|
+
['onSubmit', 'submit'],
|
|
33
|
+
['onTouchEnd', 'touchend'],
|
|
34
|
+
['onTouchMove', 'touchmove'],
|
|
35
|
+
['onTouchStart', 'touchstart'],
|
|
36
|
+
]);
|
|
37
|
+
function expression(node, file, filename, setup, defaults) {
|
|
38
|
+
const value = node.getText(file);
|
|
39
|
+
if (pathExpression.test(value) && defaults.length === 0)
|
|
40
|
+
return '{' + value + '}';
|
|
41
|
+
if (!isStatelessExpression(node))
|
|
42
|
+
reject(filename, 'expression ' + value);
|
|
43
|
+
const name = `__workstarExpression${setup.length}`;
|
|
44
|
+
const bindings = defaults.map((element) => element.getText(file)).join(', ');
|
|
45
|
+
const argumentsList = defaults
|
|
46
|
+
.map((element) => element.name.getText(file))
|
|
47
|
+
.join(', ');
|
|
48
|
+
const computed = defaults.length
|
|
49
|
+
? `((${bindings}) => (${value}))(${argumentsList})`
|
|
50
|
+
: value;
|
|
51
|
+
setup.push(`const ${name} = ${computed};`);
|
|
52
|
+
return '{' + name + '}';
|
|
53
|
+
}
|
|
54
|
+
function isStatelessExpression(node) {
|
|
55
|
+
if (ts.isIdentifier(node) ||
|
|
56
|
+
ts.isStringLiteral(node) ||
|
|
57
|
+
ts.isNumericLiteral(node) ||
|
|
58
|
+
node.kind === ts.SyntaxKind.TrueKeyword ||
|
|
59
|
+
node.kind === ts.SyntaxKind.FalseKeyword)
|
|
60
|
+
return true;
|
|
61
|
+
if (ts.isParenthesizedExpression(node))
|
|
62
|
+
return isStatelessExpression(node.expression);
|
|
63
|
+
if (ts.isPropertyAccessExpression(node))
|
|
64
|
+
return isStatelessExpression(node.expression);
|
|
65
|
+
if (ts.isTemplateExpression(node))
|
|
66
|
+
return node.templateSpans.every((span) => isStatelessExpression(span.expression));
|
|
67
|
+
if (ts.isNoSubstitutionTemplateLiteral(node))
|
|
68
|
+
return true;
|
|
69
|
+
if (ts.isBinaryExpression(node) &&
|
|
70
|
+
node.operatorToken.kind === ts.SyntaxKind.PlusToken)
|
|
71
|
+
return (isStatelessExpression(node.left) && isStatelessExpression(node.right));
|
|
72
|
+
return (ts.isCallExpression(node) &&
|
|
73
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
74
|
+
node.expression.name.text === 'trim' &&
|
|
75
|
+
node.arguments.length === 0 &&
|
|
76
|
+
isStatelessExpression(node.expression.expression));
|
|
77
|
+
}
|
|
78
|
+
function attributeName(original, filename) {
|
|
79
|
+
if (original === 'className')
|
|
80
|
+
return 'class';
|
|
81
|
+
if (original === 'htmlFor')
|
|
82
|
+
return 'for';
|
|
83
|
+
if (/^on[A-Z]/.test(original)) {
|
|
84
|
+
const event = nativeEvents.get(original);
|
|
85
|
+
if (!event)
|
|
86
|
+
reject(filename, 'event ' + original);
|
|
87
|
+
return 'on:' + event;
|
|
88
|
+
}
|
|
89
|
+
return original;
|
|
90
|
+
}
|
|
91
|
+
function attributes(input, file, filename, setup, defaults, component = false, restName) {
|
|
92
|
+
let output = '';
|
|
93
|
+
for (const attribute of input.properties) {
|
|
94
|
+
if (ts.isJsxSpreadAttribute(attribute)) {
|
|
95
|
+
if (component ||
|
|
96
|
+
!restName ||
|
|
97
|
+
!ts.isIdentifier(attribute.expression) ||
|
|
98
|
+
attribute.expression.text !== restName)
|
|
99
|
+
reject(filename, 'spread attribute');
|
|
100
|
+
output += ' bind:attrs={__workstarRest}';
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!ts.isIdentifier(attribute.name))
|
|
104
|
+
reject(filename, 'namespaced JSX attribute');
|
|
105
|
+
const original = attribute.name.text;
|
|
106
|
+
if (['style', 'ref', 'key', 'dangerouslySetInnerHTML'].includes(original) ||
|
|
107
|
+
(!component && original === 'onChange')) {
|
|
108
|
+
reject(filename, 'attribute ' + original);
|
|
109
|
+
}
|
|
110
|
+
const name = component ? original : attributeName(original, filename);
|
|
111
|
+
if (!/^[a-z][a-z0-9:._-]*$/i.test(name))
|
|
112
|
+
reject(filename, 'attribute ' + original);
|
|
113
|
+
if (component && !/^[A-Za-z_$][\w$]*$/.test(name))
|
|
114
|
+
reject(filename, 'component prop ' + original);
|
|
115
|
+
if (!attribute.initializer) {
|
|
116
|
+
output += ' ' + name + (component ? '' : '=""');
|
|
117
|
+
}
|
|
118
|
+
else if (ts.isStringLiteral(attribute.initializer)) {
|
|
119
|
+
output +=
|
|
120
|
+
' ' +
|
|
121
|
+
name +
|
|
122
|
+
'="' +
|
|
123
|
+
attribute.initializer.text
|
|
124
|
+
.replace(/&/g, '&')
|
|
125
|
+
.replace(/"/g, '"') +
|
|
126
|
+
'"';
|
|
127
|
+
}
|
|
128
|
+
else if (ts.isJsxExpression(attribute.initializer) &&
|
|
129
|
+
attribute.initializer.expression) {
|
|
130
|
+
output +=
|
|
131
|
+
' ' +
|
|
132
|
+
name +
|
|
133
|
+
'=' +
|
|
134
|
+
expression(attribute.initializer.expression, file, filename, setup, defaults);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
reject(filename, 'attribute ' + original);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return output;
|
|
141
|
+
}
|
|
142
|
+
function jsx(node, file, filename, setup, defaults, components, restName) {
|
|
143
|
+
if (ts.isJsxText(node)) {
|
|
144
|
+
const value = node.getText(file);
|
|
145
|
+
if (/[{}]/.test(value))
|
|
146
|
+
reject(filename, 'literal JSX braces');
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
if (ts.isJsxExpression(node)) {
|
|
150
|
+
if (!node.expression)
|
|
151
|
+
reject(filename, 'empty JSX expression');
|
|
152
|
+
return expression(node.expression, file, filename, setup, defaults);
|
|
153
|
+
}
|
|
154
|
+
if (ts.isJsxFragment(node))
|
|
155
|
+
return node.children
|
|
156
|
+
.map((child) => jsx(child, file, filename, setup, defaults, components, restName))
|
|
157
|
+
.join('');
|
|
158
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
159
|
+
const tag = opening.tagName.getText(file);
|
|
160
|
+
const component = components.has(tag);
|
|
161
|
+
if (!component && !/^[a-z][a-z0-9-]*$/.test(tag))
|
|
162
|
+
reject(filename, 'component or tag ' + tag);
|
|
163
|
+
const start = (component ? `<Use component={${tag}}` : `<${tag}`) +
|
|
164
|
+
attributes(opening.attributes, file, filename, setup, defaults, component, restName) +
|
|
165
|
+
'>';
|
|
166
|
+
if (voidTags.has(tag)) {
|
|
167
|
+
if (ts.isJsxElement(node))
|
|
168
|
+
reject(filename, 'children of ' + tag);
|
|
169
|
+
return start;
|
|
170
|
+
}
|
|
171
|
+
const children = ts.isJsxElement(node)
|
|
172
|
+
? node.children
|
|
173
|
+
.map((child) => jsx(child, file, filename, setup, defaults, components, restName))
|
|
174
|
+
.join('')
|
|
175
|
+
: '';
|
|
176
|
+
return start + children + (component ? '</Use>' : `</${tag}>`);
|
|
177
|
+
}
|
|
178
|
+
function exportedComponent(file, filename) {
|
|
179
|
+
const components = file.statements.filter((statement) => ts.isFunctionDeclaration(statement) &&
|
|
180
|
+
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true);
|
|
181
|
+
if (components.length !== 1 || !components[0]?.body)
|
|
182
|
+
reject(filename, 'one exported function component');
|
|
183
|
+
return components[0];
|
|
184
|
+
}
|
|
185
|
+
/** The source export used by a named TSX import; undefined means default export. */
|
|
186
|
+
export function reactComponentExportName(source, filename = 'Component.tsx') {
|
|
187
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
188
|
+
const component = exportedComponent(file, filename);
|
|
189
|
+
return component.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
|
|
190
|
+
? undefined
|
|
191
|
+
: component.name?.text;
|
|
192
|
+
}
|
|
193
|
+
/** Converts one typed, stateless exported function component; unsupported behavior fails closed. */
|
|
194
|
+
export function convertReactComponent(source, filename = 'Component.tsx', options = {}) {
|
|
195
|
+
const diagnostics = ts.transpileModule(source, {
|
|
196
|
+
fileName: filename,
|
|
197
|
+
reportDiagnostics: true,
|
|
198
|
+
compilerOptions: { jsx: ts.JsxEmit.Preserve },
|
|
199
|
+
}).diagnostics ?? [];
|
|
200
|
+
if (diagnostics.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)) {
|
|
201
|
+
reject(filename, 'invalid TSX syntax');
|
|
202
|
+
}
|
|
203
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
204
|
+
const component = exportedComponent(file, filename);
|
|
205
|
+
if (!component.body)
|
|
206
|
+
reject(filename, 'component body');
|
|
207
|
+
if (component.asteriskToken ||
|
|
208
|
+
component.modifiers?.some((modifier) => ![ts.SyntaxKind.ExportKeyword, ts.SyntaxKind.DefaultKeyword].includes(modifier.kind)))
|
|
209
|
+
reject(filename, 'async or generator component');
|
|
210
|
+
const declarations = file.statements.filter((statement) => statement !== component);
|
|
211
|
+
const imports = [];
|
|
212
|
+
const components = new Set();
|
|
213
|
+
const reactTypes = new Set();
|
|
214
|
+
for (const statement of declarations) {
|
|
215
|
+
if (!ts.isImportDeclaration(statement))
|
|
216
|
+
continue;
|
|
217
|
+
const original = ts.isStringLiteral(statement.moduleSpecifier)
|
|
218
|
+
? statement.moduleSpecifier.text
|
|
219
|
+
: '';
|
|
220
|
+
const clause = statement.importClause;
|
|
221
|
+
if (original === 'react' && clause?.isTypeOnly) {
|
|
222
|
+
if (!clause.namedBindings || !ts.isNamedImports(clause.namedBindings))
|
|
223
|
+
reject(filename, 'React type import');
|
|
224
|
+
for (const binding of clause.namedBindings.elements) {
|
|
225
|
+
reactTypes.add(binding.name.text);
|
|
226
|
+
}
|
|
227
|
+
imports.push(statement.getText(file));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const rewritten = options.resolveReactImport?.(original) ??
|
|
231
|
+
(original.endsWith('.tsx?workstar') ? original : undefined);
|
|
232
|
+
if (!rewritten || !clause || clause.isTypeOnly)
|
|
233
|
+
reject(filename, 'imports or module statements');
|
|
234
|
+
if (clause.name)
|
|
235
|
+
components.add(clause.name.text);
|
|
236
|
+
if (clause.namedBindings) {
|
|
237
|
+
if (!ts.isNamedImports(clause.namedBindings))
|
|
238
|
+
reject(filename, 'namespace component import');
|
|
239
|
+
for (const binding of clause.namedBindings.elements) {
|
|
240
|
+
if (!binding.isTypeOnly)
|
|
241
|
+
components.add(binding.name.text);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const text = statement.getText(file);
|
|
245
|
+
const start = statement.moduleSpecifier.getStart(file) - statement.getStart(file);
|
|
246
|
+
const end = statement.moduleSpecifier.getEnd() - statement.getStart(file);
|
|
247
|
+
imports.push(text.slice(0, start) + JSON.stringify(rewritten) + text.slice(end));
|
|
248
|
+
}
|
|
249
|
+
const typeDeclarations = declarations.filter((statement) => !ts.isImportDeclaration(statement));
|
|
250
|
+
if (typeDeclarations.some((statement) => !ts.isInterfaceDeclaration(statement) &&
|
|
251
|
+
!ts.isTypeAliasDeclaration(statement))) {
|
|
252
|
+
reject(filename, 'imports or module statements');
|
|
253
|
+
}
|
|
254
|
+
if (component.parameters.length > 1)
|
|
255
|
+
reject(filename, 'multiple component parameters');
|
|
256
|
+
const parameter = component.parameters[0];
|
|
257
|
+
let props = '';
|
|
258
|
+
const declaredProps = new Set();
|
|
259
|
+
if (parameter) {
|
|
260
|
+
if (!ts.isObjectBindingPattern(parameter.name) || !parameter.type)
|
|
261
|
+
reject(filename, 'typed destructured props');
|
|
262
|
+
const binding = parameter.name;
|
|
263
|
+
if (binding.elements.some((element) => !ts.isIdentifier(element.name) ||
|
|
264
|
+
Boolean(element.propertyName) ||
|
|
265
|
+
(Boolean(element.dotDotDotToken) &&
|
|
266
|
+
element !== binding.elements.at(-1)) ||
|
|
267
|
+
(element.initializer !== undefined &&
|
|
268
|
+
!isLiteralDefault(element.initializer))))
|
|
269
|
+
reject(filename, 'renamed, defaulted, or rest props');
|
|
270
|
+
if (ts.isTypeLiteralNode(parameter.type)) {
|
|
271
|
+
props = 'export type Props = ' + parameter.type.getText(file) + ';';
|
|
272
|
+
for (const member of parameter.type.members) {
|
|
273
|
+
if (ts.isPropertySignature(member) && ts.isIdentifier(member.name))
|
|
274
|
+
declaredProps.add(member.name.text);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else if (ts.isTypeReferenceNode(parameter.type) &&
|
|
278
|
+
ts.isIdentifier(parameter.type.typeName)) {
|
|
279
|
+
const typeName = parameter.type.typeName.text;
|
|
280
|
+
const declaration = typeDeclarations.find((statement) => (ts.isInterfaceDeclaration(statement) ||
|
|
281
|
+
ts.isTypeAliasDeclaration(statement)) &&
|
|
282
|
+
statement.name.text === typeName);
|
|
283
|
+
if (!declaration ||
|
|
284
|
+
(!ts.isInterfaceDeclaration(declaration) &&
|
|
285
|
+
!ts.isTypeAliasDeclaration(declaration)))
|
|
286
|
+
reject(filename, 'Props declaration');
|
|
287
|
+
if ((ts.isInterfaceDeclaration(declaration) &&
|
|
288
|
+
declaration.heritageClauses?.some((clause) => clause.types.some((type) => !ts.isIdentifier(type.expression) ||
|
|
289
|
+
!reactTypes.has(type.expression.text) ||
|
|
290
|
+
type.expression.text !== 'HTMLAttributes'))) ||
|
|
291
|
+
(ts.isTypeAliasDeclaration(declaration) &&
|
|
292
|
+
!ts.isTypeLiteralNode(declaration.type))) {
|
|
293
|
+
reject(filename, 'inherited or non-literal Props');
|
|
294
|
+
}
|
|
295
|
+
const members = ts.isInterfaceDeclaration(declaration)
|
|
296
|
+
? declaration.members
|
|
297
|
+
: ts.isTypeAliasDeclaration(declaration) &&
|
|
298
|
+
ts.isTypeLiteralNode(declaration.type)
|
|
299
|
+
? declaration.type.members
|
|
300
|
+
: [];
|
|
301
|
+
for (const member of members) {
|
|
302
|
+
if (ts.isPropertySignature(member) && ts.isIdentifier(member.name))
|
|
303
|
+
declaredProps.add(member.name.text);
|
|
304
|
+
}
|
|
305
|
+
const declarationText = declaration.getText(file);
|
|
306
|
+
const nameStart = declaration.name.getStart(file) - declaration.getStart(file);
|
|
307
|
+
const nameEnd = declaration.name.getEnd() - declaration.getStart(file);
|
|
308
|
+
props =
|
|
309
|
+
(declaration.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
|
310
|
+
? ''
|
|
311
|
+
: 'export ') +
|
|
312
|
+
declarationText.slice(0, nameStart) +
|
|
313
|
+
'Props' +
|
|
314
|
+
declarationText.slice(nameEnd);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
reject(filename, 'Props type');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
else if (typeDeclarations.length) {
|
|
321
|
+
reject(filename, 'unused module declarations');
|
|
322
|
+
}
|
|
323
|
+
const onlyStatement = component.body.statements[0];
|
|
324
|
+
if (component.body.statements.length !== 1 ||
|
|
325
|
+
!onlyStatement ||
|
|
326
|
+
!ts.isReturnStatement(onlyStatement) ||
|
|
327
|
+
!onlyStatement.expression)
|
|
328
|
+
reject(filename, 'component logic');
|
|
329
|
+
let view = onlyStatement.expression;
|
|
330
|
+
while (ts.isParenthesizedExpression(view))
|
|
331
|
+
view = view.expression;
|
|
332
|
+
if (!ts.isJsxElement(view) &&
|
|
333
|
+
!ts.isJsxSelfClosingElement(view) &&
|
|
334
|
+
!ts.isJsxFragment(view)) {
|
|
335
|
+
reject(filename, 'non-JSX return');
|
|
336
|
+
}
|
|
337
|
+
const setup = [];
|
|
338
|
+
const defaults = parameter && ts.isObjectBindingPattern(parameter.name)
|
|
339
|
+
? parameter.name.elements.filter((element) => element.initializer !== undefined)
|
|
340
|
+
: [];
|
|
341
|
+
const elements = parameter && ts.isObjectBindingPattern(parameter.name)
|
|
342
|
+
? parameter.name.elements
|
|
343
|
+
: [];
|
|
344
|
+
const rest = elements.find((element) => element.dotDotDotToken);
|
|
345
|
+
const restName = rest && ts.isIdentifier(rest.name) ? rest.name.text : undefined;
|
|
346
|
+
const additionalBindings = elements
|
|
347
|
+
.filter((element) => !element.dotDotDotToken &&
|
|
348
|
+
ts.isIdentifier(element.name) &&
|
|
349
|
+
!declaredProps.has(element.name.text))
|
|
350
|
+
.map((element) => element.name.getText(file));
|
|
351
|
+
if (additionalBindings.length)
|
|
352
|
+
setup.push(`const { ${additionalBindings.join(', ')} } = props;`);
|
|
353
|
+
if (restName) {
|
|
354
|
+
if (restName === '__workstarRest')
|
|
355
|
+
reject(filename, 'reserved rest prop name');
|
|
356
|
+
setup.push(`const __workstarRest = ((${parameter.name.getText(file)}: Props) => ${restName})(props);`);
|
|
357
|
+
}
|
|
358
|
+
const markup = jsx(view, file, filename, setup, defaults, components, restName);
|
|
359
|
+
const script = [...imports, props, ...setup].filter(Boolean).join('\n');
|
|
360
|
+
return ((script ? '<script lang="ts">\n' + script + '\n</script>\n' : '') +
|
|
361
|
+
markup +
|
|
362
|
+
'\n');
|
|
363
|
+
}
|
|
364
|
+
function isLiteralDefault(node) {
|
|
365
|
+
return (ts.isStringLiteral(node) ||
|
|
366
|
+
ts.isNumericLiteral(node) ||
|
|
367
|
+
node.kind === ts.SyntaxKind.TrueKeyword ||
|
|
368
|
+
node.kind === ts.SyntaxKind.FalseKeyword);
|
|
369
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { reject } from './compat-rules.js';
|
|
3
|
+
function importedNames(statement) {
|
|
4
|
+
if (statement.importClause?.isTypeOnly || statement.importClause?.name)
|
|
5
|
+
return [];
|
|
6
|
+
const bindings = statement.importClause?.namedBindings;
|
|
7
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
8
|
+
return [];
|
|
9
|
+
if (bindings.elements.some((element) => element.isTypeOnly || element.propertyName))
|
|
10
|
+
return [];
|
|
11
|
+
return bindings.elements.map((element) => element.name.text);
|
|
12
|
+
}
|
|
13
|
+
function containsUnsupportedEntrySyntax(node) {
|
|
14
|
+
if (ts.isJsxElement(node) ||
|
|
15
|
+
ts.isJsxSelfClosingElement(node) ||
|
|
16
|
+
ts.isJsxFragment(node) ||
|
|
17
|
+
(ts.isIdentifier(node) &&
|
|
18
|
+
(node.text === 'createRoot' || node.text === 'StrictMode')))
|
|
19
|
+
return true;
|
|
20
|
+
return ts.forEachChild(node, containsUnsupportedEntrySyntax) === true;
|
|
21
|
+
}
|
|
22
|
+
function singleComponent(node, strictMode, filename) {
|
|
23
|
+
let element = node;
|
|
24
|
+
if (ts.isJsxElement(element)) {
|
|
25
|
+
if (!strictMode ||
|
|
26
|
+
element.openingElement.tagName.getText() !== 'StrictMode' ||
|
|
27
|
+
element.closingElement.tagName.getText() !== 'StrictMode')
|
|
28
|
+
reject(filename, 'React root entry');
|
|
29
|
+
const children = element.children.filter((child) => !ts.isJsxText(child) || child.getText().trim() !== '');
|
|
30
|
+
if (children.length !== 1)
|
|
31
|
+
reject(filename, 'React root entry');
|
|
32
|
+
element = children[0];
|
|
33
|
+
}
|
|
34
|
+
if (!ts.isJsxSelfClosingElement(element) ||
|
|
35
|
+
element.attributes.properties.length !== 0 ||
|
|
36
|
+
!ts.isIdentifier(element.tagName))
|
|
37
|
+
reject(filename, 'React root entry');
|
|
38
|
+
return element.tagName.text;
|
|
39
|
+
}
|
|
40
|
+
/** Replace the conventional createRoot entry with a Workstar mount. */
|
|
41
|
+
export function convertReactRootEntry(source, filename) {
|
|
42
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
43
|
+
const imports = file.statements.filter(ts.isImportDeclaration);
|
|
44
|
+
const rootImport = imports.find((statement) => ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
45
|
+
statement.moduleSpecifier.text === 'react-dom/client');
|
|
46
|
+
if (!rootImport)
|
|
47
|
+
return undefined;
|
|
48
|
+
const reactImport = imports.find((statement) => ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
49
|
+
statement.moduleSpecifier.text === 'react');
|
|
50
|
+
const strictMode = reactImport !== undefined;
|
|
51
|
+
if (imports.filter((entry) => ts.isStringLiteral(entry.moduleSpecifier) &&
|
|
52
|
+
entry.moduleSpecifier.text === 'react-dom/client').length !== 1 ||
|
|
53
|
+
imports.filter((entry) => ts.isStringLiteral(entry.moduleSpecifier) &&
|
|
54
|
+
entry.moduleSpecifier.text === 'react').length > 1 ||
|
|
55
|
+
importedNames(rootImport).join(',') !== 'createRoot' ||
|
|
56
|
+
(reactImport && importedNames(reactImport).join(',') !== 'StrictMode'))
|
|
57
|
+
reject(filename, 'React root imports');
|
|
58
|
+
const statement = file.statements.at(-1);
|
|
59
|
+
if (!statement || !ts.isExpressionStatement(statement))
|
|
60
|
+
reject(filename, 'React root entry');
|
|
61
|
+
const renderCall = statement.expression;
|
|
62
|
+
if (!ts.isCallExpression(renderCall) ||
|
|
63
|
+
!ts.isPropertyAccessExpression(renderCall.expression) ||
|
|
64
|
+
renderCall.expression.name.text !== 'render' ||
|
|
65
|
+
renderCall.arguments.length !== 1)
|
|
66
|
+
reject(filename, 'React root entry');
|
|
67
|
+
const createCall = renderCall.expression.expression;
|
|
68
|
+
if (!ts.isCallExpression(createCall) ||
|
|
69
|
+
!ts.isIdentifier(createCall.expression) ||
|
|
70
|
+
createCall.expression.text !== 'createRoot' ||
|
|
71
|
+
createCall.arguments.length !== 1 ||
|
|
72
|
+
!ts.isIdentifier(createCall.arguments[0]))
|
|
73
|
+
reject(filename, 'React root entry');
|
|
74
|
+
const component = singleComponent(renderCall.arguments[0], strictMode, filename);
|
|
75
|
+
if (!imports.some((entry) => ts.isStringLiteral(entry.moduleSpecifier) &&
|
|
76
|
+
entry.moduleSpecifier.text.startsWith('.') &&
|
|
77
|
+
importedNames(entry).includes(component)))
|
|
78
|
+
reject(filename, 'local root component import');
|
|
79
|
+
if (file.statements.some((entry) => entry !== statement &&
|
|
80
|
+
!ts.isImportDeclaration(entry) &&
|
|
81
|
+
containsUnsupportedEntrySyntax(entry)))
|
|
82
|
+
reject(filename, 'extra React entry syntax');
|
|
83
|
+
const edits = [
|
|
84
|
+
{ start: rootImport.getStart(file), end: rootImport.getEnd(), text: '' },
|
|
85
|
+
...(reactImport
|
|
86
|
+
? [
|
|
87
|
+
{
|
|
88
|
+
start: reactImport.getStart(file),
|
|
89
|
+
end: reactImport.getEnd(),
|
|
90
|
+
text: '',
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
: []),
|
|
94
|
+
{
|
|
95
|
+
start: statement.getStart(file),
|
|
96
|
+
end: statement.getEnd(),
|
|
97
|
+
text: `mount(${createCall.arguments[0].getText(file)}, ${component}({}));`,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
let output = source;
|
|
101
|
+
for (const edit of edits.sort((left, right) => right.start - left.start)) {
|
|
102
|
+
output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
|
|
103
|
+
}
|
|
104
|
+
return `import { mount } from 'workstar';\n${output}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, resolve } from 'node:path';
|
|
3
|
+
/** Resolve a local TSX component to an explicit opt-in compatibility import. */
|
|
4
|
+
export function resolveReactComponentImport(filename, specifier) {
|
|
5
|
+
if (!specifier.startsWith('.'))
|
|
6
|
+
return undefined;
|
|
7
|
+
const candidate = specifier.endsWith('.tsx')
|
|
8
|
+
? specifier
|
|
9
|
+
: extname(specifier) === ''
|
|
10
|
+
? `${specifier}.tsx`
|
|
11
|
+
: undefined;
|
|
12
|
+
if (!candidate || !existsSync(resolve(dirname(filename), candidate)))
|
|
13
|
+
return undefined;
|
|
14
|
+
return `${candidate}?workstar`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type RawSourceMap } from 'source-map-js';
|
|
2
|
+
import type { SourceOrigin } from './source-origin.js';
|
|
3
|
+
/** Keep authored script locations while lowering generated TypeScript to Vite JavaScript. */
|
|
4
|
+
export declare function transpileComponent(generated: string, authored: string, filename: string, origins: readonly SourceOrigin[]): {
|
|
5
|
+
code: string;
|
|
6
|
+
map: RawSourceMap;
|
|
7
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { SourceMapConsumer, SourceMapGenerator, } from 'source-map-js';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
function positionAt(source, offset) {
|
|
5
|
+
const before = source.slice(0, offset);
|
|
6
|
+
const newline = before.lastIndexOf('\n');
|
|
7
|
+
return {
|
|
8
|
+
line: before.split('\n').length,
|
|
9
|
+
column: offset - newline - 1,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function componentSourceMap(authored, generated, sourceName, intermediateName, origins) {
|
|
13
|
+
const map = new SourceMapGenerator({ file: intermediateName });
|
|
14
|
+
for (const origin of origins) {
|
|
15
|
+
map.addMapping({
|
|
16
|
+
generated: positionAt(generated, origin.generatedOffset),
|
|
17
|
+
original: positionAt(authored, origin.authoredOffset),
|
|
18
|
+
source: sourceName,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
map.setSourceContent(sourceName, authored);
|
|
22
|
+
return map.toJSON();
|
|
23
|
+
}
|
|
24
|
+
/** Keep authored script locations while lowering generated TypeScript to Vite JavaScript. */
|
|
25
|
+
export function transpileComponent(generated, authored, filename, origins) {
|
|
26
|
+
const intermediateName = `${basename(filename)}.generated.ts`;
|
|
27
|
+
const output = ts.transpileModule(generated, {
|
|
28
|
+
fileName: intermediateName,
|
|
29
|
+
compilerOptions: {
|
|
30
|
+
module: ts.ModuleKind.ESNext,
|
|
31
|
+
target: ts.ScriptTarget.ES2022,
|
|
32
|
+
sourceMap: true,
|
|
33
|
+
inlineSources: true,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
if (!output.sourceMapText)
|
|
37
|
+
throw new Error('TypeScript did not emit a source map.');
|
|
38
|
+
const typescriptMap = JSON.parse(output.sourceMapText);
|
|
39
|
+
const intermediateSource = typescriptMap.sources[0];
|
|
40
|
+
if (!intermediateSource)
|
|
41
|
+
throw new Error('TypeScript emitted an empty source map.');
|
|
42
|
+
const authoredMap = componentSourceMap(authored, generated, filename, intermediateName, origins);
|
|
43
|
+
const composed = SourceMapGenerator.fromSourceMap(new SourceMapConsumer(typescriptMap));
|
|
44
|
+
composed.applySourceMap(new SourceMapConsumer(authoredMap), intermediateSource);
|
|
45
|
+
composed.setSourceContent(filename, authored);
|
|
46
|
+
return {
|
|
47
|
+
code: output.outputText.replace(/^\/\/# sourceMappingURL=.*\n?/m, ''),
|
|
48
|
+
map: composed.toJSON(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ScriptStatement } from './component-script.js';
|
|
2
|
+
export interface SourceOrigin {
|
|
3
|
+
readonly generatedOffset: number;
|
|
4
|
+
readonly authoredOffset: number;
|
|
5
|
+
}
|
|
6
|
+
/** Records provenance while assembling the generated TypeScript module. */
|
|
7
|
+
export declare class ComponentCode {
|
|
8
|
+
private readonly parts;
|
|
9
|
+
private length;
|
|
10
|
+
readonly origins: SourceOrigin[];
|
|
11
|
+
append(code: string, authoredOffset?: number, exact?: boolean): void;
|
|
12
|
+
appendStatements(statements: readonly ScriptStatement[], sourceOffset: (offset: number) => number): void;
|
|
13
|
+
toString(): string;
|
|
14
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Records provenance while assembling the generated TypeScript module. */
|
|
2
|
+
export class ComponentCode {
|
|
3
|
+
parts = [];
|
|
4
|
+
length = 0;
|
|
5
|
+
origins = [];
|
|
6
|
+
append(code, authoredOffset, exact = false) {
|
|
7
|
+
if (authoredOffset !== undefined) {
|
|
8
|
+
this.origins.push({
|
|
9
|
+
generatedOffset: this.length,
|
|
10
|
+
authoredOffset,
|
|
11
|
+
});
|
|
12
|
+
if (exact) {
|
|
13
|
+
for (const match of code.matchAll(/\n([^\n]*)/g)) {
|
|
14
|
+
const line = match[1] ?? '';
|
|
15
|
+
const firstToken = /\S/.exec(line)?.index;
|
|
16
|
+
if (firstToken === undefined)
|
|
17
|
+
continue;
|
|
18
|
+
const offset = match.index + 1 + firstToken;
|
|
19
|
+
this.origins.push({
|
|
20
|
+
generatedOffset: this.length + offset,
|
|
21
|
+
authoredOffset: authoredOffset + offset,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
this.parts.push(code);
|
|
27
|
+
this.length += code.length;
|
|
28
|
+
}
|
|
29
|
+
appendStatements(statements, sourceOffset) {
|
|
30
|
+
statements.forEach((statement, index) => {
|
|
31
|
+
if (index > 0)
|
|
32
|
+
this.append('\n');
|
|
33
|
+
this.append(statement.code, statement.sourceOffset === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: sourceOffset(statement.sourceOffset), statement.exact);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
toString() {
|
|
39
|
+
return this.parts.join('');
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/src/styles.d.ts
CHANGED
|
@@ -2,5 +2,10 @@ export interface CompiledStyle {
|
|
|
2
2
|
css: string;
|
|
3
3
|
scopeAttribute?: string;
|
|
4
4
|
}
|
|
5
|
+
export declare class StyleCompileError extends Error {
|
|
6
|
+
readonly line: number;
|
|
7
|
+
readonly column: number;
|
|
8
|
+
constructor(message: string, line: number, column: number);
|
|
9
|
+
}
|
|
5
10
|
/** Compile CSS at build time; styles never depend on client hydration. */
|
|
6
11
|
export declare function compileStyle(css: string, filename: string, global?: boolean): CompiledStyle;
|