Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
/**
* Media Provider Exports
*
* Public API for media providers.
*/
// Types
export type {
MediaProviderDescriptor,
MediaProviderCapabilities,
MediaListOptions,
MediaListResult,
MediaProviderItem,
MediaUploadInput,
EmbedOptions,
EmbedResult,
ImageEmbed,
VideoEmbed,
AudioEmbed,
ComponentEmbed,
MediaProvider,
CreateMediaProviderFn,
MediaValue,
ThumbnailOptions,
} from "./types.js";
export { mediaItemToValue } from "./types.js";
export { normalizeMediaValue } from "./normalize.js";
export { generatePlaceholder, type PlaceholderData } from "./placeholder.js";
// Built-in providers
export { localMedia, type LocalMediaConfig } from "./local.js";

View File

@@ -0,0 +1,213 @@
/**
* Local Media Provider Runtime
*
* This is the runtime implementation loaded by the entrypoint.
* It wraps the existing MediaRepository and storage adapter.
*
* Note: This provider is special because it needs access to the database
* and storage adapter. The createMediaProvider function receives these
* via the config object, injected by the runtime.
*/
import type { Kysely } from "kysely";
import { MediaRepository } from "../database/repositories/media.js";
import type { Database } from "../database/types.js";
import type { Storage } from "../index.js";
import type {
CreateMediaProviderFn,
MediaProvider,
MediaListOptions,
MediaProviderItem,
MediaValue,
EmbedResult,
EmbedOptions,
} from "./types.js";
export interface LocalMediaRuntimeConfig {
enabled?: boolean;
// These are injected by the runtime, not from user config
db?: Kysely<Database>;
storage?: Storage;
}
/**
* Create the local media provider
*/
export const createMediaProvider: CreateMediaProviderFn<LocalMediaRuntimeConfig> = (config) => {
const { db, storage } = config;
if (!db) {
throw new Error("Local media provider requires database connection");
}
const repo = new MediaRepository(db);
const provider: MediaProvider = {
async list(options: MediaListOptions) {
const result = await repo.findMany({
cursor: options.cursor,
limit: options.limit,
mimeType: options.mimeType,
// TODO: Add search support when capabilities.search is true
});
return {
items: result.items.map((item) => ({
id: item.id,
filename: item.filename,
mimeType: item.mimeType,
size: item.size ?? undefined,
width: item.width ?? undefined,
height: item.height ?? undefined,
alt: item.alt ?? undefined,
previewUrl: `/_emdash/api/media/file/${item.storageKey}`,
meta: {
storageKey: item.storageKey,
caption: item.caption,
blurhash: item.blurhash,
dominantColor: item.dominantColor,
},
})),
nextCursor: result.nextCursor,
};
},
async get(id: string) {
const item = await repo.findById(id);
if (!item) return null;
return {
id: item.id,
filename: item.filename,
mimeType: item.mimeType,
size: item.size ?? undefined,
width: item.width ?? undefined,
height: item.height ?? undefined,
alt: item.alt ?? undefined,
previewUrl: `/_emdash/api/media/file/${item.storageKey}`,
meta: {
storageKey: item.storageKey,
caption: item.caption,
blurhash: item.blurhash,
dominantColor: item.dominantColor,
},
};
},
async upload(_input) {
if (!storage) {
throw new Error("Storage not configured for local media provider");
}
// This is handled by the existing media upload endpoint
// The provider interface is used by external providers
// For local, we delegate to the existing system
throw new Error("Local upload should use /_emdash/api/media endpoint");
},
async delete(id: string) {
const item = await repo.findById(id);
if (!item) return;
// Delete from storage if available
if (storage) {
try {
await storage.delete(item.storageKey);
} catch {
// Ignore storage deletion errors
}
}
await repo.delete(id);
},
getEmbed(value: MediaValue, _options?: EmbedOptions): EmbedResult {
const storageKey =
typeof value.meta?.storageKey === "string" ? value.meta.storageKey : value.id;
const src = `/_emdash/api/media/file/${storageKey}`;
const mimeType = value.mimeType || "";
// Determine embed type based on MIME type
if (mimeType.startsWith("image/")) {
return {
type: "image",
src,
width: value.width,
height: value.height,
alt: value.alt,
};
}
if (mimeType.startsWith("video/")) {
return {
type: "video",
src,
width: value.width,
height: value.height,
controls: true,
preload: "metadata",
};
}
if (mimeType.startsWith("audio/")) {
return {
type: "audio",
src,
controls: true,
preload: "metadata",
};
}
// Fallback: treat as image (for unknown types)
return {
type: "image",
src,
width: value.width,
height: value.height,
alt: value.alt,
};
},
getThumbnailUrl(id: string, _mimeType?: string) {
// For local media, return the file URL
return `/_emdash/api/media/file/${id}`;
},
};
return provider;
};
/**
* Helper to convert a MediaRepository item to MediaProviderItem
*/
export function repoItemToProviderItem(item: {
id: string;
filename: string;
mimeType: string;
size: number | null;
width: number | null;
height: number | null;
alt: string | null;
caption: string | null;
storageKey: string;
blurhash: string | null;
dominantColor: string | null;
}): MediaProviderItem {
return {
id: item.id,
filename: item.filename,
mimeType: item.mimeType,
size: item.size ?? undefined,
width: item.width ?? undefined,
height: item.height ?? undefined,
alt: item.alt ?? undefined,
previewUrl: `/_emdash/api/media/file/${item.storageKey}`,
meta: {
storageKey: item.storageKey,
caption: item.caption,
blurhash: item.blurhash,
dominantColor: item.dominantColor,
},
};
}

