import { extname } from "node:path"; import type { ClientRuntimeDefinition, PackageAssetDefinition, PackageStyleDefinition, WrnexusPackageManifest, } from "./types.ts"; const ID_PATTERN = /^[a-z0-9@][a-z0-9@/._-]*$/i; export function assertContributionId(kind: string, id: string): void { if (!id || !ID_PATTERN.test(id) || id.includes("..")) { throw new Error(`WRN-PLUGIN-${kind.toUpperCase()}-ID: invalid id '${id}'.`); } } export function defaultClientRuntimePath(id: string): string { assertContributionId("runtime", id); return `/__wrnexus/assets/${encodeURIComponent(id.replace(/^@/, "").replace(/[/.@]+/g, "-"))}.js`; } export function defaultPackageAssetPath(asset: PackageAssetDefinition): string { assertContributionId("asset", asset.id); const extension = asset.entry ? extname(asset.entry) : ""; const suffix = extension || extensionForContentType(asset.contentType); const id = encodeURIComponent(asset.id.replace(/^@/, "").replace(/[/.@]+/g, "-")); return `/__wrnexus/assets/${id}${suffix}`; } function extensionForContentType(contentType?: string): string { if (!contentType) return ""; if (contentType.includes("javascript")) return ".js"; if (contentType.includes("css")) return ".css"; if (contentType.includes("json")) return ".json"; if (contentType.includes("svg")) return ".svg"; if (contentType.includes("wav")) return ".wav"; if (contentType.includes("png")) return ".png"; return ""; } export function normalizeClientRuntime( runtime: ClientRuntimeDefinition, ): ClientRuntimeDefinition & { publicPath: string; type: "module" | "script"; load: "eager" | "defer" | "idle"; singleton: boolean; } { assertContributionId("runtime", runtime.id); if (!runtime.entry && runtime.source === undefined) { throw new Error(`WRN-PLUGIN-RUNTIME-SOURCE: runtime '${runtime.id}' needs entry or source.`); } const publicPath = runtime.publicPath ?? defaultClientRuntimePath(runtime.id); if (!publicPath.startsWith("/") || publicPath.includes("..")) { throw new Error(`WRN-PLUGIN-RUNTIME-PATH: runtime '${runtime.id}' has an unsafe publicPath.`); } return { ...runtime, publicPath, type: runtime.type ?? "module", load: runtime.load ?? "defer", inject: runtime.inject ?? "body-end", singleton: runtime.singleton ?? true, }; } export function normalizePackageAsset( asset: PackageAssetDefinition, ): PackageAssetDefinition & { publicPath: string } { assertContributionId("asset", asset.id); if (!asset.entry && asset.source === undefined) { throw new Error(`WRN-PLUGIN-ASSET-SOURCE: asset '${asset.id}' needs entry or source.`); } const publicPath = asset.publicPath ?? defaultPackageAssetPath(asset); if (!publicPath.startsWith("/") || publicPath.includes("..")) { throw new Error(`WRN-PLUGIN-ASSET-PATH: asset '${asset.id}' has an unsafe publicPath.`); } return { ...asset, publicPath }; } export function definePackageManifest(manifest: T): T { if (manifest.name) assertContributionId("package", manifest.name); for (const runtime of manifest.clientRuntimes ?? []) normalizeClientRuntime(runtime); for (const asset of manifest.assets ?? []) normalizePackageAsset(asset); validateStyleIds(manifest.styles ?? []); for (const route of manifest.routes ?? []) { if (!route.path.startsWith("/") || route.path.includes("..") || !route.entry) { throw new Error(`WRN-PLUGIN-ROUTE: invalid ${route.kind} route '${route.path}'.`); } } for (const migration of manifest.migrations ?? []) { assertContributionId("migration", migration.id); if (!migration.entry && migration.source === undefined) { throw new Error( `WRN-PLUGIN-MIGRATION-SOURCE: migration '${migration.id}' needs entry or source.`, ); } } return manifest; } export function validateStyleIds(styles: readonly PackageStyleDefinition[]): void { const seen = new Set(); for (const style of styles) { assertContributionId("style", style.id); if (seen.has(style.id)) throw new Error(`WRN-PLUGIN-STYLE-DUPLICATE: ${style.id}`); seen.add(style.id); if (!style.entry && !style.source) { throw new Error(`WRN-PLUGIN-STYLE-SOURCE: style '${style.id}' needs entry or source.`); } } } export function contentTypeForPath(path: string): string { const extension = extname(path).toLowerCase(); switch (extension) { case ".js": case ".mjs": return "text/javascript; charset=utf-8"; case ".css": return "text/css; charset=utf-8"; case ".json": return "application/json; charset=utf-8"; case ".svg": return "image/svg+xml; charset=utf-8"; case ".png": return "image/png"; case ".jpg": case ".jpeg": return "image/jpeg"; case ".webp": return "image/webp"; case ".wav": return "audio/wav"; case ".mp3": return "audio/mpeg"; case ".woff": return "font/woff"; case ".woff2": return "font/woff2"; default: return "application/octet-stream"; } }