diff --git a/.changeset/remote-liquid-docs-vscode.md b/.changeset/remote-liquid-docs-vscode.md new file mode 100644 index 000000000..eeaac913b --- /dev/null +++ b/.changeset/remote-liquid-docs-vscode.md @@ -0,0 +1,5 @@ +--- +'theme-check-vscode': patch +--- + +Let the browser extension load remote Liquid docs with configurable index URL and bundled-docs fallback. diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 8f26d3fe5..c96ba7c38 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -138,6 +138,13 @@ "verbose" ] }, + "shopifyLiquid.remoteLiquidDocsUrl": { + "type": [ + "string" + ], + "markdownDescription": "Optional URL for the browser extension to load a remote Liquid docs index. Defaults to same-origin `/liquid-docs/v1/latest.json`. Set this to a full index URL only for hosts that do not serve same-origin docs. The revisioned bundle URL returned by the index must use the same origin as the index.", + "default": "" + }, "themeCheck.checkOnOpen": { "type": [ "boolean" diff --git a/packages/vscode-extension/src/browser/ThemeDocset.ts b/packages/vscode-extension/src/browser/ThemeDocset.ts new file mode 100644 index 000000000..97f0c4b53 --- /dev/null +++ b/packages/vscode-extension/src/browser/ThemeDocset.ts @@ -0,0 +1,154 @@ +import { memo } from '@shopify/theme-check-common'; +import { Dependencies } from '@shopify/theme-language-server-browser'; +import { Connection } from 'vscode-languageserver/browser'; + +/** + * These are replaced at build time by the contents of + * @shopify/theme-check-docs-updater's DocsManager + */ +declare global { + export const WEBPACK_TAGS: any[]; + export const WEBPACK_FILTERS: any[]; + export const WEBPACK_OBJECTS: any[]; + export const WEBPACK_SYSTEM_TRANSLATIONS: any; + export const WEBPACK_SCHEMAS: any; +} + +const RemoteDocsSchemaVersion = 1; +const RemoteDocsIndexPath = '/liquid-docs/v1/latest.json'; +const RemoteDocsIndexConfiguration = 'shopifyLiquid.remoteLiquidDocsUrl'; + +type ThemeDocset = NonNullable; +type JsonValidationSet = NonNullable; + +type ThemeDocs = { + filters: Awaited>; + tags: Awaited>; + objects: Awaited>; + systemTranslations: Awaited>; + schemas: Awaited>; +}; + +type RemoteThemeDocs = ThemeDocs & { + schemaVersion: typeof RemoteDocsSchemaVersion; + revision: string; +}; + +type RemoteThemeDocsIndex = { + schemaVersion: typeof RemoteDocsSchemaVersion; + revision: string; + url: string; +}; + +const bundledDocs: ThemeDocs = { + tags: WEBPACK_TAGS, + filters: WEBPACK_FILTERS, + objects: WEBPACK_OBJECTS, + systemTranslations: WEBPACK_SYSTEM_TRANSLATIONS, + schemas: WEBPACK_SCHEMAS, +}; + +export class ThemeDocsetManager implements ThemeDocset, JsonValidationSet { + constructor( + private connection: Connection, + private log: (message: string) => void = () => {}, + ) {} + + filters = memo(async () => (await this.docs()).filters); + tags = memo(async () => (await this.docs()).tags); + objects = memo(async () => (await this.docs()).objects); + liquidDrops = memo(async () => (await this.docs()).objects); + systemTranslations = memo(async () => (await this.docs()).systemTranslations); + schemas = memo(async () => (await this.docs()).schemas); + + private docs = memo((): Promise => { + return this.remoteDocsIndexUrl() + .then(fetchRemoteDocs) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + this.log(`Unable to load remote Liquid docs; using bundled docs. ${message}`); + return bundledDocs; + }); + }); + + private remoteDocsIndexUrl = memo((): Promise => { + return this.connection.workspace + .getConfiguration({ section: RemoteDocsIndexConfiguration }) + .then((url) => (typeof url === 'string' && url.trim() ? url.trim() : undefined)) + .catch(() => undefined); + }); +} + +async function fetchRemoteDocs(remoteDocsIndexUrl?: string): Promise { + const latestUrl = resolveRemoteDocsIndexUrl(remoteDocsIndexUrl); + const latest = await fetchJson(latestUrl); + + if (isRemoteThemeDocs(latest)) return latest; + if (!isRemoteThemeDocsIndex(latest)) throw new Error('Invalid remote Liquid docs index.'); + + const bundleUrl = new URL(latest.url, latestUrl); + if (bundleUrl.origin !== latestUrl.origin) { + throw new Error('Remote Liquid docs bundle URLs must use the same origin as the index.'); + } + + const bundle = await fetchJson(bundleUrl); + if (!isRemoteThemeDocs(bundle)) throw new Error('Invalid remote Liquid docs bundle.'); + if (bundle.revision !== latest.revision) { + throw new Error('Remote Liquid docs index and bundle revisions do not match.'); + } + + return bundle; +} + +async function fetchJson(url: URL): Promise { + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error( + `Failed to fetch remote Liquid docs: ${response.status} ${response.statusText}`, + ); + } + return response.json(); +} + +function resolveRemoteDocsIndexUrl(remoteDocsIndexUrl?: string): URL { + if (remoteDocsIndexUrl) return new URL(remoteDocsIndexUrl); + return new URL(RemoteDocsIndexPath, workerOrigin()); +} + +function workerOrigin(): string { + const location = (globalThis as unknown as { location?: { origin?: string } }).location; + + if (!location?.origin || location.origin === 'null') { + throw new Error('Unable to determine the Code Editor origin for remote Liquid docs.'); + } + + return location.origin; +} + +function isRemoteThemeDocs(value: unknown): value is RemoteThemeDocs { + return ( + hasSupportedMetadata(value) && + Array.isArray(value.filters) && + Array.isArray(value.tags) && + Array.isArray(value.objects) && + Array.isArray(value.schemas) && + isRecord(value.systemTranslations) + ); +} + +function isRemoteThemeDocsIndex(value: unknown): value is RemoteThemeDocsIndex { + return hasSupportedMetadata(value) && typeof value.url === 'string' && value.url.length > 0; +} + +function hasSupportedMetadata(value: unknown): value is Record { + return ( + isRecord(value) && + value.schemaVersion === RemoteDocsSchemaVersion && + typeof value.revision === 'string' && + value.revision.length > 0 + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/vscode-extension/src/browser/server.ts b/packages/vscode-extension/src/browser/server.ts index 958d0a225..d6ad6b3c2 100644 --- a/packages/vscode-extension/src/browser/server.ts +++ b/packages/vscode-extension/src/browser/server.ts @@ -6,28 +6,12 @@ import { startServer, } from '@shopify/theme-language-server-browser'; import { VsCodeFileSystem } from '../common/VsCodeFileSystem'; - -/** - * These are replaced at build time by the contents of - * @shopify/theme-check-docs-updater's DocsManager - */ -declare global { - export const WEBPACK_TAGS: any[]; - export const WEBPACK_FILTERS: any[]; - export const WEBPACK_OBJECTS: any[]; - export const WEBPACK_SYSTEM_TRANSLATIONS: any; - export const WEBPACK_SCHEMAS: any; -} - -const tags = WEBPACK_TAGS; -const filters = WEBPACK_FILTERS; -const objects = WEBPACK_OBJECTS; -const systemTranslations = WEBPACK_SYSTEM_TRANSLATIONS; -const schemas = WEBPACK_SCHEMAS; +import { ThemeDocsetManager } from './ThemeDocset'; const worker = self as any as Worker; const connection = getConnection(worker); const fileSystem = new VsCodeFileSystem(connection, {}); +const themeDocset = new ThemeDocsetManager(connection); const dependencies: Dependencies = { fs: fileSystem, log: console.info.bind(console), @@ -45,16 +29,8 @@ const dependencies: Dependencies = { rootUri, }; }, - themeDocset: { - filters: async () => filters, - objects: async () => objects, - liquidDrops: async () => objects, - tags: async () => tags, - systemTranslations: async () => systemTranslations, - }, - jsonValidationSet: { - schemas: async () => schemas, - }, + themeDocset: themeDocset, + jsonValidationSet: themeDocset, }; startServer(worker, dependencies, connection);