Skip to content

Commit 79d91b9

Browse files
committed
Load remote Liquid docs in browser extension
1 parent 16df60f commit 79d91b9

4 files changed

Lines changed: 183 additions & 28 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'theme-check-vscode': patch
3+
---
4+
5+
Let the browser extension load remote Liquid docs with configurable index URL and bundled-docs fallback.

packages/vscode-extension/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@
138138
"verbose"
139139
]
140140
},
141+
"shopifyLiquid.remoteLiquidDocsUrl": {
142+
"type": [
143+
"string"
144+
],
145+
"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.",
146+
"default": ""
147+
},
141148
"themeCheck.checkOnOpen": {
142149
"type": [
143150
"boolean"
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { memo } from '@shopify/theme-check-common';
2+
import { Dependencies } from '@shopify/theme-language-server-browser';
3+
import { Connection } from 'vscode-languageserver/browser';
4+
5+
/**
6+
* These are replaced at build time by the contents of
7+
* @shopify/theme-check-docs-updater's DocsManager
8+
*/
9+
declare global {
10+
export const WEBPACK_TAGS: any[];
11+
export const WEBPACK_FILTERS: any[];
12+
export const WEBPACK_OBJECTS: any[];
13+
export const WEBPACK_SYSTEM_TRANSLATIONS: any;
14+
export const WEBPACK_SCHEMAS: any;
15+
}
16+
17+
const RemoteDocsSchemaVersion = 1;
18+
const RemoteDocsIndexPath = '/liquid-docs/v1/latest.json';
19+
const RemoteDocsIndexConfiguration = 'shopifyLiquid.remoteLiquidDocsUrl';
20+
21+
type ThemeDocset = NonNullable<Dependencies['themeDocset']>;
22+
type JsonValidationSet = NonNullable<Dependencies['jsonValidationSet']>;
23+
24+
type ThemeDocs = {
25+
filters: Awaited<ReturnType<ThemeDocset['filters']>>;
26+
tags: Awaited<ReturnType<ThemeDocset['tags']>>;
27+
objects: Awaited<ReturnType<ThemeDocset['objects']>>;
28+
systemTranslations: Awaited<ReturnType<ThemeDocset['systemTranslations']>>;
29+
schemas: Awaited<ReturnType<JsonValidationSet['schemas']>>;
30+
};
31+
32+
type RemoteThemeDocs = ThemeDocs & {
33+
schemaVersion: typeof RemoteDocsSchemaVersion;
34+
revision: string;
35+
};
36+
37+
type RemoteThemeDocsIndex = {
38+
schemaVersion: typeof RemoteDocsSchemaVersion;
39+
revision: string;
40+
url: string;
41+
};
42+
43+
const bundledDocs: ThemeDocs = {
44+
tags: WEBPACK_TAGS,
45+
filters: WEBPACK_FILTERS,
46+
objects: WEBPACK_OBJECTS,
47+
systemTranslations: WEBPACK_SYSTEM_TRANSLATIONS,
48+
schemas: WEBPACK_SCHEMAS,
49+
};
50+
51+
export class ThemeDocsetManager implements ThemeDocset, JsonValidationSet {
52+
private remoteDocsFailureLogged = false;
53+
54+
constructor(
55+
private connection: Connection,
56+
private log: (message: string) => void = () => {},
57+
) {}
58+
59+
filters = memo(async () => (await this.docs()).filters);
60+
tags = memo(async () => (await this.docs()).tags);
61+
objects = memo(async () => (await this.docs()).objects);
62+
liquidDrops = memo(async () => (await this.docs()).objects);
63+
systemTranslations = memo(async () => (await this.docs()).systemTranslations);
64+
schemas = memo(async () => (await this.docs()).schemas);
65+
66+
private docs = memo(async (): Promise<ThemeDocs> => {
67+
try {
68+
return await fetchRemoteDocs(await this.remoteDocsIndexUrl());
69+
} catch (error) {
70+
this.logRemoteDocsFailure(error);
71+
return bundledDocs;
72+
}
73+
});
74+
75+
private remoteDocsIndexUrl = memo(async (): Promise<string | undefined> => {
76+
try {
77+
const url = await this.connection.workspace.getConfiguration({
78+
section: RemoteDocsIndexConfiguration,
79+
});
80+
return typeof url === 'string' && url.trim() ? url.trim() : undefined;
81+
} catch (_) {
82+
return undefined;
83+
}
84+
});
85+
86+
private logRemoteDocsFailure(error: unknown) {
87+
if (this.remoteDocsFailureLogged) return;
88+
89+
this.remoteDocsFailureLogged = true;
90+
const message = error instanceof Error ? error.message : String(error);
91+
this.log(`Unable to load remote Liquid docs; using bundled docs. ${message}`);
92+
}
93+
}
94+
95+
async function fetchRemoteDocs(remoteDocsIndexUrl?: string): Promise<RemoteThemeDocs> {
96+
const latestUrl = resolveRemoteDocsIndexUrl(remoteDocsIndexUrl);
97+
const latest = await fetchJson(latestUrl);
98+
99+
if (isRemoteThemeDocs(latest)) return latest;
100+
if (!isRemoteThemeDocsIndex(latest)) throw new Error('Invalid remote Liquid docs index.');
101+
102+
const bundleUrl = new URL(latest.url, latestUrl);
103+
if (bundleUrl.origin !== latestUrl.origin) {
104+
throw new Error('Remote Liquid docs bundle URLs must use the same origin as the index.');
105+
}
106+
107+
const bundle = await fetchJson(bundleUrl);
108+
if (!isRemoteThemeDocs(bundle)) throw new Error('Invalid remote Liquid docs bundle.');
109+
if (bundle.revision !== latest.revision) {
110+
throw new Error('Remote Liquid docs index and bundle revisions do not match.');
111+
}
112+
113+
return bundle;
114+
}
115+
116+
async function fetchJson(url: URL): Promise<unknown> {
117+
const response = await fetch(url.toString());
118+
if (!response.ok) {
119+
throw new Error(
120+
`Failed to fetch remote Liquid docs: ${response.status} ${response.statusText}`,
121+
);
122+
}
123+
return response.json();
124+
}
125+
126+
function resolveRemoteDocsIndexUrl(remoteDocsIndexUrl?: string): URL {
127+
if (remoteDocsIndexUrl) return new URL(remoteDocsIndexUrl);
128+
return new URL(RemoteDocsIndexPath, workerOrigin());
129+
}
130+
131+
function workerOrigin(): string {
132+
const location = (globalThis as unknown as { location?: { origin?: string } }).location;
133+
134+
if (!location?.origin || location.origin === 'null') {
135+
throw new Error('Unable to determine the Code Editor origin for remote Liquid docs.');
136+
}
137+
138+
return location.origin;
139+
}
140+
141+
function isRemoteThemeDocs(value: unknown): value is RemoteThemeDocs {
142+
return (
143+
hasSupportedMetadata(value) &&
144+
Array.isArray(value.filters) &&
145+
Array.isArray(value.tags) &&
146+
Array.isArray(value.objects) &&
147+
Array.isArray(value.schemas) &&
148+
isRecord(value.systemTranslations)
149+
);
150+
}
151+
152+
function isRemoteThemeDocsIndex(value: unknown): value is RemoteThemeDocsIndex {
153+
return hasSupportedMetadata(value) && typeof value.url === 'string' && value.url.length > 0;
154+
}
155+
156+
function hasSupportedMetadata(value: unknown): value is Record<string, unknown> {
157+
return (
158+
isRecord(value) &&
159+
value.schemaVersion === RemoteDocsSchemaVersion &&
160+
typeof value.revision === 'string' &&
161+
value.revision.length > 0
162+
);
163+
}
164+
165+
function isRecord(value: unknown): value is Record<string, unknown> {
166+
return typeof value === 'object' && value !== null && !Array.isArray(value);
167+
}

packages/vscode-extension/src/browser/server.ts

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,12 @@ import {
66
startServer,
77
} from '@shopify/theme-language-server-browser';
88
import { VsCodeFileSystem } from '../common/VsCodeFileSystem';
9-
10-
/**
11-
* These are replaced at build time by the contents of
12-
* @shopify/theme-check-docs-updater's DocsManager
13-
*/
14-
declare global {
15-
export const WEBPACK_TAGS: any[];
16-
export const WEBPACK_FILTERS: any[];
17-
export const WEBPACK_OBJECTS: any[];
18-
export const WEBPACK_SYSTEM_TRANSLATIONS: any;
19-
export const WEBPACK_SCHEMAS: any;
20-
}
21-
22-
const tags = WEBPACK_TAGS;
23-
const filters = WEBPACK_FILTERS;
24-
const objects = WEBPACK_OBJECTS;
25-
const systemTranslations = WEBPACK_SYSTEM_TRANSLATIONS;
26-
const schemas = WEBPACK_SCHEMAS;
9+
import { ThemeDocsetManager } from './ThemeDocset';
2710

2811
const worker = self as any as Worker;
2912
const connection = getConnection(worker);
3013
const fileSystem = new VsCodeFileSystem(connection, {});
14+
const themeDocset = new ThemeDocsetManager(connection);
3115
const dependencies: Dependencies = {
3216
fs: fileSystem,
3317
log: console.info.bind(console),
@@ -45,16 +29,8 @@ const dependencies: Dependencies = {
4529
rootUri,
4630
};
4731
},
48-
themeDocset: {
49-
filters: async () => filters,
50-
objects: async () => objects,
51-
liquidDrops: async () => objects,
52-
tags: async () => tags,
53-
systemTranslations: async () => systemTranslations,
54-
},
55-
jsonValidationSet: {
56-
schemas: async () => schemas,
57-
},
32+
themeDocset: themeDocset,
33+
jsonValidationSet: themeDocset,
5834
};
5935

6036
startServer(worker, dependencies, connection);

0 commit comments

Comments
 (0)