UNPKG

@shopify/theme-language-server-common

Version:

<h1 align="center" style="position: relative;" > <br> <img src="https://github.com/Shopify/theme-check-vscode/blob/main/images/shopify_glyph.png?raw=true" alt="logo" width="141" height="160"> <br> Theme Language Server </h1>

1,159 lines (1,003 loc) 34.8 kB
import { AssignMarkup, LiquidDocParamNode, LiquidExpression, LiquidHtmlNode, LiquidTag, LiquidTagDecrement, LiquidTagIncrement, LiquidVariable, LiquidVariableLookup, NamedTags, NodeTypes, } from '@shopify/liquid-html-parser'; import { ArrayReturnType, DocsetEntry, FilterEntry, MetafieldDefinitionMap, MetafieldDefinition, ObjectEntry, ReturnType, SourceCodeType, ThemeDocset, isError, parseJSON, path, FETCHED_METAFIELD_CATEGORIES, BasicParamTypes, getValidParamTypes, parseParamType, } from '@shopify/theme-check-common'; import { GetThemeSettingsSchemaForURI, InputSetting, isInputSetting, isSettingsCategory, } from './settings'; import { findLast, memo } from './utils'; import { visit } from '@shopify/theme-check-common'; export class TypeSystem { constructor( private readonly themeDocset: ThemeDocset, private readonly getThemeSettingsSchemaForURI: GetThemeSettingsSchemaForURI, private readonly getMetafieldDefinitions: (rootUri: string) => Promise<MetafieldDefinitionMap>, ) {} async inferType( thing: Identifier | LiquidExpression | LiquidVariable | AssignMarkup, partialAst: LiquidHtmlNode, uri: string, ): Promise<PseudoType | ArrayType> { const [objectMap, filtersMap, symbolsTable] = await Promise.all([ this.objectMap(uri, partialAst), this.filtersMap(), this.symbolsTable(partialAst, uri), ]); return inferType(thing, symbolsTable, objectMap, filtersMap); } async availableVariables( partialAst: LiquidHtmlNode, partial: string, node: LiquidVariableLookup, uri: string, ): Promise<{ entry: DocsetEntry; type: PseudoType | ArrayType }[]> { const [objectMap, filtersMap, symbolsTable] = await Promise.all([ this.objectMap(uri, partialAst), this.filtersMap(), this.symbolsTable(partialAst, uri), ]); return Object.entries(symbolsTable) .filter( ([key, typeRanges]) => key.startsWith(partial) && typeRanges.some((typeRange) => isCorrectTypeRange(typeRange, node)), ) .map(([identifier, typeRanges]) => { const typeRange = findLast(typeRanges, (typeRange) => isCorrectTypeRange(typeRange, node))!; const type = resolveTypeRangeType(typeRange.type, symbolsTable, objectMap, filtersMap); const entry = objectMap[isArrayType(type) ? type.valueType : type] ?? {}; return { entry: { ...entry, name: identifier }, type, }; }); } public async themeSettingProperties(uri: string): Promise<ObjectEntry[]> { const themeSettingsSchema = await this.getThemeSettingsSchemaForURI(uri); const categories = themeSettingsSchema.filter(isSettingsCategory); const result: ObjectEntry[] = []; for (const category of categories) { const inputSettings = category.settings.filter(isInputSetting); for (const setting of inputSettings) { result.push({ name: setting.id, summary: '', // TODO, this should lookup the locale file for settings... setting.label description: '', // TODO , this should lookup the locale file as well... setting.info, return_type: settingReturnType(setting), access: { global: false, parents: [], template: [], }, }); } } return result; } /** * An indexed representation of objects.json by name * * e.g. objectMap['product'] returns the product ObjectEntry. */ public objectMap = async (uri: string, ast: LiquidHtmlNode): Promise<ObjectMap> => { const [objectMap, themeSettingProperties, metafieldDefinitionsObjectMap] = await Promise.all([ this._objectMap(), this.themeSettingProperties(uri), this.metafieldDefinitionsObjectMap(uri), ]); // Here we shallow mutate `settings.properties` to have the properties made // available by settings_schema.json const result: ObjectMap = { ...objectMap, settings: { ...(objectMap.settings ?? {}), properties: themeSettingProperties, }, ...customMetafieldTypeEntries(objectMap['metafield']), ...metafieldDefinitionsObjectMap, }; // For each metafield definition fetched, we need to override existing types with `metafields` property // to `${category}_metafield`. // // WARNING: Since we aren't cloning the object, we are mutating the original type for all themes in // the workspace. However, this is fine since these changes are not unique to a theme. for (let category of FETCHED_METAFIELD_CATEGORIES) { if (!result[category]) continue; let metafieldsProperty = result[category].properties?.find( (prop) => prop.name === 'metafields', ); if (!metafieldsProperty) continue; metafieldsProperty.return_type = [{ type: `${category}_metafields`, name: '' }]; } // Deal with sections/file.liquid section.settings by infering the type from the {% schema %} if (/[\/\\]sections[\/\\]/.test(uri) && result.section) { result.section = JSON.parse(JSON.stringify(result.section)); // easy deep clone const settings = result.section.properties?.find((x) => x.name === 'settings'); if (!settings || !settings.return_type) return result; settings.return_type = [{ type: 'section_settings', name: '' }]; result.section_settings = { name: 'section_settings', access: { global: false, parents: [], template: [], }, properties: schemaSettingsAsProperties(ast), return_type: [], }; } // Deal with blocks/files.liquid block.settings in a similar fashion if (/[\/\\]blocks[\/\\]/.test(uri) && result.block) { result.block = JSON.parse(JSON.stringify(result.block)); // easy deep clone const settings = result.block.properties?.find((x) => x.name === 'settings'); if (!settings || !settings.return_type) return result; settings.return_type = [{ type: 'block_settings', name: '' }]; result.block_settings = { name: 'block_settings', access: { global: false, parents: [], template: [], }, properties: schemaSettingsAsProperties(ast), return_type: [], }; } return result; }; public async metafieldDefinitionsObjectMap(uri: string): Promise<ObjectMap> { let result: ObjectMap = {}; const metafieldDefinitionMap = await this.getMetafieldDefinitions(uri); for (let [category, definitions] of Object.entries(metafieldDefinitionMap)) { // Metafield definitions need to be grouped by their namespace let metafieldNamespaces = new Map<string, ObjectEntry[]>(); for (let definition of definitions as MetafieldDefinition[]) { if (!metafieldNamespaces.has(definition.namespace)) { metafieldNamespaces.set(definition.namespace, []); } metafieldNamespaces.get(definition.namespace)!.push({ name: definition.key, description: definition.description, return_type: metafieldReturnType(definition.type.name), }); } let metafieldGroupProperties: ObjectEntry[] = []; for (let [namespace, namespaceProperties] of metafieldNamespaces) { const metafieldCategoryNamespaceHandle = `${category}_metafield_${namespace}`; // Since the namespace can be shared by multiple categories, we need to make sure the return_type // handle is unique across all categories metafieldGroupProperties.push({ name: namespace, return_type: [{ type: metafieldCategoryNamespaceHandle, name: '' }], access: { global: false, parents: [], template: [], }, }); result[metafieldCategoryNamespaceHandle] = { name: metafieldCategoryNamespaceHandle, properties: namespaceProperties, access: { global: false, parents: [], template: [], }, }; } const metafieldCategoryHandle = `${category}_metafields`; result[metafieldCategoryHandle] = { name: metafieldCategoryHandle, properties: metafieldGroupProperties, access: { global: false, parents: [], template: [], }, }; } return result; } // This is the big one we reuse (memoized) private _objectMap = memo(async (): Promise<ObjectMap> => { const entries = await this.objectEntries(); return entries.reduce((map, entry) => { map[entry.name] = entry; return map; }, {} as ObjectMap); }); /** An indexed representation of filters.json by name */ public filtersMap = memo(async (): Promise<FiltersMap> => { const entries = await this.filterEntries(); return entries.reduce((map, entry) => { map[entry.name] = entry; return map; }, {} as FiltersMap); }); public filterEntries = memo(async () => { return this.themeDocset.filters(); }); public objectEntries = memo(async () => { return this.themeDocset.objects(); }); private async symbolsTable(partialAst: LiquidHtmlNode, uri: string): Promise<SymbolsTable> { const seedSymbolsTable = await this.seedSymbolsTable(uri); return buildSymbolsTable(partialAst, seedSymbolsTable, await this.themeDocset.liquidDrops()); } /** * The seedSymbolsTable contains all the global variables. * * This lets us have the ambient type of things first, but if someone * reassigns product, then we'll be able to change the type of product on * the appropriate range. * * This is not memo'ed because we would otherwise need to clone the thing. */ private seedSymbolsTable = async (uri: string) => { const [globalVariables, contextualVariables] = await Promise.all([ this.globalVariables(), this.contextualVariables(uri), ]); return globalVariables.concat(contextualVariables).reduce((table, objectEntry) => { table[objectEntry.name] ??= []; table[objectEntry.name].push({ identifier: objectEntry.name, type: objectEntryType(objectEntry), range: [0], }); return table; }, {} as SymbolsTable); }; private globalVariables = memo(async () => { const entries = await this.objectEntries(); return entries.filter( (entry) => !entry.access || entry.access.global === true || entry.access.template.length > 0, ); }); private contextualVariables = async (uri: string) => { const entries = await this.objectEntries(); const contextualEntries = getContextualEntries(uri); return entries.filter((entry) => contextualEntries.includes(entry.name)); }; } const SECTION_FILE_REGEX = /sections[\/\\][^.\\\/]*\.liquid$/; const BLOCK_FILE_REGEX = /blocks[\/\\][^.\\\/]*\.liquid$/; const SNIPPET_FILE_REGEX = /snippets[\/\\][^.\\\/]*\.liquid$/; const LAYOUT_FILE_REGEX = /layout[\/\\]checkout\.liquid$/; function getContextualEntries(uri: string): string[] { const normalizedUri = path.normalize(uri); if (LAYOUT_FILE_REGEX.test(normalizedUri)) { return [ 'locale', 'direction', 'skip_to_content_link', 'checkout_html_classes', 'checkout_stylesheets', 'checkout_scripts', 'content_for_logo', 'breadcrumb', 'order_summary_toggle', 'content_for_order_summary', 'alternative_payment_methods', 'content_for_footer', 'tracking_code', ]; } if (SECTION_FILE_REGEX.test(normalizedUri)) { return ['section', 'predictive_search', 'recommendations', 'comment']; } if (BLOCK_FILE_REGEX.test(normalizedUri)) { return ['app', 'section', 'recommendations', 'block']; } if (SNIPPET_FILE_REGEX.test(normalizedUri)) { return ['app']; } return []; } /** An indexed representation on objects.json (by name) */ type ObjectMap = Record<ObjectEntryName, ObjectEntry>; /** An indexed representation on filters.json (by name) */ type FiltersMap = Record<FilterEntryName, FilterEntry>; /** An identifier refers to the name of a variable, e.g. `x`, `product`, etc. */ type Identifier = string; type ObjectEntryName = ObjectEntry['name']; type FilterEntryName = FilterEntry['name']; /** Untyped is for declared variables without a type (like `any`) */ export const Untyped = 'untyped' as const; export type Untyped = typeof Untyped; /** Unknown is for variables that don't exist, type would come from context (e.g. snippet var without LiquidDoc) */ export const Unknown = 'unknown' as const; export type Unknown = typeof Untyped; const String = 'string' as const; type String = typeof String; /** A pseudo-type is the possible values of an ObjectEntry's return_type.type */ export type PseudoType = ObjectEntryName | String | Untyped | Unknown | 'number' | 'boolean'; /** * A variable can have many types in the same file * * Just think of this: * * {{ x }} # unknown * {% assign x = all_products['cool-handle'] %} * {{ x }} # product * {% assign x = x.featured_image %} * {{ x }} # image * [% assign x = x.src %}] * {{ x }} # string */ interface TypeRange { /** The name of the variable */ identifier: Identifier; /** The type of the variable */ type: PseudoType | ArrayType | LazyVariableType | LazyDeconstructedExpression; /** * The range may be one of two things: * - open ended (till end of file, end === undefined) * - closed (inside for loop) */ range: [start: number, end?: number]; } /** Some things can be an array type (e.g. product.images) */ export type ArrayType = { kind: 'array'; valueType: PseudoType; }; const arrayType = (valueType: PseudoType): ArrayType => ({ kind: 'array', valueType, }); /** * Because a type may depend on another, this represents the type of * something as the type of a LiquidVariable chain. * {% assign x = y.foo | filter1 | filter2 %} */ type LazyVariableType = { kind: NodeTypes.LiquidVariable; node: LiquidVariable; offset: number; }; const lazyVariable = (node: LiquidVariable, offset: number): LazyVariableType => ({ kind: NodeTypes.LiquidVariable, node, offset, }); /** * A thing may be the deconstruction of something else. * * examples * - for thing in (0..2) * - for thing in collection * - for thing in parent.collection * - for thing in 'string?' */ type LazyDeconstructedExpression = { kind: 'deconstructed'; node: LiquidExpression; offset: number; }; const LazyDeconstructedExpression = ( node: LiquidExpression, offset: number, ): LazyDeconstructedExpression => ({ kind: 'deconstructed', node, offset, }); /** * A symbols table is a map of identifiers to TypeRanges. * * It stores the mapping of variable name to type by position in the file. * * The ranges are sorted in range.start order. */ type SymbolsTable = Record<Identifier, TypeRange[]>; function buildSymbolsTable( partialAst: LiquidHtmlNode, seedSymbolsTable: SymbolsTable, liquidDrops: ObjectEntry[], ): SymbolsTable { const typeRanges = visit<SourceCodeType.LiquidHtml, TypeRange>(partialAst, { // {% assign x = foo.x | filter %} AssignMarkup(node) { return { identifier: node.name, type: lazyVariable(node.value, node.position.start), range: [node.position.end], }; }, // {% doc %} // @param {string} name - your name // {% enddoc %} LiquidDocParamNode(node) { return { identifier: node.paramName.value, type: inferLiquidDocParamType(node, liquidDrops), range: [node.position.end], }; }, // This also covers tablerow ForMarkup(node, ancestors) { const parentNode = ancestors.at(-1)! as LiquidTag; return { identifier: node.variableName, type: LazyDeconstructedExpression(node.collection, node.position.start), range: [parentNode.blockStartPosition.end, end(parentNode.blockEndPosition?.end)], }; }, // {% capture foo %} // ... // {% endcapture} LiquidTag(node) { if (node.name === 'capture' && typeof node.markup !== 'string') { return { identifier: node.markup.name!, type: String, range: [node.position.end], }; } else if (['form', 'paginate'].includes(node.name)) { return { identifier: node.name, type: node.name, range: [node.blockStartPosition.end, end(node.blockEndPosition?.end)], }; } else if (['for', 'tablerow'].includes(node.name)) { return { identifier: node.name + 'loop', type: node.name + 'loop', range: [node.blockStartPosition.end, end(node.blockEndPosition?.end)], }; } else if (isLiquidTagIncrement(node) || isLiquidTagDecrement(node)) { if (node.markup.name === null) return; return { identifier: node.markup.name, type: 'number', range: [node.position.start], }; } else if (node.name === 'layout') { return { identifier: 'none', type: 'keyword', range: [node.position.start, node.position.end], }; } }, }); return typeRanges .sort(({ range: [startA] }, { range: [startB] }) => startA - startB) .reduce((table, typeRange) => { table[typeRange.identifier] ??= []; table[typeRange.identifier].push(typeRange); return table; }, seedSymbolsTable); } /** * Given a TypeRange['type'] (which may be lazy), resolve its type recursively. * * The output is a fully resolved PseudoType | ArrayType. Which means we * could use it to power completions. */ function resolveTypeRangeType( typeRangeType: TypeRange['type'], symbolsTable: SymbolsTable, objectMap: ObjectMap, filtersMap: FiltersMap, ): PseudoType | ArrayType { if (typeof typeRangeType === 'string') { return typeRangeType; } switch (typeRangeType.kind) { case 'array': { return typeRangeType; } case 'deconstructed': { const arrayType = inferType(typeRangeType.node, symbolsTable, objectMap, filtersMap); if (typeof arrayType === 'string') { return Untyped; } else { return arrayType.valueType; } } default: { return inferType(typeRangeType.node, symbolsTable, objectMap, filtersMap); } } } function inferType( thing: Identifier | LiquidExpression | LiquidVariable | AssignMarkup, symbolsTable: SymbolsTable, objectMap: ObjectMap, filtersMap: FiltersMap, ): PseudoType | ArrayType { if (typeof thing === 'string') { return objectMap[thing as PseudoType]?.name ?? Untyped; } switch (thing.type) { case NodeTypes.Number: { return 'number'; } case NodeTypes.String: { return 'string'; } case NodeTypes.LiquidLiteral: { return 'boolean'; } case NodeTypes.Range: { return arrayType('number'); } // The type of the assign markup is the type of the right hand side. // {% assign x = y.property | filter1 | filter2 %} case NodeTypes.AssignMarkup: { return inferType(thing.value, symbolsTable, objectMap, filtersMap); } // A variable lookup is expression[.lookup]* // {{ y.property }} case NodeTypes.VariableLookup: { return inferLookupType(thing, symbolsTable, objectMap, filtersMap); } // A variable is the VariableLookup + Filters // The type is the return value of the last filter // {{ y.property | filter1 | filter2 }} case NodeTypes.LiquidVariable: { if (thing.filters.length > 0) { const lastFilter = thing.filters.at(-1)!; if (lastFilter.name === 'default') { // default filter is a special case, we need to return the type of the expression // instead of the filter. if (lastFilter.args.length > 0 && lastFilter.args[0].type !== NodeTypes.NamedArgument) { return inferType(lastFilter.args[0], symbolsTable, objectMap, filtersMap); } } const filterEntry = filtersMap[lastFilter.name]; return filterEntry ? filterEntryReturnType(filterEntry) : Untyped; } else { return inferType(thing.expression, symbolsTable, objectMap, filtersMap); } } default: { return Untyped; } } } function inferLiquidDocParamType(node: LiquidDocParamNode, liquidDrops: ObjectEntry[]) { const paramTypeValue = node.paramType?.value; if (!paramTypeValue) return Untyped; const validParamTypes = getValidParamTypes(liquidDrops); const parsedParamType = parseParamType(new Set(validParamTypes.keys()), paramTypeValue); if (!parsedParamType) return Untyped; const [type, isArray] = parsedParamType; let transformedParamType; // BasicParamTypes.Object does not map to any specific type in the type system. if (type === BasicParamTypes.Object) { transformedParamType = Untyped; } else { transformedParamType = type; } if (isArray) { return arrayType(transformedParamType); } return transformedParamType; } function inferLookupType( thing: LiquidVariableLookup, symbolsTable: SymbolsTable, objectMap: ObjectMap, filtersMap: FiltersMap, ): PseudoType | ArrayType { // we return the type of the drop, so a.b.c const node = thing; // We don't complete global lookups. It's too much of an edge case. if (node.name === null) return Untyped; /** * curr stores the type of the variable lookup starting at the beginning. * * It starts as the type of the top-level identifier, and the we * recursively change it to the return type of the lookups. * * So, for x.images.first.src we do: * - curr = infer type of x | x * - curr = x.images -> ArrayType<image> | x.images * - curr = images.first -> image | x.images.first * - curr = first.src -> string | x.images.first.src * * Once were done iterating, the type of the lookup is curr. */ let curr = inferIdentifierType(node, symbolsTable, objectMap, filtersMap); for (let lookup of node.lookups) { // Here we redefine curr to be the returnType of the lookup. // e.g. images[0] -> image // e.g. images.first -> image // e.g. images.size -> number if (isArrayType(curr)) { curr = inferArrayTypeLookupType(curr, lookup); } // e.g. product.featured_image -> image // e.g. product.images -> ArrayType<images> // e.g. product.name -> string else { curr = inferPseudoTypePropertyType(curr, lookup, objectMap); } // Early return if (curr === Untyped) { return Untyped; } } return curr; } /** * Given a VariableLookup node, infer the type of its root (position-relative). * * e.g. for the following * {% assign x = product %} * {{ x.images.first }} * * This function infers the type of `x`. */ function inferIdentifierType( node: LiquidVariableLookup, symbolsTable: SymbolsTable, objectMap: ObjectMap, filtersMap: FiltersMap, ) { // The name of a variable const identifier = node.name; // We don't complete the global access edge case // e.g. {{ ['all_products'] }} if (!identifier) { return Untyped; } const typeRanges = symbolsTable[identifier]; if (!typeRanges) { return Unknown; } const typeRange = findLast(typeRanges, (tr) => isCorrectTypeRange(tr, node)); return typeRange ? resolveTypeRangeType(typeRange.type, symbolsTable, objectMap, filtersMap) : Unknown; } /** * infers the type of a lookup on an ArrayType * - images[0] becomes 'image' * - images[index] becomes 'image' * - images.first becomes 'image' * - images.last becomes 'image' * - images.size becomes 'number' * - anything else becomes 'untyped' */ function inferArrayTypeLookupType(curr: ArrayType, lookup: LiquidExpression) { // images[0] // images[index] if (lookup.type === NodeTypes.Number || lookup.type === NodeTypes.VariableLookup) { return curr.valueType; } // images.first // images.last // images.size // anything else is undef else if (lookup.type === NodeTypes.String) { switch (lookup.value) { case 'first': case 'last': { return curr.valueType; } case 'size': { return 'number'; } default: { return Unknown; } } } // images[true] // images[(0..2)] else { return Untyped; } } function inferPseudoTypePropertyType( curr: PseudoType, // settings lookup: LiquidExpression, objectMap: ObjectMap, ) { const parentEntry: ObjectEntry | undefined = objectMap[curr]; // When doing a non string lookup, we don't really know the type. e.g. // products[0] // products[true] // products[(0..10)] if (lookup.type !== NodeTypes.String) { return Untyped; } // When we don't have docs for the parent entry if (!parentEntry) { // It might be that the parent entry is a string. // We do support a couple of properties for those if (curr === 'string') { switch (lookup.value) { // some_string.first // some_string.last case 'first': case 'last': return 'string'; // some_string.size case 'size': return 'number'; default: { // For the string type, any property access other than first/last/size // is unknown. This is different from an untyped/any object where any // property access would return untyped. // String is a known type with specific properties, so accessing // undefined properties returns an unknown. return Unknown; } } } // Or it might be that the parent entry is untyped, so its subproperty // could also be untyped (kind of like if `foo` is `any`, then `foo.bar` is `any`) return Untyped; } const propertyName = lookup.value; const property = parentEntry.properties?.find((property) => property.name === propertyName); // When the propety is not known, return Untyped. e.g. // product.foo // product.bar if (!property) { // Debating between returning Untyped or Unknown here // Might be that we have outdated docs. Prob better to return Untyped. return Untyped; } // When the property is known & we have docs for it, return its type. e.g. // product.image // product.images return objectEntryType(property); } function filterEntryReturnType(entry: FilterEntry): PseudoType | ArrayType { return docsetEntryReturnType(entry, 'string'); } function objectEntryType(entry: ObjectEntry): PseudoType | ArrayType { return docsetEntryReturnType(entry, entry.name); } /** * This function converts the return_type property in one of the .json * files into a PseudoType or ArrayType. */ export function docsetEntryReturnType( entry: ObjectEntry | FilterEntry, defaultValue: PseudoType, ): PseudoType | ArrayType { const returnTypes = entry.return_type; if (returnTypes && returnTypes.length > 0) { const returnType = returnTypes[0]; if (isArrayReturnType(returnType)) { return arrayType(returnType.array_value); } else { return returnType.type; } } return defaultValue; } function isArrayReturnType(rt: ReturnType): rt is ArrayReturnType { return rt.type === 'array'; } export function isArrayType(thing: PseudoType | ArrayType): thing is ArrayType { return typeof thing !== 'string'; } /** Assumes findLast */ function isCorrectTypeRange(typeRange: TypeRange, node: LiquidVariableLookup): boolean { const [start, end] = typeRange.range; if (end && node.position.start > end) return false; return node.position.start > start; } function end(offset: number | undefined): number | undefined { if (offset === -1) return undefined; return offset; } function isLiquidTagIncrement(node: LiquidTag): node is LiquidTagIncrement { return node.name === NamedTags.increment && typeof node.markup !== 'string'; } function isLiquidTagDecrement(node: LiquidTag): node is LiquidTagDecrement { return node.name === NamedTags.decrement && typeof node.markup !== 'string'; } function settingReturnType(setting: InputSetting): ObjectEntry['return_type'] { switch (setting.type) { // basic settings case 'checkbox': return [{ type: 'boolean', name: '' }]; case 'range': case 'number': return [{ type: 'number', name: '' }]; case 'radio': case 'select': case 'text': case 'textarea': return [{ type: 'string', name: '' }]; // specialized settings case 'article': return [{ type: 'article', name: '' }]; case 'blog': return [{ type: 'blog', name: '' }]; case 'collection': return [{ type: 'collection', name: '' }]; case 'collection_list': return [{ type: 'array', array_value: 'collection' }]; case 'color': return [{ type: 'color', name: '' }]; case 'color_background': return [{ type: 'string', name: '' }]; case 'color_scheme': return [{ type: 'color_scheme', name: '' }]; // TODO ?? case 'color_scheme_group': return []; case 'font_picker': return [{ type: 'font', name: '' }]; case 'html': return [{ type: 'string', name: '' }]; case 'image_picker': return [{ type: 'image', name: '' }]; case 'inline_richtext': return [{ type: 'string', name: '' }]; case 'link_list': return [{ type: 'linklist', name: '' }]; case 'liquid': return [{ type: 'string', name: '' }]; case 'page': return [{ type: 'page', name: '' }]; case 'product': return [{ type: 'product', name: '' }]; case 'product_list': return [{ type: 'array', array_value: 'product' }]; case 'richtext': return [{ type: 'string', name: '' }]; case 'text_alignment': return [{ type: 'string', name: '' }]; case 'url': return [{ type: 'string', name: '' }]; case 'video': return [{ type: 'video', name: '' }]; case 'video_url': return [{ type: 'string', name: '' }]; default: return []; } } const METAFIELD_TYPE_TO_TYPE = Object.freeze({ single_line_text_field: String, multi_line_text_field: String, url_reference: String, date: String, date_time: String, number_integer: 'number', number_decimal: 'number', product_reference: 'product', collection_reference: 'collection', variant_reference: 'variant', page_reference: 'page', boolean: 'boolean', color: 'color', weight: 'measurement', volume: 'measurement', dimension: 'measurement', rating: 'rating', money: 'money', json: Untyped, metaobject_reference: 'metaobject', mixed_reference: Untyped, rich_text_field: Untyped, file_reference: Untyped, }); const REFERENCE_TYPE_METAFIELDS = Object.entries(METAFIELD_TYPE_TO_TYPE) .filter(([metafieldType, _type]) => metafieldType.endsWith('_reference')) .map(([_metafieldType, type]) => type); function metafieldReturnType(metafieldType: string): ObjectEntry['return_type'] { let isArray = metafieldType.startsWith('list.'); if (isArray) { metafieldType = metafieldType.split('.')[1]; } let type = 'metafield_' + ((METAFIELD_TYPE_TO_TYPE as any)[metafieldType] ?? Untyped); if (isArray) { return [{ type: `${type}_array`, name: '' }]; } return [{ type: type, name: '' }]; } // The default `metafield` type has an untyped `value` property. // We need to create new metafield types with the labels `metafield_x` and `metafield_x_array` // where x is the type of metafield inside the `value` property. The metafields ending with `x_array` // is where the value is an array of type x. const customMetafieldTypeEntries = memo((baseMetafieldEntry: ObjectEntry) => { if (!baseMetafieldEntry) return {} as ObjectMap; return [ ...new Set([...Object.values(METAFIELD_TYPE_TO_TYPE), ...FETCHED_METAFIELD_CATEGORIES]), ].reduce((map, type) => { { const metafieldEntry = JSON.parse(JSON.stringify(baseMetafieldEntry)); // easy deep clone const metafieldValueProp = metafieldEntry.properties?.find( (prop: any) => prop.name === 'value', ); if (metafieldValueProp) { metafieldValueProp.return_type = [{ type: type, name: '' }]; metafieldValueProp.description = ''; metafieldEntry.name = `metafield_${type}`; map[metafieldEntry.name] = metafieldEntry; } } { const metafieldArrayEntry = JSON.parse(JSON.stringify(baseMetafieldEntry)); // easy deep clone const metafieldArrayValueProp = metafieldArrayEntry.properties?.find( (prop: any) => prop.name === 'value', ); if (metafieldArrayValueProp) { // A metafield definition using a list of references does not use an array, but a separate type of collection. // For auto-completion purposes, we can't use the array type // https://shopify.dev/docs/api/liquid/objects/metafield#metafield-determining-the-length-of-a-list-metafield if (REFERENCE_TYPE_METAFIELDS.includes(type as any)) { metafieldArrayValueProp.return_type = [{ type: 'untyped', name: '' }]; } else { metafieldArrayValueProp.return_type = [{ type: 'array', name: '', array_value: type }]; } metafieldArrayValueProp.description = ''; metafieldArrayEntry.name = `metafield_${type}_array`; map[metafieldArrayEntry.name] = metafieldArrayEntry; } } return map; }, {} as ObjectMap); }); function schemaSettingsAsProperties(ast: LiquidHtmlNode): ObjectEntry[] { if (ast.type !== NodeTypes.Document) return []; try { const source = ast._source; // (the unfixed source) const start = /\{%\s*schema\s*%\}/m.exec(source); const end = /\{%\s*endschema\s*%\}/m.exec(source); if (!start || !end) return []; const schema = source.slice(start.index + start[0].length, end.index); const json = parseJSON(schema); if (isError(json) || !('settings' in json) || !Array.isArray(json.settings)) return []; const result: ObjectEntry[] = []; const inputSettings = json.settings.filter(isInputSetting); for (const setting of inputSettings) { result.push({ name: setting.id, summary: '', // TODO, this should lookup the locale file for settings... setting.label description: '', // TODO , this should lookup the locale file as well... setting.info, return_type: settingReturnType(setting), access: { global: false, parents: [], template: [], }, }); } return result; } catch (_) { return []; } }