@platformos/platformos-common 0.0.8 → 0.0.9

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.
@@ -0,0 +1,350 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ PlatformOSFileType,
4
+ getFileType,
5
+ getAppPaths,
6
+ getModulePaths,
7
+ isKnownLiquidFile,
8
+ isPartial,
9
+ isPage,
10
+ isLayout,
11
+ isAuthorization,
12
+ isEmail,
13
+ isApiCall,
14
+ isSms,
15
+ isMigration,
16
+ } from './path-utils';
17
+
18
+ // Helper: build a realistic absolute URI
19
+ const uri = (path: string) => `file:///project/${path}`;
20
+
21
+ describe('getFileType', () => {
22
+ describe('app-level paths', () => {
23
+ it('identifies pages', () => {
24
+ expect(getFileType(uri('app/views/pages/home.liquid'))).toBe(PlatformOSFileType.Page);
25
+ expect(getFileType(uri('app/views/pages/nested/category/item.liquid'))).toBe(
26
+ PlatformOSFileType.Page,
27
+ );
28
+ });
29
+
30
+ it('identifies layouts', () => {
31
+ expect(getFileType(uri('app/views/layouts/default.liquid'))).toBe(PlatformOSFileType.Layout);
32
+ });
33
+
34
+ it('identifies partials in views/partials', () => {
35
+ expect(getFileType(uri('app/views/partials/header.liquid'))).toBe(PlatformOSFileType.Partial);
36
+ expect(getFileType(uri('app/views/partials/nested/card.liquid'))).toBe(
37
+ PlatformOSFileType.Partial,
38
+ );
39
+ });
40
+
41
+ it('identifies partials in app/lib', () => {
42
+ expect(getFileType(uri('app/lib/helpers/format.liquid'))).toBe(PlatformOSFileType.Partial);
43
+ expect(getFileType(uri('app/lib/utils.liquid'))).toBe(PlatformOSFileType.Partial);
44
+ });
45
+
46
+ it('identifies authorization_policies', () => {
47
+ expect(getFileType(uri('app/authorization_policies/can_edit.liquid'))).toBe(
48
+ PlatformOSFileType.Authorization,
49
+ );
50
+ });
51
+
52
+ it('identifies emails', () => {
53
+ expect(getFileType(uri('app/emails/welcome.liquid'))).toBe(PlatformOSFileType.Email);
54
+ });
55
+
56
+ it('identifies api_calls', () => {
57
+ expect(getFileType(uri('app/api_calls/create_user.liquid'))).toBe(PlatformOSFileType.ApiCall);
58
+ });
59
+
60
+ it('identifies smses', () => {
61
+ expect(getFileType(uri('app/smses/notification.liquid'))).toBe(PlatformOSFileType.Sms);
62
+ });
63
+
64
+ it('identifies migrations', () => {
65
+ expect(getFileType(uri('app/migrations/20230101_add_users.liquid'))).toBe(
66
+ PlatformOSFileType.Migration,
67
+ );
68
+ });
69
+
70
+ it('identifies graphql', () => {
71
+ expect(getFileType(uri('app/graphql/users.graphql'))).toBe(PlatformOSFileType.GraphQL);
72
+ });
73
+
74
+ it('identifies assets', () => {
75
+ expect(getFileType(uri('app/assets/app.js'))).toBe(PlatformOSFileType.Asset);
76
+ expect(getFileType(uri('app/assets/styles.css'))).toBe(PlatformOSFileType.Asset);
77
+ });
78
+ });
79
+
80
+ describe('module paths (modules/{name}/public|private/...)', () => {
81
+ it('identifies module pages', () => {
82
+ expect(getFileType(uri('modules/core/public/views/pages/home.liquid'))).toBe(
83
+ PlatformOSFileType.Page,
84
+ );
85
+ expect(getFileType(uri('modules/core/private/views/pages/admin.liquid'))).toBe(
86
+ PlatformOSFileType.Page,
87
+ );
88
+ });
89
+
90
+ it('identifies module layouts', () => {
91
+ expect(getFileType(uri('modules/core/public/views/layouts/default.liquid'))).toBe(
92
+ PlatformOSFileType.Layout,
93
+ );
94
+ });
95
+
96
+ it('identifies module partials in views/partials', () => {
97
+ expect(getFileType(uri('modules/core/public/views/partials/card.liquid'))).toBe(
98
+ PlatformOSFileType.Partial,
99
+ );
100
+ });
101
+
102
+ it('identifies module partials in lib', () => {
103
+ expect(getFileType(uri('modules/core/public/lib/utils.liquid'))).toBe(
104
+ PlatformOSFileType.Partial,
105
+ );
106
+ expect(getFileType(uri('modules/core/private/lib/internal.liquid'))).toBe(
107
+ PlatformOSFileType.Partial,
108
+ );
109
+ });
110
+
111
+ it('identifies module smses', () => {
112
+ expect(getFileType(uri('modules/core/public/smses/alert.liquid'))).toBe(
113
+ PlatformOSFileType.Sms,
114
+ );
115
+ });
116
+
117
+ it('identifies module graphql', () => {
118
+ expect(getFileType(uri('modules/core/public/graphql/query.graphql'))).toBe(
119
+ PlatformOSFileType.GraphQL,
120
+ );
121
+ });
122
+ });
123
+
124
+ describe('app/modules paths', () => {
125
+ it('identifies nested module partials in lib', () => {
126
+ expect(getFileType(uri('app/modules/core/public/lib/format.liquid'))).toBe(
127
+ PlatformOSFileType.Partial,
128
+ );
129
+ });
130
+
131
+ it('identifies nested module layouts', () => {
132
+ expect(getFileType(uri('app/modules/core/public/views/layouts/default.liquid'))).toBe(
133
+ PlatformOSFileType.Layout,
134
+ );
135
+ });
136
+ });
137
+
138
+ describe('false positive prevention — nested paths must not bleed into wrong type', () => {
139
+ it('app/lib/smses/file.liquid is Partial, not Sms', () => {
140
+ expect(getFileType(uri('app/lib/smses/file.liquid'))).toBe(PlatformOSFileType.Partial);
141
+ });
142
+
143
+ it('app/lib/emails/file.liquid is Partial, not Email', () => {
144
+ expect(getFileType(uri('app/lib/emails/file.liquid'))).toBe(PlatformOSFileType.Partial);
145
+ });
146
+
147
+ it('app/lib/api_calls/file.liquid is Partial, not ApiCall', () => {
148
+ expect(getFileType(uri('app/lib/api_calls/file.liquid'))).toBe(PlatformOSFileType.Partial);
149
+ });
150
+
151
+ it('modules/core/public/lib/smses/file.liquid is Partial, not Sms', () => {
152
+ expect(getFileType(uri('modules/core/public/lib/smses/file.liquid'))).toBe(
153
+ PlatformOSFileType.Partial,
154
+ );
155
+ });
156
+
157
+ it('modules/core/public/lib/emails/file.liquid is Partial, not Email', () => {
158
+ expect(getFileType(uri('modules/core/public/lib/emails/file.liquid'))).toBe(
159
+ PlatformOSFileType.Partial,
160
+ );
161
+ });
162
+ });
163
+
164
+ describe('unknown paths return undefined', () => {
165
+ it('returns undefined for a generator template with /lib/ in path', () => {
166
+ expect(
167
+ getFileType(uri('modules/core/generators/command/templates/lib/commands/create.liquid')),
168
+ ).toBeUndefined();
169
+ });
170
+
171
+ it('returns undefined for app/stupid/file.liquid', () => {
172
+ expect(getFileType(uri('app/stupid/file.liquid'))).toBeUndefined();
173
+ });
174
+
175
+ it('returns undefined for a file at project root', () => {
176
+ expect(getFileType(uri('file.liquid'))).toBeUndefined();
177
+ });
178
+
179
+ it('returns undefined for a path that only partially matches', () => {
180
+ // has 'views' but not 'views/pages' or 'views/layouts' etc.
181
+ expect(getFileType(uri('app/views/file.liquid'))).toBeUndefined();
182
+ });
183
+ });
184
+ });
185
+
186
+ describe('isKnownLiquidFile', () => {
187
+ it('returns true for all liquid file types', () => {
188
+ expect(isKnownLiquidFile(uri('app/views/pages/home.liquid'))).toBe(true);
189
+ expect(isKnownLiquidFile(uri('app/views/layouts/default.liquid'))).toBe(true);
190
+ expect(isKnownLiquidFile(uri('app/views/partials/header.liquid'))).toBe(true);
191
+ expect(isKnownLiquidFile(uri('app/lib/utils.liquid'))).toBe(true);
192
+ expect(isKnownLiquidFile(uri('app/authorization_policies/can_edit.liquid'))).toBe(true);
193
+ expect(isKnownLiquidFile(uri('app/emails/welcome.liquid'))).toBe(true);
194
+ expect(isKnownLiquidFile(uri('app/api_calls/create.liquid'))).toBe(true);
195
+ expect(isKnownLiquidFile(uri('app/smses/notify.liquid'))).toBe(true);
196
+ expect(isKnownLiquidFile(uri('app/migrations/001_init.liquid'))).toBe(true);
197
+ });
198
+
199
+ it('returns false for GraphQL files', () => {
200
+ expect(isKnownLiquidFile(uri('app/graphql/query.graphql'))).toBe(false);
201
+ });
202
+
203
+ it('returns false for asset files', () => {
204
+ expect(isKnownLiquidFile(uri('app/assets/app.js'))).toBe(false);
205
+ });
206
+
207
+ it('returns false for generator templates', () => {
208
+ expect(
209
+ isKnownLiquidFile(
210
+ uri('modules/core/generators/command/templates/lib/commands/create.liquid'),
211
+ ),
212
+ ).toBe(false);
213
+ });
214
+
215
+ it('returns false for unrecognized paths', () => {
216
+ expect(isKnownLiquidFile(uri('app/stupid/file.liquid'))).toBe(false);
217
+ });
218
+ });
219
+
220
+ describe('getAppPaths', () => {
221
+ it('returns correct paths for Page', () => {
222
+ expect(getAppPaths(PlatformOSFileType.Page)).toEqual(['app/views/pages']);
223
+ });
224
+
225
+ it('returns correct paths for Layout', () => {
226
+ expect(getAppPaths(PlatformOSFileType.Layout)).toEqual(['app/views/layouts']);
227
+ });
228
+
229
+ it('returns correct paths for Partial (two dirs)', () => {
230
+ expect(getAppPaths(PlatformOSFileType.Partial)).toEqual(['app/views/partials', 'app/lib']);
231
+ });
232
+
233
+ it('returns correct paths for GraphQL', () => {
234
+ expect(getAppPaths(PlatformOSFileType.GraphQL)).toEqual(['app/graphql']);
235
+ });
236
+
237
+ it('returns correct paths for Asset', () => {
238
+ expect(getAppPaths(PlatformOSFileType.Asset)).toEqual(['app/assets']);
239
+ });
240
+ });
241
+
242
+ describe('getModulePaths', () => {
243
+ it('returns all 8 module paths for Partial', () => {
244
+ expect(getModulePaths(PlatformOSFileType.Partial, 'mymodule')).toEqual([
245
+ 'app/modules/mymodule/public/views/partials',
246
+ 'app/modules/mymodule/private/views/partials',
247
+ 'modules/mymodule/public/views/partials',
248
+ 'modules/mymodule/private/views/partials',
249
+ 'app/modules/mymodule/public/lib',
250
+ 'app/modules/mymodule/private/lib',
251
+ 'modules/mymodule/public/lib',
252
+ 'modules/mymodule/private/lib',
253
+ ]);
254
+ });
255
+
256
+ it('returns all 4 module paths for GraphQL', () => {
257
+ expect(getModulePaths(PlatformOSFileType.GraphQL, 'mymodule')).toEqual([
258
+ 'app/modules/mymodule/public/graphql',
259
+ 'app/modules/mymodule/private/graphql',
260
+ 'modules/mymodule/public/graphql',
261
+ 'modules/mymodule/private/graphql',
262
+ ]);
263
+ });
264
+
265
+ it('returns all 4 module paths for Page', () => {
266
+ expect(getModulePaths(PlatformOSFileType.Page, 'core')).toEqual([
267
+ 'app/modules/core/public/views/pages',
268
+ 'app/modules/core/private/views/pages',
269
+ 'modules/core/public/views/pages',
270
+ 'modules/core/private/views/pages',
271
+ ]);
272
+ });
273
+ });
274
+
275
+ describe('type predicate convenience functions', () => {
276
+ describe('isPartial', () => {
277
+ it('returns true for views/partials', () => {
278
+ expect(isPartial(uri('app/views/partials/header.liquid'))).toBe(true);
279
+ });
280
+
281
+ it('returns true for app/lib', () => {
282
+ expect(isPartial(uri('app/lib/utils.liquid'))).toBe(true);
283
+ });
284
+
285
+ it('returns true for module lib', () => {
286
+ expect(isPartial(uri('modules/core/public/lib/utils.liquid'))).toBe(true);
287
+ });
288
+
289
+ it('returns false for pages', () => {
290
+ expect(isPartial(uri('app/views/pages/home.liquid'))).toBe(false);
291
+ });
292
+
293
+ it('returns false for generator template with /lib/ in path', () => {
294
+ expect(isPartial(uri('modules/core/generators/command/templates/lib/create.liquid'))).toBe(
295
+ false,
296
+ );
297
+ });
298
+ });
299
+
300
+ describe('isPage', () => {
301
+ it('returns true for app/views/pages', () => {
302
+ expect(isPage(uri('app/views/pages/home.liquid'))).toBe(true);
303
+ });
304
+
305
+ it('returns false for layouts', () => {
306
+ expect(isPage(uri('app/views/layouts/default.liquid'))).toBe(false);
307
+ });
308
+ });
309
+
310
+ describe('isLayout', () => {
311
+ it('returns true for app/views/layouts', () => {
312
+ expect(isLayout(uri('app/views/layouts/default.liquid'))).toBe(true);
313
+ });
314
+
315
+ it('returns true for module layouts', () => {
316
+ expect(isLayout(uri('modules/core/public/views/layouts/default.liquid'))).toBe(true);
317
+ });
318
+
319
+ it('returns false for pages', () => {
320
+ expect(isLayout(uri('app/views/pages/home.liquid'))).toBe(false);
321
+ });
322
+ });
323
+
324
+ it('isAuthorization', () => {
325
+ expect(isAuthorization(uri('app/authorization_policies/can_edit.liquid'))).toBe(true);
326
+ expect(isAuthorization(uri('app/lib/can_edit.liquid'))).toBe(false);
327
+ });
328
+
329
+ it('isEmail', () => {
330
+ expect(isEmail(uri('app/emails/welcome.liquid'))).toBe(true);
331
+ expect(isEmail(uri('app/lib/emails/welcome.liquid'))).toBe(false);
332
+ });
333
+
334
+ it('isApiCall', () => {
335
+ expect(isApiCall(uri('app/api_calls/create.liquid'))).toBe(true);
336
+ expect(isApiCall(uri('app/lib/api_calls/create.liquid'))).toBe(false);
337
+ });
338
+
339
+ it('isSms', () => {
340
+ expect(isSms(uri('app/smses/notify.liquid'))).toBe(true);
341
+ expect(isSms(uri('app/lib/smses/notify.liquid'))).toBe(false);
342
+ expect(isSms(uri('modules/core/public/smses/notify.liquid'))).toBe(true);
343
+ expect(isSms(uri('modules/core/public/lib/smses/notify.liquid'))).toBe(false);
344
+ });
345
+
346
+ it('isMigration', () => {
347
+ expect(isMigration(uri('app/migrations/001_init.liquid'))).toBe(true);
348
+ expect(isMigration(uri('app/lib/migrations/001_init.liquid'))).toBe(false);
349
+ });
350
+ });
package/src/path-utils.ts CHANGED
@@ -1,45 +1,191 @@
1
1
  /**
2
- * Utility functions for identifying platformOS file types based on their paths
2
+ * Utility functions for identifying platformOS file types based on their paths.
3
+ *
4
+ * Architecture:
5
+ * - FILE_TYPE_DIRS is the single source of truth for all platformOS directory names
6
+ * - getAppPaths() and getModulePaths() generate concrete search paths from FILE_TYPE_DIRS
7
+ * - TYPE_MATCHERS pre-compiles regexes for fast URI classification
8
+ * - getFileType() classifies any URI to a PlatformOSFileType
9
+ * - isPage(), isLayout(), isPartial() etc. are convenience wrappers around getFileType()
10
+ *
11
+ * Pattern precision: each type uses exact path segment patterns (/app/{dir}/ or
12
+ * /(public|private)/{dir}/) so that nested paths don't produce false positives.
13
+ * For example, app/lib/smses/file.liquid is correctly identified as Partial
14
+ * (matches /app/lib/), not Sms (does not match /app/smses/).
3
15
  */
