-
Notifications
You must be signed in to change notification settings - Fork 29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Space around class_list #418
base: main
Are you sure you want to change the base?
Changes from all commits
120e0fd
5a96df0
c976d9b
9f611e1
6e6394d
5af3b5d
b3c9033
95be3c4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
'@shopify/theme-check-common': minor | ||
'@shopify/theme-check-node': minor | ||
--- | ||
|
||
Add SpaceAroundClassList check |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import { expect, describe, it } from 'vitest'; | ||
import { SpaceAroundClassList } from '.'; | ||
import { runLiquidCheck, highlightedOffenses } from '../../test'; | ||
|
||
describe('Module: SpaceAroundClassList', () => { | ||
it('allows the happy path', async () => { | ||
const file = `<div class="{{ block.settings | class_list }}"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
|
||
expect(offenses).to.have.length(0); | ||
}); | ||
|
||
describe('before class_list', () => { | ||
it('should report an offense when missing a space before class_list', async () => { | ||
const file = `<div class="other-class{{ block.settings | class_list }}"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
|
||
expect(offenses).to.have.length(1); | ||
expect(offenses[0].message).toEqual( | ||
`Missing a space before using the class_list filter: 'other-class{{ block.settings | class_list'`, | ||
); | ||
|
||
const highlights = highlightedOffenses({ 'file.liquid': file }, offenses); | ||
expect(highlights).toEqual(['s']); | ||
}); | ||
|
||
it('supports classes on new lines', async () => { | ||
const file = `<div class=" | ||
other-class{{ block.settings | class_list }}"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
|
||
expect(offenses).to.have.length(1); | ||
expect(offenses[0].message).toEqual( | ||
`Missing a space before using the class_list filter: 'other-class{{ block.settings | class_list'`, | ||
); | ||
|
||
const highlights = highlightedOffenses({ 'file.liquid': file }, offenses); | ||
expect(highlights).toEqual(['s']); | ||
}); | ||
|
||
it('supports arbitrary whitespace', async () => { | ||
const file = `<div class="other-class{{block.settings | class_list}}"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
|
||
expect(offenses).to.have.length(1); | ||
expect(offenses[0].message).toEqual( | ||
`Missing a space before using the class_list filter: 'other-class{{block.settings | class_list'`, | ||
); | ||
|
||
const highlights = highlightedOffenses({ 'file.liquid': file }, offenses); | ||
expect(highlights).toEqual(['s']); | ||
}); | ||
|
||
it('catches between multiple class_list filters', async () => { | ||
const file = `<div class=" | ||
{{ block.settings.spacing | class_list }} | ||
oh-snap{{ block.settings.typography | class_list }}"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
expect(offenses).to.have.length(1); | ||
|
||
expect(offenses[0].message).toEqual( | ||
`Missing a space before using the class_list filter: 'oh-snap{{ block.settings.typography | class_list'`, | ||
); | ||
|
||
const highlights = highlightedOffenses({ 'file.liquid': file }, offenses); | ||
expect(highlights).toEqual(['p']); | ||
}); | ||
}); | ||
|
||
describe('after class_list', () => { | ||
it('should report an offense when missing a space after class_list', async () => { | ||
const file = `<div class="{{ block.settings | class_list }}other-class"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
|
||
expect(offenses).to.have.length(1); | ||
expect(offenses[0].message).toEqual( | ||
`Missing a space after using the class_list filter: 'block.settings | class_list }}other-class'`, | ||
); | ||
|
||
const highlights = highlightedOffenses({ 'file.liquid': file }, offenses); | ||
expect(highlights).toEqual(['o']); | ||
}); | ||
|
||
it('supports classes on new lines', async () => { | ||
const file = `<div class=" | ||
{{ block.settings | class_list }}other-class"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
|
||
expect(offenses).to.have.length(1); | ||
expect(offenses[0].message).toEqual( | ||
`Missing a space after using the class_list filter: 'block.settings | class_list }}other-class'`, | ||
); | ||
|
||
const highlights = highlightedOffenses({ 'file.liquid': file }, offenses); | ||
expect(highlights).toEqual(['o']); | ||
}); | ||
|
||
it('supports arbitrary whitespace', async () => { | ||
const file = `<div class="{{block.settings| class_list}}other-class"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
|
||
expect(offenses).to.have.length(1); | ||
expect(offenses[0].message).toEqual( | ||
`Missing a space after using the class_list filter: 'block.settings| class_list}}other-class'`, | ||
); | ||
|
||
const highlights = highlightedOffenses({ 'file.liquid': file }, offenses); | ||
expect(highlights).toEqual(['o']); | ||
}); | ||
|
||
it('catches between multiple class_list filters', async () => { | ||
const file = `<div class=" | ||
{{ block.settings.spacing | class_list }}oh-snap | ||
{{ block.settings.typography | class_list }}"> | ||
content | ||
</div> | ||
`; | ||
|
||
const offenses = await runLiquidCheck(SpaceAroundClassList, file); | ||
expect(offenses).to.have.length(1); | ||
|
||
expect(offenses[0].message).toEqual( | ||
`Missing a space after using the class_list filter: 'block.settings.spacing | class_list }}oh-snap'`, | ||
); | ||
|
||
const highlights = highlightedOffenses({ 'file.liquid': file }, offenses); | ||
expect(highlights).toEqual(['o']); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import { LiquidHtmlNode, NodeTypes } from '@shopify/liquid-html-parser'; | ||
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; | ||
|
||
export const SpaceAroundClassList: LiquidCheckDefinition = { | ||
meta: { | ||
code: 'SpaceAroundClassList', | ||
aliases: ['SpaceAroundClassList'], | ||
name: 'Space Around Class List', | ||
docs: { | ||
description: 'Warns you when there is no space before or after using the class_list filter', | ||
recommended: true, | ||
url: 'https://shopify.dev/docs/themes/tools/theme-check/checks/space-around-class_list', | ||
}, | ||
type: SourceCodeType.LiquidHtml, | ||
severity: Severity.ERROR, | ||
schema: {}, | ||
targets: [], | ||
}, | ||
|
||
create(context) { | ||
return { | ||
async LiquidFilter(node, ancestors) { | ||
if (node.name !== 'class_list') { | ||
return; | ||
} | ||
|
||
const classAttribute = ancestors.find( | ||
(ancestor) => | ||
ancestor.type === NodeTypes.AttrDoubleQuoted || | ||
ancestor.type === NodeTypes.AttrSingleQuoted, | ||
); | ||
|
||
if (!classAttribute) { | ||
return; | ||
} | ||
|
||
const classAttributeContent = | ||
classAttribute.source.slice(classAttribute.position.start, classAttribute.position.end) || | ||
''; | ||
|
||
// check for missing space after class_list | ||
const afterRegex = /([a-zA-Z0-9._-]+)\s*\|\s*class_list\s*}}([a-zA-Z0-9._-]+)/gm; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe we have valid cases, such as the following one where we want to raise an offense: <div class="{{ block.settings.spacing | class_list }}{{ shop.name }}other-class">content</div> It's a bit changeling to cover that case relying on the regex tho. Instead, I believe we may navigate in the AST and can check if the sibling node starts with a space: What do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah, another interesting case. I'm glad y'all know your liquid! Would it be correct then to say the filter's next sibling (if any) must be a TextNode that starts with whitespace? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that's my understanding as well :) |
||
const afterMatches = [...classAttributeContent.matchAll(afterRegex)]; | ||
|
||
for (const match of afterMatches) { | ||
if (match.index === undefined) { | ||
continue; | ||
} | ||
|
||
const styleSetting = getStyleSetting(ancestors); | ||
|
||
if (styleSetting !== match[1]) { | ||
continue; | ||
} | ||
|
||
const bracketIndex = match[0].indexOf('}}'); | ||
const errorPosition = classAttribute.position.start + match.index + bracketIndex + 2; | ||
|
||
context.report({ | ||
message: `Missing a space after using the class_list filter: '${match[0]}'`, | ||
startIndex: errorPosition, | ||
endIndex: errorPosition + 1, | ||
suggest: [ | ||
{ | ||
message: 'Add a space after the class_list filter', | ||
fix(corrector) { | ||
corrector.insert(errorPosition, ' '); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! |
||
}, | ||
}, | ||
], | ||
}); | ||
} | ||
|
||
// check for missing space before class_list | ||
const beforeRegex = /([a-zA-Z0-9._-]+){{\s*([a-zA-Z0-9._-]+)\s*\|\s*class_list/gm; | ||
const beforeMatches = [...classAttributeContent.matchAll(beforeRegex)]; | ||
|
||
for (const match of beforeMatches) { | ||
if (match.index === undefined) { | ||
continue; | ||
} | ||
|
||
const styleSetting = getStyleSetting(ancestors); | ||
|
||
if (styleSetting !== match[2]) { | ||
continue; | ||
} | ||
|
||
const liquidVariableOutput = ancestors.find( | ||
(ancestor) => ancestor.type === NodeTypes.LiquidVariableOutput, | ||
); | ||
|
||
if (!liquidVariableOutput) { | ||
continue; | ||
} | ||
|
||
const errorPosition = liquidVariableOutput?.position.start - 1; | ||
|
||
context.report({ | ||
message: `Missing a space before using the class_list filter: '${match[0]}'`, | ||
startIndex: errorPosition, | ||
endIndex: errorPosition + 1, | ||
suggest: [ | ||
{ | ||
message: 'Add a space before the class_list filter', | ||
fix(corrector) { | ||
corrector.insert(errorPosition + 1, ' '); | ||
}, | ||
}, | ||
], | ||
}); | ||
} | ||
}, | ||
}; | ||
}, | ||
}; | ||
|
||
function getStyleSetting(ancestors: LiquidHtmlNode[]) { | ||
const liquidVariable = ancestors.find((ancestor) => ancestor.type === NodeTypes.LiquidVariable); | ||
const liquidVariableContent = | ||
liquidVariable?.source.slice(liquidVariable.position.start, liquidVariable.position.end) || ''; | ||
|
||
return liquidVariableContent.split('|')[0]?.trim(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is general contribution docs, we should probably be a bit more specific about what this instruction is alluding to.