Skip to content
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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dirty-birds-warn.md
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
4 changes: 3 additions & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ If ever you want to see how the VS Code extension or playground would behave bef
theme-docs download
```

6. Proceed to debug the VS Code extension or playground as usual.
6. Note that you may have to delete the cached data directory (`~/Library/Caches/theme-liquid-docs-nodejs`) in order to see your changes.
Copy link
Contributor

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.

Suggested change
6. Note that you may have to delete the cached data directory (`~/Library/Caches/theme-liquid-docs-nodejs`) in order to see your changes.
6. Note when working on changes to theme-liquid-docs: You will have to delete the cached data directory (`~/Library/Caches/theme-liquid-docs-nodejs`) in order to see changes made to `packages/theme-check-docs-updater/data`.


7. Proceed to debug the VS Code extension or playground as usual.

## Submitting a Pull Request

Expand Down
2 changes: 2 additions & 0 deletions packages/theme-check-common/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { PaginationSize } from './pagination-size';
import { ParserBlockingScript } from './parser-blocking-script';
import { RemoteAsset } from './remote-asset';
import { RequiredLayoutThemeObject } from './required-layout-theme-object';
import { SpaceAroundClassList } from './space-around-class_list';
import { TranslationKeyExists } from './translation-key-exists';
import { UnclosedHTMLElement } from './unclosed-html-element';
import { UndefinedObject } from './undefined-object';
Expand Down Expand Up @@ -59,6 +60,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
ParserBlockingScript,
RemoteAsset,
RequiredLayoutThemeObject,
SpaceAroundClassList,
TranslationKeyExists,
UnclosedHTMLElement,
UndefinedObject,
Expand Down
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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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:

Screenshot 2024-07-22 at 08 48 34

What do you think?

Copy link
Author

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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, ' ');
Copy link
Contributor

Choose a reason for hiding this comment

The 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();
}
3 changes: 3 additions & 0 deletions packages/theme-check-node/configs/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ RemoteAsset:
RequiredLayoutThemeObject:
enabled: true
severity: 0
SpaceAroundClassList:
enabled: true
severity: 0
TranslationKeyExists:
enabled: true
severity: 0
Expand Down
3 changes: 3 additions & 0 deletions packages/theme-check-node/configs/recommended.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ RemoteAsset:
RequiredLayoutThemeObject:
enabled: true
severity: 0
SpaceAroundClassList:
enabled: true
severity: 0
TranslationKeyExists:
enabled: true
severity: 0
Expand Down
Loading