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:
<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
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.
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:paramThe 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.
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.
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.
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 396Look 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.
Related
- 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.