@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/documents-locator/DocumentsLocator.d.ts +35 -2
  3. package/dist/documents-locator/DocumentsLocator.js +126 -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 +299 -5
  29. package/src/documents-locator/DocumentsLocator.ts +144 -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
  }),
@@ -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 = '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,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':
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';