@moloodev/platform 0.0.0-stage → 0.1.0

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/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@moloodev/platform",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./builtin-editors": "./src/builtin-editors.ts",
8
+ "./network": "./src/network.ts",
9
+ "./dev-certs": "./src/dev-certs.ts",
10
+ "./constants": "./src/constants.ts",
11
+ "./naming": "./src/naming.ts",
12
+ "./occupancy-protocol": "./src/occupancy-protocol.ts",
13
+ "./file-content": "./src/file-content.ts",
14
+ "./bundle": "./src/bundle.ts",
15
+ "./file-delivery": "./src/file-delivery.ts",
16
+ "./ids": "./src/ids.ts"
17
+ },
18
+ "dependencies": {
19
+ "nanoid": "^6.0.1",
20
+ "zod": "^4.5.4"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^24.13.3",
24
+ "tsx": "^4.23.13",
25
+ "typescript": "^7.0.2",
26
+ "@moloodev/typescript-config": "0.1.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "!src/**/*.test.ts",
34
+ "!src/**/*.test.tsx"
35
+ ],
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx'"
39
+ }
6
40
  }
@@ -0,0 +1,101 @@
1
+ import { getMarketingOrigin } from "./origins";
2
+ import { getPlatformAppById, getPlatformAppUrl } from "./platform";
3
+ import { resolveSiteEnv, type SiteEnv } from "./url";
4
+ import { WORKSPACE_HOST_API } from "./workspace-host";
5
+
6
+ /**
7
+ * Shared platform API version segment. Modules mount at `/v1/{moduleId}`
8
+ * (no `/platform` prefix).
9
+ */
10
+ export const PLATFORM_API = {
11
+ id: "platform",
12
+ apiVersion: "v1",
13
+ name: "Platform",
14
+ } as const;
15
+
16
+ export type ApiSurfaceRef = {
17
+ id: string;
18
+ apiVersion: string;
19
+ name?: string;
20
+ };
21
+
22
+ /** First path segments that must never be treated as a product API id. */
23
+ export const RESERVED_API_ROOT_SEGMENTS = new Set([
24
+ "health",
25
+ "platform",
26
+ "v1",
27
+ "favicon.ico",
28
+ ]);
29
+
30
+ export function getApiOrigin(env: SiteEnv = resolveSiteEnv()): string {
31
+ const api = getPlatformAppById("api");
32
+ if (!api) {
33
+ throw new Error("PLATFORM_APPS is missing api");
34
+ }
35
+ return getPlatformAppUrl(api, env);
36
+ }
37
+
38
+ export function getDocsOrigin(env: SiteEnv = resolveSiteEnv()): string {
39
+ return `${getMarketingOrigin(env)}/docs`;
40
+ }
41
+
42
+ export function getDocsSurfaceUrl(
43
+ surfaceId: string,
44
+ env: SiteEnv = resolveSiteEnv(),
45
+ ): string {
46
+ return `${getDocsOrigin(env)}/${surfaceId}`;
47
+ }
48
+
49
+ export function getApiOpenApiUrl(
50
+ _surface?: ApiSurfaceRef,
51
+ env: SiteEnv = resolveSiteEnv(),
52
+ ): string {
53
+ return `${getApiOrigin(env)}/openapi`;
54
+ }
55
+
56
+ export function getApiOpenApiJsonUrl(
57
+ env: SiteEnv = resolveSiteEnv(),
58
+ ): string {
59
+ return `${getApiOrigin(env)}/openapi.json`;
60
+ }
61
+
62
+ /** Base URL for platform modules: `{origin}/v1`. */
63
+ export function getPlatformApiBaseUrl(
64
+ env: SiteEnv = resolveSiteEnv(),
65
+ ): string {
66
+ return `${getApiOrigin(env)}/${PLATFORM_API.apiVersion}`;
67
+ }
68
+
69
+ export function resolveApiSurface(
70
+ id: string,
71
+ ): ApiSurfaceRef | undefined {
72
+ if (id === PLATFORM_API.id) return PLATFORM_API;
73
+ if (id === WORKSPACE_HOST_API.id) return WORKSPACE_HOST_API;
74
+ return undefined;
75
+ }
76
+
77
+ export function isProductApiSurface(
78
+ id: string,
79
+ ): ApiSurfaceRef | undefined {
80
+ if (RESERVED_API_ROOT_SEGMENTS.has(id)) return undefined;
81
+ if (id === WORKSPACE_HOST_API.id) return WORKSPACE_HOST_API;
82
+ return undefined;
83
+ }
84
+
85
+ export function getSurfaceChromeLinks(
86
+ surfaceId: string,
87
+ ): { docsHref: string; apiHref: string } | null {
88
+ if (surfaceId === WORKSPACE_HOST_API.id) {
89
+ return {
90
+ docsHref: getDocsSurfaceUrl(WORKSPACE_HOST_API.id),
91
+ apiHref: getApiOpenApiUrl(),
92
+ };
93
+ }
94
+ if (surfaceId === "web") {
95
+ return {
96
+ docsHref: getDocsOrigin(),
97
+ apiHref: getApiOpenApiUrl(),
98
+ };
99
+ }
100
+ return null;
101
+ }
@@ -0,0 +1,31 @@
1
+ export type AppIcon =
2
+ | "hierarchy"
3
+ | "shuffle"
4
+ | "cube"
5
+ | "brush"
6
+ | "package"
7
+ | "grid"
8
+ | "music"
9
+ | "map"
10
+ | "sparkles"
11
+ | "users"
12
+ | "device"
13
+ | "chart"
14
+ | "world"
15
+ | "camera"
16
+ | "bolt"
17
+ | "palette"
18
+ | "robot"
19
+ | "layers"
20
+ | "message"
21
+ | "flag"
22
+ | "clock"
23
+ | "dice"
24
+ | "target"
25
+ | "wand"
26
+ | "cloud"
27
+ | "code"
28
+ | "home"
29
+ | "book"
30
+ | "activity"
31
+ | "shield";
@@ -0,0 +1,518 @@
1
+ /** Icons used by asset kinds and file appearance resolvers. Independent of chrome `AppIcon`. */
2
+ export const FILE_APPEARANCE_ICONS = [
3
+ "tabler:folder",
4
+ "tabler:hierarchy",
5
+ "tabler:layout",
6
+ "tabler:table",
7
+ "tabler:markdown",
8
+ "tabler:photo",
9
+ "tabler:movie",
10
+ "tabler:music",
11
+ "tabler:file-text",
12
+ "tabler:database",
13
+ "tabler:code",
14
+ "tabler:archive",
15
+ "tabler:typography",
16
+ "tabler:cube",
17
+ "tabler:file",
18
+ "tabler:package",
19
+ "tabler:help",
20
+ ] as const;
21
+
22
+ export type AssetTypeIcon = (typeof FILE_APPEARANCE_ICONS)[number];
23
+
24
+ /**
25
+ * Primitive storage and CRDT collaboration mode for an asset.
26
+ * - `json`: UTF-8 JSON object projected into Y.Doc `root` `SharedMap`.
27
+ * - `text`: UTF-8 text projected into `root.get("body")` (`SharedText`).
28
+ * - `blob`: Immutable binary object in object storage (`asset_blob`) with a pointer in `root`.
29
+ */
30
+ export const STORAGE_MODES = ["json", "text", "blob"] as const;
31
+ export type StorageMode = (typeof STORAGE_MODES)[number];
32
+
33
+ export function isStorageMode(value: unknown): value is StorageMode {
34
+ return (
35
+ typeof value === "string" &&
36
+ (STORAGE_MODES as readonly string[]).includes(value)
37
+ );
38
+ }
39
+
40
+ /** Canonical first-party JSON `$schema` URIs. */
41
+ export const FIRST_PARTY_SCHEMAS = {
42
+ layout: "https://moloo.dev/schemas/layout/v1.json",
43
+ catalog: "https://moloo.dev/schemas/catalog/v1.json",
44
+ table: "https://moloo.dev/schemas/table/v1.json",
45
+ loop: "https://moloo.dev/schemas/loop/v1.json",
46
+ bundle: "https://moloo.dev/schemas/bundle/v1.json",
47
+ } as const;
48
+
49
+ export type FirstPartySchemaKey = keyof typeof FIRST_PARTY_SCHEMAS;
50
+
51
+ export type FileAppearance = {
52
+ label: string;
53
+ color: string;
54
+ icon: AssetTypeIcon;
55
+ };
56
+
57
+ /** Optional file matcher rule declared by an installed `EditorManifest`. */
58
+ export type FileAppearanceMatcher = {
59
+ extensions?: readonly string[];
60
+ mimeTypes?: readonly string[];
61
+ schema?: string;
62
+ storageMode?: StorageMode;
63
+ label?: string;
64
+ icon?: AssetTypeIcon;
65
+ color?: string;
66
+ };
67
+
68
+ export const UNKNOWN_FILE_APPEARANCE: FileAppearance = {
69
+ label: "알 수 없음",
70
+ color: "#94a3b8",
71
+ icon: "tabler:help",
72
+ };
73
+
74
+ const PATTERN_APPEARANCE_MAP = {
75
+ "moloo.folder": { label: "폴더", color: "#94a3b8", icon: "tabler:folder" },
76
+ "moloo.loop": { label: "루프", color: "#f07858", icon: "tabler:hierarchy" },
77
+ "moloo.table": { label: "테이블", color: "#3d9b8f", icon: "tabler:table" },
78
+ "moloo.markdown": { label: "마크다운", color: "#5b6abf", icon: "tabler:markdown" },
79
+ "moloo.bundle": { label: "번들", color: "#2f7a86", icon: "tabler:package" },
80
+ "moloo.layout": { label: "레이아웃", color: "#7695b3", icon: "tabler:layout" },
81
+ "moloo.catalog": { label: "카탈로그", color: "#9474ad", icon: "tabler:package" },
82
+ "moloo.image": { label: "이미지", color: "#a5c65b", icon: "tabler:photo" },
83
+ "moloo.video": { label: "비디오", color: "#8f7fe0", icon: "tabler:movie" },
84
+ "moloo.audio": { label: "오디오", color: "#c78cd9", icon: "tabler:music" },
85
+ "moloo.document": { label: "문서", color: "#89b4e8", icon: "tabler:file-text" },
86
+ "moloo.data": { label: "데이터", color: "#66b06f", icon: "tabler:database" },
87
+ "moloo.code": { label: "코드", color: "#5c73c9", icon: "tabler:code" },
88
+ "moloo.archive": { label: "압축", color: "#b3a27a", icon: "tabler:archive" },
89
+ "moloo.font": { label: "글꼴", color: "#b09bbf", icon: "tabler:typography" },
90
+ "moloo.model": { label: "3D 모델", color: "#d1b24f", icon: "tabler:cube" },
91
+ "moloo.binary": { label: "바이너리", color: "#a7adba", icon: "tabler:file" },
92
+ } as const satisfies Record<string, FileAppearance>;
93
+
94
+ export type KnownPatternId = keyof typeof PATTERN_APPEARANCE_MAP;
95
+
96
+ export const KNOWN_PATTERN_IDS = Object.keys(
97
+ PATTERN_APPEARANCE_MAP,
98
+ ) as KnownPatternId[];
99
+
100
+ const DOCUMENT_SUBTYPES = new Set([
101
+ "pdf",
102
+ "msword",
103
+ "rtf",
104
+ "vnd.openxmlformats-officedocument.wordprocessingml.document",
105
+ "vnd.ms-powerpoint",
106
+ "vnd.openxmlformats-officedocument.presentationml.presentation",
107
+ "vnd.oasis.opendocument.text",
108
+ "vnd.oasis.opendocument.presentation",
109
+ "epub+zip",
110
+ ]);
111
+ const DATA_SUBTYPES = new Set([
112
+ "json",
113
+ "xml",
114
+ "sql",
115
+ "vnd.ms-excel",
116
+ "vnd.openxmlformats-officedocument.spreadsheetml.sheet",
117
+ "vnd.oasis.opendocument.spreadsheet",
118
+ "yaml",
119
+ "x-yaml",
120
+ "toml",
121
+ ]);
122
+ const CODE_SUBTYPES = new Set([
123
+ "javascript",
124
+ "typescript",
125
+ "x-httpd-php",
126
+ "wasm",
127
+ "x-sh",
128
+ "x-python",
129
+ "x-python-code",
130
+ "x-rust",
131
+ ]);
132
+ const ARCHIVE_SUBTYPES = new Set([
133
+ "zip",
134
+ "x-zip-compressed",
135
+ "x-rar-compressed",
136
+ "vnd.rar",
137
+ "x-7z-compressed",
138
+ "gzip",
139
+ "x-gzip",
140
+ "x-tar",
141
+ "x-bzip2",
142
+ "x-xz",
143
+ "zstd",
144
+ ]);
145
+ const CODE_TEXT_SUBTYPES = new Set([
146
+ "html",
147
+ "css",
148
+ "javascript",
149
+ "typescript",
150
+ "x-python",
151
+ "x-sh",
152
+ "jsx",
153
+ "tsx",
154
+ "x-rust",
155
+ "x-vue",
156
+ ]);
157
+ const DATA_TEXT_SUBTYPES = new Set(["csv", "xml", "yaml", "x-yaml", "tab-separated-values"]);
158
+
159
+ /** The MIME family pattern an uploaded MIME type maps to; unknown or malformed types are `moloo.binary`. */
160
+ export function patternForContentType(contentType: string): KnownPatternId {
161
+ const [type = "", subtypeRaw = ""] = contentType.trim().toLowerCase().split(";")[0]!.split("/");
162
+ const subtype = subtypeRaw.trim();
163
+ switch (type) {
164
+ case "image":
165
+ return "moloo.image";
166
+ case "video":
167
+ return "moloo.video";
168
+ case "audio":
169
+ return "moloo.audio";
170
+ case "font":
171
+ return "moloo.font";
172
+ case "model":
173
+ return "moloo.model";
174
+ case "text":
175
+ if (CODE_TEXT_SUBTYPES.has(subtype)) return "moloo.code";
176
+ if (DATA_TEXT_SUBTYPES.has(subtype)) return "moloo.data";
177
+ return "moloo.document";
178
+ case "application":
179
+ if (DOCUMENT_SUBTYPES.has(subtype)) return "moloo.document";
180
+ if (DATA_SUBTYPES.has(subtype)) return "moloo.data";
181
+ if (CODE_SUBTYPES.has(subtype)) return "moloo.code";
182
+ if (ARCHIVE_SUBTYPES.has(subtype)) return "moloo.archive";
183
+ if (subtype.startsWith("font-") || subtype.startsWith("x-font")) return "moloo.font";
184
+ return "moloo.binary";
185
+ default:
186
+ return "moloo.binary";
187
+ }
188
+ }
189
+
190
+ const COMPOUND_SCHEMA_BY_SUFFIX: readonly [string, string][] = [
191
+ [".layout.json", FIRST_PARTY_SCHEMAS.layout],
192
+ [".mlayout", FIRST_PARTY_SCHEMAS.layout],
193
+ [".catalog.json", FIRST_PARTY_SCHEMAS.catalog],
194
+ [".mcat", FIRST_PARTY_SCHEMAS.catalog],
195
+ [".table.json", FIRST_PARTY_SCHEMAS.table],
196
+ [".loop.json", FIRST_PARTY_SCHEMAS.loop],
197
+ [".bundle.json", FIRST_PARTY_SCHEMAS.bundle],
198
+ ];
199
+
200
+ const EXTENSION_MIME_MAP: Record<string, { contentType: string; storageMode: StorageMode }> = {
201
+ ".json": { contentType: "application/json", storageMode: "json" },
202
+ ".mlayout": { contentType: "application/json", storageMode: "json" },
203
+ ".mcat": { contentType: "application/json", storageMode: "json" },
204
+ ".md": { contentType: "text/markdown", storageMode: "text" },
205
+ ".markdown": { contentType: "text/markdown", storageMode: "text" },
206
+ ".txt": { contentType: "text/plain", storageMode: "text" },
207
+ ".csv": { contentType: "text/csv", storageMode: "text" },
208
+ ".tsv": { contentType: "text/tab-separated-values", storageMode: "text" },
209
+ ".yaml": { contentType: "application/yaml", storageMode: "text" },
210
+ ".yml": { contentType: "application/yaml", storageMode: "text" },
211
+ ".toml": { contentType: "application/toml", storageMode: "text" },
212
+ ".xml": { contentType: "application/xml", storageMode: "text" },
213
+ ".html": { contentType: "text/html", storageMode: "text" },
214
+ ".css": { contentType: "text/css", storageMode: "text" },
215
+ ".js": { contentType: "text/javascript", storageMode: "text" },
216
+ ".mjs": { contentType: "text/javascript", storageMode: "text" },
217
+ ".ts": { contentType: "text/typescript", storageMode: "text" },
218
+ ".tsx": { contentType: "text/tsx", storageMode: "text" },
219
+ ".jsx": { contentType: "text/jsx", storageMode: "text" },
220
+ ".py": { contentType: "text/x-python", storageMode: "text" },
221
+ ".rs": { contentType: "text/x-rust", storageMode: "text" },
222
+ ".sh": { contentType: "text/x-sh", storageMode: "text" },
223
+ ".sql": { contentType: "application/sql", storageMode: "text" },
224
+ ".png": { contentType: "image/png", storageMode: "blob" },
225
+ ".jpg": { contentType: "image/jpeg", storageMode: "blob" },
226
+ ".jpeg": { contentType: "image/jpeg", storageMode: "blob" },
227
+ ".webp": { contentType: "image/webp", storageMode: "blob" },
228
+ ".gif": { contentType: "image/gif", storageMode: "blob" },
229
+ ".svg": { contentType: "image/svg+xml", storageMode: "blob" },
230
+ ".avif": { contentType: "image/avif", storageMode: "blob" },
231
+ ".mp4": { contentType: "video/mp4", storageMode: "blob" },
232
+ ".webm": { contentType: "video/webm", storageMode: "blob" },
233
+ ".mp3": { contentType: "audio/mpeg", storageMode: "blob" },
234
+ ".wav": { contentType: "audio/wav", storageMode: "blob" },
235
+ ".ogg": { contentType: "audio/ogg", storageMode: "blob" },
236
+ ".pdf": { contentType: "application/pdf", storageMode: "blob" },
237
+ ".zip": { contentType: "application/zip", storageMode: "blob" },
238
+ ".gz": { contentType: "application/gzip", storageMode: "blob" },
239
+ ".7z": { contentType: "application/x-7z-compressed", storageMode: "blob" },
240
+ ".woff": { contentType: "font/woff", storageMode: "blob" },
241
+ ".woff2": { contentType: "font/woff2", storageMode: "blob" },
242
+ ".ttf": { contentType: "font/ttf", storageMode: "blob" },
243
+ ".otf": { contentType: "font/otf", storageMode: "blob" },
244
+ ".glb": { contentType: "model/gltf-binary", storageMode: "blob" },
245
+ ".gltf": { contentType: "model/gltf+json", storageMode: "json" },
246
+ };
247
+
248
+ const TEXT_APPLICATION_SUBTYPES = new Set([
249
+ "yaml",
250
+ "x-yaml",
251
+ "toml",
252
+ "xml",
253
+ "sql",
254
+ "javascript",
255
+ "typescript",
256
+ "x-sh",
257
+ "x-python",
258
+ "x-rust",
259
+ ]);
260
+
261
+ function normalizeMimeType(raw?: string | null): string {
262
+ if (!raw) return "";
263
+ const base = raw.trim().toLowerCase().split(";")[0]?.trim() ?? "";
264
+ return base.includes("/") ? base : "";
265
+ }
266
+
267
+ /**
268
+ * Classify a file by its name and optional declared MIME type into its default
269
+ * primitive `storageMode` (`"json" | "text" | "blob"`), normalized `contentType`,
270
+ * and inferred first-party `$schema` URI (if any).
271
+ */
272
+ export function classifyFile(input: {
273
+ fileName: string;
274
+ contentType?: string | null;
275
+ }): {
276
+ storageMode: StorageMode;
277
+ contentType: string;
278
+ defaultSchema: string | null;
279
+ } {
280
+ const name = input.fileName.trim();
281
+ const lower = name.toLowerCase();
282
+ if (name === ".keep") {
283
+ return {
284
+ storageMode: "json",
285
+ contentType: "application/x-directory",
286
+ defaultSchema: null,
287
+ };
288
+ }
289
+
290
+ let defaultSchema: string | null = null;
291
+ for (const [suffix, schema] of COMPOUND_SCHEMA_BY_SUFFIX) {
292
+ if (lower.endsWith(suffix) && lower.length > suffix.length) {
293
+ defaultSchema = schema;
294
+ break;
295
+ }
296
+ }
297
+
298
+ const declared = normalizeMimeType(input.contentType);
299
+ const dotIdx = lower.lastIndexOf(".");
300
+ const ext = dotIdx > 0 ? lower.slice(dotIdx) : "";
301
+ const fromExt = ext ? EXTENSION_MIME_MAP[ext] : undefined;
302
+
303
+ const contentType =
304
+ declared && declared !== "application/octet-stream"
305
+ ? declared
306
+ : (fromExt?.contentType ?? (declared || "application/octet-stream"));
307
+
308
+ const [type = "", subtype = ""] = contentType.split("/");
309
+ if (
310
+ defaultSchema !== null ||
311
+ contentType === "application/json" ||
312
+ subtype.endsWith("+json") ||
313
+ fromExt?.storageMode === "json"
314
+ ) {
315
+ return {
316
+ storageMode: "json",
317
+ contentType:
318
+ contentType === "application/octet-stream"
319
+ ? "application/json"
320
+ : contentType,
321
+ defaultSchema,
322
+ };
323
+ }
324
+
325
+ if (
326
+ (type === "text" && contentType !== "image/svg+xml") ||
327
+ TEXT_APPLICATION_SUBTYPES.has(subtype) ||
328
+ fromExt?.storageMode === "text"
329
+ ) {
330
+ return {
331
+ storageMode: "text",
332
+ contentType:
333
+ contentType === "application/octet-stream" && fromExt
334
+ ? fromExt.contentType
335
+ : contentType,
336
+ defaultSchema: null,
337
+ };
338
+ }
339
+
340
+ return {
341
+ storageMode: "blob",
342
+ contentType,
343
+ defaultSchema: null,
344
+ };
345
+ }
346
+
347
+ const FIRST_PARTY_SCHEMA_APPEARANCE: Record<string, FileAppearance> = {
348
+ [FIRST_PARTY_SCHEMAS.layout]: PATTERN_APPEARANCE_MAP["moloo.layout"],
349
+ [FIRST_PARTY_SCHEMAS.catalog]: PATTERN_APPEARANCE_MAP["moloo.catalog"],
350
+ [FIRST_PARTY_SCHEMAS.table]: PATTERN_APPEARANCE_MAP["moloo.table"],
351
+ [FIRST_PARTY_SCHEMAS.loop]: PATTERN_APPEARANCE_MAP["moloo.loop"],
352
+ [FIRST_PARTY_SCHEMAS.bundle]: PATTERN_APPEARANCE_MAP["moloo.bundle"],
353
+ };
354
+
355
+ function isCompoundExtension(ext: string): boolean {
356
+ const withoutLeadingDot = ext.startsWith(".") ? ext.slice(1) : ext;
357
+ return withoutLeadingDot.includes(".");
358
+ }
359
+
360
+ function scoreMatcher(
361
+ file: {
362
+ name: string;
363
+ storageMode?: string;
364
+ contentType: string;
365
+ schema?: string | null;
366
+ },
367
+ matcher: FileAppearanceMatcher,
368
+ ): number {
369
+ if (matcher.storageMode && file.storageMode && matcher.storageMode !== file.storageMode) {
370
+ return 0;
371
+ }
372
+ if (matcher.schema && file.schema && matcher.schema === file.schema) {
373
+ return 400;
374
+ }
375
+ const lowerName = file.name.toLowerCase();
376
+ if (matcher.extensions && matcher.extensions.length > 0) {
377
+ let bestExt = "";
378
+ for (const rawExt of matcher.extensions) {
379
+ const ext = rawExt.toLowerCase();
380
+ if (lowerName.endsWith(ext) && lowerName.length > ext.length) {
381
+ if (ext.length > bestExt.length) bestExt = ext;
382
+ }
383
+ }
384
+ if (bestExt.length > 0) {
385
+ return (isCompoundExtension(bestExt) ? 300 : 200) + bestExt.length;
386
+ }
387
+ }
388
+ const mime = normalizeMimeType(file.contentType);
389
+ if (matcher.mimeTypes && matcher.mimeTypes.length > 0 && mime) {
390
+ for (const rawTarget of matcher.mimeTypes) {
391
+ const target = rawTarget.trim().toLowerCase();
392
+ if (target === mime) return 150;
393
+ if (target.endsWith("/*")) {
394
+ const prefix = target.slice(0, -1);
395
+ if (mime.startsWith(prefix)) return 100;
396
+ }
397
+ }
398
+ }
399
+ return 0;
400
+ }
401
+
402
+ /**
403
+ * Resolve cosmetic display metadata (`label`, `color`, `icon`) for a file from
404
+ * installed editor matchers, falling back to first-party `$schema`/extension
405
+ * rules and OS-style MIME family defaults.
406
+ */
407
+ export function resolveFileAppearance(
408
+ file: {
409
+ name: string;
410
+ storageMode?: string;
411
+ contentType: string;
412
+ schema?: string | null;
413
+ },
414
+ matchers: readonly FileAppearanceMatcher[] = [],
415
+ ): FileAppearance {
416
+ if (file.name === ".keep" || normalizeMimeType(file.contentType) === "application/x-directory") {
417
+ return PATTERN_APPEARANCE_MAP["moloo.folder"];
418
+ }
419
+
420
+ const classified = classifyFile({
421
+ fileName: file.name,
422
+ contentType: file.contentType,
423
+ });
424
+ const effectiveSchema = file.schema ?? classified.defaultSchema;
425
+ const effectiveFile = {
426
+ ...file,
427
+ storageMode: file.storageMode ?? classified.storageMode,
428
+ contentType: normalizeMimeType(file.contentType) || classified.contentType,
429
+ schema: effectiveSchema,
430
+ };
431
+
432
+ let bestScore = 0;
433
+ let bestMatcher: FileAppearanceMatcher | null = null;
434
+ for (const matcher of matchers) {
435
+ const score = scoreMatcher(effectiveFile, matcher);
436
+ if (score > bestScore && (matcher.label || matcher.icon || matcher.color)) {
437
+ bestScore = score;
438
+ bestMatcher = matcher;
439
+ }
440
+ }
441
+
442
+ if (effectiveSchema && FIRST_PARTY_SCHEMA_APPEARANCE[effectiveSchema]) {
443
+ const base = FIRST_PARTY_SCHEMA_APPEARANCE[effectiveSchema]!;
444
+ // Only let a matcher override a first-party schema/compound-extension appearance
445
+ // when it matched at schema (>= 400) or compound-extension (>= 300) specificity,
446
+ // never on a generic .json or application/json rule.
447
+ const override = bestScore >= 300 ? bestMatcher : null;
448
+ return {
449
+ label: override?.label ?? base.label,
450
+ color: override?.color ?? base.color,
451
+ icon: override?.icon ?? base.icon,
452
+ };
453
+ }
454
+
455
+ const lowerName = file.name.toLowerCase();
456
+ const mime = effectiveFile.contentType;
457
+ if (
458
+ mime === "text/markdown" ||
459
+ lowerName.endsWith(".md") ||
460
+ lowerName.endsWith(".markdown")
461
+ ) {
462
+ const md = PATTERN_APPEARANCE_MAP["moloo.markdown"];
463
+ const override = bestScore >= 150 ? bestMatcher : null;
464
+ return {
465
+ label: override?.label ?? md.label,
466
+ color: override?.color ?? md.color,
467
+ icon: override?.icon ?? md.icon,
468
+ };
469
+ }
470
+
471
+ const fallbackKind = patternForContentType(mime);
472
+ const fallback = PATTERN_APPEARANCE_MAP[fallbackKind];
473
+ return {
474
+ label: bestMatcher?.label ?? fallback.label,
475
+ color: bestMatcher?.color ?? fallback.color,
476
+ icon: bestMatcher?.icon ?? fallback.icon,
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Resolve cosmetic appearance for either a pattern/kind identifier (`moloo.*`)
482
+ * or an asset summary with `name`, `storageMode`, `contentType`, and `schema`.
483
+ * When file fields (`storageMode`, `schema`, or `contentType` + `name`) are
484
+ * present, they take precedence over a legacy `type` string.
485
+ */
486
+ export function resolvePatternAppearance(
487
+ input:
488
+ | string
489
+ | {
490
+ type?: string;
491
+ name?: string;
492
+ storageMode?: StorageMode;
493
+ contentType?: string | null;
494
+ schema?: string | null;
495
+ },
496
+ ): FileAppearance {
497
+ if (typeof input === "string") {
498
+ return Object.hasOwn(PATTERN_APPEARANCE_MAP, input)
499
+ ? PATTERN_APPEARANCE_MAP[input as KnownPatternId]
500
+ : UNKNOWN_FILE_APPEARANCE;
501
+ }
502
+ if (
503
+ input.storageMode ||
504
+ input.schema ||
505
+ (input.contentType && input.name)
506
+ ) {
507
+ return resolveFileAppearance({
508
+ name: input.name ?? "",
509
+ storageMode: input.storageMode,
510
+ contentType: input.contentType ?? "application/octet-stream",
511
+ schema: input.schema ?? null,
512
+ });
513
+ }
514
+ if (input.type && Object.hasOwn(PATTERN_APPEARANCE_MAP, input.type)) {
515
+ return PATTERN_APPEARANCE_MAP[input.type as KnownPatternId];
516
+ }
517
+ return UNKNOWN_FILE_APPEARANCE;
518
+ }