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, { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy