// A space at the start or the end of a block ({#if}, {:else}, {#each}…) is dropped by Svelte, // in the browser as on the server: glued to what comes before, « {n} contre{#if s} dont // 1 simulé{/if} » reads « 0 contredont 1 simulé » (review of M6b). Every component is // read here, and a space a block would drop is pointed at: `{' '}` keeps it. import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { parse } from 'svelte/compiler'; import { describe, expect, it } from 'vitest'; const SRC = join(__dirname, '../../src'); function components(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const path = join(dir, e.name); return e.isDirectory() ? components(path) : e.name.endsWith('.svelte') ? [path] : []; }); } interface Node { type: string; start: number; data?: string; elseif?: boolean; [key: string]: unknown; } interface Fragment { nodes: Node[]; } /** The fragments a block shows, its `{:else if}` chain included. */ function branches(b: Node): Fragment[] { const out: Fragment[] = []; for (const key of ['consequent', 'alternate', 'body', 'fallback', 'pending', 'then', 'catch', 'fragment']) { const f = b[key] as Fragment | null | undefined; if (!f?.nodes) continue; const only = f.nodes.length === 1 ? f.nodes[0] : undefined; if (key === 'alternate' && only?.type === 'IfBlock' && only.elseif) out.push(...branches(only)); else out.push(f); } return out; } const BLOCKS = new Set(['IfBlock', 'EachBlock', 'AwaitBlock', 'KeyBlock']); /** A space or a tab, not the newline that only lays the markup out. */ const LEADS = /^[ \t]/; const TRAILS = /[ \t]$/; /** Each space a block drops while something is glued to it, as `line: text`. */ function dropped(source: string): string[] { const ast = parse(source, { modern: true }) as unknown as { fragment: Fragment }; const out: string[] = []; const line = (at: number) => source.slice(0, at).split('\n').length; const walk = (f: Fragment) => { f.nodes.forEach((n, i) => { if (BLOCKS.has(n.type)) { const before = f.nodes[i - 1]; const after = f.nodes[i + 1]; const gluedBefore = before !== undefined && !(before.type === 'Text' && /\s$/.test(before.data ?? '')); const gluedAfter = after !== undefined && !(after.type === 'Text' && /^\s/.test(after.data ?? '')); for (const b of branches(n)) { const first = b.nodes[0]; const last = b.nodes.at(-1); if (gluedBefore && first?.type === 'Text' && LEADS.test(first.data ?? '')) { out.push(`${line(first.start)}: « ${(first.data ?? '').slice(0, 30)} »`); } if (gluedAfter && last?.type === 'Text' && TRAILS.test(last.data ?? '')) { out.push(`${line(last.start)}: « ${(last.data ?? '').slice(-30)} »`); } } } for (const value of Object.values(n)) { const child = value as Fragment | null; if (child && typeof child === 'object' && Array.isArray(child.nodes)) walk(child); } }); }; walk(ast.fragment); return out; } describe('a space a block would drop', () => { it('is seen where it is glued', () => { expect(dropped('

{a}{#if s} x{/if}

')).toHaveLength(1); expect(dropped('

{a}{#if s} · x{:else if t} · y{:else} · z{/if}

')).toHaveLength(3); expect(dropped('

{#each xs as x}{x} {/each}y

')).toHaveLength(1); // Kept: `{' '}`, a space outside the block, or a newline that lays the markup out. expect(dropped("

{a}{#if s}{' '}x{/if}

")).toEqual([]); expect(dropped('

{a} {#if s} · x{/if}

')).toEqual([]); expect(dropped('
{a}{#if s}\n

x

\n{/if}
')).toEqual([]); }); it('is never dropped in a component of the interface', () => { const found = components(SRC).flatMap((path) => dropped(readFileSync(path, 'utf8')).map((d) => `${relative(SRC, path)}:${d}`), ); expect(found).toEqual([]); }); });