diff --git a/packages/theme-check-common/src/checks/matching-translations/index.spec.ts b/packages/theme-check-common/src/checks/matching-translations/index.spec.ts index 06f04313b..efea1e4e7 100644 --- a/packages/theme-check-common/src/checks/matching-translations/index.spec.ts +++ b/packages/theme-check-common/src/checks/matching-translations/index.spec.ts @@ -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' }), diff --git a/packages/theme-check-common/src/checks/matching-translations/index.ts b/packages/theme-check-common/src/checks/matching-translations/index.ts index b43054fe1..8d31472eb 100644 --- a/packages/theme-check-common/src/checks/matching-translations/index.ts +++ b/packages/theme-check-common/src/checks/matching-translations/index.ts @@ -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; @@ -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 @@ -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`, diff --git a/packages/theme-check-common/src/checks/unclosed-html-element/index.spec.ts b/packages/theme-check-common/src/checks/unclosed-html-element/index.spec.ts index f86453952..1ae137048 100644 --- a/packages/theme-check-common/src/checks/unclosed-html-element/index.spec.ts +++ b/packages/theme-check-common/src/checks/unclosed-html-element/index.spec.ts @@ -188,6 +188,61 @@ describe('Module: UnclosedHTMLElement', () => { expect(highlightedOffenses(file, offenses)).to.include(''); }); + 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 %} +
+ {% endif %} + +
{{ item }}
+ + {% if forloop.last or item_modulo == 0 %} +
+ {% endif %} + {% endfor %} + `, + // Simpler variant: different conditions but balanced open/close in same grandparent + ` +
+ {% if condition_a %} +
+ {% endif %} + + {% if condition_b %} +
+ {% endif %} +
+ `, + ]; + + 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 = ` +
+ {% if condition_a %} +
+ {% endif %} + + {% if condition_b %} + + {% endif %} +
+ `; + + const offenses = await runLiquidCheck(UnclosedHTMLElement, file); + expect(offenses).to.have.length(2); + expect(highlightedOffenses(file, offenses)).to.include('
'); + expect(highlightedOffenses(file, offenses)).to.include(''); + }); + it('should report offenses when conditions do not match', async () => { const file = `
diff --git a/packages/theme-check-common/src/checks/unclosed-html-element/index.ts b/packages/theme-check-common/src/checks/unclosed-html-element/index.ts index 68b0b7661..801540418 100644 --- a/packages/theme-check-common/src/checks/unclosed-html-element/index.ts +++ b/packages/theme-check-common/src/checks/unclosed-html-element/index.ts @@ -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) ?? []; @@ -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 ${ diff --git a/packages/theme-check-common/src/checks/valid-block-target/index.spec.ts b/packages/theme-check-common/src/checks/valid-block-target/index.spec.ts index 0a7705895..e9920cb20 100644 --- a/packages/theme-check-common/src/checks/valid-block-target/index.spec.ts +++ b/packages/theme-check-common/src/checks/valid-block-target/index.spec.ts @@ -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': ` diff --git a/packages/theme-check-common/src/utils/block.ts b/packages/theme-check-common/src/utils/block.ts index 442304c45..920849383 100644 --- a/packages/theme-check-common/src/utils/block.ts +++ b/packages/theme-check-common/src/utils/block.ts @@ -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(