Skip to content

Commit bd54aa1

Browse files
authored
feat: add self-closing-tags migration (#12128)
* feat: add self-closing-tags migration Companion to sveltejs/svelte#11114. This adds an npx svelte-migrate self-closing-tags migration that replaces all the self-closing non-void elements in your .svelte files. * use local Svelte installation
1 parent f71f381 commit bd54aa1

File tree

16 files changed

+310
-11
lines changed

16 files changed

+310
-11
lines changed

.changeset/sixty-walls-act.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte-migrate": minor
3+
---
4+
5+
feat: add self-closing-tags migration

packages/create-svelte/templates/default/src/routes/sverdle/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@
200200
stageHeight: window.innerHeight,
201201
colors: ['#ff3e00', '#40b3ff', '#676778']
202202
}}
203-
/>
203+
></div>
204204
{/if}
205205

206206
<style>

packages/kit/test/apps/basics/src/routes/anchor-with-manual-scroll/anchor-afternavigate/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
<p id="go-to-element">The browser scrolls to me</p>
1313
</div>
1414
<p id="abcde" style="height: 180vh; background-color: hotpink;">I take precedence</p>
15-
<div />
15+
<div></div>
1616
1717
<a href="/anchor-with-manual-scroll/anchor-afternavigate?x=y#go-to-element">reload me</a>

packages/kit/test/apps/basics/src/routes/anchor-with-manual-scroll/anchor-onmount/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
<p id="go-to-element">The browser scrolls to me</p>
1414
</div>
1515
<p id="abcde" style="height: 180vh; background-color: hotpink;">I take precedence</p>
16-
<div />
16+
<div></div>

packages/kit/test/apps/basics/src/routes/data-sveltekit/noscroll/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div style="height: 2000px; background: palegoldenrod" />
1+
<div style="height: 2000px; background: palegoldenrod"></div>
22

33
<a id="one" href="/data-sveltekit/noscroll/target" data-sveltekit-noscroll>one</a>
44

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
<div style="height: 2000px; background: palegoldenrod" />
1+
<div style="height: 2000px; background: palegoldenrod"></div>
22

33
<h1>target</h1>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<iframe title="Child content" src="./child" />
1+
<iframe title="Child content" src="./child"></iframe>

packages/kit/test/apps/basics/src/routes/no-ssr/margin/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div class="container">
22
<span> ^this is not the top of the screen</span>
3-
<div class="spacer" />
3+
<div class="spacer"></div>
44
</div>
55

66
<style>

packages/kit/test/apps/basics/src/routes/routing/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212

1313
<a href="/routing/b" data-sveltekit-reload>b</a>
1414

15-
<div class="hydrate-test" />
15+
<div class="hydrate-test"></div>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<h1>a</h1>
22

3-
<div style="height: 200vh; background: teal" />
3+
<div style="height: 200vh; background: teal"></div>
44

