@platformos/platformos-common 0.0.11 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/documents-locator/DocumentsLocator.d.ts +35 -2
- package/dist/documents-locator/DocumentsLocator.js +126 -1
- package/dist/documents-locator/DocumentsLocator.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/route-table/RouteTable.d.ts +45 -0
- package/dist/route-table/RouteTable.js +384 -0
- package/dist/route-table/RouteTable.js.map +1 -0
- package/dist/route-table/index.d.ts +5 -0
- package/dist/route-table/index.js +13 -0
- package/dist/route-table/index.js.map +1 -0
- package/dist/route-table/parseSlug.d.ts +31 -0
- package/dist/route-table/parseSlug.js +124 -0
- package/dist/route-table/parseSlug.js.map +1 -0
- package/dist/route-table/slugFromFilePath.d.ts +23 -0
- package/dist/route-table/slugFromFilePath.js +81 -0
- package/dist/route-table/slugFromFilePath.js.map +1 -0
- package/dist/route-table/types.d.ts +30 -0
- package/dist/route-table/types.js +3 -0
- package/dist/route-table/types.js.map +1 -0
- package/dist/translation-provider/TranslationProvider.d.ts +1 -1
- package/dist/translation-provider/TranslationProvider.js +2 -2
- package/dist/translation-provider/TranslationProvider.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/documents-locator/DocumentsLocator.spec.ts +299 -5
- package/src/documents-locator/DocumentsLocator.ts +144 -1
- package/src/index.ts +1 -0
- package/src/route-table/RouteTable.spec.ts +708 -0
- package/src/route-table/RouteTable.ts +437 -0
- package/src/route-table/index.ts +5 -0
- package/src/route-table/parseSlug.spec.ts +177 -0
- package/src/route-table/parseSlug.ts +136 -0
- package/src/route-table/slugFromFilePath.spec.ts +84 -0
- package/src/route-table/slugFromFilePath.ts +84 -0
- package/src/route-table/types.ts +25 -0
- package/src/translation-provider/TranslationProvider.ts +4 -2
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { URI } from 'vscode-uri';
|
|
3
|
-
import { DocumentsLocator } from './DocumentsLocator';
|
|
3
|
+
import { DocumentsLocator, loadSearchPaths } from './DocumentsLocator';
|
|
4
4
|
import { AbstractFileSystem, FileType, FileStat, FileTuple } from '../AbstractFileSystem';
|
|
5
5
|
|
|
6
6
|
function createMockFileSystem(files: Record<string, string>): AbstractFileSystem {
|
|
7
7
|
const fileSet = new Set(Object.keys(files));
|
|
8
8
|
|
|
9
|
+
// Build directory tree from file paths
|
|
10
|
+
const dirs = new Set<string>();
|
|
11
|
+
for (const filePath of fileSet) {
|
|
12
|
+
const parts = filePath.split('/');
|
|
13
|
+
for (let i = 1; i < parts.length; i++) {
|
|
14
|
+
dirs.add(parts.slice(0, i).join('/'));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
return {
|
|
10
19
|
stat: vi.fn(async (uri: string): Promise<FileStat> => {
|
|
11
20
|
if (fileSet.has(uri)) {
|
|
12
21
|
return { type: FileType.File, size: files[uri].length };
|
|
13
22
|
}
|
|
23
|
+
if (dirs.has(uri)) {
|
|
24
|
+
return { type: FileType.Directory, size: 0 };
|
|
25
|
+
}
|
|
14
26
|
throw new Error(`File not found: ${uri}`);
|
|
15
27
|
}),
|
|
16
28
|
readFile: vi.fn(async (uri: string): Promise<string> => {
|
|
@@ -21,10 +33,19 @@ function createMockFileSystem(files: Record<string, string>): AbstractFileSystem
|
|
|
21
33
|
}),
|
|
22
34
|
readDirectory: vi.fn(async (uri: string): Promise<FileTuple[]> => {
|
|
23
35
|
const results: FileTuple[] = [];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
const prefix = uri.endsWith('/') ? uri : uri + '/';
|
|
38
|
+
|
|
39
|
+
for (const path of [...fileSet, ...dirs]) {
|
|
40
|
+
if (!path.startsWith(prefix)) continue;
|
|
41
|
+
const rest = path.slice(prefix.length);
|
|
42
|
+
const firstSegment = rest.split('/')[0];
|
|
43
|
+
if (!firstSegment || seen.has(firstSegment)) continue;
|
|
44
|
+
seen.add(firstSegment);
|
|
45
|
+
|
|
46
|
+
const fullPath = prefix + firstSegment;
|
|
47
|
+
const isDir = dirs.has(fullPath) && !fileSet.has(fullPath);
|
|
48
|
+
results.push([fullPath, isDir ? FileType.Directory : FileType.File]);
|
|
28
49
|
}
|
|
29
50
|
return results;
|
|
30
51
|
}),
|
|
@@ -137,4 +158,277 @@ describe('DocumentsLocator', () => {
|
|
|
137
158
|
expect(result).toEqual([]);
|
|
138
159
|
});
|
|
139
160
|
});
|
|
161
|
+
|
|
162
|
+
describe('locateWithSearchPaths', () => {
|
|
163
|
+
it('should find partial via first search path', async () => {
|
|
164
|
+
const fs = createMockFileSystem({
|
|
165
|
+
'file:///project/app/views/partials/theme/dress/card.liquid': 'dress',
|
|
166
|
+
'file:///project/app/views/partials/theme/simple/card.liquid': 'simple',
|
|
167
|
+
});
|
|
168
|
+
const locator = new DocumentsLocator(fs);
|
|
169
|
+
|
|
170
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'card', [
|
|
171
|
+
'theme/dress',
|
|
172
|
+
'theme/simple',
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
expect(result).toBe('file:///project/app/views/partials/theme/dress/card.liquid');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should fall through to second search path', async () => {
|
|
179
|
+
const fs = createMockFileSystem({
|
|
180
|
+
'file:///project/app/views/partials/theme/simple/card.liquid': 'simple',
|
|
181
|
+
});
|
|
182
|
+
const locator = new DocumentsLocator(fs);
|
|
183
|
+
|
|
184
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'card', [
|
|
185
|
+
'theme/dress',
|
|
186
|
+
'theme/simple',
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
expect(result).toBe('file:///project/app/views/partials/theme/simple/card.liquid');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should fallback to unprefixed path when no search path matches', async () => {
|
|
193
|
+
const fs = createMockFileSystem({
|
|
194
|
+
'file:///project/app/views/partials/card.liquid': 'default',
|
|
195
|
+
});
|
|
196
|
+
const locator = new DocumentsLocator(fs);
|
|
197
|
+
|
|
198
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'card', [
|
|
199
|
+
'theme/dress',
|
|
200
|
+
'theme/simple',
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
expect(result).toBe('file:///project/app/views/partials/card.liquid');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should not fallback when empty string is in search paths', async () => {
|
|
207
|
+
const fs = createMockFileSystem({
|
|
208
|
+
'file:///project/app/views/partials/card.liquid': 'default',
|
|
209
|
+
});
|
|
210
|
+
const locator = new DocumentsLocator(fs);
|
|
211
|
+
|
|
212
|
+
// '' is at position 0, so it tries default path first, finds it
|
|
213
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'card', ['', 'theme/dress']);
|
|
214
|
+
expect(result).toBe('file:///project/app/views/partials/card.liquid');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should return undefined when nothing matches', async () => {
|
|
218
|
+
const fs = createMockFileSystem({});
|
|
219
|
+
const locator = new DocumentsLocator(fs);
|
|
220
|
+
|
|
221
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/dress']);
|
|
222
|
+
expect(result).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should handle nested partial names with search paths', async () => {
|
|
226
|
+
const fs = createMockFileSystem({
|
|
227
|
+
'file:///project/app/views/partials/theme/dress/components/hero.liquid': 'hero',
|
|
228
|
+
});
|
|
229
|
+
const locator = new DocumentsLocator(fs);
|
|
230
|
+
|
|
231
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'components/hero', [
|
|
232
|
+
'theme/dress',
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
expect(result).toBe('file:///project/app/views/partials/theme/dress/components/hero.liquid');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should also search app/lib with search paths', async () => {
|
|
239
|
+
const fs = createMockFileSystem({
|
|
240
|
+
'file:///project/app/lib/theme/dress/helper.liquid': 'helper',
|
|
241
|
+
});
|
|
242
|
+
const locator = new DocumentsLocator(fs);
|
|
243
|
+
|
|
244
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'helper', ['theme/dress']);
|
|
245
|
+
|
|
246
|
+
expect(result).toBe('file:///project/app/lib/theme/dress/helper.liquid');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should expand dynamic Liquid expressions as wildcards', async () => {
|
|
250
|
+
const fs = createMockFileSystem({
|
|
251
|
+
'file:///project/app/views/partials/theme/custom/card.liquid': 'custom card',
|
|
252
|
+
});
|
|
253
|
+
const locator = new DocumentsLocator(fs);
|
|
254
|
+
|
|
255
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'card', [
|
|
256
|
+
'theme/{{ context.constants.THEME }}',
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
expect(result).toBe('file:///project/app/views/partials/theme/custom/card.liquid');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should expand multiple wildcards in a single path', async () => {
|
|
263
|
+
const fs = createMockFileSystem({
|
|
264
|
+
'file:///project/app/views/partials/acme/premium/card.liquid': 'card',
|
|
265
|
+
});
|
|
266
|
+
const locator = new DocumentsLocator(fs);
|
|
267
|
+
|
|
268
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'card', [
|
|
269
|
+
'{{ context.constants.BRAND }}/{{ context.constants.TIER }}',
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
expect(result).toBe('file:///project/app/views/partials/acme/premium/card.liquid');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should return undefined when wildcard expands but partial not found', async () => {
|
|
276
|
+
const fs = createMockFileSystem({
|
|
277
|
+
'file:///project/app/views/partials/theme/custom/other.liquid': 'other',
|
|
278
|
+
});
|
|
279
|
+
const locator = new DocumentsLocator(fs);
|
|
280
|
+
|
|
281
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'missing', [
|
|
282
|
+
'theme/{{ context.constants.THEME }}',
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
// Fallback to unprefixed — also not found
|
|
286
|
+
expect(result).toBeUndefined();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should cache expanded paths across calls', async () => {
|
|
290
|
+
const fs = createMockFileSystem({
|
|
291
|
+
'file:///project/app/views/partials/theme/custom/a.liquid': 'a',
|
|
292
|
+
'file:///project/app/views/partials/theme/custom/b.liquid': 'b',
|
|
293
|
+
});
|
|
294
|
+
const locator = new DocumentsLocator(fs);
|
|
295
|
+
const searchPaths = ['theme/{{ x }}'];
|
|
296
|
+
|
|
297
|
+
await locator.locateWithSearchPaths(rootUri, 'a', searchPaths);
|
|
298
|
+
const readDirSpy = fs.readDirectory as ReturnType<typeof vi.fn>;
|
|
299
|
+
const callCountAfterFirst = readDirSpy.mock.calls.length;
|
|
300
|
+
|
|
301
|
+
await locator.locateWithSearchPaths(rootUri, 'b', searchPaths);
|
|
302
|
+
// readDirectory should not be called again for wildcard expansion
|
|
303
|
+
// (only for locateFile stat calls, not for listSubdirectories)
|
|
304
|
+
const expansionCalls = readDirSpy.mock.calls.filter(
|
|
305
|
+
(call: string[]) =>
|
|
306
|
+
call[0].includes('app/views/partials/theme') && !call[0].includes('.liquid'),
|
|
307
|
+
);
|
|
308
|
+
// All expansion readDirectory calls should come from the first invocation
|
|
309
|
+
const expansionCallsAfterFirst = readDirSpy.mock.calls
|
|
310
|
+
.slice(callCountAfterFirst)
|
|
311
|
+
.filter(
|
|
312
|
+
(call: string[]) =>
|
|
313
|
+
call[0].includes('app/views/partials/theme') && !call[0].includes('.liquid'),
|
|
314
|
+
);
|
|
315
|
+
expect(expansionCallsAfterFirst).toHaveLength(0);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should clear expanded paths cache', async () => {
|
|
319
|
+
const fs = createMockFileSystem({
|
|
320
|
+
'file:///project/app/views/partials/theme/v1/card.liquid': 'v1',
|
|
321
|
+
});
|
|
322
|
+
const locator = new DocumentsLocator(fs);
|
|
323
|
+
|
|
324
|
+
const result1 = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/{{ version }}']);
|
|
325
|
+
expect(result1).toBe('file:///project/app/views/partials/theme/v1/card.liquid');
|
|
326
|
+
|
|
327
|
+
locator.clearExpandedPathsCache();
|
|
328
|
+
|
|
329
|
+
// After clearing, a fresh expansion should work (same result since fs unchanged)
|
|
330
|
+
const result2 = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/{{ version }}']);
|
|
331
|
+
expect(result2).toBe('file:///project/app/views/partials/theme/v1/card.liquid');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should handle module-prefixed partials with search paths', async () => {
|
|
335
|
+
const fs = createMockFileSystem({
|
|
336
|
+
'file:///project/app/modules/shop/public/views/partials/card.liquid': 'module card',
|
|
337
|
+
});
|
|
338
|
+
const locator = new DocumentsLocator(fs);
|
|
339
|
+
|
|
340
|
+
// module path in fallback (search paths don't apply to module prefix)
|
|
341
|
+
const result = await locator.locateWithSearchPaths(rootUri, 'modules/shop/card', [
|
|
342
|
+
'theme/dress',
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
expect(result).toBe('file:///project/app/modules/shop/public/views/partials/card.liquid');
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('loadSearchPaths', () => {
|
|
350
|
+
it('should load valid theme_search_paths from config', async () => {
|
|
351
|
+
const fs = createMockFileSystem({
|
|
352
|
+
'file:///project/app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
356
|
+
expect(result).toEqual(['theme/dress', 'theme/simple']);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should return null when config file does not exist', async () => {
|
|
360
|
+
const fs = createMockFileSystem({});
|
|
361
|
+
|
|
362
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
363
|
+
expect(result).toBeNull();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should return null for empty array', async () => {
|
|
367
|
+
const fs = createMockFileSystem({
|
|
368
|
+
'file:///project/app/config.yml': 'theme_search_paths: []',
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
372
|
+
expect(result).toBeNull();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should return null when theme_search_paths is not an array', async () => {
|
|
376
|
+
const fs = createMockFileSystem({
|
|
377
|
+
'file:///project/app/config.yml': 'theme_search_paths: some_string',
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
381
|
+
expect(result).toBeNull();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should return null when config has no theme_search_paths key', async () => {
|
|
385
|
+
const fs = createMockFileSystem({
|
|
386
|
+
'file:///project/app/config.yml': 'some_other_key: value',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
390
|
+
expect(result).toBeNull();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should coerce non-string entries to strings', async () => {
|
|
394
|
+
const fs = createMockFileSystem({
|
|
395
|
+
'file:///project/app/config.yml': 'theme_search_paths:\n - 123\n - true\n - null',
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
399
|
+
expect(result).toEqual(['123', 'true', 'null']);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should handle config with Liquid expressions in paths', async () => {
|
|
403
|
+
const fs = createMockFileSystem({
|
|
404
|
+
'file:///project/app/config.yml':
|
|
405
|
+
'theme_search_paths:\n - "theme/{{ context.constants.MY_THEME | default: \'custom\' }}"\n - theme/simple',
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
409
|
+
expect(result).toEqual([
|
|
410
|
+
"theme/{{ context.constants.MY_THEME | default: 'custom' }}",
|
|
411
|
+
'theme/simple',
|
|
412
|
+
]);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should handle malformed YAML gracefully', async () => {
|
|
416
|
+
const fs = createMockFileSystem({
|
|
417
|
+
'file:///project/app/config.yml': '{{invalid yaml',
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
421
|
+
expect(result).toBeNull();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('should handle config with other properties alongside theme_search_paths', async () => {
|
|
425
|
+
const fs = createMockFileSystem({
|
|
426
|
+
'file:///project/app/config.yml':
|
|
427
|
+
'some_setting: true\ntheme_search_paths:\n - theme/dress\nanother_setting: 42',
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const result = await loadSearchPaths(fs, rootUri);
|
|
431
|
+
expect(result).toEqual(['theme/dress']);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
140
434
|
});
|
|
@@ -1,8 +1,38 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
1
2
|
import { AbstractFileSystem, FileType } from '../AbstractFileSystem';
|
|
2
3
|
import { getAppPaths, getModulePaths, PlatformOSFileType } from '../path-utils';
|
|
3
4
|
import { URI, Utils } from 'vscode-uri';
|
|
4
5
|
|
|
5
|
-
export type DocumentType =
|
|
6
|
+
export type DocumentType =
|
|
7
|
+
| 'function'
|
|
8
|
+
| 'render'
|
|
9
|
+
| 'include'
|
|
10
|
+
| 'graphql'
|
|
11
|
+
| 'asset'
|
|
12
|
+
| 'theme_render_rc';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load theme_search_paths from app/config.yml.
|
|
16
|
+
* Returns null if the file doesn't exist, is malformed, or has no valid theme_search_paths.
|
|
17
|
+
* Results should be cached per root URI.
|
|
18
|
+
*/
|
|
19
|
+
export async function loadSearchPaths(
|
|
20
|
+
fs: { readFile(uri: string): Promise<string> },
|
|
21
|
+
rootUri: URI,
|
|
22
|
+
): Promise<string[] | null> {
|
|
23
|
+
try {
|
|
24
|
+
const configUri = Utils.joinPath(rootUri, 'app/config.yml').toString();
|
|
25
|
+
const content = await fs.readFile(configUri);
|
|
26
|
+
const config = yaml.load(content) as Record<string, unknown> | null;
|
|
27
|
+
const paths = config?.theme_search_paths;
|
|
28
|
+
if (Array.isArray(paths) && paths.length > 0) {
|
|
29
|
+
return paths.map(String);
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
6
36
|
|
|
7
37
|
type ModulePathInfo =
|
|
8
38
|
| { isModule: false; key: string }
|
|
@@ -127,10 +157,117 @@ export class DocumentsLocator {
|
|
|
127
157
|
return Array.from(results).sort((a, b) => a.localeCompare(b));
|
|
128
158
|
}
|
|
129
159
|
|
|
160
|
+
private static readonly LIQUID_EXPRESSION_RE = /\{\{.*?\}\}/;
|
|
161
|
+
|
|
162
|
+
private async listSubdirectoryNames(dirUri: string): Promise<string[]> {
|
|
163
|
+
try {
|
|
164
|
+
const entries = await this.fs.readDirectory(dirUri);
|
|
165
|
+
return entries
|
|
166
|
+
.filter(([, type]) => type === FileType.Directory)
|
|
167
|
+
.map(([name]) => {
|
|
168
|
+
const lastSlash = name.lastIndexOf('/');
|
|
169
|
+
return lastSlash === -1 ? name : name.slice(lastSlash + 1);
|
|
170
|
+
})
|
|
171
|
+
.filter((name) => name.length > 0);
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private expandedPathsCache = new Map<string, Promise<string[]>>();
|
|
178
|
+
|
|
179
|
+
clearExpandedPathsCache(): void {
|
|
180
|
+
this.expandedPathsCache.clear();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Expand a search path that may contain {{ ... }} Liquid expressions into
|
|
185
|
+
* concrete directory prefixes by enumerating subdirectories at each dynamic
|
|
186
|
+
* segment. Static segments pass through unchanged.
|
|
187
|
+
*
|
|
188
|
+
* Results are cached per (rootUri, searchPath) and capped at 100 entries.
|
|
189
|
+
*/
|
|
190
|
+
private async expandDynamicPath(rootUri: URI, searchPath: string): Promise<string[]> {
|
|
191
|
+
const segments = searchPath.split('/');
|
|
192
|
+
const basePaths = this.getSearchPaths('partial');
|
|
193
|
+
let prefixes = [''];
|
|
194
|
+
|
|
195
|
+
for (const segment of segments) {
|
|
196
|
+
if (!DocumentsLocator.LIQUID_EXPRESSION_RE.test(segment)) {
|
|
197
|
+
prefixes = prefixes.map((p) => (p ? `${p}/${segment}` : segment));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const nextPrefixes: string[] = [];
|
|
202
|
+
for (const prefix of prefixes) {
|
|
203
|
+
const subdirs = new Set<string>();
|
|
204
|
+
for (const base of basePaths) {
|
|
205
|
+
const dirUri = prefix
|
|
206
|
+
? Utils.joinPath(rootUri, base, prefix).toString()
|
|
207
|
+
: Utils.joinPath(rootUri, base).toString();
|
|
208
|
+
for (const name of await this.listSubdirectoryNames(dirUri)) {
|
|
209
|
+
subdirs.add(name);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const sub of subdirs) {
|
|
213
|
+
nextPrefixes.push(prefix ? `${prefix}/${sub}` : sub);
|
|
214
|
+
if (nextPrefixes.length >= 100) break;
|
|
215
|
+
}
|
|
216
|
+
if (nextPrefixes.length >= 100) break;
|
|
217
|
+
}
|
|
218
|
+
prefixes = nextPrefixes;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return prefixes;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Resolve a search path (static, dynamic, or empty) into concrete prefix
|
|
226
|
+
* strings. Cached for dynamic paths.
|
|
227
|
+
*/
|
|
228
|
+
private async resolveSearchPath(rootUri: URI, searchPath: string): Promise<string[]> {
|
|
229
|
+
if (searchPath === '') return [''];
|
|
230
|
+
if (!DocumentsLocator.LIQUID_EXPRESSION_RE.test(searchPath)) return [searchPath];
|
|
231
|
+
|
|
232
|
+
const cacheKey = `${rootUri.toString()}:${searchPath}`;
|
|
233
|
+
if (!this.expandedPathsCache.has(cacheKey)) {
|
|
234
|
+
this.expandedPathsCache.set(cacheKey, this.expandDynamicPath(rootUri, searchPath));
|
|
235
|
+
}
|
|
236
|
+
return this.expandedPathsCache.get(cacheKey)!;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Locate a partial using theme search paths (for theme_render_rc).
|
|
241
|
+
*
|
|
242
|
+
* Tries each search path prefix in priority order, then falls back to the
|
|
243
|
+
* unprefixed name (unless '' was already in the list, meaning the default
|
|
244
|
+
* position was explicitly placed).
|
|
245
|
+
*/
|
|
246
|
+
async locateWithSearchPaths(
|
|
247
|
+
rootUri: URI,
|
|
248
|
+
fileName: string,
|
|
249
|
+
themeSearchPaths: string[],
|
|
250
|
+
): Promise<string | undefined> {
|
|
251
|
+
for (const searchPath of themeSearchPaths) {
|
|
252
|
+
for (const prefix of await this.resolveSearchPath(rootUri, searchPath)) {
|
|
253
|
+
const candidate = prefix ? `${prefix}/${fileName}` : fileName;
|
|
254
|
+
const result = await this.locateFile(rootUri, candidate, 'partial');
|
|
255
|
+
if (result) return result;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!themeSearchPaths.includes('')) {
|
|
260
|
+
return this.locateFile(rootUri, fileName, 'partial');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
130
266
|
async locate(
|
|
131
267
|
rootUri: URI,
|
|
132
268
|
nodeName: DocumentType,
|
|
133
269
|
fileName: string,
|
|
270
|
+
themeSearchPaths?: string[] | null,
|
|
134
271
|
): Promise<string | undefined> {
|
|
135
272
|
switch (nodeName) {
|
|
136
273
|
case 'render':
|
|
@@ -138,6 +275,11 @@ export class DocumentsLocator {
|
|
|
138
275
|
case 'function':
|
|
139
276
|
return this.locateFile(rootUri, fileName, 'partial');
|
|
140
277
|
|
|
278
|
+
case 'theme_render_rc':
|
|
279
|
+
return themeSearchPaths
|
|
280
|
+
? this.locateWithSearchPaths(rootUri, fileName, themeSearchPaths)
|
|
281
|
+
: this.locateFile(rootUri, fileName, 'partial');
|
|
282
|
+
|
|
141
283
|
case 'graphql':
|
|
142
284
|
return this.locateFile(rootUri, fileName, 'graphql');
|
|
143
285
|
|
|
@@ -154,6 +296,7 @@ export class DocumentsLocator {
|
|
|
154
296
|
case 'function':
|
|
155
297
|
case 'render':
|
|
156
298
|
case 'include':
|
|
299
|
+
case 'theme_render_rc':
|
|
157
300
|
return this.listFiles(rootUri, filePrefix, 'partial');
|
|
158
301
|
|
|
159
302
|
case 'graphql':
|