diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index 429c6b4a6f78..2aa9a820d24b 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -10,6 +10,7 @@ import { transform_component, transform_module } from './phases/3-transform/inde import { validate_component_options, validate_module_options } from './validate-options.js'; import * as state from './state.js'; export { default as preprocess } from './preprocess/index.js'; +export { print } from './print/index.js'; /** * `compile` converts your `.svelte` source code into a JavaScript module that exports a component diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js new file mode 100644 index 000000000000..d908cc1435d5 --- /dev/null +++ b/packages/svelte/src/compiler/print/index.js @@ -0,0 +1,885 @@ +/** @import { AST } from '#compiler'; */ +/** @import { Context, Visitors } from 'esrap' */ +import * as esrap from 'esrap'; +import ts from 'esrap/languages/ts'; +import { is_void } from '../../utils.js'; + +/** + * @param {AST.SvelteNode} ast + */ +export function print(ast) { + return esrap.print( + ast, + /** @type {Visitors} */ ({ + ...ts({ comments: ast.type === 'Root' ? ast.comments : [] }), + ...visitors + }) + ); +} + +/** + * @param {Context} context + * @param {AST.SvelteNode} node + */ +function block(context, node, allow_inline = false) { + const child_context = context.new(); + child_context.visit(node); + + if (child_context.empty()) { + return; + } + + if (allow_inline && !child_context.multiline) { + context.append(child_context); + } else { + context.indent(); + context.newline(); + context.append(child_context); + context.dedent(); + context.newline(); + } +} + +/** @type {Visitors} */ +const visitors = { + Root(node, context) { + if (node.options) { + context.write(''); + } + + let started = false; + + for (const item of [node.module, node.instance, node.fragment, node.css]) { + if (!item) continue; + + if (started) { + context.margin(); + context.newline(); + } + + context.visit(item); + started = true; + } + }, + + Script(node, context) { + context.write(''); + block(context, node.content); + context.write(''); + }, + + Fragment(node, context) { + /** @type {AST.SvelteNode[][]} */ + const items = []; + + /** @type {AST.SvelteNode[]} */ + let sequence = []; + + const flush = () => { + items.push(sequence); + sequence = []; + }; + + for (let i = 0; i < node.nodes.length; i += 1) { + let child_node = node.nodes[i]; + + const prev = node.nodes[i - 1]; + const next = node.nodes[i + 1]; + + if (child_node.type === 'Text') { + child_node = { ...child_node }; // always clone, so we can safely mutate + + child_node.data = child_node.data.replace(/[^\S]+/g, ' '); + + // trim fragment + if (i === 0) { + child_node.data = child_node.data.trimStart(); + } + + if (i === node.nodes.length - 1) { + child_node.data = child_node.data.trimEnd(); + } + + if (child_node.data === '') { + continue; + } + + if (child_node.data.startsWith(' ') && prev && prev.type !== 'ExpressionTag') { + flush(); + child_node.data = child_node.data.trimStart(); + } + + if (child_node.data !== '') { + sequence.push({ ...child_node, data: child_node.data }); + + if (child_node.data.endsWith(' ') && next && next.type !== 'ExpressionTag') { + flush(); + child_node.data = child_node.data.trimStart(); + } + } + } else { + sequence.push(child_node); + } + } + + flush(); + + let multiline = false; + let width = 0; + + const child_contexts = items.map((sequence) => { + const child_context = context.new(); + + for (const node of sequence) { + child_context.visit(node); + multiline ||= child_context.multiline; + } + + width += child_context.measure(); + + return child_context; + }); + + multiline ||= width > 30; + + for (let i = 0; i < child_contexts.length; i += 1) { + const prev = child_contexts[i]; + const next = child_contexts[i + 1]; + + context.append(prev); + + if (next) { + if (prev.multiline || next.multiline) { + context.margin(); + context.newline(); + } else if (multiline) { + context.newline(); + } else { + context.write(' '); + } + } + } + }, + + AnimateDirective(node, context) { + context.write(`animate:${node.name}`); + if ( + node.expression !== null && + !(node.expression.type === 'Identifier' && node.expression.name === node.name) + ) { + context.write('={'); + context.visit(node.expression); + context.write('}'); + } + }, + + Atrule(node, context) { + context.write(`@${node.name}`); + if (node.prelude) context.write(` ${node.prelude}`); + + if (node.block) { + context.write(' '); + context.visit(node.block); + } else { + context.write(';'); + } + }, + + AttachTag(node, context) { + context.write('{@attach '); + context.visit(node.expression); + context.write('}'); + }, + + Attribute(node, context) { + context.write(node.name); + + if (node.value === true) return; + + context.write('='); + + if (Array.isArray(node.value)) { + if (node.value.length > 1 || node.value[0].type === 'Text') { + context.write('"'); + } + + for (const chunk of node.value) { + context.visit(chunk); + } + + if (node.value.length > 1 || node.value[0].type === 'Text') { + context.write('"'); + } + } else { + context.visit(node.value); + } + }, + + AwaitBlock(node, context) { + context.write(`{#await `); + context.visit(node.expression); + + if (node.pending) { + context.write('}'); + block(context, node.pending); + context.write('{:'); + } else { + context.write(' '); + } + + if (node.then) { + context.write(node.value ? 'then ' : 'then'); + if (node.value) context.visit(node.value); + context.write('}'); + + block(context, node.then); + + if (node.catch) { + context.write('{:'); + } + } + + if (node.catch) { + context.write(node.value ? 'catch ' : 'catch'); + if (node.error) context.visit(node.error); + context.write('}'); + + block(context, node.catch); + } + + context.write('{/await}'); + }, + + BindDirective(node, context) { + context.write(`bind:${node.name}`); + + if (node.expression.type === 'Identifier' && node.expression.name === node.name) { + // shorthand + return; + } + + context.write('={'); + + if (node.expression.type === 'SequenceExpression') { + context.visit(node.expression.expressions[0]); + context.write(', '); + context.visit(node.expression.expressions[1]); + } else { + context.visit(node.expression); + } + + context.write('}'); + }, + + Block(node, context) { + context.write('{'); + + if (node.children.length > 0) { + context.indent(); + context.newline(); + + let started = false; + + for (const child of node.children) { + if (started) { + context.margin(); + context.newline(); + } + + context.visit(child); + + started = true; + } + + context.dedent(); + context.newline(); + } + + context.write('}'); + }, + + ClassDirective(node, context) { + context.write(`class:${node.name}`); + if ( + node.expression !== null && + !(node.expression.type === 'Identifier' && node.expression.name === node.name) + ) { + context.write('={'); + context.visit(node.expression); + context.write('}'); + } + }, + + ClassSelector(node, context) { + context.write(`.${node.name}`); + }, + + Comment(node, context) { + context.write(''); + }, + + ComplexSelector(node, context) { + for (const selector of node.children) { + context.visit(selector); + } + }, + + Component(node, context) { + context.write(`<${node.name}`); + + for (let i = 0; i < node.attributes.length; i += 1) { + context.write(' '); + context.visit(node.attributes[i]); + } + + if (node.fragment.nodes.length > 0) { + context.write('>'); + block(context, node.fragment, true); + context.write(``); + } else { + context.write(' />'); + } + }, + + ConstTag(node, context) { + context.write('{@const '); + context.visit(node.declaration); + context.write('}'); + }, + + DebugTag(node, context) { + context.write('{@debug '); + let started = false; + for (const identifier of node.identifiers) { + if (started) { + context.write(', '); + } + context.visit(identifier); + started = true; + } + context.write('}'); + }, + + Declaration(node, context) { + context.write(`${node.property}: ${node.value};`); + }, + + EachBlock(node, context) { + context.write('{#each '); + context.visit(node.expression); + + if (node.context) { + context.write(' as '); + context.visit(node.context); + } + + if (node.index) { + context.write(`, ${node.index}`); + } + + if (node.key) { + context.write(' ('); + context.visit(node.key); + context.write(')'); + } + + context.write('}'); + + block(context, node.body); + + if (node.fallback) { + context.write('{:else}'); + context.visit(node.fallback); + } + + context.write('{/each}'); + }, + + ExpressionTag(node, context) { + context.write('{'); + context.visit(node.expression); + context.write('}'); + }, + + HtmlTag(node, context) { + context.write('{@html '); + context.visit(node.expression); + context.write('}'); + }, + + IfBlock(node, context) { + if (node.elseif) { + context.write('{:else if '); + context.visit(node.test); + context.write('}'); + + block(context, node.consequent); + } else { + context.write('{#if '); + context.visit(node.test); + context.write('}'); + + block(context, node.consequent); + } + + if (node.alternate !== null) { + if ( + !( + node.alternate.nodes.length === 1 && + node.alternate.nodes[0].type === 'IfBlock' && + node.alternate.nodes[0].elseif + ) + ) { + context.write('{:else}'); + } + + block(context, node.alternate); + } + + if (!node.elseif) { + context.write('{/if}'); + } + }, + + KeyBlock(node, context) { + context.write('{#key '); + context.visit(node.expression); + context.write('}'); + block(context, node.fragment); + context.write('{/key}'); + }, + + LetDirective(node, context) { + context.write(`let:${node.name}`); + if ( + node.expression !== null && + !(node.expression.type === 'Identifier' && node.expression.name === node.name) + ) { + context.write('={'); + context.visit(node.expression); + context.write('}'); + } + }, + + Nth(node, context) { + context.write(node.value); // TODO is this right? + }, + + OnDirective(node, context) { + context.write(`on:${node.name}`); + for (const modifier of node.modifiers) { + context.write(`|${modifier}`); + } + if ( + node.expression !== null && + !(node.expression.type === 'Identifier' && node.expression.name === node.name) + ) { + context.write('={'); + context.visit(node.expression); + context.write('}'); + } + }, + + PseudoClassSelector(node, context) { + context.write(`:${node.name}`); + + if (node.args) { + context.write('('); + + let started = false; + + for (const arg of node.args.children) { + if (started) { + context.write(', '); + } + + context.visit(arg); + + started = true; + } + + context.write(')'); + } + }, + + PseudoElementSelector(node, context) { + context.write(`::${node.name}`); + }, + + RegularElement(node, context) { + const child_context = context.new(); + + child_context.write('<' + node.name); + + for (const attribute of node.attributes) { + // TODO handle multiline + child_context.write(' '); + child_context.visit(attribute); + } + + if (is_void(node.name)) { + child_context.write(' />'); + } else { + child_context.write('>'); + + if (node.fragment) { + block(child_context, node.fragment, child_context.measure() < 30); + child_context.write(``); + } + } + + context.append(child_context); + }, + + RelativeSelector(node, context) { + if (node.combinator) { + if (node.combinator.name === ' ') { + context.write(' '); + } else { + context.write(` ${node.combinator.name} `); + } + } + + for (const selector of node.selectors) { + context.visit(selector); + } + }, + + RenderTag(node, context) { + context.write('{@render '); + context.visit(node.expression); + context.write('}'); + }, + + Rule(node, context) { + let started = false; + + for (const selector of node.prelude.children) { + if (started) { + context.write(','); + context.newline(); + } + + context.visit(selector); + started = true; + } + + context.write(' '); + context.visit(node.block); + }, + + SelectorList(node, context) { + let started = false; + for (const selector of node.children) { + if (started) { + context.write(', '); + } + + context.visit(selector); + started = true; + } + }, + + SlotElement(node, context) { + context.write(' 0) { + context.write('>'); + context.visit(node.fragment); // TODO block/inline + context.write(''); + } else { + context.write(' />'); + } + }, + + SnippetBlock(node, context) { + context.write('{#snippet '); + context.visit(node.expression); + + if (node.typeParams) { + context.write(`<${node.typeParams}>`); + } + + context.write('('); + + for (let i = 0; i < node.parameters.length; i += 1) { + if (i > 0) context.write(', '); + context.visit(node.parameters[i]); + } + + context.write(')}'); + block(context, node.body); + context.write('{/snippet}'); + }, + + SpreadAttribute(node, context) { + context.write('{...'); + context.visit(node.expression); + context.write('}'); + }, + + StyleDirective(node, context) { + context.write(`style:${node.name}`); + for (const modifier of node.modifiers) { + context.write(`|${modifier}`); + } + + if (node.value === true) { + return; + } + + context.write('='); + + if (Array.isArray(node.value)) { + context.write('"'); + + for (const tag of node.value) { + context.visit(tag); + } + + context.write('"'); + } else { + context.visit(node.value); + } + }, + + StyleSheet(node, context) { + context.write(''); + + if (node.children.length > 0) { + context.indent(); + context.newline(); + + let started = false; + + for (const child of node.children) { + if (started) { + context.margin(); + context.newline(); + } + + context.visit(child); + started = true; + } + + context.dedent(); + context.newline(); + } + + context.write(''); + }, + + SvelteBoundary(node, context) { + context.write(''); + block(context, node.fragment, true); + context.write(``); + } else { + context.write('/>'); + } + }, + + SvelteComponent(node, context) { + context.write(''); + block(context, node.fragment, true); + context.write(``); + } else { + context.write('/>'); + } + }, + + SvelteDocument(node, context) { + context.write(''); + block(context, node.fragment, true); + context.write(``); + } else { + context.write('/>'); + } + }, + + SvelteElement(node, context) { + context.write(''); + block(context, node.fragment, true); + context.write(``); + } else { + context.write('/>'); + } + }, + + SvelteFragment(node, context) { + context.write(''); + block(context, node.fragment, true); + context.write(``); + } else { + context.write('/>'); + } + }, + + SvelteHead(node, context) { + context.write(''); + block(context, node.fragment, true); + context.write(``); + } else { + context.write('/>'); + } + }, + + SvelteSelf(node, context) { + context.write(''); + block(context, node.fragment, true); + context.write(``); + } else { + context.write('/>'); + } + }, + + SvelteWindow(node, context) { + context.write(''); + block(context, node.fragment, true); + context.write(``); + } else { + context.write('/>'); + } + }, + + Text(node, context) { + context.write(node.data); + }, + + TransitionDirective(node, context) { + const directive = node.intro && node.outro ? 'transition' : node.intro ? 'in' : 'out'; + context.write(`${directive}:${node.name}`); + for (const modifier of node.modifiers) { + context.write(`|${modifier}`); + } + if ( + node.expression !== null && + !(node.expression.type === 'Identifier' && node.expression.name === node.name) + ) { + context.write('={'); + context.visit(node.expression); + context.write('}'); + } + }, + + TypeSelector(node, context) { + context.write(node.name); + }, + + UseDirective(node, context) { + context.write(`use:${node.name}`); + if ( + node.expression !== null && + !(node.expression.type === 'Identifier' && node.expression.name === node.name) + ) { + context.write('={'); + context.visit(node.expression); + context.write('}'); + } + } +}; diff --git a/packages/svelte/tests/parser-modern/test.ts b/packages/svelte/tests/parser-modern/test.ts index 279ba7bc08ed..4f9bae4a056c 100644 --- a/packages/svelte/tests/parser-modern/test.ts +++ b/packages/svelte/tests/parser-modern/test.ts @@ -1,12 +1,16 @@ import * as fs from 'node:fs'; import { assert, it } from 'vitest'; -import { parse } from 'svelte/compiler'; +import { parse, print } from 'svelte/compiler'; import { try_load_json } from '../helpers.js'; import { suite, type BaseTest } from '../suite.js'; +import { walk } from 'zimmerframe'; +import type { AST } from 'svelte/compiler'; interface ParserTest extends BaseTest {} const { test, run } = suite(async (config, cwd) => { + const loose = cwd.split('/').pop()!.startsWith('loose-'); + const input = fs .readFileSync(`${cwd}/input.svelte`, 'utf-8') .replace(/\s+$/, '') @@ -32,8 +36,84 @@ const { test, run } = suite(async (config, cwd) => { const expected = try_load_json(`${cwd}/output.json`); assert.deepEqual(actual, expected); } + + if (!loose) { + const printed = print(actual); + const reparsed = JSON.parse( + JSON.stringify( + parse(printed.code, { + modern: true, + loose + }) + ) + ); + + fs.writeFileSync(`${cwd}/_actual.svelte`, printed.code); + + delete reparsed.comments; + + assert.deepEqual(clean(actual), clean(reparsed)); + } }); +function clean(ast: AST.SvelteNode) { + return walk(ast, null, { + _(node, context) { + // @ts-ignore + delete node.start; + // @ts-ignore + delete node.end; + // @ts-ignore + delete node.loc; + // @ts-ignore + delete node.leadingComments; + // @ts-ignore + delete node.trailingComments; + + context.next(); + }, + StyleSheet(node, context) { + return { + type: node.type, + attributes: node.attributes.map((attribute) => context.visit(attribute)), + children: node.children.map((child) => context.visit(child)), + content: {} + } as AST.SvelteNode; + }, + Fragment(node, context) { + const nodes: AST.SvelteNode[] = []; + + for (let i = 0; i < node.nodes.length; i += 1) { + let child = node.nodes[i]; + + if (child.type === 'Text') { + child = { + ...child, + data: child.data.replace(/[^\S]+/g, ' '), + raw: child.raw.replace(/[^\S]+/g, ' ') + }; + + if (i === 0) { + child.data = child.data.trimStart(); + child.raw = child.raw.trimStart(); + } + + if (i === node.nodes.length - 1) { + child.data = child.data.trimEnd(); + child.raw = child.raw.trimEnd(); + } + + if (child.data === '') continue; + } + + nodes.push(context.visit(child)); + } + + return { ...node, nodes } as AST.Fragment; + } + }); +} + export { test }; await run(__dirname); diff --git a/packages/svelte/tests/print/samples/basic/input.svelte b/packages/svelte/tests/print/samples/basic/input.svelte new file mode 100644 index 000000000000..bff6bee537b4 --- /dev/null +++ b/packages/svelte/tests/print/samples/basic/input.svelte @@ -0,0 +1,7 @@ + + +

