workstar-compiler 0.1.1 → 0.2.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.
@@ -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,2 @@
1
+ /** Resolve a local TSX component to an explicit opt-in compatibility import. */
2
+ export declare function resolveReactComponentImport(filename: string, specifier: string): string | undefined;
@@ -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
+ }
@@ -1,6 +1,10 @@
1
1
  import type { Plugin } from 'vite';
2
2
  export interface WorkstarPluginOptions {
3
3
  source?: string;
4
+ /** Compile ordinary TSX/Vue imports within source, without ?workstar. */
5
+ foreign?: 'explicit' | 'automatic' | 'runtime';
6
+ /** Optional absolute runtime directory for isolated migration builds. */
7
+ runtimeImportSource?: string;
4
8
  }
5
9
  /** Compile authored components as Vite modules without writing into src. */
6
10
  export declare function workstar(options?: WorkstarPluginOptions): Plugin;
package/dist/src/vite.js CHANGED
@@ -1,12 +1,20 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { dirname, extname, isAbsolute, relative, resolve, sep, } from 'node:path';
3
3
  import { compileComponentParts } from './index.js';
4
+ import { convertForeignComponent } from './compat.js';
5
+ import { convertReactRootEntry } from './react-entry-compat.js';
6
+ import { reactComponentExportName } from './react-compat.js';
7
+ import { resolveReactComponentImport } from './react-import-resolution.js';
4
8
  import { transpileComponent } from './source-map.js';
5
9
  /** Compile authored components as Vite modules without writing into src. */
