Skip to content

Commit 95c3356

Browse files
authored
fix(compiler-sfc): allow Node.js subpath imports patterns in asset urls (#13045)
close #9919
1 parent a2c1700 commit 95c3356

5 files changed

Lines changed: 223 additions & 70 deletions

File tree

packages/compiler-sfc/__tests__/templateTransformAssetUrl.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,42 @@ describe('compiler sfc: transform asset url', () => {
114114
expect(code).toMatch(`"xlink:href": "#myCircle"`)
115115
})
116116

117+
// #9919
118+
test('should transform subpath import paths', () => {
119+
const { code } = compileWithAssetUrls(
120+
`<img src="#src/assets/vue.svg" />` +
121+
`<img src="#/src/assets/vue.svg" />`,
122+
)
123+
expect(code).toContain(`_imports_0 from '#src/assets/vue.svg'`)
124+
expect(code).toContain(`_imports_1 from '#/src/assets/vue.svg'`)
125+
})
126+
127+
test('should not transform pure hash values for custom asset URL tags', () => {
128+
const { code } = compileWithAssetUrls(
129+
`<foo bar="#fragment" />` +
130+
`<foo bar="#src/assets/vue.svg" />` +
131+
`<foo bar="#/src/assets/vue.svg" />`,
132+
{
133+
tags: {
134+
foo: ['bar'],
135+
},
136+
},
137+
)
138+
139+
expect(code).toContain(`bar: "#fragment"`)
140+
expect(code).toContain(`bar: "#src/assets/vue.svg"`)
141+
expect(code).toContain(`bar: "#/src/assets/vue.svg"`)
142+
expect(code).not.toContain(`from '#fragment'`)
143+
expect(code).not.toContain(`from '#src/assets/vue.svg'`)
144+
expect(code).not.toContain(`from '#/src/assets/vue.svg'`)
145+
})
146+
147+
test('should not throw for malformed percent-encoding in asset paths', () => {
148+
const { code } = compileWithAssetUrls(`<img src="./foo%.png" />`)
149+
150+
expect(code).toContain(`import _imports_0 from './foo%.png'`)
151+
})
152+
117153
test('should allow for full base URLs, with paths', () => {
118154
const { code } = compileWithAssetUrls(`<img src="./logo.png" />`, {
119155
base: 'http://localhost:3000/src/',

packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,36 @@ describe('compiler sfc: transform srcset', () => {
106106
).code
107107
expect(code).toMatchSnapshot()
108108
})
109+
110+
test('should transform subpath import paths starting with #', () => {
111+
const code = compileWithSrcset(
112+
`<img srcset="#src/assets/vue.svg" />` +
113+
`<img srcset="#/src/assets/vue.svg 2x" />`,
114+
).code
115+
116+
expect(code).toContain(`_imports_0 from '#src/assets/vue.svg'`)
117+
expect(code).toContain(`_imports_1 from '#/src/assets/vue.svg'`)
118+
expect(code).toContain(`const _hoisted_1 = _imports_0`)
119+
expect(code).toContain(`const _hoisted_2 = _imports_1 + ' 2x'`)
120+
})
121+
122+
test('should preserve svg fragments in srcset URLs', () => {
123+
const code = compileWithSrcset(
124+
`<img srcset="./icons.svg#icon-heart" />` +
125+
`<img srcset="./icons.svg#icon-star 2x" />`,
126+
).code
127+
128+
expect(code).toContain(`_imports_0 from './icons.svg'`)
129+
expect(code).toContain(`const _hoisted_1 = _imports_0 + '#icon-heart'`)
130+
expect(code).toContain(
131+
`const _hoisted_2 = _imports_0 + '#icon-star' + ' 2x'`,
132+
)
133+
})
134+
135+
test('should not throw for malformed percent-encoding in srcset paths', () => {
136+
const code = compileWithSrcset(`<img srcset="./foo%.png 2x" />`).code
137+
138+
expect(code).toContain(`import _imports_0 from './foo%.png'`)
139+
expect(code).toContain(`const _hoisted_1 = _imports_0 + ' 2x'`)
140+
})
109141
})

packages/compiler-sfc/src/template/templateUtils.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { isString } from '@vue/shared'
33

44
export function isRelativeUrl(url: string): boolean {
55
const firstChar = url.charAt(0)
6-
return firstChar === '.' || firstChar === '~' || firstChar === '@'
6+
return (
7+
firstChar === '.' ||
8+
firstChar === '~' ||
9+
firstChar === '@' ||
10+
firstChar === '#'
11+
)
712
}
813

914
const externalRE = /^(?:https?:)?\/\//
@@ -16,6 +21,14 @@ export function isDataUrl(url: string): boolean {
1621
return dataUrlRE.test(url)
1722
}
1823

24+
export function normalizeDecodedImportPath(source: string): string {
25+
try {
26+
return decodeURIComponent(source)
27+
} catch {
28+
return source
29+
}
30+
}
31+
1932
/**
2033
* Parses string url into URL object.
2134
*/

packages/compiler-sfc/src/template/transformAssetUrl.ts

Lines changed: 125 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isDataUrl,
1414
isExternalUrl,
1515
isRelativeUrl,
16+
normalizeDecodedImportPath,
1617
parseUrl,
1718
} from './templateUtils'
1819
import { isArray } from '@vue/shared'
@@ -34,14 +35,20 @@ export interface AssetURLOptions {
3435
tags?: AssetURLTagConfig
3536
}
3637

38+
// Built-in attrs that always represent resource URLs. `use` is intentionally
39+
// omitted because its hash-only values may still be fragment references.
40+
const resourceUrlTagConfig: AssetURLTagConfig = {
41+
video: ['src', 'poster'],
42+
source: ['src'],
43+
img: ['src'],
44+
image: ['xlink:href', 'href'],
45+
}
46+
3747
export const defaultAssetUrlOptions: Required<AssetURLOptions> = {
3848
base: null,
3949
includeAbsolute: false,
4050
tags: {
41-
video: ['src', 'poster'],
42-
source: ['src'],
43-
img: ['src'],
44-
image: ['xlink:href', 'href'],
51+
...resourceUrlTagConfig,
4552
use: ['xlink:href', 'href'],
4653
},
4754
}
@@ -69,6 +76,10 @@ export const createAssetUrlTransformWithOptions = (
6976
(transformAssetUrl as Function)(node, context, options)
7077
}
7178

79+
function canTransformHashImport(tag: string, attrName: string): boolean {
80+
return !!resourceUrlTagConfig[tag]?.includes(attrName)
81+
}
82+
7283
/**
7384
* A `@vue/compiler-core` plugin that transforms relative asset urls into
7485
* either imports or absolute urls.
@@ -104,17 +115,24 @@ export const transformAssetUrl: NodeTransform = (
104115
if (
105116
attr.type !== NodeTypes.ATTRIBUTE ||
106117
!assetAttrs.includes(attr.name) ||
107-
!attr.value ||
108-
isExternalUrl(attr.value.content) ||
109-
isDataUrl(attr.value.content) ||
110-
attr.value.content[0] === '#' ||
111-
(!options.includeAbsolute && !isRelativeUrl(attr.value.content))
118+
!attr.value
112119
) {
113120
return
114121
}
115122

116-
const url = parseUrl(attr.value.content)
117-
if (options.base && attr.value.content[0] === '.') {
123+
const urlValue = attr.value.content
124+
const isHashOnlyValue = urlValue[0] === '#'
125+
if (
126+
isExternalUrl(urlValue) ||
127+
isDataUrl(urlValue) ||
128+
(isHashOnlyValue && !canTransformHashImport(node.tag, attr.name)) ||
129+
(!options.includeAbsolute && !isRelativeUrl(urlValue))
130+
) {
131+
return
132+
}
133+
134+
const url = parseUrl(urlValue)
135+
if (options.base && urlValue[0] === '.') {
118136
// explicit base - directly rewrite relative urls into absolute url
119137
// to avoid generating extra imports
120138
// Allow for full hostnames provided in options.base
@@ -147,70 +165,113 @@ export const transformAssetUrl: NodeTransform = (
147165
}
148166
}
149167

168+
/**
169+
* Resolves or registers an import for the given source path
170+
* @param source - Path to resolve import for
171+
* @param loc - Source location
172+
* @param context - Transform context
173+
* @returns Object containing import name and expression
174+
*/
175+
function resolveOrRegisterImport(
176+
source: string,
177+
loc: SourceLocation,
178+
context: TransformContext,
179+
): {
180+
name: string
181+
exp: SimpleExpressionNode
182+
} {
183+
const normalizedSource = normalizeDecodedImportPath(source)
184+
const existingIndex = context.imports.findIndex(
185+
i => i.path === normalizedSource,
186+
)
187+
if (existingIndex > -1) {
188+
return {
189+
name: `_imports_${existingIndex}`,
190+
exp: context.imports[existingIndex].exp as SimpleExpressionNode,
191+
}
192+
}
193+
194+
const name = `_imports_${context.imports.length}`
195+
const exp = createSimpleExpression(
196+
name,
197+
false,
198+
loc,
199+
ConstantTypes.CAN_STRINGIFY,
200+
)
201+
202+
// We need to ensure the path is not encoded (to %2F),
203+
// so we decode it back in case it is encoded
204+
context.imports.push({
205+
exp,
206+
path: normalizedSource,
207+
})
208+
209+
return { name, exp }
210+
}
211+
212+
/**
213+
* Transforms asset URLs into import expressions or string literals
214+
*/
150215
function getImportsExpressionExp(
151216
path: string | null,
152217
hash: string | null,
153218
loc: SourceLocation,
154219
context: TransformContext,
155220
): ExpressionNode {
156-
if (path) {
157-
let name: string
158-
let exp: SimpleExpressionNode
159-
const existingIndex = context.imports.findIndex(i => i.path === path)
160-
if (existingIndex > -1) {
161-
name = `_imports_${existingIndex}`
162-
exp = context.imports[existingIndex].exp as SimpleExpressionNode
163-
} else {
164-
name = `_imports_${context.imports.length}`
165-
exp = createSimpleExpression(
166-
name,
167-
false,
168-
loc,
169-
ConstantTypes.CAN_STRINGIFY,
170-
)
171-
172-
// We need to ensure the path is not encoded (to %2F),
173-
// so we decode it back in case it is encoded
174-
context.imports.push({
175-
exp,
176-
path: decodeURIComponent(path),
177-
})
178-
}
221+
// Neither path nor hash - return empty string
222+
if (!path && !hash) {
223+
return createSimpleExpression(`''`, false, loc, ConstantTypes.CAN_STRINGIFY)
224+
}
179225

180-
if (!hash) {
181-
return exp
182-
}
226+
// Only hash without path - treat hash as the import source (likely a subpath import)
227+
if (!path && hash) {
228+
const { exp } = resolveOrRegisterImport(hash, loc, context)
229+
return exp
230+
}
231+
232+
// Only path without hash - straightforward import
233+
if (path && !hash) {
234+
const { exp } = resolveOrRegisterImport(path, loc, context)
235+
return exp
236+
}
237+
238+
// At this point, we know we have both path and hash components
239+
const { name } = resolveOrRegisterImport(path!, loc, context)
240+
241+
// Combine path import with hash
242+
const hashExp = `${name} + '${hash}'`
243+
const finalExp = createSimpleExpression(
244+
hashExp,
245+
false,
246+
loc,
247+
ConstantTypes.CAN_STRINGIFY,
248+
)
183249

184-
const hashExp = `${name} + '${hash}'`
185-
const finalExp = createSimpleExpression(
186-
hashExp,
250+
// No hoisting needed
251+
if (!context.hoistStatic) {
252+
return finalExp
253+
}
254+
255+
// Check for existing hoisted expression
256+
const existingHoistIndex = context.hoists.findIndex(h => {
257+
return (
258+
h &&
259+
h.type === NodeTypes.SIMPLE_EXPRESSION &&
260+
!h.isStatic &&
261+
h.content === hashExp
262+
)
263+
})
264+
265+
// Return existing hoisted expression if found
266+
if (existingHoistIndex > -1) {
267+
return createSimpleExpression(
268+
`_hoisted_${existingHoistIndex + 1}`,
187269
false,
188270
loc,
189271
ConstantTypes.CAN_STRINGIFY,
190272
)
191-
192-
if (!context.hoistStatic) {
193-
return finalExp
194-
}
195-
196-
const existingHoistIndex = context.hoists.findIndex(h => {
197-
return (
198-
h &&
199-
h.type === NodeTypes.SIMPLE_EXPRESSION &&
200-
!h.isStatic &&
201-
h.content === hashExp
202-
)
203-
})
204-
if (existingHoistIndex > -1) {
205-
return createSimpleExpression(
206-
`_hoisted_${existingHoistIndex + 1}`,
207-
false,
208-
loc,
209-
ConstantTypes.CAN_STRINGIFY,
210-
)
211-
}
212-
return context.hoist(finalExp)
213-
} else {
214-
return createSimpleExpression(`''`, false, loc, ConstantTypes.CAN_STRINGIFY)
215273
}
274+
275+
// Hoist the expression and return the hoisted expression
276+
return context.hoist(finalExp)
216277
}

packages/compiler-sfc/src/template/transformSrcset.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
isDataUrl,
1313
isExternalUrl,
1414
isRelativeUrl,
15+
normalizeDecodedImportPath,
1516
parseUrl,
1617
} from './templateUtils'
1718
import {
@@ -109,12 +110,14 @@ export const transformSrcset: NodeTransform = (
109110
const compoundExpression = createCompoundExpression([], attr.loc)
110111
imageCandidates.forEach(({ url, descriptor }, index) => {
111112
if (shouldProcessUrl(url)) {
112-
const { path } = parseUrl(url)
113-
let exp: SimpleExpressionNode
114-
if (path) {
113+
const { path, hash } = parseUrl(url)
114+
const source = path ? path : hash
115+
if (source) {
116+
const normalizedSource = normalizeDecodedImportPath(source)
115117
const existingImportsIndex = context.imports.findIndex(
116-
i => i.path === path,
118+
i => i.path === normalizedSource,
117119
)
120+
let exp: SimpleExpressionNode
118121
if (existingImportsIndex > -1) {
119122
exp = createSimpleExpression(
120123
`_imports_${existingImportsIndex}`,
@@ -129,7 +132,15 @@ export const transformSrcset: NodeTransform = (
129132
attr.loc,
130133
ConstantTypes.CAN_STRINGIFY,
131134
)
132-
context.imports.push({ exp, path })
135+
context.imports.push({ exp, path: normalizedSource })
136+
}
137+
if (path && hash) {
138+
exp = createSimpleExpression(
139+
`${exp.content} + '${hash}'`,
140+
false,
141+
attr.loc,
142+
ConstantTypes.CAN_STRINGIFY,
143+
)
133144
}
134145
compoundExpression.children.push(exp)
135146
}

0 commit comments

Comments
 (0)