145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
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<T extends WrnexusPackageManifest>(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<string>();
|
|
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";
|
|
}
|
|
}
|