Przeglądaj źródła

fix(compiler-sfc): allow Node.js subpath imports patterns in asset urls (#13045)

close #9919
Haoqun Jiang 3 tygodni temu
rodzic
commit
95c33560c9

+ 36 - 0
packages/compiler-sfc/__tests__/templateTransformAssetUrl.spec.ts

@@ -114,6 +114,42 @@ describe('compiler sfc: transform asset url', () => {
     expect(code).toMatch(`"xlink:href": "#myCircle"`)
   })
 
+  // #9919
+  test('should transform subpath import paths', () => {
+    const { code } = compileWithAssetUrls(
+      `<img src="#src/assets/vue.svg" />` +
+        `<img src="#/src/assets/vue.svg" />`,
+    )
+    expect(code).toContain(`_imports_0 from '#src/assets/vue.svg'`)
+    expect(code).toContain(`_imports_1 from '#/src/assets/vue.svg'`)
+  })
+
+  test('should not transform pure hash values for custom asset URL tags', () => {
+    const { code } = compileWithAssetUrls(
+      `<foo bar="#fragment" />` +
+        `<foo bar="#src/assets/vue.svg" />` +
+        `<foo bar="#/src/assets/vue.svg" />`,
+      {
+        tags: {
+          foo: ['bar'],
+        },
+      },
+    )
+
+    expect(code).toContain(`bar: "#fragment"`)
+    expect(code).toContain(`bar: "#src/assets/vue.svg"`)
+    expect(code).toContain(`bar: "#/src/assets/vue.svg"`)
+    expect(code).not.toContain(`from '#fragment'`)
+    expect(code).not.toContain(`from '#src/assets/vue.svg'`)
+    expect(code).not.toContain(`from '#/src/assets/vue.svg'`)
+  })
+
+  test('should not throw for malformed percent-encoding in asset paths', () => {
+    const { code } = compileWithAssetUrls(`<img src="./foo%.png" />`)
+
+    expect(code).toContain(`import _imports_0 from './foo%.png'`)
+  })
+
   test('should allow for full base URLs, with paths', () => {
     const { code } = compileWithAssetUrls(`<img src="./logo.png" />`, {
       base: 'http://localhost:3000/src/',

+ 32 - 0
packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts

@@ -106,4 +106,36 @@ describe('compiler sfc: transform srcset', () => {
     ).code
     expect(code).toMatchSnapshot()
   })
+
+  test('should transform subpath import paths starting with #', () => {
+    const code = compileWithSrcset(
+      `<img srcset="#src/assets/vue.svg" />` +
+        `<img srcset="#/src/assets/vue.svg 2x" />`,
+    ).code
+
+    expect(code).toContain(`_imports_0 from '#src/assets/vue.svg'`)
+    expect(code).toContain(`_imports_1 from '#/src/assets/vue.svg'`)
+    expect(code).toContain(`const _hoisted_1 = _imports_0`)
+    expect(code).toContain(`const _hoisted_2 = _imports_1 + ' 2x'`)
+  })
+
+  test('should preserve svg fragments in srcset URLs', () => {
+    const code = compileWithSrcset(
+      `<img srcset="./icons.svg#icon-heart" />` +
+        `<img srcset="./icons.svg#icon-star 2x" />`,
+    ).code
+
+    expect(code).toContain(`_imports_0 from './icons.svg'`)
+    expect(code).toContain(`const _hoisted_1 = _imports_0 + '#icon-heart'`)
+    expect(code).toContain(
+      `const _hoisted_2 = _imports_0 + '#icon-star' + ' 2x'`,
+    )
+  })
+
+  test('should not throw for malformed percent-encoding in srcset paths', () => {
+    const code = compileWithSrcset(`<img srcset="./foo%.png 2x" />`).code
+
+    expect(code).toContain(`import _imports_0 from './foo%.png'`)
+    expect(code).toContain(`const _hoisted_1 = _imports_0 + ' 2x'`)
+  })
 })

+ 14 - 1
packages/compiler-sfc/src/template/templateUtils.ts

@@ -3,7 +3,12 @@ import { isString } from '@vue/shared'
 
 export function isRelativeUrl(url: string): boolean {
   const firstChar = url.charAt(0)
-  return firstChar === '.' || firstChar === '~' || firstChar === '@'
+  return (
+    firstChar === '.' ||
+    firstChar === '~' ||
+    firstChar === '@' ||
+    firstChar === '#'
+  )
 }
 
 const externalRE = /^(?:https?:)?\/\//
@@ -16,6 +21,14 @@ export function isDataUrl(url: string): boolean {
   return dataUrlRE.test(url)
 }
 
+export function normalizeDecodedImportPath(source: string): string {
+  try {
+    return decodeURIComponent(source)
+  } catch {
+    return source
+  }
+}
+
 /**
  * Parses string url into URL object.
  */

+ 125 - 64
packages/compiler-sfc/src/template/transformAssetUrl.ts

@@ -13,6 +13,7 @@ import {
   isDataUrl,
   isExternalUrl,
   isRelativeUrl,
+  normalizeDecodedImportPath,
   parseUrl,
 } from './templateUtils'
 import { isArray } from '@vue/shared'
@@ -34,14 +35,20 @@ export interface AssetURLOptions {
   tags?: AssetURLTagConfig
 }
 
+// Built-in attrs that always represent resource URLs. `use` is intentionally
+// omitted because its hash-only values may still be fragment references.
+const resourceUrlTagConfig: AssetURLTagConfig = {
+  video: ['src', 'poster'],
+  source: ['src'],
+  img: ['src'],
+  image: ['xlink:href', 'href'],
+}
+
 export const defaultAssetUrlOptions: Required<AssetURLOptions> = {
   base: null,
   includeAbsolute: false,
   tags: {
-    video: ['src', 'poster'],
-    source: ['src'],
-    img: ['src'],
-    image: ['xlink:href', 'href'],
+    ...resourceUrlTagConfig,
     use: ['xlink:href', 'href'],
   },
 }
@@ -69,6 +76,10 @@ export const createAssetUrlTransformWithOptions = (
     (transformAssetUrl as Function)(node, context, options)
 }
 
+function canTransformHashImport(tag: string, attrName: string): boolean {
+  return !!resourceUrlTagConfig[tag]?.includes(attrName)
+}
+
 /**
  * A `@vue/compiler-core` plugin that transforms relative asset urls into
  * either imports or absolute urls.
@@ -104,17 +115,24 @@ export const transformAssetUrl: NodeTransform = (
       if (
         attr.type !== NodeTypes.ATTRIBUTE ||
         !assetAttrs.includes(attr.name) ||
-        !attr.value ||
-        isExternalUrl(attr.value.content) ||
-        isDataUrl(attr.value.content) ||
-        attr.value.content[0] === '#' ||
-        (!options.includeAbsolute && !isRelativeUrl(attr.value.content))
+        !attr.value
       ) {
         return
       }
 
-      const url = parseUrl(attr.value.content)
-      if (options.base && attr.value.content[0] === '.') {
+      const urlValue = attr.value.content
+      const isHashOnlyValue = urlValue[0] === '#'
+      if (
+        isExternalUrl(urlValue) ||
+        isDataUrl(urlValue) ||
+        (isHashOnlyValue && !canTransformHashImport(node.tag, attr.name)) ||
+        (!options.includeAbsolute && !isRelativeUrl(urlValue))
+      ) {
+        return
+      }
+
+      const url = parseUrl(urlValue)
+      if (options.base && urlValue[0] === '.') {
         // explicit base - directly rewrite relative urls into absolute url
         // to avoid generating extra imports
         // Allow for full hostnames provided in options.base
@@ -147,70 +165,113 @@ export const transformAssetUrl: NodeTransform = (
   }
 }
 
+/**
+ * Resolves or registers an import for the given source path
+ * @param source - Path to resolve import for
+ * @param loc - Source location
+ * @param context - Transform context
+ * @returns Object containing import name and expression
+ */
+function resolveOrRegisterImport(
+  source: string,
+  loc: SourceLocation,
+  context: TransformContext,
+): {
+  name: string
+  exp: SimpleExpressionNode
+} {
+  const normalizedSource = normalizeDecodedImportPath(source)
+  const existingIndex = context.imports.findIndex(
+    i => i.path === normalizedSource,
+  )
+  if (existingIndex > -1) {
+    return {
+      name: `_imports_${existingIndex}`,
+      exp: context.imports[existingIndex].exp as SimpleExpressionNode,
+    }
+  }
+
+  const name = `_imports_${context.imports.length}`
+  const exp = createSimpleExpression(
+    name,
+    false,
+    loc,
+    ConstantTypes.CAN_STRINGIFY,
+  )
+
+  // We need to ensure the path is not encoded (to %2F),
+  // so we decode it back in case it is encoded
+  context.imports.push({
+    exp,
+    path: normalizedSource,
+  })
+
+  return { name, exp }
+}
+
+/**
+ * Transforms asset URLs into import expressions or string literals
+ */
 function getImportsExpressionExp(
   path: string | null,
   hash: string | null,
   loc: SourceLocation,
   context: TransformContext,
 ): ExpressionNode {
-  if (path) {
-    let name: string
-    let exp: SimpleExpressionNode
-    const existingIndex = context.imports.findIndex(i => i.path === path)
-    if (existingIndex > -1) {
-      name = `_imports_${existingIndex}`
-      exp = context.imports[existingIndex].exp as SimpleExpressionNode
-    } else {
-      name = `_imports_${context.imports.length}`
-      exp = createSimpleExpression(
-        name,
-        false,
-        loc,
-        ConstantTypes.CAN_STRINGIFY,
-      )
-
-      // We need to ensure the path is not encoded (to %2F),
-      // so we decode it back in case it is encoded
-      context.imports.push({
-        exp,
-        path: decodeURIComponent(path),
-      })
-    }
+  // Neither path nor hash - return empty string
+  if (!path && !hash) {
+    return createSimpleExpression(`''`, false, loc, ConstantTypes.CAN_STRINGIFY)
+  }
 
-    if (!hash) {
-      return exp
-    }
+  // Only hash without path - treat hash as the import source (likely a subpath import)
+  if (!path && hash) {
+    const { exp } = resolveOrRegisterImport(hash, loc, context)
+    return exp
+  }
+
+  // Only path without hash - straightforward import
+  if (path && !hash) {
+    const { exp } = resolveOrRegisterImport(path, loc, context)
+    return exp
+  }
+
+  // At this point, we know we have both path and hash components
+  const { name } = resolveOrRegisterImport(path!, loc, context)
+
+  // Combine path import with hash
+  const hashExp = `${name} + '${hash}'`
+  const finalExp = createSimpleExpression(
+    hashExp,
+    false,
+    loc,
+    ConstantTypes.CAN_STRINGIFY,
+  )
 
-    const hashExp = `${name} + '${hash}'`
-    const finalExp = createSimpleExpression(
-      hashExp,
+  // No hoisting needed
+  if (!context.hoistStatic) {
+    return finalExp
+  }
+
+  // Check for existing hoisted expression
+  const existingHoistIndex = context.hoists.findIndex(h => {
+    return (
+      h &&
+      h.type === NodeTypes.SIMPLE_EXPRESSION &&
+      !h.isStatic &&
+      h.content === hashExp
+    )
+  })
+
+  // Return existing hoisted expression if found
+  if (existingHoistIndex > -1) {
+    return createSimpleExpression(
+      `_hoisted_${existingHoistIndex + 1}`,
       false,
       loc,
       ConstantTypes.CAN_STRINGIFY,
     )
-
-    if (!context.hoistStatic) {
-      return finalExp
-    }
-
-    const existingHoistIndex = context.hoists.findIndex(h => {
-      return (
-        h &&
-        h.type === NodeTypes.SIMPLE_EXPRESSION &&
-        !h.isStatic &&
-        h.content === hashExp
-      )
-    })
-    if (existingHoistIndex > -1) {
-      return createSimpleExpression(
-        `_hoisted_${existingHoistIndex + 1}`,
-        false,
-        loc,
-        ConstantTypes.CAN_STRINGIFY,
-      )
-    }
-    return context.hoist(finalExp)
-  } else {
-    return createSimpleExpression(`''`, false, loc, ConstantTypes.CAN_STRINGIFY)
   }
+
+  // Hoist the expression and return the hoisted expression
+  return context.hoist(finalExp)
 }

+ 16 - 5
packages/compiler-sfc/src/template/transformSrcset.ts

@@ -12,6 +12,7 @@ import {
   isDataUrl,
   isExternalUrl,
   isRelativeUrl,
+  normalizeDecodedImportPath,
   parseUrl,
 } from './templateUtils'
 import {
@@ -109,12 +110,14 @@ export const transformSrcset: NodeTransform = (
           const compoundExpression = createCompoundExpression([], attr.loc)
           imageCandidates.forEach(({ url, descriptor }, index) => {
             if (shouldProcessUrl(url)) {
-              const { path } = parseUrl(url)
-              let exp: SimpleExpressionNode
-              if (path) {
+              const { path, hash } = parseUrl(url)
+              const source = path ? path : hash
+              if (source) {
+                const normalizedSource = normalizeDecodedImportPath(source)
                 const existingImportsIndex = context.imports.findIndex(
-                  i => i.path === path,
+                  i => i.path === normalizedSource,
                 )
+                let exp: SimpleExpressionNode
                 if (existingImportsIndex > -1) {
                   exp = createSimpleExpression(
                     `_imports_${existingImportsIndex}`,
@@ -129,7 +132,15 @@ export const transformSrcset: NodeTransform = (
                     attr.loc,
                     ConstantTypes.CAN_STRINGIFY,
                   )
-                  context.imports.push({ exp, path })
+                  context.imports.push({ exp, path: normalizedSource })
+                }
+                if (path && hash) {
+                  exp = createSimpleExpression(
+                    `${exp.content} + '${hash}'`,
+                    false,
+                    attr.loc,
+                    ConstantTypes.CAN_STRINGIFY,
+                  )
                 }
                 compoundExpression.children.push(exp)
               }