Skip to content
Draft
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
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 Down Expand Up @@ -170,8 +177,44 @@ export const UnclosedHTMLElement: LiquidCheckDefinition = {
}
}

// At the end, whatever is left in the stack is a reported offense.
for (const node of stack) {
unmatchedByIdentifier.set(identifier, stack);
}

// 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 like the bento-grid where an open tag is inside
// `{% if forloop.first %}` and the matching close is inside
// `{% if forloop.last %}` -- different conditions but semantically paired.
const allUnmatched = ([] as (HtmlElement | HtmlDanglingMarkerClose)[])
.concat(...unmatchedByIdentifier.values())
.sort((a, b) => a.position.start - b.position.start);

const crossBalancedStack = [] as (HtmlElement | HtmlDanglingMarkerClose)[];
for (const node of allUnmatched) {
if (node.type === NodeTypes.HtmlElement) {
crossBalancedStack.push(node);
} else if (
crossBalancedStack.length > 0 &&
getName(node) === getName(crossBalancedStack.at(-1)!) &&
crossBalancedStack.at(-1)!.type === NodeTypes.HtmlElement &&
node.type === NodeTypes.HtmlDanglingMarkerClose
) {
crossBalancedStack.pop();
} else {
crossBalancedStack.push(node);
}
}

// Build a set of nodes that were resolved by cross-identifier balancing
// so we can skip reporting them.
const crossBalancedRemaining = new Set(crossBalancedStack);

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

if (node.type === NodeTypes.HtmlDanglingMarkerClose) {
context.report({
message: `Closing tag does not have a matching opening tag for condition \`${identifier}\` in ${
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,48 @@ 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 () => {
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 not crash or timeout with cyclical nested block relationships', async () => {
const theme: MockTheme = {
'blocks/block-b.liquid': `
Expand Down
9 changes: 9 additions & 0 deletions packages/theme-check-common/src/utils/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ export function isInvalidPresetBlock(
}

const isPrivateBlockType = blockNode.type.startsWith('_');
const hasNoRootBlocks = rootLevelThemeBlocks.length === 0;

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

const isThemeInRootLevel = rootLevelThemeBlocks.some((block) => block.node.type === '@theme');
const needsExplicitRootBlock = isPrivateBlockType || !isThemeInRootLevel;
const isPresetInRootLevel = rootLevelThemeBlocks.some(
Expand Down
Loading