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.
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)
@@ -268,7 +302,12 @@ function elementMarkup(node, filename, locals, source) {
268
302
  fail(filename, `Use on:event instead of ${name}.`);
269
303
  }
270
304
  const value = dynamicAttribute(attribute.value, filename, locals);
271
- if (name.startsWith('on:')) {
305
+ if (name === 'bind:attrs') {
306
+ if (!value)
307
+ fail(filename, 'bind:attrs needs a record expression.');
308
+ result += '${__attrs(() => ' + value + ')}';
309
+ }
310
+ else if (name.startsWith('on:')) {
272
311
  if (!value)
273
312
  fail(filename, `${name} needs a handler expression.`);
274
313
  result += '${__on(' + JSON.stringify(name.slice(3)) + ', ' + value + ')}';
@@ -318,13 +357,23 @@ function elementMarkup(node, filename, locals, source) {
318
357
  `</${tag}>`);
319
358
  }
320
359
  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.');
360
+ try {
361
+ if ('tagName' in node)
362
+ return elementMarkup(node, filename, locals, source);
363
+ if ('value' in node)
364
+ return textMarkup(node.value, filename, locals);
365
+ if ('data' in node)
366
+ return `<!--${escapeTemplate(node.data)}-->`;
367
+ return fail(filename, 'Doctype belongs in the document shell.');
368
+ }
369
+ catch (error) {
370
+ if (error instanceof ComponentCompileError &&
371
+ !error.position &&
372
+ node.sourceCodeLocation) {
373
+ throw new ComponentCompileError(error.description, filename, source.position(node.sourceCodeLocation.startOffset));
374
+ }
375
+ throw error;
376
+ }
328
377
  }
329
378
  /** Compile a typed component and its optional co-located stylesheet. */
330
379
  export function compileComponentParts(source, filename = 'Component.workstar', options = {}) {
@@ -333,16 +382,26 @@ export function compileComponentParts(source, filename = 'Component.workstar', o
333
382
  normalized = normalizeControls(source);
334
383
  }
335
384
  catch (error) {
385
+ if (error instanceof ControlSyntaxError) {
386
+ throw new ComponentCompileError(error.message, filename, sourcePosition(source, error.offset));
387
+ }
336
388
  fail(filename, error instanceof Error ? error.message : String(error));
337
389
  }
338
390
  const normalizedSource = normalized.source;
391
+ const markupSource = {
392
+ text: normalizedSource,
393
+ hotState: options.hotState ?? false,
394
+ position: (offset) => sourcePosition(source, normalized.originalOffset(offset)),
395
+ };
339
396
  const errors = [];
340
397
  const fragment = parseFragment(normalizedSource, {
341
398
  sourceCodeLocationInfo: true,
342
- onParseError: (error) => errors.push(`${error.code} at ${error.startLine}:${error.startCol}`),
399
+ onParseError: (error) => errors.push({ code: error.code, startOffset: error.startOffset }),
343
400
  });
344
- if (errors.length > 0)
345
- fail(filename, errors[0]);
401
+ if (errors.length > 0) {
402
+ const error = errors[0];
403
+ throw new ComponentCompileError(error.code, filename, markupSource.position(error.startOffset));
404
+ }
346
405
  const content = fragment.childNodes.filter((node) => !('value' in node) || node.value.trim().length > 0);
347
406
  const first = content[0];
348
407
  const hasScript = first && 'tagName' in first && first.tagName === 'script';
@@ -350,11 +409,13 @@ export function compileComponentParts(source, filename = 'Component.workstar', o
350
409
  content.some((node) => 'tagName' in node && node.tagName === 'script')) {
351
410
  fail(filename, 'A <script lang="ts"> block must come first.');
352
411
  }
353
- const { moduleScript, setupScript, props } = hasScript
354
- ? componentScript(first, filename, options.componentImports ?? 'generated', options.rewriteRelativeImport)
412
+ const { moduleStatements, setupStatements, props } = hasScript
413
+ ? componentScript(first, filename, options.componentImports ?? 'generated', markupSource.position, options.rewriteRelativeImport, options.hotState)
355
414
  : {
356
- moduleScript: 'export type Props = Record<string, never>;',
357
- setupScript: '',
415
+ moduleStatements: [
416
+ { code: 'export type Props = Record<string, never>;' },
417
+ ],
418
+ setupStatements: [],
358
419
  props: [],
359
420
  };
360
421
  const componentBody = hasScript ? content.slice(1) : content;
@@ -387,27 +448,40 @@ export function compileComponentParts(source, filename = 'Component.workstar', o
387
448
  }
388
449
  }
