Skip to content

Commit e5a73db

Browse files
committed
feat: allow typescript to know used variables in template
- inject vars from markup to typescript - do not use importTransform when markup is available - inject component script to context=module script - refactor modules/markup to reuse common functions - test injection behavior
1 parent 12c1157 commit e5a73db

File tree

5 files changed

+244
-23
lines changed

5 files changed

+244
-23
lines changed

src/modules/markup.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
import type { Transformer, Preprocessor } from '../types';
22

3+
/** Create a tag matching regexp. */
4+
export function createTagRegex(tagName: string, flags?: string): RegExp {
5+
return new RegExp(
6+
`<!--[^]*?-->|<${tagName}(\\s[^]*?)?(?:>([^]*?)<\\/${tagName}>|\\/>)`,
7+
flags,
8+
);
9+
}
10+
11+
/** Strip script and style tags from markup. */
12+
export function stripTags(markup: string): string {
13+
return markup
14+
.replace(createTagRegex('style', 'gi'), '')
15+
.replace(createTagRegex('script', 'gi'), '');
16+
}
17+
18+
/** Transform an attribute string into a key-value object */
19+
export function parseAttributes(attributesStr: string): Record<string, any> {
20+
return attributesStr
21+
.split(/\s+/)
22+
.filter(Boolean)
23+
.reduce((acc: Record<string, string | boolean>, attr) => {
24+
const [name, value] = attr.split('=');
25+
26+
// istanbul ignore next
27+
acc[name] = value ? value.replace(/['"]/g, '') : true;
28+
29+
return acc;
30+
}, {});
31+
}
32+
333
export async function transformMarkup(
434
{ content, filename }: { content: string; filename: string },
535
transformer: Preprocessor | Transformer<unknown>,
@@ -9,9 +39,7 @@ export async function transformMarkup(
939

1040
markupTagName = markupTagName.toLocaleLowerCase();
1141

12-
const markupPattern = new RegExp(
13-
`/<!--[^]*?-->|<${markupTagName}(\\s[^]*?)?(?:>([^]*?)<\\/${markupTagName}>|\\/>)`,
14-
);
42+
const markupPattern = createTagRegex(markupTagName);
1543

1644
const templateMatch = content.match(markupPattern);
1745

@@ -28,18 +56,7 @@ export async function transformMarkup(
2856

2957
const [fullMatch, attributesStr = '', templateCode] = templateMatch;
3058

31-
/** Transform an attribute string into a key-value object */
32-
const attributes = attributesStr
33-
.split(/\s+/)
34-
.filter(Boolean)
35-
.reduce((acc: Record<string, string | boolean>, attr) => {
36-
const [name, value] = attr.split('=');
37-
38-
// istanbul ignore next
39-
acc[name] = value ? value.replace(/['"]/g, '') : true;
40-
41-
return acc;
42-
}, {});
59+
const attributes = parseAttributes(attributesStr);
4360

4461
/** Transform the found template code */
4562
let { code, map, dependencies } = await transformer({

src/transformers/typescript.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { dirname, isAbsolute, join } from 'path';
22

33
import ts from 'typescript';
4+
import { compile } from 'svelte/compiler';
45

56
import { throwTypescriptError } from '../modules/errors';
7+
import { createTagRegex, parseAttributes, stripTags } from '../modules/markup';
68
import type { Transformer, Options } from '../types';
79

810
type CompilerOptions = Options.Typescript['compilerOptions'];
@@ -53,6 +55,66 @@ const importTransformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
5355
return (node) => ts.visitNode(node, visit);
5456
};
5557

58+
function getComponentScriptContent(markup: string): string {
59+
const regex = createTagRegex('script', 'gi');
60+
let match: RegExpMatchArray;
61+
62+
while ((match = regex.exec(markup)) !== null) {
63+
const { context } = parseAttributes(match[1]);
64+
65+
if (context !== 'module') {
66+
return match[2];
67+
}
68+
}
69+
70+
return '';
71+
}
72+
73+
function injectVarsToCode({
74+
content,
75+
markup,
76+
filename,
77+
attributes,
78+
}: {
79+
content: string;
80+
markup?: string;
81+
filename?: string;
82+
attributes?: Record<string, any>;
83+
}): string {
84+
if (!markup) return content;
85+
86+
const { vars } = compile(stripTags(markup), {
87+
generate: false,
88+
varsReport: 'full',
89+
errorMode: 'warn',
90+
filename,
91+
});
92+
93+
const sep = '\nconst $$$$$$$$ = null;\n';
94+
const varsValues = vars.map((v) => v.name).join(',');
95+
const injectedVars = `const $$vars$$ = [${varsValues}];`;
96+
97+
if (attributes?.context === 'module') {
98+
const componentScript = getComponentScriptContent(markup);
99+
100+
return `${content}${sep}${componentScript}\n${injectedVars}`;
101+
}
102+
103+
return `${content}${sep}${injectedVars}`;
104+
}
105+
106+
function stripInjectedCode({
107+
compiledCode,
108+
markup,
109+
}: {
110+
compiledCode: string;
111+
markup?: string;
112+
}): string {
113+
return markup
114+
? compiledCode.slice(0, compiledCode.indexOf('const $$$$$$$$ = null;'))
115+
: compiledCode;
116+
}
117+
56118
export function loadTsconfig(
57119
compilerOptionsJSON: any,
58120
filename: string,
@@ -103,7 +165,9 @@ export function loadTsconfig(
103165
const transformer: Transformer<Options.Typescript> = ({
104166
content,
105167
filename,
168+
markup,
106169
options = {},
170+
attributes,
107171
}) => {
108172
// default options
109173
const compilerOptionsJSON = {
@@ -140,17 +204,18 @@ const transformer: Transformer<Options.Typescript> = ({
140204
}
141205

142206
const {
143-
outputText: code,
207+
outputText: compiledCode,
144208
sourceMapText: map,
145209
diagnostics,
146-
} = ts.transpileModule(content, {
147-
fileName: filename,
148-
compilerOptions,
149-
reportDiagnostics: options.reportDiagnostics !== false,
150-
transformers: {
151-
before: [importTransformer],
210+
} = ts.transpileModule(
211+
injectVarsToCode({ content, markup, filename, attributes }),
212+
{
213+
fileName: filename,
214+
compilerOptions,
215+
reportDiagnostics: options.reportDiagnostics !== false,
216+
transformers: markup ? {} : { before: [importTransformer] },
152217
},
153-
});
218+
);
154219

155220
if (diagnostics.length > 0) {
156221
// could this be handled elsewhere?
@@ -167,6 +232,8 @@ const transformer: Transformer<Options.Typescript> = ({
167232
}
168233
}
169234

235+
const code = stripInjectedCode({ compiledCode, markup });
236+
170237
return {
171238
code,
172239
map,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<script lang="ts">
2+
import { fly } from "svelte/transition";
3+
import { flip } from "svelte/animate";
4+
import Nested from "./Nested.svelte";
5+
import { hello } from "./script";
6+
import { AValue, AType } from "./types";
7+
const ui = { MyNested: Nested };
8+
const val: AType = "test1";
9+
const prom: Promise<AType> = Promise.resolve("test2");
10+
const arr: AType[] = ["test1", "test2"];
11+
const isTest1 = (v: AType) => v === "test1";
12+
const obj = {
13+
fn: () => "test",
14+
val: "test1" as const
15+
};
16+
let inputVal: string;
17+
const action = (node: Element, options: { id: string; }) => { node.id = options.id; };
18+
const action2 = (node: Element) => { node.classList.add("test"); };
19+
let nested: Nested;
20+
21+
let scrollY = 500;
22+
let innerWidth = 500;
23+
24+
const duration = 200;
25+
function onKeyDown(e: KeyboardEvent): void {
26+
e.preventDefault();
27+
}
28+
</script>
29+
30+
<style>
31+
.value { color: #ccc; }
32+
</style>
33+
34+
<svelte:window on:keydown={onKeyDown} {scrollY} bind:innerWidth />
35+
<svelte:body on:keydown={onKeyDown} />
36+
37+
<svelte:head>
38+
<title>Title: {val}</title>
39+
</svelte:head>
40+
41+
<div>
42+
<Nested let:var1 let:var2={var3}>
43+
<Nested bind:this={nested} />
44+
<Nested {...obj} />
45+
<Nested {...{ var1, var3 }} />
46+
47+
<svelte:fragment slot="slot1" let:var4={var5}>
48+
<Nested {...{ var5 }} />
49+
</svelte:fragment>
50+
51+
<div slot="slot2" let:var6={var7}>
52+
<Nested {...{ var7 }} />
53+
</div>
54+
55+
<div slot="slot3">
56+
<Nested {...{ val }} />
57+
</div>
58+
</Nested>
59+
60+
<svelte:component this={ui.MyNested} {val} on:keydown={onKeyDown} bind:inputVal />
61+
62+
<p class:value={!!inputVal}>{hello}</p>
63+
<input bind:value={inputVal} use:action={{ id: val }} use:action2 />
64+
65+
{#if AValue && val}
66+
<p class="value" transition:fly={{ duration }}>There is a value: {AValue}</p>
67+
{/if}
68+
69+
{#if val && isTest1(val) && AValue && true && "test"}
70+
<p class="value">There is a value: {AValue}</p>
71+
{:else if obj.val && obj.fn() && isTest1(obj.val)}
72+
<p class="value">There is a value: {AValue}</p>
73+
{:else}
74+
Else
75+
{/if}
76+
77+
{#each arr as item (item)}
78+
<p animate:flip={{ duration }}>{item}</p>
79+
{/each}
80+
81+
{#each arr as item}
82+
<p>{item}</p>
83+
{:else}
84+
<p>No items</p>
85+
{/each}
86+
87+
{#await prom}
88+
Loading...
89+
{:then value}
90+
<input type={val} {value} on:input={e => inputVal = e.currentTarget.value} />
91+
{:catch err}
92+
<p>Error: {err}</p>
93+
{/await}
94+
95+
{#await prom then value}
96+
<p>{value}</p>
97+
{/await}
98+
99+
{#key val}
100+
<p>Keyed {val}</p>
101+
{/key}
102+
103+
<slot name="slot0" {inputVal}>
104+
<p>{inputVal}</p>
105+
</slot>
106+
107+
{@html val}
108+
{@debug val, inputVal}
109+
</div>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<script lang="ts" context="module">
2+
import { AValue, AType } from "./types";
3+
</script>
4+
5+
<script lang="ts">
6+
const val: AType = "test1";
7+
const aValue = AValue;
8+
</script>
9+
10+
{val} {aValue}

test/transformers/typescript.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ describe('transformer - typescript', () => {
111111
return expect(code).toContain(getFixtureContent('script.js'));
112112
});
113113

114+
it('should strip unused and type imports', async () => {
115+
const tpl = getFixtureContent('TypeScriptImports.svelte');
116+
117+
const opts = sveltePreprocess({ typescript: { tsconfigFile: false } });
118+
const { code } = await preprocess(tpl, opts);
119+
120+
return expect(code).toContain(`import { AValue } from "./types";`);
121+
});
122+
123+
it('should strip unused and type imports in context="module" tags', async () => {
124+
const tpl = getFixtureContent('TypeScriptImportsModule.svelte');
125+
126+
const opts = sveltePreprocess({ typescript: { tsconfigFile: false } });
127+
const { code } = await preprocess(tpl, opts);
128+
129+
return expect(code).toContain(`import { AValue } from "./types";`);
130+
});
131+
114132
it('supports extends field', () => {
115133
const { options } = loadTsconfig({}, getTestAppFilename(), {
116134
tsconfigFile: './test/fixtures/tsconfig.extends1.json',

0 commit comments

Comments
 (0)