55
<a data-sveltekit-reload href="/scroll/cross-document/b">b</a>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import colors from 'kleur';
2+
import fs from 'node:fs';
3+
import prompts from 'prompts';
4+
import glob from 'tiny-glob/sync.js';
5+
import { remove_self_closing_tags } from './migrate.js';
6+
import { pathToFileURL } from 'node:url';
7+
import { resolve } from 'import-meta-resolve';
8+
9+
export async function migrate() {
10+
let compiler;
11+
try {
12+
compiler = await import_from_cwd('svelte/compiler');
13+
} catch (e) {
14+
console.log(colors.bold().red('❌ Could not find a local Svelte installation.'));
15+
return;
16+
}
17+
18+
console.log(
19+
colors.bold().yellow('\nThis will update .svelte files inside the current directory\n')
20+
);
21+
22+
const response = await prompts({
23+
type: 'confirm',
24+
name: 'value',
25+
message: 'Continue?',
26+
initial: false
27+
});
28+
29+
if (!response.value) {
30+
process.exit(1);
31+
}
32+
33+
const files = glob('**/*.svelte')
34+
.map((file) => file.replace(/\\/g, '/'))
35+
.filter((file) => !file.includes('/node_modules/'));
36+
37+
for (const file of files) {
38+
try {
39+
const code = await remove_self_closing_tags(compiler, fs.readFileSync(file, 'utf-8'));
40+
fs.writeFileSync(file, code);
41+
} catch (e) {
42+
// continue
43+
}
44+
}
45+
46+
console.log(colors.bold().green('✔ Your project has been updated'));
47+
console.log(' If using Prettier, please upgrade to the latest prettier-plugin-svelte version');
48+
}
49+
50+
/** @param {string} name */
51+
async function import_from_cwd(name) {
52+
const cwd = pathToFileURL(process.cwd()).href;
53+
const url = await resolve(name, cwd + '/x.js');
54+
55+
return import(url);
56+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import MagicString from 'magic-string';
2+
import { walk } from 'zimmerframe';
3+
4+
const VoidElements = [
5+
'area',
6+
'base',
7+
'br',
8+
'col',
9+
'embed',
10+
'hr',
11+
'img',
12+
'input',
13+
'keygen',
14+
'link',
15+
'menuitem',
16+
'meta',
17+
'param',
18+
'source',
19+
'track',
20+
'wbr'
21+
];
22+
23+
const SVGElements = [
24+
'altGlyph',
25+
'altGlyphDef',
26+
'altGlyphItem',
27+
'animate',
28+
'animateColor',
29+
'animateMotion',
30+
'animateTransform',
31+
'circle',
32+
'clipPath',
33+
'color-profile',
34+
'cursor',
35+
'defs',
36+
'desc',
37+
'discard',
38+
'ellipse',
39+
'feBlend',
40+
'feColorMatrix',
41+
'feComponentTransfer',
42+
'feComposite',
43+
'feConvolveMatrix',
44+
'feDiffuseLighting',
45+
'feDisplacementMap',
46+
'feDistantLight',
47+
'feDropShadow',
48+
'feFlood',
49+
'feFuncA',
50+
'feFuncB',
51+
'feFuncG',
52+
'feFuncR',
53+
'feGaussianBlur',
54+
'feImage',
55+
'feMerge',
56+
'feMergeNode',
57+
'feMorphology',
58+
'feOffset',
59+
'fePointLight',
60+
'feSpecularLighting',
61+
'feSpotLight',
62+
'feTile',
63+
'feTurbulence',
64+
'filter',
65+
'font',
66+
'font-face',
67+
'font-face-format',
68+
'font-face-name',
69+
'font-face-src',
70+
'font-face-uri',
71+
'foreignObject',
72+
'g',
73+
'glyph',
74+
'glyphRef',
75+
'hatch',
76+
'hatchpath',
77+
'hkern',
78+
'image',
79+
'line',
80+
'linearGradient',
81+
'marker',
82+
'mask',
83+
'mesh',
84+
'meshgradient',
85+
'meshpatch',
86+
'meshrow',
87+
'metadata',
88+
'missing-glyph',
89+
'mpath',
90+
'path',
91+
'pattern',
92+
'polygon',
93+
'polyline',
94+
'radialGradient',
95+
'rect',
96+
'set',
97+
'solidcolor',
98+
'stop',
99+
'svg',
100+
'switch',
101+
'symbol',
102+
'text',
103+
'textPath',
104+
'tref',
105+
'tspan',
106+
'unknown',
107+
'use',
108+
'view',
109+
'vkern'
110+
];
111+
112+
/**
113+
* @param {{ preprocess: any, parse: any }} svelte_compiler
114+
* @param {string} source
115+
*/
116+
export async function remove_self_closing_tags({ preprocess, parse }, source) {
117+
const preprocessed = await preprocess(source, {
118+
/** @param {{ content: string }} input */
119+
script: ({ content }) => ({
120+
code: content
121+
.split('\n')
122+
.map((line) => ' '.repeat(line.length))
123+
.join('\n')
124+
}),
125+
/** @param {{ content: string }} input */
126+
style: ({ content }) => ({
127+
code: content
128+
.split('\n')
129+
.map((line) => ' '.repeat(line.length))
130+
.join('\n')
131+
})
132+
});
133+
const ast = parse(preprocessed.code);
134+
const ms = new MagicString(source);
135+
/** @type {Array<() => void>} */
136+
const updates = [];
137+
let is_foreign = false;
138+
let is_custom_element = false;
139+
140+
walk(ast.html, null, {
141+
_(node, { next, stop }) {
142+
if (node.type === 'Options') {
143+
const namespace = node.attributes.find(
144+
/** @param {any} a */
145+
(a) => a.type === 'Attribute' && a.name === 'namespace'
146+
);
147+
if (namespace?.value[0].data === 'foreign') {
148+
is_foreign = true;
149+
stop();
150+
return;
151+
}
152+
153+
is_custom_element = node.attributes.some(
154+
/** @param {any} a */
155+
(a) => a.type === 'Attribute' && (a.name === 'customElement' || a.name === 'tag')
156+
);
157+
}
158+
159+
if (node.type === 'Element' || node.type === 'Slot') {
160+
const is_self_closing = source[node.end - 2] === '/';
161+
if (
162+
!is_self_closing ||
163+
VoidElements.includes(node.name) ||
164+
SVGElements.includes(node.name) ||
165+
!/^[a-z0-9_-]+$/.test(node.name)
166+
) {
167+
return;
168+
}
169+
170+
let start = node.end - 2;
171+
if (source[start - 1] === ' ') {
172+
start--;
173+
}
174+
updates.push(() => {
175+
if (node.type === 'Element' || is_custom_element) {
176+
ms.update(start, node.end, `></${node.name}>`);
177+
}
178+
});
179+
}
180+
181+
next();
182+
}
183+
});
184+
185+
if (is_foreign) {
186+
return source;
187+
}
188+
189+
updates.forEach((update) => update());
190+
return ms.toString();
191+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { assert, test } from 'vitest';
2+
import * as compiler from 'svelte/compiler';
3+
import { remove_self_closing_tags } from './migrate.js';
4+
5+
/** @type {Record<string, string>} */
6+
const tests = {
7+
'<div/>': '<div></div>',
8+
'<div />': '<div></div>',
9+
'<custom-element />': '<custom-element></custom-element>',
10+
'<div class="foo"/>': '<div class="foo"></div>',
11+
'<div class="foo" />': '<div class="foo"></div>',
12+
'\t<div\n\t\tonclick={blah}\n\t/>': '\t<div\n\t\tonclick={blah}\n\t></div>',
13+
'<foo-bar/>': '<foo-bar></foo-bar>',
14+
'<link/>': '<link/>',
15+
'<link />': '<link />',
16+
'<svg><g /></svg>': '<svg><g /></svg>',
17+
'<slot />': '<slot />',
18+
'<svelte:options customElement="my-element" /><slot />':
19+
'<svelte:options customElement="my-element" /><slot></slot>',
20+
'<svelte:options namespace="foreign" /><foo />': '<svelte:options namespace="foreign" /><foo />',
21+
'<script>console.log("<div />")</script>': '<script>console.log("<div />")</script>',
22+
'<script lang="ts">let a: string = ""</script><div />':
23+
'<script lang="ts">let a: string = ""</script><div></div>'
24+
};
25+
26+
for (const input in tests) {
27+
test(input, async () => {
28+
const output = tests[input];
29+
assert.equal(await remove_self_closing_tags(compiler, input), output);
30+
});
31+
}

packages/migrate/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,22 @@
2424
"!migrations/**/samples.md"
2525
],
2626
"dependencies": {
27+
"import-meta-resolve": "^4.0.0",
2728
"kleur": "^4.1.5",
2829
"magic-string": "^0.30.5",
2930
"prompts": "^2.4.2",
3031
"semver": "^7.5.4",
3132
"tiny-glob": "^0.2.9",
3233
"ts-morph": "^22.0.0",
33-
"typescript": "^5.3.3"
34+
"typescript": "^5.3.3",
35+
"zimmerframe": "^1.1.2"
3436
},
3537
"devDependencies": {
3638
"@types/node": "^18.19.3",
3739
"@types/prompts": "^2.4.9",
3840
"@types/semver": "^7.5.6",
3941
"prettier": "^3.1.1",
42+
"svelte": "^4.2.10",
4043
"vitest": "^1.5.0"
4144
},
4245
"scripts": {

0 commit comments

Comments
 (0)