UNPKG

svelte

Version:

Cybernetically enhanced web apps

369 lines (333 loc) 10.9 kB
/** @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(); }