workstar-compiler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Workstar Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # Workstar compiler
2
+
3
+ The `.workstar` component compiler, command-line checker, and optional Vite plugin for Workstar applications.
4
+
5
+ Use the compiler with a matching Workstar version and validate a project with `npm run check` before building. A component can live in any directory under the configured source root; generated type-checking files belong outside authored `src`.
6
+
7
+ The `workstar-compile` command compiles one component or a directory:
8
+
9
+ ```sh
10
+ workstar-compile --all src .workstar/generated --css .workstar/styles.css
11
+ ```
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.
14
+
15
+ 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.
16
+
17
+ `<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.
18
+
19
+ See the [Workstar repository](https://github.com/wslab-ai/workstar) for starters and current limitations.
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import {
4
+ compileViewFile,
5
+ compileViewDirectory,
6
+ watchViewDirectory,
7
+ } from '../dist/src/project.js';
8
+
9
+ const args = process.argv.slice(2);
10
+ try {
11
+ const cssOption =
12
+ (args.length === 5 && args[3] === '--css') ||
13
+ (args.length === 4 && args[2] === '--css')
14
+ ? { cssOutputPath: resolve(args.at(-1)) }
15
+ : {};
16
+ if (
17
+ (args.length === 3 || (args.length === 5 && args[3] === '--css')) &&
18
+ args[0] === '--all'
19
+ ) {
20
+ await compileViewDirectory(resolve(args[1]), resolve(args[2]), cssOption);
21
+ } else if (
22
+ (args.length === 3 || (args.length === 5 && args[3] === '--css')) &&
23
+ args[0] === '--watch'
24
+ ) {
25
+ await watchViewDirectory(
26
+ resolve(args[1]),
27
+ resolve(args[2]),
28
+ (error) => {
29
+ process.stderr.write(
30
+ `${error instanceof Error ? error.message : String(error)}\n`,
31
+ );
32
+ },
33
+ cssOption,
34
+ );
35
+ process.stdout.write(
36
+ `Watching ${resolve(args[1])} for .workstar changes.\n`,
37
+ );
38
+ } else if (
39
+ (args.length === 2 || (args.length === 4 && args[2] === '--css')) &&
40
+ args[0]?.endsWith('.workstar') &&
41
+ args[1]?.endsWith('.ts')
42
+ ) {
43
+ const [input, output] = args;
44
+ await compileViewFile(resolve(input), resolve(output), cssOption);
45
+ } else {
46
+ process.stderr.write(
47
+ 'Usage: workstar-compile input.workstar output.ts [--css public/components.css]\n' +
48
+ ' workstar-compile --all source-directory output-directory [--css public/components.css]\n' +
49
+ ' workstar-compile --watch source-directory output-directory [--css public/components.css]\n',
50
+ );
51
+ process.exitCode = 2;
52
+ }
53
+ } catch (error) {
54
+ process.stderr.write(
55
+ `${error instanceof Error ? error.message : String(error)}\n`,
56
+ );
57
+ process.exitCode = 1;
58
+ }
@@ -0,0 +1,6 @@
1
+ import type { DefaultTreeAdapterTypes as Html } from 'parse5';
2
+ export declare function componentScript(node: Html.Element, filename: string, componentImports: 'generated' | 'source', rewriteRelativeImport?: (specifier: string) => string): {
3
+ moduleScript: string;
4
+ setupScript: string;
5
+ props: string[];
6
+ };
@@ -0,0 +1,107 @@
1
+ import ts from 'typescript';
2
+ import { fail } from './errors.js';
3
+ export function componentScript(node, filename, componentImports, rewriteRelativeImport) {
4
+ if (node.attrs.length !== 1 ||
5
+ node.attrs[0]?.name !== 'lang' ||
6
+ node.attrs[0].value !== 'ts') {
7
+ fail(filename, 'The component script must be <script lang="ts">.');
8
+ }
9
+ const script = node.childNodes
10
+ .map((child) => {
11
+ if (!('value' in child))
12
+ fail(filename, 'Invalid script content.');
13
+ return child.value;
14
+ })
15
+ .join('');
16
+ const file = ts.createSourceFile(filename, script, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
17
+ const syntax = ts.transpileModule(script, {
18
+ fileName: filename.replace(/\.workstar$/, '.ts'),
19
+ reportDiagnostics: true,
20
+ });
21
+ if (syntax.diagnostics?.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)) {
22
+ fail(filename, 'Invalid TypeScript in component script.');
23
+ }
24
+ let props;
25
+ const moduleStatements = [];
26
+ const setupStatements = [];
27
+ for (const statement of file.statements) {
28
+ if (ts.isEmptyStatement(statement))
29
+ continue;
30
+ if (ts.isImportDeclaration(statement)) {
31
+ const specifier = ts.isStringLiteral(statement.moduleSpecifier)
32
+ ? statement.moduleSpecifier.text
33
+ : '';
34
+ if (specifier.endsWith('.workstar')) {
35
+ const binding = statement.importClause?.name?.text;
36
+ if (!/^(?:\.{1,2}\/)+(?:[A-Za-z0-9][\w-]*\/)*[A-Za-z0-9][\w-]*\.workstar$/.test(specifier) ||
37
+ !binding ||
38
+ statement.importClause?.isTypeOnly ||
39
+ statement.importClause?.namedBindings) {
40
+ fail(filename, 'Import a relative .workstar view with a default import.');
41
+ }
42
+ moduleStatements.push(`import { render as ${binding} } from '${componentImports === 'source' ? specifier : specifier.slice(0, -'.workstar'.length)}';`);
43
+ }
44
+ else if (specifier.startsWith('.') && rewriteRelativeImport) {
45
+ const importText = statement.getText(file);
46
+ const start = statement.moduleSpecifier.getStart(file) - statement.getStart(file);
47
+ const end = statement.moduleSpecifier.getEnd() - statement.getStart(file);
48
+ moduleStatements.push(importText.slice(0, start) +
49
+ JSON.stringify(rewriteRelativeImport(specifier)) +
50
+ importText.slice(end));
51
+ }
52
+ else {
53
+ moduleStatements.push(statement.getText(file));
54
+ }
55
+ continue;
56
+ }
57
+ if (ts.isInterfaceDeclaration(statement) &&
58
+ statement.name.text === 'Props') {
59
+ if (props)
60
+ fail(filename, 'The component can declare Props only once.');
61
+ props = statement.members.map((member) => {
62
+ if (!ts.isPropertySignature(member) ||
63
+ !member.name ||
64
+ !ts.isIdentifier(member.name)) {
65
+ fail(filename, 'Props must use named properties.');
66
+ }
67
+ return member.name.text;
68
+ });
69
+ moduleStatements.push(statement.getText(file));
70
+ continue;
71
+ }
72
+ if (ts.isTypeAliasDeclaration(statement) &&
73
+ statement.name.text === 'Props' &&
74
+ ts.isTypeLiteralNode(statement.type)) {
75
+ if (props)
76
+ fail(filename, 'The component can declare Props only once.');
77
+ props = statement.type.members.map((member) => {
78
+ if (!ts.isPropertySignature(member) ||
79
+ !member.name ||
80
+ !ts.isIdentifier(member.name)) {
81
+ fail(filename, 'Props must use named properties.');
82
+ }
83
+ return member.name.text;
84
+ });
85
+ moduleStatements.push(statement.getText(file));
86
+ continue;
87
+ }
88
+ if (ts.isVariableStatement(statement) ||
89
+ ts.isFunctionDeclaration(statement)) {
90
+ if (statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
91
+ fail(filename, 'Component-local declarations cannot be exported.');
92
+ }
93
+ setupStatements.push(statement.getText(file));
94
+ continue;
95
+ }
96
+ fail(filename, 'Only imports, Props, and component-local variables/functions are supported in the script.');
97
+ }
98
+ if (!props) {
99
+ props = [];
100
+ moduleStatements.push('type Props = Record<string, never>;');
101
+ }
102
+ return {
103
+ moduleScript: moduleStatements.join('\n'),
104
+ setupScript: setupStatements.join('\n'),
105
+ props,
106
+ };
107
+ }
@@ -0,0 +1,7 @@
1
+ export declare const controlAttribute = "data-workstar-compiler-control";
2
+ export interface NormalizedControls {
3
+ source: string;
4
+ count: number;
5
+ }
6
+ /** Adapt authoring controls to HTML parser insertion modes, including select and table. */
7
+ export declare function normalizeControls(source: string): NormalizedControls;
@@ -0,0 +1,92 @@
1
+ const controlNames = new Set(['Each', 'If', 'Else', 'Use']);
2
+ export const controlAttribute = 'data-workstar-compiler-control';
3
+ function tagEnd(source, start) {
4
+ let quote;
5
+ for (let index = start + 1; index < source.length; index++) {
6
+ const character = source[index];
7
+ if (quote) {
8
+ if (character === quote)
9
+ quote = undefined;
10
+ }
11
+ else if (character === '"' || character === "'") {
12
+ quote = character;
13
+ }
14
+ else if (character === '>') {
15
+ return index;
16
+ }
17
+ }
18
+ throw new Error('Unclosed HTML tag.');
19
+ }
20
+ /** Adapt authoring controls to HTML parser insertion modes, including select and table. */
21
+ export function normalizeControls(source) {
22
+ if (source.includes(controlAttribute)) {
23
+ throw new Error(`${controlAttribute} is reserved for the compiler.`);
24
+ }
25
+ const scriptEnd = /<\/script\s*>/i.exec(source);
26
+ const start = scriptEnd ? scriptEnd.index + scriptEnd[0].length : 0;
27
+ let result = source.slice(0, start);
28
+ let cursor = start;
29
+ let count = 0;
30
+ const stack = [];
31
+ while (cursor < source.length) {
32
+ const opening = source.indexOf('<', cursor);
33
+ if (opening < 0)
34
+ break;
35
+ result += source.slice(cursor, opening);
36
+ if (source.startsWith('<!--', opening)) {
37
+ const end = source.indexOf('-->', opening + 4);
38
+ if (end < 0)
39
+ throw new Error('Unclosed HTML comment.');
40
+ result += source.slice(opening, end + 3);
41
+ cursor = end + 3;
42
+ continue;
43
+ }
44
+ const end = tagEnd(source, opening);
45
+ const tag = source.slice(opening, end + 1);
46
+ const match = /^<\s*(\/?)\s*([A-Za-z][\w:-]*)(?=[\s/>])/.exec(tag);
47
+ const name = match?.[2];
48
+ if (!name || !controlNames.has(name)) {
49
+ result += tag;
50
+ cursor = end + 1;
51
+ if (/^<textarea(?=[\s>])/i.test(tag)) {
52
+ const closing = /<\/textarea\s*>/gi;
53
+ closing.lastIndex = cursor;
54
+ const close = closing.exec(source);
55
+ if (close) {
56
+ result += source.slice(cursor, close.index + close[0].length);
57
+ cursor = close.index + close[0].length;
58
+ }
59
+ }
60
+ continue;
61
+ }
62
+ const closing = match[1] === '/';
63
+ const selfClosing = /\/\s*>$/.test(tag);
64
+ if (closing) {
65
+ if (stack.pop() !== name) {
66
+ throw new Error(`Mismatched </${name}> control element.`);
67
+ }
68
+ result += '</template>';
69
+ }
70
+ else {
71
+ if (selfClosing && name !== 'Use') {
72
+ throw new Error(`<${name}> cannot be self-closing.`);
73
+ }
74
+ const authoredAttributes = tag.slice(match[0].length, -1);
75
+ const attributes = selfClosing
76
+ ? authoredAttributes.replace(/\/\s*$/, '')
77
+ : authoredAttributes;
78
+ result += `<template ${controlAttribute}="${name}"${attributes}>`;
79
+ if (selfClosing)
80
+ result += '</template>';
81
+ else
82
+ stack.push(name);
83
+ count++;
84
+ }
85
+ cursor = end + 1;
86
+ }
87
+ result += source.slice(cursor);
88
+ if (stack.length > 0) {
89
+ throw new Error(`Unclosed <${stack.at(-1)}> control element.`);
90
+ }
91
+ return { source: result, count };
92
+ }
@@ -0,0 +1,5 @@
1
+ export declare class ComponentCompileError extends Error {
2
+ readonly filename: string;
3
+ constructor(message: string, filename: string);
4
+ }
5
+ export declare function fail(filename: string, message: string): never;
@@ -0,0 +1,11 @@
1
+ export class ComponentCompileError extends Error {
2
+ filename;
3
+ constructor(message, filename) {
4
+ super(`${filename}: ${message}`);
5
+ this.filename = filename;
6
+ this.name = 'ComponentCompileError';
7
+ }
8
+ }
9
+ export function fail(filename, message) {
10
+ throw new ComponentCompileError(message, filename);
11
+ }
@@ -0,0 +1,16 @@
1
+ export { ComponentCompileError } from './errors.js';
2
+ /** Compile a typed component and its optional co-located stylesheet. */
3
+ export declare function compileComponentParts(source: string, filename?: string, options?: {
4
+ componentImports?: 'generated' | 'source';
5
+ rewriteRelativeImport?: (specifier: string) => string;
6
+ cssImport?: string;
7
+ }): {
8
+ code: string;
9
+ css: string;
10
+ };
11
+ /** Compile a component to a TypeScript module; use parts to emit its CSS. */
12
+ export declare function compileComponent(source: string, filename?: string, options?: {
13
+ componentImports?: 'generated' | 'source';
14
+ rewriteRelativeImport?: (specifier: string) => string;
15
+ cssImport?: string;
16
+ }): string;
@@ -0,0 +1,415 @@
1
+ import { parseFragment } from 'parse5';
2
+ import { componentScript } from './component-script.js';
3
+ import { controlAttribute, normalizeControls } from './control-elements.js';
4
+ import { fail } from './errors.js';
5
+ import { compileStyle } from './styles.js';
6
+ export { ComponentCompileError } from './errors.js';
7
+ const pathExpression = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[\d+\])*$/;
8
+ const identifier = /^[A-Za-z_$][\w$]*$/;
9
+ const urlAttributes = new Set([
10
+ 'href',
11
+ 'src',
12
+ 'action',
13
+ 'formaction',
14
+ 'xlink:href',
15
+ ]);
16
+ const voidElements = new Set([
17
+ 'area',
18
+ 'base',
19
+ 'br',
20
+ 'col',
21
+ 'embed',
22
+ 'hr',
23
+ 'img',
24
+ 'input',
25
+ 'link',
26
+ 'meta',
27
+ 'param',
28
+ 'source',
29
+ 'track',
30
+ 'wbr',
31
+ ]);
32
+ function escapeHtml(value) {
33
+ return value.replace(/[&<>"']/g, (character) => {
34
+ const entities = {
35
+ '&': '&amp;',
36
+ '<': '&lt;',
37
+ '>': '&gt;',
38
+ '"': '&quot;',
39
+ "'": '&#39;',
40
+ };
41
+ return entities[character] ?? character;
42
+ });
43
+ }
44
+ function escapeTemplate(value) {
45
+ return value
46
+ .replace(/\\/g, '\\\\')
47
+ .replace(/`/g, '\\`')
48
+ .replace(/\$\{/g, '\\${');
49
+ }
50
+ function controlName(node) {
51
+ return node.attrs.find((attribute) => attribute.name === controlAttribute)
52
+ ?.value;
53
+ }
54
+ function elementChildren(node) {
55
+ return node.tagName === 'template'
56
+ ? node.content.childNodes
57
+ : node.childNodes;
58
+ }
59
+ function expression(value, filename, locals) {
60
+ if (!pathExpression.test(value))
61
+ fail(filename, `Unsupported expression: ${value}`);
62
+ const root = /^[A-Za-z_$][\w$]*/.exec(value)?.[0];
63
+ if (!root)
64
+ fail(filename, `Unsupported expression: ${value}`);
65
+ const local = locals.get(root);
66
+ return local ? local + value.slice(root.length) : value;
67
+ }
68
+ function dynamicAttribute(value, filename, locals) {
69
+ if (!value.startsWith('{') && !value.endsWith('}'))
70
+ return null;
71
+ if (!value.startsWith('{') || !value.endsWith('}')) {
72
+ fail(filename, `Invalid attribute expression: ${value}`);
73
+ }
74
+ return expression(value.slice(1, -1).trim(), filename, locals);
75
+ }
76
+ function textMarkup(value, filename, locals) {
77
+ let result = '';
78
+ let start = 0;
79
+ const pattern = /\{([^{}]+)\}/g;
80
+ for (const match of value.matchAll(pattern)) {
81
+ const index = match.index;
82
+ const staticText = value.slice(start, index);
83
+ if (/[{}]/.test(staticText))
84
+ fail(filename, 'Invalid text expression.');
85
+ result += escapeTemplate(escapeHtml(staticText));
86
+ result += '${() => ' + expression(match[1].trim(), filename, locals) + '}';
87
+ start = index + match[0].length;
88
+ }
89
+ const remaining = value.slice(start);
90
+ if (/[{}]/.test(remaining))
91
+ fail(filename, 'Invalid text expression.');
92
+ return result + escapeTemplate(escapeHtml(remaining));
93
+ }
94
+ function childMarkup(children, filename, locals, source) {
95
+ return children
96
+ .map((child) => nodeMarkup(child, filename, locals, source))
97
+ .join('');
98
+ }
99
+ function assertControlElementsPreserved(authoredCount, body, filename) {
100
+ let parsed = 0;
101
+ const visit = (nodes) => {
102
+ for (const node of nodes) {
103
+ if (!('tagName' in node))
104
+ continue;
105
+ if (controlName(node))
106
+ parsed++;
107
+ visit(elementChildren(node));
108
+ }
109
+ };
110
+ visit(body);
111
+ if (parsed !== authoredCount) {
112
+ fail(filename, 'A control element was discarded by the HTML parser in this context.');
113
+ }
114
+ }
115
+ function scopeMarkupElements(nodes, attribute, filename) {
116
+ for (const node of nodes) {
117
+ if (!('tagName' in node))
118
+ continue;
119
+ if (node.tagName !== 'template') {
120
+ if (node.attrs.some((entry) => entry.name === attribute)) {
121
+ fail(filename, `${attribute} is reserved for component styles.`);
122
+ }
123
+ node.attrs.push({ name: attribute, value: '' });
124
+ }
125
+ scopeMarkupElements(elementChildren(node), attribute, filename);
126
+ }
127
+ }
128
+ function eachMarkup(node, filename, locals, sourceText) {
129
+ const attributes = new Map(node.attrs
130
+ .filter((attribute) => attribute.name !== controlAttribute)
131
+ .map((attribute) => [attribute.name, attribute.value]));
132
+ if (attributes.size !== 3 ||
133
+ !attributes.has('each') ||
134
+ !attributes.has('as') ||
135
+ !attributes.has('key')) {
136
+ fail(filename, '<Each> needs each={path}, as="name", and key="field|self".');
137
+ }
138
+ const source = dynamicAttribute(attributes.get('each'), filename, locals);
139
+ const name = attributes.get('as');
140
+ const key = attributes.get('key');
141
+ if (!source || !identifier.test(name))
142
+ fail(filename, 'Invalid <Each> binding.');
143
+ if (key !== 'self' && !identifier.test(key))
144
+ fail(filename, '<Each> key must be a field name or "self".');
145
+ const nested = new Map(locals);
146
+ nested.set(name, `${name}.value`);
147
+ const body = childMarkup(elementChildren(node), filename, nested, sourceText);
148
+ const keyExpression = key === 'self' ? name : `${name}.${key}`;
149
+ return ('${__repeat(() => ' +
150
+ source +
151
+ ', (' +
152
+ name +
153
+ ') => ' +
154
+ keyExpression +
155
+ ', (' +
156
+ name +
157
+ ') => __html`' +
158
+ body +
159
+ '`)}');
160
+ }
161
+ function ifMarkup(node, filename, locals, source) {
162
+ const attributes = node.attrs.filter((attribute) => attribute.name !== controlAttribute);
163
+ if (attributes.length !== 1 || attributes[0]?.name !== 'when') {
164
+ fail(filename, '<If> needs exactly when={path}.');
165
+ }
166
+ const condition = dynamicAttribute(attributes[0].value, filename, locals);
167
+ if (!condition)
168
+ fail(filename, '<If> needs a condition expression.');
169
+ const children = elementChildren(node);
170
+ const elseIndex = children.findIndex((child) => 'tagName' in child && controlName(child) === 'Else');
171
+ const before = elseIndex < 0 ? children : children.slice(0, elseIndex);
172
+ let alternate = 'null';
173
+ if (elseIndex >= 0) {
174
+ const elseNode = children[elseIndex];
175
+ if (!elseNode ||
176
+ !('tagName' in elseNode) ||
177
+ elseNode.attrs.some((attribute) => attribute.name !== controlAttribute)) {
178
+ fail(filename, '<Else> cannot have attributes.');
179
+ }
180
+ if (children
181
+ .slice(elseIndex + 1)
182
+ .some((child) => !('value' in child) || child.value.trim() !== '')) {
183
+ fail(filename, '<Else> must be the last child of <If>.');
184
+ }
185
+ alternate =
186
+ '__html`' +
187
+ childMarkup(elementChildren(elseNode), filename, locals, source) +
188
+ '`';
189
+ }
190
+ const truthy = '__html`' + childMarkup(before, filename, locals, source) + '`';
191
+ return '${() => (' + condition + ' ? ' + truthy + ' : ' + alternate + ')}';
192
+ }
193
+ function originalAttributeName(node, name, source) {
194
+ const location = node.sourceCodeLocation?.attrs?.[name];
195
+ if (!location)
196
+ return name;
197
+ return /^([^\s=/>]+)/.exec(source.slice(location.startOffset))?.[1] ?? name;
198
+ }
199
+ function componentMarkup(node, filename, locals, source) {
200
+ const children = elementChildren(node);
201
+ const hasChildren = children.some((child) => !('value' in child) || child.value.trim());
202
+ const binding = node.attrs.find((attribute) => attribute.name === 'component');
203
+ if (!binding)
204
+ fail(filename, '<Use> needs component={ImportedView}.');
205
+ const renderer = dynamicAttribute(binding.value, filename, locals);
206
+ if (!renderer)
207
+ fail(filename, '<Use> needs a component expression.');
208
+ const props = node.attrs
209
+ .filter((attribute) => attribute !== binding && attribute.name !== controlAttribute)
210
+ .map((attribute) => {
211
+ const name = originalAttributeName(node, attribute.name, source);
212
+ if (!identifier.test(name)) {
213
+ fail(filename, `<Use> prop ${name} must be a TypeScript identifier.`);
214
+ }
215
+ const value = dynamicAttribute(attribute.value, filename, locals);
216
+ const authored = node.sourceCodeLocation?.attrs?.[attribute.name];
217
+ const raw = authored
218
+ ? source.slice(authored.startOffset, authored.endOffset)
219
+ : '';
220
+ const output = value ?? (raw.includes('=') ? JSON.stringify(attribute.value) : 'true');
221
+ return `${name}: ${output}`;
222
+ });
223
+ if (hasChildren) {
224
+ if (node.attrs.some((attribute) => attribute.name === 'children')) {
225
+ fail(filename, '<Use> cannot set children twice.');
226
+ }
227
+ props.push('children: __html`' +
228
+ childMarkup(children, filename, locals, source) +
229
+ '`');
230
+ }
231
+ return '${() => ' + renderer + '({' + props.join(', ') + '})}';
232
+ }
233
+ function elementMarkup(node, filename, locals, source) {
234
+ if (!node.sourceCodeLocation)
235
+ fail(filename, 'HTML parser inserted an implicit element.');
236
+ const tag = node.tagName;
237
+ if (tag === 'template') {
238
+ switch (controlName(node)) {
239
+ case 'Each':
240
+ return eachMarkup(node, filename, locals, source);
241
+ case 'If':
242
+ return ifMarkup(node, filename, locals, source);
243
+ case 'Else':
244
+ fail(filename, '<Else> must be inside <If>.');
245
+ case 'Use':
246
+ return componentMarkup(node, filename, locals, source);
247
+ default:
248
+ fail(filename, '<template> is not supported in component markup yet.');
249
+ }
250
+ }
251
+ if (/^(script|style|title)$/.test(tag)) {
252
+ fail(filename, `<${tag}> is not supported in component markup yet.`);
253
+ }
254
+ if (tag === 'noscript' &&
255
+ node.childNodes.some((child) => !('value' in child) ||
256
+ /\{[^{}]+\}|<\s*\/?\s*[a-z][^>]*>/i.test(child.value))) {
257
+ fail(filename, '<noscript> supports static text only; place links and expressions outside it.');
258
+ }
259
+ let result = `<${tag}`;
260
+ for (const attribute of node.attrs) {
261
+ const name = attribute.name;
262
+ if (tag === 'textarea' && name === 'value') {
263
+ fail(filename, '<textarea> value belongs in element content.');
264
+ }
265
+ if (name === 'srcdoc')
266
+ fail(filename, 'srcdoc is not supported.');
267
+ if (/^on/i.test(name) && !name.startsWith('on:')) {
268
+ fail(filename, `Use on:event instead of ${name}.`);
269
+ }
270
+ const value = dynamicAttribute(attribute.value, filename, locals);
271
+ if (name.startsWith('on:')) {
272
+ if (!value)
273
+ fail(filename, `${name} needs a handler expression.`);
274
+ result += '${__on(' + JSON.stringify(name.slice(3)) + ', ' + value + ')}';
275
+ }
276
+ else if (value) {
277
+ result += '${__attr(' + JSON.stringify(name) + ', () => ' + value + ')}';
278
+ }
279
+ else if (urlAttributes.has(name)) {
280
+ result +=
281
+ '${__attr(' +
282
+ JSON.stringify(name) +
283
+ ', ' +
284
+ JSON.stringify(attribute.value) +
285
+ ')}';
286
+ }
287
+ else {
288
+ result += ` ${name}="${escapeTemplate(escapeHtml(attribute.value))}"`;
289
+ }
290
+ }
291
+ if (tag === 'textarea') {
292
+ const content = node.childNodes
293
+ .map((child) => {
294
+ if (!('value' in child)) {
295
+ fail(filename, '<textarea> can contain text only.');
296
+ }
297
+ return child.value;
298
+ })
299
+ .join('');
300
+ const dynamic = /^\s*\{([^{}]+)\}\s*$/.exec(content);
301
+ if (dynamic) {
302
+ const source = expression(dynamic[1].trim(), filename, locals);
303
+ return result + '${__textareaValue(() => ' + source + ')}></textarea>';
304
+ }
305
+ if (/[{}]/.test(content)) {
306
+ fail(filename, '<textarea> needs one {path} expression or static text.');
307
+ }
308
+ return result + '>' + escapeTemplate(escapeHtml(content)) + '</textarea>';
309
+ }
310
+ result += '>';
311
+ if (voidElements.has(tag)) {
312
+ if (node.childNodes.length > 0)
313
+ fail(filename, `<${tag}> cannot have children.`);
314
+ return result;
315
+ }
316
+ return (result +
317
+ childMarkup(node.childNodes, filename, locals, source) +
318
+ `</${tag}>`);
319
+ }
320
+ function nodeMarkup(node, filename, locals, source) {
321
+ if ('tagName' in node)
322
+ return elementMarkup(node, filename, locals, source);
323
+ if ('value' in node)
324
+ return textMarkup(node.value, filename, locals);
325
+ if ('data' in node)
326
+ return `<!--${escapeTemplate(node.data)}-->`;
327
+ return fail(filename, 'Doctype belongs in the document shell.');
328
+ }
329
+ /** Compile a typed component and its optional co-located stylesheet. */
330
+ export function compileComponentParts(source, filename = 'Component.workstar', options = {}) {
331
+ let normalized;
332
+ try {
333
+ normalized = normalizeControls(source);
334
+ }
335
+ catch (error) {
336
+ fail(filename, error instanceof Error ? error.message : String(error));
337
+ }
338
+ const normalizedSource = normalized.source;
339
+ const errors = [];
340
+ const fragment = parseFragment(normalizedSource, {
341
+ sourceCodeLocationInfo: true,
342
+ onParseError: (error) => errors.push(`${error.code} at ${error.startLine}:${error.startCol}`),
343
+ });
344
+ if (errors.length > 0)
345
+ fail(filename, errors[0]);
346
+ const content = fragment.childNodes.filter((node) => !('value' in node) || node.value.trim().length > 0);
347
+ const first = content[0];
348
+ const hasScript = first && 'tagName' in first && first.tagName === 'script';
349
+ if (!hasScript &&
350
+ content.some((node) => 'tagName' in node && node.tagName === 'script')) {
351
+ fail(filename, 'A <script lang="ts"> block must come first.');
352
+ }
353
+ const { moduleScript, setupScript, props } = hasScript
354
+ ? componentScript(first, filename, options.componentImports ?? 'generated', options.rewriteRelativeImport)
355
+ : {
356
+ moduleScript: 'export type Props = Record<string, never>;',
357
+ setupScript: '',
358
+ props: [],
359
+ };
360
+ const componentBody = hasScript ? content.slice(1) : content;
361
+ const last = componentBody.at(-1);
362
+ const hasStyle = last && 'tagName' in last && last.tagName === 'style';
363
+ const markup = hasStyle ? componentBody.slice(0, -1) : componentBody;
364
+ if (markup.some((node) => 'tagName' in node && node.tagName === 'style')) {
365
+ fail(filename, 'A single <style> block must come last.');
366
+ }
367
+ let css = '';
368
+ if (hasStyle) {
369
+ const global = last.attrs.length === 1 &&
370
+ last.attrs[0]?.name === 'global' &&
371
+ last.attrs[0].value === '';
372
+ if (last.attrs.length > 0 && !global) {
373
+ fail(filename, 'Use <style> or <style global> only.');
374
+ }
375
+ const authoredCss = last.childNodes
376
+ .map((child) => {
377
+ if (!('value' in child))
378
+ fail(filename, 'Invalid <style> content.');
379
+ return child.value;
380
+ })
381
+ .join('');
382
+ try {
383
+ const style = compileStyle(authoredCss, filename, global);
384
+ css = style.css;
385
+ if (style.scopeAttribute) {
386
+ scopeMarkupElements(markup, style.scopeAttribute, filename);
387
+ }
388
+ }
389
+ catch (error) {
390
+ fail(filename, error instanceof Error ? error.message : String(error));
391
+ }
392
+ }
393
+ assertControlElementsPreserved(normalized.count, markup, filename);
394
+ const body = childMarkup(markup, filename, new Map(), normalizedSource);
395
+ if (body.trim().length === 0)
396
+ fail(filename, 'The component has no markup.');
397
+ const destructure = props.length > 0 ? ` const { ${props.join(', ')} } = props;\n` : '';
398
+ const code = [
399
+ '// Generated by workstar-compiler. Edit the .workstar source instead.',
400
+ "import { html as __html, attr as __attr, on as __on, repeat as __repeat, textareaValue as __textareaValue } from 'workstar';",
401
+ ...(css && options.cssImport
402
+ ? [`import ${JSON.stringify(options.cssImport)};`]
403
+ : []),
404
+ moduleScript,
405
+ 'export function render(props: Props) {',
406
+ destructure + setupScript + '\n return __html`' + body + '`;',
407
+ '}',
408
+ '',
409
+ ].join('\n');
410
+ return { code, css };
411
+ }
412
+ /** Compile a component to a TypeScript module; use parts to emit its CSS. */
413
+ export function compileComponent(source, filename = 'Component.workstar', options = {}) {
414
+ return compileComponentParts(source, filename, options).code;
415
+ }
@@ -0,0 +1,9 @@
1
+ export interface ProjectStyles {
2
+ cssOutputPath?: string;
3
+ }
4
+ /** Compile one authored view without rewriting unrelated generated modules. */
5
+ export declare function compileViewFile(sourcePath: string, outputPath: string, options?: ProjectStyles): Promise<void>;
6
+ /** Compile every view before writing any generated modules. */
7
+ export declare function compileViewDirectory(sourceDirectory: string, outputDirectory: string, options?: ProjectStyles): Promise<string[]>;
8
+ /** Watch a view directory and recompile changed views for local development. */
9
+ export declare function watchViewDirectory(sourceDirectory: string, outputDirectory: string, onError: (error: unknown) => void, options?: ProjectStyles): Promise<() => void>;
@@ -0,0 +1,123 @@
1
+ import { watch } from 'node:fs';
2
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { compileComponentParts } from './index.js';
5
+ async function filesInDirectory(directory, include, prefix = '') {
6
+ const entries = await readdir(join(directory, prefix), {
7
+ withFileTypes: true,
8
+ });
9
+ const paths = await Promise.all(entries.map(async (entry) => {
10
+ const path = join(prefix, entry.name);
11
+ if (entry.isDirectory())
12
+ return filesInDirectory(directory, include, path);
13
+ return entry.isFile() && include(entry.name) ? [path] : [];
14
+ }));
15
+ return paths.flat().sort();
16
+ }
17
+ function generatedPath(viewPath) {
18
+ return `${viewPath.slice(0, -'.workstar'.length)}.ts`;
19
+ }
20
+ function relocatedImport(sourcePath, outputPath, specifier) {
21
+ const target = resolve(dirname(sourcePath), specifier);
22
+ const path = relative(dirname(outputPath), target).split(sep).join('/');
23
+ return path.startsWith('.') ? path : `./${path}`;
24
+ }
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');
28
+ const { code, css } = compileComponentParts(source, sourcePath, {
29
+ rewriteRelativeImport: (specifier) => relocatedImport(sourcePath, outputPath, specifier),
30
+ });
31
+ if (css && !options.cssOutputPath) {
32
+ throw new Error(`${sourcePath}: pass cssOutputPath to emit component styles.`);
33
+ }
34
+ await mkdir(dirname(outputPath), { recursive: true });
35
+ await writeFile(outputPath, code, 'utf8');
36
+ if (options.cssOutputPath) {
37
+ await mkdir(dirname(options.cssOutputPath), { recursive: true });
38
+ await writeFile(options.cssOutputPath, css, 'utf8');
39
+ }
40
+ }
41
+ /** Compile every view before writing any generated modules. */
42
+ export async function compileViewDirectory(sourceDirectory, outputDirectory, options = {}) {
43
+ const viewPaths = await filesInDirectory(sourceDirectory, (name) => name.endsWith('.workstar'));
44
+ if (viewPaths.length === 0) {
45
+ throw new Error(`No .workstar views found in ${sourceDirectory}.`);
46
+ }
47
+ const modules = await Promise.all(viewPaths.map(async (viewPath) => {
48
+ const sourcePath = join(sourceDirectory, viewPath);
49
+ const outputPath = join(outputDirectory, generatedPath(viewPath));
50
+ const source = await readFile(sourcePath, 'utf8');
51
+ const { code, css } = compileComponentParts(source, sourcePath, {
52
+ rewriteRelativeImport: (specifier) => relocatedImport(sourcePath, outputPath, specifier),
53
+ });
54
+ return {
55
+ filename: generatedPath(viewPath),
56
+ code,
57
+ css,
58
+ };
59
+ }));
60
+ if (modules.some(({ css }) => css) && !options.cssOutputPath) {
61
+ throw new Error('Pass cssOutputPath to emit component styles.');
62
+ }
63
+ await mkdir(outputDirectory, { recursive: true });
64
+ const current = new Set(modules.map(({ filename }) => filename));
65
+ for (const path of await filesInDirectory(outputDirectory, (name) => name.endsWith('.ts'))) {
66
+ if (current.has(path))
67
+ continue;
68
+ const stalePath = join(outputDirectory, path);
69
+ if ((await readFile(stalePath, 'utf8')).startsWith('// Generated by workstar-compiler.')) {
70
+ await rm(stalePath);
71
+ }
72
+ }
73
+ await Promise.all(modules.map(async ({ filename, code }) => {
74
+ const outputPath = join(outputDirectory, filename);
75
+ await mkdir(dirname(outputPath), { recursive: true });
76
+ await writeFile(outputPath, code, 'utf8');
77
+ }));
78
+ if (options.cssOutputPath) {
79
+ const stylesheet = modules
80
+ .filter(({ css }) => css)
81
+ .map(({ filename, css }) => `/* ${filename} */\n${css}`)
82
+ .join('\n');
83
+ await mkdir(dirname(options.cssOutputPath), { recursive: true });
84
+ await writeFile(options.cssOutputPath, stylesheet, 'utf8');
85
+ }
86
+ return modules.map(({ filename }) => filename);
87
+ }
88
+ /** Watch a view directory and recompile changed views for local development. */
89
+ export async function watchViewDirectory(sourceDirectory, outputDirectory, onError, options = {}) {
90
+ await compileViewDirectory(sourceDirectory, outputDirectory, options);
91
+ let debounce;
92
+ let compiling = false;
93
+ let pending = false;
94
+ async function recompile() {
95
+ if (compiling) {
96
+ pending = true;
97
+ return;
98
+ }
99
+ compiling = true;
100
+ do {
101
+ pending = false;
102
+ try {
103
+ await compileViewDirectory(sourceDirectory, outputDirectory, options);
104
+ }
105
+ catch (error) {
106
+ onError(error);
107
+ }
108
+ } while (pending);
109
+ compiling = false;
110
+ }
111
+ const watcher = watch(sourceDirectory, { recursive: true }, (_event, filename) => {
112
+ if (filename && !filename.endsWith('.workstar'))
113
+ return;
114
+ if (debounce)
115
+ clearTimeout(debounce);
116
+ debounce = setTimeout(() => void recompile(), 60);
117
+ });
118
+ return () => {
119
+ if (debounce)
120
+ clearTimeout(debounce);
121
+ watcher.close();
122
+ };
123
+ }
@@ -0,0 +1,6 @@
1
+ export interface CompiledStyle {
2
+ css: string;
3
+ scopeAttribute?: string;
4
+ }
5
+ /** Compile CSS at build time; styles never depend on client hydration. */
6
+ export declare function compileStyle(css: string, filename: string, global?: boolean): CompiledStyle;
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import postcss from 'postcss';
3
+ import selectorParser from 'postcss-selector-parser';
4
+ function scopeSelector(selector, attribute) {
5
+ const scopeNode = selectorParser().astSync(`:where([${attribute}])`).first
6
+ ?.first;
7
+ if (!scopeNode)
8
+ throw new Error('Could not create a style scope.');
9
+ return selectorParser((selectors) => {
10
+ selectors.each((part) => {
11
+ if (part.toString().includes(':global(')) {
12
+ throw new Error('Use <style global> for global selectors.');
13
+ }
14
+ let compound = [];
15
+ const addScope = () => {
16
+ if (compound.length === 0)
17
+ return;
18
+ const pseudoElement = compound.find((node) => node.type === 'pseudo' && node.value.startsWith('::'));
19
+ if (pseudoElement)
20
+ part.insertBefore(pseudoElement, scopeNode.clone());
21
+ else
22
+ part.insertAfter(compound.at(-1), scopeNode.clone());
23
+ compound = [];
24
+ };
25
+ for (const node of [...part.nodes]) {
26
+ if (node.type === 'combinator')
27
+ addScope();
28
+ else
29
+ compound.push(node);
30
+ }
31
+ addScope();
32
+ });
33
+ }).processSync(selector);
34
+ }
35
+ /** Compile CSS at build time; styles never depend on client hydration. */
36
+ 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>.`);
44
+ }
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 };
56
+ }
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface WorkstarPluginOptions {
3
+ source?: string;
4
+ }
5
+ /** Compile authored components as Vite modules without writing into src. */
6
+ export declare function workstar(options?: WorkstarPluginOptions): Plugin;
@@ -0,0 +1,66 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import ts from 'typescript';
4
+ import { compileComponentParts } from './index.js';
5
+ /** Compile authored components as Vite modules without writing into src. */
6
+ export function workstar(options = {}) {
7
+ let sourceDirectory;
8
+ const styleSuffix = '.css?workstar-style';
9
+ function isAuthoredComponent(filename) {
10
+ const localPath = relative(sourceDirectory, filename);
11
+ return (extname(localPath) === '.workstar' &&
12
+ localPath !== '..' &&
13
+ !localPath.startsWith(`..${sep}`) &&
14
+ !isAbsolute(localPath));
15
+ }
16
+ return {
17
+ name: 'workstar',
18
+ enforce: 'pre',
19
+ configResolved(config) {
20
+ sourceDirectory = resolve(config.root, options.source ?? 'src');
21
+ },
22
+ resolveId(id) {
23
+ if (!id.endsWith(styleSuffix))
24
+ return null;
25
+ const filename = id.slice(0, -styleSuffix.length);
26
+ return isAuthoredComponent(filename) ? id : null;
27
+ },
28
+ async load(id) {
29
+ if (!id.endsWith(styleSuffix))
30
+ return null;
31
+ const filename = id.slice(0, -styleSuffix.length);
32
+ if (!isAuthoredComponent(filename))
33
+ return null;
34
+ const source = await readFile(filename, 'utf8');
35
+ return compileComponentParts(source, filename).css;
36
+ },
37
+ transform(source, id) {
38
+ const filename = id.split('?', 1)[0];
39
+ if (!isAuthoredComponent(filename))
40
+ return null;
41
+ const generated = compileComponentParts(source, filename, {
42
+ componentImports: 'source',
43
+ cssImport: `${filename}${styleSuffix}`,
44
+ });
45
+ 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,
54
+ };
55
+ },
56
+ handleHotUpdate(context) {
57
+ if (!isAuthoredComponent(context.file))
58
+ return;
59
+ const stylesheet = context.server.moduleGraph.getModuleById(`${context.file}${styleSuffix}`);
60
+ if (!stylesheet)
61
+ return;
62
+ context.server.moduleGraph.invalidateModule(stylesheet);
63
+ return [...context.modules, stylesheet];
64
+ },
65
+ };
66
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "workstar-compiler",
3
+ "version": "0.1.0",
4
+ "description": "Component compiler and Vite plugin for Workstar applications.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Workstar Lab <hello@workstarlab.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/wslab-ai/workstar.git",
11
+ "directory": "packages/compiler"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/src/index.d.ts",
22
+ "import": "./dist/src/index.js"
23
+ },
24
+ "./vite": {
25
+ "types": "./dist/src/vite.d.ts",
26
+ "import": "./dist/src/vite.js"
27
+ }
28
+ },
29
+ "bin": {
30
+ "workstar-compile": "./bin/workstar-compile.mjs"
31
+ },
32
+ "scripts": {
33
+ "check": "tsc --noEmit -p tsconfig.json",
34
+ "test": "vitest run tests",
35
+ "build": "tsc -p tsconfig.build.json"
36
+ },
37
+ "dependencies": {
38
+ "parse5": "^8.0.0",
39
+ "postcss": "^8.5.28",
40
+ "postcss-selector-parser": "^7.1.6",
41
+ "typescript": "^6.0.3"
42
+ },
43
+ "peerDependencies": {
44
+ "vite": "^8.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "vite": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }