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.
@@ -1,6 +1,16 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import postcss from 'postcss';
3
3
  import selectorParser from 'postcss-selector-parser';
4
+ export class StyleCompileError extends Error {
5
+ line;
6
+ column;
7
+ constructor(message, line, column) {
8
+ super(message);
9
+ this.line = line;
10
+ this.column = column;
11
+ this.name = 'StyleCompileError';
12
+ }
13
+ }
4
14
  function scopeSelector(selector, attribute) {
5
15
  const scopeNode = selectorParser().astSync(`:where([${attribute}])`).first
6
16
  ?.first;
@@ -34,23 +44,36 @@ function scopeSelector(selector, attribute) {
34
44
  }
35
45
  /** Compile CSS at build time; styles never depend on client hydration. */
36
46
  export function compileStyle(css, filename, global = false) {
37
- const root = postcss.parse(css, { from: filename });
38
- root.walkAtRules((rule) => {
39
- if (rule.name === 'import' || rule.name === 'charset') {
40
- throw new Error(`${filename}: @${rule.name} belongs in a global stylesheet.`);
41
- }
42
- if (/keyframes$/i.test(rule.name) && !global) {
43
- throw new Error(`${filename}: put @keyframes in <style global>.`);
47
+ try {
48
+ const root = postcss.parse(css, { from: filename });
49
+ root.walkAtRules((rule) => {
50
+ if (rule.name === 'import' || rule.name === 'charset') {
51
+ throw rule.error(`@${rule.name} belongs in a global stylesheet.`);
52
+ }
53
+ if (/keyframes$/i.test(rule.name) && !global) {
54
+ throw rule.error('Put @keyframes in <style global>.');
55
+ }
56
+ });
57
+ if (global)
58
+ return { css: root.toString().trim() };
59
+ const scopeAttribute = `data-workstar-${createHash('sha256')
60
+ .update(filename)
61
+ .digest('hex')
62
+ .slice(0, 10)}`;
63
+ root.walkRules((rule) => {
64
+ try {
65
+ rule.selector = scopeSelector(rule.selector, scopeAttribute);
66
+ }
67
+ catch (error) {
68
+ throw rule.error(error instanceof Error ? error.message : String(error));
69
+ }
70
+ });
71
+ return { css: root.toString().trim(), scopeAttribute };
72
+ }
73
+ catch (error) {
74
+ if (error instanceof postcss.CssSyntaxError) {
75
+ throw new StyleCompileError(error.reason, error.line ?? 1, error.column ?? 1);
44
76
  }
45
- });
46
- if (global)
47
- return { css: root.toString().trim() };
48
- const scopeAttribute = `data-workstar-${createHash('sha256')
49
- .update(filename)
50
- .digest('hex')
51
- .slice(0, 10)}`;
52
- root.walkRules((rule) => {
53
- rule.selector = scopeSelector(rule.selector, scopeAttribute);
54
- });
55
- return { css: root.toString().trim(), scopeAttribute };
77
+ throw error;
78
+ }
56
79
  }
@@ -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,11 +1,21 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
3
- import ts from 'typescript';
2
+ import { dirname, extname, isAbsolute, relative, resolve, sep, } from 'node:path';
4
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';
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;
12
+ let development = false;
8
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();
18
+ const compiled = new Map();
9
19
  function isAuthoredComponent(filename) {
10
20
  const localPath = relative(sourceDirectory, filename);
11
21
  return (extname(localPath) === '.workstar' &&
@@ -13,19 +23,102 @@ export function workstar(options = {}) {
13
23
  !localPath.startsWith(`..${sep}`) &&
14
24
  !isAbsolute(localPath));
15
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
+ }
33
+ function rootComponents(modules) {
34
+ const roots = new Set();
35
+ const visited = new Set();
36
+ function visit(module) {
37
+ if (visited.has(module))
38
+ return;
39
+ visited.add(module);
40
+ const parents = [...module.importers].filter((importer) => importer.id && isAuthoredComponent(importer.id.split('?', 1)[0]));
41
+ if (parents.length === 0)
42
+ roots.add(module);
43
+ else
44
+ parents.forEach(visit);
45
+ }
46
+ modules.forEach(visit);
47
+ return [...roots];
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
+ }
16
63
  return {
17
64
  name: 'workstar',
18
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
+ },
19
91
  configResolved(config) {
20
92
  sourceDirectory = resolve(config.root, options.source ?? 'src');
93
+ development = config.command === 'serve';
21
94
  },
22
- 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
+ }
23
107
  if (!id.endsWith(styleSuffix))
24
108
  return null;
25
109
  const filename = id.slice(0, -styleSuffix.length);
26
110
  return isAuthoredComponent(filename) ? id : null;
27
111
  },
28
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
+ }
29
122
  if (!id.endsWith(styleSuffix))
30
123
  return null;
31
124
  const filename = id.slice(0, -styleSuffix.length);
@@ -36,31 +129,75 @@ export function workstar(options = {}) {
36
129
  },
37
130
  transform(source, id) {
38
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
+ }
39
140
  if (!isAuthoredComponent(filename))
40
141
  return null;
41
142
  const generated = compileComponentParts(source, filename, {
42
143
  componentImports: 'source',
43
144
  cssImport: `${filename}${styleSuffix}`,
145
+ hotState: development,
44
146
  });
147
+ compiled.set(filename, generated);
148
+ const output = transpileComponent(generated.code, source, filename, generated.origins);
45
149
  return {
46
- code: ts.transpileModule(generated.code, {
47
- fileName: filename,
48
- compilerOptions: {
49
- module: ts.ModuleKind.ESNext,
50
- target: ts.ScriptTarget.ES2022,
51
- },
52
- }).outputText,
53
- map: null,
150
+ code: output.code,
151
+ map: JSON.stringify(output.map),
54
152
  };
55
153
  },
56
- handleHotUpdate(context) {
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
+ }
57
180
  if (!isAuthoredComponent(context.file))
58
181
  return;
182
+ const previous = compiled.get(context.file);
183
+ const next = compileComponentParts(await context.read(), context.file, {
184
+ componentImports: 'source',
185
+ cssImport: `${context.file}${styleSuffix}`,
186
+ hotState: development,
187
+ });
59
188
  const stylesheet = context.server.moduleGraph.getModuleById(`${context.file}${styleSuffix}`);
60
189
  if (!stylesheet)
61
190
  return;
62
191
  context.server.moduleGraph.invalidateModule(stylesheet);
63
- return [...context.modules, stylesheet];
192
+ if (previous &&
193
+ previous.code === next.code &&
194
+ previous.css !== next.css) {
195
+ compiled.set(context.file, next);
196
+ return [stylesheet];
197
+ }
198
+ const roots = rootComponents(context.modules);
199
+ roots.forEach((module) => context.server.moduleGraph.invalidateModule(module));
200
+ return [...roots, stylesheet];
64
201
  },
65
202
  };
66
203
  }
@@ -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;