Skip to content

Commit a39bb36

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

5 files changed

Lines changed: 237 additions & 29 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: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { memo } from '@shopify/theme-check-common';
2+
import { Dependencies } from '@shopify/theme-language-server-browser';
3+
4+
/**
5+
* These are replaced at build time by the contents of
6+
* @shopify/theme-check-docs-updater's DocsManager
7+
*/
8+
declare global {
9+
export const WEBPACK_TAGS: any[];
10+
export const WEBPACK_FILTERS: any[];
11+
export const WEBPACK_OBJECTS: any[];
12+
export const WEBPACK_SYSTEM_TRANSLATIONS: any;
13+
export const WEBPACK_SCHEMAS: any;
14+
}
15+
16+
const tags = WEBPACK_TAGS;
17+
const filters = WEBPACK_FILTERS;
18+
const objects = WEBPACK_OBJECTS;
19+
const systemTranslations = WEBPACK_SYSTEM_TRANSLATIONS;
20+
const schemas = WEBPACK_SCHEMAS;
21+
22+
const RemoteDocsSchemaVersion = 1;
23+
const RemoteDocsLatestPath = '/liquid-docs/v1/latest.json';
24+
const RemoteDocsIndexUrlSearchParam = 'remoteLiquidDocsUrl';
25+
26+
type ThemeDocset = NonNullable<Dependencies['themeDocset']>;
27+
type JsonValidationSet = NonNullable<Dependencies['jsonValidationSet']>;
28+
type ThemeDocsDependency = 'filters' | 'tags' | 'objects' | 'systemTranslations' | 'schemas';
29+
30+
type ThemeDocsBundle = {
31+
schemaVersion: typeof RemoteDocsSchemaVersion;
32+
revision: string;
33+
filters: Awaited<ReturnType<ThemeDocset['filters']>>;
34+
tags: Awaited<ReturnType<ThemeDocset['tags']>>;
35+
objects: Awaited<ReturnType<ThemeDocset['objects']>>;
36+
systemTranslations: Awaited<ReturnType<ThemeDocset['systemTranslations']>>;
37+
schemas: Awaited<ReturnType<JsonValidationSet['schemas']>>;
38+
};
39+
40+
type ThemeDocsIndex = {
41+
schemaVersion: typeof RemoteDocsSchemaVersion;
42+
revision: string;
43+
url: string;
44+
};
45+
46+
export interface ThemeDocsetManagerOptions {
47+
log?: (message: string) => void;
48+
remoteDocsIndexUrl?: string;
49+
}
50+
51+
export class ThemeDocsetManager implements ThemeDocset, JsonValidationSet {
52+
private log: (message: string) => void;
53+
private remoteDocsFailureLogged = false;
54+
private remoteDocsIndexUrl?: string;
55+
56+
constructor(options: ThemeDocsetManagerOptions = {}) {
57+
this.log = options.log ?? (() => {});
58+
this.remoteDocsIndexUrl = normalizeRemoteDocsIndexUrl(options.remoteDocsIndexUrl);
59+
}
60+
61+
private fetchRemoteDocsBundle = memo(async (): Promise<ThemeDocsBundle> => {
62+
return fetchRemoteDocsBundle(this.remoteDocsIndexUrl);
63+
});
64+
65+
// Liquid documentation
66+
filters = memo(async () => this.fetchUpdatedData('filters', filters));
67+
tags = memo(async () => this.fetchUpdatedData('tags', tags));
68+
objects = memo(async () => this.fetchUpdatedData('objects', objects));
69+
liquidDrops = memo(async () => this.fetchUpdatedData('objects', objects));
70+
71+
// prettier-ignore
72+
systemTranslations = memo(async () => this.fetchUpdatedData('systemTranslations', systemTranslations));
73+
74+
// JSON validation data
75+
schemas = memo(async () => this.fetchUpdatedData('schemas', schemas));
76+
77+
private fetchUpdatedData = async <T>(
78+
dependency: ThemeDocsDependency,
79+
fallback: T,
80+
): Promise<T> => {
81+
try {
82+
const bundle = await this.fetchRemoteDocsBundle();
83+
return bundle[dependency] as T;
84+
} catch (error) {
85+
this.logRemoteDocsFailure(error);
86+
return fallback;
87+
}
88+
};
89+
90+
private logRemoteDocsFailure(error: unknown) {
91+
if (this.remoteDocsFailureLogged) {
92+
return;
93+
}
94+
95+
this.remoteDocsFailureLogged = true;
96+
const message = error instanceof Error ? error.message : String(error);
97+
this.log(`Unable to load remote Liquid docs; using bundled docs. ${message}`);
98+
}
99+
}
100+
101+
export function remoteDocsIndexUrlFromWorkerLocation(): string | undefined {
102+
const location = (globalThis as unknown as { location?: { href?: string } }).location;
103+
104+
if (!location?.href) {
105+
return undefined;
106+
}
107+
108+
return new URL(location.href).searchParams.get(RemoteDocsIndexUrlSearchParam) ?? undefined;
109+
}
110+
111+
async function fetchRemoteDocsBundle(remoteDocsIndexUrl?: string): Promise<ThemeDocsBundle> {
112+
const latestUrl = resolveRemoteDocsIndexUrl(remoteDocsIndexUrl);
113+
const latest = await fetchJson(latestUrl.toString());
114+
115+
if (isThemeDocsBundle(latest)) {
116+
return latest;
117+
}
118+
119+
if (!isThemeDocsIndex(latest)) {
120+
throw new Error('Invalid remote Liquid docs index.');
121+
}
122+
123+
const bundleUrl = resolveRemoteDocsBundleUrl(latest.url, latestUrl);
124+
const bundle = await fetchJson(bundleUrl.toString());
125+
126+
if (!isThemeDocsBundle(bundle)) {
127+
throw new Error('Invalid remote Liquid docs bundle.');
128+
}
129+
130+
if (bundle.revision !== latest.revision) {
131+
throw new Error('Remote Liquid docs index and bundle revisions do not match.');
132+
}
133+
134+
return bundle;
135+
}
136+
137+
async function fetchJson(url: string): Promise<unknown> {
138+
const response = await fetch(url);
139+
140+
if (!response.ok) {
141+
throw new Error(
142+
`Failed to fetch remote Liquid docs: ${response.status} ${response.statusText}`,
143+
);
144+
}
145+
146+
return response.json();
147+
}
148+
149+
function resolveRemoteDocsIndexUrl(remoteDocsIndexUrl?: string): URL {
150+
return new URL(remoteDocsIndexUrl ?? RemoteDocsLatestPath, workerOrigin());
151+
}
152+
153+
function resolveRemoteDocsBundleUrl(url: string, latestUrl: URL): URL {
154+
const resolved = new URL(url, latestUrl);
155+
156+
if (resolved.origin !== latestUrl.origin) {
157+
throw new Error('Remote Liquid docs bundle URLs must use the same origin as the index.');
158+
}
159+
160+
return resolved;
161+
}
162+
163+
function workerOrigin(): string {
164+
const location = (globalThis as unknown as { location?: { origin?: string } }).location;
165+
166+
if (!location?.origin || location.origin === 'null') {
167+
throw new Error('Unable to determine the Code Editor origin for remote Liquid docs.');
168+
}
169+
170+
return location.origin;
171+
}
172+
173+
function normalizeRemoteDocsIndexUrl(url: string | undefined): string | undefined {
174+
const trimmed = url?.trim();
175+
return trimmed ? trimmed : undefined;
176+
}
177+
178+
function isThemeDocsIndex(value: unknown): value is ThemeDocsIndex {
179+
return (
180+
isRecord(value) &&
181+
hasSupportedMetadata(value) &&
182+
typeof value.url === 'string' &&
183+
value.url.length > 0
184+
);
185+
}
186+
187+
function isThemeDocsBundle(value: unknown): value is ThemeDocsBundle {
188+
return (
189+
isRecord(value) &&
190+
hasSupportedMetadata(value) &&
191+
Array.isArray(value.filters) &&
192+
Array.isArray(value.tags) &&
193+
Array.isArray(value.objects) &&
194+
Array.isArray(value.schemas) &&
195+
isRecord(value.systemTranslations)
196+
);
197+
}
198+
199+
function hasSupportedMetadata(value: Record<string, unknown>) {
200+
return value.schemaVersion === RemoteDocsSchemaVersion && isNonEmptyString(value.revision);
201+
}
202+
203+
function isRecord(value: unknown): value is Record<string, unknown> {
204+
return typeof value === 'object' && value !== null && !Array.isArray(value);
205+
}
206+
207+
function isNonEmptyString(value: unknown): value is string {
208+
return typeof value === 'string' && value.length > 0;
209+
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,16 @@ function createWorkerLanguageClient(
8585
) {
8686
// Create a worker. The worker main file implements the language server.
8787
const serverMain = Uri.joinPath(context.extensionUri, 'dist', 'browser', 'server.js');
88-
const worker = new Worker(serverMain.toString(true));
88+
const remoteLiquidDocsUrl = workspace
89+
.getConfiguration('shopifyLiquid')
90+
.get<string>('remoteLiquidDocsUrl')
91+
?.trim();
92+
const serverUri = remoteLiquidDocsUrl
93+
? serverMain.with({
94+
query: new URLSearchParams({ remoteLiquidDocsUrl }).toString(),
95+
})
96+
: serverMain;
97+
const worker = new Worker(serverUri.toString(true));
8998

9099
// create the language server client to communicate with the server running in the worker
91100
return new LanguageClient('shopifyLiquid', 'Theme Check Language Server', clientOptions, worker);

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

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,14 @@ 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, remoteDocsIndexUrlFromWorkerLocation } 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({
15+
remoteDocsIndexUrl: remoteDocsIndexUrlFromWorkerLocation(),
16+
});
3117
const dependencies: Dependencies = {
3218
fs: fileSystem,
3319
log: console.info.bind(console),
@@ -45,16 +31,8 @@ const dependencies: Dependencies = {
4531
rootUri,
4632
};
4733
},
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-
},
34+
themeDocset: themeDocset,
35+
jsonValidationSet: themeDocset,
5836
};
5937

6038
startServer(worker, dependencies, connection);

0 commit comments

Comments
 (0)