From 05b928ea16d27cd906da70b27d3e8cfac9b84a7a Mon Sep 17 00:00:00 2001 From: CP Clermont Date: Fri, 29 Nov 2024 12:36:06 -0500 Subject: [PATCH] Improve the JSON completion & hover building blocks (#622) The json-languageservice API was a bit awkward to use. Had a bunch of weird type problems that nobody should really have to care about. In this PR, I use an adapter to make folks write Completion & Hover providers in a manner similar to what we do with liquid files. It's not 100% the same, but it's similar enough and abstracts the weird quirks like "return undefined not Promise when you can't hover something". --- .changeset/nice-candles-sparkle.md | 6 + packages/theme-check-common/src/index.ts | 1 + .../theme-check-common/src/utils/object.ts | 2 +- .../src/json/JSONContributions.ts | 120 ++++++++++++++ .../src/json/JSONLanguageService.ts | 10 +- .../src/json/RequestContext.ts | 35 ++++ .../json/SchemaTranslationContributions.ts | 150 ------------------ .../src/json/TranslationFileContributions.ts | 122 -------------- .../completions/JSONCompletionProvider.ts | 33 ++++ .../SchemaTranslationCompletionProvider.ts | 53 +++++++ .../src/json/fileMatch.ts | 3 - .../src/json/hover/JSONHoverProvider.ts | 8 + .../SchemaTranslationHoverProvider.ts | 37 +++++ .../providers/TranslationPathHoverProvider.ts | 71 +++++++++ .../src/json/utils.ts | 18 +++ 15 files changed, 385 insertions(+), 284 deletions(-) create mode 100644 .changeset/nice-candles-sparkle.md create mode 100644 packages/theme-language-server-common/src/json/JSONContributions.ts create mode 100644 packages/theme-language-server-common/src/json/RequestContext.ts delete mode 100644 packages/theme-language-server-common/src/json/SchemaTranslationContributions.ts delete mode 100644 packages/theme-language-server-common/src/json/TranslationFileContributions.ts create mode 100644 packages/theme-language-server-common/src/json/completions/JSONCompletionProvider.ts create mode 100644 packages/theme-language-server-common/src/json/completions/providers/SchemaTranslationCompletionProvider.ts delete mode 100644 packages/theme-language-server-common/src/json/fileMatch.ts create mode 100644 packages/theme-language-server-common/src/json/hover/JSONHoverProvider.ts create mode 100644 packages/theme-language-server-common/src/json/hover/providers/SchemaTranslationHoverProvider.ts create mode 100644 packages/theme-language-server-common/src/json/hover/providers/TranslationPathHoverProvider.ts create mode 100644 packages/theme-language-server-common/src/json/utils.ts diff --git a/.changeset/nice-candles-sparkle.md b/.changeset/nice-candles-sparkle.md new file mode 100644 index 00000000..14d77508 --- /dev/null +++ b/.changeset/nice-candles-sparkle.md @@ -0,0 +1,6 @@ +--- +'@shopify/theme-language-server-common': patch +'@shopify/theme-check-common': patch +--- + +[internal] Rejig how we do JSON completion & Hover diff --git a/packages/theme-check-common/src/index.ts b/packages/theme-check-common/src/index.ts index e7194f28..3088961d 100644 --- a/packages/theme-check-common/src/index.ts +++ b/packages/theme-check-common/src/index.ts @@ -55,6 +55,7 @@ export * from './utils/error'; export * from './utils/indexBy'; export * from './utils/memo'; export * from './utils/types'; +export * from './utils/object'; export * from './visitor'; const defaultErrorHandler = (_error: Error): void => { diff --git a/packages/theme-check-common/src/utils/object.ts b/packages/theme-check-common/src/utils/object.ts index 17d0c94e..ae713331 100644 --- a/packages/theme-check-common/src/utils/object.ts +++ b/packages/theme-check-common/src/utils/object.ts @@ -1,3 +1,3 @@ -export function deepGet(obj: any, path: string[]): any { +export function deepGet(obj: any, path: (string | number)[]): any { return path.reduce((acc, key) => acc?.[key], obj); } diff --git a/packages/theme-language-server-common/src/json/JSONContributions.ts b/packages/theme-language-server-common/src/json/JSONContributions.ts new file mode 100644 index 00000000..57250f28 --- /dev/null +++ b/packages/theme-language-server-common/src/json/JSONContributions.ts @@ -0,0 +1,120 @@ +import { parseJSON, SourceCodeType } from '@shopify/theme-check-common'; +import { + CompletionsCollector, + JSONPath, + JSONWorkerContribution, + MarkedString, +} from 'vscode-json-languageservice'; +import { AugmentedSourceCode, DocumentManager } from '../documents'; +import { GetTranslationsForURI } from '../translations'; +import { JSONCompletionProvider } from './completions/JSONCompletionProvider'; +import { SchemaTranslationsCompletionProvider } from './completions/providers/SchemaTranslationCompletionProvider'; +import { JSONHoverProvider } from './hover/JSONHoverProvider'; +import { SchemaTranslationHoverProvider } from './hover/providers/SchemaTranslationHoverProvider'; +import { TranslationPathHoverProvider } from './hover/providers/TranslationPathHoverProvider'; +import { RequestContext } from './RequestContext'; +import { findSchemaNode } from './utils'; + +/** The getInfoContribution API will only fallback if we return undefined synchronously */ +const SKIP_CONTRIBUTION = undefined as any; + +/** + * I'm not a fan of how json-languageservice does its feature contributions. It's too different + * from everything else we do in here. + * + * Instead, we'll have this little adapter that makes the completions and hover providers feel + * a bit more familiar. + */ +export class JSONContributions implements JSONWorkerContribution { + private hoverProviders: JSONHoverProvider[]; + private completionProviders: JSONCompletionProvider[]; + + constructor( + private documentManager: DocumentManager, + getDefaultSchemaTranslations: GetTranslationsForURI, + ) { + this.hoverProviders = [ + new TranslationPathHoverProvider(), + new SchemaTranslationHoverProvider(getDefaultSchemaTranslations), + ]; + this.completionProviders = [ + new SchemaTranslationsCompletionProvider(getDefaultSchemaTranslations), + ]; + } + + getInfoContribution(uri: string, location: JSONPath): Promise { + const doc = this.documentManager.get(uri); + if (!doc) return SKIP_CONTRIBUTION; + const context = this.getContext(doc); + const provider = this.hoverProviders.find((p) => p.canHover(context, location)); + if (!provider) return SKIP_CONTRIBUTION; + return provider.hover(context, location); + } + + async collectPropertyCompletions( + uri: string, + location: JSONPath, + // Don't know what those three are for. + _currentWord: string, + _addValue: boolean, + _isLast: boolean, + result: CompletionsCollector, + ): Promise { + const doc = this.documentManager.get(uri); + if (!doc || doc.ast instanceof Error) return; + + const items = await Promise.all( + this.completionProviders + .filter((provider) => provider.completeProperty) + .map((provider) => provider.completeProperty!(this.getContext(doc), location)), + ); + + for (const item of items.flat()) { + result.add(item); + } + } + + async collectValueCompletions( + uri: string, + location: JSONPath, + propertyKey: string, + result: CompletionsCollector, + ): Promise { + const doc = this.documentManager.get(uri); + if (!doc || doc.ast instanceof Error) return; + + const items = await Promise.all( + this.completionProviders + .filter((provider) => provider.completeValue) + .map((provider) => + provider.completeValue!(this.getContext(doc), location.concat(propertyKey)), + ), + ); + + for (const item of items.flat()) { + result.add(item); + } + } + + /** I'm not sure we want to do anything with that... but TS requires us to have it */ + async collectDefaultCompletions(_uri: string, _result: CompletionsCollector): Promise {} + + private getContext(doc: AugmentedSourceCode): RequestContext { + const context: RequestContext = { + doc, + }; + + if (doc.type === SourceCodeType.LiquidHtml && !(doc.ast instanceof Error)) { + const schema = findSchemaNode(doc.ast); + if (!schema) return SKIP_CONTRIBUTION; + const jsonString = schema?.source.slice( + schema.blockStartPosition.end, + schema.blockEndPosition.start, + ); + context.schema = schema; + context.parsed = parseJSON(jsonString); + } + + return context; + } +} diff --git a/packages/theme-language-server-common/src/json/JSONLanguageService.ts b/packages/theme-language-server-common/src/json/JSONLanguageService.ts index 7f086d72..d072fb72 100644 --- a/packages/theme-language-server-common/src/json/JSONLanguageService.ts +++ b/packages/theme-language-server-common/src/json/JSONLanguageService.ts @@ -21,8 +21,7 @@ import { import { TextDocument } from 'vscode-languageserver-textdocument'; import { DocumentManager } from '../documents'; import { GetTranslationsForURI } from '../translations'; -import { SchemaTranslationContributions } from './SchemaTranslationContributions'; -import { TranslationFileContributions } from './TranslationFileContributions'; +import { JSONContributions } from './JSONContributions'; export class JSONLanguageService { // We index by Mode here because I don't want to reconfigure the service depending on the URI. @@ -71,13 +70,8 @@ export class JSONLanguageService { }, }, - // Custom non-JSON schema completion & hover contributions contributions: [ - new TranslationFileContributions(this.documentManager), - new SchemaTranslationContributions( - this.documentManager, - this.getDefaultSchemaTranslations, - ), + new JSONContributions(this.documentManager, this.getDefaultSchemaTranslations), ], }); diff --git a/packages/theme-language-server-common/src/json/RequestContext.ts b/packages/theme-language-server-common/src/json/RequestContext.ts new file mode 100644 index 00000000..bf81079c --- /dev/null +++ b/packages/theme-language-server-common/src/json/RequestContext.ts @@ -0,0 +1,35 @@ +import { LiquidHtmlNode, LiquidRawTag } from '@shopify/liquid-html-parser'; +import { isError, JSONNode, SourceCodeType } from '@shopify/theme-check-common'; +import { + AugmentedJsonSourceCode, + AugmentedLiquidSourceCode, + AugmentedSourceCode, +} from '../documents'; + +export type RequestContext = { + doc: AugmentedSourceCode; + schema?: LiquidRawTag; + parsed?: any | Error; +}; + +export type LiquidRequestContext = { + doc: Omit & { ast: LiquidHtmlNode }; + schema: LiquidRawTag; + parsed: any; +}; + +export type JSONRequestContext = { + doc: Omit & { ast: JSONNode }; +}; + +export function isLiquidRequestContext(context: RequestContext): context is LiquidRequestContext { + const { doc, schema, parsed } = context; + return ( + doc.type === SourceCodeType.LiquidHtml && !!schema && !isError(doc.ast) && !isError(parsed) + ); +} + +export function isJSONRequestContext(context: RequestContext): context is JSONRequestContext { + const { doc } = context; + return doc.type === SourceCodeType.JSON && !isError(doc.ast); +} diff --git a/packages/theme-language-server-common/src/json/SchemaTranslationContributions.ts b/packages/theme-language-server-common/src/json/SchemaTranslationContributions.ts deleted file mode 100644 index 6ef0c9c8..00000000 --- a/packages/theme-language-server-common/src/json/SchemaTranslationContributions.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { LiquidRawTag } from '@shopify/liquid-html-parser'; -import { LiquidHtmlNode, SourceCodeType, isError, parseJSON } from '@shopify/theme-check-common'; -import { - CompletionsCollector, - JSONPath, - JSONWorkerContribution, -} from 'vscode-json-languageservice'; -import { CompletionItem, CompletionItemKind } from 'vscode-languageserver-protocol'; -import { DocumentManager } from '../documents'; -import { - GetTranslationsForURI, - renderTranslation, - translationOptions, - translationValue, -} from '../translations'; -import { Visitor, visit } from '@shopify/theme-check-common'; -import { uriMatch } from './fileMatch'; - -/** - * This contribution is responsible for providing completions and hover of - * `t:` translations in sections and blocks {% schema %} JSON blobs. - */ -export class SchemaTranslationContributions implements JSONWorkerContribution { - private uriPatterns = [/^.*\/(sections|blocks)\/[^\/]*\.liquid$/]; - - constructor( - private documentManager: DocumentManager, - private getDefaultSchemaTranslations: GetTranslationsForURI, - ) {} - - /** - * Because the API for JSONWorkerContribution is slightly weird, we need to - * return undefined (not Promise) for this contribution to be - * skipped and fallbacks to go through. It's not typed properly either. - */ - getInfoContribution(uri: string, location: JSONPath): Promise { - if (!uriMatch(uri, this.uriPatterns)) return undefined as any; - const doc = this.documentManager.get(uri); - if ( - !doc || - location.length === 0 || - doc.ast instanceof Error || - doc.type !== SourceCodeType.LiquidHtml - ) { - return undefined as any; - } - - const schema = findSchemaNode(doc.ast); - if (!schema) return undefined as any; - - const jsonString = schema.source.slice( - schema.blockStartPosition.end, - schema.blockEndPosition.start, - ); - const jsonDocument = parseJSON(jsonString); - if (isError(jsonDocument)) return undefined as any; - - const label = location.reduce((acc: any, val: any) => acc?.[val], jsonDocument); - if (!label || typeof label !== 'string' || !label.startsWith('t:')) return undefined as any; - - return this.getDefaultSchemaTranslations(uri).then((translations) => { - const path = label.slice(2); - const value = translationValue(path, translations); - if (!value) return undefined as any; - - return [renderTranslation(value)]; - }); - } - - async collectValueCompletions( - uri: string, - location: JSONPath, - propertyKey: string, - result: CompletionsCollector, - ) { - if (!uriMatch(uri, this.uriPatterns)) return; - const doc = this.documentManager.get(uri); - if (!doc || doc.ast instanceof Error || doc.type !== SourceCodeType.LiquidHtml) { - return; - } - - const schema = findSchemaNode(doc.ast); - if (!schema) return; - - const jsonString = schema.source.slice( - schema.blockStartPosition.end, - schema.blockEndPosition.start, - ); - const jsonDocument = parseJSON(jsonString); - if (!jsonDocument) return; - - const label = location - .concat(propertyKey) - .reduce((acc: any, val: any) => acc?.[val], jsonDocument); - if (!label || typeof label !== 'string' || !label.startsWith('t:')) { - return; - } - - const items = await this.recommendTranslations(uri, label); - for (const item of items) { - result.add(item); - } - } - - // These are only there to satisfy the TS interface - async collectDefaultCompletions(_uri: string, _result: CompletionsCollector) {} - // prettier-ignore - async collectPropertyCompletions(_uri: string, _location: JSONPath, _currentWord: string, _addValue: boolean, _isLast: boolean, _result: CompletionsCollector) {} - - private async recommendTranslations( - uri: string, - label: string, - ): Promise<(CompletionItem & { insertText: string })[]> { - const partial = /^t:(.*)/.exec(label)?.[1]; - if (!partial && partial !== '') return []; - - const translations = await this.getDefaultSchemaTranslations(uri); - - // We'll let the frontend do the filtering. But we'll only include shopify - // translations if the shopify prefix is present - const options = translationOptions(translations); - - return options.map((option): CompletionItem & { insertText: string } => { - const tLabel = `t:${option.path.join('.')}`; - return { - label: tLabel, - kind: CompletionItemKind.Value, - filterText: `"${tLabel}"`, - insertText: `"${tLabel}"`, - insertTextFormat: 1, - documentation: { - kind: 'markdown', - value: renderTranslation(option.translation), - }, - }; - }); - } -} - -export function findSchemaNode(ast: LiquidHtmlNode) { - const nodes = visit(ast, { - LiquidRawTag(node) { - if (node.name === 'schema') { - return node; - } - }, - } as Visitor); - - return nodes[0]; -} diff --git a/packages/theme-language-server-common/src/json/TranslationFileContributions.ts b/packages/theme-language-server-common/src/json/TranslationFileContributions.ts deleted file mode 100644 index 4b42d294..00000000 --- a/packages/theme-language-server-common/src/json/TranslationFileContributions.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { JSONNode, SourceCodeType } from '@shopify/theme-check-common'; -import { - CompletionsCollector, - JSONPath, - JSONWorkerContribution, - MarkedString, -} from 'vscode-json-languageservice'; -import { DocumentManager } from '../documents'; -import { extractParams, paramsString } from '../translations'; - -function nodeAtLocation(ast: JSONNode, location: JSONPath): JSONNode | undefined { - return location.reduce((value: JSONNode | undefined, segment: string | number) => { - if (value && typeof value !== 'string') { - switch (value.type) { - case 'Object': { - return value.children.find((child) => child.key.value === segment)?.value; - } - case 'Array': { - if (typeof segment !== 'number') return undefined; - return value.children[segment]; - } - case 'Identifier': { - return undefined; // trying to [segment] onto a string or number - } - case 'Literal': { - return undefined; // trying to [segment] onto a string or number - } - case 'Property': { - return undefined; // this shouldn't be happening - } - } - } - }, ast); -} - -const nothing = undefined as any; - -export class TranslationFileContributions implements JSONWorkerContribution { - private filePatterns = [/^.*\/locales\/[^\/]*\.json$/]; - - constructor(private documentManager: DocumentManager) {} - - getInfoContribution(uri: string, location: JSONPath): Promise { - // TODO: This is a hack to get around the fact that the JSON language service - // actually is not typed properly and performs "if-undefined-skip" logic. - // https://github.com/microsoft/vscode-json-languageservice/pull/222 - // would fix this, but it's not merged yet. - if (!fileMatch(uri, this.filePatterns)) return nothing; - const doc = this.documentManager.get(uri); - if (!doc || location.length === 0 || doc.type !== SourceCodeType.JSON) return nothing; - const ast = doc.ast; - if (ast instanceof Error) return nothing; - const node = nodeAtLocation(ast, location); - - switch (true) { - // Because the JSON language service doesn't support composition of hover info, - // We have to hardcode the docs for the translation file schema here. - case ['zero', 'one', 'two', 'few', 'many', 'other'].includes(location.at(-1) as string): { - if (!node || node.type !== 'Literal' || typeof node.value !== 'string') { - return Promise.resolve([`Pluralized translations should have a string value`]); - } - return Promise.resolve([contextualizedLabel(uri, location.slice(0, -1), node.value)]); - } - - case location.at(-1)!.toString().endsWith('_html'): { - if (!node || node.type !== 'Literal' || typeof node.value !== 'string') { - return Promise.resolve([`Translations ending in '_html' should have a string value`]); - } - return Promise.resolve([ - contextualizedLabel(uri, location, node.value), - `The '_html' suffix prevents the HTML content from being escaped.`, - ]); - } - - default: { - if (!node || node.type !== 'Literal' || typeof node.value !== 'string') { - return Promise.resolve([`Translation group: ${location.join('.')}`]); - } - return Promise.resolve([contextualizedLabel(uri, location, node.value)]); - } - } - } - - async collectDefaultCompletions(uri: string, result: CompletionsCollector) {} - - async collectPropertyCompletions( - uri: string, - location: JSONPath, - currentWord: string, - addValue: boolean, - isLast: boolean, - result: CompletionsCollector, - ) {} - - async collectValueCompletions( - uri: string, - location: JSONPath, - propertyKey: string, - result: CompletionsCollector, - ) {} -} - -export function fileMatch(uri: string, patterns: RegExp[]): boolean { - return patterns.some((pattern) => pattern.test(uri)); -} - -export function contextualizedLabel( - uri: string, - str: (string | number)[], - value: string, -): MarkedString { - if (uri.includes('.schema')) { - return marked(`"t:${str.join('.')}"`, 'json'); - } else { - const params = extractParams(value); - return marked(`{{ '${str.join('.')}' | t${paramsString(params)} }}`, 'liquid'); - } -} - -function marked(value: string, language = 'liquid'): { language: string; value: string } { - return { language, value }; -} diff --git a/packages/theme-language-server-common/src/json/completions/JSONCompletionProvider.ts b/packages/theme-language-server-common/src/json/completions/JSONCompletionProvider.ts new file mode 100644 index 00000000..258656dd --- /dev/null +++ b/packages/theme-language-server-common/src/json/completions/JSONCompletionProvider.ts @@ -0,0 +1,33 @@ +import { LiquidHtmlNode, LiquidRawTag } from '@shopify/liquid-html-parser'; +import { JSONNode } from '@shopify/theme-check-common'; +import { CompletionItem, JSONPath } from 'vscode-json-languageservice'; +import { + AugmentedJsonSourceCode, + AugmentedLiquidSourceCode, + AugmentedSourceCode, +} from '../../documents'; +import { RequestContext } from '../RequestContext'; + +export type HoverContext = { + doc: AugmentedSourceCode; + schema?: LiquidRawTag; + parsed?: any | Error; +}; + +export type LiquidHoverContext = { + doc: Omit & { ast: LiquidHtmlNode }; + schema: LiquidRawTag; + parsed: any; +}; + +export type JSONHoverContext = { + doc: Omit & { ast: JSONNode }; +}; + +/** For some reason, json-languageservice requires it. */ +export type JSONCompletionItem = CompletionItem & { insertText: string }; + +export interface JSONCompletionProvider { + completeProperty?(context: RequestContext, path: JSONPath): Promise; + completeValue?(context: RequestContext, path: JSONPath): Promise; +} diff --git a/packages/theme-language-server-common/src/json/completions/providers/SchemaTranslationCompletionProvider.ts b/packages/theme-language-server-common/src/json/completions/providers/SchemaTranslationCompletionProvider.ts new file mode 100644 index 00000000..42b09822 --- /dev/null +++ b/packages/theme-language-server-common/src/json/completions/providers/SchemaTranslationCompletionProvider.ts @@ -0,0 +1,53 @@ +import { deepGet } from '@shopify/theme-check-common'; +import { CompletionItemKind, JSONPath } from 'vscode-json-languageservice'; +import { + GetTranslationsForURI, + renderTranslation, + translationOptions, +} from '../../../translations'; +import { isLiquidRequestContext, RequestContext } from '../../RequestContext'; +import { fileMatch } from '../../utils'; +import { JSONCompletionItem, JSONCompletionProvider } from '../JSONCompletionProvider'; + +export class SchemaTranslationsCompletionProvider implements JSONCompletionProvider { + private uriPatterns = [/(sections|blocks)\/[^\/]*\.liquid$/]; + + constructor(private getDefaultSchemaTranslations: GetTranslationsForURI) {} + + async completeValue(context: RequestContext, path: JSONPath): Promise { + if (!fileMatch(context.doc.uri, this.uriPatterns) || !isLiquidRequestContext(context)) { + return []; + } + + const { doc, parsed } = context; + + const label = deepGet(parsed, path); + if (!label || typeof label !== 'string' || !label.startsWith('t:')) { + return []; + } + + const partial = /^t:(.*)/.exec(label)?.[1]; + if (partial === undefined) return []; + + const translations = await this.getDefaultSchemaTranslations(doc.uri); + + // We'll let the frontend do the filtering. But we'll only include shopify + // translations if the shopify prefix is present + const options = translationOptions(translations); + + return options.map((option): JSONCompletionItem => { + const tLabel = `t:${option.path.join('.')}`; + return { + label: tLabel, + kind: CompletionItemKind.Value, + filterText: `"${tLabel}"`, + insertText: `"${tLabel}"`, + insertTextFormat: 1, + documentation: { + kind: 'markdown', + value: renderTranslation(option.translation), + }, + }; + }); + } +} diff --git a/packages/theme-language-server-common/src/json/fileMatch.ts b/packages/theme-language-server-common/src/json/fileMatch.ts deleted file mode 100644 index bd33bcb0..00000000 --- a/packages/theme-language-server-common/src/json/fileMatch.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function uriMatch(uri: string, patterns: RegExp[]): boolean { - return patterns.some((pattern) => pattern.test(uri)); -} diff --git a/packages/theme-language-server-common/src/json/hover/JSONHoverProvider.ts b/packages/theme-language-server-common/src/json/hover/JSONHoverProvider.ts new file mode 100644 index 00000000..5eff3c92 --- /dev/null +++ b/packages/theme-language-server-common/src/json/hover/JSONHoverProvider.ts @@ -0,0 +1,8 @@ +import { JSONPath, MarkedString } from 'vscode-json-languageservice'; +import { RequestContext } from '../RequestContext'; + +export interface JSONHoverProvider { + /** Must be sync because of weird JSONWorkerContribution API */ + canHover(context: RequestContext, location: JSONPath): boolean; + hover(context: RequestContext, location: JSONPath): Promise; +} diff --git a/packages/theme-language-server-common/src/json/hover/providers/SchemaTranslationHoverProvider.ts b/packages/theme-language-server-common/src/json/hover/providers/SchemaTranslationHoverProvider.ts new file mode 100644 index 00000000..1b8413b5 --- /dev/null +++ b/packages/theme-language-server-common/src/json/hover/providers/SchemaTranslationHoverProvider.ts @@ -0,0 +1,37 @@ +import { deepGet } from '@shopify/theme-check-common'; +import { JSONPath, MarkedString } from 'vscode-json-languageservice'; +import { GetTranslationsForURI, renderTranslation, translationValue } from '../../../translations'; +import { isLiquidRequestContext, LiquidRequestContext, RequestContext } from '../../RequestContext'; +import { fileMatch } from '../../utils'; +import { JSONHoverProvider } from '../JSONHoverProvider'; + +export class SchemaTranslationHoverProvider implements JSONHoverProvider { + private uriPatterns = [/(sections|blocks)\/[^\/]*\.liquid$/]; + + constructor(private getDefaultSchemaTranslations: GetTranslationsForURI) {} + + canHover(context: RequestContext, path: JSONPath): context is LiquidRequestContext { + const label = deepGet(context.parsed, path); + return ( + fileMatch(context.doc.uri, this.uriPatterns) && + isLiquidRequestContext(context) && + path.length !== 0 && + label && + typeof label === 'string' && + label.startsWith('t:') + ); + } + + async hover(context: RequestContext, path: JSONPath): Promise { + if (!this.canHover(context, path)) return []; + // Can assert is a string because of `canHover` check above + const label = deepGet(context.parsed, path) as string; + return this.getDefaultSchemaTranslations(context.doc.uri).then((translations) => { + const path = label.slice(2); // remove `t:` + const value = translationValue(path, translations); + if (!value) return undefined as any; + + return [renderTranslation(value)]; + }); + } +} diff --git a/packages/theme-language-server-common/src/json/hover/providers/TranslationPathHoverProvider.ts b/packages/theme-language-server-common/src/json/hover/providers/TranslationPathHoverProvider.ts new file mode 100644 index 00000000..2bb4c7e2 --- /dev/null +++ b/packages/theme-language-server-common/src/json/hover/providers/TranslationPathHoverProvider.ts @@ -0,0 +1,71 @@ +import { nodeAtPath } from '@shopify/theme-check-common'; +import { JSONPath, MarkedString } from 'vscode-json-languageservice'; +import { extractParams, paramsString } from '../../../translations'; +import { isJSONRequestContext, JSONRequestContext, RequestContext } from '../../RequestContext'; +import { fileMatch } from '../../utils'; +import { JSONHoverProvider } from '../JSONHoverProvider'; + +export class TranslationPathHoverProvider implements JSONHoverProvider { + private filePatterns = [/^.*\/locales\/[^\/]*\.json$/]; + + canHover(context: RequestContext, path: JSONPath): context is JSONRequestContext { + return ( + fileMatch(context.doc.uri, this.filePatterns) && + path.length > 0 && + isJSONRequestContext(context) + ); + } + + async hover(context: RequestContext, path: JSONPath): Promise { + // Redundant use for type assertion + if (!this.canHover(context, path)) return []; + const { doc } = context; + const ast = doc.ast; + const node = nodeAtPath(ast, path); + + switch (true) { + // Because the JSON language service doesn't support composition of hover info, + // We have to hardcode the docs for the translation file schema here. + case ['zero', 'one', 'two', 'few', 'many', 'other'].includes(path.at(-1) as string): { + if (!node || node.type !== 'Literal' || typeof node.value !== 'string') { + return [`Pluralized translations should have a string value`]; + } + return [contextualizedLabel(doc.uri, path.slice(0, -1), node.value)]; + } + + case path.at(-1)!.toString().endsWith('_html'): { + if (!node || node.type !== 'Literal' || typeof node.value !== 'string') { + return [`Translations ending in '_html' should have a string value`]; + } + return [ + contextualizedLabel(doc.uri, path, node.value), + `The '_html' suffix prevents the HTML content from being escaped.`, + ]; + } + + default: { + if (!node || node.type !== 'Literal' || typeof node.value !== 'string') { + return [`Translation group: ${path.join('.')}`]; + } + return [contextualizedLabel(doc.uri, path, node.value)]; + } + } + } +} + +export function contextualizedLabel( + uri: string, + str: (string | number)[], + value: string, +): MarkedString { + if (uri.includes('.schema')) { + return marked(`"t:${str.join('.')}"`, 'json'); + } else { + const params = extractParams(value); + return marked(`{{ '${str.join('.')}' | t${paramsString(params)} }}`, 'liquid'); + } +} + +function marked(value: string, language = 'liquid'): { language: string; value: string } { + return { language, value }; +} diff --git a/packages/theme-language-server-common/src/json/utils.ts b/packages/theme-language-server-common/src/json/utils.ts new file mode 100644 index 00000000..219e6467 --- /dev/null +++ b/packages/theme-language-server-common/src/json/utils.ts @@ -0,0 +1,18 @@ +import { LiquidHtmlNode, LiquidRawTag } from '@shopify/liquid-html-parser'; +import { SourceCodeType, visit, Visitor } from '@shopify/theme-check-common'; + +export function fileMatch(uri: string, patterns: RegExp[]): boolean { + return patterns.some((pattern) => pattern.test(uri)); +} + +export function findSchemaNode(ast: LiquidHtmlNode): LiquidRawTag | undefined { + const nodes = visit(ast, { + LiquidRawTag(node) { + if (node.name === 'schema') { + return node; + } + }, + } as Visitor); + + return nodes[0]; +}