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