workstar-compiler 0.1.0 → 0.1.1

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
@@ -12,6 +12,13 @@ workstar-compile --all src .workstar/generated --css .workstar/styles.css
12
12
 
13
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
14
 
15
+ During Vite development, the compiler adds state identities for direct local
16
+ `signal()` declarations. Pass a stable `HotContext` from `workstar/dev` to a
17
+ root component when accepting Vite updates, as shown in the static starter.
18
+ Production output does not include this instrumentation. The compiler maps
19
+ script statements and the markup entry point back to authored source in Vite;
20
+ individual markup expressions are not mapped yet.
21
+
15
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.
16
23
 
17
24
  `<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.
@@ -1,6 +1,14 @@
1
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;
2
+ export interface ScriptStatement {
3
+ readonly code: string;
4
+ readonly sourceOffset?: number | undefined;
5
+ readonly exact?: boolean;
6
+ }
7
+ export declare function componentScript(node: Html.Element, filename: string, componentImports: 'generated' | 'source', sourcePosition: (offset: number) => {
8
+ line: number;
9
+ column: number;
10
+ }, rewriteRelativeImport?: (specifier: string) => string, hotState?: boolean): {
11
+ moduleStatements: ScriptStatement[];
12
+ setupStatements: ScriptStatement[];
5
13
  props: string[];
6
14
  };
@@ -1,15 +1,46 @@
1
1
  import ts from 'typescript';
2
- import { fail } from './errors.js';
3
- export function componentScript(node, filename, componentImports, rewriteRelativeImport) {
2
+ import { ComponentCompileError } from './errors.js';
3
+ function preserveLocalSignals(statement, file, signalBindings) {
4
+ const statementStart = statement.getStart(file);
5
+ let code = statement.getText(file);
6
+ const signals = statement.declarationList.declarations.flatMap((declaration) => {
7
+ const initializer = declaration.initializer;
8
+ if (!ts.isIdentifier(declaration.name) ||
9
+ !initializer ||
10
+ !ts.isCallExpression(initializer) ||
11
+ !ts.isIdentifier(initializer.expression) ||
12
+ !signalBindings.has(initializer.expression.text))
13
+ return [];
14
+ return [{ name: declaration.name.text, initializer }];
15
+ });
16
+ for (const { name, initializer } of signals.reverse()) {
17
+ const start = initializer.getStart(file) - statementStart;
18
+ const end = initializer.getEnd() - statementStart;
19
+ const original = code.slice(start, end);
20
+ const retained = `__context?.state(${JSON.stringify(`${name}:${original}`)}, () => ${original})`;
21
+ code =
22
+ code.slice(0, start) + `(${retained} ?? ${original})` + code.slice(end);
23
+ }
24
+ return signals.length > 0 ? code : undefined;
25
+ }
26
+ export function componentScript(node, filename, componentImports, sourcePosition, rewriteRelativeImport, hotState = false) {
4
27
  if (node.attrs.length !== 1 ||
5
28
  node.attrs[0]?.name !== 'lang' ||
6
29
  node.attrs[0].value !== 'ts') {
7
- fail(filename, 'The component script must be <script lang="ts">.');
30
+ throw new ComponentCompileError('The component script must be <script lang="ts">.', filename, node.sourceCodeLocation
31
+ ? sourcePosition(node.sourceCodeLocation.startOffset)
32
+ : undefined);
8
33
  }
34
+ const scriptStart = node.sourceCodeLocation?.startTag?.endOffset;
35
+ const failAt = (message, offset = 0) => {
36
+ throw new ComponentCompileError(message, filename, scriptStart === undefined
37
+ ? undefined
38
+ : sourcePosition(scriptStart + offset));
39
+ };
9
40
  const script = node.childNodes
10
41
  .map((child) => {
11
42
  if (!('value' in child))
12
- fail(filename, 'Invalid script content.');
43
+ return failAt('Invalid script content.');
13
44
  return child.value;
14
45
  })
15
46
  .join('');
@@ -18,90 +49,128 @@ export function componentScript(node, filename, componentImports, rewriteRelativ
18
49
  fileName: filename.replace(/\.workstar$/, '.ts'),
19
50
  reportDiagnostics: true,
20
51
  });
21
- if (syntax.diagnostics?.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)) {
22
- fail(filename, 'Invalid TypeScript in component script.');
52
+ const syntaxError = syntax.diagnostics?.find((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
53
+ if (syntaxError) {
54
+ failAt(`Invalid TypeScript in component script: ${ts.flattenDiagnosticMessageText(syntaxError.messageText, ' ')}`, syntaxError.start ?? 0);
23
55
  }
24
56
  let props;
57
+ const signalBindings = new Set();
25
58
  const moduleStatements = [];
26
59
  const setupStatements = [];
27
60
  for (const statement of file.statements) {
28
61
  if (ts.isEmptyStatement(statement))
29
62
  continue;
63
+ const sourceOffset = scriptStart === undefined
64
+ ? undefined
65
+ : scriptStart + statement.getStart(file);
30
66
  if (ts.isImportDeclaration(statement)) {
31
67
  const specifier = ts.isStringLiteral(statement.moduleSpecifier)
32
68
  ? statement.moduleSpecifier.text
33
69
  : '';
70
+ const named = statement.importClause?.namedBindings;
71
+ if (specifier === 'workstar' && named && ts.isNamedImports(named)) {
72
+ for (const binding of named.elements) {
73
+ if ((binding.propertyName ?? binding.name).text === 'signal') {
74
+ signalBindings.add(binding.name.text);
75
+ }
76
+ }
77
+ }
34
78
  if (specifier.endsWith('.workstar')) {
35
79
  const binding = statement.importClause?.name?.text;
36
80
  if (!/^(?:\.{1,2}\/)+(?:[A-Za-z0-9][\w-]*\/)*[A-Za-z0-9][\w-]*\.workstar$/.test(specifier) ||
37
81
  !binding ||
38
82
  statement.importClause?.isTypeOnly ||
39
83
  statement.importClause?.namedBindings) {
40
- fail(filename, 'Import a relative .workstar view with a default import.');
84
+ failAt('Import a relative .workstar view with a default import.', statement.getStart(file));
41
85
  }
42
- moduleStatements.push(`import { render as ${binding} } from '${componentImports === 'source' ? specifier : specifier.slice(0, -'.workstar'.length)}';`);
86
+ moduleStatements.push({
87
+ code: `import { render as ${binding} } from '${componentImports === 'source' ? specifier : specifier.slice(0, -'.workstar'.length)}';`,
88
+ sourceOffset,
89
+ });
43
90
  }
44
91
  else if (specifier.startsWith('.') && rewriteRelativeImport) {
45
92
  const importText = statement.getText(file);
46
93
  const start = statement.moduleSpecifier.getStart(file) - statement.getStart(file);
47
94
  const end = statement.moduleSpecifier.getEnd() - statement.getStart(file);
48
- moduleStatements.push(importText.slice(0, start) +
49
- JSON.stringify(rewriteRelativeImport(specifier)) +
50
- importText.slice(end));
95
+ moduleStatements.push({
96
+ code: importText.slice(0, start) +
97
+ JSON.stringify(rewriteRelativeImport(specifier)) +
98
+ importText.slice(end),
99
+ sourceOffset,
100
+ });
51
101
  }
52
102
  else {
53
- moduleStatements.push(statement.getText(file));
103
+ moduleStatements.push({
104
+ code: statement.getText(file),
105
+ sourceOffset,
106
+ exact: true,
107
+ });
54
108
  }
55
109
  continue;
56
110
  }
57
111
  if (ts.isInterfaceDeclaration(statement) &&
58
112
  statement.name.text === 'Props') {
59
113
  if (props)
60
- fail(filename, 'The component can declare Props only once.');
114
+ failAt('The component can declare Props only once.', statement.getStart(file));
61
115
  props = statement.members.map((member) => {
62
116
  if (!ts.isPropertySignature(member) ||
63
117
  !member.name ||
64
118
  !ts.isIdentifier(member.name)) {
65
- fail(filename, 'Props must use named properties.');
119
+ return failAt('Props must use named properties.', member.getStart(file));
66
120
  }
67
121
  return member.name.text;
68
122
  });
69
- moduleStatements.push(statement.getText(file));
123
+ moduleStatements.push({
124
+ code: statement.getText(file),
125
+ sourceOffset,
126
+ exact: true,
127
+ });
70
128
  continue;
71
129
  }
72
130
  if (ts.isTypeAliasDeclaration(statement) &&
73
131
  statement.name.text === 'Props' &&
74
132
  ts.isTypeLiteralNode(statement.type)) {
75
133
  if (props)
76
- fail(filename, 'The component can declare Props only once.');
134
+ failAt('The component can declare Props only once.', statement.getStart(file));
77
135
  props = statement.type.members.map((member) => {
78
136
  if (!ts.isPropertySignature(member) ||
79
137
  !member.name ||
80
138
  !ts.isIdentifier(member.name)) {
81
- fail(filename, 'Props must use named properties.');
139
+ return failAt('Props must use named properties.', member.getStart(file));
82
140
  }
83
141
  return member.name.text;
84
142
  });
85
- moduleStatements.push(statement.getText(file));
143
+ moduleStatements.push({
144
+ code: statement.getText(file),
145
+ sourceOffset,
146
+ exact: true,
147
+ });
86
148
  continue;
87
149
  }
88
150
  if (ts.isVariableStatement(statement) ||
89
151
  ts.isFunctionDeclaration(statement)) {
90
152
  if (statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
91
- fail(filename, 'Component-local declarations cannot be exported.');
153
+ failAt('Component-local declarations cannot be exported.', statement.getStart(file));
92
154
  }
93
- setupStatements.push(statement.getText(file));
155
+ const retained = hotState && ts.isVariableStatement(statement)
156
+ ? preserveLocalSignals(statement, file, signalBindings)
157
+ : undefined;
158
+ setupStatements.push({
159
+ code: retained ?? statement.getText(file),
160
+ sourceOffset,
161
+ exact: retained === undefined,
162
+ });
94
163
  continue;
95
164
  }
96
- fail(filename, 'Only imports, Props, and component-local variables/functions are supported in the script.');
165
+ failAt('Only imports, Props, and component-local variables/functions are supported in the script.', statement.getStart(file));
97
166
  }
98
167
  if (!props) {
99
168
  props = [];
100
- moduleStatements.push('type Props = Record<string, never>;');
169
+ moduleStatements.push({ code: 'type Props = Record<string, never>;' });
101
170
  }
102
171
  return {
103
- moduleScript: moduleStatements.join('\n'),
104
- setupScript: setupStatements.join('\n'),
172
+ moduleStatements,
173
+ setupStatements,
105
174
  props,
106
175
  };
107
176
  }
@@ -2,6 +2,11 @@ export declare const controlAttribute = "data-workstar-compiler-control";
2
2
  export interface NormalizedControls {
3
3
  source: string;
4
4
  count: number;
5
+ originalOffset: (normalizedOffset: number) => number;
6
+ }
7
+ export declare class ControlSyntaxError extends Error {
8
+ readonly offset: number;
9
+ constructor(message: string, offset: number);
5
10
  }
6
11
  /** Adapt authoring controls to HTML parser insertion modes, including select and table. */
7
12
  export declare function normalizeControls(source: string): NormalizedControls;
@@ -1,5 +1,13 @@
1
1
  const controlNames = new Set(['Each', 'If', 'Else', 'Use']);
2
2
  export const controlAttribute = 'data-workstar-compiler-control';
3
+ export class ControlSyntaxError extends Error {
4
+ offset;
5
+ constructor(message, offset) {
6
+ super(message);
7
+ this.offset = offset;
8
+ this.name = 'ControlSyntaxError';
9
+ }
10
+ }
3
11
  function tagEnd(source, start) {
4
12
  let quote;
5
13
  for (let index = start + 1; index < source.length; index++) {
@@ -15,19 +23,28 @@ function tagEnd(source, start) {
15
23
  return index;
16
24
  }
17
25
  }
18
- throw new Error('Unclosed HTML tag.');
26
+ throw new ControlSyntaxError('Unclosed HTML tag.', start);
19
27
  }
20
28
  /** Adapt authoring controls to HTML parser insertion modes, including select and table. */
21
29
  export function normalizeControls(source) {
22
30
  if (source.includes(controlAttribute)) {
23
- throw new Error(`${controlAttribute} is reserved for the compiler.`);
31
+ throw new ControlSyntaxError(`${controlAttribute} is reserved for the compiler.`, source.indexOf(controlAttribute));
24
32
  }
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;
33
+ let result = '';
34
+ let cursor = 0;
29
35
  let count = 0;
30
36
  const stack = [];
37
+ const replacements = [];
38
+ const appendReplacement = (authoredStart, authoredEnd, replacement) => {
39
+ const generatedStart = result.length;
40
+ result += replacement;
41
+ replacements.push({
42
+ generatedStart,
43
+ generatedEnd: result.length,
44
+ authoredStart,
45
+ authoredEnd,
46
+ });
47
+ };
31
48
  while (cursor < source.length) {
32
49
  const opening = source.indexOf('<', cursor);
33
50
  if (opening < 0)
@@ -36,7 +53,7 @@ export function normalizeControls(source) {
36
53
  if (source.startsWith('<!--', opening)) {
37
54
  const end = source.indexOf('-->', opening + 4);
38
55
  if (end < 0)
39
- throw new Error('Unclosed HTML comment.');
56
+ throw new ControlSyntaxError('Unclosed HTML comment.', opening);
40
57
  result += source.slice(opening, end + 3);
41
58
  cursor = end + 3;
42
59
  continue;
@@ -48,8 +65,9 @@ export function normalizeControls(source) {
48
65
  if (!name || !controlNames.has(name)) {
49
66
  result += tag;
50
67
  cursor = end + 1;
51
- if (/^<textarea(?=[\s>])/i.test(tag)) {
52
- const closing = /<\/textarea\s*>/gi;
68
+ const rawTextTag = /^<(script|style|textarea)(?=[\s>])/i.exec(tag)?.[1];
69
+ if (rawTextTag) {
70
+ const closing = new RegExp(`</${rawTextTag}\\s*>`, 'gi');
53
71
  closing.lastIndex = cursor;
54
72
  const close = closing.exec(source);
55
73
  if (close) {
@@ -62,31 +80,48 @@ export function normalizeControls(source) {
62
80
  const closing = match[1] === '/';
63
81
  const selfClosing = /\/\s*>$/.test(tag);
64
82
  if (closing) {
65
- if (stack.pop() !== name) {
66
- throw new Error(`Mismatched </${name}> control element.`);
83
+ if (stack.pop()?.name !== name) {
84
+ throw new ControlSyntaxError(`Mismatched </${name}> control element.`, opening);
67
85
  }
68
- result += '</template>';
86
+ appendReplacement(opening, end + 1, '</template>');
69
87
  }
70
88
  else {
71
89
  if (selfClosing && name !== 'Use') {
72
- throw new Error(`<${name}> cannot be self-closing.`);
90
+ throw new ControlSyntaxError(`<${name}> cannot be self-closing.`, opening);
73
91
  }
74
92
  const authoredAttributes = tag.slice(match[0].length, -1);
75
93
  const attributes = selfClosing
76
94
  ? authoredAttributes.replace(/\/\s*$/, '')
77
95
  : authoredAttributes;
78
- result += `<template ${controlAttribute}="${name}"${attributes}>`;
79
- if (selfClosing)
80
- result += '</template>';
81
- else
82
- stack.push(name);
96
+ appendReplacement(opening, end + 1, `<template ${controlAttribute}="${name}"${attributes}>${selfClosing ? '</template>' : ''}`);
97
+ if (!selfClosing)
98
+ stack.push({ name, offset: opening });
83
99
  count++;
84
100
  }
85
101
  cursor = end + 1;
86
102
  }
87
103
  result += source.slice(cursor);
88
104
  if (stack.length > 0) {
89
- throw new Error(`Unclosed <${stack.at(-1)}> control element.`);
105
+ const unclosed = stack.at(-1);
106
+ throw new ControlSyntaxError(`Unclosed <${unclosed.name}> control element.`, unclosed.offset);
90
107
  }
91
- return { source: result, count };
108
+ return {
109
+ source: result,
110
+ count,
111
+ originalOffset(normalizedOffset) {
112
+ let difference = 0;
113
+ for (const replacement of replacements) {
114
+ if (normalizedOffset < replacement.generatedStart)
115
+ break;
116
+ if (normalizedOffset < replacement.generatedEnd) {
117
+ return replacement.authoredStart;
118
+ }
119
+ difference +=
120
+ replacement.generatedEnd -
121
+ replacement.generatedStart -
122
+ (replacement.authoredEnd - replacement.authoredStart);
123
+ }
124
+ return normalizedOffset - difference;
125
+ },
126
+ };
92
127
  }
@@ -1,5 +1,13 @@
1
1
  export declare class ComponentCompileError extends Error {
2
+ readonly description: string;
2
3
  readonly filename: string;
3
- constructor(message: string, filename: string);
4
+ readonly position?: {
5
+ readonly line: number;
6
+ readonly column: number;
7
+ } | undefined;
8
+ constructor(description: string, filename: string, position?: {
9
+ readonly line: number;
10
+ readonly column: number;
11
+ } | undefined);
4
12
  }
5
13
  export declare function fail(filename: string, message: string): never;
@@ -1,8 +1,12 @@
1
1
  export class ComponentCompileError extends Error {
2
+ description;
2
3
  filename;
3
- constructor(message, filename) {
4
- super(`${filename}: ${message}`);
4
+ position;
5
+ constructor(description, filename, position) {
6
+ super(`${filename}${position ? `:${position.line}:${position.column}` : ''}: ${description}`);
7
+ this.description = description;
5
8
  this.filename = filename;
9
+ this.position = position;
6
10
  this.name = 'ComponentCompileError';
7
11
  }
8
12
  }
@@ -1,16 +1,20 @@
1
+ import { type SourceOrigin } from './source-origin.js';
1
2
  export { ComponentCompileError } from './errors.js';
2
3
  /** Compile a typed component and its optional co-located stylesheet. */
3
4
  export declare function compileComponentParts(source: string, filename?: string, options?: {
4
5
  componentImports?: 'generated' | 'source';
5
6
  rewriteRelativeImport?: (specifier: string) => string;
6
7
  cssImport?: string;
8
+ hotState?: boolean;
7
9
  }): {
8
10
  code: string;
9
11
  css: string;
12
+ origins: SourceOrigin[];
10
13
  };
11
14
  /** Compile a component to a TypeScript module; use parts to emit its CSS. */
12
15
  export declare function compileComponent(source: string, filename?: string, options?: {
13
16
  componentImports?: 'generated' | 'source';
14
17
  rewriteRelativeImport?: (specifier: string) => string;
15
18
  cssImport?: string;
19
+ hotState?: boolean;
16
20
  }): string;
package/dist/src/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { parseFragment } from 'parse5';
2
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';
3
+ import { ControlSyntaxError, controlAttribute, normalizeControls, } from './control-elements.js';
4
+ import { ComponentCompileError, fail } from './errors.js';
5
+ import { ComponentCode } from './source-origin.js';
6
+ import { compileStyle, StyleCompileError } from './styles.js';
6
7
  export { ComponentCompileError } from './errors.js';
7
8
  const pathExpression = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[\d+\])*$/;
8
9
  const identifier = /^[A-Za-z_$][\w$]*$/;
@@ -47,6 +48,30 @@ function escapeTemplate(value) {
47
48
  .replace(/`/g, '\\`')
