|
| 1 | +/* eslint-disable @typescript-eslint/no-explicit-any */ |
| 2 | +import {File, Store} from '../store' |
| 3 | +import { |
| 4 | + babelParse, |
| 5 | + MagicString, |
| 6 | + walk, |
| 7 | + walkIdentifiers, |
| 8 | + extractIdentifiers, |
| 9 | + isInDestructureAssignment, |
| 10 | + isStaticProperty |
| 11 | +} from 'vue/compiler-sfc' |
| 12 | +import {ExportSpecifier, Identifier, Node} from '@babel/types' |
| 13 | + |
| 14 | +export function compileModulesForPreview(store: Store) { |
| 15 | + const seen = new Set<File>() |
| 16 | + const processed: string[] = [] |
| 17 | + processFile(store, store.state.files[store.state.mainFile], processed, seen) |
| 18 | + |
| 19 | + // also add css files that are not imported |
| 20 | + for (const filename in store.state.files) { |
| 21 | + if (filename.endsWith('.css')) { |
| 22 | + const file = store.state.files[filename] |
| 23 | + if (!seen.has(file)) { |
| 24 | + processed.push(`\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}`) |
| 25 | + } |
| 26 | + } |
| 27 | + } |
| 28 | + |
| 29 | + return processed |
| 30 | +} |
| 31 | + |
| 32 | +const modulesKey = `__modules__` |
| 33 | +const exportKey = `__export__` |
| 34 | +const dynamicImportKey = `__dynamic_import__` |
| 35 | +const moduleKey = `__module__` |
| 36 | + |
| 37 | +// similar logic with Vite's SSR transform, except this is targeting the browser |
| 38 | +function processFile(store: Store, file: File, processed: string[], seen: Set<File>) { |
| 39 | + if (seen.has(file)) { |
| 40 | + return [] |
| 41 | + } |
| 42 | + seen.add(file) |
| 43 | + |
| 44 | + if (file.filename.endsWith('.html')) { |
| 45 | + return processHtmlFile(store, file.code, file.filename, processed, seen) |
| 46 | + } |
| 47 | + |
| 48 | + // eslint-disable-next-line prefer-const |
| 49 | + let [js, importedFiles] = processModule(store, file.compiled.js, file.filename) |
| 50 | + // append css |
| 51 | + if (file.compiled.css) { |
| 52 | + js += `\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}` |
| 53 | + } |
| 54 | + // crawl child imports |
| 55 | + if (importedFiles.size) { |
| 56 | + for (const imported of importedFiles) { |
| 57 | + processFile(store, store.state.files[imported], processed, seen) |
| 58 | + } |
| 59 | + } |
| 60 | + // push self |
| 61 | + processed.push(js) |
| 62 | +} |
| 63 | + |
| 64 | +function processModule(store: Store, src: string, filename: string): [string, Set<string>] { |
| 65 | + const s = new MagicString(src) |
| 66 | + |
| 67 | + const ast = babelParse(src, { |
| 68 | + sourceFilename: filename, |
| 69 | + sourceType: 'module' |
| 70 | + }).program.body |
| 71 | + |
| 72 | + const idToImportMap = new Map<string, string>() |
| 73 | + const declaredConst = new Set<string>() |
| 74 | + const importedFiles = new Set<string>() |
| 75 | + const importToIdMap = new Map<string, string>() |
| 76 | + |
| 77 | + function defineImport(node: Node, source: string) { |
| 78 | + const filename = source.replace(/^\.\/+/, '') |
| 79 | + if (!(filename in store.state.files)) { |
| 80 | + throw new Error(`File "${filename}" does not exist.`) |
| 81 | + } |
| 82 | + if (importedFiles.has(filename)) { |
| 83 | + return importToIdMap.get(filename)! |
| 84 | + } |
| 85 | + importedFiles.add(filename) |
| 86 | + const id = `__import_${importedFiles.size}__` |
| 87 | + importToIdMap.set(filename, id) |
| 88 | + s.appendLeft(node.start!, `const ${id} = ${modulesKey}[${JSON.stringify(filename)}]\n`) |
| 89 | + return id |
| 90 | + } |
| 91 | + |
| 92 | + function defineExport(name: string, local = name) { |
| 93 | + s.append(`\n${exportKey}(${moduleKey}, "${name}", () => ${local})`) |
| 94 | + } |
| 95 | + |
| 96 | + // 0. instantiate module |
| 97 | + s.prepend(`const ${moduleKey} = __modules__[${JSON.stringify(filename)}] = { [Symbol.toStringTag]: "Module" }\n\n`) |
| 98 | + |
| 99 | + // 1. check all import statements and record id -> importName map |
| 100 | + for (const node of ast) { |
| 101 | + // import foo from 'foo' --> foo -> __import_foo__.default |
| 102 | + // import { baz } from 'foo' --> baz -> __import_foo__.baz |
| 103 | + // import * as ok from 'foo' --> ok -> __import_foo__ |
| 104 | + if (node.type === 'ImportDeclaration') { |
| 105 | + const source = node.source.value |
| 106 | + if (source.startsWith('./')) { |
| 107 | + const importId = defineImport(node, node.source.value) |
| 108 | + for (const spec of node.specifiers) { |
| 109 | + if (spec.type === 'ImportSpecifier') { |
| 110 | + idToImportMap.set(spec.local.name, `${importId}.${(spec.imported as Identifier).name}`) |
| 111 | + } else if (spec.type === 'ImportDefaultSpecifier') { |
| 112 | + idToImportMap.set(spec.local.name, `${importId}.default`) |
| 113 | + } else { |
| 114 | + // namespace specifier |
| 115 | + idToImportMap.set(spec.local.name, importId) |
| 116 | + } |
| 117 | + } |
| 118 | + s.remove(node.start!, node.end!) |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + // 2. check all export statements and define exports |
| 124 | + for (const node of ast) { |
| 125 | + // named exports |
| 126 | + if (node.type === 'ExportNamedDeclaration') { |
| 127 | + if (node.declaration) { |
| 128 | + if (node.declaration.type === 'FunctionDeclaration' || node.declaration.type === 'ClassDeclaration') { |
| 129 | + // export function foo() {} |
| 130 | + defineExport(node.declaration.id!.name) |
| 131 | + } else if (node.declaration.type === 'VariableDeclaration') { |
| 132 | + // export const foo = 1, bar = 2 |
| 133 | + for (const decl of node.declaration.declarations) { |
| 134 | + for (const id of extractIdentifiers(decl.id)) { |
| 135 | + defineExport(id.name) |
| 136 | + } |
| 137 | + } |
| 138 | + } |
| 139 | + s.remove(node.start!, node.declaration.start!) |
| 140 | + } else if (node.source) { |
| 141 | + // export { foo, bar } from './foo' |
| 142 | + const importId = defineImport(node, node.source.value) |
| 143 | + for (const spec of node.specifiers) { |
| 144 | + defineExport((spec.exported as Identifier).name, `${importId}.${(spec as ExportSpecifier).local.name}`) |
| 145 | + } |
| 146 | + s.remove(node.start!, node.end!) |
| 147 | + } else { |
| 148 | + // export { foo, bar } |
| 149 | + for (const spec of node.specifiers) { |
| 150 | + const local = (spec as ExportSpecifier).local.name |
| 151 | + const binding = idToImportMap.get(local) |
| 152 | + defineExport((spec.exported as Identifier).name, binding || local) |
| 153 | + } |
| 154 | + s.remove(node.start!, node.end!) |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + // default export |
| 159 | + if (node.type === 'ExportDefaultDeclaration') { |
| 160 | + if ('id' in node.declaration && node.declaration.id) { |
| 161 | + // named hoistable/class exports |
| 162 | + // export default function foo() {} |
| 163 | + // export default class A {} |
| 164 | + const {name} = node.declaration.id |
| 165 | + s.remove(node.start!, node.start! + 15) |
| 166 | + s.append(`\n${exportKey}(${moduleKey}, "default", () => ${name})`) |
| 167 | + } else { |
| 168 | + // anonymous default exports |
| 169 | + s.overwrite(node.start!, node.start! + 14, `${moduleKey}.default =`) |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + // export * from './foo' |
| 174 | + if (node.type === 'ExportAllDeclaration') { |
| 175 | + const importId = defineImport(node, node.source.value) |
| 176 | + s.remove(node.start!, node.end!) |
| 177 | + s.append(`\nfor (const key in ${importId}) { |
| 178 | + if (key !== 'default') { |
| 179 | + ${exportKey}(${moduleKey}, key, () => ${importId}[key]) |
| 180 | + } |
| 181 | + }`) |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + // 3. convert references to import bindings |
| 186 | + for (const node of ast) { |
| 187 | + if (node.type === 'ImportDeclaration') continue |
| 188 | + walkIdentifiers(node, (id, parent, parentStack) => { |
| 189 | + const binding = idToImportMap.get(id.name) |
| 190 | + if (!binding) { |
| 191 | + return |
| 192 | + } |
| 193 | + if (isStaticProperty(parent) && parent.shorthand) { |
| 194 | + // let binding used in a property shorthand |
| 195 | + // { foo } -> { foo: __import_x__.foo } |
| 196 | + // skip for destructure patterns |
| 197 | + if (!(parent as any).inPattern || isInDestructureAssignment(parent, parentStack)) { |
| 198 | + s.appendLeft(id.end!, `: ${binding}`) |
| 199 | + } |
| 200 | + } else if (parent.type === 'ClassDeclaration' && id === parent.superClass) { |
| 201 | + if (!declaredConst.has(id.name)) { |
| 202 | + declaredConst.add(id.name) |
| 203 | + // locate the top-most node containing the class declaration |
| 204 | + const topNode = parentStack[1] |
| 205 | + s.prependRight(topNode.start!, `const ${id.name} = ${binding};\n`) |
| 206 | + } |
| 207 | + } else { |
| 208 | + s.overwrite(id.start!, id.end!, binding) |
| 209 | + } |
| 210 | + }) |
| 211 | + } |
| 212 | + |
| 213 | + // 4. convert dynamic imports |
| 214 | + ;(walk as any)(ast, { |
| 215 | + enter(node: Node, parent: Node) { |
| 216 | + if (node.type === 'Import' && parent.type === 'CallExpression') { |
| 217 | + const arg = parent.arguments[0] |
| 218 | + if (arg.type === 'StringLiteral' && arg.value.startsWith('./')) { |
| 219 | + s.overwrite(node.start!, node.start! + 6, dynamicImportKey) |
| 220 | + s.overwrite(arg.start!, arg.end!, JSON.stringify(arg.value.replace(/^\.\/+/, ''))) |
| 221 | + } |
| 222 | + } |
| 223 | + } |
| 224 | + }) |
| 225 | + |
| 226 | + return [s.toString(), importedFiles] |
| 227 | +} |
| 228 | + |
| 229 | +const scriptRE = /<script\b(?:\s[^>]*>|>)([^]*?)<\/script>/gi |
| 230 | +const scriptModuleRE = /<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>([^]*?)<\/script>/gi |
| 231 | + |
| 232 | +function processHtmlFile(store: Store, src: string, filename: string, processed: string[], seen: Set<File>) { |
| 233 | + const deps: string[] = [] |
| 234 | + let jsCode = '' |
| 235 | + const html = src |
| 236 | + .replace(scriptModuleRE, (_, content) => { |
| 237 | + const [code, importedFiles] = processModule(store, content, filename) |
| 238 | + if (importedFiles.size) { |
| 239 | + for (const imported of importedFiles) { |
| 240 | + processFile(store, store.state.files[imported], deps, seen) |
| 241 | + } |
| 242 | + } |
| 243 | + jsCode += '\n' + code |
| 244 | + return '' |
| 245 | + }) |
| 246 | + .replace(scriptRE, (_, content) => { |
| 247 | + jsCode += '\n' + content |
| 248 | + return '' |
| 249 | + }) |
| 250 | + processed.push(`document.body.innerHTML = ${JSON.stringify(html)}`) |
| 251 | + processed.push(...deps) |
| 252 | + processed.push(jsCode) |
| 253 | +} |
0 commit comments