Skip to content

Commit f223bc1

Browse files
authored
feat: change preprocessor ordering, allow attributes modification (#8618)
- change mapping order - add support to modify attributes of script/style tags - add source mapping tests to preprocessor tests
1 parent 7cec17c commit f223bc1

File tree

19 files changed

+297
-75
lines changed

19 files changed

+297
-75
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* **breaking** Deprecate `SvelteComponentTyped`, use `SvelteComponent` instead ([#8512](https://github.com/sveltejs/svelte/pull/8512))
1515
* **breaking** Error on falsy values instead of stores passed to `derived` ([#7947](https://github.com/sveltejs/svelte/pull/7947))
1616
* **breaking** Custom store implementers now need to pass an `update` function additionally to the `set` function ([#6750](https://github.com/sveltejs/svelte/pull/6750))
17+
* **breaking** Change order in which preprocessors are applied ([#8618](https://github.com/sveltejs/svelte/pull/8618))
18+
* Add a way to modify attributes for script/style preprocessors ([#8618](https://github.com/sveltejs/svelte/pull/8618))
1719
* Improve hydration speed by adding `data-svelte-h` attribute to detect unchanged HTML elements ([#7426](https://github.com/sveltejs/svelte/pull/7426))
1820
* Add `a11y no-noninteractive-element-interactions` rule ([#8391](https://github.com/sveltejs/svelte/pull/8391))
1921
* Add `a11y-no-static-element-interactions`rule ([#8251](https://github.com/sveltejs/svelte/pull/8251))

site/content/docs/05-compile-time.md

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ const ast = svelte.parse(source, { filename: 'App.svelte' });
186186

187187
### `svelte.preprocess`
188188

189-
A number of [community-maintained preprocessing plugins](https://sveltesociety.dev/tools#preprocessors) are available to allow you to use Svelte with tools like TypeScript, PostCSS, SCSS, and Less.
189+
A number of [official and community-maintained preprocessing plugins](https://sveltesociety.dev/tools#preprocessors) are available to allow you to use Svelte with tools like TypeScript, PostCSS, SCSS, and Less.
190190

191191
You can write your own preprocessor using the `svelte.preprocess` API.
192192

@@ -197,6 +197,7 @@ result: {
197197
} = await svelte.preprocess(
198198
source: string,
199199
preprocessors: Array<{
200+
name: string,
200201
markup?: (input: { content: string, filename: string }) => Promise<{
201202
code: string,
202203
dependencies?: Array<string>
@@ -220,48 +221,41 @@ result: {
220221

221222
The `preprocess` function provides convenient hooks for arbitrarily transforming component source code. For example, it can be used to convert a `<style lang="sass">` block into vanilla CSS.
222223

223-
The first argument is the component source code. The second is an array of *preprocessors* (or a single preprocessor, if you only have one), where a preprocessor is an object with `markup`, `script` and `style` functions, each of which is optional.
224-
225-
Each `markup`, `script` or `style` function must return an object (or a Promise that resolves to an object) with a `code` property, representing the transformed source code, and an optional array of `dependencies`.
224+
The first argument is the component source code. The second is an array of *preprocessors* (or a single preprocessor, if you only have one), where a preprocessor is an object with a `name` which is required, and `markup`, `script` and `style` functions, each of which is optional.
226225

227226
The `markup` function receives the entire component source text, along with the component's `filename` if it was specified in the third argument.
228227

229-
> Preprocessor functions should additionally return a `map` object alongside `code` and `dependencies`, where `map` is a sourcemap representing the transformation.
228+
The `script` and `style` functions receive the contents of `<script>` and `<style>` elements respectively (`content`) as well as the entire component source text (`markup`). In addition to `filename`, they get an object of the element's attributes.
229+
230+
Each `markup`, `script` or `style` function must return an object (or a Promise that resolves to an object) with a `code` property, representing the transformed source code. Optionally they can return an array of `dependencies` which represents files to watch for changes, and a `map` object which is a sourcemap mapping back the transformation to the original code. `script` and `style` preprocessors can optionally return a record of attributes which represent the updated attributes on the script/style tag.
231+
232+
> Preprocessor functions should return a `map` object whenever possible or else debugging becomes harder as stack traces can't link to the original code correctly.
230233
231234
```js
232-
const svelte = require('svelte/compiler');
233-
const MagicString = require('magic-string');
235+
import { preprocess } from 'svelte/compiler';
236+
import MagicString from 'magic-string';
237+
import sass from 'sass';
238+
import { dirname } from 'path';
234239

235-
const { code } = await svelte.preprocess(source, {
240+
const { code } = await preprocess(source, {
241+
name: 'my-fancy-preprocessor',
236242
markup: ({ content, filename }) => {
243+
// Return code as is when no foo string present
237244
const pos = content.indexOf('foo');
238245
if(pos < 0) {
239-
return { code: content }
246+
return;
240247
}
241-
const s = new MagicString(content, { filename })
242-
s.overwrite(pos, pos + 3, 'bar', { storeName: true })
248+
249+
// Replace foo with bar using MagicString which provides
250+
// a source map along with the changed code
251+
const s = new MagicString(content, { filename });
252+
s.overwrite(pos, pos + 3, 'bar', { storeName: true });
253+
243254
return {
244255
code: s.toString(),
245-
map: s.generateMap()
256+
map: s.generateMap({ hires: true, file: filename })
246257
}
247-
}
248-
}, {
249-
filename: 'App.svelte'
250-
});
251-
```
252-
253-
---
254-
255-
The `script` and `style` functions receive the contents of `<script>` and `<style>` elements respectively (`content`) as well as the entire component source text (`markup`). In addition to `filename`, they get an object of the element's attributes.
256-
257-
If a `dependencies` array is returned, it will be included in the result object. This is used by packages like [rollup-plugin-svelte](https://github.com/sveltejs/rollup-plugin-svelte) to watch additional files for changes, in the case where your `<style>` tag has an `@import` (for example).
258-
259-
```js
260-
const svelte = require('svelte/compiler');
261-
const sass = require('node-sass');
262-
const { dirname } = require('path');
263-
264-
const { code, dependencies } = await svelte.preprocess(source, {
258+
},
265259
style: async ({ content, attributes, filename }) => {
266260
// only process <style lang="sass">
267261
if (attributes.lang !== 'sass') return;
@@ -277,9 +271,13 @@ const { code, dependencies } = await svelte.preprocess(source, {
277271
else resolve(result);
278272
}));
279273

274+
// remove lang attribute from style tag
275+
delete attributes.lang;
276+
280277
return {
281278
code: css.toString(),
282-
dependencies: stats.includedFiles
279+
dependencies: stats.includedFiles,
280+
attributes
283281
};
284282
}
285283
}, {
@@ -289,29 +287,33 @@ const { code, dependencies } = await svelte.preprocess(source, {
289287

290288
---
291289

292-
Multiple preprocessors can be used together. The output of the first becomes the input to the second. `markup` functions run first, then `script` and `style`.
290+
Multiple preprocessors can be used together. The output of the first becomes the input to the second. Within one preprocessor, `markup` runs first, then `script` and `style`.
291+
292+
> In Svelte 3, all `markup` functions ran first, then all `script` and then all `style` preprocessors. This order was changed in Svelte 4.
293293
294294
```js
295295
const svelte = require('svelte/compiler');
296296

297297
const { code } = await svelte.preprocess(source, [
298298
{
299+
name: 'first preprocessor',
299300
markup: () => {
300301
console.log('this runs first');
301302
},
302303
script: () => {
303-
console.log('this runs third');
304+
console.log('this runs second');
304305
},
305306
style: () => {
306-
console.log('this runs fifth');
307+
console.log('this runs third');
307308
}
308309
},
309310
{
311+
name: 'second preprocessor',
310312
markup: () => {
311-
console.log('this runs second');
313+
console.log('this runs fourth');
312314
},
313315
script: () => {
314-
console.log('this runs fourth');
316+
console.log('this runs fifth');
315317
},
316318
style: () => {
317319
console.log('this runs sixth');

src/compiler/preprocess/index.js

Lines changed: 108 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from '../utils/mapped_code.js';
88
import { decode_map } from './decode_sourcemap.js';
99
import { replace_in_code, slice_source } from './replace_in_code.js';
10-
import { regex_whitespaces } from '../utils/patterns.js';
1110

1211
const regex_filepath_separator = /[/\\]/;
1312

@@ -132,11 +131,18 @@ function processed_content_to_code(processed, location, file_basename) {
132131
* representing the tag content replaced with `processed`.
133132
* @param {import('./public.js').Processed} processed
134133
* @param {'style' | 'script'} tag_name
135-
* @param {string} attributes
134+
* @param {string} original_attributes
135+
* @param {string} generated_attributes
136136
* @param {import('./private.js').Source} source
137137
* @returns {MappedCode}
138138
*/
139-
function processed_tag_to_code(processed, tag_name, attributes, source) {
139+
function processed_tag_to_code(
140+
processed,
141+
tag_name,
142+
original_attributes,
143+
generated_attributes,
144+
source
145+
) {
140146
const { file_basename, get_location } = source;
141147

142148
/**
@@ -145,34 +151,105 @@ function processed_tag_to_code(processed, tag_name, attributes, source) {
145151
*/
146152
const build_mapped_code = (code, offset) =>
147153
MappedCode.from_source(slice_source(code, offset, source));
148-
const tag_open = `<${tag_name}${attributes || ''}>`;
154+
155+
// To map the open/close tag and content starts positions correctly, we need to
156+
// differentiate between the original attributes and the generated attributes:
157+
// `source` contains the original attributes and its get_location maps accordingly.
158+
const original_tag_open = `<${tag_name}${original_attributes}>`;
159+
const tag_open = `<${tag_name}${generated_attributes}>`;
160+
/** @type {MappedCode} */
161+
let tag_open_code;
162+
163+
if (original_tag_open.length !== tag_open.length) {
164+
// Generate a source map for the open tag
165+
/** @type {import('@ampproject/remapping').DecodedSourceMap['mappings']} */
166+
const mappings = [
167+
[
168+
// start of tag
169+
[0, 0, 0, 0],
170+
// end of tag start
171+
[`<${tag_name}`.length, 0, 0, `<${tag_name}`.length]
172+
]
173+
];
174+
175+
const line = tag_open.split('\n').length - 1;
176+
const column = tag_open.length - (line === 0 ? 0 : tag_open.lastIndexOf('\n')) - 1;
177+
178+
while (mappings.length <= line) {
179+
// end of tag start again, if this is a multi line mapping
180+
mappings.push([[0, 0, 0, `<${tag_name}`.length]]);
181+
}
182+
183+
// end of tag
184+
mappings[line].push([
185+
column,
186+
0,
187+
original_tag_open.split('\n').length - 1,
188+
original_tag_open.length - original_tag_open.lastIndexOf('\n') - 1
189+
]);
190+
191+
/** @type {import('@ampproject/remapping').DecodedSourceMap} */
192+
const map = {
193+
version: 3,
194+
names: [],
195+
sources: [file_basename],
196+
mappings
197+
};
198+
sourcemap_add_offset(map, get_location(0), 0);
199+
tag_open_code = MappedCode.from_processed(tag_open, map);
200+
} else {
201+
tag_open_code = build_mapped_code(tag_open, 0);
202+
}
203+
149204
const tag_close = `</${tag_name}>`;
150-
const tag_open_code = build_mapped_code(tag_open, 0);
151-
const tag_close_code = build_mapped_code(tag_close, tag_open.length + source.source.length);
205+
const tag_close_code = build_mapped_code(
206+
tag_close,
207+
original_tag_open.length + source.source.length
208+
);
209+
152210
parse_attached_sourcemap(processed, tag_name);
153211
const content_code = processed_content_to_code(
154212
processed,
155-
get_location(tag_open.length),
213+
get_location(original_tag_open.length),
156214
file_basename
157215
);
216+
158217
return tag_open_code.concat(content_code).concat(tag_close_code);
159218
}
160-
const regex_quoted_value = /^['"](.*)['"]$/;
219+
220+
const attribute_pattern = /([\w-$]+\b)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
161221

162222
/**
163223
* @param {string} str
164224
*/
165225
function parse_tag_attributes(str) {
166-
// note: won't work with attribute values containing spaces.
167-
return str
168-
.split(regex_whitespaces)
169-
.filter(Boolean)
170-
.reduce((attrs, attr) => {
171-
const i = attr.indexOf('=');
172-
const [key, value] = i > 0 ? [attr.slice(0, i), attr.slice(i + 1)] : [attr];
173-
const [, unquoted] = (value && value.match(regex_quoted_value)) || [];
174-
return { ...attrs, [key]: unquoted ?? value ?? true };
175-
}, {});
226+
/** @type {Record<string, string | boolean>} */
227+
const attrs = {};
228+
229+
/** @type {RegExpMatchArray} */
230+
let match;
231+
while ((match = attribute_pattern.exec(str)) !== null) {
232+
const name = match[1];
233+
const value = match[2] || match[3] || match[4];
234+
attrs[name] = !value || value;
235+
}
236+
237+
return attrs;
238+
}
239+
240+
/**
241+
* @param {Record<string, string | boolean> | undefined} attributes
242+
*/
243+
function stringify_tag_attributes(attributes) {
244+
if (!attributes) return;
245+
246+
let value = Object.entries(attributes)
247+
.map(([key, value]) => (value === true ? key : `${key}="${value}"`))
248+
.join(' ');
249+
if (value) {
250+
value = ' ' + value;
251+
}
252+
return value;
176253
}
177254

178255
const regex_style_tags = /<!--[^]*?-->|<style(\s[^]*?)?(?:>([^]*?)<\/style>|\/>)/gi;
@@ -216,6 +293,7 @@ async function process_tag(tag_name, preprocessor, source) {
216293
processed,
217294
tag_name,
218295
attributes,
296+
stringify_tag_attributes(processed.attributes) ?? attributes,
219297
slice_source(content, tag_offset, source)
220298
);
221299
}
@@ -264,20 +342,21 @@ export default async function preprocess(source, preprocessor, options) {
264342
? preprocessor
265343
: [preprocessor]
266344
: [];
267-
const markup = preprocessors.map((p) => p.markup).filter(Boolean);
268-
const script = preprocessors.map((p) => p.script).filter(Boolean);
269-
const style = preprocessors.map((p) => p.style).filter(Boolean);
270345
const result = new PreprocessResult(source, filename);
346+
271347
// TODO keep track: what preprocessor generated what sourcemap?
272348
// to make debugging easier = detect low-resolution sourcemaps in fn combine_mappings
273-
for (const process of markup) {
274-
result.update_source(await process_markup(process, result));
275-
}
276-
for (const process of script) {
277-
result.update_source(await process_tag('script', process, result));
278-
}
279-
for (const preprocess of style) {
280-
result.update_source(await process_tag('style', preprocess, result));
349+
for (const preprocessor of preprocessors) {
350+
if (preprocessor.markup) {
351+
result.update_source(await process_markup(preprocessor.markup, result));
352+
}
353+
if (preprocessor.script) {
354+
result.update_source(await process_tag('script', preprocessor.script, result));
355+
}
356+
if (preprocessor.style) {
357+
result.update_source(await process_tag('style', preprocessor.style, result));
358+
}
281359
}
360+
282361
return result.to_processed();
283362
}

0 commit comments

Comments
 (0)