svelte
Version:
Cybernetically enhanced web apps
369 lines (333 loc) • 10.9 kB
JavaScript
/** @import { Processed, Preprocessor, MarkupPreprocessor, PreprocessorGroup } from './public.js' */
/** @import { SourceUpdate, Source } from './private.js' */
/** @import { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping' */
import { getLocator } from 'locate-character';
import {
MappedCode,
parse_attached_sourcemap,
sourcemap_add_offset,
combine_sourcemaps,
get_basename
} from '../utils/mapped_code.js';
import { decode_map } from './decode_sourcemap.js';
import { replace_in_code, slice_source } from './replace_in_code.js';
/**
* Represents intermediate states of the preprocessing.
* Implements the Source interface.
*/
class PreprocessResult {
/** @type {string} */
source;
/** @type {string | undefined} The filename passed as-is to preprocess */
filename;
// sourcemap_list is sorted in reverse order from last map (index 0) to first map (index -1)
// so we use sourcemap_list.unshift() to add new maps
// https://github.com/ampproject/remapping#multiple-transformations-of-a-file
/**
* @default []
* @type {Array<DecodedSourceMap | RawSourceMap>}
*/
sourcemap_list = [];
/**
* @default []
* @type {string[]}
*/
dependencies = [];
/**
* @type {string | null} last part of the filename, as used for `sources` in sourcemaps
*/
file_basename = /** @type {any} */ (undefined);
/**
* @type {ReturnType<typeof getLocator>}
*/
get_location = /** @type {any} */ (undefined);
/**
* @param {string} source
* @param {string} [filename]
*/
constructor(source, filename) {
this.source = source;
this.filename = filename;
this.update_source({ string: source });
// preprocess source must be relative to itself or equal null
this.file_basename = filename == null ? null : get_basename(filename);
}
/**
* @param {SourceUpdate} opts
*/
update_source({ string: source, map, dependencies }) {
if (source != null) {
this.source = source;
this.get_location = getLocator(source);
}
if (map) {
this.sourcemap_list.unshift(map);
}
if (dependencies) {
this.dependencies.push(...dependencies);
}
}
/**
* @returns {Processed}
*/
to_processed() {
// Combine all the source maps for each preprocessor function into one
// @ts-expect-error TODO there might be a bug in hiding here
const map = combine_sourcemaps(this.file_basename, this.sourcemap_list);
return {
// TODO return separated output, in future version where svelte.compile supports it:
// style: { code: styleCode, map: styleMap },
// script { code: scriptCode, map: scriptMap },
// markup { code: markupCode, map: markupMap },
code: this.source,
dependencies: [...new Set(this.dependencies)],
// @ts-expect-error TODO there might be a bug in hiding here
map,
toString: () => this.source
};
}
}
/**
* Convert preprocessor output for the tag content into MappedCode
* @param {Processed} processed
* @param {{ line: number; column: number; }} location
* @param {string} file_basename
* @returns {MappedCode}
*/
function processed_content_to_code(processed, location, file_basename) {
// Convert the preprocessed code and its sourcemap to a MappedCode
/**
* @type {DecodedSourceMap | undefined}
*/
let decoded_map = undefined;
if (processed.map) {
decoded_map = decode_map(processed);
// decoded map may not have sources for empty maps like `{ mappings: '' }`
if (decoded_map?.sources) {
// offset only segments pointing at original component source
const source_index = decoded_map.sources.indexOf(file_basename);
if (source_index !== -1) {
sourcemap_add_offset(decoded_map, location, source_index);
}
}
}
return MappedCode.from_processed(processed.code, decoded_map);
}
/**
* Given the whole tag including content, return a `MappedCode`
* representing the tag content replaced with `processed`.
* @param {Processed} processed
* @param {'style' | 'script'} tag_name
* @param {string} original_attributes
* @param {string} generated_attributes
* @param {Source} source
* @returns {MappedCode}
*/
function processed_tag_to_code(
processed,
tag_name,
original_attributes,
generated_attributes,
source
) {
const { file_basename, get_location } = source;
/**
* @param {string} code
* @param {number} offset
*/
const build_mapped_code = (code, offset) =>
MappedCode.from_source(slice_source(code, offset, source));
// To map the open/close tag and content starts positions correctly, we need to
// differentiate between the original attributes and the generated attributes:
// `source` contains the original attributes and its get_location maps accordingly.
const original_tag_open = `<${tag_name}${original_attributes}>`;
const tag_open = `<${tag_name}${generated_attributes}>`;
/** @type {MappedCode} */
let tag_open_code;
if (original_tag_open.length !== tag_open.length) {
// Generate a source map for the open tag
/** @type {DecodedSourceMap['mappings']} */
const mappings = [
[
// start of tag
[0, 0, 0, 0],
// end of tag start
[`<${tag_name}`.length, 0, 0, `<${tag_name}`.length]
]
];
const line = tag_open.split('\n').length - 1;
const column = tag_open.length - (line === 0 ? 0 : tag_open.lastIndexOf('\n')) - 1;
while (mappings.length <= line) {
// end of tag start again, if this is a multi line mapping
mappings.push([[0, 0, 0, `<${tag_name}`.length]]);
}
// end of tag
mappings[line].push([
column,
0,
original_tag_open.split('\n').length - 1,
original_tag_open.length - original_tag_open.lastIndexOf('\n') - 1
]);
/** @type {DecodedSourceMap} */
const map = {
version: 3,
names: [],
sources: [file_basename],
mappings
};
sourcemap_add_offset(map, get_location(0), 0);
tag_open_code = MappedCode.from_processed(tag_open, map);
} else {
tag_open_code = build_mapped_code(tag_open, 0);
}
const tag_close = `</${tag_name}>`;
const tag_close_code = build_mapped_code(
tag_close,
original_tag_open.length + source.source.length
);
parse_attached_sourcemap(processed, tag_name);
const content_code = processed_content_to_code(
processed,
get_location(original_tag_open.length),
file_basename
);
return tag_open_code.concat(content_code).concat(tag_close_code);
}
const attribute_pattern = /([\w-$]+\b)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
/**
* @param {string} str
*/
function parse_tag_attributes(str) {
/** @type {Record<string, string | boolean>} */
const attrs = {};
/** @type {RegExpMatchArray | null} */
let match;
while ((match = attribute_pattern.exec(str)) !== null) {
const name = match[1];
const value = match[2] || match[3] || match[4];
attrs[name] = !value || value;
}
return attrs;
}
/**
* @param {Record<string, string | boolean> | undefined} attributes
*/
function stringify_tag_attributes(attributes) {
if (!attributes) return;
let value = Object.entries(attributes)
.map(([key, value]) => (value === true ? key : `${key}="${value}"`))
.join(' ');
if (value) {
value = ' ' + value;
}
return value;
}
const regex_style_tags =
/<!--[^]*?-->|<style((?:\s+[^=>'"/\s]+=(?:"[^"]*"|'[^']*'|[^>\s]+)|\s+[^=>'"/\s]+)*\s*)(?:\/>|>([\S\s]*?)<\/style>)/g;
const regex_script_tags =
/<!--[^]*?-->|<script((?:\s+[^=>'"/\s]+=(?:"[^"]*"|'[^']*'|[^>\s]+)|\s+[^=>'"/\s]+)*\s*)(?:\/>|>([\S\s]*?)<\/script>)/g;
/**
* Calculate the updates required to process all instances of the specified tag.
* @param {'style' | 'script'} tag_name
* @param {Preprocessor} preprocessor
* @param {Source} source
* @returns {Promise<SourceUpdate>}
*/
async function process_tag(tag_name, preprocessor, source) {
const { filename, source: markup } = source;
const tag_regex = tag_name === 'style' ? regex_style_tags : regex_script_tags;
/**
* @type {string[]}
*/
const dependencies = [];
/**
* @param {string} tag_with_content
* @param {number} tag_offset
* @returns {Promise<MappedCode>}
*/
async function process_single_tag(tag_with_content, attributes = '', content = '', tag_offset) {
const no_change = () =>
MappedCode.from_source(slice_source(tag_with_content, tag_offset, source));
if (!attributes && !content) return no_change();
const processed = await preprocessor({
content: content || '',
attributes: parse_tag_attributes(attributes || ''),
markup,
filename
});
if (!processed) return no_change();
if (processed.dependencies) dependencies.push(...processed.dependencies);
if (!processed.map && processed.code === content) return no_change();
return processed_tag_to_code(
processed,
tag_name,
attributes,
stringify_tag_attributes(processed.attributes) ?? attributes,
slice_source(content, tag_offset, source)
);
}
const { string, map } = await replace_in_code(tag_regex, process_single_tag, source);
return { string, map, dependencies };
}
/**
* @param {MarkupPreprocessor} process
* @param {Source} source
*/
async function process_markup(process, source) {
const processed = await process({
content: source.source,
filename: source.filename
});
if (processed) {
return {
string: processed.code,
map: processed.map
? // TODO: can we use decode_sourcemap?
typeof processed.map === 'string'
? JSON.parse(processed.map)
: processed.map
: undefined,
dependencies: processed.dependencies
};
} else {
return {};
}
}
/**
* 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.
*
* @param {string} source
* @param {PreprocessorGroup | PreprocessorGroup[]} preprocessor
* @param {{ filename?: string }} [options]
* @returns {Promise<Processed>}
*/
export default async function preprocess(source, preprocessor, options) {
/**
* @type {string | undefined}
*/
const filename = (options && options.filename) || /** @type {any} */ (preprocessor).filename; // legacy
const preprocessors = preprocessor
? Array.isArray(preprocessor)
? preprocessor
: [preprocessor]
: [];
const result = new PreprocessResult(source, filename);
// TODO keep track: what preprocessor generated what sourcemap?
// to make debugging easier = detect low-resolution sourcemaps in fn combine_mappings
for (const preprocessor of preprocessors) {
if (preprocessor.markup) {
// @ts-expect-error TODO there might be a bug in hiding here
result.update_source(await process_markup(preprocessor.markup, result));
}
if (preprocessor.script) {
// @ts-expect-error TODO there might be a bug in hiding here
result.update_source(await process_tag('script', preprocessor.script, result));
}
if (preprocessor.style) {
// @ts-expect-error TODO there might be a bug in hiding here
result.update_source(await process_tag('style', preprocessor.style, result));
}
}
return result.to_processed();
}