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(