@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.
- package/CHANGELOG.md +50 -0
- package/dist/documents-locator/DocumentsLocator.d.ts +46 -2
- package/dist/documents-locator/DocumentsLocator.js +167 -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 +367 -5
- package/src/documents-locator/DocumentsLocator.ts +197 -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,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
|
+
});
|