tannijs-compiler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # @tannijs/compiler
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "tannijs-compiler",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "SFC compiler for the Tanni framework",
6
+ "license": "MIT",
7
+ "author": "Sebastijan Zindl",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ }
13
+ },
14
+ "main": "./src/index.ts",
15
+ "types": "./src/index.ts",
16
+ "files": [
17
+ "src",
18
+ "dist"
19
+ ]
20
+ }
package/src/codegen.ts ADDED
@@ -0,0 +1,354 @@
1
+ import type { CompileOptions, CompileResult, TransformElementNode, TransformNode, TransformRoot } from './types';
2
+
3
+ interface CodegenContext {
4
+ lines: string[];
5
+ indent: number;
6
+ identifier: number;
7
+ delegatedEvents: Set<string>;
8
+ }
9
+
10
+ const DEFAULT_RUNTIME_MODULE = 'tannijs/internals';
11
+
12
+ export function generate(root: TransformRoot, script: string, options: CompileOptions = {}): CompileResult {
13
+ const runtimeModule = options.runtimeModule ?? DEFAULT_RUNTIME_MODULE;
14
+ const componentName = options.componentName ?? 'Component';
15
+ const preparedScript = rewriteScriptSetupProps(script);
16
+ const scriptParts = splitScriptParts(preparedScript);
17
+ const context: CodegenContext = {
18
+ lines: [],
19
+ indent: 0,
20
+ identifier: 0,
21
+ delegatedEvents: new Set<string>(),
22
+ };
23
+
24
+ for (const scriptImport of scriptParts.imports) {
25
+ pushLine(context, scriptImport);
26
+ }
27
+ if (scriptParts.imports.length > 0) {
28
+ pushLine(context, '');
29
+ }
30
+
31
+ pushLine(context, `import { createEffect, delegateEvents } from '${runtimeModule}';`);
32
+ pushLine(context, '');
33
+ pushLine(context, `export default function ${componentName}(__props = {}) {`);
34
+ context.indent += 1;
35
+
36
+ if (scriptParts.body.length > 0) {
37
+ for (const line of scriptParts.body.split('\n')) {
38
+ pushLine(context, line);
39
+ }
40
+ pushLine(context, '');
41
+ }
42
+
43
+ const rootNodeName = declareNode(context, 'document.createDocumentFragment()');
44
+ for (const child of root.children) {
45
+ emitNode(context, child, rootNodeName);
46
+ }
47
+
48
+ if (context.delegatedEvents.size > 0) {
49
+ const eventList = Array.from(context.delegatedEvents)
50
+ .sort()
51
+ .map((eventName) => JSON.stringify(eventName))
52
+ .join(', ');
53
+ pushLine(context, `delegateEvents([${eventList}]);`);
54
+ }
55
+
56
+ pushLine(context, `return ${rootNodeName};`);
57
+ context.indent -= 1;
58
+ pushLine(context, '}');
59
+
60
+ return { code: `${context.lines.join('\n')}\n`, css: '' };
61
+ }
62
+
63
+ function emitNode(context: CodegenContext, node: TransformNode, parentName: string): void {
64
+ if (node.type === 'Text') {
65
+ emitTextNode(context, node, parentName);
66
+ return;
67
+ }
68
+
69
+ if (node.directives.for) {
70
+ emitForNode(context, node, parentName);
71
+ return;
72
+ }
73
+
74
+ if (node.directives.if) {
75
+ emitIfNode(context, node, parentName);
76
+ return;
77
+ }
78
+
79
+ emitPlainElement(context, node, parentName);
80
+ }
81
+
82
+ function emitTextNode(context: CodegenContext, node: Extract<TransformNode, { type: 'Text' }>, parentName: string): void {
83
+ const textNodeName = declareNode(context, "document.createTextNode('')");
84
+ pushLine(context, `${parentName}.append(${textNodeName});`);
85
+
86
+ const hasDynamic = node.segments.some((segment) => segment.type === 'dynamic');
87
+ if (!hasDynamic) {
88
+ const staticValue = node.segments
89
+ .map((segment) => (segment.type === 'static' ? segment.value : ''))
90
+ .join('');
91
+ pushLine(context, `${textNodeName}.data = ${JSON.stringify(staticValue)};`);
92
+ return;
93
+ }
94
+
95
+ pushLine(context, 'createEffect(() => {');
96
+ context.indent += 1;
97
+ const valueExpression = node.segments
98
+ .map((segment) => {
99
+ if (segment.type === 'static') {
100
+ return JSON.stringify(segment.value);
101
+ }
102
+ return `String(${segment.expression})`;
103
+ })
104
+ .join(' + ');
105
+ pushLine(context, `${textNodeName}.data = ${valueExpression};`);
106
+ context.indent -= 1;
107
+ pushLine(context, '});');
108
+ }
109
+
110
+ function emitPlainElement(context: CodegenContext, node: TransformElementNode, parentName: string): string {
111
+ if (node.isComponent) {
112
+ return emitComponentElement(context, node, parentName);
113
+ }
114
+
115
+ const elementName = declareNode(context, `document.createElement(${JSON.stringify(node.tag)})`);
116
+ pushLine(context, `${parentName}.append(${elementName});`);
117
+
118
+ for (const attr of node.attributes) {
119
+ pushLine(context, `${elementName}.setAttribute(${JSON.stringify(attr.name)}, ${JSON.stringify(attr.value)});`);
120
+ }
121
+
122
+ for (const binding of node.bindings) {
123
+ pushLine(context, 'createEffect(() => {');
124
+ context.indent += 1;
125
+ const valueName = createIdentifier(context, 'attrValue');
126
+ pushLine(context, `const ${valueName} = ${binding.expression};`);
127
+ pushLine(context, `if (${valueName} == null || ${valueName} === false) {`);
128
+ context.indent += 1;
129
+ pushLine(context, `${elementName}.removeAttribute(${JSON.stringify(binding.name)});`);
130
+ context.indent -= 1;
131
+ pushLine(context, '} else {');
132
+ context.indent += 1;
133
+ pushLine(context, `${elementName}.setAttribute(${JSON.stringify(binding.name)}, String(${valueName}));`);
134
+ context.indent -= 1;
135
+ pushLine(context, '}');
136
+ context.indent -= 1;
137
+ pushLine(context, '});');
138
+ }
139
+
140
+ for (const eventBinding of node.events) {
141
+ context.delegatedEvents.add(eventBinding.name.toLowerCase());
142
+ pushLine(
143
+ context,
144
+ `${elementName}.__tnDelegatedHandlers ??= {};`
145
+ );
146
+ const handlerExpr = toEventHandler(eventBinding.expression);
147
+ pushLine(
148
+ context,
149
+ `${elementName}.__tnDelegatedHandlers[${JSON.stringify(eventBinding.name.toLowerCase())}] = ${handlerExpr};`
150
+ );
151
+ }
152
+
153
+ for (const child of node.children) {
154
+ emitNode(context, child, elementName);
155
+ }
156
+
157
+ return elementName;
158
+ }
159
+
160
+ function emitComponentElement(context: CodegenContext, node: TransformElementNode, parentName: string): string {
161
+ const propEntries: string[] = [];
162
+
163
+ for (const attr of node.attributes) {
164
+ propEntries.push(`${JSON.stringify(attr.name)}: ${JSON.stringify(attr.value)}`);
165
+ }
166
+
167
+ for (const binding of node.bindings) {
168
+ propEntries.push(`get ${JSON.stringify(binding.name)}() { return ${binding.expression}; }`);
169
+ }
170
+
171
+ for (const eventBinding of node.events) {
172
+ const eventPropName = `on${eventBinding.name.charAt(0).toUpperCase()}${eventBinding.name.slice(1)}`;
173
+ const handlerExpr = toEventHandler(eventBinding.expression);
174
+ propEntries.push(`${JSON.stringify(eventPropName)}: ${handlerExpr}`);
175
+ }
176
+
177
+ const propsArg = propEntries.length > 0 ? `{ ${propEntries.join(', ')} }` : '{}';
178
+
179
+ const componentName = declareNode(context, `${node.tag}(${propsArg})`);
180
+ pushLine(context, `${parentName}.append(${componentName});`);
181
+
182
+ for (const child of node.children) {
183
+ emitNode(context, child, componentName);
184
+ }
185
+
186
+ return componentName;
187
+ }
188
+
189
+ function emitIfNode(context: CodegenContext, node: TransformElementNode, parentName: string): void {
190
+ const endMarker = declareNode(context, "document.createComment('tn-if')");
191
+ pushLine(context, `${parentName}.append(${endMarker});`);
192
+ const nodesName = createIdentifier(context, 'ifNodes');
193
+ pushLine(context, `let ${nodesName} = [];`);
194
+ pushLine(context, 'createEffect(() => {');
195
+ context.indent += 1;
196
+ pushLine(context, `for (const node of ${nodesName}) {`);
197
+ context.indent += 1;
198
+ pushLine(context, `if (node.parentNode === ${parentName}) ${parentName}.removeChild(node);`);
199
+ context.indent -= 1;
200
+ pushLine(context, '}');
201
+ pushLine(context, `${nodesName} = [];`);
202
+ pushLine(context, `if (${node.directives.if}) {`);
203
+ context.indent += 1;
204
+ const fragmentName = declareNode(context, 'document.createDocumentFragment()');
205
+ emitPlainElement(context, removeControlFlow(node), fragmentName);
206
+ const nextNodesName = createIdentifier(context, 'nextIfNodes');
207
+ pushLine(context, `const ${nextNodesName} = Array.from(${fragmentName}.childNodes);`);
208
+ pushLine(context, `for (const node of ${nextNodesName}) ${parentName}.insertBefore(node, ${endMarker});`);
209
+ pushLine(context, `${nodesName} = ${nextNodesName};`);
210
+ context.indent -= 1;
211
+ pushLine(context, '}');
212
+ context.indent -= 1;
213
+ pushLine(context, '});');
214
+ }
215
+
216
+ function emitForNode(context: CodegenContext, node: TransformElementNode, parentName: string): void {
217
+ const forDirective = node.directives.for;
218
+ if (!forDirective) {
219
+ return;
220
+ }
221
+
222
+ const endMarker = declareNode(context, "document.createComment('tn-for')");
223
+ pushLine(context, `${parentName}.append(${endMarker});`);
224
+ const renderedName = createIdentifier(context, 'forNodes');
225
+ pushLine(context, `let ${renderedName} = [];`);
226
+ pushLine(context, 'createEffect(() => {');
227
+ context.indent += 1;
228
+ pushLine(context, `for (const node of ${renderedName}) {`);
229
+ context.indent += 1;
230
+ pushLine(context, `if (node.parentNode === ${parentName}) ${parentName}.removeChild(node);`);
231
+ context.indent -= 1;
232
+ pushLine(context, '}');
233
+ pushLine(context, `${renderedName} = [];`);
234
+ const listName = createIdentifier(context, 'forList');
235
+ pushLine(context, `const ${listName} = ${forDirective.listExpression} ?? [];`);
236
+ pushLine(context, `for (let __i = 0; __i < ${listName}.length; __i += 1) {`);
237
+ context.indent += 1;
238
+ pushLine(context, `const ${forDirective.itemAlias} = ${listName}[__i];`);
239
+ if (forDirective.indexAlias) {
240
+ pushLine(context, `const ${forDirective.indexAlias} = __i;`);
241
+ }
242
+ const fragmentName = declareNode(context, 'document.createDocumentFragment()');
243
+ emitPlainElement(context, removeControlFlow(node), fragmentName);
244
+ const loopNodes = createIdentifier(context, 'loopNodes');
245
+ pushLine(context, `const ${loopNodes} = Array.from(${fragmentName}.childNodes);`);
246
+ pushLine(context, `for (const node of ${loopNodes}) ${parentName}.insertBefore(node, ${endMarker});`);
247
+ pushLine(context, `${renderedName}.push(...${loopNodes});`);
248
+ context.indent -= 1;
249
+ pushLine(context, '}');
250
+ context.indent -= 1;
251
+ pushLine(context, '});');
252
+ }
253
+
254
+ function removeControlFlow(node: TransformElementNode): TransformElementNode {
255
+ return {
256
+ ...node,
257
+ directives: {},
258
+ };
259
+ }
260
+
261
+ function rewriteScriptSetupProps(script: string): string {
262
+ if (script.trim().length === 0) {
263
+ return '';
264
+ }
265
+
266
+ return script
267
+ .replace(/withDefaults\s*\(\s*defineProps\s*(?:<[^>]+>\s*)?\(\s*\)\s*,\s*(\{[^}]*\})\s*\)/g, 'Object.create($1, Object.getOwnPropertyDescriptors(__props))')
268
+ .replace(/defineProps\s*(?:<[^>]+>\s*)?\(\s*(\{[^}]*\})\s*\)/g, 'Object.create($1, Object.getOwnPropertyDescriptors(__props))')
269
+ .replace(/defineProps\s*<[^>]+>\s*\(\s*\)/g, '__props')
270
+ .replace(/defineProps\s*\(\s*\)/g, '__props');
271
+ }
272
+
273
+ function splitScriptParts(script: string): { imports: string[]; body: string } {
274
+ const imports: string[] = [];
275
+ const bodyLines: string[] = [];
276
+
277
+ const lines = script.split('\n');
278
+ let i = 0;
279
+ while (i < lines.length) {
280
+ const line = lines[i]!;
281
+ const trimmed = line.trim();
282
+
283
+ if (trimmed.startsWith('import ')) {
284
+ imports.push(line);
285
+ i += 1;
286
+ continue;
287
+ }
288
+
289
+ if (isTypeOnlyDeclaration(trimmed)) {
290
+ i = skipBracedBlock(lines, i);
291
+ continue;
292
+ }
293
+
294
+ bodyLines.push(line);
295
+ i += 1;
296
+ }
297
+
298
+ return {
299
+ imports: imports.filter((line) => line.trim().length > 0),
300
+ body: bodyLines.join('\n').trim(),
301
+ };
302
+ }
303
+
304
+ function isTypeOnlyDeclaration(trimmedLine: string): boolean {
305
+ return (
306
+ /^(export\s+)?interface\s+/.test(trimmedLine) ||
307
+ /^(export\s+)?type\s+\w+\s*[=<]/.test(trimmedLine)
308
+ );
309
+ }
310
+
311
+ function skipBracedBlock(lines: string[], startIndex: number): number {
312
+ let depth = 0;
313
+ let i = startIndex;
314
+ while (i < lines.length) {
315
+ const line = lines[i]!;
316
+ for (const ch of line) {
317
+ if (ch === '{') depth += 1;
318
+ if (ch === '}') depth -= 1;
319
+ }
320
+ i += 1;
321
+ if (depth <= 0) break;
322
+ }
323
+ return i;
324
+ }
325
+
326
+ function declareNode(context: CodegenContext, expression: string): string {
327
+ const name = createIdentifier(context, 'node');
328
+ pushLine(context, `const ${name} = ${expression};`);
329
+ return name;
330
+ }
331
+
332
+ function createIdentifier(context: CodegenContext, base: string): string {
333
+ const id = context.identifier;
334
+ context.identifier += 1;
335
+ return `__${base}${id}`;
336
+ }
337
+
338
+ function toEventHandler(expression: string): string {
339
+ const trimmed = expression.trim();
340
+
341
+ if (/^\w+$/.test(trimmed)) {
342
+ return trimmed;
343
+ }
344
+
345
+ if (trimmed.startsWith('(') || trimmed.startsWith('function') || /^\w+\s*=>/.test(trimmed)) {
346
+ return trimmed;
347
+ }
348
+
349
+ return `(event) => { ${trimmed}; }`;
350
+ }
351
+
352
+ function pushLine(context: CodegenContext, line: string): void {
353
+ context.lines.push(`${' '.repeat(context.indent)}${line}`);
354
+ }
@@ -0,0 +1,273 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { compileSfc } from './index';
4
+ import { parseSfc } from './parser';
5
+
6
+ describe('compileSfc', () => {
7
+ it('compiles interpolation, bindings, events, tn-if and tn-for', () => {
8
+ const source = `
9
+ <script setup lang="ts">
10
+ const count = () => 1;
11
+ const visible = () => true;
12
+ const items = () => ['a', 'b'];
13
+ function increment() {}
14
+ </script>
15
+ <template>
16
+ <section :data-count="count()" @click="increment">
17
+ <p>{{ count() }}</p>
18
+ <span tn-if="visible()">Visible</span>
19
+ <li tn-for="item, index in items()">{{ index }}-{{ item }}</li>
20
+ </section>
21
+ </template>
22
+ `;
23
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
24
+
25
+ expect(code).toContain("import { createEffect, delegateEvents } from 'tanni-runtime';");
26
+ expect(code).toContain('createEffect(() => {');
27
+ expect(code).toContain('.setAttribute("data-count"');
28
+ expect(code).toContain('__tnDelegatedHandlers');
29
+ expect(code).toContain("document.createComment('tn-if')");
30
+ expect(code).toContain("document.createComment('tn-for')");
31
+ expect(code).toContain('const item =');
32
+ expect(code).toContain('const index = __i;');
33
+ expect(code).toContain('delegateEvents(["click"]);');
34
+ });
35
+
36
+ it('rewrites defineProps macro to component props', () => {
37
+ const source = `
38
+ <script setup lang="ts">
39
+ interface Props {
40
+ title: string;
41
+ }
42
+ const props = defineProps<Props>();
43
+ </script>
44
+ <template>
45
+ <h1>{{ props.title }}</h1>
46
+ </template>
47
+ `;
48
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
49
+ expect(code).toContain('const props = __props;');
50
+ expect(code).toContain('export default function Component(__props = {}) {');
51
+ });
52
+ });
53
+
54
+ describe('component detection', () => {
55
+ it('detects PascalCase tags as components and emits function calls', () => {
56
+ const source = `
57
+ <script setup lang="ts">
58
+ import Counter from './Counter.tanni';
59
+ </script>
60
+ <template>
61
+ <div>
62
+ <Counter />
63
+ </div>
64
+ </template>
65
+ `;
66
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
67
+ expect(code).toContain('Counter(');
68
+ expect(code).not.toContain('document.createElement("Counter")');
69
+ });
70
+
71
+ it('passes static attrs, bindings, and events as props to components', () => {
72
+ const source = `
73
+ <script setup lang="ts">
74
+ import MyComp from './MyComp.tanni';
75
+ const val = () => 42;
76
+ function handleClick() {}
77
+ </script>
78
+ <template>
79
+ <MyComp title="hello" :count="val()" @click="handleClick" />
80
+ </template>
81
+ `;
82
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
83
+ expect(code).toContain('MyComp(');
84
+ expect(code).toContain('"title": "hello"');
85
+ expect(code).toContain('get "count"() { return val(); }');
86
+ expect(code).toContain('"onClick": handleClick');
87
+ });
88
+
89
+ it('emits getter props for dynamic bindings instead of wrapping in createEffect', () => {
90
+ const source = `
91
+ <script setup lang="ts">
92
+ import Counter from './Counter.tanni';
93
+ const count = () => 5;
94
+ </script>
95
+ <template>
96
+ <Counter :value="count()" />
97
+ </template>
98
+ `;
99
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
100
+ expect(code).toContain('Counter({');
101
+ expect(code).toContain('get "value"() { return count(); }');
102
+ expect(code).not.toContain("document.createComment('tn-component:Counter')");
103
+ });
104
+
105
+ it('treats lowercase tags as plain HTML elements', () => {
106
+ const source = `
107
+ <script setup lang="ts">
108
+ </script>
109
+ <template>
110
+ <div>
111
+ <span>text</span>
112
+ </div>
113
+ </template>
114
+ `;
115
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
116
+ expect(code).toContain('document.createElement("div")');
117
+ expect(code).toContain('document.createElement("span")');
118
+ });
119
+ });
120
+
121
+ describe('defineProps rewriting', () => {
122
+ it('rewrites defineProps() with no args to __props', () => {
123
+ const source = `
124
+ <script setup lang="ts">
125
+ const props = defineProps();
126
+ </script>
127
+ <template>
128
+ <p>{{ props.title }}</p>
129
+ </template>
130
+ `;
131
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
132
+ expect(code).toContain('const props = __props;');
133
+ });
134
+
135
+ it('rewrites defineProps<T>() with generic only to __props', () => {
136
+ const source = `
137
+ <script setup lang="ts">
138
+ interface Props { title: string; }
139
+ const props = defineProps<Props>();
140
+ </script>
141
+ <template>
142
+ <p>{{ props.title }}</p>
143
+ </template>
144
+ `;
145
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
146
+ expect(code).toContain('const props = __props;');
147
+ expect(code).not.toContain('interface Props');
148
+ });
149
+
150
+ it('rewrites defineProps<T>({ defaults }) to spread with __props', () => {
151
+ const source = `
152
+ <script setup lang="ts">
153
+ interface Props { count: number; onIncrement: () => void; }
154
+ const props = defineProps<Props>({ count: 0 });
155
+ </script>
156
+ <template>
157
+ <p>{{ props.count }}</p>
158
+ </template>
159
+ `;
160
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
161
+ expect(code).toContain('const props = Object.create({ count: 0 }, Object.getOwnPropertyDescriptors(__props));');
162
+ expect(code).not.toContain('defineProps');
163
+ expect(code).not.toContain('interface Props');
164
+ });
165
+
166
+ it('rewrites defineProps({ defaults }) without generic to Object.create with __props', () => {
167
+ const source = `
168
+ <script setup lang="ts">
169
+ const props = defineProps({ count: 0 });
170
+ </script>
171
+ <template>
172
+ <p>{{ props.count }}</p>
173
+ </template>
174
+ `;
175
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
176
+ expect(code).toContain('const props = Object.create({ count: 0 }, Object.getOwnPropertyDescriptors(__props));');
177
+ });
178
+
179
+ it('rewrites withDefaults(defineProps<T>(), { defaults }) to Object.create with __props', () => {
180
+ const source = `
181
+ <script setup lang="ts">
182
+ interface Props { count: number; label: string; }
183
+ const props = withDefaults(defineProps<Props>(), { count: 0, label: "hello" });
184
+ </script>
185
+ <template>
186
+ <p>{{ props.label }}: {{ props.count }}</p>
187
+ </template>
188
+ `;
189
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
190
+ expect(code).toContain('const props = Object.create({ count: 0, label: "hello" }, Object.getOwnPropertyDescriptors(__props));');
191
+ expect(code).not.toContain('withDefaults');
192
+ expect(code).not.toContain('defineProps');
193
+ });
194
+
195
+ it('rewrites withDefaults without generic to Object.create with __props', () => {
196
+ const source = `
197
+ <script setup lang="ts">
198
+ const props = withDefaults(defineProps(), { count: 5 });
199
+ </script>
200
+ <template>
201
+ <p>{{ props.count }}</p>
202
+ </template>
203
+ `;
204
+ const { code } = compileSfc(source, { runtimeModule: 'tanni-runtime' });
205
+ expect(code).toContain('const props = Object.create({ count: 5 }, Object.getOwnPropertyDescriptors(__props));');
206
+ expect(code).not.toContain('withDefaults');
207
+ });
208
+ });
209
+
210
+ describe('style block extraction', () => {
211
+ it('extracts a single style block', () => {
212
+ const source = `
213
+ <script setup lang="ts">
214
+ </script>
215
+ <template>
216
+ <div>hello</div>
217
+ </template>
218
+ <style>
219
+ .app { color: red; }
220
+ </style>
221
+ `;
222
+ const result = compileSfc(source, { runtimeModule: 'tanni-runtime' });
223
+ expect(result.css).toContain('.app { color: red; }');
224
+ });
225
+
226
+ it('extracts multiple style blocks and concatenates them', () => {
227
+ const source = `
228
+ <script setup lang="ts">
229
+ </script>
230
+ <template>
231
+ <div>hello</div>
232
+ </template>
233
+ <style>
234
+ .a { color: red; }
235
+ </style>
236
+ <style>
237
+ .b { color: blue; }
238
+ </style>
239
+ `;
240
+ const result = compileSfc(source, { runtimeModule: 'tanni-runtime' });
241
+ expect(result.css).toContain('.a { color: red; }');
242
+ expect(result.css).toContain('.b { color: blue; }');
243
+ });
244
+
245
+ it('returns empty css when no style block is present', () => {
246
+ const source = `
247
+ <script setup lang="ts">
248
+ </script>
249
+ <template>
250
+ <div>hello</div>
251
+ </template>
252
+ `;
253
+ const result = compileSfc(source, { runtimeModule: 'tanni-runtime' });
254
+ expect(result.css).toBe('');
255
+ });
256
+
257
+ it('parses style block lang and scoped attributes', () => {
258
+ const source = `
259
+ <script setup lang="ts">
260
+ </script>
261
+ <template>
262
+ <div>hello</div>
263
+ </template>
264
+ <style lang="scss" scoped>
265
+ .app { color: red; }
266
+ </style>
267
+ `;
268
+ const descriptor = parseSfc(source);
269
+ expect(descriptor.styles).toHaveLength(1);
270
+ expect(descriptor.styles[0]!.lang).toBe('scss');
271
+ expect(descriptor.styles[0]!.scoped).toBe(true);
272
+ });
273
+ });
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { generate } from './codegen';
2
+ import { parseSfc, parseTemplate } from './parser';
3
+ import { transformTemplate } from './transform';
4
+ import type { CompileOptions, CompileResult } from './types';
5
+
6
+ export type { CompileOptions, CompileResult } from './types';
7
+
8
+ export function compileSfc(source: string, options: CompileOptions = {}): CompileResult {
9
+ const descriptor = parseSfc(source);
10
+ const parsedTemplate = parseTemplate(descriptor.template);
11
+ const transformed = transformTemplate(parsedTemplate);
12
+ const result = generate(transformed, descriptor.script, options);
13
+
14
+ const css = descriptor.styles
15
+ .map((s) => s.content)
16
+ .filter((c) => c.length > 0)
17
+ .join('\n\n');
18
+
19
+ return { ...result, css };
20
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,152 @@
1
+ import type { ElementNode, RawAttribute, SfcDescriptor, SfcStyleBlock, TemplateNode, TemplateRoot, TextNode } from './types';
2
+
3
+ const TEMPLATE_BLOCK = /<template\b[^>]*>([\s\S]*?)<\/template>/i;
4
+ const SCRIPT_BLOCK = /<script\b([^>]*)>([\s\S]*?)<\/script>/i;
5
+ const STYLE_BLOCK = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
6
+ const LANG_ATTR = /\blang\s*=\s*["']([^"']+)["']/i;
7
+ const SCOPED_ATTR = /\bscoped\b/i;
8
+
9
+ export function parseSfc(source: string): SfcDescriptor {
10
+ const templateMatch = source.match(TEMPLATE_BLOCK);
11
+ if (!templateMatch) {
12
+ throw new Error('Missing <template> block in .tanni file.');
13
+ }
14
+ const templateBlock = templateMatch[1];
15
+ if (!templateBlock) {
16
+ throw new Error('Template block is empty.');
17
+ }
18
+
19
+ const scriptMatch = source.match(SCRIPT_BLOCK);
20
+ const scriptAttrs = scriptMatch?.[1] ?? '';
21
+ const scriptLangMatch = scriptAttrs.match(LANG_ATTR);
22
+
23
+ const styles: SfcStyleBlock[] = [];
24
+ for (const styleMatch of source.matchAll(STYLE_BLOCK)) {
25
+ const attrs = styleMatch[1] ?? '';
26
+ const langMatch = attrs.match(LANG_ATTR);
27
+ styles.push({
28
+ content: (styleMatch[2] ?? '').trim(),
29
+ lang: langMatch?.[1] ?? null,
30
+ scoped: SCOPED_ATTR.test(attrs),
31
+ });
32
+ }
33
+
34
+ return {
35
+ template: templateBlock.trim(),
36
+ script: scriptMatch?.[2]?.trim() ?? '',
37
+ scriptLang: scriptLangMatch?.[1] ?? null,
38
+ styles,
39
+ };
40
+ }
41
+
42
+ export function parseTemplate(templateSource: string): TemplateRoot {
43
+ const root: TemplateRoot = { type: 'Root', children: [] };
44
+ const stack: ElementNode[] = [];
45
+
46
+ let index = 0;
47
+ while (index < templateSource.length) {
48
+ if (templateSource[index] === '<') {
49
+ if (templateSource.startsWith('<!--', index)) {
50
+ const closeIndex = templateSource.indexOf('-->', index + 4);
51
+ index = closeIndex === -1 ? templateSource.length : closeIndex + 3;
52
+ continue;
53
+ }
54
+
55
+ if (templateSource.startsWith('</', index)) {
56
+ const closeTag = templateSource.slice(index).match(/^<\/\s*([A-Za-z][\w-]*)\s*>/);
57
+ if (!closeTag) {
58
+ throw new Error(`Invalid closing tag near: ${templateSource.slice(index, index + 20)}`);
59
+ }
60
+
61
+ const [, tag] = closeTag;
62
+ const current = stack.pop();
63
+ if (!current || current.tag !== tag) {
64
+ throw new Error(`Mismatched closing tag </${tag}>.`);
65
+ }
66
+ index += closeTag[0].length;
67
+ continue;
68
+ }
69
+
70
+ const openTag = templateSource.slice(index).match(/^<\s*([A-Za-z][\w-]*)([\s\S]*?)>/);
71
+ if (!openTag) {
72
+ throw new Error(`Invalid opening tag near: ${templateSource.slice(index, index + 20)}`);
73
+ }
74
+
75
+ const tag = openTag[1];
76
+ const rawAttrs = openTag[2] ?? '';
77
+ if (!tag) {
78
+ throw new Error('Invalid opening tag.');
79
+ }
80
+ const selfClosing = rawAttrs.trim().endsWith('/');
81
+ const cleanAttrs = selfClosing ? rawAttrs.replace(/\/\s*$/, '') : rawAttrs;
82
+
83
+ const node: ElementNode = {
84
+ type: 'Element',
85
+ tag,
86
+ attrs: parseAttributes(cleanAttrs),
87
+ children: [],
88
+ };
89
+
90
+ appendChild(stack, root, node);
91
+ index += openTag[0].length;
92
+
93
+ if (!selfClosing) {
94
+ stack.push(node);
95
+ }
96
+ continue;
97
+ }
98
+
99
+ const nextTagIndex = templateSource.indexOf('<', index);
100
+ const textContent =
101
+ nextTagIndex === -1 ? templateSource.slice(index) : templateSource.slice(index, nextTagIndex);
102
+ index = nextTagIndex === -1 ? templateSource.length : nextTagIndex;
103
+
104
+ if (textContent.trim().length === 0) {
105
+ continue;
106
+ }
107
+
108
+ const textNode: TextNode = {
109
+ type: 'Text',
110
+ content: textContent,
111
+ };
112
+ appendChild(stack, root, textNode);
113
+ }
114
+
115
+ if (stack.length > 0) {
116
+ const unclosed = stack[stack.length - 1];
117
+ if (!unclosed) {
118
+ throw new Error('Unexpected parser state.');
119
+ }
120
+ throw new Error(`Unclosed tag <${unclosed.tag}>.`);
121
+ }
122
+
123
+ return root;
124
+ }
125
+
126
+ function appendChild(stack: ElementNode[], root: TemplateRoot, node: TemplateNode): void {
127
+ if (stack.length === 0) {
128
+ root.children.push(node);
129
+ return;
130
+ }
131
+ const parent = stack[stack.length - 1];
132
+ if (!parent) {
133
+ throw new Error('Unexpected parser state.');
134
+ }
135
+ parent.children.push(node);
136
+ }
137
+
138
+ function parseAttributes(raw: string): RawAttribute[] {
139
+ const attributes: RawAttribute[] = [];
140
+ const attrRegex = /([:@A-Za-z_][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
141
+
142
+ for (const match of raw.matchAll(attrRegex)) {
143
+ const name = match[1];
144
+ if (!name) {
145
+ continue;
146
+ }
147
+ const value = match[2] ?? match[3] ?? match[4] ?? null;
148
+ attributes.push({ name, value });
149
+ }
150
+
151
+ return attributes;
152
+ }
@@ -0,0 +1,134 @@
1
+ import type {
2
+ DirectiveMap,
3
+ ForDirective,
4
+ TemplateNode,
5
+ TemplateRoot,
6
+ TextSegment,
7
+ TransformElementNode,
8
+ TransformNode,
9
+ TransformRoot,
10
+ TransformTextNode,
11
+ } from './types';
12
+
13
+ const FOR_PATTERN = /^\s*(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\s*$/;
14
+ const INTERPOLATION_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g;
15
+
16
+ export function transformTemplate(root: TemplateRoot): TransformRoot {
17
+ return {
18
+ type: 'Root',
19
+ children: root.children.map((node) => transformNode(node)),
20
+ };
21
+ }
22
+
23
+ function transformNode(node: TemplateNode): TransformNode {
24
+ if (node.type === 'Text') {
25
+ return transformText(node.content);
26
+ }
27
+
28
+ const directives: DirectiveMap = {};
29
+ const attributes: TransformElementNode['attributes'] = [];
30
+ const bindings: TransformElementNode['bindings'] = [];
31
+ const events: TransformElementNode['events'] = [];
32
+
33
+ for (const attr of node.attrs) {
34
+ if (attr.name === 'tn-if') {
35
+ if (!attr.value) {
36
+ throw new Error('tn-if requires an expression value.');
37
+ }
38
+ directives.if = attr.value;
39
+ continue;
40
+ }
41
+
42
+ if (attr.name === 'tn-for') {
43
+ if (!attr.value) {
44
+ throw new Error('tn-for requires an expression value.');
45
+ }
46
+ directives.for = parseForDirective(attr.value);
47
+ continue;
48
+ }
49
+
50
+ if (attr.name.startsWith(':')) {
51
+ if (!attr.value) {
52
+ throw new Error(`${attr.name} requires an expression value.`);
53
+ }
54
+ bindings.push({
55
+ name: attr.name.slice(1),
56
+ expression: attr.value,
57
+ });
58
+ continue;
59
+ }
60
+
61
+ if (attr.name.startsWith('@')) {
62
+ if (!attr.value) {
63
+ throw new Error(`${attr.name} requires an event handler expression.`);
64
+ }
65
+ events.push({
66
+ name: attr.name.slice(1),
67
+ expression: attr.value,
68
+ });
69
+ continue;
70
+ }
71
+
72
+ attributes.push({
73
+ name: attr.name,
74
+ value: attr.value ?? '',
75
+ });
76
+ }
77
+
78
+ return {
79
+ type: 'Element',
80
+ tag: node.tag,
81
+ isComponent: /^[A-Z]/.test(node.tag),
82
+ attributes,
83
+ bindings,
84
+ events,
85
+ directives,
86
+ children: node.children.map((child) => transformNode(child)),
87
+ };
88
+ }
89
+
90
+ function parseForDirective(value: string): ForDirective {
91
+ const match = value.match(FOR_PATTERN);
92
+ if (!match) {
93
+ throw new Error(`Invalid tn-for expression "${value}". Expected "item in list" or "item, index in list".`);
94
+ }
95
+
96
+ return {
97
+ itemAlias: match[1] ?? 'item',
98
+ indexAlias: match[2] ?? null,
99
+ listExpression: (match[3] ?? '').trim(),
100
+ };
101
+ }
102
+
103
+ function transformText(content: string): TransformTextNode {
104
+ const segments = splitTextSegments(content);
105
+ return {
106
+ type: 'Text',
107
+ segments: segments.length > 0 ? segments : [{ type: 'static', value: content }],
108
+ };
109
+ }
110
+
111
+ function splitTextSegments(content: string): TextSegment[] {
112
+ const segments: TextSegment[] = [];
113
+ let cursor = 0;
114
+
115
+ for (const match of content.matchAll(INTERPOLATION_PATTERN)) {
116
+ const start = match.index ?? 0;
117
+ if (start > cursor) {
118
+ segments.push({ type: 'static', value: content.slice(cursor, start) });
119
+ }
120
+
121
+ const expression = match[1]?.trim();
122
+ if (expression) {
123
+ segments.push({ type: 'dynamic', expression });
124
+ }
125
+
126
+ cursor = start + match[0].length;
127
+ }
128
+
129
+ if (cursor < content.length) {
130
+ segments.push({ type: 'static', value: content.slice(cursor) });
131
+ }
132
+
133
+ return segments;
134
+ }
package/src/types.ts ADDED
@@ -0,0 +1,106 @@
1
+ export interface SfcStyleBlock {
2
+ content: string;
3
+ lang: string | null;
4
+ scoped: boolean;
5
+ }
6
+
7
+ export interface SfcDescriptor {
8
+ template: string;
9
+ script: string;
10
+ scriptLang: string | null;
11
+ styles: SfcStyleBlock[];
12
+ }
13
+
14
+ export interface TemplateRoot {
15
+ type: 'Root';
16
+ children: TemplateNode[];
17
+ }
18
+
19
+ export type TemplateNode = ElementNode | TextNode;
20
+
21
+ export interface ElementNode {
22
+ type: 'Element';
23
+ tag: string;
24
+ attrs: RawAttribute[];
25
+ children: TemplateNode[];
26
+ }
27
+
28
+ export interface TextNode {
29
+ type: 'Text';
30
+ content: string;
31
+ }
32
+
33
+ export interface RawAttribute {
34
+ name: string;
35
+ value: string | null;
36
+ }
37
+
38
+ export interface TransformRoot {
39
+ type: 'Root';
40
+ children: TransformNode[];
41
+ }
42
+
43
+ export type TransformNode = TransformElementNode | TransformTextNode;
44
+
45
+ export interface TransformTextNode {
46
+ type: 'Text';
47
+ segments: TextSegment[];
48
+ }
49
+
50
+ export type TextSegment =
51
+ | {
52
+ type: 'static';
53
+ value: string;
54
+ }
55
+ | {
56
+ type: 'dynamic';
57
+ expression: string;
58
+ };
59
+
60
+ export interface TransformElementNode {
61
+ type: 'Element';
62
+ tag: string;
63
+ isComponent: boolean;
64
+ attributes: StaticAttribute[];
65
+ bindings: DynamicBinding[];
66
+ events: EventBinding[];
67
+ directives: DirectiveMap;
68
+ children: TransformNode[];
69
+ }
70
+
71
+ export interface StaticAttribute {
72
+ name: string;
73
+ value: string;
74
+ }
75
+
76
+ export interface DynamicBinding {
77
+ name: string;
78
+ expression: string;
79
+ }
80
+
81
+ export interface EventBinding {
82
+ name: string;
83
+ expression: string;
84
+ }
85
+
86
+ export interface DirectiveMap {
87
+ if?: string;
88
+ for?: ForDirective;
89
+ }
90
+
91
+ export interface ForDirective {
92
+ itemAlias: string;
93
+ indexAlias: string | null;
94
+ listExpression: string;
95
+ }
96
+
97
+ export interface CompileOptions {
98
+ id?: string;
99
+ runtimeModule?: string;
100
+ componentName?: string;
101
+ }
102
+
103
+ export interface CompileResult {
104
+ code: string;
105
+ css: string;
106
+ }