@platformos/platformos-common 0.0.11 → 0.0.15

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/documents-locator/DocumentsLocator.d.ts +46 -2
  3. package/dist/documents-locator/DocumentsLocator.js +167 -1
  4. package/dist/documents-locator/DocumentsLocator.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/route-table/RouteTable.d.ts +45 -0
  9. package/dist/route-table/RouteTable.js +384 -0
  10. package/dist/route-table/RouteTable.js.map +1 -0
  11. package/dist/route-table/index.d.ts +5 -0
  12. package/dist/route-table/index.js +13 -0
  13. package/dist/route-table/index.js.map +1 -0
  14. package/dist/route-table/parseSlug.d.ts +31 -0
  15. package/dist/route-table/parseSlug.js +124 -0
  16. package/dist/route-table/parseSlug.js.map +1 -0
  17. package/dist/route-table/slugFromFilePath.d.ts +23 -0
  18. package/dist/route-table/slugFromFilePath.js +81 -0
  19. package/dist/route-table/slugFromFilePath.js.map +1 -0
  20. package/dist/route-table/types.d.ts +30 -0
  21. package/dist/route-table/types.js +3 -0
  22. package/dist/route-table/types.js.map +1 -0
  23. package/dist/translation-provider/TranslationProvider.d.ts +1 -1
  24. package/dist/translation-provider/TranslationProvider.js +2 -2
  25. package/dist/translation-provider/TranslationProvider.js.map +1 -1
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +1 -1
  28. package/src/documents-locator/DocumentsLocator.spec.ts +367 -5
  29. package/src/documents-locator/DocumentsLocator.ts +197 -1
  30. package/src/index.ts +1 -0
  31. package/src/route-table/RouteTable.spec.ts +708 -0
  32. package/src/route-table/RouteTable.ts +437 -0
  33. package/src/route-table/index.ts +5 -0
  34. package/src/route-table/parseSlug.spec.ts +177 -0
  35. package/src/route-table/parseSlug.ts +136 -0
  36. package/src/route-table/slugFromFilePath.spec.ts +84 -0
  37. package/src/route-table/slugFromFilePath.ts +84 -0
  38. package/src/route-table/types.ts +25 -0
  39. 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
- for (const filePath of fileSet) {
25
- if (filePath.startsWith(uri)) {
26
- results.push([filePath, FileType.File]);
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
  }),