6
10
  export function workstar(options = {}) {
7
11
  let sourceDirectory;
8
12
  let development = false;
9
13
  const styleSuffix = '.css?workstar-style';
14
+ const foreignPrefix = '\0workstar-foreign:';
15
+ const foreignStylePrefix = '\0workstar-foreign-style:';
16
+ const foreignStylePublic = 'virtual:workstar-foreign-style:';
17
+ const foreignStyles = new Map();
10
18
  const compiled = new Map();
11
19
  function isAuthoredComponent(filename) {
12
20
  const localPath = relative(sourceDirectory, filename);
@@ -15,6 +23,13 @@ export function workstar(options = {}) {
15
23
  !localPath.startsWith(`..${sep}`) &&
16
24
  !isAbsolute(localPath));
17
25
  }
26
+ function isForeignSource(filename) {
27
+ const localPath = relative(sourceDirectory, filename);
28
+ return ((filename.endsWith('.tsx') || filename.endsWith('.vue')) &&
29
+ localPath !== '..' &&
30
+ !localPath.startsWith(`..${sep}`) &&
31
+ !isAbsolute(localPath));
32
+ }
18
33
  function rootComponents(modules) {
19
34
  const roots = new Set();
20
35
  const visited = new Set();
@@ -31,20 +46,79 @@ export function workstar(options = {}) {
31
46
  modules.forEach(visit);
32
47
  return [...roots];
33
48
  }
49
+ function compileForeignSource(source, filename) {
50
+ const converted = convertForeignComponent(source, filename, {
51
+ resolveReactImport: (specifier) => resolveReactComponentImport(filename, specifier),
52
+ });
53
+ const namedExport = filename.endsWith('.tsx')
54
+ ? reactComponentExportName(source, filename)
55
+ : undefined;
56
+ const generated = compileComponentParts(converted, `${filename}.workstar`, {
57
+ cssImport: foreignStylePublic + encodeURIComponent(filename) + '.css',
58
+ });
59
+ foreignStyles.set(filename, generated.css);
60
+ const output = transpileComponent(`${generated.code}\nexport { render as ${namedExport ?? 'default'} };\n`, converted, filename, generated.origins);
61
+ return { code: output.code, map: null };
62
+ }
34
63
  return {
35
64
  name: 'workstar',
36
65
  enforce: 'pre',
66
+ config() {
67
+ if (options.foreign !== 'runtime')
68
+ return;
69
+ const runtime = options.runtimeImportSource ?? 'workstar/compat/react';
70
+ const react = isAbsolute(runtime) ? `${runtime}/index.js` : runtime;
71
+ const client = isAbsolute(runtime)
72
+ ? `${runtime}/client.js`
73
+ : `${runtime}/client`;
74
+ const router = isAbsolute(runtime)
75
+ ? resolve(runtime, '../react-router/index.js')
76
+ : 'workstar/compat/react-router';
77
+ return {
78
+ esbuild: {
79
+ jsx: 'automatic',
80
+ jsxImportSource: runtime,
81
+ },
82
+ resolve: {
83
+ alias: [
84
+ { find: 'react-router', replacement: router },
85
+ { find: 'react-dom/client', replacement: client },
86
+ { find: 'react', replacement: react },
87
+ ],
88
+ },
89
+ };
90
+ },
37
91
  configResolved(config) {
38
92
  sourceDirectory = resolve(config.root, options.source ?? 'src');
39
93
  development = config.command === 'serve';
40
94
  },
41
- resolveId(id) {
95
+ resolveId(id, importer) {
96
+ if (id.startsWith(foreignStylePublic)) {
97
+ const encoded = id.slice(foreignStylePublic.length, -'.css'.length);
98
+ return foreignStylePrefix + decodeURIComponent(encoded) + '.css';
99
+ }
100
+ if (id.endsWith('?workstar') && importer && id.startsWith('.')) {
101
+ const importerPath = importer.startsWith(foreignPrefix)
102
+ ? importer.slice(foreignPrefix.length)
103
+ : importer.split('?', 1)[0];
104
+ const filename = resolve(dirname(importerPath), id.slice(0, -'?workstar'.length));
105
+ return isForeignSource(filename) ? foreignPrefix + filename : null;
106
+ }
42
107
  if (!id.endsWith(styleSuffix))
43
108
  return null;
44
109
  const filename = id.slice(0, -styleSuffix.length);
45
110
  return isAuthoredComponent(filename) ? id : null;
46
111
  },
47
112
  async load(id) {
113
+ if (id.startsWith(foreignStylePrefix)) {
114
+ return (foreignStyles.get(id.slice(foreignStylePrefix.length, -'.css'.length)) ?? null);
115
+ }
116
+ if (id.startsWith(foreignPrefix)) {
117
+ const filename = id.slice(foreignPrefix.length);
118
+ this.addWatchFile(filename);
119
+ const source = await readFile(filename, 'utf8');
120
+ return compileForeignSource(source, filename);
121
+ }
48
122
  if (!id.endsWith(styleSuffix))
49
123
  return null;
50
124
  const filename = id.slice(0, -styleSuffix.length);
@@ -55,6 +129,14 @@ export function workstar(options = {}) {
55
129
  },
56
130
  transform(source, id) {
57
131
  const filename = id.split('?', 1)[0];
132
+ if (options.foreign === 'automatic' && isForeignSource(filename)) {
133
+ const entry = filename.endsWith('.tsx')
134
+ ? convertReactRootEntry(source, filename)
135
+ : undefined;
136
+ if (entry)
137
+ return { code: entry, map: null };
138
+ return compileForeignSource(source, filename);
139
+ }
58
140
  if (!isAuthoredComponent(filename))
59
141
  return null;
60
142
  const generated = compileComponentParts(source, filename, {
@@ -70,6 +152,31 @@ export function workstar(options = {}) {
70
152
  };
71
153
  },
72
154
  async handleHotUpdate(context) {
155
+ if (options.foreign !== 'runtime' && isForeignSource(context.file)) {
156
+ const filename = context.file;
157
+ const source = await context.read();
158
+ const entry = options.foreign === 'automatic' && filename.endsWith('.tsx')
159
+ ? convertReactRootEntry(source, filename)
160
+ : undefined;
161
+ if (!entry) {
162
+ const converted = convertForeignComponent(source, filename, {
163
+ resolveReactImport: (specifier) => resolveReactComponentImport(filename, specifier),
164
+ });
165
+ const next = compileComponentParts(converted, `${filename}.workstar`);
166
+ foreignStyles.set(filename, next.css);
167
+ }
168
+ const components = [
169
+ context.server.moduleGraph.getModuleById(foreignPrefix + filename),
170
+ options.foreign === 'automatic'
171
+ ? context.server.moduleGraph.getModuleById(filename)
172
+ : undefined,
173
+ ].filter((module) => module !== undefined);
174
+ const stylesheet = context.server.moduleGraph.getModuleById(foreignStylePrefix + filename + '.css');
175
+ components.forEach((module) => context.server.moduleGraph.invalidateModule(module));
176
+ if (stylesheet)
177
+ context.server.moduleGraph.invalidateModule(stylesheet);
178
+ return [...components, stylesheet].filter((module) => module !== undefined);
179
+ }
73
180
  if (!isAuthoredComponent(context.file))
74
181
  return;
75
182
  const previous = compiled.get(context.file);
@@ -0,0 +1,2 @@
1
+ /** Converts Vue SFC markup with typed defineProps and optional CSS. */
2
+ export declare function convertVueComponent(source: string, filename?: string): string;
@@ -0,0 +1,168 @@
1
+ import { parseFragment } from 'parse5';
2
+ import { pathExpression, reject } from './compat-rules.js';
3
+ import { convertVueScript } from './vue-script-compat.js';
4
+ function content(source, node, filename) {
5
+ const location = node.sourceCodeLocation;
6
+ if (!location?.startTag || !location.endTag)
7
+ reject(filename, 'unclosed ' + node.tagName);
8
+ return source.slice(location.startTag.endOffset, location.endTag.startOffset);
9
+ }
10
+ function templatePath(path, refs, stores, filename) {
11
+ if (!pathExpression.test(path))
12
+ reject(filename, 'expression ' + path);
13
+ const root = path.split('.', 1)[0];
14
+ const fields = stores.get(root);
15
+ if (fields) {
16
+ const field = path.slice(root.length + 1).split('.', 1)[0];
17
+ if (!fields.has(field))
18
+ reject(filename, 'store field ' + path);
19
+ return path;
20
+ }
21
+ if (!refs.has(root))
22
+ return path;
23
+ const suffix = path.slice(root.length);
24
+ if (suffix.startsWith('.value'))
25
+ reject(filename, 'explicit .value in a Vue template');
26
+ return root + '.value' + suffix;
27
+ }
28
+ /** Converts Vue SFC markup with typed defineProps and optional CSS. */
29
+ export function convertVueComponent(source, filename = 'Component.vue') {
30
+ const blockErrors = [];
31
+ const fragment = parseFragment(source, {
32
+ sourceCodeLocationInfo: true,
33
+ onParseError: (error) => blockErrors.push(error.code),
34
+ });
35
+ if (blockErrors.length)
36
+ reject(filename, 'invalid SFC markup: ' + blockErrors[0]);
37
+ const meaningful = fragment.childNodes.filter((node) => !('value' in node) || node.value.trim());
38
+ if (meaningful.some((node) => !('tagName' in node)))
39
+ reject(filename, 'content outside SFC blocks');
40
+ const blocks = meaningful;
41
+ if (blocks.some((node) => !['template', 'script', 'style'].includes(node.tagName)))
42
+ reject(filename, 'unknown SFC block');
43
+ const templates = blocks.filter((node) => node.tagName === 'template');
44
+ const scripts = blocks.filter((node) => node.tagName === 'script');
45
+ const styles = blocks.filter((node) => node.tagName === 'style');
46
+ if (templates.length !== 1 || scripts.length > 1 || styles.length > 1)
47
+ reject(filename, 'SFC block count');
48
+ if (templates[0].attrs.length)
49
+ reject(filename, 'template attributes');
50
+ let scriptContent = '';
51
+ let refs = new Set();
52
+ let stores = new Map();
53
+ if (scripts[0]) {
54
+ const attrs = new Map(scripts[0].attrs.map((attribute) => [attribute.name, attribute.value]));
55
+ if (attrs.size !== 2 || !attrs.has('setup') || attrs.get('lang') !== 'ts')
56
+ reject(filename, 'script setup attributes');
57
+ const converted = convertVueScript(content(source, scripts[0], filename), filename);
58
+ scriptContent = [converted.props, converted.setup]
59
+ .filter(Boolean)
60
+ .join('\n');
61
+ refs = converted.refs;
62
+ stores = converted.stores;
63
+ }
64
+ let markup = content(source, templates[0], filename);
65
+ const templateErrors = [];
66
+ const templateTree = parseFragment(markup, {
67
+ sourceCodeLocationInfo: true,
68
+ onParseError: (error) => templateErrors.push(error.code),
69
+ });
70
+ if (templateErrors.length)
71
+ reject(filename, 'invalid template markup: ' + templateErrors[0]);
72
+ const replacements = [];
73
+ const visit = (nodes) => {
74
+ for (const node of nodes) {
75
+ if (!('tagName' in node)) {
76
+ if ('value' in node && node.sourceCodeLocation) {
77
+ const offset = node.sourceCodeLocation.startOffset;
78
+ const raw = markup.slice(offset, node.sourceCodeLocation.endOffset);
79
+ for (const match of raw.matchAll(/\{\{([^{}]+)\}\}/g)) {
80
+ const value = templatePath(match[1].trim(), refs, stores, filename);
81
+ replacements.push({
82
+ start: offset + match.index,
83
+ end: offset + match.index + match[0].length,
84
+ value: '{' + value + '}',
85
+ });
86
+ }
87
+ }
88
+ continue;
89
+ }
90
+ const start = node.sourceCodeLocation?.startTag?.startOffset;
91
+ const originalTag = start === undefined
92
+ ? ''
93
+ : /^<\s*([A-Za-z][\w-]*)/.exec(markup.slice(start))?.[1];
94
+ if (!originalTag ||
95
+ /^[A-Z]/.test(originalTag) ||
96
+ originalTag === 'slot' ||
97
+ originalTag === 'component')
98
+ reject(filename, 'Vue component tag');
99
+ for (const attribute of node.attrs) {
100
+ const name = attribute.name;
101
+ if (!name.startsWith(':') && !name.startsWith('@')) {
102
+ if (name.startsWith('v-') ||
103
+ name.startsWith('#') ||
104
+ name === 'ref' ||
105
+ name === 'key')
106
+ reject(filename, 'Vue directive');
107
+ continue;
108
+ }
109
+ const target = name.slice(1);
110
+ if (!/^[a-z][a-z0-9-]*$/.test(target) ||
111
+ target === 'style' ||
112
+ target === 'class' ||
113
+ target === 'key' ||
114
+ target === 'ref' ||
115
+ !pathExpression.test(attribute.value)) {
116
+ reject(filename, 'Vue directive ' + name);
117
+ }
118
+ const root = attribute.value.split('.', 1)[0];
119
+ if (name.startsWith('@') && (refs.has(root) || stores.has(root)))
120
+ reject(filename, 'state used as event handler');
121
+ const location = node.sourceCodeLocation?.attrs?.[name];
122
+ if (!location)
123
+ reject(filename, 'Vue directive ' + name);
124
+ replacements.push({
125
+ start: location.startOffset,
126
+ end: location.endOffset,
127
+ value: (name.startsWith('@') ? 'on:' : '') +
128
+ target +
129
+ '={' +
130
+ (name.startsWith('@')
131
+ ? attribute.value
132
+ : templatePath(attribute.value, refs, stores, filename)) +
133
+ '}',
134
+ });
135
+ }
136
+ visit(node.tagName === 'template'
137
+ ? node.content.childNodes
138
+ : node.childNodes);
139
+ }
140
+ };
141
+ visit(templateTree.childNodes);
142
+ for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
143
+ markup =
144
+ markup.slice(0, replacement.start) +
145
+ replacement.value +
146
+ markup.slice(replacement.end);
147
+ }
148
+ if (markup.includes('{{') || markup.includes('}}'))
149
+ reject(filename, 'unparsed interpolation');
150
+ let style = '';
151
+ if (styles[0]) {
152
+ const attrs = styles[0].attrs;
153
+ if (attrs.length > 1 || (attrs.length === 1 && attrs[0]?.name !== 'scoped'))
154
+ reject(filename, 'style attributes');
155
+ style =
156
+ '<style' +
157
+ (attrs.length ? '' : ' global') +
158
+ '>\n' +
159
+ content(source, styles[0], filename) +
160
+ '\n</style>\n';
161
+ }
162
+ return ((scriptContent
163
+ ? '<script lang="ts">\n' + scriptContent + '\n</script>\n'
164
+ : '') +
165
+ markup +
166
+ '\n' +
167
+ style);
168
+ }
@@ -0,0 +1,8 @@
1
+ export interface VueScriptCompat {
2
+ readonly props: string;
3
+ readonly setup: string;
4
+ readonly refs: ReadonlySet<string>;
5
+ readonly stores: ReadonlyMap<string, ReadonlySet<string>>;
6
+ }
7
+ /** Translate primitive Vue refs, flat reactive records, props, and their handlers. */
8
+ export declare function convertVueScript(script: string, filename: string): VueScriptCompat;