389
450
  catch (error) {
451
+ const cssStart = last.sourceCodeLocation?.startTag?.endOffset;
452
+ if (error instanceof StyleCompileError && cssStart !== undefined) {
453
+ throw new ComponentCompileError(error.message, filename, markupSource.position(cssStart + offsetAtPosition(authoredCss, error.line, error.column)));
454
+ }
390
455
  fail(filename, error instanceof Error ? error.message : String(error));
391
456
  }
392
457
  }
393
458
  assertControlElementsPreserved(normalized.count, markup, filename);
394
- const body = childMarkup(markup, filename, new Map(), normalizedSource);
459
+ const body = childMarkup(markup, filename, new Map(), markupSource);
395
460
  if (body.trim().length === 0)
396
461
  fail(filename, 'The component has no markup.');
397
462
  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 };
463
+ const code = new ComponentCode();
464
+ code.append('// Generated by workstar-compiler. Edit the source component instead.\n');
465
+ code.append("import { html as __html, attr as __attr, attrs as __attrs, on as __on, repeat as __repeat, textareaValue as __textareaValue } from 'workstar';\n");
466
+ if (options.hotState) {
467
+ code.append("import type { HotContext as __WorkstarHotContext } from 'workstar/dev';\n");
468
+ }
469
+ if (css && options.cssImport) {
470
+ code.append(`import ${JSON.stringify(options.cssImport)};\n`);
471
+ }
472
+ code.appendStatements(moduleStatements, normalized.originalOffset);
473
+ code.append(options.hotState
474
+ ? '\nexport function render(props: Props, __context?: __WorkstarHotContext) {\n'
475
+ : '\nexport function render(props: Props) {\n');
476
+ code.append(destructure);
477
+ code.appendStatements(setupStatements, normalized.originalOffset);
478
+ code.append('\n ');
479
+ code.append('return __html`', markup[0]?.sourceCodeLocation
480
+ ? normalized.originalOffset(markup[0].sourceCodeLocation.startOffset)
481
+ : undefined);
482
+ code.append(body);
483
+ code.append('`;\n}\n');
484
+ return { code: code.toString(), css, origins: code.origins };
411
485
  }
412
486
  /** Compile a component to a TypeScript module; use parts to emit its CSS. */
