@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
@@ -0,0 +1,708 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { URI } from 'vscode-uri';
3
+ import { RouteTable } from './RouteTable';
4
+ import { AbstractFileSystem, FileType, FileStat, FileTuple } from '../AbstractFileSystem';
5
+
6
+ function createMockFileSystem(files: Record<string, string>): AbstractFileSystem {
7
+ const fileSet = new Map(Object.entries(files));
8
+
9
+ return {
10
+ stat: vi.fn(async (uri: string): Promise<FileStat> => {
11
+ if (fileSet.has(uri)) {
12
+ return { type: FileType.File, size: fileSet.get(uri)!.length };
13
+ }
14
+ throw new Error(`File not found: ${uri}`);
15
+ }),
16
+ readFile: vi.fn(async (uri: string): Promise<string> => {
17
+ if (fileSet.has(uri)) {
18
+ return fileSet.get(uri)!;
19
+ }
20
+ throw new Error(`File not found: ${uri}`);
21
+ }),
22
+ readDirectory: vi.fn(async (uri: string): Promise<FileTuple[]> => {
23
+ const results: FileTuple[] = [];
24
+ const dirs = new Set<string>();
25
+
26
+ for (const filePath of fileSet.keys()) {
27
+ if (filePath.startsWith(uri + '/') || filePath === uri) {
28
+ // Check if this is a direct child or nested
29
+ const remaining = filePath.slice(uri.length + 1);
30
+ const slashIdx = remaining.indexOf('/');
31
+ if (slashIdx === -1) {
32
+ results.push([filePath, FileType.File]);
33
+ } else {
34
+ const dirName = uri + '/' + remaining.slice(0, slashIdx);
35
+ if (!dirs.has(dirName)) {
36
+ dirs.add(dirName);
37
+ results.push([dirName, FileType.Directory]);
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ if (results.length === 0 && !fileSet.has(uri)) {
44
+ throw new Error(`Directory not found: ${uri}`);
45
+ }
46
+
47
+ return results;
48
+ }),
49
+ };
50
+ }
51
+
52
+ const ROOT = URI.parse('file:///project');
53
+
54
+ function page(path: string, content: string = ''): [string, string] {
55
+ return [`file:///project/${path}`, content];
56
+ }
57
+
58
+ describe('RouteTable', () => {
59
+ describe('build and static matching', () => {
60
+ it('matches a simple static page', async () => {
61
+ const fs = createMockFileSystem(
62
+ Object.fromEntries([page('app/views/pages/about.html.liquid')]),
63
+ );
64
+ const rt = new RouteTable(fs);
65
+ await rt.build(ROOT);
66
+
67
+ expect(rt.hasMatch('/about')).toBe(true);
68
+ expect(rt.hasMatch('/nonexistent')).toBe(false);
69
+ });
70
+
71
+ it('matches root index page', async () => {
72
+ const fs = createMockFileSystem(
73
+ Object.fromEntries([page('app/views/pages/index.html.liquid')]),
74
+ );
75
+ const rt = new RouteTable(fs);
76
+ await rt.build(ROOT);
77
+
78
+ expect(rt.hasMatch('/')).toBe(true);
79
+ });
80
+
81
+ it('matches root from home.html.liquid (deprecated alias)', async () => {
82
+ const fs = createMockFileSystem(
83
+ Object.fromEntries([page('app/views/pages/home.html.liquid')]),
84
+ );
85
+ const rt = new RouteTable(fs);
86
+ await rt.build(ROOT);
87
+
88
+ expect(rt.hasMatch('/')).toBe(true);
89
+ expect(rt.hasMatch('/home')).toBe(false);
90
+ });
91
+
92
+ it('normalizes trailing slashes', async () => {
93
+ const fs = createMockFileSystem(
94
+ Object.fromEntries([page('app/views/pages/about.html.liquid')]),
95
+ );
96
+ const rt = new RouteTable(fs);
97
+ await rt.build(ROOT);
98
+
99
+ expect(rt.hasMatch('/about/')).toBe(true);
100
+ });
101
+
102
+ it('matches nested pages', async () => {
103
+ const fs = createMockFileSystem(
104
+ Object.fromEntries([page('app/views/pages/users/show.html.liquid')]),
105
+ );
106
+ const rt = new RouteTable(fs);
107
+ await rt.build(ROOT);
108
+
109
+ expect(rt.hasMatch('/users/show')).toBe(true);
110
+ expect(rt.hasMatch('/users')).toBe(false);
111
+ });
112
+
113
+ it('handles index aliasing', async () => {
114
+ const fs = createMockFileSystem(
115
+ Object.fromEntries([page('app/views/pages/products/index.html.liquid')]),
116
+ );
117
+ const rt = new RouteTable(fs);
118
+ await rt.build(ROOT);
119
+
120
+ expect(rt.hasMatch('/products')).toBe(true);
121
+ expect(rt.hasMatch('/products/index')).toBe(true);
122
+ });
123
+
124
+ it('does not report false match', async () => {
125
+ const fs = createMockFileSystem(
126
+ Object.fromEntries([
127
+ page('app/views/pages/about.html.liquid'),
128
+ page('app/views/pages/contact.html.liquid'),
129
+ ]),
130
+ );
131
+ const rt = new RouteTable(fs);
132
+ await rt.build(ROOT);
133
+
134
+ expect(rt.hasMatch('/nonexistent')).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe('frontmatter slug override', () => {
139
+ it('uses frontmatter slug instead of file path', async () => {
140
+ const fs = createMockFileSystem(
141
+ Object.fromEntries([
142
+ page('app/views/pages/legacy-about.html.liquid', '---\nslug: about\n---\n<h1>About</h1>'),
143
+ ]),
144
+ );
145
+ const rt = new RouteTable(fs);
146
+ await rt.build(ROOT);
147
+
148
+ expect(rt.hasMatch('/about')).toBe(true);
149
+ expect(rt.hasMatch('/legacy-about')).toBe(false);
150
+ });
151
+ });
152
+
153
+ describe('dynamic route matching', () => {
154
+ it('matches :param segments', async () => {
155
+ const fs = createMockFileSystem(
156
+ Object.fromEntries([
157
+ page('app/views/pages/users.html.liquid', '---\nslug: users/:id\n---\n'),
158
+ ]),
159
+ );
160
+ const rt = new RouteTable(fs);
161
+ await rt.build(ROOT);
162
+
163
+ expect(rt.hasMatch('/users/42')).toBe(true);
164
+ expect(rt.hasMatch('/users/john')).toBe(true);
165
+ expect(rt.hasMatch('/users')).toBe(false);
166
+ expect(rt.hasMatch('/users/42/extra')).toBe(false);
167
+ });
168
+
169
+ it('matches :param with static suffix', async () => {
170
+ const fs = createMockFileSystem(
171
+ Object.fromEntries([
172
+ page('app/views/pages/user-edit.html.liquid', '---\nslug: users/:id/edit\n---\n'),
173
+ ]),
174
+ );
175
+ const rt = new RouteTable(fs);
176
+ await rt.build(ROOT);
177
+
178
+ expect(rt.hasMatch('/users/42/edit')).toBe(true);
179
+ expect(rt.hasMatch('/users/42')).toBe(false);
180
+ });
181
+
182
+ it('matches wildcard segments', async () => {
183
+ const fs = createMockFileSystem(
184
+ Object.fromEntries([
185
+ page('app/views/pages/api.html.liquid', '---\nslug: api/*path\n---\n'),
186
+ ]),
187
+ );
188
+ const rt = new RouteTable(fs);
189
+ await rt.build(ROOT);
190
+
191
+ expect(rt.hasMatch('/api/v1')).toBe(true);
192
+ expect(rt.hasMatch('/api/v1/users/42')).toBe(true);
193
+ expect(rt.hasMatch('/api')).toBe(false);
194
+ });
195
+ });
196
+
197
+ describe('optional segment matching', () => {
198
+ it('matches with optional part omitted', async () => {
199
+ const fs = createMockFileSystem(
200
+ Object.fromEntries([
201
+ page('app/views/pages/users.html.liquid', '---\nslug: users(/:id)\n---\n'),
202
+ ]),
203
+ );
204
+ const rt = new RouteTable(fs);
205
+ await rt.build(ROOT);
206
+
207
+ expect(rt.hasMatch('/users')).toBe(true);
208
+ expect(rt.hasMatch('/users/42')).toBe(true);
209
+ expect(rt.hasMatch('/users/42/extra')).toBe(false);
210
+ });
211
+
212
+ it('matches with multiple optional groups', async () => {
213
+ const fs = createMockFileSystem(
214
+ Object.fromEntries([
215
+ page('app/views/pages/search.html.liquid', '---\nslug: search(/:country)(/:city)\n---\n'),
216
+ ]),
217
+ );
218
+ const rt = new RouteTable(fs);
219
+ await rt.build(ROOT);
220
+
221
+ expect(rt.hasMatch('/search')).toBe(true);
222
+ expect(rt.hasMatch('/search/us')).toBe(true);
223
+ expect(rt.hasMatch('/search/us/nyc')).toBe(true);
224
+ });
225
+
226
+ it('matches optional group with static + wildcard', async () => {
227
+ const fs = createMockFileSystem(
228
+ Object.fromEntries([
229
+ page('app/views/pages/users.html.liquid', '---\nslug: users(/section/*)\n---\n'),
230
+ ]),
231
+ );
232
+ const rt = new RouteTable(fs);
233
+ await rt.build(ROOT);
234
+
235
+ expect(rt.hasMatch('/users')).toBe(true);
236
+ expect(rt.hasMatch('/users/section/anything/here')).toBe(true);
237
+ expect(rt.hasMatch('/users/other')).toBe(false);
238
+ });
239
+ });
240
+
241
+ describe('method matching', () => {
242
+ it('filters by HTTP method', async () => {
243
+ const fs = createMockFileSystem(
244
+ Object.fromEntries([
245
+ page('app/views/pages/login.html.liquid'),
246
+ page('app/views/pages/login-post.html.liquid', '---\nslug: login\nmethod: post\n---\n'),
247
+ ]),
248
+ );
249
+ const rt = new RouteTable(fs);
250
+ await rt.build(ROOT);
251
+
252
+ expect(rt.hasMatch('/login', 'get')).toBe(true);
253
+ expect(rt.hasMatch('/login', 'post')).toBe(true);
254
+ expect(rt.hasMatch('/login', 'delete')).toBe(false);
255
+ });
256
+
257
+ it('defaults method to get', async () => {
258
+ const fs = createMockFileSystem(
259
+ Object.fromEntries([page('app/views/pages/about.html.liquid')]),
260
+ );
261
+ const rt = new RouteTable(fs);
262
+ await rt.build(ROOT);
263
+
264
+ expect(rt.hasMatch('/about', 'get')).toBe(true);
265
+ expect(rt.hasMatch('/about', 'post')).toBe(false);
266
+ });
267
+ });
268
+
269
+ describe('format matching', () => {
270
+ it('does not match json format page without explicit .json extension', async () => {
271
+ const fs = createMockFileSystem(
272
+ Object.fromEntries([page('app/views/pages/api/data.json.liquid')]),
273
+ );
274
+ const rt = new RouteTable(fs);
275
+ await rt.build(ROOT);
276
+
277
+ // /api/data without extension defaults to html — no html page exists
278
+ expect(rt.hasMatch('/api/data')).toBe(false);
279
+ // Explicit .json extension matches the json page
280
+ expect(rt.hasMatch('/api/data.json')).toBe(true);
281
+ });
282
+
283
+ it('matches format extension in URL against the correct format page', async () => {
284
+ const fs = createMockFileSystem(
285
+ Object.fromEntries([
286
+ page('app/views/pages/api/my-page.html.liquid'),
287
+ page('app/views/pages/api/my-page.json.liquid'),
288
+ ]),
289
+ );
290
+ const rt = new RouteTable(fs);
291
+ await rt.build(ROOT);
292
+
293
+ // Without format suffix — defaults to html, only html page matches
294
+ const htmlDefaultMatches = rt.match('/api/my-page');
295
+ expect(htmlDefaultMatches.length).toBe(1);
296
+ expect(htmlDefaultMatches[0].format).toBe('html');
297
+
298
+ // With .json suffix — only the json page matches
299
+ const jsonMatches = rt.match('/api/my-page.json');
300
+ expect(jsonMatches.length).toBe(1);
301
+ expect(jsonMatches[0].format).toBe('json');
302
+ expect(jsonMatches[0].uri).toContain('my-page.json.liquid');
303
+
304
+ // With .html suffix — only the html page matches
305
+ const htmlMatches = rt.match('/api/my-page.html');
306
+ expect(htmlMatches.length).toBe(1);
307
+ expect(htmlMatches[0].format).toBe('html');
308
+
309
+ // hasMatch respects format too
310
+ expect(rt.hasMatch('/api/my-page.json')).toBe(true);
311
+ expect(rt.hasMatch('/api/my-page.xml')).toBe(false);
312
+ });
313
+
314
+ it('does not treat unknown extensions as format suffixes', async () => {
315
+ const fs = createMockFileSystem(
316
+ Object.fromEntries([
317
+ page(
318
+ 'app/views/pages/files/report.html.liquid',
319
+ '---\nslug: files/report.unknown\n---\n',
320
+ ),
321
+ ]),
322
+ );
323
+ const rt = new RouteTable(fs);
324
+ await rt.build(ROOT);
325
+
326
+ // .unknown is not a known format, so the whole segment is the slug component
327
+ expect(rt.hasMatch('/files/report.unknown')).toBe(true);
328
+ });
329
+ });
330
+
331
+ describe('Liquid interpolation matching', () => {
332
+ it('matches :_liquid_ against :param route', async () => {
333
+ const fs = createMockFileSystem(
334
+ Object.fromEntries([
335
+ page('app/views/pages/users.html.liquid', '---\nslug: users/:id\n---\n'),
336
+ ]),
337
+ );
338
+ const rt = new RouteTable(fs);
339
+ await rt.build(ROOT);
340
+
341
+ expect(rt.hasMatch('/users/:_liquid_')).toBe(true);
342
+ });
343
+
344
+ it('matches :_liquid_ against static route segment', async () => {
345
+ const fs = createMockFileSystem(
346
+ Object.fromEntries([page('app/views/pages/users/show.html.liquid')]),
347
+ );
348
+ const rt = new RouteTable(fs);
349
+ await rt.build(ROOT);
350
+
351
+ // /users/:_liquid_ should match users/show because :_liquid_ matches any segment
352
+ expect(rt.hasMatch('/users/:_liquid_')).toBe(true);
353
+ });
354
+
355
+ it('matches mixed static + :_liquid_ pattern', async () => {
356
+ const fs = createMockFileSystem(
357
+ Object.fromEntries([
358
+ page(
359
+ 'app/views/pages/product-reviews.html.liquid',
360
+ '---\nslug: products/:id/reviews\n---\n',
361
+ ),
362
+ ]),
363
+ );
364
+ const rt = new RouteTable(fs);
365
+ await rt.build(ROOT);
366
+
367
+ expect(rt.hasMatch('/products/:_liquid_/reviews')).toBe(true);
368
+ expect(rt.hasMatch('/products/:_liquid_/settings')).toBe(false);
369
+ });
370
+
371
+ it('does not match :_liquid_ when no route exists', async () => {
372
+ const fs = createMockFileSystem(
373
+ Object.fromEntries([page('app/views/pages/about.html.liquid')]),
374
+ );
375
+ const rt = new RouteTable(fs);
376
+ await rt.build(ROOT);
377
+
378
+ expect(rt.hasMatch('/orders/:_liquid_/invoice')).toBe(false);
379
+ });
380
+ });
381
+
382
+ describe('precedence ordering', () => {
383
+ it('returns most specific route first', async () => {
384
+ const fs = createMockFileSystem(
385
+ Object.fromEntries([
386
+ page('app/views/pages/users-section-1.html.liquid', '---\nslug: users/section/1\n---\n'),
387
+ page(
388
+ 'app/views/pages/users-section-id.html.liquid',
389
+ '---\nslug: users/section/:id\n---\n',
390
+ ),
391
+ page('app/views/pages/users-wild.html.liquid', '---\nslug: users/*\n---\n'),
392
+ ]),
393
+ );
394
+ const rt = new RouteTable(fs);
395
+ await rt.build(ROOT);
396
+
397
+ const matches = rt.match('/users/section/1');
398
+ expect(matches.length).toBeGreaterThanOrEqual(2);
399
+ // Most specific (all static) should be first
400
+ expect(matches[0].slug).toBe('users/section/1');
401
+ });
402
+
403
+ it('prefers required param over optional param', async () => {
404
+ const fs = createMockFileSystem(
405
+ Object.fromEntries([
406
+ page('app/views/pages/users-required.html.liquid', '---\nslug: users/:id\n---\n'),
407
+ page('app/views/pages/users-optional.html.liquid', '---\nslug: users(/:id)\n---\n'),
408
+ ]),
409
+ );
410
+ const rt = new RouteTable(fs);
411
+ await rt.build(ROOT);
412
+
413
+ const matches = rt.match('/users/42');
414
+ expect(matches.length).toBe(2);
415
+ expect(matches[0].slug).toBe('users/:id');
416
+ });
417
+ });
418
+
419
+ describe('updateFile and removeFile', () => {
420
+ it('adds a route via updateFile', async () => {
421
+ const fs = createMockFileSystem({});
422
+ const rt = new RouteTable(fs);
423
+
424
+ rt.updateFile(
425
+ 'file:///project/app/views/pages/new-page.html.liquid',
426
+ '---\nslug: new-page\n---\n',
427
+ );
428
+
429
+ expect(rt.hasMatch('/new-page')).toBe(true);
430
+ });
431
+
432
+ it('removes a route via removeFile', async () => {
433
+ const fs = createMockFileSystem(
434
+ Object.fromEntries([page('app/views/pages/about.html.liquid')]),
435
+ );
436
+ const rt = new RouteTable(fs);
437
+ await rt.build(ROOT);
438
+
439
+ expect(rt.hasMatch('/about')).toBe(true);
440
+
441
+ rt.removeFile('file:///project/app/views/pages/about.html.liquid');
442
+
443
+ expect(rt.hasMatch('/about')).toBe(false);
444
+ });
445
+
446
+ it('updates existing route', async () => {
447
+ const fs = createMockFileSystem(
448
+ Object.fromEntries([page('app/views/pages/page.html.liquid', '---\nslug: old\n---\n')]),
449
+ );
450
+ const rt = new RouteTable(fs);
451
+ await rt.build(ROOT);
452
+
453
+ expect(rt.hasMatch('/old')).toBe(true);
454
+ expect(rt.hasMatch('/new')).toBe(false);
455
+
456
+ rt.updateFile('file:///project/app/views/pages/page.html.liquid', '---\nslug: new\n---\n');
457
+
458
+ expect(rt.hasMatch('/old')).toBe(false);
459
+ expect(rt.hasMatch('/new')).toBe(true);
460
+ });
461
+ });
462
+
463
+ describe('module pages', () => {
464
+ it('discovers and matches module pages', async () => {
465
+ const files = Object.fromEntries([
466
+ page('modules/admin/public/views/pages/dashboard.html.liquid'),
467
+ ]);
468
+ // Also need the modules directory to be discoverable
469
+ const fs = createMockFileSystem(files);
470
+ const rt = new RouteTable(fs);
471
+
472
+ // Manually add since build requires directory listing
473
+ rt.updateFile('file:///project/modules/admin/public/views/pages/dashboard.html.liquid', '');
474
+
475
+ expect(rt.hasMatch('/dashboard')).toBe(true);
476
+ });
477
+ });
478
+
479
+ describe('build clears previous state', () => {
480
+ it('rebuild after branch switch picks up new pages', async () => {
481
+ // Simulate initial branch with one page
482
+ const initialFiles = Object.fromEntries([page('app/views/pages/about.html.liquid')]);
483
+ const fs = createMockFileSystem(initialFiles);
484
+ const rt = new RouteTable(fs);
485
+ await rt.build(ROOT);
486
+
487
+ expect(rt.hasMatch('/about')).toBe(true);
488
+ expect(rt.hasMatch('/contact')).toBe(false);
489
+
490
+ // Simulate branch switch: replace backing files and rebuild
491
+ const newFiles = Object.fromEntries([page('app/views/pages/contact.html.liquid')]);
492
+ const fs2 = createMockFileSystem(newFiles);
493
+ const rt2 = new RouteTable(fs2);
494
+ await rt2.build(ROOT);
495
+
496
+ expect(rt2.hasMatch('/about')).toBe(false);
497
+ expect(rt2.hasMatch('/contact')).toBe(true);
498
+ });
499
+
500
+ it('build clears stale routes from a previous build', async () => {
501
+ const files = Object.fromEntries([page('app/views/pages/old-page.html.liquid')]);
502
+ const fs = createMockFileSystem(files);
503
+ const rt = new RouteTable(fs);
504
+ await rt.build(ROOT);
505
+
506
+ expect(rt.hasMatch('/old-page')).toBe(true);
507
+
508
+ // Simulate changed filesystem (branch switch) and rebuild
509
+ const newFiles = Object.fromEntries([page('app/views/pages/new-page.html.liquid')]);
510
+ const fs2 = createMockFileSystem(newFiles);
511
+ const rt2 = new RouteTable(fs2);
512
+ await rt2.build(ROOT);
513
+
514
+ expect(rt2.hasMatch('/old-page')).toBe(false);
515
+ expect(rt2.hasMatch('/new-page')).toBe(true);
516
+ });
517
+ });
518
+
519
+ describe('Windows-style URIs', () => {
520
+ it('handles Windows file URIs with drive letters', () => {
521
+ const rt = new RouteTable(createMockFileSystem({}));
522
+
523
+ // vscode-uri produces forward-slash URIs even on Windows
524
+ rt.updateFile('file:///C:/Users/dev/project/app/views/pages/about.html.liquid', '');
525
+
526
+ expect(rt.hasMatch('/about')).toBe(true);
527
+ });
528
+
529
+ it('handles Windows module page URIs', () => {
530
+ const rt = new RouteTable(createMockFileSystem({}));
531
+
532
+ rt.updateFile(
533
+ 'file:///C:/Users/dev/project/modules/admin/public/views/pages/dashboard.html.liquid',
534
+ '',
535
+ );
536
+
537
+ expect(rt.hasMatch('/dashboard')).toBe(true);
538
+ });
539
+ });
540
+
541
+ describe('format defaults to html', () => {
542
+ it('plain URL without extension matches only html pages', async () => {
543
+ const fs = createMockFileSystem(
544
+ Object.fromEntries([
545
+ page('app/views/pages/about.html.liquid'),
546
+ page('app/views/pages/about.json.liquid'),
547
+ page('app/views/pages/about.xml.liquid'),
548
+ ]),
549
+ );
550
+ const rt = new RouteTable(fs);
551
+ await rt.build(ROOT);
552
+
553
+ const matches = rt.match('/about');
554
+ expect(matches.length).toBe(1);
555
+ expect(matches[0].format).toBe('html');
556
+ });
557
+
558
+ it('page with only .liquid extension defaults to html format', async () => {
559
+ const fs = createMockFileSystem(Object.fromEntries([page('app/views/pages/simple.liquid')]));
560
+ const rt = new RouteTable(fs);
561
+ await rt.build(ROOT);
562
+
563
+ expect(rt.hasMatch('/simple')).toBe(true);
564
+
565
+ const matches = rt.match('/simple');
566
+ expect(matches[0].format).toBe('html');
567
+ });
568
+
569
+ it('json-only page requires .json extension to match', async () => {
570
+ const fs = createMockFileSystem(
571
+ Object.fromEntries([page('app/views/pages/api/endpoint.json.liquid')]),
572
+ );
573
+ const rt = new RouteTable(fs);
574
+ await rt.build(ROOT);
575
+
576
+ expect(rt.hasMatch('/api/endpoint')).toBe(false);
577
+ expect(rt.hasMatch('/api/endpoint.json')).toBe(true);
578
+ });
579
+ });
580
+
581
+ describe('routeCount', () => {
582
+ it('returns 0 for empty table', () => {
583
+ const rt = new RouteTable(createMockFileSystem({}));
584
+ expect(rt.routeCount()).toBe(0);
585
+ });
586
+
587
+ it('counts single route entry', async () => {
588
+ const fs = createMockFileSystem(
589
+ Object.fromEntries([page('app/views/pages/about.html.liquid')]),
590
+ );
591
+ const rt = new RouteTable(fs);
592
+ await rt.build(ROOT);
593
+
594
+ expect(rt.routeCount()).toBe(1);
595
+ });
596
+
597
+ it('counts index alias as two entries', async () => {
598
+ const fs = createMockFileSystem(
599
+ Object.fromEntries([page('app/views/pages/products/index.html.liquid')]),
600
+ );
601
+ const rt = new RouteTable(fs);
602
+ await rt.build(ROOT);
603
+
604
+ // products + products/index
605
+ expect(rt.routeCount()).toBe(2);
606
+ });
607
+
608
+ it('decrements after removeFile', async () => {
609
+ const fs = createMockFileSystem(
610
+ Object.fromEntries([
611
+ page('app/views/pages/a.html.liquid'),
612
+ page('app/views/pages/b.html.liquid'),
613
+ ]),
614
+ );
615
+ const rt = new RouteTable(fs);
616
+ await rt.build(ROOT);
617
+
618
+ expect(rt.routeCount()).toBe(2);
619
+ rt.removeFile('file:///project/app/views/pages/a.html.liquid');
620
+ expect(rt.routeCount()).toBe(1);
621
+ });
622
+ });
623
+
624
+ describe('large route table', () => {
625
+ it('handles hundreds of routes and matches correctly', async () => {
626
+ const files: [string, string][] = [];
627
+ for (let i = 0; i < 200; i++) {
628
+ files.push(page(`app/views/pages/section-${i}/page.html.liquid`));
629
+ }
630
+ // Add some dynamic routes
631
+ for (let i = 0; i < 50; i++) {
632
+ files.push(
633
+ page(`app/views/pages/api-${i}.html.liquid`, `---\nslug: api/${i}/items/:id\n---\n`),
634
+ );
635
+ }
636
+ const fs = createMockFileSystem(Object.fromEntries(files));
637
+ const rt = new RouteTable(fs);
638
+ await rt.build(ROOT);
639
+
640
+ expect(rt.routeCount()).toBe(250);
641
+
642
+ // Static matches
643
+ expect(rt.hasMatch('/section-0/page')).toBe(true);
644
+ expect(rt.hasMatch('/section-199/page')).toBe(true);
645
+ expect(rt.hasMatch('/section-200/page')).toBe(false);
646
+
647
+ // Dynamic matches
648
+ expect(rt.hasMatch('/api/0/items/42')).toBe(true);
649
+ expect(rt.hasMatch('/api/49/items/abc')).toBe(true);
650
+ expect(rt.hasMatch('/api/50/items/1')).toBe(false);
651
+
652
+ // Precedence: static more specific than dynamic
653
+ const matches = rt.match('/api/0/items/42');
654
+ expect(matches.length).toBe(1);
655
+ expect(matches[0].slug).toBe('api/0/items/:id');
656
+ });
657
+
658
+ it('updateFile on large table does not corrupt other routes', async () => {
659
+ const files: [string, string][] = [];
660
+ for (let i = 0; i < 100; i++) {
661
+ files.push(page(`app/views/pages/page-${i}.html.liquid`));
662
+ }
663
+ const fs = createMockFileSystem(Object.fromEntries(files));
664
+ const rt = new RouteTable(fs);
665
+ await rt.build(ROOT);
666
+
667
+ expect(rt.routeCount()).toBe(100);
668
+
669
+ // Update one page to change its slug
670
+ rt.updateFile(
671
+ 'file:///project/app/views/pages/page-50.html.liquid',
672
+ '---\nslug: new-slug\n---\n',
673
+ );
674
+
675
+ expect(rt.routeCount()).toBe(100);
676
+ expect(rt.hasMatch('/page-50')).toBe(false);
677
+ expect(rt.hasMatch('/new-slug')).toBe(true);
678
+ // Other routes unaffected
679
+ expect(rt.hasMatch('/page-0')).toBe(true);
680
+ expect(rt.hasMatch('/page-99')).toBe(true);
681
+ });
682
+
683
+ it('build clears all routes before repopulating', async () => {
684
+ const files1: [string, string][] = [];
685
+ for (let i = 0; i < 50; i++) {
686
+ files1.push(page(`app/views/pages/old-${i}.html.liquid`));
687
+ }
688
+ const fs1 = createMockFileSystem(Object.fromEntries(files1));
689
+ const rt = new RouteTable(fs1);
690
+ await rt.build(ROOT);
691
+
692
+ expect(rt.routeCount()).toBe(50);
693
+
694
+ // Rebuild with different files
695
+ const files2: [string, string][] = [];
696
+ for (let i = 0; i < 30; i++) {
697
+ files2.push(page(`app/views/pages/new-${i}.html.liquid`));
698
+ }
699
+ const fs2 = createMockFileSystem(Object.fromEntries(files2));
700
+ const rt2 = new RouteTable(fs2);
701
+ await rt2.build(ROOT);
702
+
703
+ expect(rt2.routeCount()).toBe(30);
704
+ expect(rt2.hasMatch('/old-0')).toBe(false);
705
+ expect(rt2.hasMatch('/new-0')).toBe(true);
706
+ });
707
+ });
708
+ });