4
16
 
5
17
  import { UriString } from './AbstractFileSystem';
6
18
 
7
19
  /**
8
- * Checks if a URI points to a partial file.
9
- * Partials can be located in:
10
- * - app/lib
11
- * - app/views/partials
12
- * - app/modules/{moduleName}/public/lib
13
- * - app/modules/{moduleName}/private/lib
14
- * - app/modules/{moduleName}/public/views/partials
15
- * - app/modules/{moduleName}/private/views/partials
16
- * - modules/{moduleName}/public/lib
17
- * - modules/{moduleName}/private/lib
18
- * - modules/{moduleName}/public/views/partials
19
- * - modules/{moduleName}/private/views/partials
20
+ * File types that exist in a platformOS app, each mapping to one or more
21
+ * canonical directory names relative to the app root or module access level.
20
22
  */
21
- export function isPartial(uri: UriString): boolean {
22
- return uri.includes('/lib/') || uri.includes('/views/partials');
23
+ export enum PlatformOSFileType {
24
+ Page = 'Page',
25
+ Layout = 'Layout',
26
+ Partial = 'Partial',
27
+ Authorization = 'Authorization',
28
+ Email = 'Email',
29
+ ApiCall = 'ApiCall',
30
+ Sms = 'Sms',
31
+ Migration = 'Migration',
32
+ GraphQL = 'GraphQL',
33
+ Asset = 'Asset',
23
34
  }
24
35
 
25
36
  /**
26
- * Checks if a URI points to a page file.
27
- * Pages are located in app/views/pages
37
+ * The single source of truth for the platformOS directory structure.
38
+ *
39
+ * Maps each file type to its canonical directory name(s) relative to:
40
+ * - the app root: app/{dir}/
41
+ * - a module access level: modules/{name}/(public|private)/{dir}/
42
+ * - a nested module access level: app/modules/{name}/(public|private)/{dir}/
43
+ *
44
+ * Types with multiple dirs (e.g. Partial) will match any of their dirs.
45
+ * The first matching type wins, so order of evaluation matters for overlapping
46
+ * paths — but exact segment matching means dirs don't overlap in practice.
28
47
  */
29
- export function isPage(uri: UriString): boolean {
30
- return uri.includes('/views/pages');
48
+ export const FILE_TYPE_DIRS: Readonly<Record<PlatformOSFileType, readonly string[]>> = {
49
+ [PlatformOSFileType.Page]: ['views/pages'],
50
+ [PlatformOSFileType.Layout]: ['views/layouts'],
51
+ [PlatformOSFileType.Partial]: ['views/partials', 'lib'],
52
+ [PlatformOSFileType.Authorization]: ['authorization_policies'],
53
+ [PlatformOSFileType.Email]: ['emails'],
54
+ [PlatformOSFileType.ApiCall]: ['api_calls'],
55
+ [PlatformOSFileType.Sms]: ['smses'],
56
+ [PlatformOSFileType.Migration]: ['migrations'],
57
+ [PlatformOSFileType.GraphQL]: ['graphql'],
58
+ [PlatformOSFileType.Asset]: ['assets'],
59
+ };
60
+
61
+ /**
62
+ * Liquid-containing file types. GraphQL and Asset are excluded because they
63
+ * don't contain Liquid code and should not be passed to the Liquid linter.
64
+ */
65
+ const LIQUID_FILE_TYPES = new Set<PlatformOSFileType>([
66
+ PlatformOSFileType.Page,
67
+ PlatformOSFileType.Layout,
68
+ PlatformOSFileType.Partial,
69
+ PlatformOSFileType.Authorization,
70
+ PlatformOSFileType.Email,
71
+ PlatformOSFileType.ApiCall,
72
+ PlatformOSFileType.Sms,
73
+ PlatformOSFileType.Migration,
74
+ ]);
75
+
76
+ /**
77
+ * Pre-compiled regex per file type, derived entirely from FILE_TYPE_DIRS.
78
+ *
79
+ * For each canonical dir, two pattern alternatives are generated:
80
+ * /app/{dir}/ — direct app-level path (e.g. /app/lib/)
81
+ * /(public|private)/{dir}/ — module path, covers both:
82
+ * modules/{name}/(public|private)/{dir}/
83
+ * app/modules/{name}/(public|private)/{dir}/
84
+ *
85
+ * Exact path segment matching prevents false positives:
86
+ * /app/lib/smses/file.liquid → matches /app/lib/ → Partial (NOT Sms)
87
+ * /app/smses/file.liquid → matches /app/smses/ → Sms (NOT Partial)
88
+ */
89
+ const TYPE_MATCHERS = new Map<PlatformOSFileType, RegExp>(
90
+ (Object.entries(FILE_TYPE_DIRS) as [PlatformOSFileType, readonly string[]][]).map(
91
+ ([type, dirs]) => {
92
+ const alternatives = dirs.flatMap((dir) => [`/app/${dir}/`, `/(public|private)/${dir}/`]);
93
+ return [type, new RegExp(alternatives.join('|'))];
94
+ },
95
+ ),
96
+ );
97
+
98
+ /**
99
+ * Returns the PlatformOSFileType for the given URI, or undefined if the URI
100
+ * does not belong to any recognized platformOS directory.
101
+ *
102
+ * @example
103
+ * getFileType('file:///root/app/lib/smses/notify.liquid') // → PlatformOSFileType.Partial
104
+ * getFileType('file:///root/app/smses/notify.liquid') // → PlatformOSFileType.Sms
105
+ * getFileType('file:///root/modules/core/generators/templates/lib/create.liquid') // → undefined
106
+ */
107
+ export function getFileType(uri: UriString): PlatformOSFileType | undefined {
108
+ for (const [type, re] of TYPE_MATCHERS) {
109
+ if (re.test(uri)) return type;
110
+ }
111
+ return undefined;
31
112
  }
32
113
 
33
114
  /**
34
- * Checks if a URI points to a layout file.
35
- * Layouts are located in app/views/layouts
115
+ * Returns app-level search paths for a file type (relative to project root).
116
+ *
117
+ * @example
118
+ * getAppPaths(PlatformOSFileType.Partial) // → ['app/views/partials', 'app/lib']
119
+ * getAppPaths(PlatformOSFileType.GraphQL) // → ['app/graphql']
36
120
  */
37
- export function isLayout(uri: UriString): boolean {
38
- return uri.includes('/views/layouts');
121
+ export function getAppPaths(type: PlatformOSFileType): string[] {
122
+ return FILE_TYPE_DIRS[type].map((dir) => `app/${dir}`);
39
123
  }
40
124
 
41
125
  /**
42
- * Legacy Shopify terminology - use isPartial instead
43
- * @deprecated Use isPartial instead
126
+ * Returns all module search paths for a file type and module name, covering
127
+ * both app/modules/{name}/... and modules/{name}/... roots, and both
128
+ * public and private access levels (relative to project root).
129
+ *
130
+ * @example
131
+ * getModulePaths(PlatformOSFileType.Partial, 'core') // → [
132
+ * 'app/modules/core/public/views/partials',
133
+ * 'app/modules/core/private/views/partials',
134
+ * 'modules/core/public/views/partials',
135
+ * 'modules/core/private/views/partials',
136
+ * 'app/modules/core/public/lib',
137
+ * 'app/modules/core/private/lib',
138
+ * 'modules/core/public/lib',
139
+ * 'modules/core/private/lib',
140
+ * ]
44
141
  */
45
- export const isSnippet = isPartial;
142
+ export function getModulePaths(type: PlatformOSFileType, moduleName: string): string[] {
143
+ return FILE_TYPE_DIRS[type].flatMap((dir) => [
144
+ `app/modules/${moduleName}/public/${dir}`,
145
+ `app/modules/${moduleName}/private/${dir}`,
146
+ `modules/${moduleName}/public/${dir}`,
147
+ `modules/${moduleName}/private/${dir}`,
148
+ ]);
149
+ }
150
+
151
+ /**
152
+ * Returns true if the URI belongs to a recognized platformOS Liquid directory
153
+ * and should be linted. Files outside known directories (e.g. generator
154
+ * templates, build artifacts) return false and are excluded from linting.
155
+ */
156
+ export function isKnownLiquidFile(uri: UriString): boolean {
157
+ const type = getFileType(uri);
158
+ return type !== undefined && LIQUID_FILE_TYPES.has(type);
159
+ }
160
+
161
+ export function isPartial(uri: UriString): boolean {
162
+ return getFileType(uri) === PlatformOSFileType.Partial;
163
+ }
164
+
165
+ export function isPage(uri: UriString): boolean {
166
+ return getFileType(uri) === PlatformOSFileType.Page;
167
+ }
168
+
169
+ export function isLayout(uri: UriString): boolean {
170
+ return getFileType(uri) === PlatformOSFileType.Layout;
171
+ }
172
+
173
+ export function isAuthorization(uri: UriString): boolean {
174
+ return getFileType(uri) === PlatformOSFileType.Authorization;
175
+ }
176
+
177
+ export function isEmail(uri: UriString): boolean {
178
+ return getFileType(uri) === PlatformOSFileType.Email;
179
+ }
180
+
181
+ export function isApiCall(uri: UriString): boolean {
182
+ return getFileType(uri) === PlatformOSFileType.ApiCall;
183
+ }
184
+
185
+ export function isSms(uri: UriString): boolean {
186
+ return getFileType(uri) === PlatformOSFileType.Sms;
187
+ }
188
+
189
+ export function isMigration(uri: UriString): boolean {
190
+ return getFileType(uri) === PlatformOSFileType.Migration;
191
+ }
@@ -106,6 +106,89 @@ export class TranslationProvider {
106
106
  return [undefined, undefined];
107
107
  }
108
108
 
109
+ /**
110
+ * Aggregates ALL translation files for `locale` within `translationBaseUri`.
111
+ *
112
+ * Covers two layouts:
113
+ * - Single file: `{base}/{locale}.yml`
114
+ * - Split files: `{base}/{locale}/*.yml`
115
+ *
116
+ * Only files whose first YAML key matches `locale` are included, so a file
117
+ * placed in the wrong directory (or accidentally containing a different
118
+ * locale) is silently ignored.
119
+ *
120
+ * @param contentOverride Optional function called before the filesystem is
121
+ * consulted. Return the file's source string to use it instead of the
122
+ * on-disk content, or `undefined` to fall through to the filesystem.
123
+ * Used by editor integrations to honour unsaved buffer changes.
124
+ */
125
+ async loadAllTranslationsForBase(
126
+ translationBaseUri: URI,
127
+ locale: string,
128
+ contentOverride?: (uri: string) => string | undefined,
129
+ ): Promise<Record<string, any>> {
130
+ const merged: Record<string, any> = {};
131
+
132
+ const read = async (uri: string): Promise<string | undefined> => {
133
+ if (contentOverride) {
134
+ const buffered = contentOverride(uri);
135
+ if (buffered !== undefined) return buffered;
136
+ }
137
+ return this.readFileIfExists(uri);
138
+ };
139
+
140
+ // Strategy A: single locale file ({base}/{locale}.yml)
141
+ const singleFileUri = Utils.joinPath(translationBaseUri, `${locale}.yml`).toString();
142
+ const singleContent = await read(singleFileUri);
143
+ if (singleContent) {
144
+ const parsed = this.parseTranslationFile(singleContent, locale);
145
+ if (parsed) this.deepMerge(merged, parsed);
146
+ }
147
+
148
+ // Strategy B: locale directory ({base}/{locale}/*.yml)
149
+ const localeDirUri = Utils.joinPath(translationBaseUri, locale).toString();
150
+ const ymlFiles = await this.listYmlFiles(localeDirUri);
151
+ for (const fileUri of ymlFiles) {
152
+ const content = await read(fileUri);
153
+ if (content) {
154
+ const parsed = this.parseTranslationFile(content, locale);
155
+ if (parsed) this.deepMerge(merged, parsed);
156
+ }
157
+ }
158
+
159
+ return merged;
160
+ }
161
+
162
+ /**
163
+ * Parses a YAML translation file and returns its contents under the locale
164
+ * key. Returns `undefined` if the file cannot be parsed or if its first
165
+ * key does not match `expectedLocale` (guards against mis-placed files).
166
+ */
167
+ private parseTranslationFile(
168
+ content: string,
169
+ expectedLocale: string,
170
+ ): Record<string, any> | undefined {
171
+ try {
172
+ const data = yaml.load(content) as Record<string, any>;
173
+ if (!data || typeof data !== 'object') return undefined;
174
+ const firstKey = Object.keys(data)[0];
175
+ if (firstKey !== expectedLocale) return undefined;
176
+ return data[firstKey] ?? undefined;
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ private deepMerge(target: Record<string, any>, source: Record<string, any>): void {
183
+ for (const [key, value] of Object.entries(source)) {
184
+ if (typeof value === 'object' && value !== null && typeof target[key] === 'object') {
185
+ this.deepMerge(target[key], value);
186
+ } else {
187
+ target[key] = value;
188
+ }
189
+ }
190
+ }
191
+
109
192
  async translate(
110
193
  rootUri: URI,
111
194
  translationKey: string,