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:
178
packages/core/src/astro/integration/font-provider.ts
Normal file
178
packages/core/src/astro/integration/font-provider.ts
Normal 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);
|
||||
}
|
||||
414
packages/core/src/astro/integration/index.ts
Normal file
414
packages/core/src/astro/integration/index.ts
Normal 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;
|
||||
889
packages/core/src/astro/integration/routes.ts
Normal file
889
packages/core/src/astro/integration/routes.ts
Normal 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"),
|
||||
});
|
||||
}
|
||||
512
packages/core/src/astro/integration/runtime.ts
Normal file
512
packages/core/src/astro/integration/runtime.ts
Normal 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;
|
||||
}
|
||||
554
packages/core/src/astro/integration/virtual-modules.ts
Normal file
554
packages/core/src/astro/integration/virtual-modules.ts
Normal 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 ")}
|
||||
];
|
||||
`;
|
||||
}
|
||||
435
packages/core/src/astro/integration/vite-config.ts
Normal file
435
packages/core/src/astro/integration/vite-config.ts
Normal 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"],
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user