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..0a7b91871 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) ?? []; @@ -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 ${ @@ -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 ''; 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..4a848b698 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,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 () => { + 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': ` diff --git a/packages/theme-check-common/src/checks/valid-block-target/index.ts b/packages/theme-check-common/src/checks/valid-block-target/index.ts index 66905af39..f08f4c8cd 100644 --- a/packages/theme-check-common/src/checks/valid-block-target/index.ts +++ b/packages/theme-check-common/src/checks/valid-block-target/index.ts @@ -51,6 +51,7 @@ export const ValidBlockTarget: LiquidCheckDefinition = { rootLevelLocalBlocks, presetLevelBlocks, defaultLevelBlocks, + hasRootBlocksDeclaration, } = getBlocks(validSchema); if (rootLevelLocalBlocks.length > 0) return; @@ -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); @@ -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, diff --git a/packages/theme-check-common/src/utils/block.ts b/packages/theme-check-common/src/utils/block.ts index 442304c45..d87e60970 100644 --- a/packages/theme-check-common/src/utils/block.ts +++ b/packages/theme-check-common/src/utils/block.ts @@ -129,6 +129,7 @@ export function getBlocks(validSchema: ThemeBlock.Schema | Section.Schema) { rootLevelLocalBlocks, presetLevelBlocks, defaultLevelBlocks, + hasRootBlocksDeclaration: Array.isArray(rootLevelBlocks), }; } @@ -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( @@ -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( diff --git a/packages/theme-language-server-common/src/completions/providers/TranslationCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/TranslationCompletionProvider.ts index 087da363c..074ecc1cb 100644 --- a/packages/theme-language-server-common/src/completions/providers/TranslationCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/TranslationCompletionProvider.ts @@ -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