Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,33 @@ describe('Module: MatchingTranslations', async () => {
}
});

it('should not report offenses and ignore keys provided by customer accounts', async () => {
for (const prefix of ['', '.schema']) {
const theme = {
[`locales/en.default${prefix}.json`]: JSON.stringify({
hello: 'Hello',
customer_accounts: {
order: {
title: 'Order',
},
},
}),
[`locales/pt-BR${prefix}.json`]: JSON.stringify({
hello: 'Olá',
customer_accounts: {
profile: {
title: 'Perfil',
},
},
}),
};

const offenses = await check(theme, [MatchingTranslations]);

expect(offenses).to.be.of.length(0);
}
});

it('should not report offenses and ignore "*.schema.json" files', async () => {
const theme = {
'locales/en.default.json': JSON.stringify({ hello: 'Hello' }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export const MatchingTranslations: JSONCheckDefinition = {
const hasDefaultTranslations = () => defaultTranslations.size > 0;
const isTerminalNode = ({ type }: JSONNode) => type === 'Literal';
const isPluralizationNode = (node: PropertyNode) => PLURALIZATION_KEYS.has(node.key.value);
const isShopifyPath = (path: string) => path.startsWith('shopify.');
const isExternalPath = (path: string) =>
path.startsWith('shopify.') || path.startsWith('customer_accounts.');

const hasDefaultTranslation = (translationPath: string) =>
defaultTranslations.has(translationPath) ?? false;
Expand Down Expand Up @@ -129,7 +130,7 @@ export const MatchingTranslations: JSONCheckDefinition = {
if (!hasDefaultTranslations()) return;
if (isPluralizationNode(node)) return;
if (!isTerminalNode(node.value)) return;
if (isShopifyPath(path)) return;
if (isExternalPath(path)) return;

if (hasDefaultTranslation(path)) {
// As `path` is present, we remove it from the
Expand Down Expand Up @@ -158,7 +159,7 @@ export const MatchingTranslations: JSONCheckDefinition = {
const closest = closestTranslationKey(path);

if (isPluralizationPath(path)) return;
if (isShopifyPath(path)) return;
if (isExternalPath(path)) return;

context.report({
message: `The translation for '${path}' is missing`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,61 @@ describe('Module: UnclosedHTMLElement', () => {
expect(highlightedOffenses(file, offenses)).to.include('</summary>');
});

it('should not report for paired conditional open/close in for-loop boundary conditions', async () => {
const testCases = [
// Open on forloop.first, close on forloop.last
`
{% for item in items %}
{% if forloop.first or item_modulo == 1 %}
<div class="grid-row">
{% endif %}

<div class="grid-item">{{ item }}</div>

{% if forloop.last or item_modulo == 0 %}
</div>
{% endif %}
{% endfor %}
`,
// Simpler variant: different conditions but balanced open/close in same grandparent
`
<div>
{% if condition_a %}
<section>
{% endif %}

{% if condition_b %}
</section>
{% endif %}
</div>
`,
];

for (const file of testCases) {
const offenses = await runLiquidCheck(UnclosedHTMLElement, file);
expect(offenses, file).to.have.length(0);
}
});

it('should still report when cross-identifier tags do not balance by name', async () => {
const file = `
<div>
{% if condition_a %}
<section>
{% endif %}

{% if condition_b %}
</article>
{% endif %}
</div>
`;

const offenses = await runLiquidCheck(UnclosedHTMLElement, file);
expect(offenses).to.have.length(2);
expect(highlightedOffenses(file, offenses)).to.include('<section>');
expect(highlightedOffenses(file, offenses)).to.include('</article>');
});

it('should report offenses when conditions do not match', async () => {
const file = `
<div>
Expand Down
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess at runtime we still aren't sure if the this renders an unclosed html 🤔 But i guess we can't do anything about that.

{% if true %}
  <div class="grid-row">
{% endif %}

<div class="grid-item">{{ item }}</div>

{% if false %}
  </div>
{% endif %}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know 😢, this is the downside of this

Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ export const UnclosedHTMLElement: LiquidCheckDefinition = {

async onCodePathEnd() {
for (const [grandparent, stacks] of stacksByGrandparent) {
// First pass: match opens/closes within each condition identifier.
// Collect unmatched nodes with their identifier for cross-identifier balancing.
const unmatchedByIdentifier = new Map<
ConditionIdentifer,
(HtmlElement | HtmlDanglingMarkerClose)[]
>();

for (const identifier of stacks.identifiers) {
const openNodes = stacks.open.get(identifier) ?? [];
const closeNodes = stacks.close.get(identifier) ?? [];
Expand All @@ -154,24 +161,30 @@ export const UnclosedHTMLElement: LiquidCheckDefinition = {
// Then we're going to push on open and pop when the close match.
// If a close doesn't match,
// Then we'll push it onto the stack and everything after won't match.
const stack = [] as (HtmlElement | HtmlDanglingMarkerClose)[];
for (const node of nodes) {
if (node.type === NodeTypes.HtmlElement) {
stack.push(node);
} else if (
stack.length > 0 &&
getName(node) === getName(stack.at(-1)!) &&
stack.at(-1)!.type === NodeTypes.HtmlElement &&
node.type === NodeTypes.HtmlDanglingMarkerClose
) {
stack.pop();
} else {
stack.push(node);
}
}
unmatchedByIdentifier.set(identifier, balanceOpenCloseTags(nodes));
}

// Second pass: try to balance unmatched opens from one condition
// identifier against unmatched closes from sibling condition identifiers
// within the same grandparent.
//
// This handles patterns where an open tag is inside one condition
// (e.g. `{% if forloop.first %}`) and the matching close is inside a
// sibling condition (e.g. `{% if forloop.last %}`).
const allUnmatched = ([] as (HtmlElement | HtmlDanglingMarkerClose)[])
.concat(...unmatchedByIdentifier.values())
.sort((a, b) => a.position.start - b.position.start);

const crossBalancedStack = balanceOpenCloseTags(allUnmatched);

// Build a set of nodes still unmatched after cross-identifier balancing.
// Nodes NOT in this set were resolved and should not be reported.
const crossBalancedRemaining = new Set(crossBalancedStack);

for (const [identifier, unmatched] of unmatchedByIdentifier) {
for (const node of unmatched) {
if (!crossBalancedRemaining.has(node)) continue;

// At the end, whatever is left in the stack is a reported offense.
for (const node of stack) {
if (node.type === NodeTypes.HtmlDanglingMarkerClose) {
context.report({
message: `Closing tag does not have a matching opening tag for condition \`${identifier}\` in ${
Expand Down Expand Up @@ -284,6 +297,27 @@ function negateIdentifier(conditionIdentifier: ConditionIdentifer): ConditionIde
: `-${conditionIdentifier}`;
}

function balanceOpenCloseTags(
sortedNodes: (HtmlElement | HtmlDanglingMarkerClose)[],
): (HtmlElement | HtmlDanglingMarkerClose)[] {
const stack: (HtmlElement | HtmlDanglingMarkerClose)[] = [];
for (const node of sortedNodes) {
if (node.type === NodeTypes.HtmlElement) {
stack.push(node);
} else if (
stack.length > 0 &&
getName(node) === getName(stack.at(-1)!) &&
stack.at(-1)!.type === NodeTypes.HtmlElement &&
node.type === NodeTypes.HtmlDanglingMarkerClose
) {
stack.pop();
} else {
stack.push(node);
}
}
return stack;
}

function getName(node: LiquidHtmlNode) {
if (node.type === NodeTypes.HtmlElement || node.type === NodeTypes.HtmlDanglingMarkerClose) {
if (node.name.length === 0) return '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,75 @@ describe('Module: ValidBlockTarget', () => {
expect(offenses[0].uri).to.equal(`file:///${path}/slideshow.liquid`);
});

it(`should not report errors for local blocks in presets when no root-level blocks array exists (${path} bucket)`, async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this valid? Like does it render if we actually run shopify theme dev on it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh like if the block files are empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would complain that the files are empty but it shouldn't have any impact on the test for this. I'll double check though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't quite figure out what you were getting at here so feel free to poke me and I'll do a follow up

const theme: MockTheme = {
'blocks/_card.liquid': `
{% schema %}
{
"name": "Card",
"blocks": [
{ "type": "_title" },
{ "type": "_image" }
]
}
{% endschema %}
`,
'blocks/_title.liquid': '',
'blocks/_image.liquid': '',
[`${path}/cards.liquid`]: `
{% schema %}
{
"name": "Cards",
"presets": [
{
"name": "Default",
"blocks": {
"card-1": {
"type": "_card",
"blocks": {
"title-1": { "type": "_title" },
"image-1": { "type": "_image" }
}
}
}
}
]
}
{% endschema %}
`,
};

const offenses = await check(theme, [ValidBlockTarget]);
expect(offenses).to.be.empty;
});

it(`should still report errors for local blocks in presets when root-level blocks array contains only @app (${path} bucket)`, async () => {
const theme: MockTheme = {
'blocks/_card.liquid': '',
[`${path}/cards.liquid`]: `
{% schema %}
{
"name": "Cards",
"blocks": [
{ "type": "@app" }
],
"presets": [
{
"name": "Default",
"blocks": {
"card-1": { "type": "_card" }
}
}
]
}
{% endschema %}
`,
};

const offenses = await check(theme, [ValidBlockTarget]);
expect(offenses).not.to.be.empty;
});

it('should not crash or timeout with cyclical nested block relationships', async () => {
const theme: MockTheme = {
'blocks/block-b.liquid': `
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const ValidBlockTarget: LiquidCheckDefinition = {
rootLevelLocalBlocks,
presetLevelBlocks,
defaultLevelBlocks,
hasRootBlocksDeclaration,
} = getBlocks(validSchema);

if (rootLevelLocalBlocks.length > 0) return;
Expand Down Expand Up @@ -79,7 +80,15 @@ export const ValidBlockTarget: LiquidCheckDefinition = {
const blockId = 'id' in node ? node.id! : path.at(-2)!;
const isStaticBlock = !!node.static;

if (isInvalidPresetBlock(blockId, node, rootLevelThemeBlocks, staticBlockDefs)) {
if (
isInvalidPresetBlock(
blockId,
node,
rootLevelThemeBlocks,
staticBlockDefs,
hasRootBlocksDeclaration,
)
) {
const errorMessage = isStaticBlock
? `Could not find a static block of type "${node.type}" with id "${blockId}" in this file.`
: reportMissingThemeBlockDefinitionError(node);
Expand Down Expand Up @@ -110,7 +119,7 @@ export const ValidBlockTarget: LiquidCheckDefinition = {
defaultLevelBlocks.map(async ({ node, path }) => {
const typeNode = nodeAtPath(ast, path)! as LiteralNode;

if (isInvalidDefaultBlock(node, rootLevelThemeBlocks)) {
if (isInvalidDefaultBlock(node, rootLevelThemeBlocks, hasRootBlocksDeclaration)) {
reportWarning(
reportMissingThemeBlockDefinitionError(node),
offset,
Expand Down
16 changes: 16 additions & 0 deletions packages/theme-check-common/src/utils/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export function getBlocks(validSchema: ThemeBlock.Schema | Section.Schema) {
rootLevelLocalBlocks,
presetLevelBlocks,
defaultLevelBlocks,
hasRootBlocksDeclaration: Array.isArray(rootLevelBlocks),
};
}

Expand All @@ -137,12 +138,21 @@ export function isInvalidPresetBlock(
blockNode: Preset.Block,
rootLevelThemeBlocks: BlockDefNodeWithPath[],
staticBlockDefs: StaticBlockDef[],
hasRootBlocksDeclaration: boolean = true,
): boolean {
if (blockNode.static) {
return !staticBlockDefs.some((block) => block.type === blockNode.type && block.id === blockId);
}

const isPrivateBlockType = blockNode.type.startsWith('_');

// Local blocks referenced in presets are valid when the schema has no
// root-level blocks array. They reference block files (blocks/_name.liquid)
// directly and their existence is validated separately.
if (isPrivateBlockType && !hasRootBlocksDeclaration) {
return false;
}

const isThemeInRootLevel = rootLevelThemeBlocks.some((block) => block.node.type === '@theme');
const needsExplicitRootBlock = isPrivateBlockType || !isThemeInRootLevel;
const isPresetInRootLevel = rootLevelThemeBlocks.some(
Expand All @@ -155,8 +165,14 @@ export function isInvalidPresetBlock(
export function isInvalidDefaultBlock(
blockNode: Section.Block | ThemeBlock.Block,
rootLevelThemeBlocks: BlockDefNodeWithPath[],
hasRootBlocksDeclaration: boolean = true,
): boolean {
const isPrivateBlockType = blockNode.type.startsWith('_');

if (isPrivateBlockType && !hasRootBlocksDeclaration) {
return false;
}

const isThemeInRootLevel = rootLevelThemeBlocks.some((block) => block.node.type === '@theme');
const needsExplicitRootBlock = isPrivateBlockType || !isThemeInRootLevel;
const isDefaultInRootLevel = rootLevelThemeBlocks.some(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,15 @@ export class TranslationCompletionProvider implements Provider {
const translations = await this.getTranslationsForURI(params.textDocument.uri);
const partial = node.value;

// We only want to show standard translations to complete if the translation
// is prefixed by shopify. Otherwise it's too noisy.
const options = translationOptions(translations).filter(
(option) => !option.path[0]?.startsWith('shopify') || partial.startsWith('shopify'),
);
// Platform-provided translations (shopify.*, customer_accounts.*) are
// excluded from completions unless the user has already started typing
// that prefix. Otherwise they add too much noise.
const options = translationOptions(translations).filter((option) => {
const root = option.path[0] ?? '';
if (root.startsWith('shopify')) return partial.startsWith('shopify');
if (root.startsWith('customer_accounts')) return partial.startsWith('customer_accounts');
return true;
});

const [_currentNode, realAncestors] =
ast instanceof Error
Expand Down
Loading