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,178 @@
/**
* EmDash Noto Sans font provider
*
* A custom Astro font provider that wraps Google Fonts to resolve
* multiple Noto Sans families (Latin, Arabic, JP, etc.) under a
* single logical font entry. This lets all @font-face blocks share
* the same font-family name, so the browser picks the right file
* per character via unicode-range.
*
* Without this, registering "Noto Sans" and "Noto Sans Arabic" as
* separate font entries on the same cssVariable triggers an Astro
* warning and the last entry overwrites the first.
*/
import { fontProviders } from "astro/config";
/**
* All subset names used by Google Fonts CSS responses.
* Passed when resolving extra script families so the unifont
* provider doesn't filter out any faces.
*/
const ALL_GOOGLE_SUBSETS = [
"arabic",
"armenian",
"bengali",
"chinese-simplified",
"chinese-traditional",
"chinese-hongkong",
"cyrillic",
"cyrillic-ext",
"devanagari",
"ethiopic",
"farsi",
"georgian",
"greek",
"greek-ext",
"gujarati",
"gurmukhi",
"hebrew",
"japanese",
"kannada",
"khmer",
"korean",
"lao",
"latin",
"latin-ext",
"malayalam",
"math",
"myanmar",
"oriya",
"sinhala",
"symbols",
"tamil",
"telugu",
"thai",
"tibetan",
"vietnamese",
];
/**
* Known Noto Sans and Sans script families on Google Fonts.
* Maps user-friendly script names to Google Fonts family names.
*/
const NOTO_SCRIPT_FAMILIES: Record<string, string> = {
arabic: "Noto Sans Arabic",
armenian: "Noto Sans Armenian",
bengali: "Noto Sans Bengali",
"chinese-simplified": "Noto Sans SC",
"chinese-traditional": "Noto Sans TC",
"chinese-hongkong": "Noto Sans HK",
devanagari: "Noto Sans Devanagari",
ethiopic: "Noto Sans Ethiopic",
farsi: "Vazirmatn",
georgian: "Noto Sans Georgian",
gujarati: "Noto Sans Gujarati",
gurmukhi: "Noto Sans Gurmukhi",
hebrew: "Noto Sans Hebrew",
japanese: "Noto Sans JP",
kannada: "Noto Sans Kannada",
khmer: "Noto Sans Khmer",
korean: "Noto Sans KR",
lao: "Noto Sans Lao",
malayalam: "Noto Sans Malayalam",
myanmar: "Noto Sans Myanmar",
oriya: "Noto Sans Oriya",
sinhala: "Noto Sans Sinhala",
tamil: "Noto Sans Tamil",
telugu: "Noto Sans Telugu",
thai: "Noto Sans Thai",
tibetan: "Noto Sans Tibetan",
};
export interface NotoSansProviderOptions {
/**
* Additional Noto Sans script families to include.
* Use script names like "arabic", "japanese", "chinese-simplified".
*
* @see {@link NOTO_SCRIPT_FAMILIES} for the full list of supported scripts.
*/
scripts?: string[];
}
// Use ReturnType to get the provider type without importing it directly.
// The Astro FontProvider type is not part of the public API surface.
type GoogleProvider = ReturnType<typeof fontProviders.google>;
/**
* Create a font provider that resolves Noto Sans plus additional
* script-specific Noto families from Google Fonts, all under one
* font-family name.
*/
export function notoSans(options?: NotoSansProviderOptions): GoogleProvider {
// Create a single Google provider instance to share initialization
const googleProvider = fontProviders.google();
return {
name: "emdash-noto",
async init(context) {
await googleProvider.init?.(context);
},
async resolveFont(resolveFontOptions) {
// Resolve the base Noto Sans (Latin, Cyrillic, Greek, etc.)
const base = await googleProvider.resolveFont(resolveFontOptions);
const baseFonts = base?.fonts ?? [];
if (!options?.scripts?.length) {
return base;
}
// Collect subset names already covered by the base font so we
// can filter out duplicate faces from extra script families.
// e.g. Noto Sans Arabic includes latin/latin-ext faces that
// would otherwise override the base Noto Sans latin faces.
const baseSubsets = new Set(baseFonts.map((f) => f.meta?.subset).filter(Boolean));
// Resolve additional script families
const extraFonts = await Promise.all(
options.scripts.map(async (script) => {
const family = NOTO_SCRIPT_FAMILIES[script];
if (!family) {
// Silently skip subset names that are already covered
// by the base Noto Sans font (latin, cyrillic, etc.)
if (ALL_GOOGLE_SUBSETS.includes(script)) {
return undefined;
}
console.warn(
`[emdash] Unknown Noto Sans script "${script}". ` +
`Available: ${Object.keys(NOTO_SCRIPT_FAMILIES).join(", ")}`,
);
return undefined;
}
return googleProvider.resolveFont({
...resolveFontOptions,
familyName: family,
// Pass all known subset names so the unifont provider
// doesn't filter out any faces. Each script family
// only returns faces for its own subsets anyway.
subsets: ALL_GOOGLE_SUBSETS,
});
}),
);
// Merge, dropping faces from extra fonts that duplicate base subsets
const extraFaces = extraFonts.flatMap((r) =>
(r?.fonts ?? []).filter((f) => !f.meta?.subset || !baseSubsets.has(f.meta.subset)),
);
return {
fonts: [...baseFonts, ...extraFaces],
};
},
};
}
/** Get the list of available Noto Sans script names */
export function getAvailableNotoScripts(): string[] {
return Object.keys(NOTO_SCRIPT_FAMILIES);
}

View File

