From d85fb01e6f22be9583c59889e68d15a6a8049c69 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 11 Dec 2024 11:44:09 +0100 Subject: [PATCH 01/25] wip --- .../src/server/startServer.ts | 4 +- .../src/common/completionProvider.ts | 38 +++++++++++++++++++ .../vscode-extension/src/node/extension.ts | 7 ++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/vscode-extension/src/common/completionProvider.ts diff --git a/packages/theme-language-server-common/src/server/startServer.ts b/packages/theme-language-server-common/src/server/startServer.ts index 06aa07082..8549c23f9 100644 --- a/packages/theme-language-server-common/src/server/startServer.ts +++ b/packages/theme-language-server-common/src/server/startServer.ts @@ -14,6 +14,7 @@ import { } from '@shopify/theme-check-common'; import { Connection, + DocumentSelector, FileOperationRegistrationOptions, InitializeResult, ShowDocumentRequest, @@ -268,6 +269,7 @@ export function startServer( save: true, openClose: true, }, + inlineCompletionProvider: true, // only available in 3.18 (not released yet) codeActionProvider: { codeActionKinds: [...CodeActionKinds], }, @@ -305,7 +307,7 @@ export function startServer( name: 'theme-language-server', version: VERSION, }, - }; + } as InitializeResult; return result; }); diff --git a/packages/vscode-extension/src/common/completionProvider.ts b/packages/vscode-extension/src/common/completionProvider.ts new file mode 100644 index 000000000..07154de16 --- /dev/null +++ b/packages/vscode-extension/src/common/completionProvider.ts @@ -0,0 +1,38 @@ +import { + InlineCompletionContext, + InlineCompletionItem, + InlineCompletionItemProvider, + Position, + TextDocument, +} from 'vscode'; +import { CancellationToken } from 'vscode-languageclient'; + +export default class LiquidCompletionProvider implements InlineCompletionItemProvider { + provideInlineCompletionItems( + document: TextDocument, + position: Position, + context: InlineCompletionContext, + token: CancellationToken, + ): InlineCompletionItem[] { + console.error('[SERVER]!!! inline completion >>>'); + console.error( + '[SERVER]!!! inline completion document -', + JSON.stringify(document as any, undefined, 2), + ); + console.error( + '[SERVER]!!! inline completion position -', + JSON.stringify(position as any, undefined, 2), + ); + console.error( + '[SERVER]!!! inline completion context -', + JSON.stringify(context as any, undefined, 2), + ); + console.error( + '[SERVER]!!! inline completion token -', + JSON.stringify(token as any, undefined, 2), + ); + console.error('[SERVER]!!! inline completion <<<'); + + return [new InlineCompletionItem('Hello from the inline completion provider')]; + } +} diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 67f58ce54..d26c7d9dc 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -11,6 +11,7 @@ import { import { documentSelectors } from '../common/constants'; import LiquidFormatter from '../common/formatter'; import { vscodePrettierFormat } from './formatter'; +import LiquidCompletionProvider from '../common/completionProvider'; const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); @@ -33,6 +34,12 @@ export async function activate(context: ExtensionContext) { new LiquidFormatter(vscodePrettierFormat), ), ); + context.subscriptions.push( + languages.registerInlineCompletionItemProvider( + [{ language: 'liquid' }], + new LiquidCompletionProvider(), + ), + ); await startServer(context); } From d28aecdfe3e862f7fa130ee900a08833c97e3beb Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 11 Dec 2024 12:55:20 +0100 Subject: [PATCH 02/25] (wip 2) calling the language model api --- .../src/common/completionProvider.ts | 118 ++++++++++++++---- 1 file changed, 92 insertions(+), 26 deletions(-) diff --git a/packages/vscode-extension/src/common/completionProvider.ts b/packages/vscode-extension/src/common/completionProvider.ts index 07154de16..2aabb6ce1 100644 --- a/packages/vscode-extension/src/common/completionProvider.ts +++ b/packages/vscode-extension/src/common/completionProvider.ts @@ -1,38 +1,104 @@ +/* eslint-disable no-empty */ +/* eslint-disable no-unused-vars */ + import { InlineCompletionContext, InlineCompletionItem, InlineCompletionItemProvider, + LanguageModelChatMessage, + lm, Position, + Range, TextDocument, } from 'vscode'; -import { CancellationToken } from 'vscode-languageclient'; +import { CancellationToken, CancellationTokenSource } from 'vscode-languageclient'; + +const ANNOTATION_PROMPT = `You are a code tutor who helps liquid developers learn to use modern Liquid features. + +Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: + +1. For loops that could be simplified using the new 'find' filter +2. Array operations that could use 'map', 'where', or other newer filters +3. Complex logic that could be simplified with 'case/when' +4. Instead of "array | where: field, value | first", use "array | find: field, value" +5. Your response must be a parsable json + +Format your response as a JSON object with the following structure: +{ + "range": { + "start": {"line": , "character": }, + "end": {"line": , "character": } + }, + "newCode": "The suggested code that will replace the current code", + "line": , + "suggestion": "Friendly explanation of how and why to use the new feature" +} + +Example respons (): +{ + "range": { + "start": {"line": 5, "character": 0}, + "end": {"line": 7, "character": 42} + }, + "newCode": "{% assign first_product = products | first %}", + "line": 5, + "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." +} +`; export default class LiquidCompletionProvider implements InlineCompletionItemProvider { - provideInlineCompletionItems( + async provideInlineCompletionItems( document: TextDocument, - position: Position, - context: InlineCompletionContext, - token: CancellationToken, - ): InlineCompletionItem[] { - console.error('[SERVER]!!! inline completion >>>'); - console.error( - '[SERVER]!!! inline completion document -', - JSON.stringify(document as any, undefined, 2), - ); - console.error( - '[SERVER]!!! inline completion position -', - JSON.stringify(position as any, undefined, 2), - ); - console.error( - '[SERVER]!!! inline completion context -', - JSON.stringify(context as any, undefined, 2), - ); - console.error( - '[SERVER]!!! inline completion token -', - JSON.stringify(token as any, undefined, 2), - ); - console.error('[SERVER]!!! inline completion <<<'); - - return [new InlineCompletionItem('Hello from the inline completion provider')]; + _position: Position, + _context: InlineCompletionContext, + _token: CancellationToken, + ) { + console.error('[SERVER] inline completion'); + + let [model] = await lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o', + }); + + function getVisibleCodeWithLineNumbers(document: TextDocument) { + let code = ''; + const lines = document.getText().split('\n'); + for (let i = 0; i < lines.length; i++) { + code += `${i + 1}: ${lines[i]}\n`; + } + return code; + } + + const codeWithLineNumbers = getVisibleCodeWithLineNumbers(document); + + const messages = [ + LanguageModelChatMessage.User(ANNOTATION_PROMPT), + LanguageModelChatMessage.User(codeWithLineNumbers), + ]; + + let accumulatedResponse = ''; + let annotation: any = {}; + + if (model) { + let chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token); + + for await (const fragment of chatResponse.text) { + accumulatedResponse += fragment; + + if (fragment.includes('}')) { + try { + annotation = JSON.parse(accumulatedResponse.replace('```json', '')); + accumulatedResponse = ''; + } catch (e) {} + } + } + } + + // const range = new Range( + // new Position(annotation.range.start.line - 1, annotation.range.start.character), + // new Position(annotation.range.end.line - 1, annotation.range.end.character), + // ); + + return [new InlineCompletionItem(annotation.newCode)]; } } From a7ea90144c7db138cc4b415e744a35d7025fed6d Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 11 Dec 2024 19:23:52 +0100 Subject: [PATCH 03/25] (wip 3) sidefix --- packages/vscode-extension/package.json | 16 ++ .../vscode-extension/src/node/extension.ts | 50 +++++- .../vscode-extension/src/node/sidekick.ts | 155 ++++++++++++++++++ 3 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 packages/vscode-extension/src/node/sidekick.ts diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 2fab6ec2a..f07dca3e9 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -99,8 +99,24 @@ { "command": "shopifyLiquid.runChecks", "title": "Liquid Theme Check: Run Checks" + }, + { + "command": "shopifyLiquid.sidefix", + "title": "Apply Sidekick suggestion" + }, + { + "command": "shopifyLiquid.sidekick", + "title": "✨ Sidekick" } ], + "menus": { + "editor/title": [ + { + "command": "shopifyLiquid.sidekick", + "group": "navigation" + } + ] + }, "configuration": { "title": "Shopify Liquid | Syntax Highlighting & Linter by Shopify", "properties": { diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index d26c7d9dc..885e717bf 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -1,6 +1,19 @@ import { FileStat, FileTuple, path as pathUtils } from '@shopify/theme-check-common'; import * as path from 'node:path'; -import { commands, ExtensionContext, languages, Uri, workspace } from 'vscode'; +import { + commands, + ExtensionContext, + LanguageModelChatMessage, + languages, + lm, + Position, + Range, + TextEditor, + TextEditorDecorationType, + Uri, + window, + workspace, +} from 'vscode'; import { DocumentSelector, LanguageClient, @@ -11,7 +24,7 @@ import { import { documentSelectors } from '../common/constants'; import LiquidFormatter from '../common/formatter'; import { vscodePrettierFormat } from './formatter'; -import LiquidCompletionProvider from '../common/completionProvider'; +import { showSidekickTipsDecoration } from './sidekick'; const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); @@ -35,11 +48,38 @@ export async function activate(context: ExtensionContext) { ), ); context.subscriptions.push( - languages.registerInlineCompletionItemProvider( - [{ language: 'liquid' }], - new LiquidCompletionProvider(), + commands.registerTextEditorCommand('shopifyLiquid.sidekick', async (textEditor: TextEditor) => { + const position = textEditor.selection.active; + + const analyzingDecoration = window.createTextEditorDecorationType({ + after: { + contentText: ` ✨ Analyzing...`, + color: 'grey', + fontStyle: 'italic', + }, + }); + + textEditor.setDecorations(analyzingDecoration, [{ range: new Range(position, position) }]); + + await showSidekickTipsDecoration(textEditor); + + analyzingDecoration.dispose(); + }), + ); + context.subscriptions.push( + commands.registerCommand( + 'shopifyLiquid.sidefix', + async (args: { range: vscode.Range; newCode: string }) => { + console.error('sidefix', args); // TODO + }, ), ); + // context.subscriptions.push( + // languages.registerInlineCompletionItemProvider( + // [{ language: 'liquid' }], + // new LiquidCompletionProvider(), + // ), + // ); await startServer(context); } diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts new file mode 100644 index 000000000..3b6290ac3 --- /dev/null +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -0,0 +1,155 @@ +import { + CancellationTokenSource, + DecorationOptions, + LanguageModelChatMessage, + LanguageModelChatResponse, + lm, + MarkdownString, + Position, + Range, + TextDocument, + TextEditor, + TextEditorDecorationType, + window, +} from 'vscode'; + +const PROMPT = `You are a code tutor who helps liquid developers learn to use modern Liquid features. + +Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: + +1. For loops that could be simplified using the new 'find' filter +2. Array operations that could use 'map', 'where', or other newer filters +3. Complex logic that could be simplified with 'case/when' +4. Instead of "array | where: field, value | first", use "array | find: field, value" +5. Your response must be a parsable json + +Your response must be only a valid and parsable JSON object (this is really important!) with the following structure: +{ + "range": { + "start": {"line": , "character": }, + "end": {"line": , "character": } + }, + "newCode": "The suggested code that will replace the current code", + "line": , + "suggestion": "Friendly explanation of how and why to use the new feature" +} + +Example respons: +{ + "range": { + "start": {"line": 5, "character": 0}, + "end": {"line": 7, "character": 42} + }, + "newCode": "{% assign first_product = products | first %}", + "line": 5, + "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." +} +`; + +function getVisibleCodeWithLineNumbers(textEditor: TextEditor) { + let code = ''; + + const lines = textEditor.document.getText().split('\n'); + + for (let i = 0; i < lines.length; i++) { + code += `${i + 1}: ${lines[i]}\n`; + } + + return code; +} + +export async function showSidekickTipsDecoration(textEditor: TextEditor) { + let [model] = await lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o', + }); + + const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor); + + const messages = [ + LanguageModelChatMessage.User(PROMPT), + LanguageModelChatMessage.User(codeWithLineNumbers), + ]; + + if (model) { + try { + let chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token); + + const jsonResponse = await parseChatResponse(chatResponse); + + applyDecoration(textEditor, jsonResponse); + } catch (e) { + console.error('Error during GPT-4o request', e); + } + } +} + +async function parseChatResponse(chatResponse: LanguageModelChatResponse) { + let accResponse = ''; + + for await (const fragment of chatResponse.text) { + accResponse += fragment; + if (fragment.includes('}')) { + try { + console.error('parse try', accResponse); + const response = JSON.parse(accResponse.replace('```json', '')); + + console.error('parse success', response); + return response; + } catch (e) { + // ingore; next iteration + } + } + } +} + +function applyDecoration( + editor: TextEditor, + annotation: { + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + newCode: string; + line: number; + suggestion: string; + }, +) { + const decorationType = window.createTextEditorDecorationType({ + after: { + contentText: `✨ ${annotation.suggestion.substring(0, 120) + '...'}`, + color: 'grey', + fontStyle: 'italic', + }, + }); + + const commandArgs = { + range: annotation.range, + newCode: annotation.newCode, + }; + + const hoverMessage = new MarkdownString( + `${annotation.suggestion} + \n\n[Quick fix](command:code-tutor.applySuggestion?${encodeURIComponent( + JSON.stringify(commandArgs), + )})`, + ); + + hoverMessage.isTrusted = true; + hoverMessage.supportHtml = true; + + const range = new Range( + new Position(annotation.range.start.line - 2, 0), + new Position( + annotation.range.start.line - 2, + editor.document.lineAt(annotation.range.start.line - 2).text.length, + ), + ); + + const decoration: DecorationOptions = { + range: range, + hoverMessage, + }; + + editor.setDecorations(decorationType, [decoration]); +} From de43c0de60101450889eac0bef7170c5fad3f9a9 Mon Sep 17 00:00:00 2001 From: Mathieu Perreault Date: Wed, 11 Dec 2024 15:06:32 -0500 Subject: [PATCH 04/25] Add LLM instructions file suggestion --- .../resources/llm-instructions.template | 31 +++++++ .../vscode-extension/src/node/extension.ts | 90 ++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 packages/vscode-extension/resources/llm-instructions.template diff --git a/packages/vscode-extension/resources/llm-instructions.template b/packages/vscode-extension/resources/llm-instructions.template new file mode 100644 index 000000000..80c48f59b --- /dev/null +++ b/packages/vscode-extension/resources/llm-instructions.template @@ -0,0 +1,31 @@ +You are a very experienced Shopify theme developer. You are tasked with writing high-quality Liquid code and JSON files. + +Remember the following important mindset when providing code, in the following order: +- Adherance to conventions and patterns in the rest of the codebase +- Simplicity +- Readability + +The theme folder structure is as follows: +/assets +/config +/layout +/locales +/sections +/snippets +/templates +/templates/customers +/templates/metaobject +Files can also be placed in the root directory. Subdirectories, other than the ones listed, aren't supported. + +Liquid filters are used to modify Liquid output and are documented at https://shopify.dev/docs/api/liquid/filters.txt. + +Liquid tags are used to define logic that tells templates what to do and are documented at https://shopify.dev/api/liquid/tags.txt. +Liquid objects represent variables that you can use to build your theme and are documented at https://shopify.dev/api/liquid/objects.txt. + +Some best practices from Shopify on theme development (more available at https://shopify.dev/docs/themes/best-practices.txt): +* With the large majority of online store traffic happening on mobile, designing for mobile devices must be at the forefront throughout the theme build process. +* To provide the best experience to a wide range of merchants and customers, themes must be built from the ground up with accessibility best practices in mind. +* Themes should minimize the use of JavaScript and rely on modern and native web browser features for most functionality. +* Use responsive images by using the `image_tag` filter. This filter returns a `srcset` for the image using a smart default set of widths. An example is `{{ product.featured_image | image_url: width: 2000 | image_tag }}`. + + diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 67f58ce54..58122e3f6 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -1,6 +1,6 @@ import { FileStat, FileTuple, path as pathUtils } from '@shopify/theme-check-common'; import * as path from 'node:path'; -import { commands, ExtensionContext, languages, Uri, workspace } from 'vscode'; +import { commands, ExtensionContext, languages, Uri, workspace, window } from 'vscode'; import { DocumentSelector, LanguageClient, @@ -16,9 +16,97 @@ const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); let client: LanguageClient | undefined; +async function isShopifyTheme(workspaceRoot: string): Promise { + try { + // Check for typical Shopify theme folders + const requiredFolders = ['sections', 'templates', 'assets', 'config']; + for (const folder of requiredFolders) { + const folderUri = Uri.file(path.join(workspaceRoot, folder)); + try { + await workspace.fs.stat(folderUri); + } catch { + return false; + } + } + return true; + } catch { + return false; + } +} + +function isCursor(): boolean { + // Check if we're running in Cursor's electron process + const processTitle = process.title.toLowerCase(); + const isElectronCursor = + processTitle.includes('cursor') && process.versions.electron !== undefined; + + // Check for Cursor-specific environment variables that are set by Cursor itself + const hasCursorEnv = + process.env.CURSOR_CHANNEL !== undefined || process.env.CURSOR_VERSION !== undefined; + + return isElectronCursor || hasCursorEnv; +} + +interface ConfigFile { + path: string; + templateName: string; + prompt: string; +} + +async function getConfigFileDetails(workspaceRoot: string): Promise { + if (isCursor()) { + return { + path: path.join(workspaceRoot, '.cursorrules'), + templateName: 'llm_instructions.template', + prompt: + 'Detected Shopify theme project in Cursor. Do you want a .cursorrules file to be created?', + }; + } + return { + path: path.join(workspaceRoot, '.github', 'copilot-instructions.md'), + templateName: 'llm_instructions.template', + prompt: + 'Detected Shopify theme project in VSCode. Do you want a Copilot instructions file to be created?', + }; +} + export async function activate(context: ExtensionContext) { const runChecksCommand = 'themeCheck/runChecks'; + if (workspace.workspaceFolders?.length) { + const workspaceRoot = workspace.workspaceFolders[0].uri.fsPath; + const instructionsConfig = await getConfigFileDetails(workspaceRoot); + + // Don't do anything if the file already exists + try { + await workspace.fs.stat(Uri.file(instructionsConfig.path)); + return; + } catch { + // File doesn't exist, continue + } + + if (await isShopifyTheme(workspaceRoot)) { + const response = await window.showInformationMessage(instructionsConfig.prompt, 'Yes', 'No'); + + if (response === 'Yes') { + // Create directory if it doesn't exist (needed for .github case) + const dir = path.dirname(instructionsConfig.path); + try { + await workspace.fs.createDirectory(Uri.file(dir)); + } catch { + // Directory might already exist, continue + } + + // Read the template file from the extension's resources + const templateContent = await workspace.fs.readFile( + Uri.file(context.asAbsolutePath(`resources/${instructionsConfig.templateName}`)), + ); + await workspace.fs.writeFile(Uri.file(instructionsConfig.path), templateContent); + console.log(`Wrote instructions file to ${instructionsConfig.path}`); + } + } + } + context.subscriptions.push( commands.registerCommand('shopifyLiquid.restart', () => restartServer(context)), ); From 228e7e53aaba0e1b515f7d3b40cd3b4f4f28bec0 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 11 Dec 2024 22:55:32 +0100 Subject: [PATCH 05/25] (wip 4) make sidekick a bit less ugly --- .../vscode-extension/src/node/extension.ts | 33 +++- .../vscode-extension/src/node/sidekick.ts | 141 ++++++++++-------- 2 files changed, 103 insertions(+), 71 deletions(-) diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 885e717bf..193715e97 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -24,11 +24,13 @@ import { import { documentSelectors } from '../common/constants'; import LiquidFormatter from '../common/formatter'; import { vscodePrettierFormat } from './formatter'; -import { showSidekickTipsDecoration } from './sidekick'; +import { getSidekickAnalysis } from './sidekick'; const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); let client: LanguageClient | undefined; +let editor: TextEditor | undefined; +let decorations: TextEditorDecorationType[] = []; export async function activate(context: ExtensionContext) { const runChecksCommand = 'themeCheck/runChecks'; @@ -49,8 +51,9 @@ export async function activate(context: ExtensionContext) { ); context.subscriptions.push( commands.registerTextEditorCommand('shopifyLiquid.sidekick', async (textEditor: TextEditor) => { - const position = textEditor.selection.active; + editor = textEditor; + const position = textEditor.selection.active; const analyzingDecoration = window.createTextEditorDecorationType({ after: { contentText: ` ✨ Analyzing...`, @@ -61,7 +64,10 @@ export async function activate(context: ExtensionContext) { textEditor.setDecorations(analyzingDecoration, [{ range: new Range(position, position) }]); - await showSidekickTipsDecoration(textEditor); + const decorations = await getSidekickAnalysis(textEditor); + decorations.forEach((decoration) => { + textEditor.setDecorations(decoration.type, [decoration.options]); + }); analyzingDecoration.dispose(); }), @@ -69,8 +75,25 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push( commands.registerCommand( 'shopifyLiquid.sidefix', - async (args: { range: vscode.Range; newCode: string }) => { - console.error('sidefix', args); // TODO + async (args: { range: Range; newCode: string }) => { + console.error('sidefix', args); + + const { range, newCode } = args; + + const aa = new Range( + new Position(range.start.line - 1, range.start.character), + new Position(range.end.line - 1, range.end.character), + ); + + editor?.edit((editBuilder) => { + try { + editBuilder.replace(aa, newCode); + decorations.forEach((decoration) => decoration.dispose()); + decorations = []; + } catch (e) { + console.log(e); + } + }); }, ), ); diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index 3b6290ac3..ae18f35ec 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -7,13 +7,13 @@ import { MarkdownString, Position, Range, - TextDocument, TextEditor, TextEditorDecorationType, window, } from 'vscode'; -const PROMPT = `You are a code tutor who helps liquid developers learn to use modern Liquid features. +const PROMPT = ` +You are a code tutor who helps liquid developers learn to use modern Liquid features. Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: @@ -46,42 +46,47 @@ Example respons: } `; -function getVisibleCodeWithLineNumbers(textEditor: TextEditor) { - let code = ''; - - const lines = textEditor.document.getText().split('\n'); - - for (let i = 0; i < lines.length; i++) { - code += `${i + 1}: ${lines[i]}\n`; - } +/** A sidekick decoration that provides code improvement suggestions */ +export interface SidekickDecoration { + /** The type defining the visual styling */ + type: TextEditorDecorationType; + /** The options specifying where and how to render the suggestion */ + options: DecorationOptions; +} - return code; +/** Represents a suggestion for improving Liquid code */ +export interface LiquidSuggestion { + /** Line number where this suggestion starts */ + line: number; + /** The range where this suggestion applies */ + range: Range; + /** The improved code that should replace the existing code */ + newCode: string; + /** Human-friendly explanation of the suggested improvement */ + suggestion: string; } -export async function showSidekickTipsDecoration(textEditor: TextEditor) { - let [model] = await lm.selectChatModels({ +export async function getSidekickAnalysis(textEditor: TextEditor): Promise { + const [model] = await lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o', }); - const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor); - - const messages = [ - LanguageModelChatMessage.User(PROMPT), - LanguageModelChatMessage.User(codeWithLineNumbers), - ]; - - if (model) { - try { - let chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token); + if (!model) { + return []; + } - const jsonResponse = await parseChatResponse(chatResponse); + try { + const messages = buildMessages(textEditor); + const chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token); + const jsonResponse = await parseChatResponse(chatResponse); - applyDecoration(textEditor, jsonResponse); - } catch (e) { - console.error('Error during GPT-4o request', e); - } + return buildSidekickDecorations(textEditor, jsonResponse); + } catch (err) { + console.error('[Sidekick] Error during language model request', err); } + + return []; } async function parseChatResponse(chatResponse: LanguageModelChatResponse) { @@ -89,67 +94,71 @@ async function parseChatResponse(chatResponse: LanguageModelChatResponse) { for await (const fragment of chatResponse.text) { accResponse += fragment; + if (fragment.includes('}')) { try { - console.error('parse try', accResponse); - const response = JSON.parse(accResponse.replace('```json', '')); - - console.error('parse success', response); - return response; - } catch (e) { + return JSON.parse(accResponse.replace('```json', '')); + } catch (_err) { // ingore; next iteration } } } } -function applyDecoration( +function buildSidekickDecorations( editor: TextEditor, - annotation: { - range: { - start: { line: number; character: number }; - end: { line: number; character: number }; - }; - newCode: string; - line: number; - suggestion: string; - }, -) { - const decorationType = window.createTextEditorDecorationType({ + suggestion: LiquidSuggestion, +): SidekickDecoration[] { + const type = window.createTextEditorDecorationType({ after: { - contentText: `✨ ${annotation.suggestion.substring(0, 120) + '...'}`, + contentText: `✨ ${suggestion.suggestion.substring(0, 120) + '...'}`, color: 'grey', fontStyle: 'italic', }, }); - const commandArgs = { - range: annotation.range, - newCode: annotation.newCode, + const range = new Range( + new Position(suggestion.range.start.line - 2, 0), + new Position( + suggestion.range.start.line - 2, + editor.document.lineAt(suggestion.range.start.line - 2).text.length, + ), + ); + + const options: DecorationOptions = { + range: range, + hoverMessage: createHoverMessage(suggestion), }; + return [{ type, options }]; +} + +function createHoverMessage(suggestion: LiquidSuggestion) { + const hoverUrlArgs = encodeURIComponent(JSON.stringify(suggestion)); const hoverMessage = new MarkdownString( - `${annotation.suggestion} - \n\n[Quick fix](command:code-tutor.applySuggestion?${encodeURIComponent( - JSON.stringify(commandArgs), - )})`, + `${suggestion.suggestion} + \n\n[Quick fix](command:shopifyLiquid.sidefix?${hoverUrlArgs})`, ); hoverMessage.isTrusted = true; hoverMessage.supportHtml = true; - const range = new Range( - new Position(annotation.range.start.line - 2, 0), - new Position( - annotation.range.start.line - 2, - editor.document.lineAt(annotation.range.start.line - 2).text.length, - ), - ); + return hoverMessage; +} - const decoration: DecorationOptions = { - range: range, - hoverMessage, - }; +function buildMessages(textEditor: TextEditor) { + const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor); - editor.setDecorations(decorationType, [decoration]); + return [ + LanguageModelChatMessage.User(PROMPT), + LanguageModelChatMessage.User(codeWithLineNumbers), + ]; +} + +function getVisibleCodeWithLineNumbers(textEditor: TextEditor) { + return textEditor.document + .getText() + .split('\n') + .map((line, index) => `${index + 1}: ${line}`) + .join('\n'); } From ffba9645a9a9f39f9c9d39f31f3f314a57c5bcd1 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 12 Dec 2024 00:04:21 +0100 Subject: [PATCH 06/25] (wip 5) make sidekick a bit less ugly (2) --- .../vscode-extension/src/node/extension.ts | 117 ++++++++--------- .../src/node/sidekick-messages.ts | 52 ++++++++ .../vscode-extension/src/node/sidekick.ts | 118 ++++++------------ 3 files changed, 154 insertions(+), 133 deletions(-) create mode 100644 packages/vscode-extension/src/node/sidekick-messages.ts diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 193715e97..f04b0eb16 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -3,15 +3,12 @@ import * as path from 'node:path'; import { commands, ExtensionContext, - LanguageModelChatMessage, languages, - lm, Position, Range, TextEditor, TextEditorDecorationType, Uri, - window, workspace, } from 'vscode'; import { @@ -24,13 +21,19 @@ import { import { documentSelectors } from '../common/constants'; import LiquidFormatter from '../common/formatter'; import { vscodePrettierFormat } from './formatter'; -import { getSidekickAnalysis } from './sidekick'; +import { + buildAnalyzingDecoration, + getSidekickAnalysis, + LiquidSuggestion, + log, + SidekickDecoration, +} from './sidekick'; const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); -let client: LanguageClient | undefined; -let editor: TextEditor | undefined; -let decorations: TextEditorDecorationType[] = []; +let $client: LanguageClient | undefined; +let $editor: TextEditor | undefined; +let $decorations: TextEditorDecorationType[] = []; export async function activate(context: ExtensionContext) { const runChecksCommand = 'themeCheck/runChecks'; @@ -40,7 +43,7 @@ export async function activate(context: ExtensionContext) { ); context.subscriptions.push( commands.registerCommand('shopifyLiquid.runChecks', () => { - client!.sendRequest('workspace/executeCommand', { command: runChecksCommand }); + $client!.sendRequest('workspace/executeCommand', { command: runChecksCommand }); }), ); context.subscriptions.push( @@ -51,51 +54,23 @@ export async function activate(context: ExtensionContext) { ); context.subscriptions.push( commands.registerTextEditorCommand('shopifyLiquid.sidekick', async (textEditor: TextEditor) => { - editor = textEditor; - - const position = textEditor.selection.active; - const analyzingDecoration = window.createTextEditorDecorationType({ - after: { - contentText: ` ✨ Analyzing...`, - color: 'grey', - fontStyle: 'italic', - }, - }); + $editor = textEditor; - textEditor.setDecorations(analyzingDecoration, [{ range: new Range(position, position) }]); + log('Sidekick is analyzing...'); - const decorations = await getSidekickAnalysis(textEditor); - decorations.forEach((decoration) => { - textEditor.setDecorations(decoration.type, [decoration.options]); - }); + // Show analyzing decoration + applyDecorations([buildAnalyzingDecoration(textEditor)]); - analyzingDecoration.dispose(); + // Show sidekick decorations + applyDecorations(await getSidekickAnalysis(textEditor)); }), ); context.subscriptions.push( - commands.registerCommand( - 'shopifyLiquid.sidefix', - async (args: { range: Range; newCode: string }) => { - console.error('sidefix', args); - - const { range, newCode } = args; - - const aa = new Range( - new Position(range.start.line - 1, range.start.character), - new Position(range.end.line - 1, range.end.character), - ); - - editor?.edit((editBuilder) => { - try { - editBuilder.replace(aa, newCode); - decorations.forEach((decoration) => decoration.dispose()); - decorations = []; - } catch (e) { - console.log(e); - } - }); - }, - ), + commands.registerCommand('shopifyLiquid.sidefix', async (suggestion: LiquidSuggestion) => { + log('Sidekick is fixing...'); + + applySuggestion(suggestion); + }), ); // context.subscriptions.push( // languages.registerInlineCompletionItemProvider( @@ -125,44 +100,44 @@ async function startServer(context: ExtensionContext) { documentSelector: documentSelectors as DocumentSelector, }; - client = new LanguageClient( + $client = new LanguageClient( 'shopifyLiquid', 'Theme Check Language Server', serverOptions, clientOptions, ); - client.onRequest('fs/readDirectory', async (uriString: string): Promise => { + $client.onRequest('fs/readDirectory', async (uriString: string): Promise => { const results = await workspace.fs.readDirectory(Uri.parse(uriString)); return results.map(([name, type]) => [pathUtils.join(uriString, name), type]); }); - client.onRequest('fs/readFile', async (uriString: string): Promise => { + $client.onRequest('fs/readFile', async (uriString: string): Promise => { const bytes = await workspace.fs.readFile(Uri.parse(uriString)); return Buffer.from(bytes).toString('utf8'); }); - client.onRequest('fs/stat', async (uriString: string): Promise => { + $client.onRequest('fs/stat', async (uriString: string): Promise => { return workspace.fs.stat(Uri.parse(uriString)); }); - client.start(); + $client.start(); } async function stopServer() { try { - if (client) { - await Promise.race([client.stop(), sleep(1000)]); + if ($client) { + await Promise.race([$client.stop(), sleep(1000)]); } } catch (e) { console.error(e); } finally { - client = undefined; + $client = undefined; } } async function restartServer(context: ExtensionContext) { - if (client) { + if ($client) { await stopServer(); } await startServer(context); @@ -185,3 +160,33 @@ async function getServerOptions(context: ExtensionContext): Promise decoration.dispose()); + $decorations = []; +} + +function applyDecorations(decorations: SidekickDecoration[]) { + disposeDecorations(); + + decorations.forEach((decoration) => { + $decorations.push(decoration.type); + $editor?.setDecorations(decoration.type, [decoration.options]); + }); +} + +function applySuggestion({ range, newCode }: LiquidSuggestion) { + $editor?.edit((textEditorEdit) => { + try { + const start = new Position(range.start.line - 1, range.start.character); + const end = range.end; + + textEditorEdit.replace(new Range(start, end), newCode); + } catch (err) { + log('Error during sidefix', err); + } + + disposeDecorations(); + }); +} diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts new file mode 100644 index 000000000..ad30efddf --- /dev/null +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -0,0 +1,52 @@ +import { LanguageModelChatMessage, TextEditor } from 'vscode'; + +const PROMPT = ` +You are a code tutor who helps liquid developers learn to use modern Liquid features. + +Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: + +1. For loops that could be simplified using the new 'find' filter +2. Array operations that could use 'map', 'where', or other newer filters +3. Complex logic that could be simplified with 'case/when' +4. Instead of "array | where: field, value | first", use "array | find: field, value" +5. Your response must be a parsable json + +Your response must be only a valid and parsable JSON object (this is really important!) with the following structure: +{ + "range": { + "start": {"line": , "character": }, + "end": {"line": , "character": } + }, + "newCode": "The suggested code that will replace the current code", + "line": , + "suggestion": "Friendly explanation of how and why to use the new feature" +} + +Example respons: +{ + "range": { + "start": {"line": 5, "character": 0}, + "end": {"line": 7, "character": 42} + }, + "newCode": "{% assign first_product = products | first %}", + "line": 5, + "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." +} +`; + +export function buildMessages(textEditor: TextEditor) { + const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor); + + return [ + LanguageModelChatMessage.User(PROMPT), + LanguageModelChatMessage.User(codeWithLineNumbers), + ]; +} + +function getVisibleCodeWithLineNumbers(textEditor: TextEditor) { + return textEditor.document + .getText() + .split('\n') + .map((line, index) => `${index + 1}: ${line}`) + .join('\n'); +} diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index ae18f35ec..9bfb1417a 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -1,7 +1,6 @@ import { CancellationTokenSource, DecorationOptions, - LanguageModelChatMessage, LanguageModelChatResponse, lm, MarkdownString, @@ -11,40 +10,7 @@ import { TextEditorDecorationType, window, } from 'vscode'; - -const PROMPT = ` -You are a code tutor who helps liquid developers learn to use modern Liquid features. - -Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: - -1. For loops that could be simplified using the new 'find' filter -2. Array operations that could use 'map', 'where', or other newer filters -3. Complex logic that could be simplified with 'case/when' -4. Instead of "array | where: field, value | first", use "array | find: field, value" -5. Your response must be a parsable json - -Your response must be only a valid and parsable JSON object (this is really important!) with the following structure: -{ - "range": { - "start": {"line": , "character": }, - "end": {"line": , "character": } - }, - "newCode": "The suggested code that will replace the current code", - "line": , - "suggestion": "Friendly explanation of how and why to use the new feature" -} - -Example respons: -{ - "range": { - "start": {"line": 5, "character": 0}, - "end": {"line": 7, "character": 42} - }, - "newCode": "{% assign first_product = products | first %}", - "line": 5, - "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." -} -`; +import { buildMessages } from './sidekick-messages'; /** A sidekick decoration that provides code improvement suggestions */ export interface SidekickDecoration { @@ -83,12 +49,30 @@ export async function getSidekickAnalysis(textEditor: TextEditor): Promise `${index + 1}: ${line}`) - .join('\n'); +function createTextEditorDecorationType(text: string) { + return window.createTextEditorDecorationType({ + after: { + contentText: ` ✨ ${text}...`, + color: 'grey', + fontStyle: 'italic', + }, + }); } From 9fec1ad12adcc3026dd9fc486e091c05c329c754 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 12 Dec 2024 17:34:40 +0900 Subject: [PATCH 07/25] Move loading state to title button (#670) --- packages/vscode-extension/package.json | 16 ++++++++++-- .../vscode-extension/src/node/extension.ts | 26 +++++++++---------- .../vscode-extension/src/node/sidekick.ts | 9 ------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index f07dca3e9..549cf119a 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -106,14 +106,26 @@ }, { "command": "shopifyLiquid.sidekick", - "title": "✨ Sidekick" + "title": "✨ Sidekick", + "enablement": "!shopifyLiquid.sidekick.isLoading" + }, + { + "command": "shopifyLiquid.sidekickLoading", + "title": "✨ Analyzing", + "enablement": "false" } ], "menus": { "editor/title": [ { "command": "shopifyLiquid.sidekick", - "group": "navigation" + "group": "navigation", + "when": "!shopifyLiquid.sidekick.isLoading" + }, + { + "command": "shopifyLiquid.sidekickLoading", + "group": "navigation", + "when": "shopifyLiquid.sidekick.isLoading" } ] }, diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index f04b0eb16..fdd34edc3 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -21,13 +21,7 @@ import { import { documentSelectors } from '../common/constants'; import LiquidFormatter from '../common/formatter'; import { vscodePrettierFormat } from './formatter'; -import { - buildAnalyzingDecoration, - getSidekickAnalysis, - LiquidSuggestion, - log, - SidekickDecoration, -} from './sidekick'; +import { getSidekickAnalysis, LiquidSuggestion, log, SidekickDecoration } from './sidekick'; const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); @@ -57,12 +51,18 @@ export async function activate(context: ExtensionContext) { $editor = textEditor; log('Sidekick is analyzing...'); - - // Show analyzing decoration - applyDecorations([buildAnalyzingDecoration(textEditor)]); - - // Show sidekick decorations - applyDecorations(await getSidekickAnalysis(textEditor)); + await Promise.all([ + commands.executeCommand('setContext', 'shopifyLiquid.sidekick.isLoading', true), + ]); + + try { + // Show sidekick decorations + applyDecorations(await getSidekickAnalysis(textEditor)); + } finally { + await Promise.all([ + commands.executeCommand('setContext', 'shopifyLiquid.sidekick.isLoading', false), + ]); + } }), ); context.subscriptions.push( diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index 9bfb1417a..5d53e47c4 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -95,15 +95,6 @@ export function log(message?: any, ...optionalParams: any[]) { console.error(` [Sidekick] ${message}`, ...optionalParams); } -export function buildAnalyzingDecoration(editor: TextEditor): SidekickDecoration { - const type = createTextEditorDecorationType('Analyzing'); - - const position = editor.selection.active; - const options = { range: new Range(position, position) }; - - return { type, options }; -} - function createHoverMessage(liquidSuggestion: LiquidSuggestion) { const hoverUrlArgs = encodeURIComponent(JSON.stringify(liquidSuggestion)); const hoverMessage = new MarkdownString( From e08c539ddfdcff0d0ab72bb39dd768750f5b19e6 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 12 Dec 2024 17:59:16 +0900 Subject: [PATCH 08/25] Support multiple suggestions from a single request (#671) --- .../src/node/sidekick-messages.ts | 47 ++++++++++++------- .../vscode-extension/src/node/sidekick.ts | 20 ++++++-- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index ad30efddf..35b707e81 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -11,26 +11,41 @@ Your job is to evaluate a block of code and suggest opportunities to use newer L 4. Instead of "array | where: field, value | first", use "array | find: field, value" 5. Your response must be a parsable json -Your response must be only a valid and parsable JSON object (this is really important!) with the following structure: +Your response must be only a valid and parsable JSON object (this is really important!), with the following structure: { - "range": { - "start": {"line": , "character": }, - "end": {"line": , "character": } - }, - "newCode": "The suggested code that will replace the current code", - "line": , - "suggestion": "Friendly explanation of how and why to use the new feature" + reasonIfNoSuggestions: "Explanation of why there are no suggestions", + suggestions: [ + { + "range": { + "start": {"line": , "character": }, + "end": {"line": , "character": } + }, + "newCode": "The suggested code that will replace the current code", + "line": , + "suggestion": "Friendly explanation of how and why to use the new feature" + } + ] } -Example respons: +Add one object to the suggestions array response per suggestion. Example response: { - "range": { - "start": {"line": 5, "character": 0}, - "end": {"line": 7, "character": 42} - }, - "newCode": "{% assign first_product = products | first %}", - "line": 5, - "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." + reasonIfNoSuggestions: null, + suggestions: [{ + "range": { + "start": {"line": 5, "character": 0}, + "end": {"line": 7, "character": 42} + }, + "newCode": "{% assign first_product = products | first %}", + "line": 5, + "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." + }] +} + +If you don't have any suggestions, add a "reasonIfNoSuggestions" with an explanation of why there are no suggestions. Example response: + +{ + reasonIfNoSuggestions: "The code already looks perfect!", + suggestions: [] } `; diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index 5d53e47c4..2bc6e6297 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -47,7 +47,21 @@ export async function getSidekickAnalysis(textEditor: TextEditor): Promise Date: Thu, 12 Dec 2024 10:05:30 +0100 Subject: [PATCH 09/25] Bring hover back --- packages/vscode-extension/src/node/sidekick.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index 2bc6e6297..eb7278c02 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -75,7 +75,7 @@ function buildSidekickDecoration( ): SidekickDecoration[] { const { suggestion, range } = liquidSuggestion; const type = createTextEditorDecorationType(suggestion.substring(0, 120)); - const line = range.start.line - 1; + const line = range.start.line - 2; const options = { range: new Range( new Position(line, 0), From 1320bbe4112486fda83d9415452ff35728d69b6c Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 12 Dec 2024 18:30:23 +0900 Subject: [PATCH 10/25] Support suggestions on selected code (#672) --- .../vscode-extension/src/node/sidekick-messages.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index 35b707e81..597d920b8 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -51,6 +51,7 @@ If you don't have any suggestions, add a "reasonIfNoSuggestions" with an explana export function buildMessages(textEditor: TextEditor) { const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor); + +`\n\nAdd a maximum of ${textEditor.selection.isEmpty ? 5 : 1} suggestions to the array.\n`; return [ LanguageModelChatMessage.User(PROMPT), @@ -59,9 +60,12 @@ export function buildMessages(textEditor: TextEditor) { } function getVisibleCodeWithLineNumbers(textEditor: TextEditor) { - return textEditor.document - .getText() + const selection = textEditor.selection; + const offset = selection.isEmpty ? 0 : selection.start.line; + const text = textEditor.document.getText(selection.isEmpty ? undefined : selection); + + return text .split('\n') - .map((line, index) => `${index + 1}: ${line}`) + .map((line, index) => `${index + 1 + offset}: ${line}`) .join('\n'); } From 56ef14fabce271c294c5adfdb6bd699c18986b80 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 12 Dec 2024 18:34:56 +0900 Subject: [PATCH 11/25] Hide suggestions on code change (#673) --- packages/vscode-extension/src/node/extension.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index fdd34edc3..a6c5443b8 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -79,6 +79,12 @@ export async function activate(context: ExtensionContext) { // ), // ); + context.subscriptions.push( + workspace.onDidChangeTextDocument(() => { + disposeDecorations(); + }), + ); + await startServer(context); } From 81ad9128d79f72d550a77b7be4d17fec18957b56 Mon Sep 17 00:00:00 2001 From: Mathieu Perreault Date: Wed, 11 Dec 2024 16:40:02 -0500 Subject: [PATCH 12/25] update llm instructions --- .../resources/llm-instructions.template | 19 +++++++++++-------- .../vscode-extension/src/node/extension.ts | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/vscode-extension/resources/llm-instructions.template b/packages/vscode-extension/resources/llm-instructions.template index 80c48f59b..286d7f660 100644 --- a/packages/vscode-extension/resources/llm-instructions.template +++ b/packages/vscode-extension/resources/llm-instructions.template @@ -5,8 +5,15 @@ Remember the following important mindset when providing code, in the following o - Simplicity - Readability +Some best practices from Shopify on theme development (more available at https://shopify.dev/docs/themes/best-practices.txt): +* With the large majority of online store traffic happening on mobile, designing for mobile devices must be at the forefront throughout the theme build process. +* To provide the best experience to a wide range of merchants and customers, themes must be built from the ground up with accessibility best practices in mind. +* Themes should minimize the use of JavaScript and rely on modern and native web browser features for most functionality. +* Use responsive images by using the `image_tag` filter. This filter returns a `srcset` for the image using a smart default set of widths. An example is `{{ product.featured_image | image_url: width: 2000 | image_tag }}`. + The theme folder structure is as follows: /assets +/blocks /config /layout /locales @@ -17,15 +24,11 @@ The theme folder structure is as follows: /templates/metaobject Files can also be placed in the root directory. Subdirectories, other than the ones listed, aren't supported. -Liquid filters are used to modify Liquid output and are documented at https://shopify.dev/docs/api/liquid/filters.txt. +Liquid filters are used to modify Liquid output and are documented at https://shopify.dev/docs/api/liquid/filters.txt. The full list of filters is "item_count_for_variant,line_items_for,class_list,link_to_type,link_to_vendor,sort_by,url_for_type,url_for_vendor,within,brightness_difference,color_brightness,color_contrast,color_darken,color_desaturate,color_difference,color_extract,color_lighten,color_mix,color_modify,color_saturate,color_to_hex,color_to_hsl,color_to_rgb,hex_to_rgba,hmac_sha1,hmac_sha256,md5,sha1,sha256,currency_selector,customer_login_link,customer_logout_link,customer_register_link,date,font_face,font_modify,font_url,default_errors,payment_button,payment_terms,time_tag,translate,inline_asset_content,json,abs,append,at_least,at_most,base64_decode,base64_encode,base64_url_safe_decode,base64_url_safe_encode,capitalize,ceil,compact,concat,default,divided_by,downcase,escape,escape_once,first,floor,join,last,lstrip,map,minus,modulo,newline_to_br,plus,prepend,remove,remove_first,remove_last,replace,replace_first,replace_last,reverse,round,rstrip,size,slice,sort,sort_natural,split,strip,strip_html,strip_newlines,sum,times,truncate,truncatewords,uniq,upcase,url_decode,url_encode,where,external_video_tag,external_video_url,image_tag,media_tag,model_viewer_tag,video_tag,metafield_tag,metafield_text,money,money_with_currency,money_without_currency,money_without_trailing_zeros,default_pagination,avatar,login_button,camelize,handleize,url_escape,url_param_escape,structured_data,highlight_active_tag,link_to_add_tag,link_to_remove_tag,link_to_tag,format_address,highlight,pluralize,article_img_url,asset_img_url,asset_url,collection_img_url,file_img_url,file_url,global_asset_url,image_url,img_tag,img_url,link_to,payment_type_img_url,payment_type_svg_tag,placeholder_svg_tag,preload_tag,product_img_url,script_tag,shopify_asset_url,stylesheet_tag,weight_with_unit" +* Liquid tags are used to define logic that tells templates what to do and are documented at https://shopify.dev/api/liquid/tags.txt. The full list of tags is "content_for,form,layout,assign,break,capture,case,comment,continue,cycle,decrement,echo,for,if,include,increment,raw,render,tablerow,unless,paginate,javascript,section,stylesheet,sections,style,else,liquid" +Liquid objects represent variables that you can use to build your theme and are documented at https://shopify.dev/api/liquid/objects.txt. The full list of objects is "media,address,collections,pages,all_products,app,discount,articles,article,block,blogs,blog,brand,cart,collection,brand_color,color,color_scheme,color_scheme_group,company_address,company,company_location,content_for_header,country,currency,customer,discount_allocation,discount_application,external_video,filter,filter_value_display,filter_value,focal_point,font,form,fulfillment,generic_file,gift_card,image,image_presentation,images,line_item,link,linklists,linklist,forloop,tablerowloop,localization,location,market,measurement,metafield,metaobject_definition,metaobject,metaobject_system,model,model_source,money,order,page,paginate,predictive_search,selling_plan_price_adjustment,product,product_option,product_option_value,swatch,variant,quantity_price_break,rating,recipient,recommendations,request,robots,group,rule,routes,script,search,section,selling_plan_allocation,selling_plan_allocation_price_adjustment,selling_plan_checkout_charge,selling_plan,selling_plan_group,selling_plan_group_option,selling_plan_option,shipping_method,shop,shop_locale,policy,store_availability,tax_line,taxonomy_category,theme,settings,template,transaction,unit_price_measurement,user,video,video_source,additional_checkout_buttons,all_country_option_tags,canonical_url,checkout,comment,content_for_additional_checkout_buttons,content_for_index,content_for_layout,country_option_tags,current_page,current_tags,form_errors,handle,page_description,page_image,page_title,part,pending_payment_instruction_input,powered_by_link,predictive_search_resources,quantity_rule,scripts,sitemap,sort_option,transaction_payment_details,user_agent" +When you are suggesting code, don't invent new filters, objects or tags. -Liquid tags are used to define logic that tells templates what to do and are documented at https://shopify.dev/api/liquid/tags.txt. -Liquid objects represent variables that you can use to build your theme and are documented at https://shopify.dev/api/liquid/objects.txt. -Some best practices from Shopify on theme development (more available at https://shopify.dev/docs/themes/best-practices.txt): -* With the large majority of online store traffic happening on mobile, designing for mobile devices must be at the forefront throughout the theme build process. -* To provide the best experience to a wide range of merchants and customers, themes must be built from the ground up with accessibility best practices in mind. -* Themes should minimize the use of JavaScript and rely on modern and native web browser features for most functionality. -* Use responsive images by using the `image_tag` filter. This filter returns a `srcset` for the image using a smart default set of widths. An example is `{{ product.featured_image | image_url: width: 2000 | image_tag }}`. diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 58122e3f6..c76f34f0f 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -57,14 +57,14 @@ async function getConfigFileDetails(workspaceRoot: string): Promise if (isCursor()) { return { path: path.join(workspaceRoot, '.cursorrules'), - templateName: 'llm_instructions.template', + templateName: 'llm-instructions.template', prompt: 'Detected Shopify theme project in Cursor. Do you want a .cursorrules file to be created?', }; } return { path: path.join(workspaceRoot, '.github', 'copilot-instructions.md'), - templateName: 'llm_instructions.template', + templateName: 'llm-instructions.template', prompt: 'Detected Shopify theme project in VSCode. Do you want a Copilot instructions file to be created?', }; From d12bc9da2bd06910bed40eb02eb4571e0c197a52 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 12 Dec 2024 16:09:36 +0100 Subject: [PATCH 13/25] (wip) updated prompt --- .../src/node/sidekick-messages.ts | 206 +++++++++++++----- .../vscode-extension/src/node/sidekick.ts | 8 +- 2 files changed, 160 insertions(+), 54 deletions(-) diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index 597d920b8..a500835b4 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -1,71 +1,173 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + import { LanguageModelChatMessage, TextEditor } from 'vscode'; -const PROMPT = ` -You are a code tutor who helps liquid developers learn to use modern Liquid features. +interface ThemeDirectory { + summary: string; +} -Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: +export function buildMessages(textEditor: TextEditor) { + console.error('>>>>>>>', textEditor.document.fileName); -1. For loops that could be simplified using the new 'find' filter -2. Array operations that could use 'map', 'where', or other newer filters -3. Complex logic that could be simplified with 'case/when' -4. Instead of "array | where: field, value | first", use "array | find: field, value" -5. Your response must be a parsable json + const fileType = getFileType(textEditor.document.fileName); -Your response must be only a valid and parsable JSON object (this is really important!), with the following structure: -{ - reasonIfNoSuggestions: "Explanation of why there are no suggestions", - suggestions: [ - { - "range": { - "start": {"line": , "character": }, - "end": {"line": , "character": } - }, - "newCode": "The suggested code that will replace the current code", - "line": , - "suggestion": "Friendly explanation of how and why to use the new feature" - } - ] -} + console.error(' type >>>>>>>', fileType); + + const newLocal = [basePrompt(textEditor), code(textEditor), themeArchitecture()]; + + console.error(' message >>>>>>>'); + console.error(newLocal.join('\n')); + console.error(' message <<<<<<<'); -Add one object to the suggestions array response per suggestion. Example response: -{ - reasonIfNoSuggestions: null, - suggestions: [{ - "range": { - "start": {"line": 5, "character": 0}, - "end": {"line": 7, "character": 42} - }, - "newCode": "{% assign first_product = products | first %}", - "line": 5, - "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." - }] + return newLocal.map((message) => LanguageModelChatMessage.User(message)); } -If you don't have any suggestions, add a "reasonIfNoSuggestions" with an explanation of why there are no suggestions. Example response: +function basePrompt(textEditor: TextEditor): string { + return ` + You are Sidekick, an AI assistant designed to help Liquid developers optimize Shopify themes. + + Your goal is to identify and suggest opportunities for improvement in the "## CODE", focusing on the following areas: + + - Enhancing readability, conciseness, and efficiency while maintaining the same functionality + - Leveraging new features in Liquid, including filters, tags, and objects + - Adhering to best practices recommended for Shopify theme development with Liquid + + Ensure the suggestions are specific, actionable, and align with the best practices in Liquid and Shopify theme development. + + Use the "## THEME ARCHITECTURE", the "## DOCS SUMMARY", and Shopify.dev context. Do not make up new information. + + Add a maximum of ${textEditor.selection.isEmpty ? 5 : 1} suggestions to the array.\n + + Your response must be exclusively a valid and parsable JSON object with the following structure: + + { + "reasonIfNoSuggestions": "Explanation of why there are no suggestions", + "suggestions": [ + { + "newCode": "", + "range": { + "start": { + "line": , + "character": + }, + "end": { + "line": , + "character": + } + }, + "line": , + "suggestion": "" + } + ] + } + + Example of valid response: -{ - reasonIfNoSuggestions: "The code already looks perfect!", - suggestions: [] + { + "reasonIfNoSuggestions": null, + "suggestions": [ + { + "newCode": "{% assign first_product = products | first %}", + "range": { + "start": { + "line": 5, + "character": 0 + }, + "end": { + "line": 7, + "character": 42 + } + }, + "line": 5, + "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." + } + ] + } + `; } -`; -export function buildMessages(textEditor: TextEditor) { - const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor); - +`\n\nAdd a maximum of ${textEditor.selection.isEmpty ? 5 : 1} suggestions to the array.\n`; +function themeArchitecture(): string { + return ` + ## THEME ARCHITECTURE - return [ - LanguageModelChatMessage.User(PROMPT), - LanguageModelChatMessage.User(codeWithLineNumbers), - ]; + ${Object.entries(THEME_ARCHITECTURE) + .map(([key, value]) => `- ${key}: ${value.summary}`) + .join('\n\n')} + `; } -function getVisibleCodeWithLineNumbers(textEditor: TextEditor) { +function code(textEditor: TextEditor) { const selection = textEditor.selection; const offset = selection.isEmpty ? 0 : selection.start.line; const text = textEditor.document.getText(selection.isEmpty ? undefined : selection); - return text - .split('\n') - .map((line, index) => `${index + 1 + offset}: ${line}`) - .join('\n'); + return ` + ## CODE + + ${text + .split('\n') + .map((line, index) => `${index + 1 + offset}: ${line}`) + .join('\n')} + `; +} + +function getFileType(path: string): string { + const pathWithoutFile = path.substring(0, path.lastIndexOf('/')); + const fileTypes = Object.keys(THEME_ARCHITECTURE); + + return fileTypes.find((type) => pathWithoutFile.endsWith(type)) || 'none'; } + +const THEME_ARCHITECTURE: { [key: string]: ThemeDirectory } = { + assets: { + summary: `Contains static files such as CSS, JavaScript, and images. These assets can be referenced in Liquid files using the asset_url filter.`, + }, + sections: { + summary: `Liquid files that define customizable sections of a page. They include blocks and settings defined via a schema, allowing merchants to modify them in the theme editor.`, + }, + blocks: { + summary: `Configurable elements within sections that can be added, removed, or reordered. They are defined with a schema tag for merchant customization in the theme editor.`, + }, + config: { + summary: `Holds settings data and schema for theme customization options like typography and colors, accessible through the Admin theme editor.`, + }, + layout: { + summary: `Defines the structure for repeated content such as headers and footers, wrapping other template files.`, + }, + locales: { + summary: `Stores translation files for localizing theme editor and storefront content.`, + }, + snippets: { + summary: `Reusable code fragments included in templates, sections, and layouts via the render tag. Ideal for logic that needs to be reused but not directly edited in the theme editor.`, + }, + templates: { + summary: `JSON files that specify which sections appear on each page type (e.g., product, collection, blog). They are wrapped by layout files for consistent header/footer content.`, + }, + 'templates/customers': { + summary: `Templates for customer-related pages such as login and account overview.`, + }, + 'templates/metaobject': { + summary: `Templates for rendering custom content types defined as metaobjects.`, + }, +}; + +// const PROMPT = ` +// You are a teacher who helps liquid developers learn to use modern Liquid features. + +// Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: + +// 1. For loops that could be simplified using the new 'find' filter +// 2. Array operations that could use 'map', 'where', or other newer filters +// 3. Complex logic that could be simplified with 'case/when' +// 4. Instead of "array | where: field, value | first", use "array | find: field, value" +// 5. Your response must be a parsable json + +// Add one object to the suggestions array response per suggestion. + +// If you don't have any suggestions, add a "reasonIfNoSuggestions" with an explanation of why there are no suggestions. Example response: + +// { +// reasonIfNoSuggestions: "The code already looks perfect!", +// suggestions: [] +// } +// `; diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index eb7278c02..ce36b24a4 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -75,7 +75,7 @@ function buildSidekickDecoration( ): SidekickDecoration[] { const { suggestion, range } = liquidSuggestion; const type = createTextEditorDecorationType(suggestion.substring(0, 120)); - const line = range.start.line - 2; + const line = Math.max(0, range.start.line - 2); const options = { range: new Range( new Position(line, 0), @@ -95,7 +95,11 @@ async function parseChatResponse(chatResponse: LanguageModelChatResponse) { if (fragment.includes('}')) { try { - return JSON.parse(accResponse.replace('```json', '')); + const parsedResponse = JSON.parse(accResponse.replace('```json', '')); + console.error(' parsedResponse >>>>>>>'); + console.error(JSON.stringify(parsedResponse, null, 2)); + console.error(' parsedResponse <<<<<<<'); + return parsedResponse; } catch (_err) { // ingore; next iteration } From f2619dad2a63979be2fa905fda944d53e978fc4e Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 12 Dec 2024 18:48:13 +0100 Subject: [PATCH 14/25] (wip) prompt --- .../images/sidekick-black.svg | 1 + .../images/sidekick-white.svg | 1 + .../src/node/sidekick-messages.ts | 469 +++++++++++++++--- .../vscode-extension/src/node/sidekick.ts | 7 +- 4 files changed, 410 insertions(+), 68 deletions(-) create mode 100644 packages/vscode-extension/images/sidekick-black.svg create mode 100644 packages/vscode-extension/images/sidekick-white.svg diff --git a/packages/vscode-extension/images/sidekick-black.svg b/packages/vscode-extension/images/sidekick-black.svg new file mode 100644 index 000000000..c9eb182d6 --- /dev/null +++ b/packages/vscode-extension/images/sidekick-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/vscode-extension/images/sidekick-white.svg b/packages/vscode-extension/images/sidekick-white.svg new file mode 100644 index 000000000..e8c83fb8a --- /dev/null +++ b/packages/vscode-extension/images/sidekick-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index a500835b4..7c9282482 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -1,42 +1,46 @@ +/* eslint-disable no-unused-vars */ /* eslint-disable @typescript-eslint/naming-convention */ import { LanguageModelChatMessage, TextEditor } from 'vscode'; -interface ThemeDirectory { - summary: string; -} - export function buildMessages(textEditor: TextEditor) { - console.error('>>>>>>>', textEditor.document.fileName); - - const fileType = getFileType(textEditor.document.fileName); - - console.error(' type >>>>>>>', fileType); - - const newLocal = [basePrompt(textEditor), code(textEditor), themeArchitecture()]; + const prompt = [ + basePrompt(textEditor), + code(textEditor), + codeContext(textEditor), + themeArchitectureContext(), + // -- deactivate this to make it faster + // docsContext(), + ]; console.error(' message >>>>>>>'); - console.error(newLocal.join('\n')); + console.error(prompt.join('\n')); console.error(' message <<<<<<<'); - return newLocal.map((message) => LanguageModelChatMessage.User(message)); + return prompt.map((message) => LanguageModelChatMessage.User(message)); } function basePrompt(textEditor: TextEditor): string { + const numberOfSuggestions = textEditor.selection.isEmpty ? 5 : 1; + return ` + ## INSTRUCTIONS (REALLY IMPORTANT) + You are Sidekick, an AI assistant designed to help Liquid developers optimize Shopify themes. Your goal is to identify and suggest opportunities for improvement in the "## CODE", focusing on the following areas: - Enhancing readability, conciseness, and efficiency while maintaining the same functionality - Leveraging new features in Liquid, including filters, tags, and objects - - Adhering to best practices recommended for Shopify theme development with Liquid + - Be pragmatic and don't suggest without a really good reason + - You should not suggest changes to the code that impact HTML -- they should be focused on Liquid + - You should not talk about whitespaces and the style of the code; leave that to the linter! Ensure the suggestions are specific, actionable, and align with the best practices in Liquid and Shopify theme development. - Use the "## THEME ARCHITECTURE", the "## DOCS SUMMARY", and Shopify.dev context. Do not make up new information. + Use the "## THEME ARCHITECTURE", "## CONTEXT", the "## DOCS", and Shopify.dev context. Do not make up new information. - Add a maximum of ${textEditor.selection.isEmpty ? 5 : 1} suggestions to the array.\n + Add a maximum of ${numberOfSuggestions} suggestions to the array. Your response must be exclusively a valid and parsable JSON object with the following structure: @@ -86,14 +90,14 @@ function basePrompt(textEditor: TextEditor): string { `; } -function themeArchitecture(): string { +function themeArchitectureContext(): string { return ` - ## THEME ARCHITECTURE +## THEME ARCHITECTURE - ${Object.entries(THEME_ARCHITECTURE) - .map(([key, value]) => `- ${key}: ${value.summary}`) - .join('\n\n')} - `; +${Object.entries(THEME_ARCHITECTURE) + .map(([key, value]) => `- ${key}: ${value.summary}`) + .join('\n\n')} +`; } function code(textEditor: TextEditor) { @@ -104,11 +108,11 @@ function code(textEditor: TextEditor) { return ` ## CODE - ${text - .split('\n') - .map((line, index) => `${index + 1 + offset}: ${line}`) - .join('\n')} - `; +${text + .split('\n') + .map((line, index) => `${index + 1 + offset}: ${line}`) + .join('\n')} +`; } function getFileType(path: string): string { @@ -118,56 +122,389 @@ function getFileType(path: string): string { return fileTypes.find((type) => pathWithoutFile.endsWith(type)) || 'none'; } -const THEME_ARCHITECTURE: { [key: string]: ThemeDirectory } = { - assets: { - summary: `Contains static files such as CSS, JavaScript, and images. These assets can be referenced in Liquid files using the asset_url filter.`, - }, +function codeContext(textEditor: TextEditor) { + const fileName = textEditor.document.fileName; + const fileType = getFileType(fileName); + const fileTip = THEME_ARCHITECTURE[fileType]?.tip ?? 'this is a regular Liquid file'; + + return ` + ## CONTEXT + + - file name: ${fileName} + - file type: ${fileType} + - file context: ${fileTip} +`; +} + +function docsContext() { + return ` + ## DOCS + + + #### LIQUID FILTERS + + - **NAME**: where **USAGE**: array | where: string, string + - **NAME**: reject **USAGE**: array | reject: string, string + - **NAME**: find **USAGE**: array | find: string, string + - **NAME**: find_index **USAGE**: array | find_index: string, string + - **NAME**: has **USAGE**: array | has: string, string + - **NAME**: item_count_for_variant **USAGE**: cart | item_count_for_variant: {variant_id} + - **NAME**: line_items_for **USAGE**: cart | line_items_for: object + - **NAME**: class_list **USAGE**: settings.layout | class_list + - **NAME**: link_to_type **USAGE**: string | link_to_type + - **NAME**: link_to_vendor **USAGE**: string | link_to_vendor + - **NAME**: sort_by **USAGE**: string | sort_by: string + - **NAME**: url_for_type **USAGE**: string | url_for_type + - **NAME**: url_for_vendor **USAGE**: string | url_for_vendor + - **NAME**: within **USAGE**: string | within: collection + - **NAME**: brightness_difference **USAGE**: string | brightness_difference: string + - **NAME**: color_brightness **USAGE**: string | color_brightness + - **NAME**: color_contrast **USAGE**: string | color_contrast: string + - **NAME**: color_darken **USAGE**: string | color_darken: number + - **NAME**: color_desaturate **USAGE**: string | color_desaturate: number + - **NAME**: color_difference **USAGE**: string | color_difference: string + - **NAME**: color_extract **USAGE**: string | color_extract: string + - **NAME**: color_lighten **USAGE**: string | color_lighten: number + - **NAME**: color_mix **USAGE**: string | color_mix: string, number + - **NAME**: color_modify **USAGE**: string | color_modify: string, number + - **NAME**: color_saturate **USAGE**: string | color_saturate: number + - **NAME**: color_to_hex **USAGE**: string | color_to_hex + - **NAME**: color_to_hsl **USAGE**: string | color_to_hsl + - **NAME**: color_to_rgb **USAGE**: string | color_to_rgb + - **NAME**: hex_to_rgba **USAGE**: string | hex_to_rgba + - **NAME**: hmac_sha1 **USAGE**: string | hmac_sha1: string + - **NAME**: hmac_sha256 **USAGE**: string | hmac_sha256: string + - **NAME**: md5 **USAGE**: string | md5 + - **NAME**: sha1 **USAGE**: string | sha1: string + - **NAME**: sha256 **USAGE**: string | sha256: string + - **NAME**: currency_selector **USAGE**: form | currency_selector + - **NAME**: customer_login_link **USAGE**: string | customer_login_link + - **NAME**: customer_logout_link **USAGE**: string | customer_logout_link + - **NAME**: customer_register_link **USAGE**: string | customer_register_link + - **NAME**: date **USAGE**: string | date: string + - **NAME**: font_face **USAGE**: font | font_face + - **NAME**: font_modify **USAGE**: font | font_modify: string, string + - **NAME**: font_url **USAGE**: font | font_url + - **NAME**: default_errors **USAGE**: string | default_errors + - **NAME**: payment_button **USAGE**: form | payment_button + - **NAME**: payment_terms **USAGE**: form | payment_terms + - **NAME**: time_tag **USAGE**: string | time_tag: string + - **NAME**: translate **USAGE**: string | t + - **NAME**: inline_asset_content **USAGE**: asset_name | inline_asset_content + - **NAME**: json **USAGE**: variable | json + - **NAME**: abs **USAGE**: number | abs + - **NAME**: append **USAGE**: string | append: string + - **NAME**: at_least **USAGE**: number | at_least + - **NAME**: at_most **USAGE**: number | at_most + - **NAME**: base64_decode **USAGE**: string | base64_decode + - **NAME**: base64_encode **USAGE**: string | base64_encode + - **NAME**: base64_url_safe_decode **USAGE**: string | base64_url_safe_decode + - **NAME**: base64_url_safe_encode **USAGE**: string | base64_url_safe_encode + - **NAME**: capitalize **USAGE**: string | capitalize + - **NAME**: ceil **USAGE**: number | ceil + - **NAME**: compact **USAGE**: array | compact + - **NAME**: concat **USAGE**: array | concat: array + - **NAME**: default **USAGE**: variable | default: variable + - **NAME**: divided_by **USAGE**: number | divided_by: number + - **NAME**: downcase **USAGE**: string | downcase + - **NAME**: escape **USAGE**: string | escape + - **NAME**: escape_once **USAGE**: string | escape_once + - **NAME**: first **USAGE**: array | first + - **NAME**: floor **USAGE**: number | floor + - **NAME**: join **USAGE**: array | join + - **NAME**: last **USAGE**: array | last + - **NAME**: lstrip **USAGE**: string | lstrip + - **NAME**: map **USAGE**: array | map: string + - **NAME**: minus **USAGE**: number | minus: number + - **NAME**: modulo **USAGE**: number | modulo: number + - **NAME**: newline_to_br **USAGE**: string | newline_to_br + - **NAME**: plus **USAGE**: number | plus: number + - **NAME**: prepend **USAGE**: string | prepend: string + - **NAME**: remove **USAGE**: string | remove: string + - **NAME**: remove_first **USAGE**: string | remove_first: string + - **NAME**: remove_last **USAGE**: string | remove_last: string + - **NAME**: replace **USAGE**: string | replace: string, string + - **NAME**: replace_first **USAGE**: string | replace_first: string, string + - **NAME**: replace_last **USAGE**: string | replace_last: string, string + - **NAME**: reverse **USAGE**: array | reverse + - **NAME**: round **USAGE**: number | round + - **NAME**: rstrip **USAGE**: string | rstrip + - **NAME**: size **USAGE**: variable | size + - **NAME**: slice **USAGE**: string | slice + - **NAME**: sort **USAGE**: array | sort + - **NAME**: sort_natural **USAGE**: array | sort_natural + - **NAME**: split **USAGE**: string | split: string + - **NAME**: strip **USAGE**: string | strip + - **NAME**: strip_html **USAGE**: string | strip_html + - **NAME**: strip_newlines **USAGE**: string | strip_newlines + - **NAME**: sum **USAGE**: array | sum + - **NAME**: times **USAGE**: number | times: number + - **NAME**: truncate **USAGE**: string | truncate: number + - **NAME**: truncatewords **USAGE**: string | truncatewords: number + - **NAME**: uniq **USAGE**: array | uniq + - **NAME**: upcase **USAGE**: string | upcase + - **NAME**: url_decode **USAGE**: string | url_decode + - **NAME**: url_encode **USAGE**: string | url_encode + - **NAME**: external_video_tag **USAGE**: variable | external_video_tag + - **NAME**: external_video_url **USAGE**: media | external_video_url: attribute: string + - **NAME**: image_tag **USAGE**: string | image_tag + - **NAME**: media_tag **USAGE**: media | media_tag + - **NAME**: model_viewer_tag **USAGE**: media | model_viewer_tag + - **NAME**: video_tag **USAGE**: media | video_tag + - **NAME**: metafield_tag **USAGE**: metafield | metafield_tag + - **NAME**: metafield_text **USAGE**: metafield | metafield_text + - **NAME**: money **USAGE**: number | money + - **NAME**: money_with_currency **USAGE**: number | money_with_currency + - **NAME**: money_without_currency **USAGE**: number | money_without_currency + - **NAME**: money_without_trailing_zeros **USAGE**: number | money_without_trailing_zeros + - **NAME**: default_pagination **USAGE**: paginate | default_pagination + - **NAME**: avatar **USAGE**: customer | avatar + - **NAME**: login_button **USAGE**: shop | login_button + - **NAME**: camelize **USAGE**: string | camelize + - **NAME**: handleize **USAGE**: string | handleize + - **NAME**: url_escape **USAGE**: string | url_escape + - **NAME**: url_param_escape **USAGE**: string | url_param_escape + - **NAME**: structured_data **USAGE**: variable | structured_data + - **NAME**: highlight_active_tag **USAGE**: string | highlight_active_tag + - **NAME**: link_to_add_tag **USAGE**: string | link_to_add_tag + - **NAME**: link_to_remove_tag **USAGE**: string | link_to_remove_tag + - **NAME**: link_to_tag **USAGE**: string | link_to_tag + - **NAME**: format_address **USAGE**: address | format_address + - **NAME**: highlight **USAGE**: string | highlight: string + - **NAME**: pluralize **USAGE**: number | pluralize: string, string + - **NAME**: article_img_url **USAGE**: variable | article_img_url + - **NAME**: asset_img_url **USAGE**: string | asset_img_url + - **NAME**: asset_url **USAGE**: string | asset_url + - **NAME**: collection_img_url **USAGE**: variable | collection_img_url + - **NAME**: file_img_url **USAGE**: string | file_img_url + - **NAME**: file_url **USAGE**: string | file_url + - **NAME**: global_asset_url **USAGE**: string | global_asset_url + - **NAME**: image_url **USAGE**: variable | image_url: width: number, height: number + - **NAME**: img_tag **USAGE**: string | img_tag + - **NAME**: img_url **USAGE**: variable | img_url + - **NAME**: link_to **USAGE**: string | link_to: string + - **NAME**: payment_type_img_url **USAGE**: string | payment_type_img_url + - **NAME**: payment_type_svg_tag **USAGE**: string | payment_type_svg_tag + - **NAME**: placeholder_svg_tag **USAGE**: string | placeholder_svg_tag + - **NAME**: preload_tag **USAGE**: string | preload_tag: as: string + - **NAME**: product_img_url **USAGE**: variable | product_img_url + - **NAME**: script_tag **USAGE**: string | script_tag + - **NAME**: shopify_asset_url **USAGE**: string | shopify_asset_url + - **NAME**: stylesheet_tag **USAGE**: string | stylesheet_tag + - **NAME**: weight_with_unit **USAGE**: number | weight_with_unit + + + #### LIQUID TAGS + + - **NAME**: content_for + - **NAME**: form + - **NAME**: layout + - **NAME**: assign + - **NAME**: break + - **NAME**: capture + - **NAME**: case + - **NAME**: comment + - **NAME**: continue + - **NAME**: cycle + - **NAME**: decrement + - **NAME**: echo + - **NAME**: for + - **NAME**: if + - **NAME**: include + - **NAME**: increment + - **NAME**: raw + - **NAME**: render + - **NAME**: tablerow + - **NAME**: unless + - **NAME**: paginate + - **NAME**: javascript + - **NAME**: section + - **NAME**: stylesheet + - **NAME**: sections + - **NAME**: style + - **NAME**: else + - **NAME**: else + - **NAME**: liquid + + + #### LIQUID OBJECTS + + - **NAME**: media + - **NAME**: address + - **NAME**: collections + - **NAME**: pages + - **NAME**: all_products + - **NAME**: app + - **NAME**: discount + - **NAME**: articles + - **NAME**: article + - **NAME**: block + - **NAME**: blogs + - **NAME**: blog + - **NAME**: brand + - **NAME**: cart + - **NAME**: collection + - **NAME**: brand_color + - **NAME**: color + - **NAME**: color_scheme + - **NAME**: color_scheme_group + - **NAME**: company_address + - **NAME**: company + - **NAME**: company_location + - **NAME**: content_for_header + - **NAME**: country + - **NAME**: currency + - **NAME**: customer + - **NAME**: discount_allocation + - **NAME**: discount_application + - **NAME**: external_video + - **NAME**: filter + - **NAME**: filter_value_display + - **NAME**: filter_value + - **NAME**: focal_point + - **NAME**: font + - **NAME**: form + - **NAME**: fulfillment + - **NAME**: generic_file + - **NAME**: gift_card + - **NAME**: image + - **NAME**: image_presentation + - **NAME**: images + - **NAME**: line_item + - **NAME**: link + - **NAME**: linklists + - **NAME**: linklist + - **NAME**: forloop + - **NAME**: tablerowloop + - **NAME**: localization + - **NAME**: location + - **NAME**: market + - **NAME**: measurement + - **NAME**: metafield + - **NAME**: metaobject_definition + - **NAME**: metaobject + - **NAME**: metaobject_system + - **NAME**: model + - **NAME**: model_source + - **NAME**: money + - **NAME**: order + - **NAME**: page + - **NAME**: paginate + - **NAME**: predictive_search + - **NAME**: selling_plan_price_adjustment + - **NAME**: product + - **NAME**: product_option + - **NAME**: product_option_value + - **NAME**: swatch + - **NAME**: variant + - **NAME**: quantity_price_break + - **NAME**: rating + - **NAME**: recipient + - **NAME**: recommendations + - **NAME**: request + - **NAME**: robots + - **NAME**: group + - **NAME**: rule + - **NAME**: routes + - **NAME**: script + - **NAME**: search + - **NAME**: section + - **NAME**: selling_plan_allocation + - **NAME**: selling_plan_allocation_price_adjustment + - **NAME**: selling_plan_checkout_charge + - **NAME**: selling_plan + - **NAME**: selling_plan_group + - **NAME**: selling_plan_group_option + - **NAME**: selling_plan_option + - **NAME**: shipping_method + - **NAME**: shop + - **NAME**: shop_locale + - **NAME**: policy + - **NAME**: store_availability + - **NAME**: tax_line + - **NAME**: taxonomy_category + - **NAME**: theme + - **NAME**: settings + - **NAME**: template + - **NAME**: transaction + - **NAME**: unit_price_measurement + - **NAME**: user + - **NAME**: video + - **NAME**: video_source + - **NAME**: additional_checkout_buttons + - **NAME**: all_country_option_tags + - **NAME**: canonical_url + - **NAME**: checkout + - **NAME**: comment + - **NAME**: content_for_additional_checkout_buttons + - **NAME**: content_for_index + - **NAME**: content_for_layout + - **NAME**: country_option_tags + - **NAME**: current_page + - **NAME**: current_tags + - **NAME**: form_errors + - **NAME**: handle + - **NAME**: page_description + - **NAME**: page_image + - **NAME**: page_title + - **NAME**: part + - **NAME**: pending_payment_instruction_input + - **NAME**: powered_by_link + - **NAME**: predictive_search_resources + - **NAME**: quantity_rule + - **NAME**: scripts + - **NAME**: sitemap + - **NAME**: sort_option + - **NAME**: transaction_payment_details + - **NAME**: user_agent + `; +} + +const THEME_ARCHITECTURE: { [key: string]: { summary: string; tip?: string } } = { sections: { summary: `Liquid files that define customizable sections of a page. They include blocks and settings defined via a schema, allowing merchants to modify them in the theme editor.`, + tip: `As sections grow in complexity, consider extracting reusable parts into snippets for better maintainability. Also look for opportunities to make components more flexible by moving hardcoded values into section settings that merchants can customize.`, }, blocks: { summary: `Configurable elements within sections that can be added, removed, or reordered. They are defined with a schema tag for merchant customization in the theme editor.`, - }, - config: { - summary: `Holds settings data and schema for theme customization options like typography and colors, accessible through the Admin theme editor.`, + tip: `Break blocks into smaller, focused components that each do one thing well. Look for opportunities to extract repeated patterns into separate block types. Make blocks more flexible by moving hardcoded values into schema settings, but keep each block's schema simple and focused on its specific purpose.`, }, layout: { summary: `Defines the structure for repeated content such as headers and footers, wrapping other template files.`, - }, - locales: { - summary: `Stores translation files for localizing theme editor and storefront content.`, + tip: `Keep layouts focused on structural elements and look for opportunities to extract components into sections. Headers, footers, navigation menus, and other reusable elements should be sections to enable merchant customization through the theme editor.`, }, snippets: { summary: `Reusable code fragments included in templates, sections, and layouts via the render tag. Ideal for logic that needs to be reused but not directly edited in the theme editor.`, - }, - templates: { - summary: `JSON files that specify which sections appear on each page type (e.g., product, collection, blog). They are wrapped by layout files for consistent header/footer content.`, - }, - 'templates/customers': { - summary: `Templates for customer-related pages such as login and account overview.`, - }, - 'templates/metaobject': { - summary: `Templates for rendering custom content types defined as metaobjects.`, - }, -}; - -// const PROMPT = ` -// You are a teacher who helps liquid developers learn to use modern Liquid features. - -// Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: + tip: `Keep snippets focused on a single responsibility. Use variables to make snippets more reusable. Add a header comment block that documents expected inputs, dependencies, and any required objects/variables that need to be passed to the snippet. For example: + {% doc %} + Renders loading-spinner. -// 1. For loops that could be simplified using the new 'find' filter -// 2. Array operations that could use 'map', 'where', or other newer filters -// 3. Complex logic that could be simplified with 'case/when' -// 4. Instead of "array | where: field, value | first", use "array | find: field, value" -// 5. Your response must be a parsable json + @param {string} foo - some foo + @param {string} [bar] - optional bar -// Add one object to the suggestions array response per suggestion. - -// If you don't have any suggestions, add a "reasonIfNoSuggestions" with an explanation of why there are no suggestions. Example response: + @example + {% render 'loading-spinner', foo: 'foo' %} + {% render 'loading-spinner', foo: 'foo', bar: 'bar' %} + {% enddoc %}`, + }, -// { -// reasonIfNoSuggestions: "The code already looks perfect!", -// suggestions: [] -// } -// `; + // Removing the types below as they, generally, are not Liquid files. + // config: { + // summary: `Holds settings data and schema for theme customization options like typography and colors, accessible through the Admin theme editor.`, + // }, + // assets: { + // summary: `Contains static files such as CSS, JavaScript, and images. These assets can be referenced in Liquid files using the asset_url filter.`, + // }, + // locales: { + // summary: `Stores translation files for localizing theme editor and storefront content.`, + // }, + // templates: { + // summary: `JSON files that specify which sections appear on each page type (e.g., product, collection, blog). They are wrapped by layout files for consistent header/footer content.`, + // }, + // 'templates/customers': { + // summary: `Templates for customer-related pages such as login and account overview.`, + // }, + // 'templates/metaobject': { + // summary: `Templates for rendering custom content types defined as metaobjects.`, + // }, +}; diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index ce36b24a4..a5596b264 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -39,6 +39,7 @@ export async function getSidekickAnalysis(textEditor: TextEditor): Promise Date: Thu, 12 Dec 2024 18:53:18 +0100 Subject: [PATCH 15/25] (wip) prompt 2 --- packages/vscode-extension/src/node/extension.ts | 2 +- packages/vscode-extension/src/node/sidekick-messages.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index a6c5443b8..3c0260ac6 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -188,7 +188,7 @@ function applySuggestion({ range, newCode }: LiquidSuggestion) { const start = new Position(range.start.line - 1, range.start.character); const end = range.end; - textEditorEdit.replace(new Range(start, end), newCode); + textEditorEdit.replace(new Range(start, end), newCode + '\n'); } catch (err) { log('Error during sidefix', err); } diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index 7c9282482..a169f1f15 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -9,8 +9,7 @@ export function buildMessages(textEditor: TextEditor) { code(textEditor), codeContext(textEditor), themeArchitectureContext(), - // -- deactivate this to make it faster - // docsContext(), + docsContext(), ]; console.error(' message >>>>>>>'); From dfeb4c5aabaceb61c70d463f98ec543ba8e00e2b Mon Sep 17 00:00:00 2001 From: Mathieu Perreault Date: Wed, 11 Dec 2024 15:06:32 -0500 Subject: [PATCH 16/25] Add LLM instructions file suggestion --- .../resources/llm-instructions.template | 31 +++++++ .../vscode-extension/src/node/extension.ts | 89 +++++++++++++++++++ .../src/node/sidekick-messages.ts | 2 +- 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 packages/vscode-extension/resources/llm-instructions.template diff --git a/packages/vscode-extension/resources/llm-instructions.template b/packages/vscode-extension/resources/llm-instructions.template new file mode 100644 index 000000000..80c48f59b --- /dev/null +++ b/packages/vscode-extension/resources/llm-instructions.template @@ -0,0 +1,31 @@ +You are a very experienced Shopify theme developer. You are tasked with writing high-quality Liquid code and JSON files. + +Remember the following important mindset when providing code, in the following order: +- Adherance to conventions and patterns in the rest of the codebase +- Simplicity +- Readability + +The theme folder structure is as follows: +/assets +/config +/layout +/locales +/sections +/snippets +/templates +/templates/customers +/templates/metaobject +Files can also be placed in the root directory. Subdirectories, other than the ones listed, aren't supported. + +Liquid filters are used to modify Liquid output and are documented at https://shopify.dev/docs/api/liquid/filters.txt. + +Liquid tags are used to define logic that tells templates what to do and are documented at https://shopify.dev/api/liquid/tags.txt. +Liquid objects represent variables that you can use to build your theme and are documented at https://shopify.dev/api/liquid/objects.txt. + +Some best practices from Shopify on theme development (more available at https://shopify.dev/docs/themes/best-practices.txt): +* With the large majority of online store traffic happening on mobile, designing for mobile devices must be at the forefront throughout the theme build process. +* To provide the best experience to a wide range of merchants and customers, themes must be built from the ground up with accessibility best practices in mind. +* Themes should minimize the use of JavaScript and rely on modern and native web browser features for most functionality. +* Use responsive images by using the `image_tag` filter. This filter returns a `srcset` for the image using a smart default set of widths. An example is `{{ product.featured_image | image_url: width: 2000 | image_tag }}`. + + diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 3c0260ac6..5f4eef09c 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -9,6 +9,7 @@ import { TextEditor, TextEditorDecorationType, Uri, + window, workspace, } from 'vscode'; import { @@ -29,9 +30,97 @@ let $client: LanguageClient | undefined; let $editor: TextEditor | undefined; let $decorations: TextEditorDecorationType[] = []; +async function isShopifyTheme(workspaceRoot: string): Promise { + try { + // Check for typical Shopify theme folders + const requiredFolders = ['sections', 'templates', 'assets', 'config']; + for (const folder of requiredFolders) { + const folderUri = Uri.file(path.join(workspaceRoot, folder)); + try { + await workspace.fs.stat(folderUri); + } catch { + return false; + } + } + return true; + } catch { + return false; + } +} + +function isCursor(): boolean { + // Check if we're running in Cursor's electron process + const processTitle = process.title.toLowerCase(); + const isElectronCursor = + processTitle.includes('cursor') && process.versions.electron !== undefined; + + // Check for Cursor-specific environment variables that are set by Cursor itself + const hasCursorEnv = + process.env.CURSOR_CHANNEL !== undefined || process.env.CURSOR_VERSION !== undefined; + + return isElectronCursor || hasCursorEnv; +} + +interface ConfigFile { + path: string; + templateName: string; + prompt: string; +} + +async function getConfigFileDetails(workspaceRoot: string): Promise { + if (isCursor()) { + return { + path: path.join(workspaceRoot, '.cursorrules'), + templateName: 'llm_instructions.template', + prompt: + 'Detected Shopify theme project in Cursor. Do you want a .cursorrules file to be created?', + }; + } + return { + path: path.join(workspaceRoot, '.github', 'copilot-instructions.md'), + templateName: 'llm_instructions.template', + prompt: + 'Detected Shopify theme project in VSCode. Do you want a Copilot instructions file to be created?', + }; +} + export async function activate(context: ExtensionContext) { const runChecksCommand = 'themeCheck/runChecks'; + if (workspace.workspaceFolders?.length) { + const workspaceRoot = workspace.workspaceFolders[0].uri.fsPath; + const instructionsConfig = await getConfigFileDetails(workspaceRoot); + + // Don't do anything if the file already exists + try { + await workspace.fs.stat(Uri.file(instructionsConfig.path)); + return; + } catch { + // File doesn't exist, continue + } + + if (await isShopifyTheme(workspaceRoot)) { + const response = await window.showInformationMessage(instructionsConfig.prompt, 'Yes', 'No'); + + if (response === 'Yes') { + // Create directory if it doesn't exist (needed for .github case) + const dir = path.dirname(instructionsConfig.path); + try { + await workspace.fs.createDirectory(Uri.file(dir)); + } catch { + // Directory might already exist, continue + } + + // Read the template file from the extension's resources + const templateContent = await workspace.fs.readFile( + Uri.file(context.asAbsolutePath(`resources/${instructionsConfig.templateName}`)), + ); + await workspace.fs.writeFile(Uri.file(instructionsConfig.path), templateContent); + console.log(`Wrote instructions file to ${instructionsConfig.path}`); + } + } + } + context.subscriptions.push( commands.registerCommand('shopifyLiquid.restart', () => restartServer(context)), ); diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index a169f1f15..cd0e1834a 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -474,7 +474,7 @@ const THEME_ARCHITECTURE: { [key: string]: { summary: string; tip?: string } } = }, snippets: { summary: `Reusable code fragments included in templates, sections, and layouts via the render tag. Ideal for logic that needs to be reused but not directly edited in the theme editor.`, - tip: `Keep snippets focused on a single responsibility. Use variables to make snippets more reusable. Add a header comment block that documents expected inputs, dependencies, and any required objects/variables that need to be passed to the snippet. For example: + tip: `Keep snippets focused on a single responsibility. They should always have a header that documents expected inputs, like this: {% doc %} Renders loading-spinner. From 8a1a11af1360378357973febab7721815eaab110 Mon Sep 17 00:00:00 2001 From: Mathieu Perreault Date: Wed, 11 Dec 2024 16:40:02 -0500 Subject: [PATCH 17/25] update llm instructions --- .../resources/llm-instructions.template | 19 +++++++++++-------- .../vscode-extension/src/node/extension.ts | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/vscode-extension/resources/llm-instructions.template b/packages/vscode-extension/resources/llm-instructions.template index 80c48f59b..286d7f660 100644 --- a/packages/vscode-extension/resources/llm-instructions.template +++ b/packages/vscode-extension/resources/llm-instructions.template @@ -5,8 +5,15 @@ Remember the following important mindset when providing code, in the following o - Simplicity - Readability +Some best practices from Shopify on theme development (more available at https://shopify.dev/docs/themes/best-practices.txt): +* With the large majority of online store traffic happening on mobile, designing for mobile devices must be at the forefront throughout the theme build process. +* To provide the best experience to a wide range of merchants and customers, themes must be built from the ground up with accessibility best practices in mind. +* Themes should minimize the use of JavaScript and rely on modern and native web browser features for most functionality. +* Use responsive images by using the `image_tag` filter. This filter returns a `srcset` for the image using a smart default set of widths. An example is `{{ product.featured_image | image_url: width: 2000 | image_tag }}`. + The theme folder structure is as follows: /assets +/blocks /config /layout /locales @@ -17,15 +24,11 @@ The theme folder structure is as follows: /templates/metaobject Files can also be placed in the root directory. Subdirectories, other than the ones listed, aren't supported. -Liquid filters are used to modify Liquid output and are documented at https://shopify.dev/docs/api/liquid/filters.txt. +Liquid filters are used to modify Liquid output and are documented at https://shopify.dev/docs/api/liquid/filters.txt. The full list of filters is "item_count_for_variant,line_items_for,class_list,link_to_type,link_to_vendor,sort_by,url_for_type,url_for_vendor,within,brightness_difference,color_brightness,color_contrast,color_darken,color_desaturate,color_difference,color_extract,color_lighten,color_mix,color_modify,color_saturate,color_to_hex,color_to_hsl,color_to_rgb,hex_to_rgba,hmac_sha1,hmac_sha256,md5,sha1,sha256,currency_selector,customer_login_link,customer_logout_link,customer_register_link,date,font_face,font_modify,font_url,default_errors,payment_button,payment_terms,time_tag,translate,inline_asset_content,json,abs,append,at_least,at_most,base64_decode,base64_encode,base64_url_safe_decode,base64_url_safe_encode,capitalize,ceil,compact,concat,default,divided_by,downcase,escape,escape_once,first,floor,join,last,lstrip,map,minus,modulo,newline_to_br,plus,prepend,remove,remove_first,remove_last,replace,replace_first,replace_last,reverse,round,rstrip,size,slice,sort,sort_natural,split,strip,strip_html,strip_newlines,sum,times,truncate,truncatewords,uniq,upcase,url_decode,url_encode,where,external_video_tag,external_video_url,image_tag,media_tag,model_viewer_tag,video_tag,metafield_tag,metafield_text,money,money_with_currency,money_without_currency,money_without_trailing_zeros,default_pagination,avatar,login_button,camelize,handleize,url_escape,url_param_escape,structured_data,highlight_active_tag,link_to_add_tag,link_to_remove_tag,link_to_tag,format_address,highlight,pluralize,article_img_url,asset_img_url,asset_url,collection_img_url,file_img_url,file_url,global_asset_url,image_url,img_tag,img_url,link_to,payment_type_img_url,payment_type_svg_tag,placeholder_svg_tag,preload_tag,product_img_url,script_tag,shopify_asset_url,stylesheet_tag,weight_with_unit" +* Liquid tags are used to define logic that tells templates what to do and are documented at https://shopify.dev/api/liquid/tags.txt. The full list of tags is "content_for,form,layout,assign,break,capture,case,comment,continue,cycle,decrement,echo,for,if,include,increment,raw,render,tablerow,unless,paginate,javascript,section,stylesheet,sections,style,else,liquid" +Liquid objects represent variables that you can use to build your theme and are documented at https://shopify.dev/api/liquid/objects.txt. The full list of objects is "media,address,collections,pages,all_products,app,discount,articles,article,block,blogs,blog,brand,cart,collection,brand_color,color,color_scheme,color_scheme_group,company_address,company,company_location,content_for_header,country,currency,customer,discount_allocation,discount_application,external_video,filter,filter_value_display,filter_value,focal_point,font,form,fulfillment,generic_file,gift_card,image,image_presentation,images,line_item,link,linklists,linklist,forloop,tablerowloop,localization,location,market,measurement,metafield,metaobject_definition,metaobject,metaobject_system,model,model_source,money,order,page,paginate,predictive_search,selling_plan_price_adjustment,product,product_option,product_option_value,swatch,variant,quantity_price_break,rating,recipient,recommendations,request,robots,group,rule,routes,script,search,section,selling_plan_allocation,selling_plan_allocation_price_adjustment,selling_plan_checkout_charge,selling_plan,selling_plan_group,selling_plan_group_option,selling_plan_option,shipping_method,shop,shop_locale,policy,store_availability,tax_line,taxonomy_category,theme,settings,template,transaction,unit_price_measurement,user,video,video_source,additional_checkout_buttons,all_country_option_tags,canonical_url,checkout,comment,content_for_additional_checkout_buttons,content_for_index,content_for_layout,country_option_tags,current_page,current_tags,form_errors,handle,page_description,page_image,page_title,part,pending_payment_instruction_input,powered_by_link,predictive_search_resources,quantity_rule,scripts,sitemap,sort_option,transaction_payment_details,user_agent" +When you are suggesting code, don't invent new filters, objects or tags. -Liquid tags are used to define logic that tells templates what to do and are documented at https://shopify.dev/api/liquid/tags.txt. -Liquid objects represent variables that you can use to build your theme and are documented at https://shopify.dev/api/liquid/objects.txt. -Some best practices from Shopify on theme development (more available at https://shopify.dev/docs/themes/best-practices.txt): -* With the large majority of online store traffic happening on mobile, designing for mobile devices must be at the forefront throughout the theme build process. -* To provide the best experience to a wide range of merchants and customers, themes must be built from the ground up with accessibility best practices in mind. -* Themes should minimize the use of JavaScript and rely on modern and native web browser features for most functionality. -* Use responsive images by using the `image_tag` filter. This filter returns a `srcset` for the image using a smart default set of widths. An example is `{{ product.featured_image | image_url: width: 2000 | image_tag }}`. diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 5f4eef09c..bd468a541 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -71,14 +71,14 @@ async function getConfigFileDetails(workspaceRoot: string): Promise if (isCursor()) { return { path: path.join(workspaceRoot, '.cursorrules'), - templateName: 'llm_instructions.template', + templateName: 'llm-instructions.template', prompt: 'Detected Shopify theme project in Cursor. Do you want a .cursorrules file to be created?', }; } return { path: path.join(workspaceRoot, '.github', 'copilot-instructions.md'), - templateName: 'llm_instructions.template', + templateName: 'llm-instructions.template', prompt: 'Detected Shopify theme project in VSCode. Do you want a Copilot instructions file to be created?', }; From 1e3509bd397c3e5e1777236807b6661717b15bfd Mon Sep 17 00:00:00 2001 From: Benjamin Sehl Date: Thu, 12 Dec 2024 14:14:14 -0500 Subject: [PATCH 18/25] New llm instruction file --- .../resources/llm-instructions.template | 396 ++++++++++++++++-- 1 file changed, 370 insertions(+), 26 deletions(-) diff --git a/packages/vscode-extension/resources/llm-instructions.template b/packages/vscode-extension/resources/llm-instructions.template index 286d7f660..02c24418f 100644 --- a/packages/vscode-extension/resources/llm-instructions.template +++ b/packages/vscode-extension/resources/llm-instructions.template @@ -1,34 +1,378 @@ -You are a very experienced Shopify theme developer. You are tasked with writing high-quality Liquid code and JSON files. + + +Type: Advanced Frontend Development Catalyst +Purpose: Enhanced Pattern Recognition with Liquid Expertise +Paradigm: Component-First Server-Side Rendering +Constraints: Shopify Theme Architecture +Objective: Optimal Theme Development + -Remember the following important mindset when providing code, in the following order: -- Adherance to conventions and patterns in the rest of the codebase -- Simplicity -- Readability + +documentation = { + liquid_objects: "https://shopify.dev/docs/api/liquid/objects", + liquid_filters: "https://shopify.dev/docs/api/liquid/filters", + liquid_tags: "https://shopify.dev/docs/api/liquid/tags", + theme_development: "https://shopify.dev/docs/themes/best-practices", + architecture: "https://shopify.dev/docs/themes/architecture" +} -Some best practices from Shopify on theme development (more available at https://shopify.dev/docs/themes/best-practices.txt): -* With the large majority of online store traffic happening on mobile, designing for mobile devices must be at the forefront throughout the theme build process. -* To provide the best experience to a wide range of merchants and customers, themes must be built from the ground up with accessibility best practices in mind. -* Themes should minimize the use of JavaScript and rely on modern and native web browser features for most functionality. -* Use responsive images by using the `image_tag` filter. This filter returns a `srcset` for the image using a smart default set of widths. An example is `{{ product.featured_image | image_url: width: 2000 | image_tag }}`. +∀ implementation_detail: + verify_against(documentation); + -The theme folder structure is as follows: -/assets -/blocks -/config -/layout -/locales -/sections -/snippets -/templates -/templates/customers -/templates/metaobject -Files can also be placed in the root directory. Subdirectories, other than the ones listed, aren't supported. + +{ + server_side ⇔ client_side + markup ⇔ style ⇔ behavior + component(x) → component(x′) where x′ = optimize(x) + ∀ element ∈ DOM: accessibility(element) = true + ∀ template ∈ theme: follows_conventions(template) = true + ∃ pattern: pattern ∈ bestPractices ∧ pattern ∈ userNeeds +} + -Liquid filters are used to modify Liquid output and are documented at https://shopify.dev/docs/api/liquid/filters.txt. The full list of filters is "item_count_for_variant,line_items_for,class_list,link_to_type,link_to_vendor,sort_by,url_for_type,url_for_vendor,within,brightness_difference,color_brightness,color_contrast,color_darken,color_desaturate,color_difference,color_extract,color_lighten,color_mix,color_modify,color_saturate,color_to_hex,color_to_hsl,color_to_rgb,hex_to_rgba,hmac_sha1,hmac_sha256,md5,sha1,sha256,currency_selector,customer_login_link,customer_logout_link,customer_register_link,date,font_face,font_modify,font_url,default_errors,payment_button,payment_terms,time_tag,translate,inline_asset_content,json,abs,append,at_least,at_most,base64_decode,base64_encode,base64_url_safe_decode,base64_url_safe_encode,capitalize,ceil,compact,concat,default,divided_by,downcase,escape,escape_once,first,floor,join,last,lstrip,map,minus,modulo,newline_to_br,plus,prepend,remove,remove_first,remove_last,replace,replace_first,replace_last,reverse,round,rstrip,size,slice,sort,sort_natural,split,strip,strip_html,strip_newlines,sum,times,truncate,truncatewords,uniq,upcase,url_decode,url_encode,where,external_video_tag,external_video_url,image_tag,media_tag,model_viewer_tag,video_tag,metafield_tag,metafield_text,money,money_with_currency,money_without_currency,money_without_trailing_zeros,default_pagination,avatar,login_button,camelize,handleize,url_escape,url_param_escape,structured_data,highlight_active_tag,link_to_add_tag,link_to_remove_tag,link_to_tag,format_address,highlight,pluralize,article_img_url,asset_img_url,asset_url,collection_img_url,file_img_url,file_url,global_asset_url,image_url,img_tag,img_url,link_to,payment_type_img_url,payment_type_svg_tag,placeholder_svg_tag,preload_tag,product_img_url,script_tag,shopify_asset_url,stylesheet_tag,weight_with_unit" -* Liquid tags are used to define logic that tells templates what to do and are documented at https://shopify.dev/api/liquid/tags.txt. The full list of tags is "content_for,form,layout,assign,break,capture,case,comment,continue,cycle,decrement,echo,for,if,include,increment,raw,render,tablerow,unless,paginate,javascript,section,stylesheet,sections,style,else,liquid" -Liquid objects represent variables that you can use to build your theme and are documented at https://shopify.dev/api/liquid/objects.txt. The full list of objects is "media,address,collections,pages,all_products,app,discount,articles,article,block,blogs,blog,brand,cart,collection,brand_color,color,color_scheme,color_scheme_group,company_address,company,company_location,content_for_header,country,currency,customer,discount_allocation,discount_application,external_video,filter,filter_value_display,filter_value,focal_point,font,form,fulfillment,generic_file,gift_card,image,image_presentation,images,line_item,link,linklists,linklist,forloop,tablerowloop,localization,location,market,measurement,metafield,metaobject_definition,metaobject,metaobject_system,model,model_source,money,order,page,paginate,predictive_search,selling_plan_price_adjustment,product,product_option,product_option_value,swatch,variant,quantity_price_break,rating,recipient,recommendations,request,robots,group,rule,routes,script,search,section,selling_plan_allocation,selling_plan_allocation_price_adjustment,selling_plan_checkout_charge,selling_plan,selling_plan_group,selling_plan_group_option,selling_plan_option,shipping_method,shop,shop_locale,policy,store_availability,tax_line,taxonomy_category,theme,settings,template,transaction,unit_price_measurement,user,video,video_source,additional_checkout_buttons,all_country_option_tags,canonical_url,checkout,comment,content_for_additional_checkout_buttons,content_for_index,content_for_layout,country_option_tags,current_page,current_tags,form_errors,handle,page_description,page_image,page_title,part,pending_payment_instruction_input,powered_by_link,predictive_search_resources,quantity_rule,scripts,sitemap,sort_option,transaction_payment_details,user_agent" -When you are suggesting code, don't invent new filters, objects or tags. + +knowledge_base = { + filters: [documented_filters], + tags: [documented_tags], + objects: [documented_objects], + conventions: { + multiline: use_liquid_tag(), + comments: use_inline_comments(), + structure: follow_theme_architecture() + }, + best_practices: { + • Prioritize server-side rendering + • Minimize JavaScript usage + • Use responsive images + • Follow folder structure + • Maintain proper scoping + } +} +∀ code_segment ∈ liquid: + validate(code_segment) ∈ knowledge_base; + + +while(developing) { + analyze_requirements(); + identify_patterns(); + validate_liquid_syntax(); + + if(novel_approach_found()) { + validate_against_standards(); + check_liquid_compatibility(); + if(meets_criteria() && is_valid_liquid()) { + implement(); + document_reasoning(); + } + } + + optimize_output(); + validate_accessibility(); + review_performance(); +} + + + +∀ solution ∈ theme: { + identify_common_patterns(); + validate_liquid_syntax(); + abstract_reusable_components(); + establish_section_architecture(); + map_relationships(pattern, context); + evaluate_effectiveness(); + + if(pattern.frequency > threshold) { + create_reusable_snippet(); + document_usage_patterns(); + } +} + + + +context = { + platform_constraints, + performance_requirements, + accessibility_needs, + user_experience_goals, + maintenance_considerations, + team_capabilities, + project_timeline +} + +for each decision_point: + evaluate(context); + adjust(implementation); + validate(outcome); + document_reasoning(); + + + +∀ component ∈ system: { + • Evaluate reusability potential + • Define clear interfaces + • Establish prop contracts + • Implement proper error boundaries + • Consider state management + • Plan for extensibility + • Document behavior patterns +} + +component_evaluation(c) = { + reusability: assess_reuse_potential(c), + complexity: measure_complexity(c), + maintainability: evaluate_maintenance_cost(c), + performance: measure_performance_impact(c) +} + + + +folder_structure = { + assets: static_files(), + blocks: component_blocks(), + config: theme_settings(), + layout: theme_layouts(), + locales: translations(), + sections: theme_sections(), + snippets: reusable_components(), + templates: page_templates(), + templates/customers: customer_templates(), + templates/metaobject: metaobject_templates() +} + +∀ file ∈ theme: + validate(file.location) ∈ folder_structure; + + + +for each liquid_code: + validate({ + syntax: check_liquid_syntax(), + filters: validate_filters(), + tags: validate_tags(), + objects: validate_objects(), + conventions: check_conventions(), + best_practices: verify_practices() + }); + + provide_error_context(); + suggest_improvements(); + + + +For each development request: +1. Analyze requirements systematically +2. Validate Liquid compatibility +3. Check theme architecture fit +4. Evaluate existing patterns +5. Consider server-side rendering +6. Assess performance impact +7. Implement solution +8. Document decisions +9. Provide usage examples +10. Validate theme conventions + + +Mission: +- Create optimal, maintainable Shopify themes +- Prioritize server-side rendering +- Ensure proper Liquid syntax +- Follow theme architecture +- Maintain consistent patterns +- Provide clear documentation +- Think systematically about requirements +- Consider future implications +- Optimize developer experience +- Learn from implementation patterns + +Constraints: +- Must use valid Liquid syntax +- Must follow theme architecture +- Must prioritize server-side rendering +- Must minimize JavaScript usage +- Must maintain accessibility +- Must optimize performance +- Must document decisions +- Must validate solutions +- Must provide clear patterns +- Must consider mobile-first + + + + +valid_filters = [ + // Collection/Product filters + "item_count_for_variant", "line_items_for", "class_list", "link_to_type", "link_to_vendor", "sort_by", "url_for_type", "url_for_vendor", "within", + + // Color manipulation + "brightness_difference", "color_brightness", "color_contrast", "color_darken", "color_desaturate", "color_difference", "color_extract", "color_lighten", "color_mix", "color_modify", "color_saturate", "color_to_hex", "color_to_hsl", "color_to_rgb", "hex_to_rgba", + + // Cryptographic + "hmac_sha1", "hmac_sha256", "md5", "sha1", "sha256", + + // Customer/Store + "currency_selector", "customer_login_link", "customer_logout_link", "customer_register_link", + + // Asset/Content + "date", "font_face", "font_modify", "font_url", "default_errors", "payment_button", "payment_terms", "time_tag", "translate", "inline_asset_content", + + // Data manipulation + "json", "abs", "append", "at_least", "at_most", "base64_decode", "base64_encode", "base64_url_safe_decode", "base64_url_safe_encode", "capitalize", "ceil", "compact", "concat", "default", "divided_by", "downcase", "escape", "escape_once", "first", "floor", "join", "last", "lstrip", "map", "minus", "modulo", "newline_to_br", "plus", "prepend", "remove", "remove_first", "remove_last", "replace", "replace_first", "replace_last", "reverse", "round", "rstrip", "size", "slice", "sort", "sort_natural", "split", "strip", "strip_html", "strip_newlines", "sum", "times", "truncate", "truncatewords", "uniq", "upcase", "url_decode", "url_encode", "where", + + // Media + "external_video_tag", "external_video_url", "image_tag", "media_tag", "model_viewer_tag", "video_tag", "metafield_tag", "metafield_text", + + // Money + "money", "money_with_currency", "money_without_currency", "money_without_trailing_zeros", + + // UI/UX + "default_pagination", "avatar", "login_button", "camelize", "handleize", "url_escape", "url_param_escape", "structured_data", + + // Navigation/Links + "highlight_active_tag", "link_to_add_tag", "link_to_remove_tag", "link_to_tag", + + // Formatting + "format_address", "highlight", "pluralize", + + // URLs and Assets + "article_img_url", "asset_img_url", "asset_url", "collection_img_url", "file_img_url", "file_url", "global_asset_url", "image_url", "img_tag", "img_url", "link_to", "payment_type_img_url", "payment_type_svg_tag", "placeholder_svg_tag", "preload_tag", "product_img_url", "script_tag", "shopify_asset_url", "stylesheet_tag", "weight_with_unit" +] + +valid_tags = [ + // Content tags + "content_for", "form", "layout", + + // Variable tags + "assign", "capture", "increment", "decrement", + + // Control flow + "if", "unless", "case", "when", "else", "elsif", + + // Iteration + "for", "break", "continue", "cycle", "tablerow", + + // Output + "echo", "raw", + + // Template + "render", "include", "section", "sections", + + // Style/Script + "javascript", "stylesheet", "style", + + // Utility + "liquid", "comment", "paginate" +] + +valid_objects = [ + // Core objects + "media", "address", "collections", "pages", "all_products", "app", "discount", "articles", "article", "block", "blogs", "blog", "brand", "cart", "collection", + + // Design/Theme + "brand_color", "color", "color_scheme", "color_scheme_group", "theme", "settings", "template", + + // Business + "company_address", "company", "company_location", "shop", "shop_locale", "policy", + + // Header/Layout + "content_for_header", "content_for_layout", + + // Customer/Commerce + "country", "currency", "customer", "discount_allocation", "discount_application", + + // Media + "external_video", "image", "image_presentation", "images", "video", "video_source", + + // Navigation/Filtering + "filter", "filter_value_display", "filter_value", "linklists", "linklist", + + // Loop controls + "forloop", "tablerowloop", + + // Localization/Markets + "localization", "location", "market", + + // Products/Variants + "measurement", "product", "product_option", "product_option_value", "swatch", "variant", "quantity_price_break", + + // Metadata + "metafield", "metaobject_definition", "metaobject", "metaobject_system", + + // Models/3D + "model", "model_source", + + // Orders/Transactions + "money", "order", "transaction", "transaction_payment_details", + + // Search/Recommendations + "predictive_search", "recommendations", "search", + + // Selling plans + "selling_plan_price_adjustment", "selling_plan_allocation", "selling_plan_allocation_price_adjustment", "selling_plan_checkout_charge", "selling_plan", "selling_plan_group", "selling_plan_group_option", "selling_plan_option", + + // Shipping/Availability + "shipping_method", "store_availability", + + // System/Request + "request", "robots", "routes", "script", "user", "user_agent", + + // Utilities + "focal_point", "font", "form", "fulfillment", "generic_file", "gift_card", "line_item", "link", "page", "paginate", "rating", "recipient", "section", "tax_line", "taxonomy_category", "unit_price_measurement", + + // Additional features + "additional_checkout_buttons", "all_country_option_tags", "canonical_url", "checkout", "comment", "content_for_additional_checkout_buttons", "content_for_index", "country_option_tags", "current_page", "current_tags", "form_errors", "handle", "page_description", "page_image", "page_title", "part", "pending_payment_instruction_input", "powered_by_link", "predictive_search_resources", "quantity_rule", "scripts", "sitemap", "sort_option" +] + +validation_rules = { + syntax: { + • Use {% liquid %} for multiline code + • Use {% # comments %} for inline comments + • Never invent new filters, tags, or objects + • Follow proper tag closing order + • Use proper object dot notation + • Respect object scope and availability + }, + + theme_structure: { + • Place files in appropriate directories + • Follow naming conventions + • Respect template hierarchy + • Maintain proper section/block structure + • Use appropriate schema settings + } +} + +∀ liquid_code ∈ theme: + validate_syntax(liquid_code) ∧ + validate_filters(liquid_code.filters ∈ valid_filters) ∧ + validate_tags(liquid_code.tags ∈ valid_tags) ∧ + validate_objects(liquid_code.objects ∈ valid_objects) ∧ + validate_structure(liquid_code.location ∈ theme_structure) + + + +specificity_rules = { + • Target 0-1-0 specificity (single class) + • Maximum 0-2-0 for nested + • No !important unless documented + • Follow BEM naming + • Scope variables inline +} + +nesting_rules = { + • Single level only + • Media queries allowed + • Parent-child state relationships + • No descendant selectors +} + +∀ css_code ∈ theme: + validate_specificity(css_code) ∧ + validate_nesting(css_code) ∧ + validate_methodology(css_code) + + From 5df43b38189717bcefa25e3c22862bedb7531bea Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 13 Dec 2024 15:13:14 +0900 Subject: [PATCH 19/25] Use diff view for sidefix --- .../vscode-extension/src/node/extension.ts | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 1798920ef..d3add35bb 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -11,6 +11,7 @@ import { Uri, window, workspace, + WorkspaceEdit, } from 'vscode'; import { DocumentSelector, @@ -325,17 +326,31 @@ function applyDecorations(decorations: SidekickDecoration[]) { }); } -function applySuggestion({ range, newCode }: LiquidSuggestion) { - $editor?.edit((textEditorEdit) => { - try { - const start = new Position(range.start.line - 1, range.start.character); - const end = range.end; +async function applySuggestion({ range, newCode }: LiquidSuggestion) { + log('Applying suggestion...'); + if (!$editor) { + return; + } - textEditorEdit.replace(new Range(start, end), newCode + '\n'); - } catch (err) { - log('Error during sidefix', err); - } + const endLineIndex = range.end.line - 1; + const start = new Position(range.start.line - 1, 0); + const end = new Position(endLineIndex, $editor.document.lineAt(endLineIndex).text.length); + const oldCode = $editor.document.getText(new Range(start, end)); + const initialIndentation = oldCode.match(/^[ \t]+/)?.[0] ?? ''; + + // Create a merge conflict style text + const conflictText = [ + '<<<<<<< Current', + oldCode, + '=======', + newCode.replace(/^/gm, initialIndentation), + '>>>>>>> Suggested Change', + ].join('\n'); + + // Replace the current text with the conflict markers + const edit = new WorkspaceEdit(); + edit.replace($editor.document.uri, new Range(start, end), conflictText); + await workspace.applyEdit(edit); - disposeDecorations(); - }); + disposeDecorations(); } From 92c6643a00a92f5810596654bb9d2173ac2fe898 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 13 Dec 2024 17:18:41 +0900 Subject: [PATCH 20/25] Improve the timing for removing suggestions --- .../vscode-extension/src/node/extension.ts | 66 +++++++++++++++---- .../vscode-extension/src/node/sidekick.ts | 8 +-- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index d3add35bb..7479f2293 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -25,11 +25,15 @@ import LiquidFormatter from '../common/formatter'; import { vscodePrettierFormat } from './formatter'; import { getSidekickAnalysis, LiquidSuggestion, log, SidekickDecoration } from './sidekick'; +type LiquidSuggestionWithDecorationKey = LiquidSuggestion & { key: string }; + const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); let $client: LanguageClient | undefined; let $editor: TextEditor | undefined; let $decorations: TextEditorDecorationType[] = []; +let $isApplyingSuggestion = false; +let $previousShownConflicts = new Map(); async function isShopifyTheme(workspaceRoot: string): Promise { try { @@ -210,11 +214,14 @@ export async function activate(context: ExtensionContext) { }), ); context.subscriptions.push( - commands.registerCommand('shopifyLiquid.sidefix', async (suggestion: LiquidSuggestion) => { - log('Sidekick is fixing...'); + commands.registerCommand( + 'shopifyLiquid.sidefix', + async (suggestion: LiquidSuggestionWithDecorationKey) => { + log('Sidekick is fixing...'); - applySuggestion(suggestion); - }), + applySuggestion(suggestion); + }, + ), ); // context.subscriptions.push( // languages.registerInlineCompletionItemProvider( @@ -224,8 +231,32 @@ export async function activate(context: ExtensionContext) { // ); context.subscriptions.push( - workspace.onDidChangeTextDocument(() => { - disposeDecorations(); + workspace.onDidChangeTextDocument(({ contentChanges, reason, document }) => { + // Each shown suggestion fix is displayed as a conflict in the editor. We want to + // hide all suggestion hints when the user starts typing, as they are no longer + // relevant, but we don't want to remove them on conflict resolution since that + // only means the user has accepted/rejected a suggestion and might continue with + // the other suggestions. + const currentShownConflicts = document.getText().split(conflictMarkerStart).length - 1; + + if ( + // Ignore when there are no content changes + contentChanges.length > 0 && + // Ignore when initiating the diff view (it triggers a change event) + !$isApplyingSuggestion && + // Ignore undo/redos + reason === undefined && + // Only dispose decorations when there are no conflicts currently shown (no diff views) + // and when there were no conflicts shown previously. This means that the current + // change is not related to a conflict resolution but a manual user input. + currentShownConflicts === 0 && + !$previousShownConflicts.get(document.fileName) + ) { + disposeDecorations(); + } + + // Store the previous number of conflicts shown for this document. + $previousShownConflicts.set(document.fileName, currentShownConflicts); }), ); @@ -326,12 +357,18 @@ function applyDecorations(decorations: SidekickDecoration[]) { }); } -async function applySuggestion({ range, newCode }: LiquidSuggestion) { +const conflictMarkerStart = '<<<<<<< Current'; +const conflictMarkerMiddle = '======='; +const conflictMarkerEnd = '>>>>>>> Suggested Change'; + +async function applySuggestion({ key, range, newCode }: LiquidSuggestionWithDecorationKey) { log('Applying suggestion...'); if (!$editor) { return; } + $isApplyingSuggestion = true; + const endLineIndex = range.end.line - 1; const start = new Position(range.start.line - 1, 0); const end = new Position(endLineIndex, $editor.document.lineAt(endLineIndex).text.length); @@ -340,11 +377,11 @@ async function applySuggestion({ range, newCode }: LiquidSuggestion) { // Create a merge conflict style text const conflictText = [ - '<<<<<<< Current', + conflictMarkerStart, oldCode, - '=======', + conflictMarkerMiddle, newCode.replace(/^/gm, initialIndentation), - '>>>>>>> Suggested Change', + conflictMarkerEnd, ].join('\n'); // Replace the current text with the conflict markers @@ -352,5 +389,12 @@ async function applySuggestion({ range, newCode }: LiquidSuggestion) { edit.replace($editor.document.uri, new Range(start, end), conflictText); await workspace.applyEdit(edit); - disposeDecorations(); + // Only dispose the decoration associated with this suggestion + const decorationIndex = $decorations.findIndex((d) => d.key === key); + if (decorationIndex !== -1) { + $decorations[decorationIndex].dispose(); + $decorations.splice(decorationIndex, 1); + } + + $isApplyingSuggestion = false; } diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index a5596b264..f73c9b8d3 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -82,10 +82,10 @@ function buildSidekickDecoration( new Position(line, 0), new Position(line, editor.document.lineAt(line).text.length), ), - hoverMessage: createHoverMessage(liquidSuggestion), + hoverMessage: createHoverMessage(type.key, liquidSuggestion), }; - return [{ type, options }]; + return [{ type, options }]; } async function parseChatResponse(chatResponse: LanguageModelChatResponse) { @@ -114,8 +114,8 @@ export function log(message?: any, ...optionalParams: any[]) { console.error(` [Sidekick] ${message}`, ...optionalParams); } -function createHoverMessage(liquidSuggestion: LiquidSuggestion) { - const hoverUrlArgs = encodeURIComponent(JSON.stringify(liquidSuggestion)); +function createHoverMessage(key: string, liquidSuggestion: LiquidSuggestion) { + const hoverUrlArgs = encodeURIComponent(JSON.stringify({ key, ...liquidSuggestion })); const hoverMessage = new MarkdownString( `✨ ${liquidSuggestion.suggestion} \n\n[Quick fix](command:shopifyLiquid.sidefix?${hoverUrlArgs})`, From f1074e8b4c483712d99caf6db4da4f26d69182e9 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 13 Dec 2024 17:18:59 +0900 Subject: [PATCH 21/25] Try to improve the prompt --- .../vscode-extension/src/node/sidekick-messages.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index cd0e1834a..47925423d 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -32,19 +32,23 @@ function basePrompt(textEditor: TextEditor): string { - Enhancing readability, conciseness, and efficiency while maintaining the same functionality - Leveraging new features in Liquid, including filters, tags, and objects - Be pragmatic and don't suggest without a really good reason - - You should not suggest changes to the code that impact HTML -- they should be focused on Liquid + - You should not suggest changes to the code that impact only HTML -- they should be focused on Liquid and Theme features. - You should not talk about whitespaces and the style of the code; leave that to the linter! - Ensure the suggestions are specific, actionable, and align with the best practices in Liquid and Shopify theme development. + At the same time, ensure the following is true: - Use the "## THEME ARCHITECTURE", "## CONTEXT", the "## DOCS", and Shopify.dev context. Do not make up new information. + - The resulting code must work and should not break existing HTML tags or Liquid syntax. Make full-scope suggestions that consider the entire context of the code you are modifying + - The new code you propose contain full lines of valid code and keep the correct indentation and style format as the original code + - The suggestions are specific, actionable, and align with the best practices in Liquid and Shopify theme development + - Add a maximum of ${numberOfSuggestions} distinct suggestions to the array + - Code suggestions cannot overlap in line numbers. If you have multiple suggestions for the same code chunk, merge them into a single suggestion - Add a maximum of ${numberOfSuggestions} suggestions to the array. + Use the "## THEME ARCHITECTURE", "## CONTEXT", the "## DOCS", and Shopify.dev context as a reference. Do not make up new information. Your response must be exclusively a valid and parsable JSON object with the following structure: { - "reasonIfNoSuggestions": "Explanation of why there are no suggestions", + "reasonIfNoSuggestions": "", "suggestions": [ { "newCode": "", From a38a8cb5675e993689faf50933d56d9467443261 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 13 Dec 2024 12:13:23 +0100 Subject: [PATCH 22/25] Update prompt to use tag convention --- .../src/node/sidekick-messages.ts | 1036 ++++++++++------- .../vscode-extension/src/node/sidekick.ts | 4 +- 2 files changed, 588 insertions(+), 452 deletions(-) diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index 47925423d..53b07296d 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -7,15 +7,11 @@ export function buildMessages(textEditor: TextEditor) { const prompt = [ basePrompt(textEditor), code(textEditor), - codeContext(textEditor), + codeMetadata(textEditor), + liquidRules(), themeArchitectureContext(), - docsContext(), ]; - console.error(' message >>>>>>>'); - console.error(prompt.join('\n')); - console.error(' message <<<<<<<'); - return prompt.map((message) => LanguageModelChatMessage.User(message)); } @@ -23,84 +19,212 @@ function basePrompt(textEditor: TextEditor): string { const numberOfSuggestions = textEditor.selection.isEmpty ? 5 : 1; return ` - ## INSTRUCTIONS (REALLY IMPORTANT) - - You are Sidekick, an AI assistant designed to help Liquid developers optimize Shopify themes. - - Your goal is to identify and suggest opportunities for improvement in the "## CODE", focusing on the following areas: - - - Enhancing readability, conciseness, and efficiency while maintaining the same functionality - - Leveraging new features in Liquid, including filters, tags, and objects - - Be pragmatic and don't suggest without a really good reason - - You should not suggest changes to the code that impact only HTML -- they should be focused on Liquid and Theme features. - - You should not talk about whitespaces and the style of the code; leave that to the linter! - - At the same time, ensure the following is true: - - - The resulting code must work and should not break existing HTML tags or Liquid syntax. Make full-scope suggestions that consider the entire context of the code you are modifying - - The new code you propose contain full lines of valid code and keep the correct indentation and style format as the original code - - The suggestions are specific, actionable, and align with the best practices in Liquid and Shopify theme development - - Add a maximum of ${numberOfSuggestions} distinct suggestions to the array - - Code suggestions cannot overlap in line numbers. If you have multiple suggestions for the same code chunk, merge them into a single suggestion - - Use the "## THEME ARCHITECTURE", "## CONTEXT", the "## DOCS", and Shopify.dev context as a reference. Do not make up new information. - - Your response must be exclusively a valid and parsable JSON object with the following structure: - - { - "reasonIfNoSuggestions": "", - "suggestions": [ - { - "newCode": "", - "range": { - "start": { - "line": , - "character": - }, - "end": { - "line": , - "character": - } - }, - "line": , - "suggestion": "" - } - ] - } - - Example of valid response: - - { - "reasonIfNoSuggestions": null, - "suggestions": [ - { - "newCode": "{% assign first_product = products | first %}", - "range": { - "start": { - "line": 5, - "character": 0 - }, - "end": { - "line": 7, - "character": 42 - } - }, - "line": 5, - "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." - } - ] - } + + + + You are Sidekick, an AI assistant designed to help Liquid developers optimize Shopify themes. + + + Advanced Frontend Development Catalyst + Enhanced Pattern Recognition with Liquid Expertise + Component-First Server-Side Rendering + Shopify Theme Architecture + Optimal Theme Development + + + Enhancing readability, conciseness, and efficiency while maintaining the same functionality + Leveraging new features in Liquid, including filters, tags, and objects + Be pragmatic and don't suggest without a really good reason + Combine multiple operations into one (example, use the find filter instead of where and first) to improve readability and performance + You should not suggest changes to the code that impact only HTML -- they should be focused on Liquid and Theme features. + You should not talk about whitespaces and the style of the code; leave that to the linter! + + + The new code you propose contain full lines of valid code and keep the correct indentation, scope, and style format as the original code + Scopes are defined by the opened by "{%", "{{" with the matching closing element "%}" or "}}" + The range of the code being edited with "{%"/"{{" must encopass the matching closing element "%}"/"}}" (they should be even, never odd) + Code suggestions cannot overlap in line numbers. If you have multiple suggestions for the same code chunk, merge them into a single suggestion + Make full-scope suggestions that consider the entire context of the code you are modifying, keeping the logical scope of the code valid + The resulting code must work and should not break existing HTML tags or Liquid syntax + The suggestions are specific, actionable, and align with the best practices in Liquid and Shopify theme development + Add a maximum of ${numberOfSuggestions} distinct suggestions to the array + + + Use the , , and Shopify.dev context as a reference. Do not make up new information. + + + ∀ solution ∈ theme: { + identify_common_patterns(); + validate_liquid_syntax(); + abstract_reusable_components(); + establish_section_architecture(); + map_relationships(pattern, context); + evaluate_effectiveness(); + + if(pattern.frequency > threshold) { + create_reusable_snippet(); + document_usage_patterns(); + } + } + + + context = { + platform_constraints, + performance_requirements, + accessibility_needs, + user_experience_goals, + maintenance_considerations, + team_capabilities, + project_timeline + } + + for each decision_point: + evaluate(context); + adjust(implementation); + validate(outcome); + document_reasoning(); + + + while(developing) { + analyze_requirements(); + identify_patterns(); + validate_liquid_syntax(); + + if(novel_approach_found()) { + validate_against_standards(); + check_liquid_compatibility(); + if(meets_criteria() && is_valid_liquid()) { + implement(); + document_reasoning(); + } + } + + optimize_output(); + validate_accessibility(); + review_performance(); + combine_two_operations_into_one(); + } + + + + + Your response must be exclusively a valid and parsable JSON object with the following structure schema: + + + { + "$schema": { + "type": "object", + "properties": { + "reasonIfNoSuggestions": { + "type": ["string", "null"], + "description": "Explanation of why there are no suggestions" + }, + "suggestions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "newCode": { + "type": "string", + "description": "The improved code to replace the current code" + }, + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "number", + "description": "Start line the new code starts" + }, + "character": { + "type": "number", + "description": "Start character the new code starts" + } + } + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "number", + "description": "End line the new code ends" + }, + "character": { + "type": "number", + "description": "End character the new code ends" + } + } + } + } + }, + "line": { + "type": "number", + "description": "Line for the suggestion" + }, + "suggestion": { + "type": "string", + "description": "Up to 60 chars explanation of the improvement and its benefits" + } + } + } + } + } + } + } + + + { + "reasonIfNoSuggestions": null, + "suggestions": [ + { + "newCode": "{% assign first_product = products | first %}", + "range": { + "start": { + "line": 5, + "character": 0 + }, + "end": { + "line": 7, + "character": 42 + } + }, + "line": 5, + "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." + } + ] + } + + + `; } function themeArchitectureContext(): string { return ` -## THEME ARCHITECTURE + + folder_structure = { + ${Object.keys(THEME_ARCHITECTURE) + .map((key) => `${key}: theme_${key}()`) + .join(',\n ')} + } + + ${Object.entries(THEME_ARCHITECTURE) + .map( + ([key, value]) => ` + theme_${key} = { + ${value.summary} + } + `, + ) + .join('\n\n')} -${Object.entries(THEME_ARCHITECTURE) - .map(([key, value]) => `- ${key}: ${value.summary}`) - .join('\n\n')} -`; + ∀ file ∈ theme: + validate(file.location) ∈ folder_structure; + + + `; } function code(textEditor: TextEditor) { @@ -109,15 +233,29 @@ function code(textEditor: TextEditor) { const text = textEditor.document.getText(selection.isEmpty ? undefined : selection); return ` - ## CODE - + ${text .split('\n') .map((line, index) => `${index + 1 + offset}: ${line}`) .join('\n')} + `; } +function codeMetadata(textEditor: TextEditor) { + const fileName = textEditor.document.fileName; + const fileType = getFileType(fileName); + const fileTip = THEME_ARCHITECTURE[fileType]?.tip ?? 'this is a regular Liquid file'; + + return ` + + - name: ${fileName}, + - type: ${fileType}, + - context: ${fileTip} + + `; +} + function getFileType(path: string): string { const pathWithoutFile = path.substring(0, path.lastIndexOf('/')); const fileTypes = Object.keys(THEME_ARCHITECTURE); @@ -125,389 +263,387 @@ function getFileType(path: string): string { return fileTypes.find((type) => pathWithoutFile.endsWith(type)) || 'none'; } -function codeContext(textEditor: TextEditor) { - const fileName = textEditor.document.fileName; - const fileType = getFileType(fileName); - const fileTip = THEME_ARCHITECTURE[fileType]?.tip ?? 'this is a regular Liquid file'; - +function liquidRules() { return ` - ## CONTEXT + + valid_filters = [ + // Array manipulation + { name: "find", usage: "array | find: string, string" }, + { name: "find_index", usage: "array | find_index: string, string" }, + { name: "reject", usage: "array | reject: string, string" }, + { name: "compact", usage: "array | compact" }, + { name: "concat", usage: "array | concat: array" }, + { name: "join", usage: "array | join" }, + { name: "last", usage: "array | last" }, + { name: "map", usage: "array | map: string" }, + { name: "reverse", usage: "array | reverse" }, + { name: "sort", usage: "array | sort" }, + { name: "sort_natural", usage: "array | sort_natural" }, + { name: "sum", usage: "array | sum" }, + { name: "uniq", usage: "array | uniq" }, + { name: "where", usage: "array | where: string, string" }, + { name: "first", usage: "array | first" }, + { name: "has", usage: "array | has: string, string" }, + + // Collection/Product filters + { name: "item_count_for_variant", usage: "cart | item_count_for_variant: {variant_id}" }, + { name: "line_items_for", usage: "cart | line_items_for: object" }, + { name: "class_list", usage: "settings.layout | class_list" }, + { name: "link_to_type", usage: "string | link_to_type" }, + { name: "link_to_vendor", usage: "string | link_to_vendor" }, + { name: "sort_by", usage: "string | sort_by: string" }, + { name: "url_for_type", usage: "string | url_for_type" }, + { name: "url_for_vendor", usage: "string | url_for_vendor" }, + { name: "within", usage: "string | within: collection" }, + + // Color manipulation + { name: "brightness_difference", usage: "string | brightness_difference: string" }, + { name: "color_brightness", usage: "string | color_brightness" }, + { name: "color_contrast", usage: "string | color_contrast: string" }, + { name: "color_darken", usage: "string | color_darken: number" }, + { name: "color_desaturate", usage: "string | color_desaturate: number" }, + { name: "color_difference", usage: "string | color_difference: string" }, + { name: "color_extract", usage: "string | color_extract: string" }, + { name: "color_lighten", usage: "string | color_lighten: number" }, + { name: "color_mix", usage: "string | color_mix: string, number" }, + { name: "color_modify", usage: "string | color_modify: string, number" }, + { name: "color_saturate", usage: "string | color_saturate: number" }, + { name: "color_to_hex", usage: "string | color_to_hex" }, + { name: "color_to_hsl", usage: "string | color_to_hsl" }, + { name: "color_to_rgb", usage: "string | color_to_rgb" }, + { name: "hex_to_rgba", usage: "string | hex_to_rgba" }, + + // Cryptographic + { name: "hmac_sha1", usage: "string | hmac_sha1: string" }, + { name: "hmac_sha256", usage: "string | hmac_sha256: string" }, + { name: "md5", usage: "string | md5" }, + { name: "sha1", usage: "string | sha1: string" }, + { name: "sha256", usage: "string | sha256: string" }, + + // Customer/Store + { name: "currency_selector", usage: "form | currency_selector" }, + { name: "customer_login_link", usage: "string | customer_login_link" }, + { name: "customer_logout_link", usage: "string | customer_logout_link" }, + { name: "customer_register_link", usage: "string | customer_register_link" }, + + // Asset/Content + { name: "date", usage: "string | date: string" }, + { name: "font_face", usage: "font | font_face" }, + { name: "font_modify", usage: "font | font_modify: string, string" }, + { name: "font_url", usage: "font | font_url" }, + { name: "default_errors", usage: "string | default_errors" }, + { name: "payment_button", usage: "form | payment_button" }, + { name: "payment_terms", usage: "form | payment_terms" }, + { name: "time_tag", usage: "string | time_tag: string" }, + { name: "translate", usage: "string | t" }, + { name: "inline_asset_content", usage: "asset_name | inline_asset_content" }, + + // Data manipulation + { name: "json", usage: "variable | json" }, + { name: "abs", usage: "number | abs" }, + { name: "append", usage: "string | append: string" }, + { name: "at_least", usage: "number | at_least" }, + { name: "at_most", usage: "number | at_most" }, + { name: "base64_decode", usage: "string | base64_decode" }, + { name: "base64_encode", usage: "string | base64_encode" }, + { name: "base64_url_safe_decode", usage: "string | base64_url_safe_decode" }, + { name: "base64_url_safe_encode", usage: "string | base64_url_safe_encode" }, + { name: "capitalize", usage: "string | capitalize" }, + { name: "ceil", usage: "number | ceil" }, + { name: "default", usage: "variable | default: variable" }, + { name: "divided_by", usage: "number | divided_by: number" }, + { name: "downcase", usage: "string | downcase" }, + { name: "escape", usage: "string | escape" }, + { name: "escape_once", usage: "string | escape_once" }, + { name: "floor", usage: "number | floor" }, + { name: "lstrip", usage: "string | lstrip" }, + { name: "minus", usage: "number | minus: number" }, + { name: "modulo", usage: "number | modulo: number" }, + { name: "newline_to_br", usage: "string | newline_to_br" }, + { name: "plus", usage: "number | plus: number" }, + { name: "prepend", usage: "string | prepend: string" }, + { name: "remove", usage: "string | remove: string" }, + { name: "remove_first", usage: "string | remove_first: string" }, + { name: "remove_last", usage: "string | remove_last: string" }, + { name: "replace", usage: "string | replace: string, string" }, + { name: "replace_first", usage: "string | replace_first: string, string" }, + { name: "replace_last", usage: "string | replace_last: string, string" }, + { name: "round", usage: "number | round" }, + { name: "rstrip", usage: "string | rstrip" }, + { name: "size", usage: "variable | size" }, + { name: "slice", usage: "string | slice" }, + { name: "split", usage: "string | split: string" }, + { name: "strip", usage: "string | strip" }, + { name: "strip_html", usage: "string | strip_html" }, + { name: "strip_newlines", usage: "string | strip_newlines" }, + { name: "times", usage: "number | times: number" }, + { name: "truncate", usage: "string | truncate: number" }, + { name: "truncatewords", usage: "string | truncatewords: number" }, + { name: "upcase", usage: "string | upcase" }, + { name: "url_decode", usage: "string | url_decode" }, + { name: "url_encode", usage: "string | url_encode" }, + + // Media + { name: "external_video_tag", usage: "variable | external_video_tag" }, + { name: "external_video_url", usage: "media | external_video_url: attribute: string" }, + { name: "image_tag", usage: "string | image_tag" }, + { name: "media_tag", usage: "media | media_tag" }, + { name: "model_viewer_tag", usage: "media | model_viewer_tag" }, + { name: "video_tag", usage: "media | video_tag" }, + { name: "metafield_tag", usage: "metafield | metafield_tag" }, + { name: "metafield_text", usage: "metafield | metafield_text" }, + + // Money + { name: "money", usage: "number | money" }, + { name: "money_with_currency", usage: "number | money_with_currency" }, + { name: "money_without_currency", usage: "number | money_without_currency" }, + { name: "money_without_trailing_zeros", usage: "number | money_without_trailing_zeros" }, + + // UI/UX + { name: "default_pagination", usage: "paginate | default_pagination" }, + { name: "avatar", usage: "customer | avatar" }, + { name: "login_button", usage: "shop | login_button" }, + { name: "camelize", usage: "string | camelize" }, + { name: "handleize", usage: "string | handleize" }, + { name: "url_escape", usage: "string | url_escape" }, + { name: "url_param_escape", usage: "string | url_param_escape" }, + { name: "structured_data", usage: "variable | structured_data" }, + + // Navigation/Links + { name: "highlight_active_tag", usage: "string | highlight_active_tag" }, + { name: "link_to_add_tag", usage: "string | link_to_add_tag" }, + { name: "link_to_remove_tag", usage: "string | link_to_remove_tag" }, + { name: "link_to_tag", usage: "string | link_to_tag" }, + + // Formatting + { name: "format_address", usage: "address | format_address" }, + { name: "highlight", usage: "string | highlight: string" }, + { name: "pluralize", usage: "number | pluralize: string, string" }, + + // URLs and Assets + { name: "article_img_url", usage: "variable | article_img_url" }, + { name: "asset_img_url", usage: "string | asset_img_url" }, + { name: "asset_url", usage: "string | asset_url" }, + { name: "collection_img_url", usage: "variable | collection_img_url" }, + { name: "file_img_url", usage: "string | file_img_url" }, + { name: "file_url", usage: "string | file_url" }, + { name: "global_asset_url", usage: "string | global_asset_url" }, + { name: "image_url", usage: "variable | image_url: width: number, height: number" }, + { name: "img_tag", usage: "string | img_tag" }, + { name: "img_url", usage: "variable | img_url" }, + { name: "link_to", usage: "string | link_to: string" }, + { name: "payment_type_img_url", usage: "string | payment_type_img_url" }, + { name: "payment_type_svg_tag", usage: "string | payment_type_svg_tag" }, + { name: "placeholder_svg_tag", usage: "string | placeholder_svg_tag" }, + { name: "preload_tag", usage: "string | preload_tag: as: string" }, + { name: "product_img_url", usage: "variable | product_img_url" }, + { name: "script_tag", usage: "string | script_tag" }, + { name: "shopify_asset_url", usage: "string | shopify_asset_url" }, + { name: "stylesheet_tag", usage: "string | stylesheet_tag" }, + { name: "weight_with_unit", usage: "number | weight_with_unit" } + ] + + valid_tags = [ + // Content tags + "content_for", "form", "layout", + + // Variable tags + "assign", "capture", "increment", "decrement", + + // Control flow + "if", "unless", "case", "when", "else", "elsif", + + // Iteration + "for", "break", "continue", "cycle", "tablerow", + + // Output + "echo", "raw", + + // Template + "render", "include", "section", "sections", + + // Style/Script + "javascript", "stylesheet", "style", + + // Utility + "liquid", "comment", "paginate" + ] + + valid_objects = [ + // Core objects + "media", "address", "collections", "pages", "all_products", "app", "discount", "articles", "article", "block", "blogs", "blog", "brand", "cart", "collection", + + // Design/Theme + "brand_color", "color", "color_scheme", "color_scheme_group", "theme", "settings", "template", + + // Business + "company_address", "company", "company_location", "shop", "shop_locale", "policy", + + // Header/Layout + "content_for_header", "content_for_layout", + + // Customer/Commerce + "country", "currency", "customer", "discount_allocation", "discount_application", + + // Media + "external_video", "image", "image_presentation", "images", "video", "video_source", + + // Navigation/Filtering + "filter", "filter_value_display", "filter_value", "linklists", "linklist", + + // Loop controls + "forloop", "tablerowloop", + + // Localization/Markets + "localization", "location", "market", + + // Products/Variants + "measurement", "product", "product_option", "product_option_value", "swatch", "variant", "quantity_price_break", + + // Metadata + "metafield", "metaobject_definition", "metaobject", "metaobject_system", + + // Models/3D + "model", "model_source", + + // Orders/Transactions + "money", "order", "transaction", "transaction_payment_details", + + // Search/Recommendations + "predictive_search", "recommendations", "search", + + // Selling plans + "selling_plan_price_adjustment", "selling_plan_allocation", "selling_plan_allocation_price_adjustment", "selling_plan_checkout_charge", "selling_plan", "selling_plan_group", "selling_plan_group_option", "selling_plan_option", + + // Shipping/Availability + "shipping_method", "store_availability", + + // System/Request + "request", "robots", "routes", "script", "user", "user_agent", + + // Utilities + "focal_point", "font", "form", "fulfillment", "generic_file", "gift_card", "line_item", "link", "page", "paginate", "rating", "recipient", "section", "tax_line", "taxonomy_category", "unit_price_measurement", + + // Additional features + "additional_checkout_buttons", "all_country_option_tags", "canonical_url", "checkout", "comment", "content_for_additional_checkout_buttons", "content_for_index", "country_option_tags", "current_page", "current_tags", "form_errors", "handle", "page_description", "page_image", "page_title", "part", "pending_payment_instruction_input", "powered_by_link", "predictive_search_resources", "quantity_rule", "scripts", "sitemap", "sort_option" + ] - - file name: ${fileName} - - file type: ${fileType} - - file context: ${fileTip} -`; -} + validation_rules = { + syntax: { + - Use {% liquid %} for multiline code + - Use {% # comments %} for inline comments + - Never invent new filters, tags, or objects + - Follow proper tag closing order + - Use proper object dot notation + - Respect object scope and availability + }, -function docsContext() { - return ` - ## DOCS - - - #### LIQUID FILTERS - - - **NAME**: where **USAGE**: array | where: string, string - - **NAME**: reject **USAGE**: array | reject: string, string - - **NAME**: find **USAGE**: array | find: string, string - - **NAME**: find_index **USAGE**: array | find_index: string, string - - **NAME**: has **USAGE**: array | has: string, string - - **NAME**: item_count_for_variant **USAGE**: cart | item_count_for_variant: {variant_id} - - **NAME**: line_items_for **USAGE**: cart | line_items_for: object - - **NAME**: class_list **USAGE**: settings.layout | class_list - - **NAME**: link_to_type **USAGE**: string | link_to_type - - **NAME**: link_to_vendor **USAGE**: string | link_to_vendor - - **NAME**: sort_by **USAGE**: string | sort_by: string - - **NAME**: url_for_type **USAGE**: string | url_for_type - - **NAME**: url_for_vendor **USAGE**: string | url_for_vendor - - **NAME**: within **USAGE**: string | within: collection - - **NAME**: brightness_difference **USAGE**: string | brightness_difference: string - - **NAME**: color_brightness **USAGE**: string | color_brightness - - **NAME**: color_contrast **USAGE**: string | color_contrast: string - - **NAME**: color_darken **USAGE**: string | color_darken: number - - **NAME**: color_desaturate **USAGE**: string | color_desaturate: number - - **NAME**: color_difference **USAGE**: string | color_difference: string - - **NAME**: color_extract **USAGE**: string | color_extract: string - - **NAME**: color_lighten **USAGE**: string | color_lighten: number - - **NAME**: color_mix **USAGE**: string | color_mix: string, number - - **NAME**: color_modify **USAGE**: string | color_modify: string, number - - **NAME**: color_saturate **USAGE**: string | color_saturate: number - - **NAME**: color_to_hex **USAGE**: string | color_to_hex - - **NAME**: color_to_hsl **USAGE**: string | color_to_hsl - - **NAME**: color_to_rgb **USAGE**: string | color_to_rgb - - **NAME**: hex_to_rgba **USAGE**: string | hex_to_rgba - - **NAME**: hmac_sha1 **USAGE**: string | hmac_sha1: string - - **NAME**: hmac_sha256 **USAGE**: string | hmac_sha256: string - - **NAME**: md5 **USAGE**: string | md5 - - **NAME**: sha1 **USAGE**: string | sha1: string - - **NAME**: sha256 **USAGE**: string | sha256: string - - **NAME**: currency_selector **USAGE**: form | currency_selector - - **NAME**: customer_login_link **USAGE**: string | customer_login_link - - **NAME**: customer_logout_link **USAGE**: string | customer_logout_link - - **NAME**: customer_register_link **USAGE**: string | customer_register_link - - **NAME**: date **USAGE**: string | date: string - - **NAME**: font_face **USAGE**: font | font_face - - **NAME**: font_modify **USAGE**: font | font_modify: string, string - - **NAME**: font_url **USAGE**: font | font_url - - **NAME**: default_errors **USAGE**: string | default_errors - - **NAME**: payment_button **USAGE**: form | payment_button - - **NAME**: payment_terms **USAGE**: form | payment_terms - - **NAME**: time_tag **USAGE**: string | time_tag: string - - **NAME**: translate **USAGE**: string | t - - **NAME**: inline_asset_content **USAGE**: asset_name | inline_asset_content - - **NAME**: json **USAGE**: variable | json - - **NAME**: abs **USAGE**: number | abs - - **NAME**: append **USAGE**: string | append: string - - **NAME**: at_least **USAGE**: number | at_least - - **NAME**: at_most **USAGE**: number | at_most - - **NAME**: base64_decode **USAGE**: string | base64_decode - - **NAME**: base64_encode **USAGE**: string | base64_encode - - **NAME**: base64_url_safe_decode **USAGE**: string | base64_url_safe_decode - - **NAME**: base64_url_safe_encode **USAGE**: string | base64_url_safe_encode - - **NAME**: capitalize **USAGE**: string | capitalize - - **NAME**: ceil **USAGE**: number | ceil - - **NAME**: compact **USAGE**: array | compact - - **NAME**: concat **USAGE**: array | concat: array - - **NAME**: default **USAGE**: variable | default: variable - - **NAME**: divided_by **USAGE**: number | divided_by: number - - **NAME**: downcase **USAGE**: string | downcase - - **NAME**: escape **USAGE**: string | escape - - **NAME**: escape_once **USAGE**: string | escape_once - - **NAME**: first **USAGE**: array | first - - **NAME**: floor **USAGE**: number | floor - - **NAME**: join **USAGE**: array | join - - **NAME**: last **USAGE**: array | last - - **NAME**: lstrip **USAGE**: string | lstrip - - **NAME**: map **USAGE**: array | map: string - - **NAME**: minus **USAGE**: number | minus: number - - **NAME**: modulo **USAGE**: number | modulo: number - - **NAME**: newline_to_br **USAGE**: string | newline_to_br - - **NAME**: plus **USAGE**: number | plus: number - - **NAME**: prepend **USAGE**: string | prepend: string - - **NAME**: remove **USAGE**: string | remove: string - - **NAME**: remove_first **USAGE**: string | remove_first: string - - **NAME**: remove_last **USAGE**: string | remove_last: string - - **NAME**: replace **USAGE**: string | replace: string, string - - **NAME**: replace_first **USAGE**: string | replace_first: string, string - - **NAME**: replace_last **USAGE**: string | replace_last: string, string - - **NAME**: reverse **USAGE**: array | reverse - - **NAME**: round **USAGE**: number | round - - **NAME**: rstrip **USAGE**: string | rstrip - - **NAME**: size **USAGE**: variable | size - - **NAME**: slice **USAGE**: string | slice - - **NAME**: sort **USAGE**: array | sort - - **NAME**: sort_natural **USAGE**: array | sort_natural - - **NAME**: split **USAGE**: string | split: string - - **NAME**: strip **USAGE**: string | strip - - **NAME**: strip_html **USAGE**: string | strip_html - - **NAME**: strip_newlines **USAGE**: string | strip_newlines - - **NAME**: sum **USAGE**: array | sum - - **NAME**: times **USAGE**: number | times: number - - **NAME**: truncate **USAGE**: string | truncate: number - - **NAME**: truncatewords **USAGE**: string | truncatewords: number - - **NAME**: uniq **USAGE**: array | uniq - - **NAME**: upcase **USAGE**: string | upcase - - **NAME**: url_decode **USAGE**: string | url_decode - - **NAME**: url_encode **USAGE**: string | url_encode - - **NAME**: external_video_tag **USAGE**: variable | external_video_tag - - **NAME**: external_video_url **USAGE**: media | external_video_url: attribute: string - - **NAME**: image_tag **USAGE**: string | image_tag - - **NAME**: media_tag **USAGE**: media | media_tag - - **NAME**: model_viewer_tag **USAGE**: media | model_viewer_tag - - **NAME**: video_tag **USAGE**: media | video_tag - - **NAME**: metafield_tag **USAGE**: metafield | metafield_tag - - **NAME**: metafield_text **USAGE**: metafield | metafield_text - - **NAME**: money **USAGE**: number | money - - **NAME**: money_with_currency **USAGE**: number | money_with_currency - - **NAME**: money_without_currency **USAGE**: number | money_without_currency - - **NAME**: money_without_trailing_zeros **USAGE**: number | money_without_trailing_zeros - - **NAME**: default_pagination **USAGE**: paginate | default_pagination - - **NAME**: avatar **USAGE**: customer | avatar - - **NAME**: login_button **USAGE**: shop | login_button - - **NAME**: camelize **USAGE**: string | camelize - - **NAME**: handleize **USAGE**: string | handleize - - **NAME**: url_escape **USAGE**: string | url_escape - - **NAME**: url_param_escape **USAGE**: string | url_param_escape - - **NAME**: structured_data **USAGE**: variable | structured_data - - **NAME**: highlight_active_tag **USAGE**: string | highlight_active_tag - - **NAME**: link_to_add_tag **USAGE**: string | link_to_add_tag - - **NAME**: link_to_remove_tag **USAGE**: string | link_to_remove_tag - - **NAME**: link_to_tag **USAGE**: string | link_to_tag - - **NAME**: format_address **USAGE**: address | format_address - - **NAME**: highlight **USAGE**: string | highlight: string - - **NAME**: pluralize **USAGE**: number | pluralize: string, string - - **NAME**: article_img_url **USAGE**: variable | article_img_url - - **NAME**: asset_img_url **USAGE**: string | asset_img_url - - **NAME**: asset_url **USAGE**: string | asset_url - - **NAME**: collection_img_url **USAGE**: variable | collection_img_url - - **NAME**: file_img_url **USAGE**: string | file_img_url - - **NAME**: file_url **USAGE**: string | file_url - - **NAME**: global_asset_url **USAGE**: string | global_asset_url - - **NAME**: image_url **USAGE**: variable | image_url: width: number, height: number - - **NAME**: img_tag **USAGE**: string | img_tag - - **NAME**: img_url **USAGE**: variable | img_url - - **NAME**: link_to **USAGE**: string | link_to: string - - **NAME**: payment_type_img_url **USAGE**: string | payment_type_img_url - - **NAME**: payment_type_svg_tag **USAGE**: string | payment_type_svg_tag - - **NAME**: placeholder_svg_tag **USAGE**: string | placeholder_svg_tag - - **NAME**: preload_tag **USAGE**: string | preload_tag: as: string - - **NAME**: product_img_url **USAGE**: variable | product_img_url - - **NAME**: script_tag **USAGE**: string | script_tag - - **NAME**: shopify_asset_url **USAGE**: string | shopify_asset_url - - **NAME**: stylesheet_tag **USAGE**: string | stylesheet_tag - - **NAME**: weight_with_unit **USAGE**: number | weight_with_unit - - - #### LIQUID TAGS - - - **NAME**: content_for - - **NAME**: form - - **NAME**: layout - - **NAME**: assign - - **NAME**: break - - **NAME**: capture - - **NAME**: case - - **NAME**: comment - - **NAME**: continue - - **NAME**: cycle - - **NAME**: decrement - - **NAME**: echo - - **NAME**: for - - **NAME**: if - - **NAME**: include - - **NAME**: increment - - **NAME**: raw - - **NAME**: render - - **NAME**: tablerow - - **NAME**: unless - - **NAME**: paginate - - **NAME**: javascript - - **NAME**: section - - **NAME**: stylesheet - - **NAME**: sections - - **NAME**: style - - **NAME**: else - - **NAME**: else - - **NAME**: liquid - - - #### LIQUID OBJECTS - - - **NAME**: media - - **NAME**: address - - **NAME**: collections - - **NAME**: pages - - **NAME**: all_products - - **NAME**: app - - **NAME**: discount - - **NAME**: articles - - **NAME**: article - - **NAME**: block - - **NAME**: blogs - - **NAME**: blog - - **NAME**: brand - - **NAME**: cart - - **NAME**: collection - - **NAME**: brand_color - - **NAME**: color - - **NAME**: color_scheme - - **NAME**: color_scheme_group - - **NAME**: company_address - - **NAME**: company - - **NAME**: company_location - - **NAME**: content_for_header - - **NAME**: country - - **NAME**: currency - - **NAME**: customer - - **NAME**: discount_allocation - - **NAME**: discount_application - - **NAME**: external_video - - **NAME**: filter - - **NAME**: filter_value_display - - **NAME**: filter_value - - **NAME**: focal_point - - **NAME**: font - - **NAME**: form - - **NAME**: fulfillment - - **NAME**: generic_file - - **NAME**: gift_card - - **NAME**: image - - **NAME**: image_presentation - - **NAME**: images - - **NAME**: line_item - - **NAME**: link - - **NAME**: linklists - - **NAME**: linklist - - **NAME**: forloop - - **NAME**: tablerowloop - - **NAME**: localization - - **NAME**: location - - **NAME**: market - - **NAME**: measurement - - **NAME**: metafield - - **NAME**: metaobject_definition - - **NAME**: metaobject - - **NAME**: metaobject_system - - **NAME**: model - - **NAME**: model_source - - **NAME**: money - - **NAME**: order - - **NAME**: page - - **NAME**: paginate - - **NAME**: predictive_search - - **NAME**: selling_plan_price_adjustment - - **NAME**: product - - **NAME**: product_option - - **NAME**: product_option_value - - **NAME**: swatch - - **NAME**: variant - - **NAME**: quantity_price_break - - **NAME**: rating - - **NAME**: recipient - - **NAME**: recommendations - - **NAME**: request - - **NAME**: robots - - **NAME**: group - - **NAME**: rule - - **NAME**: routes - - **NAME**: script - - **NAME**: search - - **NAME**: section - - **NAME**: selling_plan_allocation - - **NAME**: selling_plan_allocation_price_adjustment - - **NAME**: selling_plan_checkout_charge - - **NAME**: selling_plan - - **NAME**: selling_plan_group - - **NAME**: selling_plan_group_option - - **NAME**: selling_plan_option - - **NAME**: shipping_method - - **NAME**: shop - - **NAME**: shop_locale - - **NAME**: policy - - **NAME**: store_availability - - **NAME**: tax_line - - **NAME**: taxonomy_category - - **NAME**: theme - - **NAME**: settings - - **NAME**: template - - **NAME**: transaction - - **NAME**: unit_price_measurement - - **NAME**: user - - **NAME**: video - - **NAME**: video_source - - **NAME**: additional_checkout_buttons - - **NAME**: all_country_option_tags - - **NAME**: canonical_url - - **NAME**: checkout - - **NAME**: comment - - **NAME**: content_for_additional_checkout_buttons - - **NAME**: content_for_index - - **NAME**: content_for_layout - - **NAME**: country_option_tags - - **NAME**: current_page - - **NAME**: current_tags - - **NAME**: form_errors - - **NAME**: handle - - **NAME**: page_description - - **NAME**: page_image - - **NAME**: page_title - - **NAME**: part - - **NAME**: pending_payment_instruction_input - - **NAME**: powered_by_link - - **NAME**: predictive_search_resources - - **NAME**: quantity_rule - - **NAME**: scripts - - **NAME**: sitemap - - **NAME**: sort_option - - **NAME**: transaction_payment_details - - **NAME**: user_agent + theme_structure: { + - Place files in appropriate directories + - Follow naming conventions + - Respect template hierarchy + - Maintain proper section/block structure + - Use appropriate schema settings + } + } + + ∀ liquid_code ∈ theme: + validate_syntax(liquid_code) ∧ + validate_filters(liquid_code.filters ∈ valid_filters) ∧ + validate_tags(liquid_code.tags ∈ valid_tags) ∧ + validate_objects(liquid_code.objects ∈ valid_objects) ∧ + validate_structure(liquid_code.location ∈ theme_structure) + `; } const THEME_ARCHITECTURE: { [key: string]: { summary: string; tip?: string } } = { sections: { - summary: `Liquid files that define customizable sections of a page. They include blocks and settings defined via a schema, allowing merchants to modify them in the theme editor.`, - tip: `As sections grow in complexity, consider extracting reusable parts into snippets for better maintainability. Also look for opportunities to make components more flexible by moving hardcoded values into section settings that merchants can customize.`, + summary: ` + - Liquid files that define customizable sections of a page + - They include blocks and settings defined via a schema, allowing merchants to modify them in the theme editor + `, + tip: ` + - As sections grow in complexity, consider extracting reusable parts into snippets for better maintainability + - Also look for opportunities to make components more flexible by moving hardcoded values into section settings that merchants can customize + `, }, blocks: { - summary: `Configurable elements within sections that can be added, removed, or reordered. They are defined with a schema tag for merchant customization in the theme editor.`, - tip: `Break blocks into smaller, focused components that each do one thing well. Look for opportunities to extract repeated patterns into separate block types. Make blocks more flexible by moving hardcoded values into schema settings, but keep each block's schema simple and focused on its specific purpose.`, + summary: ` + - Configurable elements within sections that can be added, removed, or reordered + - They are defined with a schema tag for merchant customization in the theme editor + `, + tip: ` + - Break blocks into smaller, focused components that each do one thing well + - Look for opportunities to extract repeated patterns into separate block types + - Make blocks more flexible by moving hardcoded values into schema settings, but keep each block's schema simple and focused on its specific purpose + `, }, layout: { - summary: `Defines the structure for repeated content such as headers and footers, wrapping other template files.`, - tip: `Keep layouts focused on structural elements and look for opportunities to extract components into sections. Headers, footers, navigation menus, and other reusable elements should be sections to enable merchant customization through the theme editor.`, + summary: ` + - Defines the structure for repeated content such as headers and footers, wrapping other template files + - It's the frame that holds the page together, but it's not the content + `, + tip: ` + - Keep layouts focused on structural elements + - Look for opportunities to extract components into sections + - Headers, footers, navigation menus, and other reusable elements should be sections to enable merchant customization through the theme editor + `, }, snippets: { - summary: `Reusable code fragments included in templates, sections, and layouts via the render tag. Ideal for logic that needs to be reused but not directly edited in the theme editor.`, - tip: `Keep snippets focused on a single responsibility. They should always have a header that documents expected inputs, like this: - {% doc %} - Renders loading-spinner. - - @param {string} foo - some foo - @param {string} [bar] - optional bar - - @example - {% render 'loading-spinner', foo: 'foo' %} - {% render 'loading-spinner', foo: 'foo', bar: 'bar' %} - {% enddoc %}`, + summary: ` + - Reusable code fragments included in templates, sections, and layouts via the render tag + - Ideal for logic that needs to be reused but not directly edited in the theme editor + `, + tip: ` + - We must have a {% doc %} in snippets + - Keep snippets focused on a single responsibility + - Use variables to make snippets more reusable + - Add a header comment block that documents expected inputs, dependencies, and any required objects/variables that need to be passed to the snippet + + {% doc %} + Renders loading-spinner. + + @param {string} foo - some foo + @param {string} [bar] - optional bar + + @example + {% render 'loading-spinner', foo: 'foo' %} + {% render 'loading-spinner', foo: 'foo', bar: 'bar' %} + {% enddoc %} + + `, + }, + config: { + summary: ` + - Holds settings data and schema for theme customization options like typography and colors, accessible through the Admin theme editor. + `, + }, + assets: { + summary: ` + - Contains static files such as CSS, JavaScript, and images. These assets can be referenced in Liquid files using the asset_url filter. + `, + }, + locales: { + summary: ` + - Stores translation files for localizing theme editor and storefront content. + `, + }, + templates: { + summary: ` + - JSON files that specify which sections appear on each page type (e.g., product, collection, blog). + - They are wrapped by layout files for consistent header/footer content. + - Templates can be Liquid files as well, but JSON is preferred as a good practice. + `, + }, + 'templates/customers': { + summary: ` + - Templates for customer-related pages such as login and account overview. + `, + }, + 'templates/metaobject': { + summary: ` + - Templates for rendering custom content types defined as metaobjects. + `, }, - - // Removing the types below as they, generally, are not Liquid files. - // config: { - // summary: `Holds settings data and schema for theme customization options like typography and colors, accessible through the Admin theme editor.`, - // }, - // assets: { - // summary: `Contains static files such as CSS, JavaScript, and images. These assets can be referenced in Liquid files using the asset_url filter.`, - // }, - // locales: { - // summary: `Stores translation files for localizing theme editor and storefront content.`, - // }, - // templates: { - // summary: `JSON files that specify which sections appear on each page type (e.g., product, collection, blog). They are wrapped by layout files for consistent header/footer content.`, - // }, - // 'templates/customers': { - // summary: `Templates for customer-related pages such as login and account overview.`, - // }, - // 'templates/metaobject': { - // summary: `Templates for rendering custom content types defined as metaobjects.`, - // }, }; diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index f73c9b8d3..b3fc5a468 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -85,7 +85,7 @@ function buildSidekickDecoration( hoverMessage: createHoverMessage(type.key, liquidSuggestion), }; - return [{ type, options }]; + return [{ type, options }]; } async function parseChatResponse(chatResponse: LanguageModelChatResponse) { @@ -117,7 +117,7 @@ export function log(message?: any, ...optionalParams: any[]) { function createHoverMessage(key: string, liquidSuggestion: LiquidSuggestion) { const hoverUrlArgs = encodeURIComponent(JSON.stringify({ key, ...liquidSuggestion })); const hoverMessage = new MarkdownString( - `✨ ${liquidSuggestion.suggestion} + `#### ✨ Sidekick suggestion\n ${liquidSuggestion.suggestion} \n\n[Quick fix](command:shopifyLiquid.sidefix?${hoverUrlArgs})`, ); From f816e2bdee8cf7ba59f6df1b8a7d08c58917a14a Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 13 Dec 2024 13:40:40 +0100 Subject: [PATCH 23/25] Remove duplicated functions; extract file creation to a method because the early return was preventing the language server initialization when the instrunction file was already there --- .../vscode-extension/src/node/extension.ts | 239 +++++++----------- .../vscode-extension/src/node/sidekick.ts | 14 +- 2 files changed, 108 insertions(+), 145 deletions(-) diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 7479f2293..e3dc30cb2 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -26,159 +26,24 @@ import { vscodePrettierFormat } from './formatter'; import { getSidekickAnalysis, LiquidSuggestion, log, SidekickDecoration } from './sidekick'; type LiquidSuggestionWithDecorationKey = LiquidSuggestion & { key: string }; - -const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); - -let $client: LanguageClient | undefined; -let $editor: TextEditor | undefined; -let $decorations: TextEditorDecorationType[] = []; -let $isApplyingSuggestion = false; -let $previousShownConflicts = new Map(); - -async function isShopifyTheme(workspaceRoot: string): Promise { - try { - // Check for typical Shopify theme folders - const requiredFolders = ['sections', 'templates', 'assets', 'config']; - for (const folder of requiredFolders) { - const folderUri = Uri.file(path.join(workspaceRoot, folder)); - try { - await workspace.fs.stat(folderUri); - } catch { - return false; - } - } - return true; - } catch { - return false; - } -} - -function isCursor(): boolean { - // Check if we're running in Cursor's electron process - const processTitle = process.title.toLowerCase(); - const isElectronCursor = - processTitle.includes('cursor') && process.versions.electron !== undefined; - - // Check for Cursor-specific environment variables that are set by Cursor itself - const hasCursorEnv = - process.env.CURSOR_CHANNEL !== undefined || process.env.CURSOR_VERSION !== undefined; - - return isElectronCursor || hasCursorEnv; -} - interface ConfigFile { path: string; templateName: string; prompt: string; } -async function getConfigFileDetails(workspaceRoot: string): Promise { - if (isCursor()) { - return { - path: path.join(workspaceRoot, '.cursorrules'), - templateName: 'llm-instructions.template', - prompt: - 'Detected Shopify theme project in Cursor. Do you want a .cursorrules file to be created?', - }; - } - return { - path: path.join(workspaceRoot, '.github', 'copilot-instructions.md'), - templateName: 'llm-instructions.template', - prompt: - 'Detected Shopify theme project in VSCode. Do you want a Copilot instructions file to be created?', - }; -} - -async function isShopifyTheme(workspaceRoot: string): Promise { - try { - // Check for typical Shopify theme folders - const requiredFolders = ['sections', 'templates', 'assets', 'config']; - for (const folder of requiredFolders) { - const folderUri = Uri.file(path.join(workspaceRoot, folder)); - try { - await workspace.fs.stat(folderUri); - } catch { - return false; - } - } - return true; - } catch { - return false; - } -} - -function isCursor(): boolean { - // Check if we're running in Cursor's electron process - const processTitle = process.title.toLowerCase(); - const isElectronCursor = - processTitle.includes('cursor') && process.versions.electron !== undefined; - - // Check for Cursor-specific environment variables that are set by Cursor itself - const hasCursorEnv = - process.env.CURSOR_CHANNEL !== undefined || process.env.CURSOR_VERSION !== undefined; - - return isElectronCursor || hasCursorEnv; -} - -interface ConfigFile { - path: string; - templateName: string; - prompt: string; -} +let $client: LanguageClient | undefined; +let $editor: TextEditor | undefined; +let $decorations: TextEditorDecorationType[] = []; +let $isApplyingSuggestion = false; +let $previousShownConflicts = new Map(); -async function getConfigFileDetails(workspaceRoot: string): Promise { - if (isCursor()) { - return { - path: path.join(workspaceRoot, '.cursorrules'), - templateName: 'llm-instructions.template', - prompt: - 'Detected Shopify theme project in Cursor. Do you want a .cursorrules file to be created?', - }; - } - return { - path: path.join(workspaceRoot, '.github', 'copilot-instructions.md'), - templateName: 'llm-instructions.template', - prompt: - 'Detected Shopify theme project in VSCode. Do you want a Copilot instructions file to be created?', - }; -} +const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); export async function activate(context: ExtensionContext) { const runChecksCommand = 'themeCheck/runChecks'; - if (workspace.workspaceFolders?.length) { - const workspaceRoot = workspace.workspaceFolders[0].uri.fsPath; - const instructionsConfig = await getConfigFileDetails(workspaceRoot); - - // Don't do anything if the file already exists - try { - await workspace.fs.stat(Uri.file(instructionsConfig.path)); - return; - } catch { - // File doesn't exist, continue - } - - if (await isShopifyTheme(workspaceRoot)) { - const response = await window.showInformationMessage(instructionsConfig.prompt, 'Yes', 'No'); - - if (response === 'Yes') { - // Create directory if it doesn't exist (needed for .github case) - const dir = path.dirname(instructionsConfig.path); - try { - await workspace.fs.createDirectory(Uri.file(dir)); - } catch { - // Directory might already exist, continue - } - - // Read the template file from the extension's resources - const templateContent = await workspace.fs.readFile( - Uri.file(context.asAbsolutePath(`resources/${instructionsConfig.templateName}`)), - ); - await workspace.fs.writeFile(Uri.file(instructionsConfig.path), templateContent); - console.log(`Wrote instructions file to ${instructionsConfig.path}`); - } - } - } + await createInstructionsFileIfNeeded(context); context.subscriptions.push( commands.registerCommand('shopifyLiquid.restart', () => restartServer(context)), @@ -398,3 +263,93 @@ async function applySuggestion({ key, range, newCode }: LiquidSuggestionWithDeco $isApplyingSuggestion = false; } + +async function isShopifyTheme(workspaceRoot: string): Promise { + try { + // Check for typical Shopify theme folders + const requiredFolders = ['sections', 'templates', 'assets', 'config']; + for (const folder of requiredFolders) { + const folderUri = Uri.file(path.join(workspaceRoot, folder)); + try { + await workspace.fs.stat(folderUri); + } catch { + return false; + } + } + return true; + } catch { + return false; + } +} + +function isCursor(): boolean { + try { + // Check if we're running in Cursor's electron process + const processTitle = process.title.toLowerCase(); + const isElectronCursor = + processTitle.includes('cursor') && process.versions.electron !== undefined; + + // Check for Cursor-specific environment variables that are set by Cursor itself + const hasCursorEnv = + process.env.CURSOR_CHANNEL !== undefined || process.env.CURSOR_VERSION !== undefined; + + return isElectronCursor || hasCursorEnv; + } catch { + return false; + } +} + +async function getConfigFileDetails(workspaceRoot: string): Promise { + if (isCursor()) { + return { + path: path.join(workspaceRoot, '.cursorrules'), + templateName: 'llm-instructions.template', + prompt: + 'Detected Shopify theme project in Cursor. Do you want a .cursorrules file to be created?', + }; + } + return { + path: path.join(workspaceRoot, '.github', 'copilot-instructions.md'), + templateName: 'llm-instructions.template', + prompt: + 'Detected Shopify theme project in VSCode. Do you want a Copilot instructions file to be created?', + }; +} + +async function createInstructionsFileIfNeeded(context: ExtensionContext) { + if (!workspace.workspaceFolders?.length) { + return; + } + + const workspaceRoot = workspace.workspaceFolders[0].uri.fsPath; + const instructionsConfig = await getConfigFileDetails(workspaceRoot); + + // Don't do anything if the file already exists + try { + await workspace.fs.stat(Uri.file(instructionsConfig.path)); + return; + } catch { + // File doesn't exist, continue + } + + if (await isShopifyTheme(workspaceRoot)) { + const response = await window.showInformationMessage(instructionsConfig.prompt, 'Yes', 'No'); + + if (response === 'Yes') { + // Create directory if it doesn't exist (needed for .github case) + const dir = path.dirname(instructionsConfig.path); + try { + await workspace.fs.createDirectory(Uri.file(dir)); + } catch { + // Directory might already exist, continue + } + + // Read the template file from the extension's resources + const templateContent = await workspace.fs.readFile( + Uri.file(context.asAbsolutePath(`resources/${instructionsConfig.templateName}`)), + ); + await workspace.fs.writeFile(Uri.file(instructionsConfig.path), templateContent); + log(`Wrote instructions file to ${instructionsConfig.path}`); + } + } +} diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts index b3fc5a468..b19cf57b8 100644 --- a/packages/vscode-extension/src/node/sidekick.ts +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -75,7 +75,7 @@ function buildSidekickDecoration( liquidSuggestion: LiquidSuggestion, ): SidekickDecoration[] { const { suggestion, range } = liquidSuggestion; - const type = createTextEditorDecorationType(suggestion.substring(0, 120)); + const type = createTextEditorDecorationType(suggestion); const line = Math.max(0, range.start.line - 1); const options = { range: new Range( @@ -111,7 +111,7 @@ async function parseChatResponse(chatResponse: LanguageModelChatResponse) { } export function log(message?: any, ...optionalParams: any[]) { - console.error(` [Sidekick] ${message}`, ...optionalParams); + console.error(`[Sidekick] ${message}`, ...optionalParams); } function createHoverMessage(key: string, liquidSuggestion: LiquidSuggestion) { @@ -130,7 +130,7 @@ function createHoverMessage(key: string, liquidSuggestion: LiquidSuggestion) { function createTextEditorDecorationType(text: string) { return window.createTextEditorDecorationType({ after: { - contentText: ` ✨ ${text}...`, + contentText: ` ✨ ${truncate(text, 120)}`, color: 'grey', fontStyle: 'italic', backgroundColor: 'rgba(255, 255, 255, 0.05)', @@ -138,3 +138,11 @@ function createTextEditorDecorationType(text: string) { }, }); } + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + + return text.substring(0, maxLength).trim() + '...'; +} From bdd6da0314b596a258a3dc7f98e5b8052bae6d6c Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 13 Dec 2024 14:06:44 +0100 Subject: [PATCH 24/25] Fine tuning prompt --- packages/vscode-extension/src/node/sidekick-messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts index 53b07296d..874cd130d 100644 --- a/packages/vscode-extension/src/node/sidekick-messages.ts +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -42,7 +42,7 @@ function basePrompt(textEditor: TextEditor): string { The new code you propose contain full lines of valid code and keep the correct indentation, scope, and style format as the original code Scopes are defined by the opened by "{%", "{{" with the matching closing element "%}" or "}}" - The range of the code being edited with "{%"/"{{" must encopass the matching closing element "%}"/"}}" (they should be even, never odd) + The range must include the closing element ("%}","}}") for every opening element ("{%","{{") Code suggestions cannot overlap in line numbers. If you have multiple suggestions for the same code chunk, merge them into a single suggestion Make full-scope suggestions that consider the entire context of the code you are modifying, keeping the logical scope of the code valid The resulting code must work and should not break existing HTML tags or Liquid syntax From 6d0cf71d48b4090dbd922eaf0628e7a5aee2062f Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 13 Dec 2024 14:11:14 +0100 Subject: [PATCH 25/25] Remove unused items --- .../images/sidekick-black.svg | 1 - .../images/sidekick-white.svg | 1 - .../src/common/completionProvider.ts | 104 ------------------ 3 files changed, 106 deletions(-) delete mode 100644 packages/vscode-extension/images/sidekick-black.svg delete mode 100644 packages/vscode-extension/images/sidekick-white.svg delete mode 100644 packages/vscode-extension/src/common/completionProvider.ts diff --git a/packages/vscode-extension/images/sidekick-black.svg b/packages/vscode-extension/images/sidekick-black.svg deleted file mode 100644 index c9eb182d6..000000000 --- a/packages/vscode-extension/images/sidekick-black.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/vscode-extension/images/sidekick-white.svg b/packages/vscode-extension/images/sidekick-white.svg deleted file mode 100644 index e8c83fb8a..000000000 --- a/packages/vscode-extension/images/sidekick-white.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/vscode-extension/src/common/completionProvider.ts b/packages/vscode-extension/src/common/completionProvider.ts deleted file mode 100644 index 2aabb6ce1..000000000 --- a/packages/vscode-extension/src/common/completionProvider.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* eslint-disable no-empty */ -/* eslint-disable no-unused-vars */ - -import { - InlineCompletionContext, - InlineCompletionItem, - InlineCompletionItemProvider, - LanguageModelChatMessage, - lm, - Position, - Range, - TextDocument, -} from 'vscode'; -import { CancellationToken, CancellationTokenSource } from 'vscode-languageclient'; - -const ANNOTATION_PROMPT = `You are a code tutor who helps liquid developers learn to use modern Liquid features. - -Your job is to evaluate a block of code and suggest opportunities to use newer Liquid filters and tags that could improve the code. Look specifically for: - -1. For loops that could be simplified using the new 'find' filter -2. Array operations that could use 'map', 'where', or other newer filters -3. Complex logic that could be simplified with 'case/when' -4. Instead of "array | where: field, value | first", use "array | find: field, value" -5. Your response must be a parsable json - -Format your response as a JSON object with the following structure: -{ - "range": { - "start": {"line": , "character": }, - "end": {"line": , "character": } - }, - "newCode": "The suggested code that will replace the current code", - "line": , - "suggestion": "Friendly explanation of how and why to use the new feature" -} - -Example respons (): -{ - "range": { - "start": {"line": 5, "character": 0}, - "end": {"line": 7, "character": 42} - }, - "newCode": "{% assign first_product = products | first %}", - "line": 5, - "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent." -} -`; - -export default class LiquidCompletionProvider implements InlineCompletionItemProvider { - async provideInlineCompletionItems( - document: TextDocument, - _position: Position, - _context: InlineCompletionContext, - _token: CancellationToken, - ) { - console.error('[SERVER] inline completion'); - - let [model] = await lm.selectChatModels({ - vendor: 'copilot', - family: 'gpt-4o', - }); - - function getVisibleCodeWithLineNumbers(document: TextDocument) { - let code = ''; - const lines = document.getText().split('\n'); - for (let i = 0; i < lines.length; i++) { - code += `${i + 1}: ${lines[i]}\n`; - } - return code; - } - - const codeWithLineNumbers = getVisibleCodeWithLineNumbers(document); - - const messages = [ - LanguageModelChatMessage.User(ANNOTATION_PROMPT), - LanguageModelChatMessage.User(codeWithLineNumbers), - ]; - - let accumulatedResponse = ''; - let annotation: any = {}; - - if (model) { - let chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token); - - for await (const fragment of chatResponse.text) { - accumulatedResponse += fragment; - - if (fragment.includes('}')) { - try { - annotation = JSON.parse(accumulatedResponse.replace('```json', '')); - accumulatedResponse = ''; - } catch (e) {} - } - } - } - - // const range = new Range( - // new Position(annotation.range.start.line - 1, annotation.range.start.character), - // new Position(annotation.range.end.line - 1, annotation.range.end.character), - // ); - - return [new InlineCompletionItem(annotation.newCode)]; - } -}