import * as fs from 'node:fs'; import * as path from 'node:path'; type JsonRecord = Record; export interface ILoadedMatrixWebappManifest { readonly packageDir: string; readonly packageName: string; readonly manifestPath: string; readonly distDir: string; readonly entryFile: string; readonly appName: string; readonly displayName?: string; readonly icon?: string; readonly navOrder?: number; readonly description?: string; readonly shells?: readonly string[]; } export function loadMatrixWebappManifest(packageDir: string): ILoadedMatrixWebappManifest | null { const resolvedPackageDir = path.resolve(packageDir); const manifestPath = path.join(resolvedPackageDir, 'matrix.json'); if (!fs.existsSync(manifestPath)) { return null; } const manifest = readJsonObject(manifestPath, 'matrix.json'); const webapp = asRecord(manifest.webapp); if (!webapp) { return null; } const packageName = readRequiredString( manifest.name, `matrix.json in ${resolvedPackageDir} requires a non-empty "name"`, ); const distDir = path.resolve( resolvedPackageDir, readOptionalString(webapp.distDir) ?? 'dist', ); const entryFile = readOptionalString(webapp.entry) ?? 'index.html'; const appName = readOptionalString(webapp.appName) ?? packageName.replace(/^@[^/]+\//, ''); const displayName = readOptionalString(webapp.displayName); const icon = readOptionalString(webapp.icon); const navOrder = readOptionalNumber(webapp.navOrder); const description = readOptionalString(webapp.description); const shells = readStringArray(webapp.shells); return { packageDir: resolvedPackageDir, packageName, manifestPath, distDir, entryFile, appName, ...(displayName ? { displayName } : {}), ...(icon ? { icon } : {}), ...(typeof navOrder === 'number' ? { navOrder } : {}), ...(description ? { description } : {}), ...(shells.length > 0 ? { shells } : {}), }; } function readJsonObject(filePath: string, label: string): JsonRecord { try { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); if (!isRecord(parsed)) { throw new Error(`${label} must contain a JSON object`); } return parsed; } catch (error) { throw new Error(`Failed to parse ${label}: ${error instanceof Error ? error.message : String(error)}`); } } function readRequiredString(value: unknown, message: string): string { const resolved = readOptionalString(value); if (!resolved) { throw new Error(message); } return resolved; } function readOptionalString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function readOptionalNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } function readStringArray(value: unknown): readonly string[] { if (!Array.isArray(value)) { return []; } const entries = value .map((entry) => readOptionalString(entry)) .filter((entry): entry is string => typeof entry === 'string'); return [...new Set(entries)]; } function isRecord(value: unknown): value is JsonRecord { return typeof value === 'object' && value !== null && !Array.isArray(value); } function asRecord(value: unknown): JsonRecord | undefined { return isRecord(value) ? value : undefined; }