@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,136 @@
|
|
|
1
|
+
import { RouteSegment } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a single segment string (without slashes) into a RouteSegment.
|
|
5
|
+
* - Starts with `:` -> param
|
|
6
|
+
* - Starts with `*` -> wildcard
|
|
7
|
+
* - Otherwise -> static
|
|
8
|
+
*/
|
|
9
|
+
function parseSegment(raw: string): RouteSegment {
|
|
10
|
+
// Strip trailing `)` if present (from optional group splitting)
|
|
11
|
+
const cleaned = raw.endsWith(')') ? raw.slice(0, -1) : raw;
|
|
12
|
+
|
|
13
|
+
if (cleaned.startsWith(':')) {
|
|
14
|
+
return { type: 'param', name: cleaned.slice(1) };
|
|
15
|
+
}
|
|
16
|
+
if (cleaned.startsWith('*')) {
|
|
17
|
+
return { type: 'wildcard', name: cleaned.slice(1) || '*' };
|
|
18
|
+
}
|
|
19
|
+
return { type: 'static', value: cleaned };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseSegments(part: string): RouteSegment[] {
|
|
23
|
+
return part
|
|
24
|
+
.split('/')
|
|
25
|
+
.filter((s) => s.length > 0)
|
|
26
|
+
.map(parseSegment);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ParsedSlug {
|
|
30
|
+
requiredSegments: RouteSegment[];
|
|
31
|
+
optionalGroups: RouteSegment[][];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a slug string into required segments and optional groups.
|
|
36
|
+
*
|
|
37
|
+
* Slug syntax:
|
|
38
|
+
* - `about` -> required: [static('about')]
|
|
39
|
+
* - `users/:id` -> required: [static('users'), param('id')]
|
|
40
|
+
* - `users(/:id)` -> required: [static('users')], optional: [[param('id')]]
|
|
41
|
+
* - `search(/:country)(/:city)` -> required: [static('search')], optional: [[param('country')], [param('city')]]
|
|
42
|
+
* - `users(/section/*)` -> required: [static('users')], optional: [[static('section'), wildcard('*')]]
|
|
43
|
+
* - `/` (root) -> required: [], optional: []
|
|
44
|
+
*/
|
|
45
|
+
export function parseSlug(slug: string): ParsedSlug {
|
|
46
|
+
// Root is special
|
|
47
|
+
if (slug === '/' || slug === '') {
|
|
48
|
+
return { requiredSegments: [], optionalGroups: [] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Split into required part and optional groups by finding `(`
|
|
52
|
+
const firstParen = slug.indexOf('(');
|
|
53
|
+
|
|
54
|
+
if (firstParen === -1) {
|
|
55
|
+
// No optional groups
|
|
56
|
+
return {
|
|
57
|
+
requiredSegments: parseSegments(slug),
|
|
58
|
+
optionalGroups: [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Required part is everything before the first `(`
|
|
63
|
+
const requiredPart = slug.slice(0, firstParen);
|
|
64
|
+
const optionalPart = slug.slice(firstParen);
|
|
65
|
+
|
|
66
|
+
const requiredSegments = requiredPart.length > 0 ? parseSegments(requiredPart) : [];
|
|
67
|
+
|
|
68
|
+
// Parse optional groups: split on `(` to get each group, strip `)` and leading `/`
|
|
69
|
+
const optionalGroups: RouteSegment[][] = [];
|
|
70
|
+
const groupRegex = /\(([^)]+)\)/g;
|
|
71
|
+
let match: RegExpExecArray | null;
|
|
72
|
+
while ((match = groupRegex.exec(optionalPart)) !== null) {
|
|
73
|
+
const groupContent = match[1];
|
|
74
|
+
// Strip leading `/` if present
|
|
75
|
+
const normalized = groupContent.startsWith('/') ? groupContent.slice(1) : groupContent;
|
|
76
|
+
if (normalized.length > 0) {
|
|
77
|
+
optionalGroups.push(parseSegments(normalized));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { requiredSegments, optionalGroups };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Calculate route precedence following the backend's scoring algorithm.
|
|
86
|
+
* Returns a negative number; more negative = higher priority.
|
|
87
|
+
* When sorting ascending, highest-priority routes come first.
|
|
88
|
+
*
|
|
89
|
+
* Segment weights:
|
|
90
|
+
* - Static/hardcoded: 100 points
|
|
91
|
+
* - Required parameter (:param): 10 points
|
|
92
|
+
* - Optional parameter (inside parens): 1 point
|
|
93
|
+
*
|
|
94
|
+
* Base: weighted_size * -100
|
|
95
|
+
* Adjustments: slug='/' +1, format='html' +1, format-in-last-component -1
|
|
96
|
+
*/
|
|
97
|
+
export function calculatePrecedence(slug: string, format: string): number {
|
|
98
|
+
if (slug === '/' || slug === '') {
|
|
99
|
+
// Special case for root: weighted_size=0 -> use 1
|
|
100
|
+
let precedence = 1 * -100;
|
|
101
|
+
precedence += 1; // root "/" adjustment
|
|
102
|
+
if (format === 'html') precedence += 1;
|
|
103
|
+
return precedence;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Split on `(?/` boundary to get all segments with their weight context
|
|
107
|
+
// The Ruby code: slug.split(%r{\(?/}).inject(0) { ... }
|
|
108
|
+
const parts = slug.split(/\(?\//);
|
|
109
|
+
let weightedSize = 0;
|
|
110
|
+
for (const part of parts) {
|
|
111
|
+
if (part.length === 0) continue;
|
|
112
|
+
if (part.startsWith(':')) {
|
|
113
|
+
weightedSize += part.endsWith(')') ? 1 : 10;
|
|
114
|
+
} else if (part.startsWith('*')) {
|
|
115
|
+
weightedSize += part.endsWith(')') ? 1 : 10;
|
|
116
|
+
} else {
|
|
117
|
+
weightedSize += 100;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let precedence = (weightedSize === 0 ? 1 : weightedSize) * -100;
|
|
122
|
+
|
|
123
|
+
if (format === 'html') precedence += 1;
|
|
124
|
+
|
|
125
|
+
// Check if format is embedded in last slug component (e.g. `data.json`)
|
|
126
|
+
const lastSlash = slug.lastIndexOf('/');
|
|
127
|
+
const lastComponent = lastSlash >= 0 ? slug.slice(lastSlash + 1) : slug;
|
|
128
|
+
// Strip optional group parens for checking
|
|
129
|
+
const cleanLast = lastComponent.replace(/[()]/g, '');
|
|
130
|
+
const dotIdx = cleanLast.lastIndexOf('.');
|
|
131
|
+
if (dotIdx > 0 && dotIdx < cleanLast.length - 1) {
|
|
132
|
+
precedence -= 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return precedence;
|
|
136
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { slugFromFilePath, formatFromFilePath } from './slugFromFilePath';
|
|
3
|
+
|
|
4
|
+
describe('slugFromFilePath', () => {
|
|
5
|
+
it('derives / from index.html.liquid', () => {
|
|
6
|
+
expect(slugFromFilePath('index.html.liquid', 'html')).toBe('/');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('derives / from index.liquid', () => {
|
|
10
|
+
expect(slugFromFilePath('index.liquid', 'html')).toBe('/');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('derives / from home.html.liquid (deprecated alias)', () => {
|
|
14
|
+
expect(slugFromFilePath('home.html.liquid', 'html')).toBe('/');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('derives / from home.liquid (deprecated alias)', () => {
|
|
18
|
+
expect(slugFromFilePath('home.liquid', 'html')).toBe('/');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('derives about from about.html.liquid', () => {
|
|
22
|
+
expect(slugFromFilePath('about.html.liquid', 'html')).toBe('about');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('derives about from about.liquid', () => {
|
|
26
|
+
expect(slugFromFilePath('about.liquid', 'html')).toBe('about');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('derives users/show from users/show.html.liquid', () => {
|
|
30
|
+
expect(slugFromFilePath('users/show.html.liquid', 'html')).toBe('users/show');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('derives users from users/index.html.liquid', () => {
|
|
34
|
+
expect(slugFromFilePath('users/index.html.liquid', 'html')).toBe('users');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('derives users from users/index.liquid', () => {
|
|
38
|
+
expect(slugFromFilePath('users/index.liquid', 'html')).toBe('users');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('derives api/v2/data from api/v2/data.json.liquid', () => {
|
|
42
|
+
expect(slugFromFilePath('api/v2/data.json.liquid', 'json')).toBe('api/v2/data');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('derives api/v2/data from api/v2/data.liquid', () => {
|
|
46
|
+
expect(slugFromFilePath('api/v2/data.liquid', 'html')).toBe('api/v2/data');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('derives deeply/nested/path/page from deeply/nested/path/page.html.liquid', () => {
|
|
50
|
+
expect(slugFromFilePath('deeply/nested/path/page.html.liquid', 'html')).toBe(
|
|
51
|
+
'deeply/nested/path/page',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('derives test/abc from test/abc/index.html.liquid', () => {
|
|
56
|
+
expect(slugFromFilePath('test/abc/index.html.liquid', 'html')).toBe('test/abc');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('formatFromFilePath', () => {
|
|
61
|
+
it('returns html for plain .liquid files', () => {
|
|
62
|
+
expect(formatFromFilePath('about.liquid')).toBe('html');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns html for .html.liquid files', () => {
|
|
66
|
+
expect(formatFromFilePath('about.html.liquid')).toBe('html');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns json for .json.liquid files', () => {
|
|
70
|
+
expect(formatFromFilePath('api/data.json.liquid')).toBe('json');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns xml for .xml.liquid files', () => {
|
|
74
|
+
expect(formatFromFilePath('feed.xml.liquid')).toBe('xml');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns csv for .csv.liquid files', () => {
|
|
78
|
+
expect(formatFromFilePath('export.csv.liquid')).toBe('csv');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns html for index.liquid', () => {
|
|
82
|
+
expect(formatFromFilePath('index.liquid')).toBe('html');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a URL slug from a page file path relative to the pages directory.
|
|
3
|
+
*
|
|
4
|
+
* Rules (ported from Ruby Page.default_routing_options):
|
|
5
|
+
* 1. Strip file extensions: first `.liquid`, then `.{format}` (e.g. `.html`)
|
|
6
|
+
* 2. If result is `index` -> slug = `/`
|
|
7
|
+
* 3. If result ends with `/index` -> strip it (e.g. `test/index` -> `test`)
|
|
8
|
+
* 4. If result is `home` -> slug = `/` (deprecated alias for root)
|
|
9
|
+
* 5. Otherwise slug = the remaining path
|
|
10
|
+
*/
|
|
11
|
+
export function slugFromFilePath(relativeToPages: string, format: string = 'html'): string {
|
|
12
|
+
// Strip .liquid extension first
|
|
13
|
+
let slug = relativeToPages;
|
|
14
|
+
if (slug.endsWith('.liquid')) {
|
|
15
|
+
slug = slug.slice(0, -'.liquid'.length);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Strip .{format} extension (e.g. .html, .json)
|
|
19
|
+
if (slug.endsWith(`.${format}`)) {
|
|
20
|
+
slug = slug.slice(0, -`.${format}`.length);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// index -> root
|
|
24
|
+
if (slug === 'index') {
|
|
25
|
+
return '/';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// path/to/index -> path/to
|
|
29
|
+
if (slug.endsWith('/index')) {
|
|
30
|
+
return slug.slice(0, -'/index'.length);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// home -> root (deprecated alias)
|
|
34
|
+
if (slug === 'home') {
|
|
35
|
+
return '/';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return slug;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Known response formats supported by the platformOS engine.
|
|
43
|
+
* Derived from the platform's FORMAT_ENUM.
|
|
44
|
+
*/
|
|
45
|
+
export const KNOWN_FORMATS = new Set([
|
|
46
|
+
'html',
|
|
47
|
+
'json',
|
|
48
|
+
'xml',
|
|
49
|
+
'rss',
|
|
50
|
+
'csv',
|
|
51
|
+
'pdf',
|
|
52
|
+
'css',
|
|
53
|
+
'text',
|
|
54
|
+
'js',
|
|
55
|
+
'txt',
|
|
56
|
+
'svg',
|
|
57
|
+
'ics',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extracts the format from a page filename.
|
|
62
|
+
* Returns the format if the file has a double extension like `.json.liquid` or `.xml.liquid`
|
|
63
|
+
* and the extension is a known platformOS format.
|
|
64
|
+
* Returns 'html' as the default if only `.liquid` is present or the extension is unknown.
|
|
65
|
+
*/
|
|
66
|
+
export function formatFromFilePath(relativeToPages: string): string {
|
|
67
|
+
// Strip .liquid first
|
|
68
|
+
let name = relativeToPages;
|
|
69
|
+
if (name.endsWith('.liquid')) {
|
|
70
|
+
name = name.slice(0, -'.liquid'.length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check for a remaining extension
|
|
74
|
+
const lastDot = name.lastIndexOf('.');
|
|
75
|
+
const lastSlash = name.lastIndexOf('/');
|
|
76
|
+
if (lastDot > lastSlash && lastDot > 0) {
|
|
77
|
+
const ext = name.slice(lastDot + 1);
|
|
78
|
+
if (KNOWN_FORMATS.has(ext)) {
|
|
79
|
+
return ext;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return 'html';
|
|
84
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type RouteSegment =
|
|
2
|
+
| { type: 'static'; value: string }
|
|
3
|
+
| { type: 'param'; name: string }
|
|
4
|
+
| { type: 'wildcard'; name: string };
|
|
5
|
+
|
|
6
|
+
export interface RouteEntry {
|
|
7
|
+
/** The resolved slug, e.g. "users/:id" or "/" */
|
|
8
|
+
slug: string;
|
|
9
|
+
/** HTTP method: get, post, put, patch, delete. Default: "get" */
|
|
10
|
+
method: string;
|
|
11
|
+
/** Response format: html, json, xml, etc. Default: "html" */
|
|
12
|
+
format: string;
|
|
13
|
+
/** URI of the source .liquid file */
|
|
14
|
+
uri: string;
|
|
15
|
+
/** Parsed required segments */
|
|
16
|
+
requiredSegments: RouteSegment[];
|
|
17
|
+
/** Parsed optional segment groups (each group is a sequence of segments) */
|
|
18
|
+
optionalGroups: RouteSegment[][];
|
|
19
|
+
/**
|
|
20
|
+
* Computed precedence score. More negative = higher priority.
|
|
21
|
+
* Scores are negative (e.g. -10000 for a route with many static segments).
|
|
22
|
+
* When sorting, ascending order puts highest-priority routes first.
|
|
23
|
+
*/
|
|
24
|
+
precedence: number;
|
|
25
|
+
}
|
|
@@ -52,7 +52,7 @@ export class TranslationProvider {
|
|
|
52
52
|
return key ? { isModule: true, moduleName, key } : { isModule: false, key: translationKey };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
static getSearchPaths(moduleName?: string): string[] {
|
|
56
56
|
if (!moduleName) {
|
|
57
57
|
return ['app/translations'];
|
|
58
58
|
}
|
|
@@ -76,7 +76,9 @@ export class TranslationProvider {
|
|
|
76
76
|
return [undefined, undefined];
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const searchPaths =
|
|
79
|
+
const searchPaths = TranslationProvider.getSearchPaths(
|
|
80
|
+
parsed.isModule ? parsed.moduleName : undefined,
|
|
81
|
+
);
|
|
80
82
|
|
|
81
83
|
for (const basePath of searchPaths) {
|
|
82
84
|
// Strategy A: single locale file ({basePath}/{locale}.yml)
|