|
| 1 | +import MagicString from 'magic-string'; |
| 2 | +import { parse } from 'svelte-parse-markup'; |
| 3 | +import { walk } from 'svelte/compiler'; |
| 4 | + |
| 5 | +const IGNORE_FLAG = 'svelte-image-disable'; |
| 6 | +const FORCE_FLAG = 'svelte-image-enable'; |
| 7 | +const ASSET_PREFIX = '___ASSET___'; |
| 8 | + |
| 9 | +// TODO: expose this in vite-imagetools rather than duplicating it |
| 10 | +const OPTIMIZABLE = /^[^?]+\.(heic|heif|avif|jpeg|jpg|png|tiff|webp|gif)(\?.*)?$/; |
| 11 | + |
| 12 | +/** |
| 13 | + * @returns {import('svelte/types/compiler/preprocess').PreprocessorGroup} |
| 14 | + */ |
| 15 | +export function image() { |
| 16 | + return { |
| 17 | + markup({ content, filename }) { |
| 18 | + const s = new MagicString(content); |
| 19 | + const ast = parse(content, { filename }); |
| 20 | + |
| 21 | + // Import path to import name |
| 22 | + // e.g. ./foo.png => ___ASSET___0 |
| 23 | + /** @type {Map<string, string>} */ |
| 24 | + const imports = new Map(); |
| 25 | + |
| 26 | + /** |
| 27 | + * @param {import('svelte/types/compiler/interfaces').TemplateNode} node |
| 28 | + * @param {{ type: string, start: number, end: number, raw: string }} attribute_value |
| 29 | + */ |
| 30 | + function update_element(node, attribute_value) { |
| 31 | + if (attribute_value.type === 'MustacheTag') { |
| 32 | + const src_var_name = content |
| 33 | + .substring(attribute_value.start + 1, attribute_value.end - 1) |
| 34 | + .trim(); |
| 35 | + s.update(node.start, node.end, dynamic_img_to_picture(content, node, src_var_name)); |
| 36 | + return; |
| 37 | + } |
| 38 | + |
| 39 | + const url = attribute_value.raw.trim(); |
| 40 | + |
| 41 | + // if it's not a relative reference or Vite alias then skip it |
| 42 | + // TODO: read vite aliases here rather than assuming $ |
| 43 | + if (!url.startsWith('./') && !url.startsWith('$')) return; |
| 44 | + |
| 45 | + let import_name = ''; |
| 46 | + |
| 47 | + if (imports.has(url)) { |
| 48 | + import_name = /** @type {string} */ (imports.get(url)); |
| 49 | + } else { |
| 50 | + import_name = ASSET_PREFIX + imports.size; |
| 51 | + imports.set(url, import_name); |
| 52 | + } |
| 53 | + |
| 54 | + if (OPTIMIZABLE.test(url)) { |
| 55 | + s.update(node.start, node.end, img_to_picture(content, node, import_name)); |
| 56 | + } else { |
| 57 | + // e.g. <img src="./foo.svg" /> => <img src="{___ASSET___0}" /> |
| 58 | + s.update(attribute_value.start, attribute_value.end, `{${import_name}}`); |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + let ignore_next_element = false; |
| 63 | + let force_next_element = false; |
| 64 | + |
| 65 | + // @ts-ignore |
| 66 | + walk(ast.html, { |
| 67 | + /** |
| 68 | + * @param {import('svelte/types/compiler/interfaces').TemplateNode} node |
| 69 | + */ |
| 70 | + enter(node) { |
| 71 | + if (node.type === 'Comment') { |
| 72 | + if (node.data.trim() === IGNORE_FLAG) { |
| 73 | + ignore_next_element = true; |
| 74 | + } else if (node.data.trim() === FORCE_FLAG) { |
| 75 | + force_next_element = true; |
| 76 | + } |
| 77 | + } else if (node.type === 'Element') { |
| 78 | + if (ignore_next_element) { |
| 79 | + ignore_next_element = false; |
| 80 | + return; |
| 81 | + } |
| 82 | + |
| 83 | + // Compare node tag match |
| 84 | + if (node.name === 'img') { |
| 85 | + /** |
| 86 | + * @param {string} attr |
| 87 | + */ |
| 88 | + function get_attr_value(attr) { |
| 89 | + const attribute = node.attributes.find( |
| 90 | + /** @param {any} v */ (v) => v.type === 'Attribute' && v.name === attr |
| 91 | + ); |
| 92 | + if (!attribute) return; |
| 93 | + |
| 94 | + // Ensure value only consists of one element, and is of type "Text". |
| 95 | + // Which should only match instances of static `foo="bar"` attributes. |
| 96 | + if ( |
| 97 | + !force_next_element && |
| 98 | + (attribute.value.length !== 1 || attribute.value[0].type !== 'Text') |
| 99 | + ) { |
| 100 | + return; |
| 101 | + } |
| 102 | + |
| 103 | + return attribute.value[0]; |
| 104 | + } |
| 105 | + |
| 106 | + const src = get_attr_value('src'); |
| 107 | + if (!src) return; |
| 108 | + update_element(node, src); |
| 109 | + } |
| 110 | + } |
| 111 | + } |
| 112 | + }); |
| 113 | + |
| 114 | + // add imports |
| 115 | + if (imports.size) { |
| 116 | + let import_text = ''; |
| 117 | + for (const [path, import_name] of imports.entries()) { |
| 118 | + import_text += `import ${import_name} from "${path}";`; |
| 119 | + } |
| 120 | + if (ast.instance) { |
| 121 | + // @ts-ignore |
| 122 | + s.appendLeft(ast.instance.content.start, import_text); |
| 123 | + } else { |
| 124 | + s.append(`<script>${import_text}</script>`); |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + return { |
| 129 | + code: s.toString(), |
| 130 | + map: s.generateMap() |
| 131 | + }; |
| 132 | + } |
| 133 | + }; |
| 134 | +} |
| 135 | + |
| 136 | +/** |
| 137 | + * @param {string} content |
| 138 | + * @param {Array<import('svelte/types/compiler/interfaces').BaseDirective | import('svelte/types/compiler/interfaces').Attribute | import('svelte/types/compiler/interfaces').SpreadAttribute>} attributes |
| 139 | + * @param {string} src_var_name |
| 140 | + */ |
| 141 | +function attributes_to_markdown(content, attributes, src_var_name) { |
| 142 | + const attribute_strings = attributes.map((attribute) => { |
| 143 | + if (attribute.name === 'src') { |
| 144 | + return `src={${src_var_name}.img.src}`; |
| 145 | + } |
| 146 | + return content.substring(attribute.start, attribute.end); |
| 147 | + }); |
| 148 | + |
| 149 | + let has_width = false; |
| 150 | + let has_height = false; |
| 151 | + for (const attribute of attributes) { |
| 152 | + if (attribute.name === 'width') has_width = true; |
| 153 | + if (attribute.name === 'height') has_height = true; |
| 154 | + } |
| 155 | + if (!has_width && !has_height) { |
| 156 | + attribute_strings.push(`width={${src_var_name}.img.w}`); |
| 157 | + attribute_strings.push(`height={${src_var_name}.img.h}`); |
| 158 | + } |
| 159 | + |
| 160 | + return attribute_strings.join(' '); |
| 161 | +} |
| 162 | + |
| 163 | +/** |
| 164 | + * @param {string} content |
| 165 | + * @param {import('svelte/types/compiler/interfaces').TemplateNode} node |
| 166 | + * @param {string} import_name |
| 167 | + */ |
| 168 | +function img_to_picture(content, node, import_name) { |
| 169 | + return `<picture> |
| 170 | + {#each Object.entries(${import_name}.sources) as [format, images]} |
| 171 | + <source srcset={images.map((i) => \`\${i.src} \${i.w}w\`).join(', ')} type={'image/' + format} /> |
| 172 | + {/each} |
| 173 | + <img ${attributes_to_markdown(content, node.attributes, import_name)} /> |
| 174 | +</picture>`; |
| 175 | +} |
| 176 | + |
| 177 | +/** |
| 178 | + * For images like `<img src={manually_imported} />` |
| 179 | + * @param {string} content |
| 180 | + * @param {import('svelte/types/compiler/interfaces').TemplateNode} node |
| 181 | + * @param {string} src_var_name |
| 182 | + */ |
| 183 | +function dynamic_img_to_picture(content, node, src_var_name) { |
| 184 | + return `{#if typeof ${src_var_name} === 'string'} |
| 185 | + <img ${attributes_to_markdown(content, node.attributes, src_var_name)} /> |
| 186 | +{:else} |
| 187 | + ${img_to_picture(content, node, src_var_name)} |
| 188 | +{/if}`; |
| 189 | +} |
0 commit comments