+ Hello {name}! +

diff --git a/packages/svelte/tests/print/samples/basic/output.svelte b/packages/svelte/tests/print/samples/basic/output.svelte new file mode 100644 index 000000000000..22b3c84db099 --- /dev/null +++ b/packages/svelte/tests/print/samples/basic/output.svelte @@ -0,0 +1,5 @@ + + +

Hello {name}!

\ No newline at end of file diff --git a/packages/svelte/tests/print/samples/block/input.svelte b/packages/svelte/tests/print/samples/block/input.svelte new file mode 100644 index 000000000000..470f7a1efbdc --- /dev/null +++ b/packages/svelte/tests/print/samples/block/input.svelte @@ -0,0 +1 @@ +{#if condition} yes {:else} no {/if} diff --git a/packages/svelte/tests/print/samples/block/output.svelte b/packages/svelte/tests/print/samples/block/output.svelte new file mode 100644 index 000000000000..e0ff317fc8f4 --- /dev/null +++ b/packages/svelte/tests/print/samples/block/output.svelte @@ -0,0 +1,5 @@ +{#if condition} + yes +{:else} + no +{/if} diff --git a/packages/svelte/tests/print/test.ts b/packages/svelte/tests/print/test.ts new file mode 100644 index 000000000000..c678dedf3f36 --- /dev/null +++ b/packages/svelte/tests/print/test.ts @@ -0,0 +1,32 @@ +import * as fs from 'node:fs'; +import { assert, it } from 'vitest'; +import { parse, print } from 'svelte/compiler'; +import { try_load_json } from '../helpers.js'; +import { suite, type BaseTest } from '../suite.js'; +import { walk } from 'zimmerframe'; +import type { AST } from 'svelte/compiler'; + +interface ParserTest extends BaseTest {} + +const { test, run } = suite(async (config, cwd) => { + const input = fs.readFileSync(`${cwd}/input.svelte`, 'utf-8'); + + const ast = parse(input, { modern: true }); + const output = print(ast); + + // run `UPDATE_SNAPSHOTS=true pnpm test parser` to update parser tests + if (process.env.UPDATE_SNAPSHOTS) { + fs.writeFileSync(`${cwd}/output.svelte`, output.code); + } else { + fs.writeFileSync(`${cwd}/_actual.svelte`, output.code); + + const file = `${cwd}/output.svelte`; + + const expected = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + assert.deepEqual(output.code.trim().replaceAll('\r', ''), expected.trim().replaceAll('\r', '')); + } +}); + +export { test }; + +await run(__dirname); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 3401efac0464..b295dbdac6cd 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1518,6 +1518,10 @@ declare module 'svelte/compiler' { export function preprocess(source: string, preprocessor: PreprocessorGroup | PreprocessorGroup[], options?: { filename?: string; } | undefined): Promise; + export function print(ast: AST.SvelteNode): { + code: string; + map: any; + }; /** * The current version, as set in package.json. * */ diff --git a/playgrounds/sandbox/run.js b/playgrounds/sandbox/run.js index 2029937f52dc..348e43b9c062 100644 --- a/playgrounds/sandbox/run.js +++ b/playgrounds/sandbox/run.js @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { globSync } from 'tinyglobby'; -import { compile, compileModule, parse, migrate } from 'svelte/compiler'; +import { compile, compileModule, parse, print, migrate } from 'svelte/compiler'; const argv = parseArgs({ options: { runes: { type: 'boolean' } }, args: process.argv.slice(2) }); @@ -70,6 +70,10 @@ for (const generate of /** @type {const} */ (['client', 'server'])) { } catch (e) { console.warn(`Error migrating ${file}`, e); } + + const printed = print(ast); + + write(`${cwd}/output/printed/${file}`, printed.code); } const compiled = compile(source, {