@@ -34,6 +55,74 @@ function createMockFileSystem(files: Record<string, string>): AbstractFileSystem
34
55
  describe('DocumentsLocator', () => {
35
56
  const rootUri = URI.parse('file:///project');
36
57
 
58
+ describe('locateDefault', () => {
59
+ it('render → app/views/partials', () => {
60
+ const locator = new DocumentsLocator(createMockFileSystem({}));
61
+ expect(locator.locateDefault(rootUri, 'render', 'my/partial')).toBe(
62
+ 'file:///project/app/views/partials/my/partial.liquid',
63
+ );
64
+ });
65
+
66
+ it('include → app/views/partials', () => {
67
+ const locator = new DocumentsLocator(createMockFileSystem({}));
68
+ expect(locator.locateDefault(rootUri, 'include', 'card')).toBe(
69
+ 'file:///project/app/views/partials/card.liquid',
70
+ );
71
+ });
72
+
73
+ it('function → app/lib', () => {
74
+ const locator = new DocumentsLocator(createMockFileSystem({}));
75
+ expect(locator.locateDefault(rootUri, 'function', 'commands/apply')).toBe(
76
+ 'file:///project/app/lib/commands/apply.liquid',
77
+ );
78
+ });
79
+
80
+ it('graphql → app/graphql', () => {
81
+ const locator = new DocumentsLocator(createMockFileSystem({}));
82
+ expect(locator.locateDefault(rootUri, 'graphql', 'users/search')).toBe(
83
+ 'file:///project/app/graphql/users/search.graphql',
84
+ );
85
+ });
86
+
87
+ it('theme_render_rc → undefined', () => {
88
+ const locator = new DocumentsLocator(createMockFileSystem({}));
89
+ expect(locator.locateDefault(rootUri, 'theme_render_rc', 'card')).toBeUndefined();
90
+ });
91
+
92
+ it('module render → modules/.../public/views/partials', () => {
93
+ const locator = new DocumentsLocator(createMockFileSystem({}));
94
+ expect(locator.locateDefault(rootUri, 'render', 'modules/core/my/partial')).toBe(
95
+ 'file:///project/modules/core/public/views/partials/my/partial.liquid',
96
+ );
97
+ });
98
+
99
+ it('module function → modules/.../public/lib', () => {
100
+ const locator = new DocumentsLocator(createMockFileSystem({}));
101
+ expect(locator.locateDefault(rootUri, 'function', 'modules/core/commands/apply')).toBe(
102
+ 'file:///project/modules/core/public/lib/commands/apply.liquid',
103
+ );
104
+ });
105
+
106
+ it('module graphql → modules/.../public/graphql', () => {
107
+ const locator = new DocumentsLocator(createMockFileSystem({}));
108
+ expect(locator.locateDefault(rootUri, 'graphql', 'modules/core/users/search')).toBe(
109
+ 'file:///project/modules/core/public/graphql/users/search.graphql',
110
+ );
111
+ });
112
+
113
+ it('deeply nested path — creates all missing intermediate dirs', () => {
114
+ const locator = new DocumentsLocator(createMockFileSystem({}));
115
+ expect(locator.locateDefault(rootUri, 'render', 'a/b/c/d/partial')).toBe(
116
+ 'file:///project/app/views/partials/a/b/c/d/partial.liquid',
117
+ );
118
+ });
119
+
120
+ it('asset → undefined (no canonical creation path)', () => {
121
+ const locator = new DocumentsLocator(createMockFileSystem({}));
122
+ expect(locator.locateDefault(rootUri, 'asset', 'logo.png')).toBeUndefined();
123
+ });
124
+ });
125
+
37
126
  describe('locate', () => {
38
127
  it('should locate a partial file in app/lib', async () => {
39
128
  const fs = createMockFileSystem({
@@ -137,4 +226,277 @@ describe('DocumentsLocator', () => {
137
226
  expect(result).toEqual([]);
138
227
  });
139
228
  });
229
+
230
+ describe('locateWithSearchPaths', () => {
231
+ it('should find partial via first search path', async () => {
232
+ const fs = createMockFileSystem({
233
+ 'file:///project/app/views/partials/theme/dress/card.liquid': 'dress',
234
+ 'file:///project/app/views/partials/theme/simple/card.liquid': 'simple',
235
+ });
236
+ const locator = new DocumentsLocator(fs);
237
+
238
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
239
+ 'theme/dress',
240
+ 'theme/simple',
241
+ ]);
242
+
243
+ expect(result).toBe('file:///project/app/views/partials/theme/dress/card.liquid');
244
+ });
245
+
246
+ it('should fall through to second search path', async () => {
247
+ const fs = createMockFileSystem({
248
+ 'file:///project/app/views/partials/theme/simple/card.liquid': 'simple',
249
+ });
250
+ const locator = new DocumentsLocator(fs);
251
+
252
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
253
+ 'theme/dress',
254
+ 'theme/simple',
255
+ ]);
256
+
257
+ expect(result).toBe('file:///project/app/views/partials/theme/simple/card.liquid');
258
+ });
259
+
260
+ it('should fallback to unprefixed path when no search path matches', async () => {
261
+ const fs = createMockFileSystem({
262
+ 'file:///project/app/views/partials/card.liquid': 'default',
263
+ });
264
+ const locator = new DocumentsLocator(fs);
265
+
266
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
267
+ 'theme/dress',
268
+ 'theme/simple',
269
+ ]);
270
+
271
+ expect(result).toBe('file:///project/app/views/partials/card.liquid');
272
+ });
273
+
274
+ it('should not fallback when empty string is in search paths', async () => {
275
+ const fs = createMockFileSystem({
276
+ 'file:///project/app/views/partials/card.liquid': 'default',
277
+ });
278
+ const locator = new DocumentsLocator(fs);
279
+
280
+ // '' is at position 0, so it tries default path first, finds it
281
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', ['', 'theme/dress']);
282
+ expect(result).toBe('file:///project/app/views/partials/card.liquid');
283
+ });
284
+
285
+ it('should return undefined when nothing matches', async () => {
286
+ const fs = createMockFileSystem({});
287
+ const locator = new DocumentsLocator(fs);
288
+
289
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/dress']);
290
+ expect(result).toBeUndefined();
291
+ });
292
+
293
+ it('should handle nested partial names with search paths', async () => {
294
+ const fs = createMockFileSystem({
295
+ 'file:///project/app/views/partials/theme/dress/components/hero.liquid': 'hero',
296
+ });
297
+ const locator = new DocumentsLocator(fs);
298
+
299
+ const result = await locator.locateWithSearchPaths(rootUri, 'components/hero', [
300
+ 'theme/dress',
301
+ ]);
302
+
303
+ expect(result).toBe('file:///project/app/views/partials/theme/dress/components/hero.liquid');
304
+ });
305
+
306
+ it('should also search app/lib with search paths', async () => {
307
+ const fs = createMockFileSystem({
308
+ 'file:///project/app/lib/theme/dress/helper.liquid': 'helper',
309
+ });
310
+ const locator = new DocumentsLocator(fs);
311
+
312
+ const result = await locator.locateWithSearchPaths(rootUri, 'helper', ['theme/dress']);
313
+
314
+ expect(result).toBe('file:///project/app/lib/theme/dress/helper.liquid');
315
+ });
316
+
317
+ it('should expand dynamic Liquid expressions as wildcards', async () => {
318
+ const fs = createMockFileSystem({
319
+ 'file:///project/app/views/partials/theme/custom/card.liquid': 'custom card',
320
+ });
321
+ const locator = new DocumentsLocator(fs);
322
+
323
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
324
+ 'theme/{{ context.constants.THEME }}',
325
+ ]);
326
+
327
+ expect(result).toBe('file:///project/app/views/partials/theme/custom/card.liquid');
328
+ });
329
+
330
+ it('should expand multiple wildcards in a single path', async () => {
331
+ const fs = createMockFileSystem({
332
+ 'file:///project/app/views/partials/acme/premium/card.liquid': 'card',
333
+ });
334
+ const locator = new DocumentsLocator(fs);
335
+
336
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
337
+ '{{ context.constants.BRAND }}/{{ context.constants.TIER }}',
338
+ ]);
339
+
340
+ expect(result).toBe('file:///project/app/views/partials/acme/premium/card.liquid');
341
+ });
342
+
343
+ it('should return undefined when wildcard expands but partial not found', async () => {
344
+ const fs = createMockFileSystem({
345
+ 'file:///project/app/views/partials/theme/custom/other.liquid': 'other',
346
+ });
347
+ const locator = new DocumentsLocator(fs);
348
+
349
+ const result = await locator.locateWithSearchPaths(rootUri, 'missing', [
350
+ 'theme/{{ context.constants.THEME }}',
351
+ ]);
352
+
353
+ // Fallback to unprefixed — also not found
354
+ expect(result).toBeUndefined();
355
+ });
356
+
357
+ it('should cache expanded paths across calls', async () => {
358
+ const fs = createMockFileSystem({
359
+ 'file:///project/app/views/partials/theme/custom/a.liquid': 'a',
360
+ 'file:///project/app/views/partials/theme/custom/b.liquid': 'b',
361
+ });
362
+ const locator = new DocumentsLocator(fs);
363
+ const searchPaths = ['theme/{{ x }}'];
364
+
365
+ await locator.locateWithSearchPaths(rootUri, 'a', searchPaths);
366
+ const readDirSpy = fs.readDirectory as ReturnType<typeof vi.fn>;
367
+ const callCountAfterFirst = readDirSpy.mock.calls.length;
368
+
369
+ await locator.locateWithSearchPaths(rootUri, 'b', searchPaths);
370
+ // readDirectory should not be called again for wildcard expansion
371
+ // (only for locateFile stat calls, not for listSubdirectories)
372
+ const expansionCalls = readDirSpy.mock.calls.filter(
373
+ (call: string[]) =>
374
+ call[0].includes('app/views/partials/theme') && !call[0].includes('.liquid'),
375
+ );
376
+ // All expansion readDirectory calls should come from the first invocation
377
+ const expansionCallsAfterFirst = readDirSpy.mock.calls
378
+ .slice(callCountAfterFirst)
379
+ .filter(
380
+ (call: string[]) =>
381
+ call[0].includes('app/views/partials/theme') && !call[0].includes('.liquid'),
382
+ );
383
+ expect(expansionCallsAfterFirst).toHaveLength(0);
384
+ });
385
+
386
+ it('should clear expanded paths cache', async () => {
387
+ const fs = createMockFileSystem({
388
+ 'file:///project/app/views/partials/theme/v1/card.liquid': 'v1',
389
+ });
390
+ const locator = new DocumentsLocator(fs);
391
+
392
+ const result1 = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/{{ version }}']);
393
+ expect(result1).toBe('file:///project/app/views/partials/theme/v1/card.liquid');
394
+
395
+ locator.clearExpandedPathsCache();
396
+
397
+ // After clearing, a fresh expansion should work (same result since fs unchanged)
398
+ const result2 = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/{{ version }}']);
399
+ expect(result2).toBe('file:///project/app/views/partials/theme/v1/card.liquid');
400
+ });
401
+
402
+ it('should handle module-prefixed partials with search paths', async () => {
403
+ const fs = createMockFileSystem({
404
+ 'file:///project/app/modules/shop/public/views/partials/card.liquid': 'module card',
405
+ });
406
+ const locator = new DocumentsLocator(fs);
407
+
408
+ // module path in fallback (search paths don't apply to module prefix)
409
+ const result = await locator.locateWithSearchPaths(rootUri, 'modules/shop/card', [
410
+ 'theme/dress',
411
+ ]);
412
+
413
+ expect(result).toBe('file:///project/app/modules/shop/public/views/partials/card.liquid');
414
+ });
415
+ });
416
+
417
+ describe('loadSearchPaths', () => {
418
+ it('should load valid theme_search_paths from config', async () => {
419
+ const fs = createMockFileSystem({
420
+ 'file:///project/app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
421
+ });
422
+
423
+ const result = await loadSearchPaths(fs, rootUri);
424
+ expect(result).toEqual(['theme/dress', 'theme/simple']);
425
+ });
426
+
427
+ it('should return null when config file does not exist', async () => {
428
+ const fs = createMockFileSystem({});
429
+
430
+ const result = await loadSearchPaths(fs, rootUri);
431
+ expect(result).toBeNull();
432
+ });
433
+
434
+ it('should return null for empty array', async () => {
435
+ const fs = createMockFileSystem({
436
+ 'file:///project/app/config.yml': 'theme_search_paths: []',
437
+ });
438
+
439
+ const result = await loadSearchPaths(fs, rootUri);
440
+ expect(result).toBeNull();
441
+ });
442
+
443
+ it('should return null when theme_search_paths is not an array', async () => {
444
+ const fs = createMockFileSystem({
445
+ 'file:///project/app/config.yml': 'theme_search_paths: some_string',
446
+ });
447
+
448
+ const result = await loadSearchPaths(fs, rootUri);
449
+ expect(result).toBeNull();
450
+ });
451
+
452
+ it('should return null when config has no theme_search_paths key', async () => {
453
+ const fs = createMockFileSystem({
454
+ 'file:///project/app/config.yml': 'some_other_key: value',
455
+ });
456
+
457
+ const result = await loadSearchPaths(fs, rootUri);
458
+ expect(result).toBeNull();
459
+ });
460
+
461
+ it('should coerce non-string entries to strings', async () => {
462
+ const fs = createMockFileSystem({
463
+ 'file:///project/app/config.yml': 'theme_search_paths:\n - 123\n - true\n - null',
464
+ });
465
+
466
+ const result = await loadSearchPaths(fs, rootUri);
467
+ expect(result).toEqual(['123', 'true', 'null']);
468
+ });
469
+
470
+ it('should handle config with Liquid expressions in paths', async () => {
471
+ const fs = createMockFileSystem({
472
+ 'file:///project/app/config.yml':
473
+ 'theme_search_paths:\n - "theme/{{ context.constants.MY_THEME | default: \'custom\' }}"\n - theme/simple',
474
+ });
475
+
476
+ const result = await loadSearchPaths(fs, rootUri);
477
+ expect(result).toEqual([
478
+ "theme/{{ context.constants.MY_THEME | default: 'custom' }}",
479
+ 'theme/simple',
480
+ ]);
481
+ });
482
+
483
+ it('should handle malformed YAML gracefully', async () => {
484
+ const fs = createMockFileSystem({
485
+ 'file:///project/app/config.yml': '{{invalid yaml',
486
+ });
487
+
488
+ const result = await loadSearchPaths(fs, rootUri);
489
+ expect(result).toBeNull();
490
+ });
491
+
492
+ it('should handle config with other properties alongside theme_search_paths', async () => {
493
+ const fs = createMockFileSystem({
494
+ 'file:///project/app/config.yml':
495
+ 'some_setting: true\ntheme_search_paths:\n - theme/dress\nanother_setting: 42',
496
+ });
497
+
498
+ const result = await loadSearchPaths(fs, rootUri);
499
+ expect(result).toEqual(['theme/dress']);
500
+ });
501
+ });
140
502
  });