413
487
  export function compileComponent(source, filename = 'Component.workstar', options = {}) {
@@ -1,8 +1,22 @@
1
1
  export interface ProjectStyles {
2
2
  cssOutputPath?: string;
3
3
  }
4
- /** Compile one authored view without rewriting unrelated generated modules. */
4
+ export interface ForeignAuditEntry {
5
+ filename: string;
6
+ supported: boolean;
7
+ reason?: string;
8
+ }
9
+ export interface ForeignAudit {
10
+ total: number;
11
+ supported: number;
12
+ entries: ForeignAuditEntry[];
13
+ }
14
+ /** Compile one authored Workstar view without rewriting unrelated generated modules. */
5
15
  export declare function compileViewFile(sourcePath: string, outputPath: string, options?: ProjectStyles): Promise<void>;
16
+ /** Compile a supported TSX or Vue SFC through the Workstar runtime. */
17
+ export declare function compileForeignFile(sourcePath: string, outputPath: string, options?: ProjectStyles): Promise<void>;
18
+ /** Report source compatibility without writing generated files or changing the app. */
19
+ export declare function auditForeignDirectory(sourceDirectory: string): Promise<ForeignAudit>;
6
20
  /** Compile every view before writing any generated modules. */
7
21
  export declare function compileViewDirectory(sourceDirectory: string, outputDirectory: string, options?: ProjectStyles): Promise<string[]>;
8
22
  /** Watch a view directory and recompile changed views for local development. */
@@ -2,6 +2,9 @@ import { watch } from 'node:fs';
2
2
  import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { compileComponentParts } from './index.js';
5
+ import { convertForeignComponent } from './compat.js';
6
+ import { reactComponentExportName } from './react-compat.js';
7
+ import { resolveReactComponentImport } from './react-import-resolution.js';
5
8
  async function filesInDirectory(directory, include, prefix = '') {
6
9
  const entries = await readdir(join(directory, prefix), {
7
10
  withFileTypes: true,
@@ -18,13 +21,19 @@ function generatedPath(viewPath) {
18
21
  return `${viewPath.slice(0, -'.workstar'.length)}.ts`;
19
22
  }
20
23
  function relocatedImport(sourcePath, outputPath, specifier) {
24
+ if (specifier.endsWith('.tsx?workstar')) {
25
+ const sourceTarget = resolve(dirname(sourcePath), specifier.slice(0, -'?workstar'.length));
26
+ const generatedTarget = resolve(dirname(outputPath), relative(dirname(sourcePath), sourceTarget).slice(0, -'.tsx'.length));
27
+ const path = relative(dirname(outputPath), generatedTarget)
28
+ .split(sep)
29
+ .join('/');
30
+ return path.startsWith('.') ? path : `./${path}`;
31
+ }
21
32
  const target = resolve(dirname(sourcePath), specifier);
22
33
  const path = relative(dirname(outputPath), target).split(sep).join('/');
23
34
  return path.startsWith('.') ? path : `./${path}`;
24
35
  }
25
- /** Compile one authored view without rewriting unrelated generated modules. */
26
- export async function compileViewFile(sourcePath, outputPath, options = {}) {
27
- const source = await readFile(sourcePath, 'utf8');
36
+ async function compileSourceFile(sourcePath, outputPath, source, options, namedExport) {
28
37
  const { code, css } = compileComponentParts(source, sourcePath, {
29
38
  rewriteRelativeImport: (specifier) => relocatedImport(sourcePath, outputPath, specifier),
30
39
  });
@@ -32,12 +41,55 @@ export async function compileViewFile(sourcePath, outputPath, options = {}) {
32
41
  throw new Error(`${sourcePath}: pass cssOutputPath to emit component styles.`);
33
42
  }
34
43
  await mkdir(dirname(outputPath), { recursive: true });
35
- await writeFile(outputPath, code, 'utf8');
44
+ await writeFile(outputPath, code + (namedExport ? `\nexport { render as ${namedExport} };\n` : ''), 'utf8');
36
45
  if (options.cssOutputPath) {
37
46
  await mkdir(dirname(options.cssOutputPath), { recursive: true });
38
47
  await writeFile(options.cssOutputPath, css, 'utf8');
39
48
  }
40
49
  }
50
+ /** Compile one authored Workstar view without rewriting unrelated generated modules. */
51
+ export async function compileViewFile(sourcePath, outputPath, options = {}) {
52
+ await compileSourceFile(sourcePath, outputPath, await readFile(sourcePath, 'utf8'), options);
53
+ }
54
+ /** Compile a supported TSX or Vue SFC through the Workstar runtime. */
55
+ export async function compileForeignFile(sourcePath, outputPath, options = {}) {
56
+ const original = await readFile(sourcePath, 'utf8');
57
+ const source = convertForeignComponent(original, sourcePath, {
58
+ resolveReactImport: (specifier) => resolveReactComponentImport(sourcePath, specifier),
59
+ });
60
+ const namedExport = sourcePath.endsWith('.tsx')
61
+ ? reactComponentExportName(original, sourcePath)
62
+ : undefined;
63
+ await compileSourceFile(sourcePath, outputPath, source, options, namedExport);
64
+ }
65
+ /** Report source compatibility without writing generated files or changing the app. */
66
+ export async function auditForeignDirectory(sourceDirectory) {
67
+ const paths = await filesInDirectory(sourceDirectory, (name) => /\.(tsx|vue)$/.test(name) &&
68
+ !/\.(test|spec|stories)\.(tsx|vue)$/.test(name));
69
+ const entries = await Promise.all(paths.map(async (filename) => {
70
+ const sourcePath = join(sourceDirectory, filename);
71
+ try {
72
+ const converted = convertForeignComponent(await readFile(sourcePath, 'utf8'), sourcePath, {
73
+ resolveReactImport: (specifier) => resolveReactComponentImport(sourcePath, specifier),
74
+ });
75
+ compileComponentParts(converted, `${sourcePath}.workstar`);
76
+ return { filename, supported: true };
77
+ }
78
+ catch (error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ return {
81
+ filename,
82
+ supported: false,
83
+ reason: message.replace(sourcePath, filename),
84
+ };
85
+ }
86
+ }));
87
+ return {
88
+ total: entries.length,
89
+ supported: entries.filter((entry) => entry.supported).length,
90
+ entries,
91
+ };
92
+ }
41
93
  /** Compile every view before writing any generated modules. */
42
94
  export async function compileViewDirectory(sourceDirectory, outputDirectory, options = {}) {
43
95
  const viewPaths = await filesInDirectory(sourceDirectory, (name) => name.endsWith('.workstar'));
@@ -0,0 +1,6 @@
1
+ /** The source export used by a named TSX import; undefined means default export. */
2
+ export declare function reactComponentExportName(source: string, filename?: string): string | undefined;
3
+ /** Converts one typed, stateless exported function component; unsupported behavior fails closed. */
4
+ export declare function convertReactComponent(source: string, filename?: string, options?: {
5
+ resolveReactImport?: (specifier: string) => string | undefined;
6
+ }): string;