Skip to content

Commit 212e084

Browse files
committed
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.
1 parent f71f381 commit 212e084

File tree

16 files changed

+271
-10
lines changed

16 files changed

+271
-10
lines changed

.changeset/sixty-walls-act.md

+5
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

+1-1
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

+1-1
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

+1-1
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

+1-1
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

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>
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

+1-1
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

+1-1
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>
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>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
7+
export async function migrate() {
8+
console.log(
9+
colors.bold().yellow('\nThis will update .svelte files inside the current directory\n')
10+
);
11+
12+
const response = await prompts({
13+
type: 'confirm',
14+
name: 'value',
15+
message: 'Continue?',
16+
initial: false
17+
});
18+
19+
if (!response.value) {
20+
process.exit(1);
21+
}
22+
23+
const files = glob('**/*.svelte')
24+
.map((file) => file.replace(/\\/g, '/'))
25+
.filter((file) => !file.includes('/node_modules/'));
26+
27+
for (const file of files) {
28+
try {
29+
const code = await remove_self_closing_tags(fs.readFileSync(file, 'utf-8'));
30+
fs.writeFileSync(file, code);
31+
} catch (e) {
32+
// continue
33+
}
34+
}
35+
36+
console.log(colors.bold().green('✔ Your project has been updated'));
37+
console.log(' If using Prettier, please upgrade to the latest prettier-plugin-svelte version');
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import MagicString from 'magic-string';
2+
import { parse, preprocess, walk } from 'svelte/compiler';
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+
/** @param {string} source */
113+
export async function remove_self_closing_tags(source) {
114+
const preprocessed = await preprocess(source, {
115+
script: ({ content }) => ({
116+
code: content
117+
.split('\n')
118+
.map((line) => ' '.repeat(line.length))
119+
.join('\n')
120+
}),
121+
style: ({ content }) => ({
122+
code: content
123+
.split('\n')
124+
.map((line) => ' '.repeat(line.length))
125+
.join('\n')
126+
})
127+
});
128+
const ast = parse(preprocessed.code);
129+
const ms = new MagicString(source);
130+
/** @type {Array<() => void>} */
131+
const updates = [];
132+
let is_foreign = false;
133+
let is_custom_element = false;
134+
135+
walk(/** @type {any} */ (ast.html), {
136+
/** @param {Record<string, any>} node */
137+
enter(node) {
138+
if (node.type === 'Options') {
139+
const namespace = node.attributes.find(
140+
/** @param {any} a */
141+
(a) => a.type === 'Attribute' && a.name === 'namespace'
142+
);
143+
if (namespace?.value[0].data === 'foreign') {
144+
is_foreign = true;
145+
return;
146+
}
147+
148+
is_custom_element = node.attributes.some(
149+
/** @param {any} a */
150+
(a) => a.type === 'Attribute' && (a.name === 'customElement' || a.name === 'tag')
151+
);
152+
}
153+
154+
if (node.type === 'Element' || node.type === 'Slot') {
155+
const is_self_closing = source[node.end - 2] === '/';
156+
if (
157+
!is_self_closing ||
158+
VoidElements.includes(node.name) ||
159+
SVGElements.includes(node.name) ||
160+
!/^[a-zA-Z0-9_-]+$/.test(node.name)
161+
) {
162+
return;
163+
}
164+
165+
let start = node.end - 2;
166+
if (source[start - 1] === ' ') {
167+
start--;
168+
}
169+
updates.push(() => {
170+
if (node.type === 'Element' || is_custom_element) {
171+
ms.update(start, node.end, `></${node.name}>`);
172+
}
173+
});
174+
}
175+
}
176+
});
177+
178+
if (is_foreign) {
179+
return source;
180+
}
181+
182+
updates.forEach((update) => update());
183+
return ms.toString();
184+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { assert, test } from 'vitest';
2+
import { remove_self_closing_tags } from './migrate.js';
3+
4+
/** @type {Record<string, string>} */
5+
const tests = {
6+
'<div/>': '<div></div>',
7+
'<div />': '<div></div>',
8+
'<custom-element />': '<custom-element></custom-element>',
9+
'<div class="foo"/>': '<div class="foo"></div>',
10+
'<div class="foo" />': '<div class="foo"></div>',
11+
'\t<div\n\t\tonclick={blah}\n\t/>': '\t<div\n\t\tonclick={blah}\n\t></div>',
12+
'<foo-bar/>': '<foo-bar></foo-bar>',
13+
'<link/>': '<link/>',
14+
'<link />': '<link />',
15+
'<svg><g /></svg>': '<svg><g /></svg>',
16+
'<slot />': '<slot />',
17+
'<svelte:options customElement="my-element" /><slot />':
18+
'<svelte:options customElement="my-element" /><slot></slot>',
19+
'<svelte:options namespace="foreign" /><foo />': '<svelte:options namespace="foreign" /><foo />',
20+
'<script>console.log("<div />")</script>': '<script>console.log("<div />")</script>',
21+
'<script lang="ts">let a: string = ""</script><div />':
22+
'<script lang="ts">let a: string = ""</script><div></div>'
23+
};
24+
25+
for (const input in tests) {
26+
test(input, async () => {
27+
const output = tests[input];
28+
assert.equal(await remove_self_closing_tags(input), output);
29+
});
30+
}

packages/migrate/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"magic-string": "^0.30.5",
2929
"prompts": "^2.4.2",
3030
"semver": "^7.5.4",
31+
"svelte": "^4.0.0",
3132
"tiny-glob": "^0.2.9",
3233
"ts-morph": "^22.0.0",
3334
"typescript": "^5.3.3"

pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sites/kit.svelte.dev/src/routes/home/Video.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
</video>
121121

122122
{#if d}
123-
<div class="progress-bar" style={`width: ${(t / d) * 100}%`} />
123+
<div class="progress-bar" style={`width: ${(t / d) * 100}%`}></div>
124124
{/if}
125125

126126
<div class="top-controls">

0 commit comments

Comments
 (0)