@platformos/platformos-common 0.0.10 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CLAUDE.md +70 -0
  3. package/dist/documents-locator/DocumentsLocator.d.ts +35 -2
  4. package/dist/documents-locator/DocumentsLocator.js +126 -1
  5. package/dist/documents-locator/DocumentsLocator.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/path-utils.d.ts +59 -15
  10. package/dist/path-utils.js +100 -28
  11. package/dist/path-utils.js.map +1 -1
  12. package/dist/route-table/RouteTable.d.ts +45 -0
  13. package/dist/route-table/RouteTable.js +384 -0
  14. package/dist/route-table/RouteTable.js.map +1 -0
  15. package/dist/route-table/index.d.ts +5 -0
  16. package/dist/route-table/index.js +13 -0
  17. package/dist/route-table/index.js.map +1 -0
  18. package/dist/route-table/parseSlug.d.ts +31 -0
  19. package/dist/route-table/parseSlug.js +124 -0
  20. package/dist/route-table/parseSlug.js.map +1 -0
  21. package/dist/route-table/slugFromFilePath.d.ts +23 -0
  22. package/dist/route-table/slugFromFilePath.js +81 -0
  23. package/dist/route-table/slugFromFilePath.js.map +1 -0
  24. package/dist/route-table/types.d.ts +30 -0
  25. package/dist/route-table/types.js +3 -0
  26. package/dist/route-table/types.js.map +1 -0
  27. package/dist/translation-provider/TranslationProvider.d.ts +1 -1
  28. package/dist/translation-provider/TranslationProvider.js +2 -2
  29. package/dist/translation-provider/TranslationProvider.js.map +1 -1
  30. package/dist/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +1 -1
  32. package/src/documents-locator/DocumentsLocator.spec.ts +299 -5
  33. package/src/documents-locator/DocumentsLocator.ts +144 -1
  34. package/src/index.ts +1 -0
  35. package/src/path-utils.spec.ts +320 -33
  36. package/src/path-utils.ts +103 -28
  37. package/src/route-table/RouteTable.spec.ts +708 -0
  38. package/src/route-table/RouteTable.ts +437 -0
  39. package/src/route-table/index.ts +5 -0
  40. package/src/route-table/parseSlug.spec.ts +177 -0
  41. package/src/route-table/parseSlug.ts +136 -0
  42. package/src/route-table/slugFromFilePath.spec.ts +84 -0
  43. package/src/route-table/slugFromFilePath.ts +84 -0
  44. package/src/route-table/types.ts +25 -0
  45. 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
- private getSearchPaths(moduleName?: string): string[] {
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 = this.getSearchPaths(parsed.isModule ? parsed.moduleName : undefined);
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)