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/package.json b/packages/vscode-extension/package.json index ed02fa44a..b72e30eee 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -99,8 +99,36 @@ { "command": "shopifyLiquid.runChecks", "title": "Liquid Theme Check: Run Checks" + }, + { + "command": "shopifyLiquid.sidefix", + "title": "Apply Sidekick suggestion" + }, + { + "command": "shopifyLiquid.sidekick", + "title": "✨ Sidekick", + "enablement": "!shopifyLiquid.sidekick.isLoading" + }, + { + "command": "shopifyLiquid.sidekickLoading", + "title": "✨ Analyzing", + "enablement": "false" } ], + "menus": { + "editor/title": [ + { + "command": "shopifyLiquid.sidekick", + "group": "navigation", + "when": "!shopifyLiquid.sidekick.isLoading" + }, + { + "command": "shopifyLiquid.sidekickLoading", + "group": "navigation", + "when": "shopifyLiquid.sidekick.isLoading" + } + ] + }, "configuration": { "title": "Shopify Liquid | Syntax Highlighting & Linter by Shopify", "properties": { diff --git a/packages/vscode-extension/resources/llm-instructions.template b/packages/vscode-extension/resources/llm-instructions.template new file mode 100644 index 000000000..e0f0831ae --- /dev/null +++ b/packages/vscode-extension/resources/llm-instructions.template @@ -0,0 +1,378 @@ + + +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 + + + +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" +} + +∀ implementation_detail: + verify_against(documentation); + + + +{ + 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 +} + + + +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) + + \ No newline at end of file diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts index 67f58ce54..e3dc30cb2 100644 --- a/packages/vscode-extension/src/node/extension.ts +++ b/packages/vscode-extension/src/node/extension.ts @@ -1,6 +1,18 @@ 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, + Position, + Range, + TextEditor, + TextEditorDecorationType, + Uri, + window, + workspace, + WorkspaceEdit, +} from 'vscode'; import { DocumentSelector, LanguageClient, @@ -11,20 +23,34 @@ import { import { documentSelectors } from '../common/constants'; import LiquidFormatter from '../common/formatter'; import { vscodePrettierFormat } from './formatter'; +import { getSidekickAnalysis, LiquidSuggestion, log, SidekickDecoration } from './sidekick'; -const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); +type LiquidSuggestionWithDecorationKey = LiquidSuggestion & { key: string }; +interface ConfigFile { + path: string; + templateName: string; + prompt: string; +} -let client: LanguageClient | undefined; +let $client: LanguageClient | undefined; +let $editor: TextEditor | undefined; +let $decorations: TextEditorDecorationType[] = []; +let $isApplyingSuggestion = false; +let $previousShownConflicts = new Map(); + +const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); export async function activate(context: ExtensionContext) { const runChecksCommand = 'themeCheck/runChecks'; + await createInstructionsFileIfNeeded(context); + context.subscriptions.push( commands.registerCommand('shopifyLiquid.restart', () => restartServer(context)), ); context.subscriptions.push( commands.registerCommand('shopifyLiquid.runChecks', () => { - client!.sendRequest('workspace/executeCommand', { command: runChecksCommand }); + $client!.sendRequest('workspace/executeCommand', { command: runChecksCommand }); }), ); context.subscriptions.push( @@ -33,6 +59,71 @@ export async function activate(context: ExtensionContext) { new LiquidFormatter(vscodePrettierFormat), ), ); + context.subscriptions.push( + commands.registerTextEditorCommand('shopifyLiquid.sidekick', async (textEditor: TextEditor) => { + $editor = textEditor; + + log('Sidekick is analyzing...'); + 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( + commands.registerCommand( + 'shopifyLiquid.sidefix', + async (suggestion: LiquidSuggestionWithDecorationKey) => { + log('Sidekick is fixing...'); + + applySuggestion(suggestion); + }, + ), + ); + // context.subscriptions.push( + // languages.registerInlineCompletionItemProvider( + // [{ language: 'liquid' }], + // new LiquidCompletionProvider(), + // ), + // ); + + context.subscriptions.push( + 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); + }), + ); await startServer(context); } @@ -55,44 +146,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); @@ -115,3 +206,150 @@ 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]); + }); +} + +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); + const oldCode = $editor.document.getText(new Range(start, end)); + const initialIndentation = oldCode.match(/^[ \t]+/)?.[0] ?? ''; + + // Create a merge conflict style text + const conflictText = [ + conflictMarkerStart, + oldCode, + conflictMarkerMiddle, + newCode.replace(/^/gm, initialIndentation), + conflictMarkerEnd, + ].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); + + // 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; +} + +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-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts new file mode 100644 index 000000000..874cd130d --- /dev/null +++ b/packages/vscode-extension/src/node/sidekick-messages.ts @@ -0,0 +1,649 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { LanguageModelChatMessage, TextEditor } from 'vscode'; + +export function buildMessages(textEditor: TextEditor) { + const prompt = [ + basePrompt(textEditor), + code(textEditor), + codeMetadata(textEditor), + liquidRules(), + themeArchitectureContext(), + ]; + + return prompt.map((message) => LanguageModelChatMessage.User(message)); +} + +function basePrompt(textEditor: TextEditor): string { + const numberOfSuggestions = textEditor.selection.isEmpty ? 5 : 1; + + return ` + + + + 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 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 + 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 ` + + 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')} + + ∀ file ∈ theme: + validate(file.location) ∈ folder_structure; + + + `; +} + +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')} + +`; +} + +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); + + return fileTypes.find((type) => pathWithoutFile.endsWith(type)) || 'none'; +} + +function liquidRules() { + return ` + + 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" + ] + + 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) + + `; +} + +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 + `, + 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 + - 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: ` + - 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. + `, + }, +}; diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts new file mode 100644 index 000000000..b19cf57b8 --- /dev/null +++ b/packages/vscode-extension/src/node/sidekick.ts @@ -0,0 +1,148 @@ +import { + CancellationTokenSource, + DecorationOptions, + LanguageModelChatResponse, + lm, + MarkdownString, + Position, + Range, + TextEditor, + TextEditorDecorationType, + window, +} from 'vscode'; +import { buildMessages } from './sidekick-messages'; + +/** 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; +} + +/** 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 getSidekickAnalysis(textEditor: TextEditor): Promise { + const [model] = await lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o', + }); + + if (!model) { + log('No language model available'); + return []; + } + + try { + const messages = buildMessages(textEditor); + const chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token); + const jsonResponse = await parseChatResponse(chatResponse); + + if (!Array.isArray(jsonResponse?.suggestions)) { + log('Invalid response from language model', jsonResponse); + return []; + } + + log( + `Received response from language model with ${jsonResponse?.suggestions?.length} suggestions.`, + ); + + if (jsonResponse.suggestions.length === 0) { + log(jsonResponse.reasonIfNoSuggestions ?? 'No suggestions provided'); + return []; + } + + return jsonResponse.suggestions.flatMap(buildSidekickDecoration.bind(null, textEditor)); + } catch (err) { + log('Error during language model request', err); + } + + return []; +} + +function buildSidekickDecoration( + editor: TextEditor, + liquidSuggestion: LiquidSuggestion, +): SidekickDecoration[] { + const { suggestion, range } = liquidSuggestion; + const type = createTextEditorDecorationType(suggestion); + const line = Math.max(0, range.start.line - 1); + const options = { + range: new Range( + new Position(line, 0), + new Position(line, editor.document.lineAt(line).text.length), + ), + hoverMessage: createHoverMessage(type.key, liquidSuggestion), + }; + + return [{ type, options }]; +} + +async function parseChatResponse(chatResponse: LanguageModelChatResponse) { + let accResponse = ''; + + for await (const fragment of chatResponse.text) { + accResponse += fragment; + + if (fragment.includes('}')) { + try { + 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 + } + } + } + + return []; +} + +export function log(message?: any, ...optionalParams: any[]) { + console.error(`[Sidekick] ${message}`, ...optionalParams); +} + +function createHoverMessage(key: string, liquidSuggestion: LiquidSuggestion) { + const hoverUrlArgs = encodeURIComponent(JSON.stringify({ key, ...liquidSuggestion })); + const hoverMessage = new MarkdownString( + `#### ✨ Sidekick suggestion\n ${liquidSuggestion.suggestion} + \n\n[Quick fix](command:shopifyLiquid.sidefix?${hoverUrlArgs})`, + ); + + hoverMessage.isTrusted = true; + hoverMessage.supportHtml = true; + + return hoverMessage; +} + +function createTextEditorDecorationType(text: string) { + return window.createTextEditorDecorationType({ + after: { + contentText: ` ✨ ${truncate(text, 120)}`, + color: 'grey', + fontStyle: 'italic', + backgroundColor: 'rgba(255, 255, 255, 0.05)', + border: '0; border-radius: 3px', + }, + }); +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + + return text.substring(0, maxLength).trim() + '...'; +}