48
49
  .replace(/\$\{/g, '\\${');
49
50
  }
51
+ function sourcePosition(source, offset) {
52
+ let line = 1;
53
+ let column = 1;
54
+ for (let index = 0; index < offset; index++) {
55
+ if (source[index] === '\n') {
56
+ line++;
57
+ column = 1;
58
+ }
59
+ else {
60
+ column++;
61
+ }
62
+ }
63
+ return { line, column };
64
+ }
65
+ function offsetAtPosition(source, line, column) {
66
+ let offset = 0;
67
+ for (let currentLine = 1; currentLine < line; currentLine++) {
68
+ const newline = source.indexOf('\n', offset);
69
+ if (newline < 0)
70
+ return source.length;
71
+ offset = newline + 1;
72
+ }
73
+ return Math.min(offset + column - 1, source.length);
74
+ }
50
75
  function controlName(node) {
51
76
  return node.attrs.find((attribute) => attribute.name === controlAttribute)
52
77
  ?.value;
@@ -125,7 +150,7 @@ function scopeMarkupElements(nodes, attribute, filename) {
125
150
  scopeMarkupElements(elementChildren(node), attribute, filename);
126
151
  }
127
152
  }
128
- function eachMarkup(node, filename, locals, sourceText) {
153
+ function eachMarkup(node, filename, locals, source) {
129
154
  const attributes = new Map(node.attrs
130
155
  .filter((attribute) => attribute.name !== controlAttribute)
131
156
  .map((attribute) => [attribute.name, attribute.value]));
@@ -135,19 +160,24 @@ function eachMarkup(node, filename, locals, sourceText) {
135
160
  !attributes.has('key')) {
136
161
  fail(filename, '<Each> needs each={path}, as="name", and key="field|self".');
137
162
  }
138
- const source = dynamicAttribute(attributes.get('each'), filename, locals);
163
+ const collection = dynamicAttribute(attributes.get('each'), filename, locals);
139
164
  const name = attributes.get('as');
140
165
  const key = attributes.get('key');
141
- if (!source || !identifier.test(name))
166
+ if (!collection || !identifier.test(name))
142
167
  fail(filename, 'Invalid <Each> binding.');
143
168
  if (key !== 'self' && !identifier.test(key))
144
169
  fail(filename, '<Each> key must be a field name or "self".');
145
170
  const nested = new Map(locals);
146
171
  nested.set(name, `${name}.value`);
147
- const body = childMarkup(elementChildren(node), filename, nested, sourceText);
172
+ if (source.hotState) {
173
+ const parent = locals.get('__workstarContext') ?? '__context';
174
+ const itemKey = key === 'self' ? `${name}.value` : `${name}.value.${key}`;
175
+ nested.set('__workstarContext', `${parent}?.child('each:${node.sourceCodeLocation?.startOffset}')?.child(${itemKey})`);
176
+ }
177
+ const body = childMarkup(elementChildren(node), filename, nested, source);
148
178
  const keyExpression = key === 'self' ? name : `${name}.${key}`;
149
179
  return ('${__repeat(() => ' +
150
- source +
180
+ collection +
151
181
  ', (' +
152
182
  name +
153
183
  ') => ' +
@@ -208,14 +238,14 @@ function componentMarkup(node, filename, locals, source) {
208
238
  const props = node.attrs
209
239
  .filter((attribute) => attribute !== binding && attribute.name !== controlAttribute)
210
240
  .map((attribute) => {
211
- const name = originalAttributeName(node, attribute.name, source);
241
+ const name = originalAttributeName(node, attribute.name, source.text);
212
242
  if (!identifier.test(name)) {
213
243
  fail(filename, `<Use> prop ${name} must be a TypeScript identifier.`);
214
244
  }
215
245
  const value = dynamicAttribute(attribute.value, filename, locals);
216
246
  const authored = node.sourceCodeLocation?.attrs?.[attribute.name];
217
247
  const raw = authored
218
- ? source.slice(authored.startOffset, authored.endOffset)
248
+ ? source.text.slice(authored.startOffset, authored.endOffset)
219
249
  : '';
220
250
  const output = value ?? (raw.includes('=') ? JSON.stringify(attribute.value) : 'true');
221
251
  return `${name}: ${output}`;
@@ -228,7 +258,11 @@ function componentMarkup(node, filename, locals, source) {
228
258
  childMarkup(children, filename, locals, source) +
229
259
  '`');
230
260
  }
231
- return '${() => ' + renderer + '({' + props.join(', ') + '})}';
261
+ const context = locals.get('__workstarContext') ?? '__context';
262
+ const hotArgument = source.hotState
263
+ ? `, ${context}?.child('use:${node.sourceCodeLocation?.startOffset}')`
264
+ : '';
265
+ return ('${() => ' + renderer + '({' + props.join(', ') + '}' + hotArgument + ')}');
232
266
  }
233
267
  function elementMarkup(node, filename, locals, source) {
234
268
  if (!node.sourceCodeLocation)
@@ -318,13 +352,23 @@ function elementMarkup(node, filename, locals, source) {
318
352
  `</${tag}>`);
319
353
  }
320
354
  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.');
355
+ try {
356
+ if ('tagName' in node)
357
+ return elementMarkup(node, filename, locals, source);
358
+ if ('value' in node)
359
+ return textMarkup(node.value, filename, locals);
360
+ if ('data' in node)
361
+ return `<!--${escapeTemplate(node.data)}-->`;
362
+ return fail(filename, 'Doctype belongs in the document shell.');
363
+ }
364
+ catch (error) {
365
+ if (error instanceof ComponentCompileError &&
366
+ !error.position &&
367
+ node.sourceCodeLocation) {
368
+ throw new ComponentCompileError(error.description, filename, source.position(node.sourceCodeLocation.startOffset));
369
+ }
370
+ throw error;
371
+ }
328
372
  }
329
373
  /** Compile a typed component and its optional co-located stylesheet. */
330
374
  export function compileComponentParts(source, filename = 'Component.workstar', options = {}) {
@@ -333,16 +377,26 @@ export function compileComponentParts(source, filename = 'Component.workstar', o
333
377
  normalized = normalizeControls(source);
334
378
  }
335
379
  catch (error) {
380
+ if (error instanceof ControlSyntaxError) {
381
+ throw new ComponentCompileError(error.message, filename, sourcePosition(source, error.offset));
382
+ }
336
383
  fail(filename, error instanceof Error ? error.message : String(error));
337
384
  }
338
385
  const normalizedSource = normalized.source;
386
+ const markupSource = {
387
+ text: normalizedSource,
388
+ hotState: options.hotState ?? false,
389
+ position: (offset) => sourcePosition(source, normalized.originalOffset(offset)),
390
+ };
339
391
  const errors = [];
340
392
  const fragment = parseFragment(normalizedSource, {
341
393
  sourceCodeLocationInfo: true,
342
- onParseError: (error) => errors.push(`${error.code} at ${error.startLine}:${error.startCol}`),
394
+ onParseError: (error) => errors.push({ code: error.code, startOffset: error.startOffset }),
343
395
  });
344
- if (errors.length > 0)
345
- fail(filename, errors[0]);
396
+ if (errors.length > 0) {
397
+ const error = errors[0];
398
+ throw new ComponentCompileError(error.code, filename, markupSource.position(error.startOffset));
399
+ }
346
400
  const content = fragment.childNodes.filter((node) => !('value' in node) || node.value.trim().length > 0);
347
401
  const first = content[0];
348
402
  const hasScript = first && 'tagName' in first && first.tagName === 'script';
@@ -350,11 +404,13 @@ export function compileComponentParts(source, filename = 'Component.workstar', o
350
404
  content.some((node) => 'tagName' in node && node.tagName === 'script')) {
351
405
  fail(filename, 'A <script lang="ts"> block must come first.');
352
406
  }
353
- const { moduleScript, setupScript, props } = hasScript
354
- ? componentScript(first, filename, options.componentImports ?? 'generated', options.rewriteRelativeImport)
407
+ const { moduleStatements, setupStatements, props } = hasScript
408
+ ? componentScript(first, filename, options.componentImports ?? 'generated', markupSource.position, options.rewriteRelativeImport, options.hotState)
355
409
  : {
356
- moduleScript: 'export type Props = Record<string, never>;',
357
- setupScript: '',
410
+ moduleStatements: [
411
+ { code: 'export type Props = Record<string, never>;' },
412
+ ],
413
+ setupStatements: [],
358
414
  props: [],
359
415
  };
360
416
  const componentBody = hasScript ? content.slice(1) : content;
@@ -387,27 +443,40 @@ export function compileComponentParts(source, filename = 'Component.workstar', o
387
443
  }
388
444
  }
389
445
  catch (error) {
446
+ const cssStart = last.sourceCodeLocation?.startTag?.endOffset;
447
+ if (error instanceof StyleCompileError && cssStart !== undefined) {
448
+ throw new ComponentCompileError(error.message, filename, markupSource.position(cssStart + offsetAtPosition(authoredCss, error.line, error.column)));
449
+ }
390
450
  fail(filename, error instanceof Error ? error.message : String(error));
391
451
  }
392
452
  }
393
453
  assertControlElementsPreserved(normalized.count, markup, filename);
394
- const body = childMarkup(markup, filename, new Map(), normalizedSource);
454
+ const body = childMarkup(markup, filename, new Map(), markupSource);
395
455
  if (body.trim().length === 0)
396
456
  fail(filename, 'The component has no markup.');
397
457
  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 };
458
+ 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");
461
+ if (options.hotState) {
462
+ code.append("import type { HotContext as __WorkstarHotContext } from 'workstar/dev';\n");
463
+ }
464
+ if (css && options.cssImport) {
465
+ code.append(`import ${JSON.stringify(options.cssImport)};\n`);
466
+ }
467
+ code.appendStatements(moduleStatements, normalized.originalOffset);
468
+ code.append(options.hotState
469
+ ? '\nexport function render(props: Props, __context?: __WorkstarHotContext) {\n'
470
+ : '\nexport function render(props: Props) {\n');
471
+ code.append(destructure);
472
+ code.appendStatements(setupStatements, normalized.originalOffset);
473
+ code.append('\n ');
474
+ code.append('return __html`', markup[0]?.sourceCodeLocation
475
+ ? normalized.originalOffset(markup[0].sourceCodeLocation.startOffset)
476
+ : undefined);
477
+ code.append(body);
478
+ code.append('`;\n}\n');
479
+ return { code: code.toString(), css, origins: code.origins };
411
480
  }
