@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,437 @@
1
+ import yaml from 'js-yaml';
2
+ import { URI, Utils } from 'vscode-uri';
3
+ import { AbstractFileSystem, FileType } from '../AbstractFileSystem';
4
+ import { getAppPaths, getModulePaths, PlatformOSFileType } from '../path-utils';
5
+ import { RouteEntry, RouteSegment } from './types';
6
+ import { slugFromFilePath, formatFromFilePath, KNOWN_FORMATS } from './slugFromFilePath';
7
+ import { parseSlug, calculatePrecedence } from './parseSlug';
8
+
9
+ interface PageFrontmatter {
10
+ slug?: string;
11
+ method?: string;
12
+ format?: string;
13
+ }
14
+
15
+ function extractFrontmatter(source: string): PageFrontmatter | null {
16
+ const trimmed = source.trimStart();
17
+ if (!trimmed.startsWith('---')) return null;
18
+
19
+ const end = trimmed.indexOf('---', 3);
20
+ if (end === -1) return null;
21
+
22
+ const yamlBlock = trimmed.slice(3, end).trim();
23
+ if (yamlBlock.length === 0) return null;
24
+
25
+ try {
26
+ const result = yaml.load(yamlBlock);
27
+ if (typeof result !== 'object' || result === null) return null;
28
+ return result as PageFrontmatter;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Extract the path relative to the pages directory from a full URI.
36
+ * Handles both app-level and module page paths.
37
+ *
38
+ * App-level: matches `/(app|marketplace_builder)/(views/pages|pages)/`
39
+ * Module-level: matches `/(public|private)/(views/pages|pages)/`
40
+ * (marketplace_builder module pages always go through public/private subdirs,
41
+ * so the second pattern covers them.)
42
+ *
43
+ * Expects a `file://` URI with forward slashes (as produced by `vscode-uri`).
44
+ * On Windows, `vscode-uri` normalizes filesystem paths to forward-slash URIs
45
+ * (`file:///C:/...`), so the forward-slash regex patterns work cross-platform.
46
+ *
47
+ * Examples:
48
+ * file:///project/app/views/pages/about.html.liquid -> about.html.liquid
49
+ * file:///project/modules/admin/public/views/pages/dashboard.html.liquid -> dashboard.html.liquid
50
+ */
51
+ function extractRelativePagePath(uri: string): string | null {
52
+ const patterns = [
53
+ // App-level pages: app/views/pages/ or marketplace_builder/pages/
54
+ /\/(app|marketplace_builder)\/(views\/pages|pages)\//,
55
+ // Module pages: must be under modules/<name>/(public|private)/(views/pages|pages)/
56
+ /\/modules\/[^/]+\/(public|private)\/(views\/pages|pages)\//,
57
+ ];
58
+
59
+ for (const pattern of patterns) {
60
+ const match = uri.match(pattern);
61
+ if (match) {
62
+ const idx = uri.indexOf(match[0]) + match[0].length;
63
+ return uri.slice(idx);
64
+ }
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ function buildRouteEntry(uri: string, slug: string, method: string, format: string): RouteEntry {
71
+ const { requiredSegments, optionalGroups } = parseSlug(slug);
72
+ const precedence = calculatePrecedence(slug, format);
73
+
74
+ return {
75
+ slug,
76
+ method,
77
+ format,
78
+ uri,
79
+ requiredSegments,
80
+ optionalGroups,
81
+ precedence,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Try to match a list of URL segments against a list of route segments.
87
+ * Returns true if all URL segments are consumed and matched.
88
+ */
89
+ function matchSegments(
90
+ urlSegments: UrlSegment[],
91
+ routeSegments: RouteSegment[],
92
+ urlStart: number,
93
+ ): number | false {
94
+ let ui = urlStart;
95
+
96
+ for (let ri = 0; ri < routeSegments.length; ri++) {
97
+ const rs = routeSegments[ri];
98
+
99
+ if (rs.type === 'wildcard') {
100
+ // Wildcard consumes all remaining segments but requires at least one
101
+ if (ui >= urlSegments.length) return false;
102
+ return urlSegments.length;
103
+ }
104
+
105
+ if (ui >= urlSegments.length) {
106
+ // Ran out of URL segments but route still has more
107
+ return false;
108
+ }
109
+
110
+ const us = urlSegments[ui];
111
+
112
+ if (us.type === 'dynamic') {
113
+ // Liquid interpolation matches any route segment type
114
+ ui++;
115
+ continue;
116
+ }
117
+
118
+ // us.type === 'static'
119
+ if (rs.type === 'static') {
120
+ if (us.value !== rs.value) return false;
121
+ ui++;
122
+ } else if (rs.type === 'param') {
123
+ // :param matches any single static segment
124
+ ui++;
125
+ }
126
+ }
127
+
128
+ return ui;
129
+ }
130
+
131
+ type UrlSegment = { type: 'static'; value: string } | { type: 'dynamic' };
132
+
133
+ interface ParsedUrlPattern {
134
+ segments: UrlSegment[];
135
+ /** Format extracted from the last segment's extension (e.g., 'json' from 'my-page.json') */
136
+ format: string | null;
137
+ }
138
+
139
+ /**
140
+ * Parse a URL pattern into segments and an optional format suffix.
141
+ *
142
+ * Following Rails/platformOS convention, a known format extension on the last
143
+ * segment (e.g., `/api/data.json`) is stripped and returned separately so that
144
+ * matching can filter by format.
145
+ */
146
+ function parseUrlPattern(pattern: string): ParsedUrlPattern {
147
+ // Strip leading and trailing slashes
148
+ let path = pattern.startsWith('/') ? pattern.slice(1) : pattern;
149
+ if (path.endsWith('/')) path = path.slice(0, -1);
150
+ if (path === '') return { segments: [], format: null };
151
+
152
+ const rawSegments = path.split('/');
153
+ let format: string | null = null;
154
+
155
+ // Check if the last segment has a known format extension (e.g., my-page.json)
156
+ const lastIdx = rawSegments.length - 1;
157
+ const lastSeg = rawSegments[lastIdx];
158
+ if (lastSeg !== ':_liquid_') {
159
+ const dotIdx = lastSeg.lastIndexOf('.');
160
+ if (dotIdx > 0) {
161
+ const ext = lastSeg.slice(dotIdx + 1);
162
+ if (KNOWN_FORMATS.has(ext)) {
163
+ format = ext;
164
+ rawSegments[lastIdx] = lastSeg.slice(0, dotIdx);
165
+ }
166
+ }
167
+ }
168
+
169
+ const segments = rawSegments.map((seg): UrlSegment => {
170
+ if (seg === ':_liquid_') {
171
+ return { type: 'dynamic' };
172
+ }
173
+ return { type: 'static', value: seg };
174
+ });
175
+
176
+ return { segments, format };
177
+ }
178
+
179
+ export class RouteTable {
180
+ private routes: Map<string, RouteEntry[]> = new Map(); // uri -> entries (a page can register multiple entries for index aliasing)
181
+ private _built = false;
182
+
183
+ constructor(private fs: AbstractFileSystem) {}
184
+
185
+ /** Returns true if build() has completed at least once. */
186
+ isBuilt(): boolean {
187
+ return this._built;
188
+ }
189
+
190
+ async build(rootUri: URI): Promise<void> {
191
+ this.routes.clear();
192
+
193
+ const pageUris = await this.discoverPageFiles(rootUri);
194
+ for (const uri of pageUris) {
195
+ try {
196
+ const content = await this.fs.readFile(uri);
197
+ this.addPageFromContent(uri, content);
198
+ } catch {
199
+ // Skip files we can't read
200
+ }
201
+ }
202
+
203
+ this._built = true;
204
+ }
205
+
206
+ updateFile(uri: string, content: string): void {
207
+ this.removeFile(uri);
208
+ this.addPageFromContent(uri, content);
209
+ }
210
+
211
+ removeFile(uri: string): void {
212
+ this.routes.delete(uri);
213
+ }
214
+
215
+ /**
216
+ * Find all routes matching a URL pattern and optional method.
217
+ * The pattern can contain `:_liquid_` segments for Liquid interpolations.
218
+ * Results are sorted by precedence (highest priority first = lowest number).
219
+ *
220
+ * A known format extension on the last segment (e.g., `/api/data.json`)
221
+ * is stripped and used to filter routes by format. When no format extension
222
+ * is present (e.g., `/about`), only `html` routes match — following the
223
+ * platformOS/Rails convention where HTML is the default format and non-HTML
224
+ * formats require an explicit extension or Accept header.
225
+ */
226
+ match(urlPattern: string, method?: string): RouteEntry[] {
227
+ const { segments: urlSegments, format } = parseUrlPattern(urlPattern);
228
+ const effectiveFormat = format ?? 'html';
229
+
230
+ const results: RouteEntry[] = [];
231
+
232
+ for (const entries of this.routes.values()) {
233
+ for (const entry of entries) {
234
+ if (method && entry.method !== method) continue;
235
+ if (entry.format !== effectiveFormat) continue;
236
+ if (this.matchEntry(urlSegments, entry)) {
237
+ results.push(entry);
238
+ }
239
+ }
240
+ }
241
+
242
+ results.sort((a, b) => a.precedence - b.precedence);
243
+ return results;
244
+ }
245
+
246
+ hasMatch(urlPattern: string, method?: string): boolean {
247
+ const { segments: urlSegments, format } = parseUrlPattern(urlPattern);
248
+ const effectiveFormat = format ?? 'html';
249
+
250
+ for (const entries of this.routes.values()) {
251
+ for (const entry of entries) {
252
+ if (method && entry.method !== method) continue;
253
+ if (entry.format !== effectiveFormat) continue;
254
+ if (this.matchEntry(urlSegments, entry)) return true;
255
+ }
256
+ }
257
+ return false;
258
+ }
259
+
260
+ /** Returns the total number of route entries (including index aliases). */
261
+ routeCount(): number {
262
+ let count = 0;
263
+ for (const entries of this.routes.values()) {
264
+ count += entries.length;
265
+ }
266
+ return count;
267
+ }
268
+
269
+ allRoutes(): RouteEntry[] {
270
+ const all: RouteEntry[] = [];
271
+ for (const entries of this.routes.values()) {
272
+ all.push(...entries);
273
+ }
274
+ return all.sort((a, b) => a.precedence - b.precedence);
275
+ }
276
+
277
+ private matchEntry(urlSegments: UrlSegment[], entry: RouteEntry): boolean {
278
+ // Try matching with required segments only
279
+ const afterRequired = matchSegments(urlSegments, entry.requiredSegments, 0);
280
+ if (afterRequired !== false && afterRequired === urlSegments.length) {
281
+ return true;
282
+ }
283
+
284
+ // Try matching with required + optional groups progressively
285
+ if (afterRequired !== false && entry.optionalGroups.length > 0) {
286
+ return this.matchOptionalGroups(urlSegments, entry.optionalGroups, afterRequired);
287
+ }
288
+
289
+ return false;
290
+ }
291
+
292
+ /**
293
+ * Try optional groups left-to-right greedily — matching Rails' ActionDispatch::Journey
294
+ * semantics. Each group is tried in order; if it matches, its segments are consumed
295
+ * and the next group is attempted. If it doesn't match, it's skipped (optional).
296
+ *
297
+ * This greedy approach is correct because the platformOS backend converts slugs like
298
+ * `search(/:country)(/:city)` into Journey path strings and Journey matches
299
+ * left-to-right without backtracking.
300
+ */
301
+ private matchOptionalGroups(
302
+ urlSegments: UrlSegment[],
303
+ groups: RouteSegment[][],
304
+ startIdx: number,
305
+ ): boolean {
306
+ let current = startIdx;
307
+
308
+ for (const group of groups) {
309
+ if (current === urlSegments.length) {
310
+ // Remaining optional groups can be omitted
311
+ return true;
312
+ }
313
+
314
+ const after = matchSegments(urlSegments, group, current);
315
+ if (after !== false) {
316
+ current = after;
317
+ if (current === urlSegments.length) return true;
318
+ }
319
+ }
320
+
321
+ return current === urlSegments.length;
322
+ }
323
+
324
+ private addPageFromContent(uri: string, content: string): void {
325
+ const relativePath = extractRelativePagePath(uri);
326
+ if (!relativePath) return;
327
+
328
+ const frontmatter = extractFrontmatter(content);
329
+ const fileFormat = formatFromFilePath(relativePath);
330
+
331
+ const method = (frontmatter?.method || 'get').toLowerCase();
332
+ const format = frontmatter?.format || fileFormat;
333
+
334
+ let slug: string;
335
+ if (frontmatter?.slug !== undefined && frontmatter.slug !== null) {
336
+ slug = String(frontmatter.slug);
337
+ } else {
338
+ slug = slugFromFilePath(relativePath, format);
339
+ }
340
+
341
+ const entries: RouteEntry[] = [];
342
+
343
+ // Primary route
344
+ entries.push(buildRouteEntry(uri, slug, method, format));
345
+
346
+ // Index aliasing: if slug was derived from stripping /index,
347
+ // also register the /index variant
348
+ if (!frontmatter?.slug) {
349
+ const slugWithIndex = slug + '/index';
350
+ // Only add alias if the raw derivation actually stripped /index
351
+ // (i.e., the file is named index.{format}.liquid or index.liquid)
352
+ const baseName = relativePath.split('/').pop() || '';
353
+ const isIndexFile =
354
+ baseName === 'index.liquid' ||
355
+ baseName === `index.${format}.liquid` ||
356
+ baseName === `index.${format}`;
357
+ if (isIndexFile && slug !== '/') {
358
+ entries.push(buildRouteEntry(uri, slugWithIndex, method, format));
359
+ }
360
+ }
361
+
362
+ this.routes.set(uri, entries);
363
+ }
364
+
365
+ private async discoverPageFiles(rootUri: URI): Promise<string[]> {
366
+ const uris: string[] = [];
367
+
368
+ // App-level pages
369
+ const appPaths = getAppPaths(PlatformOSFileType.Page);
370
+ for (const basePath of appPaths) {
371
+ const baseUri = Utils.joinPath(rootUri, basePath);
372
+ await this.walkDirectory(baseUri.toString(), uris);
373
+ }
374
+
375
+ // Module pages — discover module names first, then walk all in parallel
376
+ const moduleNames = await this.discoverModuleNames(rootUri);
377
+ const moduleWalks: Promise<void>[] = [];
378
+ for (const moduleName of moduleNames) {
379
+ const modulePaths = getModulePaths(PlatformOSFileType.Page, moduleName);
380
+ for (const basePath of modulePaths) {
381
+ const baseUri = Utils.joinPath(rootUri, basePath);
382
+ moduleWalks.push(this.walkDirectory(baseUri.toString(), uris));
383
+ }
384
+ }
385
+ await Promise.all(moduleWalks);
386
+
387
+ return uris;
388
+ }
389
+
390
+ private async discoverModuleNames(rootUri: URI): Promise<string[]> {
391
+ const names = new Set<string>();
392
+
393
+ for (const prefix of ['app/modules', 'modules']) {
394
+ const dirUri = Utils.joinPath(rootUri, prefix).toString();
395
+ try {
396
+ const entries = await this.fs.readDirectory(dirUri);
397
+ for (const [name, type] of entries) {
398
+ if (type === FileType.Directory) {
399
+ // Extract the module name from the entry.
400
+ // readDirectory may return a full URI (file:///…/modules/admin) or
401
+ // a bare name ("admin"). Use URI.parse().path to normalise, then
402
+ // take the last non-empty segment.
403
+ const parsed = URI.parse(name).path || name;
404
+ const segments = parsed.split('/').filter((s) => s.length > 0);
405
+ const moduleName = segments[segments.length - 1];
406
+ if (moduleName) names.add(moduleName);
407
+ }
408
+ }
409
+ } catch {
410
+ // Directory doesn't exist
411
+ }
412
+ }
413
+
414
+ return Array.from(names);
415
+ }
416
+
417
+ private async walkDirectory(dirUri: string, results: string[]): Promise<void> {
418
+ let entries: [string, FileType][];
419
+ try {
420
+ entries = await this.fs.readDirectory(dirUri);
421
+ } catch {
422
+ return;
423
+ }
424
+
425
+ const subdirs: Promise<void>[] = [];
426
+ for (const [name, type] of entries) {
427
+ if (type === FileType.Directory) {
428
+ subdirs.push(this.walkDirectory(name, results));
429
+ } else if (type === FileType.File && name.endsWith('.liquid')) {
430
+ // All platformOS page files use the .liquid extension (e.g. page.html.liquid,
431
+ // page.json.liquid). Non-.liquid pages are not supported by the platform.
432
+ results.push(name);
433
+ }
434
+ }
435
+ await Promise.all(subdirs);
436
+ }
437
+ }
@@ -0,0 +1,5 @@
1
+ export { RouteTable } from './RouteTable';
2
+ export { slugFromFilePath, formatFromFilePath, KNOWN_FORMATS } from './slugFromFilePath';
3
+ export { parseSlug, calculatePrecedence } from './parseSlug';
4
+ export type { RouteEntry, RouteSegment } from './types';
5
+ export type { ParsedSlug } from './parseSlug';
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseSlug, calculatePrecedence } from './parseSlug';
3
+
4
+ describe('parseSlug', () => {
5
+ describe('required segments only', () => {
6
+ it('parses root as empty', () => {
7
+ expect(parseSlug('/')).toEqual({ requiredSegments: [], optionalGroups: [] });
8
+ });
9
+
10
+ it('parses empty string as empty', () => {
11
+ expect(parseSlug('')).toEqual({ requiredSegments: [], optionalGroups: [] });
12
+ });
13
+
14
+ it('parses single static segment', () => {
15
+ expect(parseSlug('about')).toEqual({
16
+ requiredSegments: [{ type: 'static', value: 'about' }],
17
+ optionalGroups: [],
18
+ });
19
+ });
20
+
21
+ it('parses multiple static segments', () => {
22
+ expect(parseSlug('users/show')).toEqual({
23
+ requiredSegments: [
24
+ { type: 'static', value: 'users' },
25
+ { type: 'static', value: 'show' },
26
+ ],
27
+ optionalGroups: [],
28
+ });
29
+ });
30
+
31
+ it('parses static + param', () => {
32
+ expect(parseSlug('users/:id')).toEqual({
33
+ requiredSegments: [
34
+ { type: 'static', value: 'users' },
35
+ { type: 'param', name: 'id' },
36
+ ],
37
+ optionalGroups: [],
38
+ });
39
+ });
40
+
41
+ it('parses static + param + static', () => {
42
+ expect(parseSlug('users/:id/edit')).toEqual({
43
+ requiredSegments: [
44
+ { type: 'static', value: 'users' },
45
+ { type: 'param', name: 'id' },
46
+ { type: 'static', value: 'edit' },
47
+ ],
48
+ optionalGroups: [],
49
+ });
50
+ });
51
+
52
+ it('parses static + wildcard', () => {
53
+ expect(parseSlug('api/*path')).toEqual({
54
+ requiredSegments: [
55
+ { type: 'static', value: 'api' },
56
+ { type: 'wildcard', name: 'path' },
57
+ ],
58
+ optionalGroups: [],
59
+ });
60
+ });
61
+
62
+ it('parses multiple params', () => {
63
+ expect(parseSlug('blog/:year/:month/:slug')).toEqual({
64
+ requiredSegments: [
65
+ { type: 'static', value: 'blog' },
66
+ { type: 'param', name: 'year' },
67
+ { type: 'param', name: 'month' },
68
+ { type: 'param', name: 'slug' },
69
+ ],
70
+ optionalGroups: [],
71
+ });
72
+ });
73
+ });
74
+
75
+ describe('with optional segments', () => {
76
+ it('parses single optional param', () => {
77
+ expect(parseSlug('users(/:id)')).toEqual({
78
+ requiredSegments: [{ type: 'static', value: 'users' }],
79
+ optionalGroups: [[{ type: 'param', name: 'id' }]],
80
+ });
81
+ });
82
+
83
+ it('parses multiple optional groups', () => {
84
+ expect(parseSlug('search(/:country)(/:city)')).toEqual({
85
+ requiredSegments: [{ type: 'static', value: 'search' }],
86
+ optionalGroups: [[{ type: 'param', name: 'country' }], [{ type: 'param', name: 'city' }]],
87
+ });
88
+ });
89
+
90
+ it('parses optional group with static + wildcard', () => {
91
+ expect(parseSlug('users(/section/*)')).toEqual({
92
+ requiredSegments: [{ type: 'static', value: 'users' }],
93
+ optionalGroups: [
94
+ [
95
+ { type: 'static', value: 'section' },
96
+ { type: 'wildcard', name: '*' },
97
+ ],
98
+ ],
99
+ });
100
+ });
101
+
102
+ it('parses required + optional param', () => {
103
+ expect(parseSlug('users/:id(/:action)')).toEqual({
104
+ requiredSegments: [
105
+ { type: 'static', value: 'users' },
106
+ { type: 'param', name: 'id' },
107
+ ],
108
+ optionalGroups: [[{ type: 'param', name: 'action' }]],
109
+ });
110
+ });
111
+
112
+ it('parses optional version + wildcard', () => {
113
+ expect(parseSlug('api(/:version)(/*path)')).toEqual({
114
+ requiredSegments: [{ type: 'static', value: 'api' }],
115
+ optionalGroups: [
116
+ [{ type: 'param', name: 'version' }],
117
+ [{ type: 'wildcard', name: 'path' }],
118
+ ],
119
+ });
120
+ });
121
+ });
122
+ });
123
+
124
+ describe('calculatePrecedence', () => {
125
+ it('scores all-static slug highest', () => {
126
+ // users/section/1: 300 * -100 + 1 (html) = -29999
127
+ expect(calculatePrecedence('users/section/1', 'html')).toBe(-29999);
128
+ });
129
+
130
+ it('scores two static + one param lower', () => {
131
+ // users/section/:id: 210 * -100 + 1 (html) = -20999
132
+ expect(calculatePrecedence('users/section/:id', 'html')).toBe(-20999);
133
+ });
134
+
135
+ it('scores required + optional param', () => {
136
+ // users/:id(/:action): 111 * -100 + 1 (html) = -11099
137
+ expect(calculatePrecedence('users/:id(/:action)', 'html')).toBe(-11099);
138
+ });
139
+
140
+ it('scores single static + single param', () => {
141
+ // users/:id: 110 * -100 + 1 (html) = -10999
142
+ expect(calculatePrecedence('users/:id', 'html')).toBe(-10999);
143
+ });
144
+
145
+ it('scores single static + optional param', () => {
146
+ // users(/:id): 101 * -100 + 1 (html) = -10099
147
+ expect(calculatePrecedence('users(/:id)', 'html')).toBe(-10099);
148
+ });
149
+
150
+ it('subtracts 1 for format embedded in slug', () => {
151
+ // users/data.json: 200 * -100 - 1 (format in slug) = -20001
152
+ expect(calculatePrecedence('users/data.json', 'json')).toBe(-20001);
153
+ });
154
+
155
+ it('scores non-html format without +1', () => {
156
+ // users/data: 200 * -100 = -20000
157
+ expect(calculatePrecedence('users/data', 'json')).toBe(-20000);
158
+ });
159
+
160
+ it('scores root with adjustments', () => {
161
+ // /: 1 * -100 + 1 (root) + 1 (html) = -98
162
+ expect(calculatePrecedence('/', 'html')).toBe(-98);
163
+ });
164
+
165
+ it('more specific routes have lower (better) precedence', () => {
166
+ const allStatic = calculatePrecedence('users/section/1', 'html');
167
+ const oneParam = calculatePrecedence('users/section/:id', 'html');
168
+ const twoParams = calculatePrecedence('users/:id', 'html');
169
+ const optParam = calculatePrecedence('users(/:id)', 'html');
170
+ const root = calculatePrecedence('/', 'html');
171
+
172
+ expect(allStatic).toBeLessThan(oneParam);
173
+ expect(oneParam).toBeLessThan(twoParams);
174
+ expect(twoParams).toBeLessThan(optParam);
175
+ expect(optParam).toBeLessThan(root);
176
+ });
177
+ });