@@ -0,0 +1,414 @@
/**
* EmDash Astro Integration
*
* This integration:
* - Injects the admin shell route at /_emdash/admin/[...path].astro
* - Sets up REST API endpoints under /_emdash/api/*
* - Configures middleware to provide database and manifest
*
* NOTE: This file is for build-time only. Runtime utilities are in runtime.ts
* to avoid bundling Node.js-only code into the production build.
*/
import type { AstroIntegration, AstroIntegrationLogger } from "astro";
import { validateAllowedOrigins, validateOriginShape } from "../../auth/allowed-origins.js";
import type { ResolvedPlugin } from "../../plugins/types.js";
import { local } from "../storage/adapters.js";
import { notoSans } from "./font-provider.js";
import {
injectCoreRoutes,
injectBuiltinAuthRoutes,
injectAuthProviderRoutes,
injectMcpRoute,
} from "./routes.js";
import type { EmDashConfig, PluginDescriptor } from "./runtime.js";
import { createViteConfig } from "./vite-config.js";
// Re-export runtime types and functions
export type {
EmDashConfig,
PluginDescriptor,
SandboxedPluginDescriptor,
ResolvedPlugin,
} from "./runtime.js";
export { getStoredConfig } from "./runtime.js";
/** Default storage: Local filesystem in .emdash directory */
const DEFAULT_STORAGE = local({
directory: "./.emdash/uploads",
baseUrl: "/_emdash/api/media/file",
});
// Terminal formatting
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;
/** Print the EmDash startup banner */
function printBanner(_logger: AstroIntegrationLogger): void {
const banner = `
${bold(cyan("— E M D A S H —"))}
`;
console.log(banner);
}
/** Print route injection summary */
function printRoutesSummary(_logger: AstroIntegrationLogger): void {
console.log(`\n ${dim("")} Admin UI ${cyan("/_emdash/admin")}`);
console.log(` ${dim("")} API ${cyan("/_emdash/api/*")}`);
console.log("");
}
/**
* Create the EmDash Astro integration
*/
export function emdash(config: EmDashConfig = {}): AstroIntegration {
// Apply defaults
const resolvedConfig: EmDashConfig = {
...config,
storage: config.storage ?? DEFAULT_STORAGE,
};
// Validate marketplace URL
if (resolvedConfig.marketplace) {
const url = resolvedConfig.marketplace;
try {
const parsed = new URL(url);
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.protocol !== "https:" && !isLocalhost) {
throw new Error(
`Marketplace URL must use HTTPS (got ${parsed.protocol}). ` +
`Only localhost URLs are allowed over HTTP.`,
);
}
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`Invalid marketplace URL: "${url}"`, { cause: e });
}
throw e;
}
if (!resolvedConfig.sandboxRunner) {
throw new Error(
"Marketplace requires `sandboxRunner` to be configured. " +
"Marketplace plugins run in sandboxed V8 isolates.",
);
}
}
// Validate siteUrl if provided in astro.config.mjs.
// Env-var fallback (EMDASH_SITE_URL / SITE_URL) is handled at runtime by
// getPublicOrigin() in api/public-url.ts — NOT here — so Docker images built
// without a domain can pick it up at container start via process.env.
if (resolvedConfig.siteUrl) {
const raw = resolvedConfig.siteUrl;
try {
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`siteUrl must be http or https (got ${parsed.protocol})`);
}
// Always store origin-normalized value (no path) — security invariant L-1
resolvedConfig.siteUrl = parsed.origin;
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`Invalid siteUrl: "${raw}"`, { cause: e });
}
throw e;
}
}
// Validate config.allowedOrigins shape at startup (per-entry rules: parseable,
// http(s), no trailing dots, no empty labels). The siteUrl-dependent rules
// (Rule A: requires siteUrl; Rule B: must be a subdomain of siteUrl) are
// deferred to runtime when config.siteUrl is absent — EMDASH_SITE_URL may
// supply it post-build, just like the env-var fallback for siteUrl above.
// When config.siteUrl IS present, run the full validator here for fail-fast.
if (resolvedConfig.allowedOrigins?.length) {
const tagged = resolvedConfig.allowedOrigins.map((origin) => ({
origin,
source: "config.allowedOrigins" as const,
}));
resolvedConfig.allowedOrigins = resolvedConfig.siteUrl
? validateAllowedOrigins(resolvedConfig.siteUrl, tagged)
: validateOriginShape(tagged);
}
// Plugin descriptors from config
const pluginDescriptors = resolvedConfig.plugins ?? [];
const sandboxedDescriptors = resolvedConfig.sandboxed ?? [];
// Validate all plugin descriptors
for (const descriptor of [...pluginDescriptors, ...sandboxedDescriptors]) {
// Standard-format plugins can't use features that require trusted mode
if (descriptor.format === "standard") {
if (descriptor.adminEntry) {
throw new Error(
`Plugin "${descriptor.id}" is standard format but declares adminEntry. ` +
`Standard plugins use Block Kit for admin UI, not React components. ` +
`Remove adminEntry or change format to "native".`,
);
}
if (descriptor.componentsEntry) {
throw new Error(
`Plugin "${descriptor.id}" is standard format but declares componentsEntry. ` +
`Portable Text block components require native format. ` +
`Remove componentsEntry or change format to "native".`,
);
}
}
}
// Validate: non-standard plugins cannot be placed in sandboxed: []
for (const descriptor of sandboxedDescriptors) {
if (descriptor.format !== "standard") {
throw new Error(
`Plugin "${descriptor.id}" uses the native format and cannot be placed in ` +
`\`sandboxed: []\`. Native plugins can only run in \`plugins: []\`. ` +
`To sandbox this plugin, convert it to the standard format.`,
);
}
}
// Resolved plugins (populated at build time by importing entrypoints)
let _resolvedPlugins: ResolvedPlugin[] = [];
// Serialize config for virtual module (database/storage/auth - plugins handled separately)
// i18n is populated in astro:config:setup from astroConfig.i18n
const serializableConfig: Record<string, unknown> = {
database: resolvedConfig.database,
storage: resolvedConfig.storage,
auth: resolvedConfig.auth,
authProviders: resolvedConfig.authProviders,
marketplace: resolvedConfig.marketplace,
siteUrl: resolvedConfig.siteUrl,
trustedProxyHeaders: resolvedConfig.trustedProxyHeaders,
maxUploadSize: resolvedConfig.maxUploadSize,
admin: resolvedConfig.admin,
};
// Determine auth mode for route injection
// Check if auth is an AuthDescriptor (has entrypoint) indicating external auth
const useExternalAuth = !!(resolvedConfig.auth && "entrypoint" in resolvedConfig.auth);
return {
name: "emdash",
hooks: {
"astro:config:setup": ({
injectRoute,
addMiddleware,
logger,
updateConfig,
config: astroConfig,
command,
}) => {
printBanner(logger);
// Extract i18n config from Astro config
// Astro locales can be strings OR { path, codes } objects — normalize to paths
if (astroConfig.i18n) {
const routing = astroConfig.i18n.routing;
serializableConfig.i18n = {
defaultLocale: astroConfig.i18n.defaultLocale,
locales: astroConfig.i18n.locales.map((l) => (typeof l === "string" ? l : l.path)),
fallback: astroConfig.i18n.fallback,
prefixDefaultLocale:
typeof routing === "object" ? (routing.prefixDefaultLocale ?? false) : false,
};
}
// Disable Astro's built-in checkOrigin -- EmDash's own CSRF
// layer (checkPublicCsrf in api/csrf.ts) handles origin
// validation with dual-origin support: it accepts both the
// internal origin AND the public origin from getPublicOrigin(),
// which resolves siteUrl from config or env vars at runtime.
// Astro's check can't do this because allowedDomains is baked
// at build time, which breaks Docker deployments where the
// domain is only known at container start via EMDASH_SITE_URL.
//
// When siteUrl is known at build time, also set allowedDomains
// so Astro.url reflects the public origin (helps user template
// code that reads Astro.url directly).
const securityConfig: Record<string, unknown> = {
checkOrigin: false,
...(resolvedConfig.siteUrl
? { allowedDomains: [{ hostname: new URL(resolvedConfig.siteUrl).hostname }] }
: {}),
};
// Inject default Noto Sans font for the admin UI.
// Uses the Astro Font API so fonts are downloaded at build time
// and self-hosted (no runtime CDN requests).
//
// The admin CSS references var(--font-emdash) with a system font
// fallback. Users can add extra script coverage (Arabic, CJK, etc.)
// by passing fonts.scripts in the emdash() config. The custom
// notoSans provider resolves all script families from Google Fonts
// under a single font-family name, so they stack via unicode-range.
const fontsConfig = resolvedConfig.fonts;
const emdashFonts =
fontsConfig === false
? []
: [
{
provider: notoSans({
scripts: fontsConfig?.scripts,
}),
name: "Noto Sans",
cssVariable: "--font-emdash",
weights: ["100 900" as const],
styles: ["normal" as const, "italic" as const],
subsets: [
"latin" as const,
"latin-ext" as const,
"cyrillic" as const,
"cyrillic-ext" as const,
"devanagari" as const,
"greek" as const,
"greek-ext" as const,
"vietnamese" as const,
],
fallbacks: ["ui-sans-serif", "system-ui", "sans-serif"],
},
];
updateConfig({
security: securityConfig,
// fonts is a valid AstroConfig key but may not be in the
// type definition for the minimum supported Astro version
...({ fonts: emdashFonts } as Record<string, unknown>),
vite: createViteConfig(
{
serializableConfig,
resolvedConfig,
pluginDescriptors,
astroConfig,
},
command,
),
});
// Inject all core routes
injectCoreRoutes(injectRoute);
// Inject routes from pluggable auth providers (authProviders config)
if (resolvedConfig.authProviders?.length) {
injectAuthProviderRoutes(injectRoute, resolvedConfig.authProviders);
}
// Inject passkey/oauth/magic-link routes unless transparent external auth is active
if (!useExternalAuth) {
injectBuiltinAuthRoutes(injectRoute);
}
// Inject MCP endpoint (always on — bearer-token-only, no cost if unused)
if (resolvedConfig.mcp !== false) {
injectMcpRoute(injectRoute);
}
// In playground mode, inject the playground middleware FIRST.
// It sets up a per-session DO database in ALS before anything
// else runs, so the runtime init middleware sees a real DB.
if (resolvedConfig.playground) {
addMiddleware({
entrypoint: resolvedConfig.playground.middlewareEntrypoint,
order: "pre",
});
}
// Add middleware to provide database and manifest
addMiddleware({
entrypoint: "emdash/middleware",
order: "pre",
});
// Add redirect middleware (runs after runtime init, before setup/auth)
addMiddleware({
entrypoint: "emdash/middleware/redirect",
order: "pre",
});
// Skip setup and auth in playground mode -- the playground middleware
// handles session creation and injects an anonymous admin user.
if (!resolvedConfig.playground) {
addMiddleware({
entrypoint: "emdash/middleware/setup",
order: "pre",
});
addMiddleware({
entrypoint: "emdash/middleware/auth",
order: "pre",
});
}
// Add request context middleware (runs after auth, on ALL routes)
// Sets up ALS-based context for query functions (edit mode, preview)
addMiddleware({
entrypoint: "emdash/middleware/request-context",
order: "pre",
});
printRoutesSummary(logger);
},
"astro:server:setup": ({ server, logger }) => {
// Generate types once the server is listening.
// The endpoint returns the types content; we write the file here
// (in Node) because workerd has no real filesystem access.
server.httpServer?.once("listening", async () => {
const { writeFile, readFile } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const address = server.httpServer?.address();
if (!address || typeof address === "string") return;
const port = address.port;
const typegenUrl = `http://localhost:${port}/_emdash/api/typegen`;
const outputPath = resolve(process.cwd(), "emdash-env.d.ts");
try {
const response = await fetch(typegenUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const body = await response.text().catch(() => "");
logger.warn(`Typegen failed: ${response.status} ${body.slice(0, 200)}`);
return;
}
const { data: result } = (await response.json()) as {
data: {
types: string;
hash: string;
collections: number;
};
};
// Only write if content changed
let needsWrite = true;
try {
const existing = await readFile(outputPath, "utf-8");
if (existing === result.types) needsWrite = false;
} catch {
// File doesn't exist yet
}
if (needsWrite) {
await writeFile(outputPath, result.types, "utf-8");
logger.info(`Generated emdash-env.d.ts (${result.collections} collections)`);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.warn(`Typegen failed: ${msg}`);
}
});
},
"astro:build:done": ({ logger }) => {
logger.info("Build complete");
},
},
};
}
export default emdash;

View File

