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 +7 -0
- package/dist/src/component-script.d.ts +11 -3
- package/dist/src/component-script.js +93 -24
- package/dist/src/control-elements.d.ts +5 -0
- package/dist/src/control-elements.js +55 -20
- package/dist/src/errors.d.ts +9 -1
- package/dist/src/errors.js +6 -2
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +108 -39
- package/dist/src/source-map.d.ts +7 -0
- package/dist/src/source-map.js +50 -0
- package/dist/src/source-origin.d.ts +14 -0
- package/dist/src/source-origin.js +41 -0
- package/dist/src/styles.d.ts +5 -0
- package/dist/src/styles.js +41 -18
- package/dist/src/vite.js +41 -11
- package/package.json +2 -1
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
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
84
|
+
failAt('Import a relative .workstar view with a default import.', statement.getStart(file));
|
|
41
85
|
}
|
|
42
|
-
moduleStatements.push(
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
return failAt('Props must use named properties.', member.getStart(file));
|
|
66
120
|
}
|
|
67
121
|
return member.name.text;
|
|
68
122
|
});
|
|
69
|
-
moduleStatements.push(
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
return failAt('Props must use named properties.', member.getStart(file));
|
|
82
140
|
}
|
|
83
141
|
return member.name.text;
|
|
84
142
|
});
|
|
85
|
-
moduleStatements.push(
|
|
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
|
-
|
|
153
|
+
failAt('Component-local declarations cannot be exported.', statement.getStart(file));
|
|
92
154
|
}
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
31
|
+
throw new ControlSyntaxError(`${controlAttribute} is reserved for the compiler.`, source.indexOf(controlAttribute));
|
|
24
32
|
}
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
83
|
+
if (stack.pop()?.name !== name) {
|
|
84
|
+
throw new ControlSyntaxError(`Mismatched </${name}> control element.`, opening);
|
|
67
85
|
}
|
|
68
|
-
|
|
86
|
+
appendReplacement(opening, end + 1, '</template>');
|
|
69
87
|
}
|
|
70
88
|
else {
|
|
71
89
|
if (selfClosing && name !== 'Use') {
|
|
72
|
-
throw new
|
|
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
|
-
|
|
79
|
-
if (selfClosing)
|
|
80
|
-
|
|
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
|
-
|
|
105
|
+
const unclosed = stack.at(-1);
|
|
106
|
+
throw new ControlSyntaxError(`Unclosed <${unclosed.name}> control element.`, unclosed.offset);
|
|
90
107
|
}
|
|
91
|
-
return {
|
|
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
|
}
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
export declare class ComponentCompileError extends Error {
|
|
2
|
+
readonly description: string;
|
|
2
3
|
readonly filename: string;
|
|
3
|
-
|
|
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;
|
package/dist/src/errors.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
export class ComponentCompileError extends Error {
|
|
2
|
+
description;
|
|
2
3
|
filename;
|
|
3
|
-
|
|
4
|
-
|
|
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
|
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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(
|
|
394
|
+
onParseError: (error) => errors.push({ code: error.code, startOffset: error.startOffset }),
|
|
343
395
|
});
|
|
344
|
-
if (errors.length > 0)
|
|
345
|
-
|
|
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 {
|
|
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
|
-
|
|
357
|
-
|
|
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(),
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
+
}
|
package/dist/src/styles.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/styles.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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:
|
|
47
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|