Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Themes] Theme Console - Handle the REPL lifecycle #4224

Merged
merged 14 commits into from
Aug 6, 2024
4 changes: 2 additions & 2 deletions packages/theme/src/cli/commands/theme/console.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {themeFlags} from '../../flags.js'
import ThemeCommand from '../../utilities/theme-command.js'
import {ensureThemeStore} from '../../utilities/theme-store.js'
import {ensureReplEnv, repl} from '../../services/console.js'
import {ensureReplEnv, initializeRepl} from '../../services/console.js'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {ensureAuthenticatedStorefront, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
import {execCLI2} from '@shopify/cli-kit/node/ruby'
Expand Down Expand Up @@ -61,7 +61,7 @@ export default class Console extends ThemeCommand {
if (flags['dev-preview']) {
outputInfo('This feature is currently in development and is not ready for use or testing yet.')
const {themeId, storePassword} = await ensureReplEnv(adminSession, flags['store-password'])
await repl(adminSession, storefrontToken, themeId, storePassword)
await initializeRepl(adminSession, storefrontToken, themeId, url, storePassword)
return
}

Expand Down
2 changes: 1 addition & 1 deletion packages/theme/src/cli/services/console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('ensureReplEnv', () => {
expect(storePassword).toBeUndefined()
})

test('shoul return undefined for storePassword when password is provided but storefront is not password protected', async () => {
test('should return undefined for storePassword when password is provided but storefront is not password protected', async () => {
// Given
vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false)
vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true)
Expand Down
25 changes: 19 additions & 6 deletions packages/theme/src/cli/services/console.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js'
import {REPLThemeManager} from '../utilities/repl-theme-manager.js'
import {ensureValidPassword} from '../utilities/prompts.js'
import {replLoop} from '../utilities/repl.js'
import {DevServerSession} from '../utilities/theme-environment/types.js'
import {AdminSession} from '@shopify/cli-kit/node/session'
import {consoleLog} from '@shopify/cli-kit/node/output'

export async function ensureReplEnv(adminSession: AdminSession, storePasswordFlag?: string) {
const themeId = await findOrCreateReplTheme(adminSession)
Expand All @@ -23,9 +26,19 @@ async function findOrCreateReplTheme(adminSession: AdminSession): Promise<string
return replTheme.id.toString()
}

export async function repl(
_adminSession: AdminSession,
_storefrontToken: string,
_themeId: string,
_password: string | undefined,
) {}
export async function initializeRepl(
adminSession: AdminSession,
storefrontToken: string,
themeId: string,
url: string,
password: string | undefined,
) {
consoleLog('Welcome to Shopify Liquid console\n(press Ctrl + C to exit)')
const themeSession: DevServerSession = {
...adminSession,
storefrontToken,
storefrontPassword: password,
expiresAt: new Date(),
}
return replLoop(themeSession, themeId, url)
}
51 changes: 51 additions & 0 deletions packages/theme/src/cli/utilities/repl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {handleInput} from './repl.js'
import {DevServerSession} from './theme-environment/types.js'
import {evaluate} from './repl/evaluater.js'
import {presentValue} from './repl/presenter.js'
import {describe, expect, test, vi} from 'vitest'
import {consoleWarn} from '@shopify/cli-kit/node/output'
import {createInterface} from 'readline'

vi.mock('@shopify/cli-kit/node/output')
vi.mock('./repl/evaluater.js')
vi.mock('./repl/presenter.js')

describe('handleInput', () => {
const themeSesssion: DevServerSession = {
storefrontPassword: 'password',
token: 'token',
expiresAt: new Date(),
storeFqdn: 'store.myshopify.com',
storefrontToken: 'storefrontToken',
}
const themeId = '123'
const url = '/'
const rl = createInterface({
input: process.stdin,
output: process.stdout,
})

test('should call consoleWarn and prompt readline if input has delimiter', async () => {
// Given
const inputValue = '{{ collections.first }}'

// When
await handleInput(inputValue, themeSesssion, themeId, url, rl)

// Then
expect(consoleWarn).toHaveBeenCalled()
})

test('should call evaluate, presentValue, and prompt readline if input is valid', async () => {
// Given
const inputValue = 'collections.first'

// When
await handleInput(inputValue, themeSesssion, themeId, url, rl)

// Then
expect(consoleWarn).not.toHaveBeenCalled()
expect(evaluate).toHaveBeenCalled()
expect(presentValue).toHaveBeenCalled()
})
})
59 changes: 59 additions & 0 deletions packages/theme/src/cli/utilities/repl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {DevServerSession} from './theme-environment/types.js'
import {evaluate} from './repl/evaluater.js'
import {presentValue} from './repl/presenter.js'
import {AbortSilentError} from '@shopify/cli-kit/node/error'
import {consoleWarn, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output'
import {createInterface, Interface} from 'readline'

export async function replLoop(themeSession: DevServerSession, themeId: string, url: string) {
try {
if (process.stdin.isTTY) {
// We want to indicate that we're still using stdin, so that the process
// doesn't exit early.
process.stdin.ref()
}

const rl = createInterface({
input: process.stdin,
output: process.stdout,
})

rl.on('line', (input) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleInput(input, themeSession, themeId, url, rl)
})
rl.prompt()
} catch (error) {
shutdownReplSession(error)
throw new AbortSilentError()
}
}

export async function handleInput(
inputValue: string,
themeSession: DevServerSession,
themeId: string,
url: string,
rl: Interface,
) {
if (hasDelimiter(inputValue)) {
consoleWarn(
"Liquid Console doesn't support Liquid delimiters such as '{{ ... }}' or '{% ... %}'.\nPlease use 'collections.first' instead of '{{ collections.first }}'.",
)
return rl.prompt()
}
const evaluatedValue = await evaluate(themeSession, inputValue, themeId, url)
presentValue(evaluatedValue)
rl.prompt()
}

function shutdownReplSession(error: unknown) {
if (error instanceof Error) {
outputInfo(outputContent`${outputToken.errorText(`Shopify Liquid console error: ${error.message}`)}`)
outputDebug(error.stack || 'Error backtrace not found')
}
}

function hasDelimiter(input: string): boolean {
return /\{\{|\}\}|\{%|%\}/.test(input)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the Ruby implementation, but I wonder if we could be a bit more specific; something like this: /^\s*(\{\{|{%)/, this way we may avoid from failing in some specific valid use cases :)

}
45 changes: 45 additions & 0 deletions packages/theme/src/cli/utilities/repl/evaluater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {render} from '../theme-environment/storefront-renderer.js'
import {DevServerSession} from '../theme-environment/types.js'
import {outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output'

export async function evaluate(
themeSession: DevServerSession,
snippet: string,
themeId: string,
url: string,
): Promise<string | undefined> {
const result = await evaluateResult(themeSession, themeId, snippet, url)
try {
return sanitizeResult(result)
// eslint-disable-next-line no-catch-all/no-catch-all, @typescript-eslint/no-explicit-any
} catch (error: any) {
outputInfo(outputContent`${outputToken.errorText(error.message)}`)
outputDebug(error.stack || 'Error backtrace not found')
}
}

async function evaluateResult(themeSession: DevServerSession, themeId: string, snippet: string, url: string) {
outputDebug(`Evaluating snippet - ${snippet}`)
const response = await render(themeSession, {
path: url,
query: [],
themeId,
cookies: '',
sectionId: 'announcement-bar',
headers: {},
replaceTemplates: {
'sections/announcement-bar.liquid': `{{ ${snippet} | json }}`,
},
})

return response.text()
}

function sanitizeResult(result: string) {
const regex = />([^<]+)</
const match = result.match(regex)

if (match && match[1]) {
return JSON.parse(match[1])
}
}
59 changes: 59 additions & 0 deletions packages/theme/src/cli/utilities/repl/presenter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {presentValue} from './presenter.js'
import {consoleWarn, outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output'
import {describe, expect, test, vi} from 'vitest'

vi.mock('@shopify/cli-kit/node/output')

describe('presentValue', () => {
test('should print a warning message if value has a JSON error', () => {
// Given
const value = {error: 'json not allowed for this object'}

// When
presentValue(value)

// Then
expect(consoleWarn).toHaveBeenCalledWith(
"Object can't be printed, but you can access its fields. Read more at https://shopify.dev/docs/api/liquid.",
)
expect(outputInfo).not.toHaveBeenCalled()
})

test('should print "null" if value is undefined', () => {
// Given
const value = undefined

// When
presentValue(value)

// Then
expect(outputInfo).toHaveBeenCalledWith(outputContent`${outputToken.cyan('null')}`)
expect(consoleWarn).not.toHaveBeenCalled()
})

// if null
test('should print "null" if value is null', () => {
// Given
const value = null

// When
presentValue(value)

// Then
expect(outputInfo).toHaveBeenCalledWith(outputContent`${outputToken.cyan('null')}`)
expect(consoleWarn).not.toHaveBeenCalled()
})

test('should print the formatted output if value is not undefined, null, or has a JSON error', () => {
// Given
const value = {foo: 'bar'}
const formattedOutput = JSON.stringify(value, null, 2)

// When
presentValue(value)

// Then
expect(outputInfo).toHaveBeenCalledWith(outputContent`${outputToken.cyan(formattedOutput)}`)
expect(consoleWarn).not.toHaveBeenCalled()
})
})
37 changes: 37 additions & 0 deletions packages/theme/src/cli/utilities/repl/presenter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {consoleWarn, outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output'

export function presentValue(value?: unknown) {
if (hasJsonError(value)) {
consoleWarn(
"Object can't be printed, but you can access its fields. Read more at https://shopify.dev/docs/api/liquid.",
)
return
}

if (value === undefined || value === null) {
renderValue('null')
return
}

const formattedOutput = JSON.stringify(value, null, 2)
renderValue(formattedOutput)
}

function hasJsonError(output: unknown): boolean {
switch (typeof output) {
case 'object':
if (Array.isArray(output)) {
return hasJsonError(output[0])
} else if (output !== null) {
const errorOutput = output as {error?: string}
return errorOutput.error?.includes('json not allowed for this object') ?? false
}
return false
default:
return false
}
}

function renderValue(value: string) {
return outputInfo(outputContent`${outputToken.cyan(value)}`)
}