diff --git a/community-addon-template/src/index.js b/community-addon-template/src/index.js index 61dc312ea..57d34e1d1 100644 --- a/community-addon-template/src/index.js +++ b/community-addon-template/src/index.js @@ -1,5 +1,6 @@ import { defineAddon, defineAddonOptions } from '@sveltejs/cli-core'; import { imports } from '@sveltejs/cli-core/js'; +import * as svelte from '@sveltejs/cli-core/svelte'; import { parseSvelte } from '@sveltejs/cli-core/parsers'; export const options = defineAddonOptions() @@ -26,9 +27,10 @@ export default defineAddon({ sv.file('src/DemoComponent.svelte', (content) => { if (!options.demo) return content; - const { script, generateCode } = parseSvelte(content, { typescript }); - imports.addDefault(script.ast, { from: '../addon-template-demo.txt?raw', as: 'demo' }); - return generateCode({ script: script.generateCode(), template: '{demo}' }); + const { ast, generateCode } = parseSvelte(content); + const scriptAst = svelte.ensureScript(ast, { langTs: typescript }); + imports.addDefault(scriptAst, { from: '../addon-template-demo.txt?raw', as: 'demo' }); + return generateCode(); }); } }); diff --git a/packages/addons/_tests/mdsvex/test.ts b/packages/addons/_tests/mdsvex/test.ts index 07c7a0ecd..ebf622898 100644 --- a/packages/addons/_tests/mdsvex/test.ts +++ b/packages/addons/_tests/mdsvex/test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { expect } from '@playwright/test'; import { parseSvelte } from '@sveltejs/cli-core/parsers'; import { imports } from '@sveltejs/cli-core/js'; -import * as html from '@sveltejs/cli-core/html'; +import * as svelte from '@sveltejs/cli-core/svelte'; import { setupTest } from '../_setup/suite.ts'; import { svxFile } from './fixtures.ts'; import mdsvex from '../../mdsvex/index.ts'; @@ -40,19 +40,32 @@ function addFixture(cwd: string, variant: string) { } const src = fs.readFileSync(page, 'utf8'); - const { script, template, generateCode } = parseSvelte(src); - imports.addDefault(script.ast, { from: './Demo.svx', as: 'Demo' }); + const { ast, generateCode } = parseSvelte(src); + const scriptAst = svelte.ensureScript(ast); + imports.addDefault(scriptAst, { from: './Demo.svx', as: 'Demo' }); - const div = html.createDiv({ class: 'mdsvex' }); - html.appendElement(template.ast.childNodes, div); - const mdsvexNode = html.createElement('Demo'); - html.appendElement(div.childNodes, mdsvexNode); - - const content = generateCode({ - script: script.generateCode(), - template: template.generateCode() + ast.fragment.nodes.push({ + type: 'RegularElement', + name: 'div', + attributes: [ + { + type: 'Attribute', + name: 'class', + value: [{ type: 'Text', data: 'mdsvex', raw: 'mdsvex', start: 0, end: 0 }], + start: 0, + end: 0 + } + ], + fragment: { + type: 'Fragment', + nodes: svelte.toFragment('') + }, + start: 0, + end: 0 }); + const content = generateCode(); + fs.writeFileSync(page, content, 'utf8'); fs.writeFileSync(svx, svxFile, 'utf8'); } diff --git a/packages/addons/common.ts b/packages/addons/common.ts index 2b318a62c..f7f73c868 100644 --- a/packages/addons/common.ts +++ b/packages/addons/common.ts @@ -1,4 +1,5 @@ import { imports, exports, common } from '@sveltejs/cli-core/js'; +import { toFragment, type SvelteAst, ensureScript } from '@sveltejs/cli-core/svelte'; import { parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; import process from 'node:process'; @@ -63,22 +64,34 @@ export function addEslintConfigPrettier(content: string): string { return generateCode(); } -export function addToDemoPage(existingContent: string, path: string, typescript: boolean): string { - const { script, template, generateCode } = parseSvelte(existingContent, { typescript }); - - for (const node of template.ast.childNodes) { - // we use includes as it could be "/demo/${path}" or "resolve("demo/${path}")" or "resolve('demo/${path}')" - if (node.type === 'tag' && node.attribs['href'].includes(`/demo/${path}`)) { - return existingContent; +export function addToDemoPage(existingContent: string, path: string, langTs: boolean): string { + const { ast, generateCode } = parseSvelte(existingContent); + + for (const node of ast.fragment.nodes) { + if (node.type === 'RegularElement') { + const hrefAttribute = node.attributes.find( + (x) => x.type === 'Attribute' && x.name === 'href' + ) as SvelteAst.Attribute; + if (!hrefAttribute || !hrefAttribute.value) continue; + + if (!Array.isArray(hrefAttribute.value)) continue; + + const hasDemo = hrefAttribute.value.some( + // we use includes as it could be "/demo/${path}" or "resolve("demo/${path}")" or "resolve('demo/${path}')" + (x) => x.type === 'Text' && x.data.includes(`/demo/${path}`) + ); + if (hasDemo) { + return existingContent; + } } } - imports.addNamed(script.ast, { imports: ['resolve'], from: '$app/paths' }); + imports.addNamed(ensureScript(ast, { langTs }), { imports: ['resolve'], from: '$app/paths' }); + + ast.fragment.nodes.unshift(...toFragment(`${path}`)); + ast.fragment.nodes.unshift(); - return generateCode({ - script: script.generateCode(), - template: `${path}\n${template.source}` - }); + return generateCode(); } /** diff --git a/packages/addons/paraglide/index.ts b/packages/addons/paraglide/index.ts index b8d4d2de0..81b73dc62 100644 --- a/packages/addons/paraglide/index.ts +++ b/packages/addons/paraglide/index.ts @@ -1,7 +1,7 @@ -import MagicString from 'magic-string'; import { colors, defineAddon, defineAddonOptions, log } from '@sveltejs/cli-core'; import { common, imports, variables, exports, kit as kitJs, vite } from '@sveltejs/cli-core/js'; import * as html from '@sveltejs/cli-core/html'; +import * as svelte from '@sveltejs/cli-core/svelte'; import { parseHtml, parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; import { addToDemoPage } from '../common.ts'; @@ -183,35 +183,34 @@ export default defineAddon({ // add usage example sv.file(`${kit.routesDirectory}/demo/paraglide/+page.svelte`, (content) => { - const { script, template, generateCode } = parseSvelte(content, { typescript }); - imports.addNamed(script.ast, { from: '$lib/paraglide/messages.js', imports: ['m'] }); - imports.addNamed(script.ast, { from: '$app/navigation', imports: ['goto'] }); - imports.addNamed(script.ast, { from: '$app/state', imports: ['page'] }); - imports.addNamed(script.ast, { from: '$lib/paraglide/runtime', imports: ['setLocale'] }); + const { ast, generateCode } = parseSvelte(content); + const scriptAst = svelte.ensureScript(ast, { langTs: typescript }); - const scriptCode = new MagicString(script.generateCode()); - - const templateCode = new MagicString(template.source); + imports.addNamed(scriptAst, { imports: { m: 'm' }, from: '$lib/paraglide/messages.js' }); + imports.addNamed(scriptAst, { + imports: { + setLocale: 'setLocale' + }, + from: '$lib/paraglide/runtime' + }); // add localized message - templateCode.append("\n\n

{m.hello_world({ name: 'SvelteKit User' })}

\n"); + let templateCode = "

{m.hello_world({ name: 'SvelteKit User' })}

"; // add links to other localized pages, the first one is the default // language, thus it does not require any localized route const { validLanguageTags } = parseLanguageTagInput(options.languageTags); const links = validLanguageTags - .map( - (x) => - `${templateCode.getIndentString()}` - ) - .join('\n'); - templateCode.append(`
\n${links}\n
`); - - templateCode.append( - '

\nIf you use VSCode, install the Sherlock i18n extension for a better i18n experience.\n

' - ); + .map((x) => ``) + .join(''); + templateCode += `
${links}
`; + + templateCode += + '

If you use VSCode, install the Sherlock i18n extension for a better i18n experience.

'; - return generateCode({ script: scriptCode.toString(), template: templateCode.toString() }); + ast.fragment.nodes.push(...svelte.toFragment(templateCode)); + + return generateCode(); }); } diff --git a/packages/addons/tailwindcss/index.ts b/packages/addons/tailwindcss/index.ts index 8c56eb72e..3d3540bbc 100644 --- a/packages/addons/tailwindcss/index.ts +++ b/packages/addons/tailwindcss/index.ts @@ -1,7 +1,7 @@ import { defineAddon, defineAddonOptions } from '@sveltejs/cli-core'; import { imports, vite } from '@sveltejs/cli-core/js'; +import * as svelte from '@sveltejs/cli-core/svelte'; import { parseCss, parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; -import { addSlot } from '@sveltejs/cli-core/html'; const plugins = [ { @@ -32,7 +32,7 @@ export default defineAddon({ shortDescription: 'css framework', homepage: 'https://tailwindcss.com', options, - run: ({ sv, options, files, typescript, kit, dependencyVersion }) => { + run: ({ sv, options, files, kit, dependencyVersion, typescript }) => { const prettierInstalled = Boolean(dependencyVersion('prettier')); sv.devDependency('tailwindcss', '^4.1.17'); @@ -97,30 +97,28 @@ export default defineAddon({ const appSvelte = 'src/App.svelte'; const stylesheetRelative = files.getRelative({ from: appSvelte, to: files.stylesheet }); sv.file(appSvelte, (content) => { - const { script, generateCode } = parseSvelte(content, { typescript }); - imports.addEmpty(script.ast, { from: stylesheetRelative }); - return generateCode({ script: script.generateCode() }); + const { ast, generateCode } = parseSvelte(content); + const scriptAst = svelte.ensureScript(ast, { langTs: typescript }); + imports.addEmpty(scriptAst, { from: stylesheetRelative }); + return generateCode(); }); } else { const layoutSvelte = `${kit?.routesDirectory}/+layout.svelte`; const stylesheetRelative = files.getRelative({ from: layoutSvelte, to: files.stylesheet }); sv.file(layoutSvelte, (content) => { - const { script, template, generateCode } = parseSvelte(content, { typescript }); - imports.addEmpty(script.ast, { from: stylesheetRelative }); + const { ast, generateCode } = parseSvelte(content); + const scriptAst = svelte.ensureScript(ast, { langTs: typescript }); + imports.addEmpty(scriptAst, { from: stylesheetRelative }); if (content.length === 0) { const svelteVersion = dependencyVersion('svelte'); if (!svelteVersion) throw new Error('Failed to determine svelte version'); - addSlot(script.ast, { - htmlAst: template.ast, + svelte.addSlot(ast, { svelteVersion }); } - return generateCode({ - script: script.generateCode(), - template: content.length === 0 ? template.generateCode() : undefined - }); + return generateCode(); }); } diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 35addd565..616956b1d 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -255,11 +255,7 @@ async function createProject(cwd: ProjectPath, options: Options) { }); if (options.fromPlayground) { - await createProjectFromPlayground( - options.fromPlayground, - projectPath, - language === 'typescript' - ); + await createProjectFromPlayground(options.fromPlayground, projectPath); } p.log.success('Project created'); @@ -322,11 +318,7 @@ async function createProject(cwd: ProjectPath, options: Options) { return { directory: projectPath, addOnNextSteps, packageManager }; } -async function createProjectFromPlayground( - url: string, - cwd: string, - typescript: boolean -): Promise { +async function createProjectFromPlayground(url: string, cwd: string): Promise { const urlData = parsePlaygroundUrl(url); const playground = await downloadPlaygroundData(urlData); @@ -334,7 +326,7 @@ async function createProjectFromPlayground( const dependencies = detectPlaygroundDependencies(playground.files); const installDependencies = await confirmExternalDependencies(Array.from(dependencies.keys())); - setupPlaygroundProject(url, playground, cwd, installDependencies, typescript); + setupPlaygroundProject(url, playground, cwd, installDependencies); } async function confirmExternalDependencies(dependencies: string[]): Promise { diff --git a/packages/cli/tests/snapshots/create-with-all-addons/src/routes/+layout.svelte b/packages/cli/tests/snapshots/create-with-all-addons/src/routes/+layout.svelte index 8b9bd05ca..0d8eb0307 100644 --- a/packages/cli/tests/snapshots/create-with-all-addons/src/routes/+layout.svelte +++ b/packages/cli/tests/snapshots/create-with-all-addons/src/routes/+layout.svelte @@ -1,12 +1,9 @@ - - - - + {@render children()} diff --git a/packages/cli/tests/snapshots/create-with-all-addons/src/routes/demo/paraglide/+page.svelte b/packages/cli/tests/snapshots/create-with-all-addons/src/routes/demo/paraglide/+page.svelte index 04d3480cc..671434223 100644 --- a/packages/cli/tests/snapshots/create-with-all-addons/src/routes/demo/paraglide/+page.svelte +++ b/packages/cli/tests/snapshots/create-with-all-addons/src/routes/demo/paraglide/+page.svelte @@ -1,16 +1,20 @@ - -

{m.hello_world({ name: 'SvelteKit User' })}

-

-If you use VSCode, install the Sherlock i18n extension for a better i18n experience. + +

+ If you use VSCode, install the + + Sherlock i18n extension + + for a better i18n experience.

diff --git a/packages/core/package.json b/packages/core/package.json index 7019a1447..4e08e1088 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,6 +22,7 @@ "./css": "./tooling/css/index.ts", "./html": "./tooling/html/index.ts", "./js": "./tooling/js/index.ts", + "./svelte": "./tooling/svelte/index.ts", "./parsers": "./tooling/parsers.ts" }, "devDependencies": { @@ -40,6 +41,7 @@ "picocolors": "^1.1.1", "postcss": "^8.5.6", "silver-fleece": "^1.2.1", + "svelte": "^5.45.0", "yaml": "^2.8.1", "zimmerframe": "^1.1.4" }, diff --git a/packages/core/tests/js/object/property-node/input.ts b/packages/core/tests/js/object/property-node/input.ts index 4b075890b..537cd089c 100644 --- a/packages/core/tests/js/object/property-node/input.ts +++ b/packages/core/tests/js/object/property-node/input.ts @@ -1,4 +1,4 @@ const test = { - /** a comment */ + // prettier-ignore foo: 1 }; diff --git a/packages/core/tests/js/object/property-node/output.ts b/packages/core/tests/js/object/property-node/output.ts index 3719acb91..c163865df 100644 --- a/packages/core/tests/js/object/property-node/output.ts +++ b/packages/core/tests/js/object/property-node/output.ts @@ -1,6 +1,6 @@ const test = { - /** a comment */ + /*a comment updated*/ // prettier-ignore foo: 1, - james: '007' + /*aka: bond, james bond*/ james: '007' }; diff --git a/packages/core/tests/js/object/property-node/run.ts b/packages/core/tests/js/object/property-node/run.ts index 9db2672ea..18372acb4 100644 --- a/packages/core/tests/js/object/property-node/run.ts +++ b/packages/core/tests/js/object/property-node/run.ts @@ -1,18 +1,18 @@ -import { object, common, type AstTypes } from '@sveltejs/cli-core/js'; +import { object, common, type AstTypes, type Comments } from '@sveltejs/cli-core/js'; import { getTestObjectExpression } from '../objectTestHelper.ts'; -export function run(ast: AstTypes.Program): void { +export function run(ast: AstTypes.Program, comments: Comments): void { const obj = getTestObjectExpression(ast); const p1 = object.propertyNode(obj, { name: 'foo', fallback: object.create({}) }); - p1.leadingComments = [{ type: 'Block', value: 'a comment updated' }]; + comments.add(p1, { type: 'Block', value: 'a comment updated' }); const p2 = object.propertyNode(obj, { name: 'james', fallback: common.createLiteral('007') }); - p2.leadingComments = [{ type: 'Block', value: 'aka: bond, james bond' }]; + comments.add(p2, { type: 'Block', value: 'aka: bond, james bond' }); } diff --git a/packages/core/tests/svelte/common/ensure-script-ts/input.svelte b/packages/core/tests/svelte/common/ensure-script-ts/input.svelte new file mode 100644 index 000000000..175b82b52 --- /dev/null +++ b/packages/core/tests/svelte/common/ensure-script-ts/input.svelte @@ -0,0 +1 @@ +Nothing to see here diff --git a/packages/core/tests/svelte/common/ensure-script-ts/output.svelte b/packages/core/tests/svelte/common/ensure-script-ts/output.svelte new file mode 100644 index 000000000..ff42b6951 --- /dev/null +++ b/packages/core/tests/svelte/common/ensure-script-ts/output.svelte @@ -0,0 +1,3 @@ + + +Nothing to see here diff --git a/packages/core/tests/svelte/common/ensure-script-ts/run.ts b/packages/core/tests/svelte/common/ensure-script-ts/run.ts new file mode 100644 index 000000000..2895bd1cd --- /dev/null +++ b/packages/core/tests/svelte/common/ensure-script-ts/run.ts @@ -0,0 +1,5 @@ +import { type SvelteAst, ensureScript } from '@sveltejs/cli-core/svelte'; + +export function run(ast: SvelteAst.Root): void { + ensureScript(ast, { langTs: true }); +} diff --git a/packages/core/tests/svelte/common/ensure-script/input.svelte b/packages/core/tests/svelte/common/ensure-script/input.svelte new file mode 100644 index 000000000..f7cb47851 --- /dev/null +++ b/packages/core/tests/svelte/common/ensure-script/input.svelte @@ -0,0 +1,3 @@ +
+

This is a Svelte component without script block

+
diff --git a/packages/core/tests/svelte/common/ensure-script/output.svelte b/packages/core/tests/svelte/common/ensure-script/output.svelte new file mode 100644 index 000000000..c0e89bbf3 --- /dev/null +++ b/packages/core/tests/svelte/common/ensure-script/output.svelte @@ -0,0 +1,5 @@ + + +
+

This is a Svelte component without script block

+
diff --git a/packages/core/tests/svelte/common/ensure-script/run.ts b/packages/core/tests/svelte/common/ensure-script/run.ts new file mode 100644 index 000000000..27992909c --- /dev/null +++ b/packages/core/tests/svelte/common/ensure-script/run.ts @@ -0,0 +1,5 @@ +import { type SvelteAst, ensureScript } from '@sveltejs/cli-core/svelte'; + +export function run(ast: SvelteAst.Root): void { + ensureScript(ast); +} diff --git a/packages/core/tests/svelte/common/keep-script-ts/input.svelte b/packages/core/tests/svelte/common/keep-script-ts/input.svelte new file mode 100644 index 000000000..3382f954b --- /dev/null +++ b/packages/core/tests/svelte/common/keep-script-ts/input.svelte @@ -0,0 +1,3 @@ + + +A script tag with ts lang attribute diff --git a/packages/core/tests/svelte/common/keep-script-ts/output.svelte b/packages/core/tests/svelte/common/keep-script-ts/output.svelte new file mode 100644 index 000000000..3382f954b --- /dev/null +++ b/packages/core/tests/svelte/common/keep-script-ts/output.svelte @@ -0,0 +1,3 @@ + + +A script tag with ts lang attribute diff --git a/packages/core/tests/svelte/common/keep-script-ts/run.ts b/packages/core/tests/svelte/common/keep-script-ts/run.ts new file mode 100644 index 000000000..27992909c --- /dev/null +++ b/packages/core/tests/svelte/common/keep-script-ts/run.ts @@ -0,0 +1,5 @@ +import { type SvelteAst, ensureScript } from '@sveltejs/cli-core/svelte'; + +export function run(ast: SvelteAst.Root): void { + ensureScript(ast); +} diff --git a/packages/core/tests/svelte/common/slot-svelte-4/output.svelte b/packages/core/tests/svelte/common/slot-svelte-4/output.svelte new file mode 100644 index 000000000..13e0e91ed --- /dev/null +++ b/packages/core/tests/svelte/common/slot-svelte-4/output.svelte @@ -0,0 +1 @@ + diff --git a/packages/core/tests/svelte/common/slot-svelte-4/run.ts b/packages/core/tests/svelte/common/slot-svelte-4/run.ts new file mode 100644 index 000000000..11d06ce56 --- /dev/null +++ b/packages/core/tests/svelte/common/slot-svelte-4/run.ts @@ -0,0 +1,5 @@ +import { type SvelteAst, addSlot } from '@sveltejs/cli-core/svelte'; + +export function run(ast: SvelteAst.Root): void { + addSlot(ast, { svelteVersion: '4.0.0' }); +} diff --git a/packages/core/tests/svelte/common/slot-svelte-5/output.svelte b/packages/core/tests/svelte/common/slot-svelte-5/output.svelte new file mode 100644 index 000000000..f82c4d3da --- /dev/null +++ b/packages/core/tests/svelte/common/slot-svelte-5/output.svelte @@ -0,0 +1,5 @@ + + +{@render children()} diff --git a/packages/core/tests/svelte/common/slot-svelte-5/run.ts b/packages/core/tests/svelte/common/slot-svelte-5/run.ts new file mode 100644 index 000000000..5ee31421e --- /dev/null +++ b/packages/core/tests/svelte/common/slot-svelte-5/run.ts @@ -0,0 +1,5 @@ +import { type SvelteAst, addSlot } from '@sveltejs/cli-core/svelte'; + +export function run(ast: SvelteAst.Root): void { + addSlot(ast, { svelteVersion: '5.0.0' }); +} diff --git a/packages/core/tests/svelte/common/to-fragment/input.svelte b/packages/core/tests/svelte/common/to-fragment/input.svelte new file mode 100644 index 000000000..f4f500fb8 --- /dev/null +++ b/packages/core/tests/svelte/common/to-fragment/input.svelte @@ -0,0 +1,5 @@ + +
+

This is a Svelte component.

+
+ diff --git a/packages/core/tests/svelte/common/to-fragment/output.svelte b/packages/core/tests/svelte/common/to-fragment/output.svelte new file mode 100644 index 000000000..ed8025189 --- /dev/null +++ b/packages/core/tests/svelte/common/to-fragment/output.svelte @@ -0,0 +1,3 @@ + +

This is a Svelte component.

+Appended Fragment diff --git a/packages/core/tests/svelte/common/to-fragment/run.ts b/packages/core/tests/svelte/common/to-fragment/run.ts new file mode 100644 index 000000000..fad358c42 --- /dev/null +++ b/packages/core/tests/svelte/common/to-fragment/run.ts @@ -0,0 +1,5 @@ +import { type SvelteAst, toFragment } from '@sveltejs/cli-core/svelte'; + +export function run(ast: SvelteAst.Root): void { + ast.fragment.nodes.push(...toFragment('Appended Fragment')); +} diff --git a/packages/core/tests/svelte/index.ts b/packages/core/tests/svelte/index.ts new file mode 100644 index 000000000..08b5c238a --- /dev/null +++ b/packages/core/tests/svelte/index.ts @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, test } from 'vitest'; +import { parseSvelte, serializeSvelte } from '../../tooling/index.ts'; + +const baseDir = resolve(fileURLToPath(import.meta.url), '..'); +const categoryDirectories = getDirectoryNames(baseDir); + +for (const categoryDirectory of categoryDirectories) { + describe(categoryDirectory, () => { + const testNames = getDirectoryNames(join(baseDir, categoryDirectory)); + for (const testName of testNames) { + test(testName, async () => { + const testDirectoryPath = join(baseDir, categoryDirectory, testName); + + const inputFilePath = join(testDirectoryPath, 'input.svelte'); + const input = fs.existsSync(inputFilePath) ? fs.readFileSync(inputFilePath, 'utf8') : ''; + const ast = parseSvelte(input); + + // dynamic imports always need to provide the path inline for static analysis + const module = await import(`./${categoryDirectory}/${testName}/run.ts`); + module.run(ast); + + let output = serializeSvelte(ast); + if (!output.endsWith('\n')) output += '\n'; + await expect(output).toMatchFileSnapshot(`${testDirectoryPath}/output.svelte`); + }); + } + }); +} + +function getDirectoryNames(dir: string) { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); +} diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts index 585c6fcaa..476b78610 100644 --- a/packages/core/tests/utils.ts +++ b/packages/core/tests/utils.ts @@ -290,86 +290,3 @@ describe('yaml', () => { `); }); }); - -// TODO: fix https://github.com/rolldown/tsdown/issues/575 to remove the `skip` -test.skip('tsdown escapes script tags in bundled source code', async () => { - const { execSync } = await import('node:child_process'); - const fs = await import('node:fs'); - const path = await import('node:path'); - - const testDir = path.join('../..', '.test-output', `tsdown-test`); - fs.rmSync(testDir, { recursive: true, force: true }); - fs.mkdirSync(testDir, { recursive: true }); - - // Create a test file that uses dedent with script tags - const testFileLiteral = path.join(testDir, 'testLiteral.ts'); - fs.writeFileSync( - testFileLiteral, - `import dedent from 'dedent'; - -export const result = dedent\` - -\`; -` - ); - - const testFileFunction = path.join(testDir, 'testFunction.ts'); - fs.writeFileSync( - testFileFunction, - `import dedent from 'dedent'; - -export const result = dedent(\` - -\`); -` - ); - - // Create a tsdown config - const configFile = path.join(testDir, 'tsdown.config.ts'); - fs.writeFileSync( - configFile, - `import { defineConfig } from 'tsdown'; - -export default defineConfig({ - entry: ['testLiteral.ts', 'testFunction.ts'], - format: ['esm'], - outDir: 'dist', -}); -` - ); - - // Create package.json with tsdown - const pkgJson = { - name: 'test', - type: 'module', - devDependencies: { - tsdown: '^0.15.2', - dedent: '^1.6.0' - } - }; - fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify(pkgJson, null, 2)); - - // Install dependencies and build - execSync('npm install', { cwd: testDir, stdio: 'pipe' }); - execSync('npx tsdown', { cwd: testDir, stdio: 'pipe' }); - - // Read the bundled output - const bundledFileLiteral = path.join(testDir, 'dist', 'testLiteral.js'); - const bundledFileFunction = path.join(testDir, 'dist', 'testFunction.js'); - const bundledCodeLiteral = fs.readFileSync(bundledFileLiteral, 'utf-8'); - const bundledCodeFunction = fs.readFileSync(bundledFileFunction, 'utf-8'); - - // Check if the bundled code contains escaped script tags - const hasEscapedScriptTagLiteral = bundledCodeLiteral.includes('<\\/script>'); - const hasEscapedScriptTagFunction = bundledCodeFunction.includes('<\\/script>'); - - // This test demonstrates the issue: tsdown escapes in the bundled source - // Expected: Bundled code should NOT contain escaped script tags - // Actual: Bundled code contains <\/script> when using dedent`...` syntax - expect(hasEscapedScriptTagLiteral).toBe(false); - expect(hasEscapedScriptTagFunction).toBe(false); -}, 30000); // 30s timeout for npm install and build diff --git a/packages/core/tooling/html/index.ts b/packages/core/tooling/html/index.ts index 60b2ae078..b79c5074c 100644 --- a/packages/core/tooling/html/index.ts +++ b/packages/core/tooling/html/index.ts @@ -1,12 +1,10 @@ import { - type AstTypes, type HtmlChildNode, type HtmlDocument, HtmlElement, HtmlElementType, parseHtml } from '../index.ts'; -import { appendFromString } from '../js/common.ts'; export { HtmlElement, HtmlElementType }; export type { HtmlDocument }; @@ -38,23 +36,3 @@ export function addFromRawHtml(childNodes: HtmlChildNode[], html: string): void childNodes.push(childNode); } } - -export function addSlot( - jsAst: AstTypes.Program, - options: { htmlAst: HtmlDocument; svelteVersion: string } -): void { - const slotSyntax = - options.svelteVersion && - (options.svelteVersion.startsWith('4') || options.svelteVersion.startsWith('3')); - - if (slotSyntax) { - const slot = createElement('slot'); - appendElement(options.htmlAst.childNodes, slot); - return; - } - - appendFromString(jsAst, { - code: 'let { children } = $props();' - }); - addFromRawHtml(options.htmlAst.childNodes, '{@render children()}'); -} diff --git a/packages/core/tooling/index.ts b/packages/core/tooling/index.ts index c9fcc7074..9fb28cfa3 100644 --- a/packages/core/tooling/index.ts +++ b/packages/core/tooling/index.ts @@ -17,6 +17,7 @@ import { print as esrapPrint } from 'esrap'; import ts from 'esrap/languages/ts'; import * as acorn from 'acorn'; import { tsPlugin } from '@sveltejs/acorn-typescript'; +import { parse as svelteParse, type AST as SvelteAst, print as sveltePrint } from 'svelte/compiler'; import * as yaml from 'yaml'; import type { BaseNode } from 'estree'; @@ -40,6 +41,7 @@ export { export type { // html ChildNode as HtmlChildNode, + SvelteAst, // js TsEstree as AstTypes, @@ -291,3 +293,11 @@ interface CommentsInternal { function transformToInternal(comments: Comments | undefined): CommentsInternal { return (comments ?? new Comments()) as unknown as CommentsInternal; } + +export function parseSvelte(content: string): SvelteAst.Root { + return svelteParse(content, { modern: true }); +} + +export function serializeSvelte(ast: SvelteAst.Root): string { + return sveltePrint(ast).code; +} diff --git a/packages/core/tooling/parsers.ts b/packages/core/tooling/parsers.ts index 94b913fed..24fd027a3 100644 --- a/packages/core/tooling/parsers.ts +++ b/packages/core/tooling/parsers.ts @@ -1,5 +1,4 @@ import * as utils from './index.ts'; -import MagicString from 'magic-string'; type ParseBase = { source: string; @@ -48,136 +47,13 @@ export function parseYaml( return { data, source, generateCode }; } -type SvelteGenerator = (code: { - script?: string; - module?: string; - css?: string; - template?: string; -}) => string; -export function parseSvelte( - source: string, - options?: { typescript?: boolean } -): { - script: ReturnType; - module: ReturnType; - css: ReturnType; - template: ReturnType; - generateCode: SvelteGenerator; -} { - // `xTag` captures the whole tag block (ex: ) - // `xSource` is the contents within the tags - const scripts = extractScripts(source); - // instance block - const { tag: scriptTag = '', src: scriptSource = '' } = - scripts.find(({ attrs }) => !attrs.includes('module')) ?? {}; - // module block - const { tag: moduleScriptTag = '', src: moduleSource = '' } = - scripts.find(({ attrs }) => attrs.includes('module')) ?? {}; - // style block - const { styleTag, cssSource } = extractStyle(source); - // rest of the template - // TODO: needs more testing - const templateSource = source - .replace(moduleScriptTag, '') - .replace(scriptTag, '') - .replace(styleTag, '') - .trim(); - - const script = parseScript(scriptSource); - const module = parseScript(moduleSource); - const css = parseCss(cssSource); - const template = parseHtml(templateSource); - - const generateCode: SvelteGenerator = (code) => { - const ms = new MagicString(source); - // TODO: this is imperfect and needs adjustments - if (code.script !== undefined) { - if (scriptSource.length === 0) { - const ts = options?.typescript ? ' lang="ts"' : ''; - const indented = code.script.split('\n').join('\n\t'); - const script = `\n\t${indented}\n\n\n`; - ms.prepend(script); - } else { - const { start, end } = locations(source, scriptSource); - const formatted = indent(code.script, ms.getIndentString()); - ms.update(start, end, formatted); - } - } - if (code.module !== undefined) { - if (moduleSource.length === 0) { - const ts = options?.typescript ? ' lang="ts"' : ''; - const indented = code.module.split('\n').join('\n\t'); - // TODO: make a svelte 5 variant - const module = `\n\t${indented}\n\n\n`; - ms.prepend(module); - } else { - const { start, end } = locations(source, moduleSource); - const formatted = indent(code.module, ms.getIndentString()); - ms.update(start, end, formatted); - } - } - if (code.css !== undefined) { - if (cssSource.length === 0) { - const indented = code.css.split('\n').join('\n\t'); - const style = `\n\n`; - ms.append(style); - } else { - const { start, end } = locations(source, cssSource); - const formatted = indent(code.css, ms.getIndentString()); - ms.update(start, end, formatted); - } - } - if (code.template !== undefined) { - if (templateSource.length === 0) { - ms.appendLeft(0, code.template); - } else { - const { start, end } = locations(source, templateSource); - ms.update(start, end, code.template); - } - } - return ms.toString(); - }; +export function parseSvelte(source: string): { ast: utils.SvelteAst.Root } & ParseBase { + const ast = utils.parseSvelte(source); + const generateCode = () => utils.serializeSvelte(ast); return { - script: { ...script, source: scriptSource }, - module: { ...module, source: moduleSource }, - css: { ...css, source: cssSource }, - template: { ...template, source: templateSource }, + ast, + source, generateCode }; } - -function locations(source: string, search: string): { start: number; end: number } { - const start = source.indexOf(search); - const end = start + search.length; - return { start, end }; -} - -function indent(content: string, indent: string): string { - const indented = indent + content.split('\n').join(`\n${indent}`); - return `\n${indented}\n`; -} - -// sourced from Svelte: https://github.com/sveltejs/svelte/blob/0d3d5a2a85c0f9eccb2c8dbbecc0532ec918b157/packages/svelte/src/compiler/preprocess/index.js#L253-L256 -const regexScriptTags = - /|'"/\s]+=(?:"[^"]*"|'[^']*'|[^>\s]+)|\s+[^=>'"/\s]+)*\s*)(?:\/>|>([\S\s]*?)<\/script>)/; -const regexStyleTags = - /|'"/\s]+=(?:"[^"]*"|'[^']*'|[^>\s]+)|\s+[^=>'"/\s]+)*\s*)(?:\/>|>([\S\s]*?)<\/style>)/; - -type Script = { tag: string; attrs: string; src: string }; -function extractScripts(source: string): Script[] { - const scripts = []; - const [tag = '', attrs = '', src = ''] = regexScriptTags.exec(source) ?? []; - if (tag) { - const stripped = source.replace(tag, ''); - scripts.push({ tag, attrs, src }, ...extractScripts(stripped)); - return scripts; - } - - return []; -} - -function extractStyle(source: string) { - const [styleTag = '', attributes = '', cssSource = ''] = regexStyleTags.exec(source) ?? []; - return { styleTag, attributes, cssSource }; -} diff --git a/packages/core/tooling/svelte/index.ts b/packages/core/tooling/svelte/index.ts new file mode 100644 index 000000000..ea5dcd33a --- /dev/null +++ b/packages/core/tooling/svelte/index.ts @@ -0,0 +1,87 @@ +import { parseScript, type AstTypes, type SvelteAst } from '../index.ts'; +import { parseSvelte } from '../parsers.ts'; +import { appendFromString } from '../js/common.ts'; + +export type { SvelteAst }; + +export function ensureScript( + ast: SvelteAst.Root, + options?: { langTs?: boolean } +): AstTypes.Program { + let scriptAst = ast.instance?.content; + if (!scriptAst) { + scriptAst = parseScript('').ast; + ast.instance = { + type: 'Script', + start: 0, + end: 0, + context: 'default', + attributes: options?.langTs + ? [ + { + type: 'Attribute', + start: 8, + end: 17, + name: 'lang', + value: [{ start: 14, end: 16, type: 'Text', raw: 'ts', data: 'ts' }] + } + ] + : [], + content: scriptAst + }; + } + + return scriptAst; +} + +export function addSlot( + ast: SvelteAst.Root, + options: { svelteVersion: string; langTs?: boolean } +): void { + const slotSyntax = + options.svelteVersion && + (options.svelteVersion.startsWith('4') || options.svelteVersion.startsWith('3')); + + if (slotSyntax) { + ast.fragment.nodes.push({ + type: 'SlotElement', + attributes: [], + fragment: { + type: 'Fragment', + nodes: [] + }, + name: 'slot', + start: 0, + end: 0 + }); + + return; + } + + const scriptAst = ensureScript(ast, { langTs: options.langTs }); + appendFromString(scriptAst, { + code: 'const { children } = $props();' + }); + + ast.fragment.nodes.push({ + type: 'RenderTag', + expression: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'children', + start: 0, + end: 0 + }, + optional: false, + arguments: [] + }, + start: 0, + end: 0 + }); +} + +export function toFragment(content: string): SvelteAst.Fragment['nodes'] { + const { ast } = parseSvelte(content); + return ast.fragment.nodes; +} diff --git a/packages/create/package.json b/packages/create/package.json index 6568e2d8e..99908763c 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -29,7 +29,8 @@ "@types/gitignore-parser": "^0.0.3", "gitignore-parser": "^0.0.2", "sucrase": "^3.35.0", - "tiny-glob": "^0.2.9" + "tiny-glob": "^0.2.9", + "zimmerframe": "^1.1.4" }, "keywords": [ "create", diff --git a/packages/create/playground.ts b/packages/create/playground.ts index 0741e30d0..ff1fbf57d 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -1,9 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; import * as js from '@sveltejs/cli-core/js'; +import * as svelte from '@sveltejs/cli-core/svelte'; import { parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; import { isVersionUnsupportedBelow } from '@sveltejs/cli-core'; import { getSharedFiles } from './utils.ts'; +import { walk } from 'zimmerframe'; export function validatePlaygroundUrl(link: string): boolean { try { @@ -100,7 +102,8 @@ export function detectPlaygroundDependencies(files: PlaygroundData['files']): Ma for (const file of files) { let ast: js.AstTypes.Program | undefined; if (file.name.endsWith('.svelte')) { - ast = parseSvelte(file.content).script.ast; + const { ast: svelteAst } = parseSvelte(file.content); + ast = svelte.ensureScript(svelteAst); } else if (file.name.endsWith('.js') || file.name.endsWith('.ts')) { ast = parseScript(file.content).ast; } @@ -158,8 +161,7 @@ export function setupPlaygroundProject( url: string, playground: PlaygroundData, cwd: string, - installDependencies: boolean, - typescript: boolean + installDependencies: boolean ): void { const mainFile = playground.files.find((file) => file.name === 'App.svelte'); if (!mainFile) throw new Error('Failed to find `App.svelte` entrypoint.'); @@ -188,19 +190,22 @@ export function setupPlaygroundProject( if (file.name === 'src/lib/PlaygroundLayout.svelte') { // getting raw content - const { script, template, css } = parseSvelte(file.contents); - // generating new content with the right language style - const { generateCode } = parseSvelte('', { typescript }); - contentToWrite = generateCode({ - script: script - .generateCode() - .replaceAll('$sv-title-$sv', playground.name) - .replaceAll('$sv-url-$sv', url), - template: template - .generateCode() - .replaceAll('onclick="{switchTheme}"', 'onclick={switchTheme}'), - css: css.generateCode() + const { ast, generateCode } = parseSvelte(file.contents); + // change title and url placeholders + const scriptAst = svelte.ensureScript(ast); + walk(scriptAst as js.AstTypes.Node, null, { + Literal(node) { + if (node.value === '$sv-title-$sv') { + node.value = playground.name; + node.raw = undefined; + } else if (node.value === '$sv-url-$sv') { + node.value = url; + node.raw = undefined; + } + } }); + + contentToWrite = generateCode(); } fs.writeFileSync(path.join(cwd, file.name), contentToWrite, 'utf-8'); @@ -210,18 +215,19 @@ export function setupPlaygroundProject( // add app import to +page.svelte const filePath = path.join(cwd, 'src/routes/+page.svelte'); const content = fs.readFileSync(filePath, 'utf-8'); - const { script, generateCode } = parseSvelte(content, { typescript }); - js.imports.addDefault(script.ast, { as: 'App', from: `$lib/playground/${mainFile.name}` }); - js.imports.addDefault(script.ast, { + const { ast, generateCode } = parseSvelte(content); + const scriptAst = svelte.ensureScript(ast); + js.imports.addDefault(scriptAst, { as: 'App', from: `$lib/playground/${mainFile.name}` }); + js.imports.addDefault(scriptAst, { as: 'PlaygroundLayout', from: `$lib/PlaygroundLayout.svelte` }); - const newContent = generateCode({ - script: script.generateCode(), - template: ` + ast.fragment.nodes.push( + ...svelte.toFragment(` -` - }); +`) + ); + const newContent = generateCode(); fs.writeFileSync(filePath, newContent, 'utf-8'); // add packages as dependencies to package.json if requested diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts index cb9277495..f06ad2644 100644 --- a/packages/create/test/playground.ts +++ b/packages/create/test/playground.ts @@ -169,7 +169,6 @@ test('real world download and convert playground async', async () => { 'https://svelte.dev/playground/770bbef086034b9f8e337bab57efe8d8', playground, directory, - true, true ); @@ -221,7 +220,6 @@ test('real world download and convert playground without async', async () => { 'https://svelte.dev/playground/770bbef086034b9f8e337bab57efe8d8', playground, directory, - true, true ); diff --git a/packages/migrate/utils.js b/packages/migrate/utils.js index a2826bab1..bff1eda97 100644 --- a/packages/migrate/utils.js +++ b/packages/migrate/utils.js @@ -314,8 +314,7 @@ export function update_svelte_file(file_path, transform_script_code, transform_s ); fs.writeFileSync(file_path, transform_svelte_code(updated, file_path), 'utf-8'); } catch (err) { - // TODO: change to import('svelte/compiler').Warning after upgrading to Svelte 5 - const e = /** @type {any} */ (err); + const e = /** @type {import('svelte/compiler').Warning} */ (err); console.warn(buildExtendedLogMessage(e), e.frame); console.info(e.stack); } @@ -332,8 +331,7 @@ export function update_js_file(file_path, transform_code) { const updated = transform_code(content, file_path.endsWith('.ts'), file_path); fs.writeFileSync(file_path, updated, 'utf-8'); } catch (err) { - // TODO: change to import('svelte/compiler').Warning after upgrading to Svelte 5 - const e = /** @type {any} */ (err); + const e = /** @type {import('svelte/compiler').Warning} */ (err); console.warn(buildExtendedLogMessage(e), e.frame); console.info(e.stack); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaee94169..20f7bb971 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: silver-fleece: specifier: ^1.2.1 version: 1.2.1 + svelte: + specifier: ^5.45.0 + version: 5.45.2 yaml: specifier: ^2.8.1 version: 2.8.1 @@ -211,6 +214,9 @@ importers: tiny-glob: specifier: ^0.2.9 version: 0.2.9 + zimmerframe: + specifier: ^1.1.4 + version: 1.1.4 packages/migrate: dependencies: @@ -1194,6 +1200,9 @@ packages: resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + devalue@5.5.0: + resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==} + diff@8.0.2: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} @@ -1347,6 +1356,9 @@ packages: esrap@2.1.3: resolution: {integrity: sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==} + esrap@2.2.0: + resolution: {integrity: sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg==} + esrap@2.2.1: resolution: {integrity: sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==} @@ -2055,6 +2067,10 @@ packages: resolution: {integrity: sha512-d53/xClCjHsuFXuHsn7+F/0NKkkwgRv8kLg2his5YBYqVtfIrBqkvWd+5ZjYN6ryk/jv/rJF00vexXHkK8ofXA==} engines: {node: '>=18'} + svelte@5.45.2: + resolution: {integrity: sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==} + engines: {node: '>=18'} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3206,6 +3222,8 @@ snapshots: detect-newline@4.0.1: {} + devalue@5.5.0: {} + diff@8.0.2: {} dir-glob@3.0.1: @@ -3405,6 +3423,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + esrap@2.2.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + esrap@2.2.1: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4070,6 +4092,24 @@ snapshots: magic-string: 0.30.21 zimmerframe: 1.1.4 + svelte@5.45.2: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0) + '@types/estree': 1.0.8 + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.5.0 + esm-env: 1.2.2 + esrap: 2.2.0 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9