teasel
Search
Search the documentation

Svelte component

This example reads a Svelte component: a <script> block, then a template with JavaScript inside {…}. The parser reads JavaScript and does not know the template syntax. So your code finds each {, and asks the parser to read the JavaScript that starts there.

At the end, every name the template uses is matched to the declaration it refers to, in the script or in an each block. That match is what a compiler, a linter or a rename tool for the format is built on.

This page uses three things that Embedded JavaScript explains: parsing at an offset, pieces, and until.

The component:

List.svelte
<script>
	import { onMount } from './lifecycle.js';
	let { items = [], selected = null } = $props();
	let query = $state('');
	const shown = $derived(items.filter((item) => item.name.includes(query)));
	function pick(item) { selected = item; }
</script>

<input bind:value={query} placeholder="filter" />
{#if shown.length === 0}
	<p>nothing matches "{query}"</p>
{:else}
	<ul>
		{#each shown as item, i (item.id)}
			<li class:active={item === selected} onclick={() => pick(item)}>
				{i + 1}. {item.name}
			</li>
		{/each}
	</ul>
{/if}

1. One Source for the whole file

read-component.js
import { Source, js, scopeOf } from '@teasel/parser';

const source = new Source(text, { sourceType: 'module', scopes: true });

text is the component above, as a string.

2. Read the script block

Find where the script's text starts and ends, then parse that range as a program.

read-component.js
const open = text.indexOf('<script>') + '<script>'.length;
const close = text.indexOf('</script>');
const script = source.parse(js, [open, close]);

const topLevel = script.bindings.filter(
	(binding) => binding.scope === scopeOf(script.node),
);

script.bindings has every declaration, including the parameters of the two functions:

onMount:import  items:let  selected:let  query:let  shown:const
item:param  pick:function  item:param

The template can only see the top-level ones, so topLevel leaves the two item parameters out.

3. Read one expression, and match its names

Most braces hold one expression: {query}, {i + 1}, onclick={() => pick(item)}. This function reads the expression that starts at from, stops at one of your tokens, and looks up every name the expression uses.

read-component.js
const inScope = [topLevel];
const resolved = [];

function expression(from, ...stops) {
	const answer = source.parse(js.expression.until(...stops), from);
	for (const reference of answer.references) {
		if (reference.binding !== null) continue;
		const name = reference.node.name;
		const found = inScope.flat().findLast((binding) => binding.name === name);
		resolved.push({ name, at: reference.node.start, found });
	}
	return answer;
}

The parser reads this expression alone. It has not seen the script, so it cannot know that query is declared there: for every such name, reference.binding is null. Looking those up is your code's job, because only your code knows the template's scoping rules. found stays undefined for a name that nothing declares, a global like console.

4. Read an each block's head

{#each shown as item, i (item.id)} has up to four pieces of JavaScript in it, and each one ends at a different token:

piece what it is read as ends at
shown the list js.expression as
item declares the item js.pattern , or ( or }
i declares the index, optional js.pattern ( or }
item.id the key, optional js.expression )

Every answer has end, the offset where that parse stopped. That is where the next piece starts.

read-component.js
const skip = (i) => {
	while (text[i] === ' ' || text[i] === '\t') i += 1;
	return i;
};

function each(from) {
	const list = expression(from, 'as');
	const afterAs = skip(skip(list.end) + 'as'.length);
	const item = source.parse(js.pattern.until(',', '(', '}'), afterAs);
	const declared = [...item.bindings];
	let next = skip(item.end);
	if (text[next] === ',') {
		const index = source.parse(js.pattern.until('(', '}'), skip(next + 1));
		declared.push(...index.bindings);
		next = skip(index.end);
	}
	inScope.push(declared);
	if (text[next] === '(') next = expression(next + 1, ')').end;
	return next;
}

5. Walk the template

The loop finds each { after the script and picks what to read from the text that follows it.

read-component.js
let at = close;
while ((at = text.indexOf('{', at)) !== -1) {
	if (text.startsWith('{#each ', at)) at = each(at + '{#each '.length);
	else if (text.startsWith('{/each}', at)) { inScope.pop(); at += 1; }
	else if (text.startsWith('{#if ', at)) {
		at = expression(at + '{#if '.length, '}').end;
	}
	else if (text.startsWith('{:else}', at) || text.startsWith('{/if}', at)) at += 1;
	else at = expression(at + 1, '}').end;
}

These are the parses the loop asked for, in order, with the offset each started at:

program      8  Program
expression 274  Identifier                 bind:value={query}
expression 310  BinaryExpression           {#if shown.length === 0}
expression 352  Identifier                 "{query}"
expression 387  Identifier                 {#each shown as …
pattern    396  Identifier   declares item
pattern    402  Identifier   declares i
expression 405  MemberExpression           (item.id)
expression 436  BinaryExpression           class:active={item === selected}
expression 464  ArrowFunctionExpression    onclick={() => pick(item)}
expression 488  BinaryExpression           {i + 1}
expression 497  MemberExpression           {item.name}

The result

resolved now has one entry per name the template uses, with the declaration it refers to:

query      at 274 -> let at 106
shown      at 310 -> const at 133
query      at 352 -> let at 106
shown      at 387 -> const at 133
item       at 405 -> pattern at 396
item       at 436 -> pattern at 396
selected   at 445 -> let at 71
pick       at 470 -> function at 212
item       at 475 -> pattern at 396
i          at 488 -> pattern at 402
item       at 497 -> pattern at 396

scriptitemsqueryshownselectedpicktemplateeach blockitemi{query}shown.lengthshown asitem.idi + 1item.nameitem === selected() => pick(item)

Look at pick(item) at 475. It sits inside an arrow function, () => pick(item), and the arrow declares no item, so the name came back unresolved and the lookup found the each block's. Had it been written (item) => pick(item), the parser would have resolved item to the parameter itself, and it would not be in resolved at all.

What to keep

  • The Source, for as long as the file is open. Any piece can be parsed again from it.
  • script, the script's tree and bindings.
  • resolved. A compiler reads it to know which template expressions depend on which declarations; a linter reads it to find a declaration nothing uses.
  • Embedded JavaScript is the guide this example applies.
  • Parsing with a grammar is the other way to do this: describe the template syntax to the parser once, and get the script and the template back as one tree.
  • Scopes has everything a binding and a reference carry.