@@ -0,0 +1,889 @@
/**
* Route Injection
*
* Defines and injects all EmDash routes into the Astro application.
*/
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Resolve path to a route file in the package
* Uses Node.js APIs - only call at build time
*/
function resolveRoute(route: string): string {
// Lazy initialization to avoid running Node.js code at import time
// This prevents issues when the module is bundled for Cloudflare Workers
const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
try {
// Try to resolve as package export
return require.resolve(`emdash/routes/${route}`);
} catch {
// Fallback to relative path (for development)
return resolve(__dirname, "../routes", route);
}
}
/** Route injection function type */
type InjectRoute = (route: { pattern: string; entrypoint: string }) => void;
/**
* Injects all core EmDash routes.
*/
export function injectCoreRoutes(injectRoute: InjectRoute): void {
// Inject admin shell route
injectRoute({
pattern: "/_emdash/admin/[...path]",
entrypoint: resolveRoute("admin.astro"),
});
// Inject API routes
injectRoute({
pattern: "/_emdash/api/manifest",
entrypoint: resolveRoute("api/manifest.ts"),
});
// Auth mode endpoint (public — used by the login page to pick the right UI)
injectRoute({
pattern: "/_emdash/api/auth/mode",
entrypoint: resolveRoute("api/auth/mode.ts"),
});
injectRoute({
pattern: "/_emdash/api/dashboard",
entrypoint: resolveRoute("api/dashboard.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]",
entrypoint: resolveRoute("api/content/[collection]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]",
entrypoint: resolveRoute("api/content/[collection]/[id].ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/revisions",
entrypoint: resolveRoute("api/content/[collection]/[id]/revisions.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/preview-url",
entrypoint: resolveRoute("api/content/[collection]/[id]/preview-url.ts"),
});
// Trash/restore routes
injectRoute({
pattern: "/_emdash/api/content/[collection]/trash",
entrypoint: resolveRoute("api/content/[collection]/trash.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/restore",
entrypoint: resolveRoute("api/content/[collection]/[id]/restore.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/permanent",
entrypoint: resolveRoute("api/content/[collection]/[id]/permanent.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/duplicate",
entrypoint: resolveRoute("api/content/[collection]/[id]/duplicate.ts"),
});
// Publishing routes
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/publish",
entrypoint: resolveRoute("api/content/[collection]/[id]/publish.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/unpublish",
entrypoint: resolveRoute("api/content/[collection]/[id]/unpublish.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/discard-draft",
entrypoint: resolveRoute("api/content/[collection]/[id]/discard-draft.ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/compare",
entrypoint: resolveRoute("api/content/[collection]/[id]/compare.ts"),
});
// i18n translation routes
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/translations",
entrypoint: resolveRoute("api/content/[collection]/[id]/translations.ts"),
});
// Scheduled publishing routes
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/schedule",
entrypoint: resolveRoute("api/content/[collection]/[id]/schedule.ts"),
});
// Revision management routes (for restore, etc.)
injectRoute({
pattern: "/_emdash/api/revisions/[revisionId]",
entrypoint: resolveRoute("api/revisions/[revisionId]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/revisions/[revisionId]/restore",
entrypoint: resolveRoute("api/revisions/[revisionId]/restore.ts"),
});
// Media API routes
injectRoute({
pattern: "/_emdash/api/media",
entrypoint: resolveRoute("api/media.ts"),
});
injectRoute({
pattern: "/_emdash/api/media/upload-url",
entrypoint: resolveRoute("api/media/upload-url.ts"),
});
injectRoute({
pattern: "/_emdash/api/media/file/[...key]",
entrypoint: resolveRoute("api/media/file/[...key].ts"),
});
injectRoute({
pattern: "/_emdash/api/media/[id]",
entrypoint: resolveRoute("api/media/[id].ts"),
});
injectRoute({
pattern: "/_emdash/api/media/[id]/confirm",
entrypoint: resolveRoute("api/media/[id]/confirm.ts"),
});
// Media provider routes
injectRoute({
pattern: "/_emdash/api/media/providers",
entrypoint: resolveRoute("api/media/providers/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/media/providers/[providerId]",
entrypoint: resolveRoute("api/media/providers/[providerId]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/media/providers/[providerId]/[itemId]",
entrypoint: resolveRoute("api/media/providers/[providerId]/[itemId].ts"),
});
// Import API routes
injectRoute({
pattern: "/_emdash/api/import/probe",
entrypoint: resolveRoute("api/import/probe.ts"),
});
injectRoute({
pattern: "/_emdash/api/import/wordpress/analyze",
entrypoint: resolveRoute("api/import/wordpress/analyze.ts"),
});
injectRoute({
pattern: "/_emdash/api/import/wordpress/prepare",
entrypoint: resolveRoute("api/import/wordpress/prepare.ts"),
});
injectRoute({
pattern: "/_emdash/api/import/wordpress/execute",
entrypoint: resolveRoute("api/import/wordpress/execute.ts"),
});
injectRoute({
pattern: "/_emdash/api/import/wordpress/media",
entrypoint: resolveRoute("api/import/wordpress/media.ts"),
});
injectRoute({
pattern: "/_emdash/api/import/wordpress/rewrite-urls",
entrypoint: resolveRoute("api/import/wordpress/rewrite-urls.ts"),
});
// WordPress Plugin (EmDash Exporter) direct import routes
injectRoute({
pattern: "/_emdash/api/import/wordpress-plugin/analyze",
entrypoint: resolveRoute("api/import/wordpress-plugin/analyze.ts"),
});
injectRoute({
pattern: "/_emdash/api/import/wordpress-plugin/execute",
entrypoint: resolveRoute("api/import/wordpress-plugin/execute.ts"),
});
injectRoute({
pattern: "/_emdash/api/import/wordpress-plugin/callback",
entrypoint: resolveRoute("api/import/wordpress-plugin/callback.ts"),
});
// Schema API routes
injectRoute({
pattern: "/_emdash/api/schema",
entrypoint: resolveRoute("api/schema/index.ts"),
});
// Typegen endpoint (dev-only)
injectRoute({
pattern: "/_emdash/api/typegen",
entrypoint: resolveRoute("api/typegen.ts"),
});
injectRoute({
pattern: "/_emdash/api/schema/collections",
entrypoint: resolveRoute("api/schema/collections/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/schema/collections/[slug]",
entrypoint: resolveRoute("api/schema/collections/[slug]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/schema/collections/[slug]/fields",
entrypoint: resolveRoute("api/schema/collections/[slug]/fields/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/schema/collections/[slug]/fields/reorder",
entrypoint: resolveRoute("api/schema/collections/[slug]/fields/reorder.ts"),
});
injectRoute({
pattern: "/_emdash/api/schema/collections/[slug]/fields/[fieldSlug]",
entrypoint: resolveRoute("api/schema/collections/[slug]/fields/[fieldSlug].ts"),
});
// Orphaned tables discovery
injectRoute({
pattern: "/_emdash/api/schema/orphans",
entrypoint: resolveRoute("api/schema/orphans/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/schema/orphans/[slug]",
entrypoint: resolveRoute("api/schema/orphans/[slug].ts"),
});
// Site settings route
injectRoute({
pattern: "/_emdash/api/settings",
entrypoint: resolveRoute("api/settings.ts"),
});
// Email settings route
injectRoute({
pattern: "/_emdash/api/settings/email",
entrypoint: resolveRoute("api/settings/email.ts"),
});
// Snapshot route (for DO preview database population)
injectRoute({
pattern: "/_emdash/api/snapshot",
entrypoint: resolveRoute("api/snapshot.ts"),
});
// Taxonomy API routes
injectRoute({
pattern: "/_emdash/api/taxonomies",
entrypoint: resolveRoute("api/taxonomies/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/taxonomies/[name]/terms",
entrypoint: resolveRoute("api/taxonomies/[name]/terms/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/taxonomies/[name]/terms/[slug]",
entrypoint: resolveRoute("api/taxonomies/[name]/terms/[slug].ts"),
});
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/terms/[taxonomy]",
entrypoint: resolveRoute("api/content/[collection]/[id]/terms/[taxonomy].ts"),
});
// Plugin management routes (under /admin to avoid conflict with plugin API routes)
injectRoute({
pattern: "/_emdash/api/admin/plugins",
entrypoint: resolveRoute("api/admin/plugins/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/[id]",
entrypoint: resolveRoute("api/admin/plugins/[id]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/[id]/enable",
entrypoint: resolveRoute("api/admin/plugins/[id]/enable.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/[id]/disable",
entrypoint: resolveRoute("api/admin/plugins/[id]/disable.ts"),
});
// Marketplace plugin routes
injectRoute({
pattern: "/_emdash/api/admin/plugins/marketplace",
entrypoint: resolveRoute("api/admin/plugins/marketplace/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/marketplace/[id]",
entrypoint: resolveRoute("api/admin/plugins/marketplace/[id]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/marketplace/[id]/icon",
entrypoint: resolveRoute("api/admin/plugins/marketplace/[id]/icon.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/marketplace/[id]/install",
entrypoint: resolveRoute("api/admin/plugins/marketplace/[id]/install.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/[id]/update",
entrypoint: resolveRoute("api/admin/plugins/[id]/update.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/[id]/uninstall",
entrypoint: resolveRoute("api/admin/plugins/[id]/uninstall.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/plugins/updates",
entrypoint: resolveRoute("api/admin/plugins/updates.ts"),
});
// Exclusive hooks admin routes
injectRoute({
pattern: "/_emdash/api/admin/hooks/exclusive",
entrypoint: resolveRoute("api/admin/hooks/exclusive/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/hooks/exclusive/[hookName]",
entrypoint: resolveRoute("api/admin/hooks/exclusive/[hookName].ts"),
});
// Theme marketplace routes
injectRoute({
pattern: "/_emdash/api/admin/themes/marketplace",
entrypoint: resolveRoute("api/admin/themes/marketplace/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/themes/marketplace/[id]",
entrypoint: resolveRoute("api/admin/themes/marketplace/[id]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/themes/marketplace/[id]/thumbnail",
entrypoint: resolveRoute("api/admin/themes/marketplace/[id]/thumbnail.ts"),
});
// Theme preview signing (local, not proxied)
injectRoute({
pattern: "/_emdash/api/themes/preview",
entrypoint: resolveRoute("api/themes/preview.ts"),
});
// User management routes
injectRoute({
pattern: "/_emdash/api/admin/users",
entrypoint: resolveRoute("api/admin/users/index.ts"),
});
// Bylines routes
injectRoute({
pattern: "/_emdash/api/admin/bylines",
entrypoint: resolveRoute("api/admin/bylines/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/bylines/[id]",
entrypoint: resolveRoute("api/admin/bylines/[id]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/users/[id]",
entrypoint: resolveRoute("api/admin/users/[id]/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/users/[id]/disable",
entrypoint: resolveRoute("api/admin/users/[id]/disable.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/users/[id]/enable",
entrypoint: resolveRoute("api/admin/users/[id]/enable.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/users/[id]/send-recovery",
entrypoint: resolveRoute("api/admin/users/[id]/send-recovery.ts"),
});
// API token admin routes
injectRoute({
pattern: "/_emdash/api/admin/api-tokens",
entrypoint: resolveRoute("api/admin/api-tokens/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/api-tokens/[id]",
entrypoint: resolveRoute("api/admin/api-tokens/[id].ts"),
});
// OAuth client admin routes
injectRoute({
pattern: "/_emdash/api/admin/oauth-clients",
entrypoint: resolveRoute("api/admin/oauth-clients/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/oauth-clients/[id]",
entrypoint: resolveRoute("api/admin/oauth-clients/[id].ts"),
});
// OAuth Device Flow routes
injectRoute({
pattern: "/_emdash/api/oauth/device/code",
entrypoint: resolveRoute("api/oauth/device/code.ts"),
});
injectRoute({
pattern: "/_emdash/api/oauth/device/token",
entrypoint: resolveRoute("api/oauth/device/token.ts"),
});
injectRoute({
pattern: "/_emdash/api/oauth/device/authorize",
entrypoint: resolveRoute("api/oauth/device/authorize.ts"),
});
injectRoute({
pattern: "/_emdash/api/oauth/token/refresh",
entrypoint: resolveRoute("api/oauth/token/refresh.ts"),
});
injectRoute({
pattern: "/_emdash/api/oauth/token/revoke",
entrypoint: resolveRoute("api/oauth/token/revoke.ts"),
});
// Auth discovery endpoint
injectRoute({
pattern: "/_emdash/.well-known/auth",
entrypoint: resolveRoute("api/well-known/auth.ts"),
});
// OAuth 2.1 Authorization Code flow routes
injectRoute({
pattern: "/_emdash/api/oauth/token",
entrypoint: resolveRoute("api/oauth/token.ts"),
});
injectRoute({
pattern: "/_emdash/oauth/authorize",
entrypoint: resolveRoute("api/oauth/authorize.ts"),
});
// OAuth discovery endpoints (RFC 9728, RFC 8414)
injectRoute({
pattern: "/.well-known/oauth-protected-resource",
entrypoint: resolveRoute("api/well-known/oauth-protected-resource.ts"),
});
injectRoute({
pattern: "/.well-known/oauth-authorization-server/_emdash",
entrypoint: resolveRoute("api/well-known/oauth-authorization-server.ts"),
});
// RFC 7591 Dynamic Client Registration
injectRoute({
pattern: "/_emdash/api/oauth/register",
entrypoint: resolveRoute("api/oauth/register.ts"),
});
// Plugin-defined API routes
// All plugin routes are handled by a single catch-all handler
injectRoute({
pattern: "/_emdash/api/plugins/[pluginId]/[...path]",
entrypoint: resolveRoute("api/plugins/[pluginId]/[...path].ts"),
});
// Menu API routes
injectRoute({
pattern: "/_emdash/api/menus",
entrypoint: resolveRoute("api/menus/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/menus/[name]",
entrypoint: resolveRoute("api/menus/[name].ts"),
});
injectRoute({
pattern: "/_emdash/api/menus/[name]/items",
entrypoint: resolveRoute("api/menus/[name]/items.ts"),
});
injectRoute({
pattern: "/_emdash/api/menus/[name]/reorder",
entrypoint: resolveRoute("api/menus/[name]/reorder.ts"),
});
// Widget area routes
injectRoute({
pattern: "/_emdash/api/widget-areas",
entrypoint: resolveRoute("api/widget-areas/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/widget-components",
entrypoint: resolveRoute("api/widget-components.ts"),
});
injectRoute({
pattern: "/_emdash/api/widget-areas/[name]",
entrypoint: resolveRoute("api/widget-areas/[name].ts"),
});
injectRoute({
pattern: "/_emdash/api/widget-areas/[name]/widgets",
entrypoint: resolveRoute("api/widget-areas/[name]/widgets.ts"),
});
injectRoute({
pattern: "/_emdash/api/widget-areas/[name]/widgets/[id]",
entrypoint: resolveRoute("api/widget-areas/[name]/widgets/[id].ts"),
});
injectRoute({
pattern: "/_emdash/api/widget-areas/[name]/reorder",
entrypoint: resolveRoute("api/widget-areas/[name]/reorder.ts"),
});
// Section routes
injectRoute({
pattern: "/_emdash/api/sections",
entrypoint: resolveRoute("api/sections/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/sections/[slug]",
entrypoint: resolveRoute("api/sections/[slug].ts"),
});
// Redirect routes
injectRoute({
pattern: "/_emdash/api/redirects",
entrypoint: resolveRoute("api/redirects/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/redirects/404s/summary",
entrypoint: resolveRoute("api/redirects/404s/summary.ts"),
});
injectRoute({
pattern: "/_emdash/api/redirects/404s",
entrypoint: resolveRoute("api/redirects/404s/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/redirects/[id]",
entrypoint: resolveRoute("api/redirects/[id].ts"),
});
// Search routes
injectRoute({
pattern: "/_emdash/api/search",
entrypoint: resolveRoute("api/search/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/search/suggest",
entrypoint: resolveRoute("api/search/suggest.ts"),
});
injectRoute({
pattern: "/_emdash/api/search/stats",
entrypoint: resolveRoute("api/search/stats.ts"),
});
injectRoute({
pattern: "/_emdash/api/search/rebuild",
entrypoint: resolveRoute("api/search/rebuild.ts"),
});
injectRoute({
pattern: "/_emdash/api/search/enable",
entrypoint: resolveRoute("api/search/enable.ts"),
});
// Comment routes (public)
injectRoute({
pattern: "/_emdash/api/comments/[collection]/[contentId]",
entrypoint: resolveRoute("api/comments/[collection]/[contentId]/index.ts"),
});
// Comment routes (admin)
injectRoute({
pattern: "/_emdash/api/admin/comments",
entrypoint: resolveRoute("api/admin/comments/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/comments/counts",
entrypoint: resolveRoute("api/admin/comments/counts.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/comments/bulk",
entrypoint: resolveRoute("api/admin/comments/bulk.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/comments/[id]/status",
entrypoint: resolveRoute("api/admin/comments/[id]/status.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/comments/[id]",
entrypoint: resolveRoute("api/admin/comments/[id].ts"),
});
// SEO routes (public, at site root)
injectRoute({
pattern: "/sitemap.xml",
entrypoint: resolveRoute("sitemap.xml.ts"),
});
injectRoute({
pattern: "/sitemap-[collection].xml",
entrypoint: resolveRoute("sitemap-[collection].xml.ts"),
});
injectRoute({
pattern: "/robots.txt",
entrypoint: resolveRoute("robots.txt.ts"),
});
// Setup wizard API routes
injectRoute({
pattern: "/_emdash/api/setup/status",
entrypoint: resolveRoute("api/setup/status.ts"),
});
injectRoute({
pattern: "/_emdash/api/setup",
entrypoint: resolveRoute("api/setup/index.ts"),
});
// Auth API routes
injectRoute({
pattern: "/_emdash/api/setup/admin",
entrypoint: resolveRoute("api/setup/admin.ts"),
});
injectRoute({
pattern: "/_emdash/api/setup/admin/verify",
entrypoint: resolveRoute("api/setup/admin-verify.ts"),
});
injectRoute({
pattern: "/_emdash/api/setup/dev-bypass",
entrypoint: resolveRoute("api/setup/dev-bypass.ts"),
});
injectRoute({
pattern: "/_emdash/api/setup/dev-reset",
entrypoint: resolveRoute("api/setup/dev-reset.ts"),
});
injectRoute({
pattern: "/_emdash/api/dev/emails",
entrypoint: resolveRoute("api/dev/emails.ts"),
});
// Current user endpoint (always available)
injectRoute({
pattern: "/_emdash/api/auth/me",
entrypoint: resolveRoute("api/auth/me.ts"),
});
// Logout is always available (though behavior differs by auth mode)
injectRoute({
pattern: "/_emdash/api/auth/logout",
entrypoint: resolveRoute("api/auth/logout.ts"),
});
}
/**
* Injects the MCP (Model Context Protocol) server route.
* Only injected when `mcp: true` is set in the EmDash config.
*/
export function injectMcpRoute(injectRoute: InjectRoute): void {
injectRoute({
pattern: "/_emdash/api/mcp",
entrypoint: resolveRoute("api/mcp.ts"),
});
}
/**
* Injects routes from pluggable auth providers.
*
* Each provider declares the routes it needs in its `AuthProviderDescriptor.routes` array.
* Routes are injected at build time so Vite can bundle them.
*/
export function injectAuthProviderRoutes(
injectRoute: InjectRoute,
providers: Array<{ routes?: Array<{ pattern: string; entrypoint: string }> }>,
): void {
for (const provider of providers) {
if (provider.routes) {
for (const route of provider.routes) {
injectRoute({
pattern: route.pattern,
entrypoint: route.entrypoint,
});
}
}
}
}
/**
* Injects passkey/oauth/magic-link auth routes.
* Only used when NOT using external auth.
*/
export function injectBuiltinAuthRoutes(injectRoute: InjectRoute): void {
// Passkey authentication routes
injectRoute({
pattern: "/_emdash/api/auth/passkey/options",
entrypoint: resolveRoute("api/auth/passkey/options.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/passkey/verify",
entrypoint: resolveRoute("api/auth/passkey/verify.ts"),
});
// Passkey management routes (authenticated users)
injectRoute({
pattern: "/_emdash/api/auth/passkey",
entrypoint: resolveRoute("api/auth/passkey/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/passkey/register/options",
entrypoint: resolveRoute("api/auth/passkey/register/options.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/passkey/register/verify",
entrypoint: resolveRoute("api/auth/passkey/register/verify.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/passkey/[id]",
entrypoint: resolveRoute("api/auth/passkey/[id].ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/dev-bypass",
entrypoint: resolveRoute("api/auth/dev-bypass.ts"),
});
// Invite routes
injectRoute({
pattern: "/_emdash/api/auth/invite",
entrypoint: resolveRoute("api/auth/invite/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/invite/accept",
entrypoint: resolveRoute("api/auth/invite/accept.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/invite/complete",
entrypoint: resolveRoute("api/auth/invite/complete.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/invite/register-options",
entrypoint: resolveRoute("api/auth/invite/register-options.ts"),
});
// Magic link routes
injectRoute({
pattern: "/_emdash/api/auth/magic-link/send",
entrypoint: resolveRoute("api/auth/magic-link/send.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/magic-link/verify",
entrypoint: resolveRoute("api/auth/magic-link/verify.ts"),
});
// OAuth routes
injectRoute({
pattern: "/_emdash/api/auth/oauth/[provider]",
entrypoint: resolveRoute("api/auth/oauth/[provider].ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/oauth/[provider]/callback",
entrypoint: resolveRoute("api/auth/oauth/[provider]/callback.ts"),
});
// Self-signup routes
injectRoute({
pattern: "/_emdash/api/auth/signup/request",
entrypoint: resolveRoute("api/auth/signup/request.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/signup/verify",
entrypoint: resolveRoute("api/auth/signup/verify.ts"),
});
injectRoute({
pattern: "/_emdash/api/auth/signup/complete",
entrypoint: resolveRoute("api/auth/signup/complete.ts"),
});
// Allowed domains admin routes (only relevant for passkey mode)
injectRoute({
pattern: "/_emdash/api/admin/allowed-domains",
entrypoint: resolveRoute("api/admin/allowed-domains/index.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/allowed-domains/[domain]",
entrypoint: resolveRoute("api/admin/allowed-domains/[domain].ts"),
});
}

View File

@@ -0,0 +1,512 @@
/**
* Runtime utilities for EmDash
*
* This file contains functions that are used at runtime (in middleware, routes, etc.)
* and must work in all environments including Cloudflare Workers.
*
* DO NOT import Node.js-only modules here (fs, path, module, etc.)
*/
import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js";
import type { DatabaseDescriptor } from "../../db/adapters.js";
import type { MediaProviderDescriptor } from "../../media/types.js";
import type { ResolvedPlugin } from "../../plugins/types.js";
import type { StorageDescriptor } from "../storage/types.js";
export type { ResolvedPlugin };
export type { MediaProviderDescriptor };
/**
* Admin page definition (copied from plugins/types to avoid circular deps)
*/
export interface PluginAdminPage {
path: string;
label: string;
icon?: string;
}
/**
* Dashboard widget definition (copied from plugins/types to avoid circular deps)
*/
export interface PluginDashboardWidget {
id: string;
size?: "full" | "half" | "third";
title?: string;
}
/**
* Plugin descriptor - returned by plugin factory functions
*
* Contains all static metadata needed for manifest and admin UI,
* plus the entrypoint for runtime instantiation.
*
* @example
* ```ts
* export function myPlugin(options?: MyPluginOptions): PluginDescriptor {
* return {
* id: "my-plugin",
* version: "1.0.0",
* entrypoint: "@my-org/emdash-plugin-foo",
* options: options ?? {},
* adminEntry: "@my-org/emdash-plugin-foo/admin",
* adminPages: [{ path: "/settings", label: "Settings" }],
* };
* }
* ```
*/
/**
* Storage collection declaration for sandboxed plugins
*/
export interface StorageCollectionDeclaration {
indexes?: string[];
uniqueIndexes?: string[];
}
export interface PluginDescriptor<TOptions = Record<string, unknown>> {
/** Unique plugin identifier */
id: string;
/** Plugin version (semver) */
version: string;
/** Module specifier to import (e.g., "@emdash-cms/plugin-api-test") */
entrypoint: string;
/**
* Options to pass to createPlugin(). Native format only.
* Standard-format plugins configure themselves via KV settings
* and Block Kit admin pages -- not constructor options.
*/
options?: TOptions;
/**
* Plugin format. Determines how the entrypoint is loaded:
* - `"standard"` -- exports `definePlugin({ hooks, routes })` as default.
* Wrapped with `adaptSandboxEntry` for in-process execution. Can run in both
* `plugins: []` (in-process) and `sandboxed: []` (isolate).
* - `"native"` -- exports `createPlugin(options)` returning a `ResolvedPlugin`.
* Can only run in `plugins: []`. Cannot be sandboxed or published to marketplace.
*
* Defaults to `"native"` when unset.
*
*/
format?: "standard" | "native";
/** Admin UI module specifier (e.g., "@emdash-cms/plugin-audit-log/admin") */
adminEntry?: string;
/** Module specifier for site-side Astro rendering components (must export `blockComponents`) */
componentsEntry?: string;
/** Admin pages for navigation */
adminPages?: PluginAdminPage[];
/** Dashboard widgets */
adminWidgets?: PluginDashboardWidget[];
// === Sandbox-specific fields (for sandboxed plugins) ===
/**
* Capabilities the plugin requests.
* For standard-format plugins, capabilities are enforced in both trusted and
* sandboxed modes via the PluginContextFactory.
*/
capabilities?: string[];
/**
* Allowed hosts for network:fetch capability
* Supports wildcards like "*.example.com"
*/
allowedHosts?: string[];
/**
* Storage collections the plugin declares
* Sandboxed plugins can only access declared collections.
*/
storage?: Record<string, StorageCollectionDeclaration>;
}
/**
* Sandboxed plugin descriptor - same format as PluginDescriptor
*
* These run in isolated V8 isolates via Worker Loader on Cloudflare.
* The `entrypoint` is resolved to a file and bundled at build time.
*/
export type SandboxedPluginDescriptor<TOptions = Record<string, unknown>> =
PluginDescriptor<TOptions>;
export interface EmDashConfig {
/**
* Database configuration
*
* Use one of the adapter functions:
* - `sqlite({ url: "file:./data.db" })` - Local SQLite
* - `libsql({ url: "...", authToken: "..." })` - Turso/libSQL
* - `d1({ binding: "DB" })` - Cloudflare D1
*
* @example
* ```ts
* import { sqlite } from "emdash/db";
*
* emdash({
* database: sqlite({ url: "file:./data.db" }),
* })
* ```
*/
database?: DatabaseDescriptor;
/**
* Storage configuration (for media)
*/
storage?: StorageDescriptor;
/**
* Trusted plugins to load (run in main isolate)
*
* @example
* ```ts
* import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
* import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier";
*
* emdash({
* plugins: [
* auditLogPlugin(),
* webhookNotifierPlugin({ url: "https://example.com/webhook" }),
* ],
* })
* ```
*/
plugins?: PluginDescriptor[];
/**
* Sandboxed plugins to load (run in isolated V8 isolates)
*
* Only works on Cloudflare with Worker Loader enabled.
* Uses the same format as `plugins` - the difference is where they run.
*
* @example
* ```ts
* import { untrustedPlugin } from "some-third-party-plugin";
*
* emdash({
* plugins: [trustedPlugin()], // runs in host
* sandboxed: [untrustedPlugin()], // runs in isolate
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
* })
* ```
*/
sandboxed?: SandboxedPluginDescriptor[];
/**
* Module that exports the sandbox runner factory.
* Required if using sandboxed plugins.
*
* @example
* ```ts
* emdash({
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
* })
* ```
*/
sandboxRunner?: string;
/**
* Authentication configuration
*
* Use an auth adapter function from a platform package:
* - `access({ teamDomain: "..." })` from `@emdash-cms/cloudflare`
*
* When an external auth provider is configured, passkey auth is disabled.
*
* @example
* ```ts
* import { access } from "@emdash-cms/cloudflare";
*
* emdash({
* auth: access({
* teamDomain: "myteam.cloudflareaccess.com",
* audience: "abc123...",
* roleMapping: {
* "Admins": 50,
* "Editors": 30,
* },
* }),
* })
* ```
*/
auth?: AuthDescriptor;
/**
* Pluggable auth providers (login methods on the login page).
*
* Auth providers appear as options alongside passkey on the login page
* and setup wizard. Any provider can be used to create the initial
* admin account. Passkey is built-in; providers listed here are additive.
*
* @example
* ```ts
* import { atproto } from "@emdash-cms/auth-atproto";
*
* emdash({
* authProviders: [atproto()],
* })
* ```
*/
authProviders?: AuthProviderDescriptor[];
/**
* MCP (Model Context Protocol) server endpoint.
*
* Exposes an MCP Streamable HTTP server at `/_emdash/api/mcp`
* that allows AI agents and tools to interact with the CMS using
* the standardized MCP protocol.
*
* Enabled by default. The endpoint requires bearer token auth, so
* it has no effect unless the user creates an API token and
* configures a client. Set to `false` to disable.
*
* @default true
*/
mcp?: boolean;
/**
* Plugin marketplace URL
*
* When set, enables the marketplace features: browse, install, update,
* and uninstall plugins from a remote marketplace.
*
* Must be an HTTPS URL in production, or localhost/127.0.0.1 in dev.
* Requires `sandboxRunner` to be configured (marketplace plugins run sandboxed).
*
* @example
* ```ts
* emdash({
* marketplace: "https://marketplace.emdashcms.com",
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
* })
* ```
*/
marketplace?: string;
/**
* Maximum allowed media file upload size in bytes.
*
* Applies to both direct multipart uploads and signed-URL uploads.
* When unset, defaults to 52_428_800 (50 MB).
*
* @example
* ```ts
* emdash({ maxUploadSize: 100 * 1024 * 1024 }) // 100 MB
* ```
*/
maxUploadSize?: number;
/**
* Public browser-facing origin for the site.
*
* Use when `Astro.url` / `request.url` do not match what users open — common with a
* **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop
* while the browser uses `https://`, which breaks WebAuthn, CSRF, OAuth, and redirect URLs.
*
* Set to the full origin users type in the address bar (no path), e.g.
* `https://mysite.example.com`. When not set, falls back to environment variables
* `EMDASH_SITE_URL` > `SITE_URL`, then to the request URL's origin.
*
* Replaces `passkeyPublicOrigin` (which only fixed passkeys).
*/
siteUrl?: string;
/**
* Additional origins accepted by passkey verification.
*
* When the same EmDash deployment is reachable under several hostnames sharing
* a registrable parent (e.g. `https://example.com` plus
* `https://preview.example.com`), the canonical `siteUrl` defines the `rpId`
* and the entries here are the *additional* origins from which assertions
* are accepted. Each entry must be the same hostname as `siteUrl` or a
* subdomain of it — WebAuthn requires `rpId` to be a registrable suffix of
* every origin.
*
* Merged at runtime with the `EMDASH_ALLOWED_ORIGINS` env var (comma-separated).
* Validation:
* - Config-declared entries are shape-checked at Astro startup.
* - Subdomain relationship to `siteUrl` is checked at startup when
* `siteUrl` is also config-declared, otherwise at first passkey
* verification (since `siteUrl` may come from `EMDASH_SITE_URL`).
*
* Mismatches throw with a source-attributed message naming
* `config.allowedOrigins` or `EMDASH_ALLOWED_ORIGINS`.
*
* @example
* ```ts
* emdash({
* siteUrl: "https://example.com",
* allowedOrigins: ["https://preview.example.com"],
* })
* ```
*/
allowedOrigins?: string[];
/*
* Headers to trust for client IP resolution when running behind a reverse
* proxy. The first header in this list that is present on the request
* wins. Applies to rate limiting for auth endpoints and comment
* submission.
*
* Common values:
* - `x-real-ip` — nginx, Caddy, Traefik
* - `fly-client-ip` — Fly.io
* - `x-forwarded-for` — generic (first entry is used)
*
* Only set this when you **control the reverse proxy**. Untrusted
* clients can set any header they like; trusting headers from an open
* network is an IP-spoofing vulnerability that defeats rate limiting.
*
* On Cloudflare the `cf` object on the request is used automatically —
* you normally don't need to set this. Leave unset (or empty) to
* preserve the default: IP is resolved only when the request came
* through Cloudflare's edge.
*
* Falls back to `EMDASH_TRUSTED_PROXY_HEADERS` env var (comma-separated)
* when this option is not set, so operators can configure at deploy
* time without touching the Astro config.
*/
trustedProxyHeaders?: string[];
/**
* Enable playground mode for ephemeral "try EmDash" sites.
*
* When set, the integration injects a playground middleware (order: "pre")
* that runs BEFORE the normal EmDash middleware chain. It creates an
* isolated Durable Object database per session, runs migrations, applies
* the seed, creates an anonymous admin user, and sets the DB in ALS.
* By the time the runtime middleware runs, the database is fully ready.
*
* Setup and auth middleware are skipped (the playground handles both).
*
* Requires `@emdash-cms/cloudflare` as a dependency and a DO binding
* in wrangler.jsonc.
*
* @example
* ```ts
* emdash({
* database: playgroundDatabase({ binding: "PLAYGROUND_DB" }),
* playground: {
* middlewareEntrypoint: "@emdash-cms/cloudflare/db/playground-middleware",
* },
* })
* ```
*/
playground?: {
/** Module path for the playground middleware. */
middlewareEntrypoint: string;
};
/**
* Media providers for browsing and uploading media
*
* The local media provider (using storage adapter) is available by default.
* Additional providers can be added for external services like Unsplash,
* Cloudinary, Mux, Cloudflare Images, etc.
*
* @example
* ```ts
* import { cloudflareImages, cloudflareStream } from "@emdash-cms/cloudflare";
* import { unsplash } from "@emdash-cms/provider-unsplash";
*
* emdash({
* mediaProviders: [
* cloudflareImages({ accountId: "..." }),
* cloudflareStream({ accountId: "..." }),
* unsplash({ accessKey: "..." }),
* ],
* })
* ```
*/
mediaProviders?: MediaProviderDescriptor[];
/**
* Admin UI font configuration.
*
* By default, EmDash loads Noto Sans via the Astro Font API, covering
* Latin, Latin Extended, Cyrillic, Cyrillic Extended, Greek, Greek
* Extended, Devanagari, and Vietnamese. Fonts are downloaded from
* Google at build time and self-hosted, so there are no runtime CDN
* requests.
*
* To add support for additional writing systems (Arabic, CJK, etc.),
* pass script names. EmDash resolves the matching Noto Sans variant
* from Google Fonts and merges all script faces under a single
* font-family, so the browser downloads only the glyphs it needs
* via unicode-range.
*
* Set to `false` to disable font injection entirely and use system fonts.
*
* @example
* ```ts
* // Add Arabic and Japanese support
* emdash({
* fonts: {
* scripts: ["arabic", "japanese"],
* },
* })
* ```
*
* @example
* ```ts
* // Disable web fonts entirely (use system fonts)
* emdash({
* fonts: false,
* })
* ```
*/
fonts?:
| false
| {
/**
* Additional Noto Sans script families to include.
*
* Available scripts: arabic, armenian, bengali, chinese-simplified,
* chinese-traditional, chinese-hongkong, devanagari, ethiopic, farsi,
* georgian, gujarati, gurmukhi, hebrew, japanese, kannada, khmer,
* korean, lao, malayalam, myanmar, oriya, sinhala, tamil, telugu,
* thai, tibetan.
*/
scripts?: string[];
};
/**
* Admin UI branding (white-labeling).
*
* Overrides the default EmDash logo and name in the admin panel.
* Use this to white-label the CMS for agency or enterprise deployments.
* These settings are separate from the public site settings (title, logo,
* favicon) which remain available for SEO and front-end use.
*
* @example
* ```ts
* emdash({
* admin: {
* logo: "/images/agency-logo.webp",
* siteName: "AgencyX CMS",
* favicon: "/favicon.ico",
* },
* })
* ```
*/
admin?: {
/** URL or path to a custom logo image for the admin UI (login page, sidebar). */
logo?: string;
/** Custom name displayed in the admin sidebar and browser tab. */
siteName?: string;
/** URL or path to a custom favicon for the admin panel. */
favicon?: string;
};
}
/**
* Get stored config from global
* This is set by the virtual module at build time
*/
export function getStoredConfig(): EmDashConfig | null {
return globalThis.__emdashConfig || null;
}
/**
* Set stored config in global
* Called by the integration at config time
*/
export function setStoredConfig(config: EmDashConfig): void {
globalThis.__emdashConfig = config;
}
// Declare global type
declare global {
// eslint-disable-next-line no-var
var __emdashConfig: EmDashConfig | undefined;
}

View File

@@ -0,0 +1,554 @@
/**
* Virtual Module Generators
*
* Functions that generate virtual module content for Vite.
* These modules statically import configured dependencies
* so Vite can properly resolve and bundle them.
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import type { AuthProviderDescriptor } from "../../auth/types.js";
import type { MediaProviderDescriptor } from "../../media/types.js";
import { defaultSeed } from "../../seed/default.js";
import type { PluginDescriptor } from "./runtime.js";
const TS_SOURCE_EXT_RE = /^\.(ts|tsx|mts|cts|jsx)$/;
/** Pattern to remove scoped package prefix from plugin ID */
const SCOPED_PREFIX_PATTERN = /^@[^/]+\/plugin-/;
/** Pattern to remove emdash-plugin- prefix from plugin ID */
const EMDASH_PREFIX_PATTERN = /^emdash-plugin-/;
// Virtual module IDs
export const VIRTUAL_CONFIG_ID = "virtual:emdash/config";
export const RESOLVED_VIRTUAL_CONFIG_ID = "\0" + VIRTUAL_CONFIG_ID;
export const VIRTUAL_DIALECT_ID = "virtual:emdash/dialect";
export const RESOLVED_VIRTUAL_DIALECT_ID = "\0" + VIRTUAL_DIALECT_ID;
export const VIRTUAL_STORAGE_ID = "virtual:emdash/storage";
export const RESOLVED_VIRTUAL_STORAGE_ID = "\0" + VIRTUAL_STORAGE_ID;
export const VIRTUAL_ADMIN_REGISTRY_ID = "virtual:emdash/admin-registry";
export const RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID = "\0" + VIRTUAL_ADMIN_REGISTRY_ID;
export const VIRTUAL_PLUGINS_ID = "virtual:emdash/plugins";
export const RESOLVED_VIRTUAL_PLUGINS_ID = "\0" + VIRTUAL_PLUGINS_ID;
export const VIRTUAL_SANDBOX_RUNNER_ID = "virtual:emdash/sandbox-runner";
export const RESOLVED_VIRTUAL_SANDBOX_RUNNER_ID = "\0" + VIRTUAL_SANDBOX_RUNNER_ID;
export const VIRTUAL_SANDBOXED_PLUGINS_ID = "virtual:emdash/sandboxed-plugins";
export const RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID = "\0" + VIRTUAL_SANDBOXED_PLUGINS_ID;
export const VIRTUAL_AUTH_ID = "virtual:emdash/auth";
export const RESOLVED_VIRTUAL_AUTH_ID = "\0" + VIRTUAL_AUTH_ID;
export const VIRTUAL_AUTH_PROVIDERS_ID = "virtual:emdash/auth-providers";
export const RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID = "\0" + VIRTUAL_AUTH_PROVIDERS_ID;
export const VIRTUAL_MEDIA_PROVIDERS_ID = "virtual:emdash/media-providers";
export const RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID = "\0" + VIRTUAL_MEDIA_PROVIDERS_ID;
export const VIRTUAL_BLOCK_COMPONENTS_ID = "virtual:emdash/block-components";
export const RESOLVED_VIRTUAL_BLOCK_COMPONENTS_ID = "\0" + VIRTUAL_BLOCK_COMPONENTS_ID;
export const VIRTUAL_SEED_ID = "virtual:emdash/seed";
export const RESOLVED_VIRTUAL_SEED_ID = "\0" + VIRTUAL_SEED_ID;
export const VIRTUAL_WAIT_UNTIL_ID = "virtual:emdash/wait-until";
export const RESOLVED_VIRTUAL_WAIT_UNTIL_ID = "\0" + VIRTUAL_WAIT_UNTIL_ID;
/**
* Generates the config virtual module.
*/
export function generateConfigModule(serializableConfig: Record<string, unknown>): string {
return `export default ${JSON.stringify(serializableConfig)};`;
}
/**
* Generates the dialect virtual module.
*
* Adapters that set `supportsRequestScope: true` on their descriptor are
* expected to export `createRequestScopedDb` from their runtime entrypoint;
* the generator re-exports it so middleware can ask for a per-request Kysely
* (used for D1 Sessions API, bookmark cookies, read-replica routing). Other
* adapters get a stub that returns null.
*/
export function generateDialectModule(opts: {
entrypoint?: string;
type?: string;
supportsRequestScope: boolean;
}): string {
const { entrypoint, supportsRequestScope } = opts;
if (!entrypoint) {
return [
`export const createDialect = undefined;`,
`export const dialectType = "sqlite";`,
`export const createRequestScopedDb = (_opts) => null;`,
].join("\n");
}
const type = opts.type ?? "sqlite";
if (supportsRequestScope) {
return `
import { createDialect as _createDialect } from "${entrypoint}";
export { createRequestScopedDb } from "${entrypoint}";
export const createDialect = _createDialect;
export const dialectType = ${JSON.stringify(type)};
`;
}
return `
import { createDialect as _createDialect } from "${entrypoint}";
export const createDialect = _createDialect;
export const dialectType = ${JSON.stringify(type)};
export const createRequestScopedDb = (_opts) => null;
`;
}
/**
* Generates the storage virtual module.
* Statically imports the configured storage adapter.
*/
export function generateStorageModule(storageEntrypoint?: string): string {
if (!storageEntrypoint) {
return `export const createStorage = undefined;`;
}
return `
import { createStorage as _createStorage } from "${storageEntrypoint}";
export const createStorage = _createStorage;
`;
}
/**
* Generates the auth virtual module.
* Statically imports the configured auth provider.
*/
export function generateAuthModule(authEntrypoint?: string): string {
if (!authEntrypoint) {
return `export const authenticate = undefined;`;
}
return `
import { authenticate as _authenticate } from "${authEntrypoint}";
export const authenticate = _authenticate;
`;
}
/**
* Generates the auth providers module.
*
* Statically imports each auth provider's `adminEntry` module and exports
* a registry keyed by provider ID. The admin UI uses this to render
* provider-specific login buttons/forms and setup steps.
*
* Follows the same pattern as `generateAdminRegistryModule()` for plugins.
*/
export function generateAuthProvidersModule(descriptors: AuthProviderDescriptor[]): string {
const withAdmin = descriptors.filter((d) => d.adminEntry);
if (withAdmin.length === 0) {
return `export const authProviders = {};`;
}
const imports: string[] = [];
const entries: string[] = [];
withAdmin.forEach((descriptor, index) => {
const varName = `authProvider${index}`;
imports.push(`import * as ${varName} from ${JSON.stringify(descriptor.adminEntry)};`);
entries.push(
` ${JSON.stringify(descriptor.id)}: { ...${varName}, id: ${JSON.stringify(descriptor.id)}, label: ${JSON.stringify(descriptor.label)} },`,
);
});
return `
// Auto-generated auth provider registry
${imports.join("\n")}
export const authProviders = {
${entries.join("\n")}
};
`;
}
/**
* Generates the plugins module.
* Imports and instantiates all plugins at runtime.
*
* Handles two plugin formats:
* - **Native**: imports `createPlugin` and calls it with options
* - **Standard**: imports the default export and wraps it with `adaptSandboxEntry`
*
* The format is determined by `descriptor.format`:
* - `"standard"` -- uses adaptSandboxEntry
* - `"native"` or undefined -- uses createPlugin
*
* This is critical for Cloudflare Workers where globals don't persist
* between build time and runtime.
*/
export function generatePluginsModule(descriptors: PluginDescriptor[]): string {
if (descriptors.length === 0) {
return `export const plugins = [];`;
}
const imports: string[] = [];
const instantiations: string[] = [];
// Track whether we need the adapter import
let needsAdapter = false;
descriptors.forEach((descriptor, index) => {
if (descriptor.format === "standard") {
// Standard format: import default export, wrap with adaptSandboxEntry
needsAdapter = true;
const varName = `pluginDef${index}`;
imports.push(`import ${varName} from "${descriptor.entrypoint}";`);
instantiations.push(
`adaptSandboxEntry(${varName}, ${JSON.stringify({
id: descriptor.id,
version: descriptor.version,
capabilities: descriptor.capabilities,
allowedHosts: descriptor.allowedHosts,
storage: descriptor.storage,
adminPages: descriptor.adminPages,
adminWidgets: descriptor.adminWidgets,
})})`,
);
} else {
// Native format: import createPlugin and call with options
const varName = `createPlugin${index}`;
imports.push(`import { createPlugin as ${varName} } from "${descriptor.entrypoint}";`);
instantiations.push(`${varName}(${JSON.stringify(descriptor.options ?? {})})`);
}
});
const adapterImport = needsAdapter
? `import { adaptSandboxEntry } from "emdash/plugins/adapt-sandbox-entry";\n`
: "";
return `
// Auto-generated plugins module
// Imports and instantiates all configured plugins at runtime
${adapterImport}${imports.join("\n")}
/** Resolved plugins array */
export const plugins = [
${instantiations.join(",\n ")}
];
`;
}
/**
* Generates the admin registry module.
* Uses adminEntry from plugin descriptors to statically import admin modules.
*/
export function generateAdminRegistryModule(descriptors: PluginDescriptor[]): string {
// Filter to descriptors with admin entries
const adminDescriptors = descriptors.filter((d) => d.adminEntry);
if (adminDescriptors.length === 0) {
return `export const pluginAdmins = {};`;
}
const imports: string[] = [];
const entries: string[] = [];
adminDescriptors.forEach((descriptor, index) => {
const varName = `admin${index}`;
// Use explicit ID from descriptor if available, otherwise derive from entrypoint
const pluginId =
descriptor.id ??
descriptor.entrypoint.replace(SCOPED_PREFIX_PATTERN, "").replace(EMDASH_PREFIX_PATTERN, "");
imports.push(`import * as ${varName} from "${descriptor.adminEntry}";`);
entries.push(` "${pluginId}": ${varName},`);
});
return `
// Auto-generated plugin admin registry
${imports.join("\n")}
export const pluginAdmins = {
${entries.join("\n")}
};
`;
}
/**
* Generates the sandbox runner module.
* Imports the configured sandbox runner factory or provides a noop default.
*/
export function generateSandboxRunnerModule(sandboxRunner?: string): string {
if (!sandboxRunner) {
// No sandbox runner configured - use noop
return `
// No sandbox runner configured - sandboxed plugins disabled
import { createNoopSandboxRunner } from "emdash";
export const createSandboxRunner = createNoopSandboxRunner;
export const sandboxEnabled = false;
`;
}
return `
// Auto-generated sandbox runner module
import { createSandboxRunner as _createSandboxRunner } from "${sandboxRunner}";
export const createSandboxRunner = _createSandboxRunner;
export const sandboxEnabled = true;
`;
}
/**
* Generates the media providers module.
* Imports and instantiates configured media providers at runtime.
*/
export function generateMediaProvidersModule(descriptors: MediaProviderDescriptor[]): string {
// Always include local provider by default unless explicitly disabled
const localDisabled = descriptors.some((d) => d.id === "local" && d.config.enabled === false);
const imports: string[] = [];
const entries: string[] = [];
// Add local provider first if not disabled
if (!localDisabled) {
imports.push(
`import { createMediaProvider as createLocalProvider } from "emdash/media/local-runtime";`,
);
entries.push(`{
id: "local",
name: "Library",
icon: "folder",
capabilities: { browse: true, search: false, upload: true, delete: true },
createProvider: (ctx) => createLocalProvider({ ...ctx, enabled: true }),
}`);
}
// Add custom providers
descriptors
.filter((d) => d.id !== "local" || d.config.enabled !== false)
.filter((d) => d.id !== "local") // Skip local if we already added it
.forEach((descriptor, index) => {
const varName = `createProvider${index}`;
imports.push(`import { createMediaProvider as ${varName} } from "${descriptor.entrypoint}";`);
entries.push(`{
id: ${JSON.stringify(descriptor.id)},
name: ${JSON.stringify(descriptor.name)},
icon: ${JSON.stringify(descriptor.icon)},
capabilities: ${JSON.stringify(descriptor.capabilities)},
createProvider: (ctx) => ${varName}({ ...${JSON.stringify(descriptor.config)}, ...ctx }),
}`);
});
return `
// Auto-generated media providers module
${imports.join("\n")}
/** Media provider descriptors with factory functions */
export const mediaProviders = [
${entries.join(",\n ")}
];
`;
}
/**
* Generates the block components module.
* Collects and merges `blockComponents` exports from plugin component entries.
*/
export function generateBlockComponentsModule(descriptors: PluginDescriptor[]): string {
const withComponents = descriptors.filter((d) => d.componentsEntry);
if (withComponents.length === 0) {
return `export const pluginBlockComponents = {};`;
}
const imports: string[] = [];
const spreads: string[] = [];
withComponents.forEach((d, i) => {
imports.push(`import { blockComponents as bc${i} } from "${d.componentsEntry}";`);
spreads.push(`...bc${i}`);
});
return `${imports.join("\n")}\nexport const pluginBlockComponents = { ${spreads.join(", ")} };`;
}
/**
* Generates the wait-until virtual module.
*
* Under @astrojs/cloudflare, re-exports `waitUntil` from `cloudflare:workers`
* so `after(fn)` in core can extend the worker's lifetime past the response
* for deferred bookkeeping. For any other adapter, exports `undefined` —
* Node's long-lived event loop keeps deferred promises running without a
* lifetime extender.
*
* Keeping the adapter check here — rather than in core — means core itself
* has no Cloudflare-specific imports or code paths.
*/
export function generateWaitUntilModule(adapterName: string | undefined): string {
if (adapterName === "@astrojs/cloudflare") {
return `export { waitUntil } from "cloudflare:workers";`;
}
return `export const waitUntil = undefined;`;
}
/**
* Generates the seed virtual module.
* Reads the user's seed file at build time (in Node context) and embeds it,
* so the runtime doesn't need filesystem access (required for workerd).
*
* Search order:
* 1. `.emdash/seed.json`
* 2. `package.json` → `emdash.seed` reference
* 3. `seed/seed.json` (conventional template path)
*
* Exports `userSeed` (user's seed or null) and `seed` (user's seed or default).
*
* When no user seed is found, falls back to the built-in default seed and
* (if `warnOnFallback` is true) logs a warning so misconfiguration is visible
* during `astro dev`. Build/preview/sync stay silent so sites that
* intentionally use the default seed (e.g. the blank template) don't
* generate noisy logs.
*/
export function generateSeedModule(projectRoot: string, warnOnFallback = false): string {
let userSeedJson: string | null = null;
// Try .emdash/seed.json
try {
const seedPath = resolve(projectRoot, ".emdash", "seed.json");
const content = readFileSync(seedPath, "utf-8");
JSON.parse(content); // validate
userSeedJson = content;
} catch {
// Not found, try next
}
// Try package.json → emdash.seed reference
if (!userSeedJson) {
try {
const pkgPath = resolve(projectRoot, "package.json");
const pkgContent = readFileSync(pkgPath, "utf-8");
const pkg: { emdash?: { seed?: string } } = JSON.parse(pkgContent);
if (pkg.emdash?.seed) {
const seedPath = resolve(projectRoot, pkg.emdash.seed);
const content = readFileSync(seedPath, "utf-8");
JSON.parse(content); // validate
userSeedJson = content;
}
} catch {
// Not found
}
}
// Try conventional seed/seed.json fallback
if (!userSeedJson) {
try {
const seedPath = resolve(projectRoot, "seed", "seed.json");
const content = readFileSync(seedPath, "utf-8");
JSON.parse(content); // validate
userSeedJson = content;
} catch {
// Not found
}
}
if (userSeedJson) {
return [`export const userSeed = ${userSeedJson};`, `export const seed = userSeed;`].join("\n");
}
// No user seed — inline the default. Caller (the Vite plugin) gates this
// to dev-only so production builds stay quiet for sites that intentionally
// rely on the default seed.
if (warnOnFallback) {
console.warn(
"[emdash] No user seed found at .emdash/seed.json, package.json#emdash.seed, or seed/seed.json. Falling back to the built-in default seed; the setup wizard will not offer demo content for this site.",
);
}
return [
`export const userSeed = null;`,
`export const seed = ${JSON.stringify(defaultSeed)};`,
].join("\n");
}
/**
* Resolve a module specifier from the project's context.
* Uses Node.js require.resolve with the project root as base.
*/
function resolveModulePathFromProject(specifier: string, projectRoot: string): string {
// Create require from the project's package.json location
const projectPackageJson = resolve(projectRoot, "package.json");
const require = createRequire(projectPackageJson);
return require.resolve(specifier);
}
/**
* Generates the sandboxed plugins module.
* Resolves plugin entrypoints to files, reads them, and embeds the code.
*
* At runtime, middleware uses SandboxRunner to load these into isolates.
*/
export function generateSandboxedPluginsModule(
sandboxed: PluginDescriptor[],
projectRoot: string,
): string {
if (sandboxed.length === 0) {
return `
// No sandboxed plugins configured
export const sandboxedPlugins = [];
`;
}
const pluginEntries: string[] = [];
for (const descriptor of sandboxed) {
const bundleSpecifier = descriptor.entrypoint;
// Resolve the bundle to a file path using project's require context
const filePath = resolveModulePathFromProject(bundleSpecifier, projectRoot);
const ext = filePath.slice(filePath.lastIndexOf("."));
if (TS_SOURCE_EXT_RE.test(ext)) {
throw new Error(
`Sandboxed plugin "${descriptor.id}" entrypoint "${bundleSpecifier}" resolves to ` +
`unbuilt source (${filePath}). Sandbox entries must be pre-built JavaScript. ` +
`Ensure the plugin's package.json exports point to built files (e.g. dist/*.mjs) ` +
`and run the plugin's build step before building the site.`,
);
}
const code = readFileSync(filePath, "utf-8");
// Create the plugin entry with embedded code and sandbox config
pluginEntries.push(`{
id: ${JSON.stringify(descriptor.id)},
version: ${JSON.stringify(descriptor.version)},
options: ${JSON.stringify(descriptor.options ?? {})},
capabilities: ${JSON.stringify(descriptor.capabilities ?? [])},
allowedHosts: ${JSON.stringify(descriptor.allowedHosts ?? [])},
storage: ${JSON.stringify(descriptor.storage ?? {})},
adminPages: ${JSON.stringify(descriptor.adminPages ?? [])},
adminWidgets: ${JSON.stringify(descriptor.adminWidgets ?? [])},
adminEntry: ${JSON.stringify(descriptor.adminEntry)},
// Code read from: ${filePath}
code: ${JSON.stringify(code)},
}`);
}
return `
// Auto-generated sandboxed plugins module
// Plugin code is embedded at build time
/**
* Sandboxed plugin entries with embedded code.
* Loaded at runtime via SandboxRunner.
*/
export const sandboxedPlugins = [
${pluginEntries.join(",\n ")}
];
`;
}

View File

@@ -0,0 +1,435 @@
/**
* Vite Plugin Configuration
*
* Defines the Vite plugin that handles virtual modules and other
* Vite-specific configuration for EmDash.
*/
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { AstroConfig } from "astro";
import type { Plugin } from "vite";
import { COMMIT, VERSION } from "../../version.js";
import type { EmDashConfig, PluginDescriptor } from "./runtime.js";
import {
VIRTUAL_CONFIG_ID,
RESOLVED_VIRTUAL_CONFIG_ID,
VIRTUAL_DIALECT_ID,
RESOLVED_VIRTUAL_DIALECT_ID,
VIRTUAL_STORAGE_ID,
RESOLVED_VIRTUAL_STORAGE_ID,
VIRTUAL_ADMIN_REGISTRY_ID,
RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID,
VIRTUAL_PLUGINS_ID,
RESOLVED_VIRTUAL_PLUGINS_ID,
VIRTUAL_SANDBOX_RUNNER_ID,
RESOLVED_VIRTUAL_SANDBOX_RUNNER_ID,
VIRTUAL_SANDBOXED_PLUGINS_ID,
RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID,
VIRTUAL_AUTH_ID,
RESOLVED_VIRTUAL_AUTH_ID,
VIRTUAL_AUTH_PROVIDERS_ID,
RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID,
VIRTUAL_MEDIA_PROVIDERS_ID,
RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID,
VIRTUAL_BLOCK_COMPONENTS_ID,
RESOLVED_VIRTUAL_BLOCK_COMPONENTS_ID,
VIRTUAL_SEED_ID,
RESOLVED_VIRTUAL_SEED_ID,
VIRTUAL_WAIT_UNTIL_ID,
RESOLVED_VIRTUAL_WAIT_UNTIL_ID,
generateSeedModule,
generateWaitUntilModule,
generateConfigModule,
generateDialectModule,
generateStorageModule,
generateAuthModule,
generateAuthProvidersModule,
generatePluginsModule,
generateAdminRegistryModule,
generateSandboxRunnerModule,
generateSandboxedPluginsModule,
generateMediaProvidersModule,
generateBlockComponentsModule,
} from "./virtual-modules.js";
const LOCALE_MESSAGES_RE = /[/\\]([a-z]{2}(?:-[A-Z]{2})?)[/\\]messages\.mjs$/;
/**
* Vite plugin that compiles Lingui macros in admin source files.
* Only active in dev mode when the admin package is aliased to source for HMR.
* @babel/core is dynamically imported from admin's devDependencies —
* not declared by core, never ships to end users.
*/
function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plugin {
// Resolve @babel/core from admin's devDependencies, not core's.
const adminRequire = createRequire(resolve(adminDistPath, "index.js"));
const babelCorePath = adminRequire.resolve("@babel/core");
return {
name: "emdash-lingui-macro",
enforce: "pre",
resolveId(id, importer) {
// Redirect relative locale catalog imports (e.g. ./de/messages.mjs) from
// within admin source to the compiled dist/locales/ directory, since
// lingui compile only runs during build — not in dev watch mode.
if (!importer?.startsWith(adminSourcePath)) return;
const match = id.match(LOCALE_MESSAGES_RE);
if (match?.[1]) {
return resolve(adminDistPath, "locales", match[1], "messages.mjs");
}
},
async transform(code, id) {
if (!id.startsWith(adminSourcePath) || !code.includes("@lingui")) return;
const { transformAsync } = (await import(babelCorePath)) as typeof import("@babel/core");
const result = await transformAsync(code, {
filename: id,
plugins: ["@lingui/babel-plugin-lingui-macro"],
parserOpts: { plugins: ["jsx", "typescript"] },
});
if (!result?.code) return;
return { code: result.code, map: result.map ?? undefined };
},
};
}
/**
* Resolve path to the admin package dist directory.
* Used for Vite alias to ensure the package is found in pnpm's isolated node_modules.
*/
function resolveAdminDist(): string {
const require = createRequire(import.meta.url);
const adminPath = require.resolve("@emdash-cms/admin");
// Return the directory containing the built package (dist/)
return dirname(adminPath);
}
/**
* Check whether child is inside parent without relying on simple prefix checks.
*/
function isInside(parent: string, child: string): boolean {
const relativePath = relative(parent, child);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
/**
* Resolve path to the admin package source directory.
* In dev mode inside this repo, we alias @emdash-cms/admin to the source so
* Vite processes it directly — giving instant HMR instead of requiring a
* rebuild + restart. External apps should use the built package surface.
*/
function resolveAdminSource(projectRoot: string): string | undefined {
const require = createRequire(import.meta.url);
const adminPath = require.resolve("@emdash-cms/admin");
// dist/index.js -> go up to package root, then into src/
const packageRoot = resolve(dirname(adminPath), "..");
const repoRoot = resolve(packageRoot, "..", "..");
const srcEntry = resolve(packageRoot, "src", "index.ts");
try {
if (existsSync(srcEntry) && isInside(repoRoot, projectRoot)) {
return resolve(packageRoot, "src");
}
} catch {
// Not in local repo — fall back to dist
}
return undefined;
}
export interface VitePluginOptions {
/** Serializable config (database, storage, auth descriptors) */
serializableConfig: Record<string, unknown>;
/** Resolved EmDash config */
resolvedConfig: EmDashConfig;
/** Plugin descriptors */
pluginDescriptors: PluginDescriptor[];
/** Astro config */
astroConfig: AstroConfig;
}
/**
* Creates the EmDash virtual modules Vite plugin.
*/
export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin {
const { serializableConfig, resolvedConfig, pluginDescriptors, astroConfig } = options;
let viteCommand: "build" | "serve" | undefined;
return {
name: "emdash-virtual-modules",
configResolved(config) {
viteCommand = config.command;
},
resolveId(id: string) {
if (id === VIRTUAL_CONFIG_ID) {
return RESOLVED_VIRTUAL_CONFIG_ID;
}
if (id === VIRTUAL_DIALECT_ID) {
return RESOLVED_VIRTUAL_DIALECT_ID;
}
if (id === VIRTUAL_STORAGE_ID) {
return RESOLVED_VIRTUAL_STORAGE_ID;
}
if (id === VIRTUAL_ADMIN_REGISTRY_ID) {
return RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID;
}
if (id === VIRTUAL_PLUGINS_ID) {
return RESOLVED_VIRTUAL_PLUGINS_ID;
}
if (id === VIRTUAL_SANDBOX_RUNNER_ID) {
return RESOLVED_VIRTUAL_SANDBOX_RUNNER_ID;
}
if (id === VIRTUAL_SANDBOXED_PLUGINS_ID) {
return RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID;
}
if (id === VIRTUAL_AUTH_ID) {
return RESOLVED_VIRTUAL_AUTH_ID;
}
if (id === VIRTUAL_AUTH_PROVIDERS_ID) {
return RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID;
}
if (id === VIRTUAL_MEDIA_PROVIDERS_ID) {
return RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID;
}
if (id === VIRTUAL_BLOCK_COMPONENTS_ID) {
return RESOLVED_VIRTUAL_BLOCK_COMPONENTS_ID;
}
if (id === VIRTUAL_SEED_ID) {
return RESOLVED_VIRTUAL_SEED_ID;
}
if (id === VIRTUAL_WAIT_UNTIL_ID) {
return RESOLVED_VIRTUAL_WAIT_UNTIL_ID;
}
},
load(id: string) {
if (id === RESOLVED_VIRTUAL_CONFIG_ID) {
return generateConfigModule(serializableConfig);
}
// Generate a module that statically imports the configured dialect
// This allows Vite to properly resolve and bundle it
if (id === RESOLVED_VIRTUAL_DIALECT_ID) {
return generateDialectModule({
entrypoint: resolvedConfig.database?.entrypoint,
type: resolvedConfig.database?.type,
supportsRequestScope: resolvedConfig.database?.supportsRequestScope ?? false,
});
}
// Generate a module that statically imports the configured storage
if (id === RESOLVED_VIRTUAL_STORAGE_ID) {
return generateStorageModule(resolvedConfig.storage?.entrypoint);
}
// Generate plugins module that imports and instantiates all plugins
if (id === RESOLVED_VIRTUAL_PLUGINS_ID) {
return generatePluginsModule(pluginDescriptors);
}
// Generate admin registry module with plugin components
if (id === RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID) {
// Include both trusted and sandboxed plugins
const allDescriptors = [...pluginDescriptors, ...(resolvedConfig.sandboxed ?? [])];
return generateAdminRegistryModule(allDescriptors);
}
// Generate sandbox runner module
if (id === RESOLVED_VIRTUAL_SANDBOX_RUNNER_ID) {
return generateSandboxRunnerModule(resolvedConfig.sandboxRunner);
}
// Generate sandboxed plugins config module
if (id === RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID) {
// Pass project root for proper module resolution
const projectRoot = fileURLToPath(astroConfig.root);
return generateSandboxedPluginsModule(resolvedConfig.sandboxed ?? [], projectRoot);
}
// Generate auth module that statically imports the configured auth provider
if (id === RESOLVED_VIRTUAL_AUTH_ID) {
const authDescriptor = resolvedConfig.auth;
if (!authDescriptor || !("entrypoint" in authDescriptor)) {
return generateAuthModule(undefined);
}
return generateAuthModule(authDescriptor.entrypoint);
}
// Generate auth providers module (pluggable login methods)
if (id === RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID) {
return generateAuthProvidersModule(resolvedConfig.authProviders ?? []);
}
// Generate media providers module
if (id === RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID) {
return generateMediaProvidersModule(resolvedConfig.mediaProviders ?? []);
}
// Generate block components module (plugin rendering components for PortableText)
if (id === RESOLVED_VIRTUAL_BLOCK_COMPONENTS_ID) {
return generateBlockComponentsModule(pluginDescriptors);
}
// Generate seed module — embeds user seed or default at build time
if (id === RESOLVED_VIRTUAL_SEED_ID) {
const projectRoot = fileURLToPath(astroConfig.root);
return generateSeedModule(projectRoot, viteCommand === "serve");
}
// Generate wait-until module — re-exports cloudflare:workers'
// waitUntil under the Cloudflare adapter, undefined otherwise.
if (id === RESOLVED_VIRTUAL_WAIT_UNTIL_ID) {
return generateWaitUntilModule(astroConfig.adapter?.name);
}
},
};
}
/**
* Modules that contain native Node.js addons or Node-only code.
* These must be external in SSR to avoid bundling failures on Node.
* On Cloudflare, the adapter handles its own externalization — setting
* ssr.external there conflicts with @cloudflare/vite-plugin's validation.
*/
const NODE_NATIVE_EXTERNALS = [
"better-sqlite3",
"bindings",
"file-uri-to-path",
"@libsql/kysely-libsql",
"pg",
];
/**
* Detect whether the Cloudflare adapter is being used.
*/
function isCloudflareAdapter(astroConfig: AstroConfig): boolean {
return astroConfig.adapter?.name === "@astrojs/cloudflare";
}
/**
* Creates the Vite config update for EmDash.
*/
export function createViteConfig(
options: VitePluginOptions,
command: "dev" | "build" | "preview" | "sync",
): NonNullable<AstroConfig["vite"]> {
const adminDistPath = resolveAdminDist();
const cloudflare = isCloudflareAdapter(options.astroConfig);
const isDev = command === "dev";
const projectRoot = fileURLToPath(options.astroConfig.root);
const adminSourcePath = isDev ? resolveAdminSource(projectRoot) : undefined;
const useSource = adminSourcePath !== undefined;
return {
// Astro SSR routes resolve version.ts from source (not tsdown dist),
// so Vite needs its own define pass for the __EMDASH_*__ placeholders.
define: {
__EMDASH_VERSION__: JSON.stringify(VERSION),
__EMDASH_COMMIT__: JSON.stringify(COMMIT),
__EMDASH_PSEUDO_LOCALE__: JSON.stringify(
isDev && process.env["EMDASH_PSEUDO_LOCALE"] === "1",
),
},
resolve: {
dedupe: ["@emdash-cms/admin", "react", "react-dom"],
// Array form so more-specific entries are checked first.
// The styles.css alias must come before the package alias, otherwise
// Vite's prefix matching on "@emdash-cms/admin" would resolve
// "@emdash-cms/admin/styles.css" through the source directory.
alias: [
{ find: "@emdash-cms/admin/styles.css", replacement: resolve(adminDistPath, "styles.css") },
{ find: "@emdash-cms/admin", replacement: useSource ? adminSourcePath : adminDistPath },
// `use-sync-external-store/shim` is a React <18 polyfill that ships
// only as CJS. It's pulled in transitively by `@tiptap/react`. With
// pnpm's virtual store the file lives under .pnpm/, where Vite's
// dep scanner can't reach it for pre-bundling — so the browser is
// served raw `module.exports` and hydration fails with
// `SyntaxError: ... does not provide an export named
// 'useSyncExternalStore'`. Redirect both shim entry points to the
// main `use-sync-external-store` package, which on React >=18
// (our peer-dep floor) delegates to React's built-in hook.
{
find: "use-sync-external-store/shim/index.js",
replacement: "use-sync-external-store",
},
{ find: "use-sync-external-store/shim", replacement: "use-sync-external-store" },
],
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Monorepo has both vite 6 (docs) and vite 7 (core). tsgo resolves correctly.
plugins: [
createVirtualModulesPlugin(options),
// In dev mode with source alias, compile Lingui macros on the fly
// and redirect locale .mjs imports to dist/.
// In production, macros are pre-compiled by tsdown in the admin package.
...(useSource ? [linguiMacroPlugin(adminSourcePath, adminDistPath)] : []),
] as NonNullable<AstroConfig["vite"]>["plugins"],
// Handle native modules for SSR.
// On Node: external keeps native addons out of the SSR bundle.
// On Cloudflare: skip — the adapter handles externalization, and setting
// ssr.external conflicts with @cloudflare/vite-plugin's resolve.external validation.
ssr: cloudflare
? {
noExternal: ["emdash", "@emdash-cms/admin"],
// Pre-bundle EmDash's runtime deps for workerd. Without this,
// Vite discovers them one-by-one on first request, causing workerd
// to enter "worker cancelled" state on cold cache.
optimizeDeps: {
// Exclude EmDash virtual modules from esbuild's dependency
// scan. These are resolved by the Vite plugin at transform time,
// but esbuild encounters them when crawling emdash's dist files
// during pre-bundling and can't resolve them. Vite's exclude
// uses prefix matching (id.startsWith(m + "/")), so
// "virtual:emdash" matches all "virtual:emdash/*" imports.
exclude: ["virtual:emdash"],
include: [
// EmDash direct deps
"emdash > @portabletext/toolkit",
"emdash > @unpic/placeholder",
"emdash > blurhash",
"emdash > croner",
"emdash > image-size",
"emdash > jose",
"emdash > jpeg-js",
"emdash > kysely",
"emdash > mime/lite",
"emdash > modern-tar",
"emdash > sanitize-html",
"emdash > ulidx",
"emdash > upng-js",
"emdash > astro-portabletext",
"emdash > sax",
// Deeper transitive deps
"emdash > sanitize-html > parse5",
"emdash > @emdash-cms/gutenberg-to-portable-text > @wordpress/block-serialization-default-parser",
"emdash > @emdash-cms/auth > @oslojs/crypto/ecdsa",
"emdash > @emdash-cms/auth > @oslojs/crypto/sha2",
"emdash > @emdash-cms/auth > @oslojs/webauthn",
// MCP SDK — server/index.js statically imports ajv (CJS-only).
// Pre-bundling converts CJS to ESM so workerd can load it.
"emdash > @modelcontextprotocol/sdk > ajv",
"emdash > @modelcontextprotocol/sdk > ajv-formats",
// React (commonly used, may be hoisted)
"react",
"react/jsx-dev-runtime",
"react/jsx-runtime",
"react-dom",
"react-dom/server",
// Top-level deps (use astro > path for pnpm compat)
"astro > zod/v4",
"astro > zod/v4/core",
"@emdash-cms/cloudflare > kysely-d1",
// Astro internal deps not covered by @astrojs/cloudflare adapter
"astro/virtual-modules/middleware.js",
"astro/virtual-modules/live-config",
"astro/content/runtime",
"astro/assets/utils/inferRemoteSize.js",
"astro/assets/fonts/runtime.js",
"@astrojs/cloudflare/image-service",
],
},
}
: {
external: NODE_NATIVE_EXTERNALS,
noExternal: ["emdash", "@emdash-cms/admin"],
},
optimizeDeps: {
// When using source, don't pre-bundle JS — let Vite transform on the fly for HMR.
// When using dist, pre-bundle to avoid re-optimization on first hydration.
include: useSource
? ["@astrojs/react/client.js"]
: ["@emdash-cms/admin", "@astrojs/react/client.js"],
exclude: cloudflare ? ["virtual:emdash"] : [...NODE_NATIVE_EXTERNALS, "virtual:emdash"],
},
};
}