workstar-compiler 0.1.1 → 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 CHANGED
@@ -10,7 +10,7 @@ The `workstar-compile` command compiles one component or a directory:
10
10
  workstar-compile --all src .workstar/generated --css .workstar/styles.css
11
11
  ```
12
12
 
13
- For Vite, import `workstar` from `workstar-compiler/vite` and add `workstar()` to `plugins`. Vite compiles imported components in memory; the CLI is for explicit output and type checking.
13
+ For Vite, import `workstar` from `workstar-compiler/vite` and add `workstar()` to `plugins`. Vite compiles explicit `?workstar` component imports in memory. Use `workstar({ foreign: 'automatic' })` to compile ordinary TSX/Vue imports under `src`, including a supported `createRoot(...).render(...)` entry. Use `workstar({ foreign: 'runtime' })` for unchanged React TSX applications that need Workstar-backed state and routing. The runtime mode is experimental and does not yet reproduce every React behavior. The CLI is for explicit output and type checking.
14
14
 
15
15
  During Vite development, the compiler adds state identities for direct local
16
16
  `signal()` declarations. Pass a stable `HotContext` from `workstar/dev` to a
@@ -21,6 +21,10 @@ individual markup expressions are not mapped yet.
21
21
 
22
22
  Inside `<script lang="ts">`, imports and an optional exported `Props` type define the component contract. Top-level `const`, `let`, and function declarations create state and behavior for each component instance; import `signal` from `workstar` for reactive local state. Keep module-wide shared state in an imported TypeScript module when sharing is intentional.
23
23
 
24
+ Use `bind:attrs={record}` on a native element to spread a plain record of checked HTML attributes. The record can be reactive. Event handlers, styles, refs, and unsafe URLs are rejected; bind events explicitly with `on:event`.
25
+
24
26
  `<noscript>` accepts static text only. Put links and localized expressions elsewhere in the page; nested markup would be parsed as raw text and dynamic markers cannot hydrate reliably.
25
27
 
26
28
  See the [Workstar repository](https://github.com/wslab-ai/workstar) for starters and current limitations.
29
+
30
+ For experimental React-style TSX and Vue SFC source conversion without their runtimes, see the [compatibility guide](https://github.com/wslab-ai/workstar/blob/main/docs/foreign-components.md). Use `?workstar` imports with the Vite plugin or `workstar-compile --compat` with any other build system.
@@ -2,8 +2,10 @@
2
2
  import { resolve } from 'node:path';
3
3
  import {
4
4
  compileViewFile,
5
+ compileForeignFile,
5
6
  compileViewDirectory,
6
7
  watchViewDirectory,
8
+ auditForeignDirectory,
7
9
  } from '../dist/src/project.js';
8
10
 
9
11
  const args = process.argv.slice(2);
@@ -13,7 +15,10 @@ try {
13
15
  (args.length === 4 && args[2] === '--css')
14
16
  ? { cssOutputPath: resolve(args.at(-1)) }
15
17
  : {};
16
- if (
18
+ if (args.length === 2 && args[0] === '--compat-audit') {
19
+ const report = await auditForeignDirectory(resolve(args[1]));
20
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
21
+ } else if (
17
22
  (args.length === 3 || (args.length === 5 && args[3] === '--css')) &&
18
23
  args[0] === '--all'
19
24
  ) {
@@ -35,6 +40,13 @@ try {
35
40
  process.stdout.write(
36
41
  `Watching ${resolve(args[1])} for .workstar changes.\n`,
37
42
  );
43
+ } else if (
44
+ (args.length === 3 || (args.length === 5 && args[3] === '--css')) &&
45
+ args[0] === '--compat' &&
46
+ /\.(tsx|vue)$/.test(args[1] ?? '') &&
47
+ args[2]?.endsWith('.ts')
48
+ ) {
49
+ await compileForeignFile(resolve(args[1]), resolve(args[2]), cssOption);
38
50
  } else if (
39
51
  (args.length === 2 || (args.length === 4 && args[2] === '--css')) &&
40
52
  args[0]?.endsWith('.workstar') &&
@@ -45,7 +57,9 @@ try {
45
57
  } else {
46
58
  process.stderr.write(
47
59
  'Usage: workstar-compile input.workstar output.ts [--css public/components.css]\n' +
60
+ ' workstar-compile --compat input.tsx|input.vue output.ts [--css public/components.css]\n' +
48
61
  ' workstar-compile --all source-directory output-directory [--css public/components.css]\n' +
62
+ ' workstar-compile --compat-audit source-directory\n' +
49
63
  ' workstar-compile --watch source-directory output-directory [--css public/components.css]\n',
50
64
  );
51
65
  process.exitCode = 2;
@@ -0,0 +1,2 @@
1
+ export declare const pathExpression: RegExp;
2
+ export declare function reject(filename: string, reason: string): never;
@@ -0,0 +1,4 @@
1
+ export const pathExpression = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
2
+ export function reject(filename, reason) {
3
+ throw new Error(filename + ': unsupported compatibility syntax: ' + reason);
4
+ }
@@ -0,0 +1,5 @@
1
+ export { convertReactComponent } from './react-compat.js';
2
+ export { convertVueComponent } from './vue-compat.js';
3
+ export declare function convertForeignComponent(source: string, filename: string, options?: {
4
+ resolveReactImport?: (specifier: string) => string | undefined;
5
+ }): string;
@@ -0,0 +1,12 @@
1
+ import { convertReactComponent } from './react-compat.js';
2
+ import { convertVueComponent } from './vue-compat.js';
3
+ import { reject } from './compat-rules.js';
4
+ export { convertReactComponent } from './react-compat.js';
5
+ export { convertVueComponent } from './vue-compat.js';
6
+ export function convertForeignComponent(source, filename, options = {}) {
7
+ if (filename.endsWith('.tsx'))
8
+ return convertReactComponent(source, filename, options);
9
+ if (filename.endsWith('.vue'))
10
+ return convertVueComponent(source, filename);
11
+ return reject(filename, 'file extension');
12
+ }
package/dist/src/index.js CHANGED
@@ -302,7 +302,12 @@ function elementMarkup(node, filename, locals, source) {
302
302
  fail(filename, `Use on:event instead of ${name}.`);
303
303
  }
304
304
  const value = dynamicAttribute(attribute.value, filename, locals);
305
- if (name.startsWith('on:')) {
305
+ if (name === 'bind:attrs') {
306
+ if (!value)
307
+ fail(filename, 'bind:attrs needs a record expression.');
308
+ result += '${__attrs(() => ' + value + ')}';
309
+ }
310
+ else if (name.startsWith('on:')) {
306
311
  if (!value)
307
312
  fail(filename, `${name} needs a handler expression.`);
308
313
  result += '${__on(' + JSON.stringify(name.slice(3)) + ', ' + value + ')}';
@@ -456,8 +461,8 @@ export function compileComponentParts(source, filename = 'Component.workstar', o
456
461
  fail(filename, 'The component has no markup.');
457
462
  const destructure = props.length > 0 ? ` const { ${props.join(', ')} } = props;\n` : '';
458
463
  const code = new ComponentCode();
459
- code.append('// Generated by workstar-compiler. Edit the .workstar source instead.\n');
460
- code.append("import { html as __html, attr as __attr, on as __on, repeat as __repeat, textareaValue as __textareaValue } from 'workstar';\n");
464
+ code.append('// Generated by workstar-compiler. Edit the source component instead.\n');
465
+ code.append("import { html as __html, attr as __attr, attrs as __attrs, on as __on, repeat as __repeat, textareaValue as __textareaValue } from 'workstar';\n");
461
466
  if (options.hotState) {
462
467
  code.append("import type { HotContext as __WorkstarHotContext } from 'workstar/dev';\n");
463
468
  }
@@ -1,8 +1,22 @@
1
1
  export interface ProjectStyles {
2
2
  cssOutputPath?: string;
3
3
  }
4
- /** Compile one authored view without rewriting unrelated generated modules. */
4
+ export interface ForeignAuditEntry {
5
+ filename: string;
6
+ supported: boolean;
7
+ reason?: string;
8
+ }
9
+ export interface ForeignAudit {
10
+ total: number;
11
+ supported: number;
12
+ entries: ForeignAuditEntry[];
13
+ }
14
+ /** Compile one authored Workstar view without rewriting unrelated generated modules. */
5
15
  export declare function compileViewFile(sourcePath: string, outputPath: string, options?: ProjectStyles): Promise<void>;
16
+ /** Compile a supported TSX or Vue SFC through the Workstar runtime. */
17
+ export declare function compileForeignFile(sourcePath: string, outputPath: string, options?: ProjectStyles): Promise<void>;
18
+ /** Report source compatibility without writing generated files or changing the app. */
19
+ export declare function auditForeignDirectory(sourceDirectory: string): Promise<ForeignAudit>;
6
20
  /** Compile every view before writing any generated modules. */
7
21
  export declare function compileViewDirectory(sourceDirectory: string, outputDirectory: string, options?: ProjectStyles): Promise<string[]>;
8
22
  /** Watch a view directory and recompile changed views for local development. */
@@ -2,6 +2,9 @@ import { watch } from 'node:fs';
2
2
  import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { compileComponentParts } from './index.js';
5
+ import { convertForeignComponent } from './compat.js';
6
+ import { reactComponentExportName } from './react-compat.js';
7
+ import { resolveReactComponentImport } from './react-import-resolution.js';
5
8
  async function filesInDirectory(directory, include, prefix = '') {
6
9
  const entries = await readdir(join(directory, prefix), {
7
10
  withFileTypes: true,
@@ -18,13 +21,19 @@ function generatedPath(viewPath) {
18
21
  return `${viewPath.slice(0, -'.workstar'.length)}.ts`;
19
22
  }
20
23
  function relocatedImport(sourcePath, outputPath, specifier) {
24
+ if (specifier.endsWith('.tsx?workstar')) {
25
+ const sourceTarget = resolve(dirname(sourcePath), specifier.slice(0, -'?workstar'.length));
26
+ const generatedTarget = resolve(dirname(outputPath), relative(dirname(sourcePath), sourceTarget).slice(0, -'.tsx'.length));
27
+ const path = relative(dirname(outputPath), generatedTarget)
28
+ .split(sep)
29
+ .join('/');
30
+ return path.startsWith('.') ? path : `./${path}`;
31
+ }
21
32
  const target = resolve(dirname(sourcePath), specifier);
22
33
  const path = relative(dirname(outputPath), target).split(sep).join('/');
23
34
  return path.startsWith('.') ? path : `./${path}`;
24
35
  }
25
- /** Compile one authored view without rewriting unrelated generated modules. */
26
- export async function compileViewFile(sourcePath, outputPath, options = {}) {
27
- const source = await readFile(sourcePath, 'utf8');
36
+ async function compileSourceFile(sourcePath, outputPath, source, options, namedExport) {
28
37
  const { code, css } = compileComponentParts(source, sourcePath, {
29
38
  rewriteRelativeImport: (specifier) => relocatedImport(sourcePath, outputPath, specifier),
30
39
  });
@@ -32,12 +41,55 @@ export async function compileViewFile(sourcePath, outputPath, options = {}) {
32
41
  throw new Error(`${sourcePath}: pass cssOutputPath to emit component styles.`);
33
42
  }
34
43
  await mkdir(dirname(outputPath), { recursive: true });
35
- await writeFile(outputPath, code, 'utf8');
44
+ await writeFile(outputPath, code + (namedExport ? `\nexport { render as ${namedExport} };\n` : ''), 'utf8');
36
45
  if (options.cssOutputPath) {
37
46
  await mkdir(dirname(options.cssOutputPath), { recursive: true });
38
47
  await writeFile(options.cssOutputPath, css, 'utf8');
39
48
  }
40
49
  }
50
+ /** Compile one authored Workstar view without rewriting unrelated generated modules. */
51
+ export async function compileViewFile(sourcePath, outputPath, options = {}) {
52
+ await compileSourceFile(sourcePath, outputPath, await readFile(sourcePath, 'utf8'), options);
53
+ }
54
+ /** Compile a supported TSX or Vue SFC through the Workstar runtime. */
55
+ export async function compileForeignFile(sourcePath, outputPath, options = {}) {
56
+ const original = await readFile(sourcePath, 'utf8');
57
+ const source = convertForeignComponent(original, sourcePath, {
58
+ resolveReactImport: (specifier) => resolveReactComponentImport(sourcePath, specifier),
59
+ });
60
+ const namedExport = sourcePath.endsWith('.tsx')
61
+ ? reactComponentExportName(original, sourcePath)
62
+ : undefined;
63
+ await compileSourceFile(sourcePath, outputPath, source, options, namedExport);
64
+ }
65
+ /** Report source compatibility without writing generated files or changing the app. */
66
+ export async function auditForeignDirectory(sourceDirectory) {
67
+ const paths = await filesInDirectory(sourceDirectory, (name) => /\.(tsx|vue)$/.test(name) &&
68
+ !/\.(test|spec|stories)\.(tsx|vue)$/.test(name));
69
+ const entries = await Promise.all(paths.map(async (filename) => {
70
+ const sourcePath = join(sourceDirectory, filename);
71
+ try {
72
+ const converted = convertForeignComponent(await readFile(sourcePath, 'utf8'), sourcePath, {
73
+ resolveReactImport: (specifier) => resolveReactComponentImport(sourcePath, specifier),
74
+ });
75
+ compileComponentParts(converted, `${sourcePath}.workstar`);
76
+ return { filename, supported: true };
77
+ }
78
+ catch (error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ return {
81
+ filename,
82
+ supported: false,
83
+ reason: message.replace(sourcePath, filename),
84
+ };
85
+ }
86
+ }));
87
+ return {
88
+ total: entries.length,
89
+ supported: entries.filter((entry) => entry.supported).length,
90
+ entries,
91
+ };
92
+ }
41
93
  /** Compile every view before writing any generated modules. */
42
94
  export async function compileViewDirectory(sourceDirectory, outputDirectory, options = {}) {
43
95
  const viewPaths = await filesInDirectory(sourceDirectory, (name) => name.endsWith('.workstar'));
@@ -0,0 +1,6 @@
1
+ /** The source export used by a named TSX import; undefined means default export. */
2
+ export declare function reactComponentExportName(source: string, filename?: string): string | undefined;
3
+ /** Converts one typed, stateless exported function component; unsupported behavior fails closed. */
4
+ export declare function convertReactComponent(source: string, filename?: string, options?: {
5
+ resolveReactImport?: (specifier: string) => string | undefined;
6
+ }): string;
@@ -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, '&amp;')
125
+ .replace(/"/g, '&quot;') +
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,2 @@
1
+ /** Replace the conventional createRoot entry with a Workstar mount. */
2
+ export declare function convertReactRootEntry(source: string, filename: string): string | undefined;