View File

@@ -0,0 +1,46 @@
/**
* Local Media Provider
*
* The built-in media provider that wraps the current storage adapter and media database.
* This is the default provider and is available unless explicitly disabled.
*/
import type { MediaProviderDescriptor } from "./types.js";
export interface LocalMediaConfig {
/** Whether the local provider is enabled (default true) */
enabled?: boolean;
}
/**
* Local media provider configuration
*
* @example
* ```ts
* import { localMedia } from "emdash/media";
*
* emdash({
* mediaProviders: [
* localMedia(), // Uses defaults
* // or: localMedia({ enabled: false }) to disable
* ],
* })
* ```
*/
export function localMedia(config: LocalMediaConfig = {}): MediaProviderDescriptor {
return {
id: "local",
name: "Library",
icon: "📁",
entrypoint: "emdash/media/local-runtime",
capabilities: {
browse: true,
search: false, // TODO: Add search support
upload: true,
delete: true,
},
config: {
enabled: config.enabled ?? true,
},
};
}

View File

@@ -0,0 +1,190 @@
/**
* Media Value Normalization
*
* Normalizes media field values into a consistent shape regardless of
* creation path (seed scripts, media picker, WP import, URL input).
*
* Called at content create/update time when a media provider is available,
* filling in missing dimensions, storageKey, mimeType, and filename from
* the provider's `get()` method.
*/
import type { MediaProvider, MediaProviderItem, MediaValue } from "./types.js";
export const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
const URL_PATTERN = /^https?:\/\//;
/**
* Normalize a media field value into a consistent MediaValue shape.
*
* - `null`/`undefined` → `null`
* - Bare URL string → `{ provider: "external", id: "", src: url }`
* - Bare internal media URL → resolved via local provider's `get()`
* - Object with `provider` + `id` → enriched with missing fields from provider
*/
export async function normalizeMediaValue(
value: unknown,
getProvider: (id: string) => MediaProvider | undefined,
): Promise<MediaValue | null> {
if (value == null) return null;
// Bare string URL
if (typeof value === "string") {
return normalizeStringUrl(value, getProvider);
}
// Not an object — can't normalize
if (!isRecord(value)) return null;
// Must have at least an id to be a valid media value
if (!("id" in value) && !("src" in value)) return null;
const provider = (typeof value.provider === "string" ? value.provider : undefined) || "local";
const id = typeof value.id === "string" ? value.id : "";
// External URLs — return as-is, no server-side dimension detection
if (provider === "external") {
return recordToMediaValue(value);
}
// Build the base value from the input
const result: MediaValue = { ...recordToMediaValue(value), provider };
// For local media, strip `src` — it's derived at display time from storageKey
if (provider === "local") {
delete result.src;
}
// Determine if we need to call the provider
const needsDimensions = result.width == null || result.height == null;
const needsStorageKey = provider === "local" && !result.meta?.storageKey;
const needsFileInfo = !result.mimeType || !result.filename;
const needsLookup = needsDimensions || needsStorageKey || needsFileInfo;
if (!needsLookup || !id) return result;
// Try to enrich from provider
const mediaProvider = getProvider(provider);
if (!mediaProvider?.get) return result;
let providerItem: MediaProviderItem | null;
try {
providerItem = await mediaProvider.get(id);
} catch {
return result;
}
if (!providerItem) return result;
return mergeProviderData(result, providerItem);
}
function normalizeStringUrl(
url: string,
getProvider: (id: string) => MediaProvider | undefined,
): Promise<MediaValue | null> {
// Internal media URL — try to resolve via local provider
if (url.startsWith(INTERNAL_MEDIA_PREFIX)) {
return resolveInternalUrl(url, getProvider);
}
// External HTTP(S) URL
if (URL_PATTERN.test(url)) {
return Promise.resolve({
provider: "external",
id: "",
src: url,
});
}
// Unrecognized string — treat as external
return Promise.resolve({
provider: "external",
id: "",
src: url,
});
}
async function resolveInternalUrl(
url: string,
getProvider: (id: string) => MediaProvider | undefined,
): Promise<MediaValue> {
const storageKey = url.slice(INTERNAL_MEDIA_PREFIX.length);
const localProvider = getProvider("local");
if (!localProvider?.get) {
return { provider: "external", id: "", src: url };
}
let item: MediaProviderItem | null;
try {
item = await localProvider.get(storageKey);
} catch {
return { provider: "external", id: "", src: url };
}
if (!item) {
return { provider: "external", id: "", src: url };
}
return {
provider: "local",
id: item.id,
filename: item.filename,
mimeType: item.mimeType,
width: item.width,
height: item.height,
alt: item.alt,
meta: item.meta,
};
}
/**
* Merge provider data into an existing MediaValue, preserving caller-supplied fields.
* Caller `alt` takes priority over provider `alt` (per-usage, not per-image).
*/
function mergeProviderData(existing: MediaValue, item: MediaProviderItem): MediaValue {
const result = { ...existing };
// Fill missing dimensions
if (result.width == null && item.width != null) result.width = item.width;
if (result.height == null && item.height != null) result.height = item.height;
// Fill missing file info
if (!result.filename && item.filename) result.filename = item.filename;
if (!result.mimeType && item.mimeType) result.mimeType = item.mimeType;
// Fill missing alt (provider alt is fallback, not override)
if (!result.alt && item.alt) result.alt = item.alt;
// Fill missing meta (merge, don't replace)
if (item.meta) {
result.meta = { ...item.meta, ...result.meta };
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Extract known MediaValue fields from a runtime-checked record.
* Avoids unsafe `as MediaValue` cast by reading each property explicitly.
*/
function recordToMediaValue(obj: Record<string, unknown>): MediaValue {
const result: MediaValue = {
id: typeof obj.id === "string" ? obj.id : "",
};
if (typeof obj.provider === "string") result.provider = obj.provider;
if (typeof obj.src === "string") result.src = obj.src;
if (typeof obj.previewUrl === "string") result.previewUrl = obj.previewUrl;
if (typeof obj.filename === "string") result.filename = obj.filename;
if (typeof obj.mimeType === "string") result.mimeType = obj.mimeType;
if (typeof obj.width === "number") result.width = obj.width;
if (typeof obj.height === "number") result.height = obj.height;
if (typeof obj.alt === "string") result.alt = obj.alt;
if (isRecord(obj.meta)) result.meta = obj.meta;
return result;
}

View File

@@ -0,0 +1,181 @@
/**
* Image Placeholder Generation
*
* Generates blurhash and dominant color from image buffers for LQIP support.
* Decodes images via jpeg-js (pure JS) and upng-js (pure JS, uses pako for
* deflate). No Node-specific dependencies — works in Workers and Node SSR.
*/
import { encode } from "blurhash";
import { imageSize } from "image-size";
export interface PlaceholderData {
blurhash: string;
dominantColor: string;
}
const SUPPORTED_TYPES: Record<string, "jpeg" | "png"> = {
"image/jpeg": "jpeg",
"image/jpg": "jpeg",
"image/png": "png",
};
/** Max width for blurhash input. Encode is O(w*h*components), so downsample first. */
const MAX_ENCODE_WIDTH = 32;
/** Max decoded RGBA size (32 MB). Images exceeding this skip placeholder generation. */
const MAX_DECODED_BYTES = 32 * 1024 * 1024;
interface DecodedImage {
width: number;
height: number;
data: Uint8Array;
}
/**
* Decode a JPEG buffer into raw RGBA pixel data.
*/
async function decodeJpeg(buffer: Uint8Array): Promise<DecodedImage> {
const { decode } = await import("jpeg-js");
const result = decode(buffer, { useTArray: true });
return { width: result.width, height: result.height, data: result.data };
}
/**
* Decode a PNG buffer into raw RGBA pixel data.
* Uses upng-js (pure JS with pako deflate) — no Node zlib dependency.
*/
async function decodePng(buffer: Uint8Array): Promise<DecodedImage> {
// @ts-expect-error -- upng-js has no type declarations
const UPNG = (await import("upng-js")).default;
const img = UPNG.decode(buffer.buffer);
// toRGBA8 returns an array of frames; take the first frame
const frames: ArrayBuffer[] = UPNG.toRGBA8(img);
const rgba = new Uint8Array(frames[0]);
return { width: img.width, height: img.height, data: rgba };
}
/**
* Extract the dominant color from RGBA pixel data.
* Simple average of all non-transparent pixels.
*/
function extractDominantColor(data: Uint8Array, width: number, height: number): string {
let r = 0;
let g = 0;
let b = 0;
let count = 0;
const len = width * height * 4;
for (let i = 0; i < len; i += 4) {
const a = data[i + 3];
if (a < 128) continue; // skip mostly-transparent pixels
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
if (count === 0) return "rgb(0,0,0)";
const avgR = Math.round(r / count);
const avgG = Math.round(g / count);
const avgB = Math.round(b / count);
return `rgb(${avgR},${avgG},${avgB})`;
}
/**
* Read image dimensions from headers without decoding pixel data.
*/
function getImageDimensions(buffer: Uint8Array): { width: number; height: number } | null {
try {
const result = imageSize(buffer);
if (result.width != null && result.height != null) {
return { width: result.width, height: result.height };
}
return null;
} catch {
return null;
}
}
/**
* Generate blurhash and dominant color from an image buffer.
* Returns null for non-image MIME types or on failure.
*
* @param dimensions - Optional pre-known dimensions. Used as a fallback when
* image-size cannot parse the buffer (e.g. truncated headers). When the
* decoded size (width * height * 4) exceeds MAX_DECODED_BYTES, placeholder
* generation is skipped to avoid OOM on memory-constrained runtimes.
*/
export async function generatePlaceholder(
buffer: Uint8Array,
mimeType: string,
dimensions?: { width: number; height: number },
): Promise<PlaceholderData | null> {
const format = SUPPORTED_TYPES[mimeType];
if (!format) return null;
try {
// Safety net: skip decode if the image would exceed the memory budget
const dims = getImageDimensions(buffer) ?? dimensions;
if (dims && dims.width * dims.height * 4 > MAX_DECODED_BYTES) {
return null;
}
const imageData = format === "jpeg" ? await decodeJpeg(buffer) : await decodePng(buffer);
const { width, height, data } = imageData;
if (width === 0 || height === 0) return null;
// Downsample for blurhash encoding if needed
let encodePixels: Uint8ClampedArray;
let encodeWidth: number;
let encodeHeight: number;
if (width > MAX_ENCODE_WIDTH) {
const scale = MAX_ENCODE_WIDTH / width;
encodeWidth = MAX_ENCODE_WIDTH;
encodeHeight = Math.max(1, Math.round(height * scale));
encodePixels = downsample(data, width, height, encodeWidth, encodeHeight);
} else {
encodeWidth = width;
encodeHeight = height;
encodePixels = new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength);
}
const blurhash = encode(encodePixels, encodeWidth, encodeHeight, 4, 3);
const dominantColor = extractDominantColor(data, width, height);
return { blurhash, dominantColor };
} catch {
return null;
}
}
/**
* Nearest-neighbor downsample of RGBA pixel data.
*/
function downsample(
src: Uint8Array,
srcW: number,
srcH: number,
dstW: number,
dstH: number,
): Uint8ClampedArray {
const dst = new Uint8ClampedArray(dstW * dstH * 4);
for (let y = 0; y < dstH; y++) {
const srcY = Math.floor((y * srcH) / dstH);
for (let x = 0; x < dstW; x++) {
const srcX = Math.floor((x * srcW) / dstW);
const srcIdx = (srcY * srcW + srcX) * 4;
const dstIdx = (y * dstW + x) * 4;
dst[dstIdx] = src[srcIdx]!;
dst[dstIdx + 1] = src[srcIdx + 1]!;
dst[dstIdx + 2] = src[srcIdx + 2]!;
dst[dstIdx + 3] = src[srcIdx + 3]!;
}
}
return dst;
}

View File

@@ -0,0 +1,78 @@
/**
* Media Provider Loader
*
* Lazy-loads media providers from virtual module for frontend rendering.
* Similar pattern to getDb() - providers are loaded on first use and cached.
*
* This allows EmDashMedia component to render media without requiring
* the full EmDash runtime to be initialized.
*/
import type { MediaProvider, MediaProviderCapabilities } from "./types.js";
/**
* Media provider entry from virtual module
*/
interface MediaProviderEntry {
id: string;
name: string;
icon?: string;
capabilities: MediaProviderCapabilities;
createProvider: (ctx: Record<string, unknown>) => MediaProvider;
}
// Cached provider entries from virtual module
let virtualMediaProviders: MediaProviderEntry[] | undefined;
// Cached provider instances (shared across calls)
const mediaProviderInstances = new Map<string, MediaProvider>();
/**
* Load media providers from virtual module
*/
async function loadMediaProviders(): Promise<void> {
if (virtualMediaProviders === undefined) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - virtual module
const providersModule = await import("virtual:emdash/media-providers");
virtualMediaProviders = providersModule.mediaProviders || [];
}
}
/**
* Get a media provider by ID.
*
* Used by EmDashMedia component for frontend rendering.
* Providers are lazy-loaded from virtual module and cached.
*
* @example
* ```ts
* const provider = await getMediaProvider("cloudflare-images");
* if (provider) {
* const embed = provider.getEmbed(mediaValue, { width: 800 });
* }
* ```
*/
export async function getMediaProvider(providerId: string): Promise<MediaProvider | undefined> {
// Check cache first
const cached = mediaProviderInstances.get(providerId);
if (cached) {
return cached;
}
// Load media providers from virtual module
await loadMediaProviders();
// Find the provider entry
const entry = virtualMediaProviders?.find((p) => p.id === providerId);
if (!entry) {
return undefined;
}
// Create the provider instance
// For providers that don't need db/storage (like Cloudflare Images),
// they'll get empty context and use env vars directly
const provider = entry.createProvider({});
mediaProviderInstances.set(providerId, provider);
return provider;
}

View File

@@ -0,0 +1,32 @@
/**
* Thumbnail sizing for client-side placeholder generation.
*
* When the browser generates a thumbnail to send to the server for blurhash
* generation, the thumbnail dimensions must fit within a bounded box. Naively
* fixing one dimension and deriving the other from the aspect ratio can
* explode for extreme aspect ratios (e.g. a 100×840000 image would produce a
* 64×537600 canvas), defeating the purpose of the thumbnail.
*/
/** Max dimension (px) for client-generated upload thumbnails. */
export const THUMBNAIL_MAX_DIMENSION = 64;
/**
* Compute thumbnail dimensions that fit within a THUMBNAIL_MAX_DIMENSION box,
* preserving aspect ratio. Both output dimensions are clamped to at least 1.
* Never upscales (scale is capped at 1).
*/
export function computeThumbnailSize(
width: number,
height: number,
): { width: number; height: number } {
if (width <= 0 || height <= 0) {
return { width: 1, height: 1 };
}
const maxDim = Math.max(width, height);
const scale = Math.min(1, THUMBNAIL_MAX_DIMENSION / maxDim);
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}

View File

@@ -0,0 +1,279 @@
/**
* Media Provider Types
*
* Media providers are pluggable sources for browsing, uploading, and embedding media.
* They enable integration with external services (Unsplash, Cloudinary, Mux, etc.)
* alongside the built-in local media library.
*/
/**
* Serializable media provider configuration descriptor
* Returned by provider config functions (e.g., unsplash(), mux())
*/
export interface MediaProviderDescriptor<TConfig = Record<string, unknown>> {
/** Unique identifier, used in MediaValue.provider */
id: string;
/** Display name for admin UI */
name: string;
/** Icon for tab UI (emoji or URL) */
icon?: string;
/** Module path exporting createMediaProvider function */
entrypoint: string;
/** Optional React component module for custom admin UI */
adminModule?: string;
/** Capability flags determine UI behavior */
capabilities: MediaProviderCapabilities;
/** Serializable config passed to createMediaProvider at runtime */
config: TConfig;
}
/**
* Provider capabilities determine what UI elements to show
*/
export interface MediaProviderCapabilities {
/** Can list/browse media */
browse: boolean;
/** Supports text search */
search: boolean;
/** Can upload new media */
upload: boolean;
/** Can delete media */
delete: boolean;
}
/**
* Options for listing media
*/
export interface MediaListOptions {
/** Pagination cursor */
cursor?: string;
/** Max items to return (default 20) */
limit?: number;
/** Search query (if capabilities.search is true) */
query?: string;
/** Filter by MIME type prefix, e.g., "image/", "video/" */
mimeType?: string;
}
/**
* Result from listing media
*/
export interface MediaListResult {
items: MediaProviderItem[];
nextCursor?: string;
}
/**
* A media item as returned by a provider
* This is the provider's view of the item, before it's selected
*/
export interface MediaProviderItem {
/** Provider-specific ID */
id: string;
/** Original filename */
filename: string;
/** MIME type */
mimeType: string;
/** File size in bytes (if known) */
size?: number;
/** Dimensions (for images/video) */
width?: number;
height?: number;
/** Accessibility text */
alt?: string;
/** Preview URL for admin UI thumbnail */
previewUrl?: string;
/** Provider-specific metadata */
meta?: Record<string, unknown>;
}
/**
* Input for uploading media
*/
export interface MediaUploadInput {
file: File;
filename: string;
alt?: string;
}
/**
* Options for generating embed
*/
export interface EmbedOptions {
/** Desired width (provider may use for optimization) */
width?: number;
/** Desired height */
height?: number;
/** Image format preference */
format?: "webp" | "avif" | "jpeg" | "png" | "auto";
}
/**
* Embed result types
*/
export type EmbedResult = ImageEmbed | VideoEmbed | AudioEmbed | ComponentEmbed;
export interface ImageEmbed {
type: "image";
src: string;
srcset?: string;
sizes?: string;
width?: number;
height?: number;
alt?: string;
/** Base URL without transforms, for responsive image generation */
cdnBaseUrl?: string;
/** For providers with URL-based transforms (Cloudinary, imgix) */
getSrc?: (opts: { width?: number; height?: number; format?: string }) => string;
}
export interface VideoEmbed {
type: "video";
/** Single source URL */
src?: string;
/** Multiple sources for format fallback */
sources?: Array<{ src: string; type: string }>;
/** Poster/thumbnail image */
poster?: string;
width?: number;
height?: number;
/** Player controls */
controls?: boolean;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
playsinline?: boolean;
preload?: "none" | "metadata" | "auto";
crossorigin?: "anonymous" | "use-credentials";
}
export interface AudioEmbed {
type: "audio";
src?: string;
sources?: Array<{ src: string; type: string }>;
controls?: boolean;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
preload?: "none" | "metadata" | "auto";
}
export interface ComponentEmbed {
type: "component";
/** Package to import from, e.g., "@mux/player-react" */
package: string;
/** Named export (default export if not specified) */
export?: string;
/** Props to pass to the component */
props: Record<string, unknown>;
}
/**
* Options for thumbnail generation
*/
export interface ThumbnailOptions {
/** Desired width */
width?: number;
/** Desired height */
height?: number;
}
/**
* Runtime media provider interface
* Implemented by provider entrypoints
*/
export interface MediaProvider {
/**
* List/search media items
*/
list(options: MediaListOptions): Promise<MediaListResult>;
/**
* Get a single item by ID (optional, for refresh/validation)
*/
get?(id: string): Promise<MediaProviderItem | null>;
/**
* Upload new media (if capabilities.upload is true)
*/
upload?(input: MediaUploadInput): Promise<MediaProviderItem>;
/**
* Delete media (if capabilities.delete is true)
*/
delete?(id: string): Promise<void>;
/**
* Get embed information for rendering this media item
* Called at runtime when rendering content
*/
getEmbed(value: MediaValue, options?: EmbedOptions): Promise<EmbedResult> | EmbedResult;
/**
* Get a thumbnail URL for admin display
* For images: returns a resized image URL
* For videos: returns a poster/thumbnail URL
*/
getThumbnailUrl?(id: string, mimeType?: string, options?: ThumbnailOptions): string;
}
/**
* Function signature for provider entrypoint modules
*/
export type CreateMediaProviderFn<TConfig = Record<string, unknown>> = (
config: TConfig,
) => MediaProvider;
/**
* Media value stored in content fields
* This is what gets persisted when media is selected
*
* For backwards compatibility:
* - `provider` defaults to "local" if not specified
* - `src` is supported for legacy data or external URLs
*/
export interface MediaValue {
/** Provider ID, e.g., "local", "unsplash", "mux" (defaults to "local") */
provider?: string;
/** Provider-specific item ID */
id: string;
/** Direct URL (for local media or legacy data) */
src?: string;
/** Preview URL for admin display (external providers) */
previewUrl?: string;
/** Cached metadata for display without runtime lookup */
filename?: string;
mimeType?: string;
width?: number;
height?: number;
alt?: string;
/** Provider-specific data needed for embedding */
meta?: Record<string, unknown>;
}
/**
* Convert a MediaProviderItem to a MediaValue for storage
*/
export function mediaItemToValue(providerId: string, item: MediaProviderItem): MediaValue {
return {
provider: providerId,
id: item.id,
filename: item.filename,
mimeType: item.mimeType,
width: item.width,
height: item.height,
alt: item.alt,
meta: item.meta,
};
}

View File

@@ -0,0 +1,78 @@
/**
* Public media URL resolution.
*
* Used at render time by the Image components to decide whether a storage
* key should be served from the configured `publicUrl` (R2 custom domain,
* S3 CDN) or through the internal `/_emdash/api/media/file/{key}` route.
*/
import type { Storage } from "../storage/types.js";
import { INTERNAL_MEDIA_PREFIX } from "./normalize.js";
// Keys accepted by the public-URL rewrite: the `{ulid}{ext}` shape produced by
// the upload pipeline, with letters, digits, dots, dashes, and underscores.
// Slashes, `?`, `#`, and `%` are rejected so attacker-controlled content in a
// portable-text `asset.url` cannot traverse or reroute on the CDN origin.
const SAFE_STORAGE_KEY = /^[A-Za-z0-9._-]+$/;
/**
* Resolve the public URL for a locally stored media key. Returns an empty
* string when no key is given. When a storage adapter is supplied, defers to
* `storage.getPublicUrl()`; otherwise returns the internal proxy route.
*/
export function resolvePublicMediaUrl(
storage: Storage | null | undefined,
storageKey: string,
): string {
if (!storageKey) return "";
if (storage) return storage.getPublicUrl(storageKey);
return `/_emdash/api/media/file/${storageKey}`;
}
/**
* Build the `getPublicMediaUrl` closure attached to `Astro.locals.emdash`.
* Shared by the anonymous fast path and the full-runtime path in middleware.
*
* @internal
*/
export function createPublicMediaUrlResolver(
storage: Storage | null | undefined,
): (key: string) => string {
return (key) => resolvePublicMediaUrl(storage, key);
}
/** Input shape for {@link buildRenderMediaUrl}. */
export interface RenderMediaRef {
/** Storage key with extension (the canonical shape from the upload pipeline). */
storageKey?: string;
/** Pre-baked URL (either an internal proxy URL or an external URL). */
url?: string;
/** Bare media id (ULID without extension); only the internal proxy can look this up. */
id?: string;
}
/**
* Build a render-time media URL. Prefers `storageKey`, then rewrites an
* internal `url` via `resolve`, then falls back to the internal proxy for a
* bare `id`. External URLs and non-matching internal-looking URLs pass
* through untouched. Returns `""` when nothing usable is present.
*
* @internal
*/
export function buildRenderMediaUrl(
resolve: ((key: string) => string) | undefined,
ref: RenderMediaRef,
): string {
const { storageKey, url, id } = ref;
if (storageKey) {
return resolve ? resolve(storageKey) : `${INTERNAL_MEDIA_PREFIX}${storageKey}`;
}
if (url) {
if (resolve && url.startsWith(INTERNAL_MEDIA_PREFIX)) {
const key = url.slice(INTERNAL_MEDIA_PREFIX.length);
if (SAFE_STORAGE_KEY.test(key)) return resolve(key);
}
return url;
}
if (id) return `${INTERNAL_MEDIA_PREFIX}${id}`;
return "";
}