@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
|
@@ -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
|
+
});
|