@@ -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 = 'function' | 'render' | 'include' | 'graphql' | 'asset';
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,170 @@ 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
+
266
+ /**
267
+ * Returns the canonical URI where `fileName` would live — used as a
268
+ * go-to-definition fallback when the file doesn't exist yet.
269
+ * Returns undefined for theme_render_rc (ambiguous search path) and asset.
270
+ */
271
+ locateDefault(rootUri: URI, nodeName: DocumentType, fileName: string): string | undefined {
272
+ const parsed = this.parseModulePath(fileName);
273
+
274
+ let basePath: string;
275
+ let ext: string;
276
+
277
+ switch (nodeName) {
278
+ case 'render':
279
+ case 'include':
280
+ basePath = parsed.isModule
281
+ ? `modules/${parsed.moduleName}/public/views/partials`
282
+ : 'app/views/partials';
283
+ ext = '.liquid';
284
+ break;
285
+ case 'function':
286
+ basePath = parsed.isModule ? `modules/${parsed.moduleName}/public/lib` : 'app/lib';
287
+ ext = '.liquid';
288
+ break;
289
+ case 'graphql':
290
+ basePath = parsed.isModule ? `modules/${parsed.moduleName}/public/graphql` : 'app/graphql';
291
+ ext = '.graphql';
292
+ break;
293
+ case 'theme_render_rc': // ambiguous — multiple search paths, no single canonical location
294
+ case 'asset': // no canonical creation path
295
+ return undefined;
296
+ default:
297
+ return undefined;
298
+ }
299
+
300
+ return Utils.joinPath(rootUri, basePath, parsed.key + ext).toString();
301
+ }
302
+
303
+ /**
304
+ * Resolves `fileName` to a filesystem URI (if the file exists), or falls
305
+ * back to the canonical default URI from `locateDefault`.
306
+ */
307
+ async locateOrDefault(
308
+ rootUri: URI,
309
+ nodeName: DocumentType,
310
+ fileName: string,
311
+ themeSearchPaths?: string[] | null,
312
+ ): Promise<string | undefined> {
313
+ return (
314
+ (await this.locate(rootUri, nodeName, fileName, themeSearchPaths)) ??
315
+ this.locateDefault(rootUri, nodeName, fileName)
316
+ );
317
+ }
318
+
130
319
  async locate(
131
320
  rootUri: URI,
132
321
  nodeName: DocumentType,
133
322
  fileName: string,
323
+ themeSearchPaths?: string[] | null,
134
324
  ): Promise<string | undefined> {
135
325
  switch (nodeName) {
136
326
  case 'render':
@@ -138,6 +328,11 @@ export class DocumentsLocator {
138
328
  case 'function':
139
329
  return this.locateFile(rootUri, fileName, 'partial');
140
330
 
331
+ case 'theme_render_rc':
332
+ return themeSearchPaths
333
+ ? this.locateWithSearchPaths(rootUri, fileName, themeSearchPaths)
334
+ : this.locateFile(rootUri, fileName, 'partial');
335
+
141
336
  case 'graphql':
142
337
  return this.locateFile(rootUri, fileName, 'graphql');
143
338
 
@@ -154,6 +349,7 @@ export class DocumentsLocator {
154
349
  case 'function':
155
350
  case 'render':
156
351
  case 'include':
352
+ case 'theme_render_rc':
157
353
  return this.listFiles(rootUri, filePrefix, 'partial');
158
354
 
159
355
  case 'graphql':
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './documents-locator/DocumentsLocator';
2
2
  export * from './translation-provider/TranslationProvider';
3
+ export * from './route-table';
3
4
  export * from './AbstractFileSystem';
4
5
  export * from './path-utils';