412
481
  /** Compile a component to a TypeScript module; use parts to emit its CSS. */
413
482
  export function compileComponent(source, filename = 'Component.workstar', options = {}) {
@@ -0,0 +1,7 @@
1
+ import { type RawSourceMap } from 'source-map-js';
2
+ import type { SourceOrigin } from './source-origin.js';
3
+ /** Keep authored script locations while lowering generated TypeScript to Vite JavaScript. */
4
+ export declare function transpileComponent(generated: string, authored: string, filename: string, origins: readonly SourceOrigin[]): {
5
+ code: string;
6
+ map: RawSourceMap;
7
+ };
@@ -0,0 +1,50 @@
1
+ import { basename } from 'node:path';
2
+ import { SourceMapConsumer, SourceMapGenerator, } from 'source-map-js';
3
+ import ts from 'typescript';
4
+ function positionAt(source, offset) {
5
+ const before = source.slice(0, offset);
6
+ const newline = before.lastIndexOf('\n');
7
+ return {
8
+ line: before.split('\n').length,
9
+ column: offset - newline - 1,
10
+ };
11
+ }
12
+ function componentSourceMap(authored, generated, sourceName, intermediateName, origins) {
13
+ const map = new SourceMapGenerator({ file: intermediateName });
14
+ for (const origin of origins) {
15
+ map.addMapping({
16
+ generated: positionAt(generated, origin.generatedOffset),
17
+ original: positionAt(authored, origin.authoredOffset),
18
+ source: sourceName,
19
+ });
20
+ }
21
+ map.setSourceContent(sourceName, authored);
22
+ return map.toJSON();
23
+ }
24
+ /** Keep authored script locations while lowering generated TypeScript to Vite JavaScript. */
25
+ export function transpileComponent(generated, authored, filename, origins) {
26
+ const intermediateName = `${basename(filename)}.generated.ts`;
27
+ const output = ts.transpileModule(generated, {
28
+ fileName: intermediateName,
29
+ compilerOptions: {
30
+ module: ts.ModuleKind.ESNext,
31
+ target: ts.ScriptTarget.ES2022,
32
+ sourceMap: true,
33
+ inlineSources: true,
34
+ },
35
+ });
36
+ if (!output.sourceMapText)
37
+ throw new Error('TypeScript did not emit a source map.');
38
+ const typescriptMap = JSON.parse(output.sourceMapText);
39
+ const intermediateSource = typescriptMap.sources[0];
40
+ if (!intermediateSource)
41
+ throw new Error('TypeScript emitted an empty source map.');
42
+ const authoredMap = componentSourceMap(authored, generated, filename, intermediateName, origins);
43
+ const composed = SourceMapGenerator.fromSourceMap(new SourceMapConsumer(typescriptMap));
44
+ composed.applySourceMap(new SourceMapConsumer(authoredMap), intermediateSource);
45
+ composed.setSourceContent(filename, authored);
46
+ return {
47
+ code: output.outputText.replace(/^\/\/# sourceMappingURL=.*\n?/m, ''),
48
+ map: composed.toJSON(),
49
+ };
50
+ }
@@ -0,0 +1,14 @@
1
+ import type { ScriptStatement } from './component-script.js';
2
+ export interface SourceOrigin {
3
+ readonly generatedOffset: number;
4
+ readonly authoredOffset: number;
5
+ }
6
+ /** Records provenance while assembling the generated TypeScript module. */
7
+ export declare class ComponentCode {
8
+ private readonly parts;
9
+ private length;
10
+ readonly origins: SourceOrigin[];
11
+ append(code: string, authoredOffset?: number, exact?: boolean): void;
12
+ appendStatements(statements: readonly ScriptStatement[], sourceOffset: (offset: number) => number): void;
13
+ toString(): string;
14
+ }
@@ -0,0 +1,41 @@
1
+ /** Records provenance while assembling the generated TypeScript module. */
2
+ export class ComponentCode {
3
+ parts = [];
4
+ length = 0;
5
+ origins = [];
6
+ append(code, authoredOffset, exact = false) {
7
+ if (authoredOffset !== undefined) {
8
+ this.origins.push({
9
+ generatedOffset: this.length,
10
+ authoredOffset,
11
+ });
12
+ if (exact) {
13
+ for (const match of code.matchAll(/\n([^\n]*)/g)) {
14
+ const line = match[1] ?? '';
15
+ const firstToken = /\S/.exec(line)?.index;
16
+ if (firstToken === undefined)
17
+ continue;
18
+ const offset = match.index + 1 + firstToken;
19
+ this.origins.push({
20
+ generatedOffset: this.length + offset,
21
+ authoredOffset: authoredOffset + offset,
22
+ });
23
+ }
24
+ }
25
+ }
26
+ this.parts.push(code);
27
+ this.length += code.length;
28
+ }
29
+ appendStatements(statements, sourceOffset) {
30
+ statements.forEach((statement, index) => {
31
+ if (index > 0)
32
+ this.append('\n');
33
+ this.append(statement.code, statement.sourceOffset === undefined
34
+ ? undefined
35
+ : sourceOffset(statement.sourceOffset), statement.exact);
36
+ });
37
+ }
38
+ toString() {
39
+ return this.parts.join('');
40
+ }
41
+ }
@@ -2,5 +2,10 @@ export interface CompiledStyle {
2
2
  css: string;
3
3
  scopeAttribute?: string;
4
4
  }
5
+ export declare class StyleCompileError extends Error {
6
+ readonly line: number;
7
+ readonly column: number;
8
+ constructor(message: string, line: number, column: number);
9
+ }
5
10
  /** Compile CSS at build time; styles never depend on client hydration. */
6
11
  export declare function compileStyle(css: string, filename: string, global?: boolean): CompiledStyle;
@@ -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
  }
package/dist/src/vite.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
3
- import ts from 'typescript';
4
3
  import { compileComponentParts } from './index.js';
4
+ import { transpileComponent } from './source-map.js';
5
5
  /** Compile authored components as Vite modules without writing into src. */
6
6
  export function workstar(options = {}) {
7
7
  let sourceDirectory;
8
+ let development = false;
8
9
  const styleSuffix = '.css?workstar-style';
10
+ const compiled = new Map();
9
11
  function isAuthoredComponent(filename) {
10
12
  const localPath = relative(sourceDirectory, filename);
11
13
  return (extname(localPath) === '.workstar' &&
@@ -13,11 +15,28 @@ export function workstar(options = {}) {
13
15
  !localPath.startsWith(`..${sep}`) &&
14
16
  !isAbsolute(localPath));
15
17
  }
18
+ function rootComponents(modules) {
19
+ const roots = new Set();
20
+ const visited = new Set();
21
+ function visit(module) {
22
+ if (visited.has(module))
23
+ return;
24
+ visited.add(module);
25
+ const parents = [...module.importers].filter((importer) => importer.id && isAuthoredComponent(importer.id.split('?', 1)[0]));
26
+ if (parents.length === 0)
27
+ roots.add(module);
28
+ else
29
+ parents.forEach(visit);
30
+ }
31
+ modules.forEach(visit);
32
+ return [...roots];
33
+ }
16
34
  return {
17
35
  name: 'workstar',
18
36
  enforce: 'pre',
19
37
  configResolved(config) {
20
38
  sourceDirectory = resolve(config.root, options.source ?? 'src');
39
+ development = config.command === 'serve';
21
40
  },
22
41
  resolveId(id) {
23
42
  if (!id.endsWith(styleSuffix))
@@ -41,26 +60,37 @@ export function workstar(options = {}) {
41
60
  const generated = compileComponentParts(source, filename, {
42
61
  componentImports: 'source',
43
62
  cssImport: `${filename}${styleSuffix}`,
63
+ hotState: development,
44
64
  });
65
+ compiled.set(filename, generated);
66
+ const output = transpileComponent(generated.code, source, filename, generated.origins);
45
67
  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,
68
+ code: output.code,
69
+ map: JSON.stringify(output.map),
54
70
  };
55
71
  },
56
- handleHotUpdate(context) {
72
+ async handleHotUpdate(context) {
57
73
  if (!isAuthoredComponent(context.file))
58
74
  return;
75
+ const previous = compiled.get(context.file);
76
+ const next = compileComponentParts(await context.read(), context.file, {
77
+ componentImports: 'source',
78
+ cssImport: `${context.file}${styleSuffix}`,
79
+ hotState: development,
80
+ });
59
81
  const stylesheet = context.server.moduleGraph.getModuleById(`${context.file}${styleSuffix}`);
60
82
  if (!stylesheet)
61
83
  return;
62
84
  context.server.moduleGraph.invalidateModule(stylesheet);
63
- return [...context.modules, stylesheet];
85
+ if (previous &&
86
+ previous.code === next.code &&
87
+ previous.css !== next.css) {
88
+ compiled.set(context.file, next);
89
+ return [stylesheet];
90
+ }
91
+ const roots = rootComponents(context.modules);
92
+ roots.forEach((module) => context.server.moduleGraph.invalidateModule(module));
93
+ return [...roots, stylesheet];
64
94
  },
65
95
  };
66
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workstar-compiler",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Component compiler and Vite plugin for Workstar applications.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@
38
38
  "parse5": "^8.0.0",
39
39
  "postcss": "^8.5.28",
40
40
  "postcss-selector-parser": "^7.1.6",
41
+ "source-map-js": "^1.2.1",
41
42
  "typescript": "^6.0.3"
42
43
  },
43
44
  "peerDependencies": {