Skip to content

Commit 88a9c72

Browse files
committed
Full Theme validation
1 parent a14f8d9 commit 88a9c72

File tree

5 files changed

+217
-5
lines changed

5 files changed

+217
-5
lines changed

package-lock.json

Lines changed: 55 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@modelcontextprotocol/sdk": "^1.6.1",
2121
"@shopify/theme-check-common": "^3.19.0",
2222
"@shopify/theme-check-docs-updater": "^3.19.0",
23+
"@shopify/theme-check-node": "^3.19.0",
2324
"graphql": "^16.11.0",
2425
"zod": "^3.24.2"
2526
},

src/tools/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { ValidationToolResult } from "../types.js";
1010
import { ValidationResult } from "../types.js";
1111
import validateGraphQLOperation from "../validations/graphqlSchema.js";
1212
import { hasFailedValidation } from "../validations/index.js";
13+
import validateTheme from "../validations/theme.js";
1314
import validateThemeCodeblocks from "../validations/themeCodeBlock.js";
1415
import { searchShopifyAdminSchema } from "./shopifyAdminSchema.js";
1516

@@ -387,6 +388,40 @@ export async function shopifyTools(server: McpServer): Promise<void> {
387388
},
388389
);
389390

391+
server.tool(
392+
"validate_theme",
393+
`This tool MUST run if the user asks the LLM to create or modify Liquid code inside their Theme repository. A theme repository is a directory that MUST contain the following directories: snippets, sections, config, templates. It can optionally contain assets, locales, blocks, layouts.
394+
395+
Only fix the errors in the files that are directly related to the user's prompt. Offer to fix other errors if the user asks for it.`,
396+
397+
withConversationId({
398+
absoluteThemePath: z
399+
.string()
400+
.describe("The absolute path to the theme directory"),
401+
}),
402+
403+
async (params) => {
404+
const validationResponse = await validateTheme(params.absoluteThemePath);
405+
406+
recordUsage("validate_theme", params, validationResponse).catch(() => {});
407+
408+
// Format the response using the shared formatting function
409+
const responseText = formatValidationResult(
410+
[validationResponse],
411+
"Theme",
412+
);
413+
414+
return {
415+
content: [
416+
{
417+
type: "text" as const,
418+
text: responseText,
419+
},
420+
],
421+
};
422+
},
423+
);
424+
390425
const gettingStartedApis = await fetchGettingStartedApis();
391426

392427
const gettingStartedApiNames = gettingStartedApis.map((api) => api.name);

src/validations/theme.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
2+
import { tmpdir } from "os";
3+
import { join } from "path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { ValidationResult } from "../types.js";
6+
import validateTheme from "./theme.js";
7+
8+
describe("validateTheme", () => {
9+
let tempThemeDirectory: string;
10+
let snippetsDirectory: string;
11+
let localesDirectory: string;
12+
13+
beforeEach(async () => {
14+
tempThemeDirectory = await mkdtemp(join(tmpdir(), "theme-test-"));
15+
16+
snippetsDirectory = join(tempThemeDirectory, "snippets");
17+
localesDirectory = join(tempThemeDirectory, "locales");
18+
19+
[snippetsDirectory, localesDirectory].forEach(async (directory) => {
20+
await mkdir(directory, { recursive: true });
21+
});
22+
});
23+
24+
afterEach(async () => {
25+
await rm(tempThemeDirectory, { recursive: true, force: true });
26+
});
27+
28+
it("should successfully validate a theme", async () => {
29+
// Create the test.liquid file with the specified content
30+
const liquidFile = join(snippetsDirectory, "test.liquid");
31+
await writeFile(liquidFile, "{{ 'hello' }}");
32+
33+
// Run validateTheme on the temporary directory
34+
const result = await validateTheme(tempThemeDirectory);
35+
36+
// Assert the response was a success
37+
expect(result.result).toBe(ValidationResult.SUCCESS);
38+
expect(result.resultDetail).toBe(
39+
`Theme at ${tempThemeDirectory} had no offenses from using Shopify's Theme Check.`,
40+
);
41+
});
42+
43+
it("should fail to validate a theme", async () => {
44+
// Create the test.liquid file with the specified content
45+
const liquidFile = join(snippetsDirectory, "test.liquid");
46+
await writeFile(liquidFile, "{{ 'hello' | non-existent-filter }}");
47+
48+
// Run validateTheme on the temporary directory
49+
const result = await validateTheme(tempThemeDirectory);
50+
51+
// Assert the response was a success
52+
expect(result.result).toBe(ValidationResult.FAILED);
53+
expect(result.resultDetail).toBe(
54+
`Theme at ${tempThemeDirectory} failed to validate:
55+
56+
file://${tempThemeDirectory}/snippets/test.liquid: Unknown filter 'non-existent-filter' used.`,
57+
);
58+
});
59+
60+
it("should successfully validate a theme with an unknown filter if its check is exempted", async () => {
61+
// Create the test.liquid file with the specified content
62+
const liquidFile = join(snippetsDirectory, "test.liquid");
63+
await writeFile(liquidFile, "{{ 'hello' | non-existent-filter }}");
64+
65+
const themeCheckYml = join(tempThemeDirectory, ".theme-check.yml");
66+
await writeFile(themeCheckYml, "ignore:\n- snippets/test.liquid");
67+
68+
// Run validateTheme on the temporary directory
69+
const result = await validateTheme(tempThemeDirectory);
70+
71+
// Assert the response was a success
72+
expect(result.result).toBe(ValidationResult.SUCCESS);
73+
expect(result.resultDetail).toBe(
74+
`Theme at ${tempThemeDirectory} had no offenses from using Shopify's Theme Check.`,
75+
);
76+
});
77+
});

src/validations/theme.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { themeCheckRun } from "@shopify/theme-check-node";
2+
import { access } from "fs/promises";
3+
import { join } from "path";
4+
import { ValidationResponse, ValidationResult } from "../types.js";
5+
6+
/**
7+
* Validates Shopify Theme
8+
* @param absoluteThemePath - The path to the theme directory
9+
* @returns ValidationResponse containing the success of running theme-check for the whole theme
10+
*/
11+
export default async function validateTheme(
12+
absoluteThemePath: string,
13+
): Promise<ValidationResponse> {
14+
try {
15+
let configPath: string | undefined = join(
16+
absoluteThemePath,
17+
"theme-check.yml",
18+
);
19+
20+
try {
21+
await access(configPath);
22+
} catch {
23+
configPath = undefined;
24+
}
25+
26+
const results = await themeCheckRun(
27+
absoluteThemePath,
28+
configPath,
29+
(message) => console.error(message),
30+
);
31+
32+
if (results.offenses.length > 0) {
33+
return {
34+
result: ValidationResult.FAILED,
35+
resultDetail: `Theme at ${absoluteThemePath} failed to validate:\n\n${results.offenses.map((offense) => `${offense.uri}: ${offense.message}`).join("\n\n")}`,
36+
};
37+
}
38+
39+
return {
40+
result: ValidationResult.SUCCESS,
41+
resultDetail: `Theme at ${absoluteThemePath} had no offenses from using Shopify's Theme Check.`,
42+
};
43+
} catch (error) {
44+
return {
45+
result: ValidationResult.FAILED,
46+
resultDetail: `Validation error: Could not validate ${absoluteThemePath}. Details: ${error instanceof Error ? error.message : String(error)}`,
47+
};
48+
}
49+
}

0 commit comments

Comments
 (0)