Skip to content

Commit 496f1bb

Browse files
committed
Fix RemoteAsset false positives for hash URLs and variable lookups
- Add isHashUrl() to skip linting hash-only URLs (#anchor, #section) - Add startsWithVariableLookup() to skip linting when attribute value starts with a VariableLookup (can't statically analyze variables) - Add test cases for both patterns
1 parent b1bca3f commit 496f1bb

File tree

2 files changed

+57
-7
lines changed

2 files changed

+57
-7
lines changed

packages/theme-check-common/src/checks/remote-asset/index.spec.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -183,19 +183,39 @@ describe('Module: RemoteAsset', () => {
183183
);
184184
});
185185

186-
it('should report an offense for image drops without img_url filter', async () => {
186+
it('should not report an offense for variable-based URLs', async () => {
187187
const sourceCode = `
188188
<img src="{{ image }}">
189189
<img src="{{ image.src }}">
190+
<source src="{{ source.url }}" type="{{ source.mime_type }}">
191+
<source src="{{ video_source.url }}">
190192
`;
191193

192194
const offenses = await runLiquidCheck(RemoteAsset, sourceCode);
193-
expect(offenses).to.have.length(2);
194-
offenses.forEach((offense) => {
195-
expect(offense.message).to.equal(
196-
'Use one of the asset_url filters to serve assets for better performance.',
197-
);
198-
});
195+
expect(offenses).to.be.empty;
196+
});
197+
198+
it('should not report an offense for hash URLs', async () => {
199+
const sourceCode = `
200+
<link rel="expect" href="#MainContent" blocking="render">
201+
<link href="#section-header" rel="preload">
202+
<img src="#placeholder">
203+
`;
204+
205+
const offenses = await runLiquidCheck(RemoteAsset, sourceCode);
206+
expect(offenses).to.be.empty;
207+
});
208+
209+
it('should still report an offense for string literals without asset_url filter', async () => {
210+
const sourceCode = `
211+
<img src="{{ 'image.png' }}" />
212+
`;
213+
214+
const offenses = await runLiquidCheck(RemoteAsset, sourceCode);
215+
expect(offenses).to.have.length(1);
216+
expect(offenses[0].message).to.equal(
217+
'Use one of the asset_url filters to serve assets for better performance.',
218+
);
199219
});
200220

201221
it('should not report an offence if url is a shopify CDN', async () => {

packages/theme-check-common/src/checks/remote-asset/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,29 @@ function isLiquidVariable(node: LiquidHtmlNode | string): node is LiquidVariable
4343
return typeof node !== 'string' && node.type === NodeTypes.LiquidVariable;
4444
}
4545

46+
function isHashUrl(url: string): boolean {
47+
return url.startsWith('#');
48+
}
49+
50+
/**
51+
* Checks if the attribute value starts with a variable lookup.
52+
* When a value starts with a VariableLookup (e.g., {{ source.url }}, {{ image }}),
53+
* we can't statically analyze what it contains, so we skip linting.
54+
* This prevents false positives for cases like video_source.url which already
55+
* returns CDN-hosted URLs.
56+
*/
57+
function startsWithVariableLookup(attr: ValuedHtmlAttribute): boolean {
58+
const firstNode = attr.value[0];
59+
if (!firstNode) return false;
60+
61+
if (!isLiquidVariableOutput(firstNode)) return false;
62+
63+
const variable = firstNode.markup;
64+
if (!isLiquidVariable(variable)) return false;
65+
66+
return variable.expression.type === NodeTypes.VariableLookup;
67+
}
68+
4669
function isUrlHostedbyShopify(url: string, allowedDomains: string[] = []): boolean {
4770
if (/^\/cdn\//.test(url)) {
4871
return true;
@@ -157,6 +180,13 @@ export const RemoteAsset: LiquidCheckDefinition<typeof schema> = {
157180

158181
if (!urlAttribute) return;
159182

183+
const firstTextNode = urlAttribute.value.find(
184+
(node): node is TextNode => node.type === NodeTypes.TextNode,
185+
);
186+
if (firstTextNode && isHashUrl(firstTextNode.value)) return;
187+
188+
if (startsWithVariableLookup(urlAttribute)) return;
189+
160190
const isShopifyUrl = urlAttribute.value
161191
.filter((node): node is TextNode => node.type === NodeTypes.TextNode)
162192
.some((textNode) => isUrlHostedbyShopify(textNode.value, allowedDomains));

0 commit comments

Comments
 (0)