diff --git a/.changeset/sweet-areas-live.md b/.changeset/sweet-areas-live.md new file mode 100644 index 00000000000..91c7534dc64 --- /dev/null +++ b/.changeset/sweet-areas-live.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': minor +--- + +Add a new `--standard-events-inspector` flag to the `shopify theme dev` command to inject the Standard Storefront Events inspector into storefront previews. diff --git a/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts b/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts index 0945a3a9d46..6cca4461c16 100644 --- a/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts +++ b/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts @@ -100,6 +100,12 @@ export interface themedev { */ '--port '?: string + /** + * Inject the standard events inspector into storefront HTML. + * @environment SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR + */ + '--standard-events-inspector'?: '' + /** * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). * @environment SHOPIFY_FLAG_STORE diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 3c9205db032..b83f906791c 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4739,6 +4739,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_PORT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--standard-events-inspector", + "value": "''", + "description": "Inject the standard events inspector into storefront HTML.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -4830,7 +4839,7 @@ "environmentValue": "SHOPIFY_FLAG_IGNORE" } ], - "value": "export interface themedev {\n /**\n * Allow development on a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Controls the visibility of the error overlay when an theme asset upload fails:\n- silent Prevents the error overlay from appearing.\n- default Displays the error overlay.\n \n * @environment SHOPIFY_FLAG_ERROR_OVERLAY\n */\n '--error-overlay '?: string\n\n /**\n * Set which network interface the web server listens on. The default value is 127.0.0.1.\n * @environment SHOPIFY_FLAG_HOST\n */\n '--host '?: string\n\n /**\n * Skip hot reloading any files that match the specified pattern.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * The live reload mode switches the server behavior when a file is modified:\n- hot-reload Hot reloads local changes to CSS and sections (default)\n- full-page Always refreshes the entire page\n- off Deactivate live reload\n * @environment SHOPIFY_FLAG_LIVE_RELOAD\n */\n '--live-reload '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * Hot reload only files that match the specified pattern.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port to serve theme preview from. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Synchronize Theme Editor updates in the local theme files.\n * @environment SHOPIFY_FLAG_THEME_EDITOR_SYNC\n */\n '--theme-editor-sync'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themedev {\n /**\n * Allow development on a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Controls the visibility of the error overlay when an theme asset upload fails:\n- silent Prevents the error overlay from appearing.\n- default Displays the error overlay.\n \n * @environment SHOPIFY_FLAG_ERROR_OVERLAY\n */\n '--error-overlay '?: string\n\n /**\n * Set which network interface the web server listens on. The default value is 127.0.0.1.\n * @environment SHOPIFY_FLAG_HOST\n */\n '--host '?: string\n\n /**\n * Skip hot reloading any files that match the specified pattern.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * The live reload mode switches the server behavior when a file is modified:\n- hot-reload Hot reloads local changes to CSS and sections (default)\n- full-page Always refreshes the entire page\n- off Deactivate live reload\n * @environment SHOPIFY_FLAG_LIVE_RELOAD\n */\n '--live-reload '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * Hot reload only files that match the specified pattern.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port to serve theme preview from. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Inject the standard events inspector into storefront HTML.\n * @environment SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR\n */\n '--standard-events-inspector'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Synchronize Theme Editor updates in the local theme files.\n * @environment SHOPIFY_FLAG_THEME_EDITOR_SYNC\n */\n '--theme-editor-sync'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themeduplicate": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 02da54b2d35..c13a042ffca 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2321,8 +2321,8 @@ Uploads the current theme as a development theme to the connected store, then pr USAGE $ shopify theme dev [-a] [-e ...] [--error-overlay silent|default] [--host ] [-x ...] [--listing ] [--live-reload hot-reload|full-page|off] [--no-color] [-n] [--notify ] [-o ...] - [--open] [--password ] [--path ] [--port ] [-s ] [--store-password ] [-t ] - [--theme-editor-sync] [--verbose] + [--open] [--password ] [--path ] [--port ] [--standard-events-inspector] [-s ] + [--store-password ] [-t ] [--theme-editor-sync] [--verbose] FLAGS -a, --allow-live @@ -2391,6 +2391,9 @@ FLAGS --port= [env: SHOPIFY_FLAG_PORT] Local port to serve theme preview from. Must be between 1 and 65535. + --standard-events-inspector + [env: SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR] Inject the standard events inspector into storefront HTML. + --store-password= [env: SHOPIFY_FLAG_STORE_PASSWORD] The password for storefronts with password protection. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a5922db0fd3..f0449b188f3 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6503,6 +6503,13 @@ "name": "port", "type": "option" }, + "standard-events-inspector": { + "allowNo": false, + "description": "Inject the standard events inspector into storefront HTML.", + "env": "SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR", + "name": "standard-events-inspector", + "type": "boolean" + }, "store": { "char": "s", "description": "Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).", diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index b38af843f3b..35a4df24359 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -77,6 +77,11 @@ You can run this command only in a directory that matches the [default Shopify t description: 'Synchronize Theme Editor updates in the local theme files.', env: 'SHOPIFY_FLAG_THEME_EDITOR_SYNC', }), + 'standard-events-inspector': Flags.boolean({ + description: 'Inject the standard events inspector into storefront HTML.', + env: 'SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR', + default: false, + }), port: portFlag({ description: 'Local port to serve theme preview from.', env: 'SHOPIFY_FLAG_PORT', @@ -180,6 +185,7 @@ You can run this command only in a directory that matches the [default Shopify t force: flags.force, open: flags.open, 'theme-editor-sync': flags['theme-editor-sync'], + 'standard-events-inspector': flags['standard-events-inspector'], noDelete: flags.nodelete, ignore, only, diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index 2030d7ce3be..a0ca32ca763 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -3,8 +3,8 @@ import {setupDevServer} from '../utilities/theme-environment/theme-environment.j import {hasRequiredThemeDirectories} from '../utilities/theme-fs.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' import {initializeDevServerSession} from '../utilities/theme-environment/dev-server-session.js' -import {describe, expect, test, vi, beforeEach, afterEach, type MockInstance} from 'vitest' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {describe, expect, test, vi, beforeEach, afterEach, type MockInstance} from 'vitest' import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {openURL} from '@shopify/cli-kit/node/system' @@ -284,6 +284,7 @@ describe('dev() Ctrl-C analytics', () => { open: false, theme, force: false, + 'standard-events-inspector': false, 'theme-editor-sync': false, 'live-reload': 'hot-reload' as const, 'error-overlay': 'default' as const, @@ -351,6 +352,44 @@ describe('dev() Ctrl-C analytics', () => { expect(reportAnalyticsEvent).toHaveBeenCalledTimes(1) }) + + test('enables the standard events dev bundle by default', async () => { + const devPromise = dev({...baseOptions, 'standard-events-inspector': false}) + + await new Promise((resolve) => setImmediate(resolve)) + + resolveBackgroundJob() + await devPromise + + expect(setupDevServer).toHaveBeenCalledWith( + theme, + expect.objectContaining({ + options: expect.objectContaining({ + standardEventsDevBundle: true, + standardEventsInspector: false, + }), + }), + ) + }) + + test('propagates the inspector option to the dev server context', async () => { + const devPromise = dev({...baseOptions, 'standard-events-inspector': true}) + + await new Promise((resolve) => setImmediate(resolve)) + + resolveBackgroundJob() + await devPromise + + expect(setupDevServer).toHaveBeenCalledWith( + theme, + expect.objectContaining({ + options: expect.objectContaining({ + standardEventsDevBundle: true, + standardEventsInspector: true, + }), + }), + ) + }) }) describe('dev() port validation', () => { @@ -368,6 +407,7 @@ describe('dev() port validation', () => { open: false, theme, force: false, + 'standard-events-inspector': false, 'theme-editor-sync': false, 'live-reload': 'hot-reload' as const, 'error-overlay': 'default' as const, diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index aa97132ef5d..f9f440bcc69 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -39,6 +39,7 @@ interface DevOptions { host?: string port?: number force: boolean + 'standard-events-inspector': boolean 'theme-editor-sync': boolean 'live-reload': LiveReload 'error-overlay': ErrorOverlayMode @@ -132,6 +133,8 @@ export async function dev(options: DevOptions) { port, open: options.open, liveReload: options['live-reload'], + standardEventsDevBundle: true, + standardEventsInspector: options['standard-events-inspector'], noDelete: options.noDelete, ignore: options.ignore, only: options.only, diff --git a/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts b/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts index 261e5541e87..bf577bfbc85 100644 --- a/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts @@ -557,6 +557,8 @@ function createTestContext(options?: {files?: [string, string][]}) { host: '', port: 9292, liveReload: 'hot-reload', + standardEventsDevBundle: false, + standardEventsInspector: false, open: false, themeEditorSync: false, errorOverlay: 'default', diff --git a/packages/theme/src/cli/utilities/theme-environment/html.ts b/packages/theme/src/cli/utilities/theme-environment/html.ts index 909b6ba3d1c..8420869055d 100644 --- a/packages/theme/src/cli/utilities/theme-environment/html.ts +++ b/packages/theme/src/cli/utilities/theme-environment/html.ts @@ -2,6 +2,7 @@ import {getErrorPage} from './hot-reload/error-page.js' import {render} from './storefront-renderer.js' import {getInMemoryTemplates, handleHotReloadScriptInjection} from './hot-reload/server.js' import {getProxyStorefrontHeaders, patchRenderingResponse, proxyStorefrontRequest} from './proxy.js' +import {injectStandardEventsInspector} from './standard-events.js' import {extractFetchErrorInfo} from '../errors.js' import {logRequestLine} from '../log-request-line.js' import {getExtensionInMemoryTemplates} from '../theme-ext-environment/theme-ext-server.js' @@ -144,7 +145,8 @@ function createErrorPageResponse( responseInit: ResponseInit, options: Parameters[0], ) { - const errorPageHtml = handleHotReloadScriptInjection(getErrorPage(options), ctx) + let errorPageHtml = handleHotReloadScriptInjection(getErrorPage(options), ctx) + if (ctx.options.standardEventsInspector) errorPageHtml = injectStandardEventsInspector(errorPageHtml) recordEvent('theme-service:error-page:rendered') @@ -180,6 +182,14 @@ async function tryProxyRequest(event: H3Event, ctx: DevServerContext, response: if (proxyResponse.status < 400) { outputDebug(`Proxy status: ${proxyResponse.status}. Returning proxy response.`) + + if ( + ctx.options.standardEventsInspector && + (proxyResponse.headers.get('content-type') ?? '').includes('text/html') + ) { + return patchRenderingResponse(ctx, proxyResponse) + } + return proxyResponse } else { outputDebug(`Proxy status: ${proxyResponse.status}. Returning render error.`) diff --git a/packages/theme/src/cli/utilities/theme-environment/local-assets.ts b/packages/theme/src/cli/utilities/theme-environment/local-assets.ts index e925fef4a42..073c3d71a99 100644 --- a/packages/theme/src/cli/utilities/theme-environment/local-assets.ts +++ b/packages/theme/src/cli/utilities/theme-environment/local-assets.ts @@ -235,7 +235,7 @@ function getTagContent(file: ThemeAsset, tag: 'javascript' | 'stylesheet') { const contents = [`/* ${file.key} */`] - const tagContent = getLiquidTagContent(file.value ?? '', tag) + const tagContent = getLiquidTagContent(file.value ?? '', tag)?.trimEnd() if (tagContent) { contents.push(tagContent) } diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts index beb524f3384..def95497c22 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts @@ -5,6 +5,12 @@ import { patchRenderingResponse, proxyStorefrontRequest, } from './proxy.js' +import { + standardEventsInspectorScriptId, + standardEventsInspectorUrl, + standardEventsRuntimeDevUrl, + standardEventsRuntimeUrl, +} from './standard-events.js' import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' import {createEvent} from 'h3' import {IncomingMessage, ServerResponse} from 'node:http' @@ -29,7 +35,7 @@ describe('dev proxy', () => { const ctx = { session: {storeFqdn: 'my-store.myshopify.com', sessionCookies: {}}, - options: {host: 'localhost', port: 1337}, + options: {host: 'localhost', port: 1337, standardEventsDevBundle: false, standardEventsInspector: false}, localThemeFileSystem: {files: new Map([['assets/file1', 'content']])}, localThemeExtensionFileSystem: {files: new Map([['assets/file-ext', 'content']])}, } as unknown as DevServerContext @@ -115,10 +121,24 @@ describe('dev proxy', () => { const url = "/cdn/path/to/assets/file1#zzz"; fetch(\`/cdn/path/to/assets/file1?q=123\`); " - `, + `, ) }) + test('rewrites standard events runtime URLs in JS files when enabled', () => { + const standardEventsCtx = { + ...ctx, + options: {...ctx.options, standardEventsDevBundle: true}, + } as unknown as DevServerContext + const content = ` + const runtimeUrl = "${standardEventsRuntimeUrl}"; + import("${standardEventsRuntimeUrl}"); + ` + + expect(injectCdnProxy(content, standardEventsCtx)).toContain(standardEventsRuntimeDevUrl) + expect(injectCdnProxy(content, standardEventsCtx)).not.toContain(standardEventsRuntimeUrl) + }) + test('proxies urls in Link header', () => { const linkHeader = `; rel="preconnect", ; rel="preconnect"; crossorigin,` + @@ -250,6 +270,38 @@ describe('dev proxy', () => { expect(ctx.session.sessionCookies).toHaveProperty('_shopify_essential', ':AZFbAlZ..yAAH:') }) + test('injects the standard events inspector at the beginning of the head when enabled', async () => { + const standardEventsCtx = { + ...ctx, + options: {...ctx.options, standardEventsInspector: true}, + } as unknown as DevServerContext + + const renderingResponse = new Response('') + + const patchedResponse = await patchRenderingResponse(standardEventsCtx, renderingResponse) + + await expect(patchedResponse.text()).resolves.toBe( + ``, + ) + }) + + test('does not rewrite standard events runtime URLs when only the inspector is enabled', async () => { + const standardEventsCtx = { + ...ctx, + options: {...ctx.options, standardEventsInspector: true}, + } as unknown as DevServerContext + + const renderingResponse = new Response( + ``, + ) + + const patchedResponse = await patchRenderingResponse(standardEventsCtx, renderingResponse) + + await expect(patchedResponse.text()).resolves.toBe( + ``, + ) + }) + test('captures _shopify_essential from Set-Cookie into session on 3xx responses', async () => { const localCtx = { ...ctx, diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.ts index 2dfa7f95527..1a59801faa2 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.ts @@ -1,5 +1,6 @@ import {cleanHeader, defaultHeaders} from './storefront-utils.js' import {buildCookies} from './storefront-renderer.js' +import {injectStandardEventsInspector, rewriteStandardEventsRuntimeReferences} from './standard-events.js' import {logRequestLine} from '../log-request-line.js' import {createFetchError, extractFetchErrorInfo} from '../errors.js' @@ -169,6 +170,10 @@ export function injectCdnProxy(originalContent: string, ctx: DevServerContext) { return matchedUrl }) + if (ctx.options.standardEventsDevBundle) { + content = rewriteStandardEventsRuntimeReferences(content) + } + return content } @@ -213,6 +218,7 @@ export async function patchRenderingResponse( let html = await response.text() html = injectCdnProxy(html, ctx) html = patchBaseUrlAttributes(html, ctx) + if (ctx.options.standardEventsInspector) html = injectStandardEventsInspector(html) if (patchCallback) html = patchCallback(html) ?? html return new Response(html, response) diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts new file mode 100644 index 00000000000..8934785e664 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts @@ -0,0 +1,65 @@ +import { + injectStandardEventsInspector, + rewriteStandardEventsRuntimeReferences, + standardEventsInspectorScriptId, + standardEventsInspectorUrl, + standardEventsRuntimeDevUrl, + standardEventsRuntimeUrl, +} from './standard-events.js' +import {describe, expect, test} from 'vitest' + +describe('injectStandardEventsInspector', () => { + test('injects the inspector script at the beginning of the head element', () => { + const html = '' + + expect(injectStandardEventsInspector(html)).toBe( + ``, + ) + }) + + test('does not rewrite standard-events.js when injecting the inspector', () => { + const html = `` + + expect(injectStandardEventsInspector(html)).toBe( + ``, + ) + }) + + test('does not inject the inspector twice', () => { + const html = `` + + expect(injectStandardEventsInspector(html)).toBe(html) + }) + + test('does not treat a plain inspector URL string as an existing inspector script', () => { + const html = `` + + expect(injectStandardEventsInspector(html)).toBe( + ``, + ) + }) + + test('leaves standard-events.js unchanged when the inspector is already present', () => { + const html = `` + + expect(injectStandardEventsInspector(html)).toBe(html) + }) +}) + +describe('rewriteStandardEventsRuntimeReferences', () => { + test('rewrites standard-events.js to standard-events.dev.js', () => { + const html = `` + + expect(rewriteStandardEventsRuntimeReferences(html)).toBe( + ``, + ) + }) + + test('rewrites multiple standard-events.js references', () => { + const content = `"${standardEventsRuntimeUrl}" import("${standardEventsRuntimeUrl}")` + + expect(rewriteStandardEventsRuntimeReferences(content)).toBe( + `"${standardEventsRuntimeDevUrl}" import("${standardEventsRuntimeDevUrl}")`, + ) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts new file mode 100644 index 00000000000..58cf8f85384 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts @@ -0,0 +1,34 @@ +const headTagRE = /]*)?>/i + +const standardEventsBaseUrl = 'https://cdn.shopify.com/storefront' +export const standardEventsRuntimeUrl = `${standardEventsBaseUrl}/standard-events.js` +export const standardEventsRuntimeDevUrl = `${standardEventsBaseUrl}/standard-events.dev.js` +export const standardEventsInspectorUrl = `${standardEventsBaseUrl}/standard-events-inspector.js` +export const standardEventsInspectorScriptId = 'shopify-cli-standard-events-inspector' +const standardEventsInspectorScriptRE = new RegExp( + `]*(?:\\bid=["']${escapeRegExp(standardEventsInspectorScriptId)}["']|\\bsrc=["']${escapeRegExp( + standardEventsInspectorUrl, + )}["'])[^>]*>`, + 'i', +) +const standardEventsRuntimeRE = new RegExp(escapeRegExp(standardEventsRuntimeUrl), 'g') + +export function injectStandardEventsInspector(html: string) { + if (standardEventsInspectorScriptRE.test(html)) { + return html + } + + return html.replace( + headTagRE, + (headTag: string) => + `${headTag}`, + ) +} + +export function rewriteStandardEventsRuntimeReferences(content: string) { + return content.replace(standardEventsRuntimeRE, standardEventsRuntimeDevUrl) +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts b/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts index 0175c759eb7..ee1df677679 100644 --- a/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts @@ -3,6 +3,11 @@ import {setupDevServer} from './theme-environment.js' import {render} from './storefront-renderer.js' import {reconcileAndPollThemeEditorChanges} from './remote-theme-watcher.js' import {hotReloadScriptId} from './hot-reload/server.js' +import { + standardEventsInspectorScriptId, + standardEventsRuntimeDevUrl, + standardEventsRuntimeUrl, +} from './standard-events.js' import {uploadTheme} from '../theme-uploader.js' import {fakeThemeFileSystem} from '../theme-fs/theme-fs-mock-factory.js' import {emptyThemeExtFileSystem} from '../theme-fs-empty.js' @@ -118,6 +123,14 @@ describe('setupDevServer', () => { value: 'const x = "Hello\u00A0World";', }, ], + [ + 'assets/file-standard-events.js', + { + checksum: '1', + key: 'assets/file-standard-events.js', + value: `window.__standardEventsRuntime__ = "${standardEventsRuntimeUrl}";`, + }, + ], ]) const localThemeFileSystem = fakeThemeFileSystem('tmp', localFiles) @@ -141,6 +154,8 @@ describe('setupDevServer', () => { host: '127.0.0.1', port: 9292, liveReload: 'hot-reload', + standardEventsDevBundle: false, + standardEventsInspector: false, open: false, themeEditorSync: false, errorOverlay: 'default', @@ -369,6 +384,25 @@ describe('setupDevServer', () => { expect(res.getHeader('content-length')).toEqual(25) }) + test('rewrites standard events runtime URLs in served local JS assets when enabled', async () => { + const standardEventsContext: DevServerContext = { + ...defaultServerContext, + options: { + ...defaultServerContext.options, + standardEventsDevBundle: true, + }, + } + const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) + + const {res, body} = await dispatchEvent(standardEventsServer, '/assets/file-standard-events.js', { + host: defaultHost, + }) + + expect(res.getHeader('content-type')).toEqual('application/javascript') + expect(body.toString()).toContain(standardEventsRuntimeDevUrl) + expect(body.toString()).not.toContain(standardEventsRuntimeUrl) + }) + test('serves compiled_assets/styles.css by aggregating liquid stylesheets in a fault tolerant way', async () => { const sectionFile = { key: 'sections/test-section.liquid', @@ -519,7 +553,6 @@ describe('setupDevServer', () => { /* blocks/another-block.liquid */ console.log('This is another block script'); - } catch (e) { console.error(e); } @@ -531,7 +564,6 @@ describe('setupDevServer', () => { /* blocks/test-block.liquid */ console.log('This is block script'); - } catch (e) { console.error(e); } @@ -606,7 +638,6 @@ describe('setupDevServer', () => { /* snippets/another-snippet.liquid */ console.log('This is another snippet script'); - } catch (e) { console.error(e); } @@ -618,7 +649,6 @@ describe('setupDevServer', () => { /* snippets/test-snippet.liquid */ console.log('This is snippet script'); - } catch (e) { console.error(e); } @@ -690,7 +720,6 @@ describe('setupDevServer', () => { /* sections/another-section.liquid */ console.log('This is another section script'); - } catch (e) { console.error(e); } @@ -702,7 +731,6 @@ describe('setupDevServer', () => { /* sections/test-section.liquid */ console.log('This is section script'); - } catch (e) { console.error(e); } @@ -906,6 +934,35 @@ describe('setupDevServer', () => { await expect(eventPromise).resolves.toHaveProperty('status', 302) }) + test('patches HTML proxy fallback responses with standard events when enabled', async () => { + const standardEventsContext: DevServerContext = { + ...defaultServerContext, + options: { + ...defaultServerContext.options, + standardEventsDevBundle: true, + standardEventsInspector: true, + }, + } + const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) + + const fetchStub = vi.fn() + vi.stubGlobal('fetch', fetchStub) + fetchStub.mockResolvedValueOnce( + new Response(``, { + status: 200, + headers: {'content-type': 'text/html; charset=utf-8'}, + }), + ) + vi.mocked(render).mockResolvedValueOnce(new Response(null, {status: 401})) + + const {body, status} = await dispatchEvent(standardEventsServer, '/non-renderable-path', {host: defaultHost}) + + expect(status).toBe(200) + expect(body).toMatch(standardEventsInspectorScriptId) + expect(body).toMatch(standardEventsRuntimeDevUrl) + expect(body).not.toMatch(standardEventsRuntimeUrl) + }) + test('forwards rendering error after proxy failure', async () => { const fetchStub = vi.fn() vi.stubGlobal('fetch', fetchStub) @@ -1013,6 +1070,25 @@ describe('setupDevServer', () => { expect(body).toMatch(hotReloadScriptId) }) + test('renders error page with the standard events inspector when enabled', async () => { + const standardEventsContext: DevServerContext = { + ...defaultServerContext, + options: { + ...defaultServerContext.options, + standardEventsInspector: true, + }, + } + const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) + + vi.mocked(render).mockRejectedValueOnce(new Error('Network error')) + + const {res, body} = await dispatchEvent(standardEventsServer, '/', {host: defaultHost}) + + expect(res.getHeader('content-type')).toEqual('text/html; charset=utf-8') + expect(body).toMatch(standardEventsInspectorScriptId) + expect(body).toMatch(hotReloadScriptId) + }) + test('renders error page on upload errors with hot reload script injected', async () => { const fetchStub = vi.fn() vi.stubGlobal('fetch', fetchStub) diff --git a/packages/theme/src/cli/utilities/theme-environment/types.ts b/packages/theme/src/cli/utilities/theme-environment/types.ts index d01377975ea..b019515d95a 100644 --- a/packages/theme/src/cli/utilities/theme-environment/types.ts +++ b/packages/theme/src/cli/utilities/theme-environment/types.ts @@ -130,6 +130,16 @@ export interface DevServerContext { */ liveReload: LiveReload + /** + * Rewrites standard events runtime URLs to use the dev bundle. + */ + standardEventsDevBundle: boolean + + /** + * Injects the standard events inspector into rendered HTML. + */ + standardEventsInspector: boolean + /** * Automatically open the theme preview in the default browser. */ diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.test.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.test.ts index e365662567d..2ee66bcdbe1 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.test.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.test.ts @@ -72,6 +72,8 @@ describe('createDevelopmentExtensionServer', () => { host: '127.0.0.1', port: 9293, liveReload: 'hot-reload', + standardEventsDevBundle: false, + standardEventsInspector: false, open: false, themeEditorSync: false, errorOverlay: 'default', diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts index 55477a74de7..43a3482d7eb 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts @@ -95,6 +95,8 @@ async function contextDevServerContext( host, port, liveReload: 'hot-reload', + standardEventsDevBundle: false, + standardEventsInspector: false, open: false, errorOverlay: 'default', },