Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fix-sandbox-bundling-and-codeblock-lang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"emdash": patch
"@emdash-cms/blocks": patch
"@emdash-cms/plugin-webhook-notifier": patch
"@emdash-cms/plugin-sandboxed-test": patch
---

Fix sandboxed plugin entries failing with "Unexpected token '{'" by bundling them with esbuild at build time instead of embedding raw TypeScript source. Also fix CodeBlock crash on unsupported language values by normalizing aliases before passing to Kumo.
27 changes: 26 additions & 1 deletion packages/blocks/src/blocks/code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@ import { CodeBlock as KumoCodeBlock } from "@cloudflare/kumo";

import type { CodeBlock } from "../types.js";

/** Languages supported by Kumo's CodeBlock component. */
type SupportedLang = "ts" | "tsx" | "jsonc" | "bash" | "css";

const SUPPORTED_LANGS = new Set<string>(["ts", "tsx", "jsonc", "bash", "css"]);

/** Map common language names to their Kumo equivalents. */
const LANG_ALIASES: Record<string, SupportedLang> = {
json: "jsonc",
javascript: "ts",
typescript: "ts",
js: "ts",
sh: "bash",
shell: "bash",
};

/**
* Normalize a language string to a Kumo-supported value.
* Falls back to "bash" (plain monospace rendering) for unknown languages.
*/
function normalizeLang(lang?: string): SupportedLang {
if (!lang) return "bash";
if (SUPPORTED_LANGS.has(lang)) return lang as SupportedLang;
return LANG_ALIASES[lang] ?? "bash";
}

export function CodeBlockComponent({ block }: { block: CodeBlock }) {
return <KumoCodeBlock code={block.code} lang={block.language} />;
return <KumoCodeBlock code={block.code} lang={normalizeLang(block.language)} />;
}
3 changes: 2 additions & 1 deletion packages/blocks/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ChartBlock,
ChartSeries,
CodeBlock,
CodeLanguage,
ComboboxElement,
ColumnsBlock,
ConfirmDialog,
Expand Down Expand Up @@ -387,7 +388,7 @@ function meter(opts: {
function codeBlock(opts: {
blockId?: string;
code: string;
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
language?: CodeLanguage;
}): CodeBlock {
return {
type: "code",
Expand Down
1 change: 1 addition & 0 deletions packages/blocks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type {
ColumnsBlock,
ChartBlock,
CodeBlock,
CodeLanguage,
BannerBlock,
MeterBlock,
Block,
Expand Down
2 changes: 2 additions & 0 deletions packages/blocks/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export type {
ImageBlock,
ContextBlock,
ColumnsBlock,
CodeBlock,
CodeLanguage,
Block,
// Interactions
BlockAction,
Expand Down
20 changes: 19 additions & 1 deletion packages/blocks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,28 @@ export interface MeterBlock extends BlockBase {
custom_value?: string;
}

/**
* Accepted code block language values.
* Canonical Kumo languages: ts, tsx, jsonc, bash, css.
* Common aliases are also accepted and normalized at render time.
*/
export type CodeLanguage =
| "ts"
| "tsx"
| "jsonc"
| "bash"
| "css"
| "json"
| "javascript"
| "typescript"
| "js"
| "sh"
| "shell";

export interface CodeBlock extends BlockBase {
type: "code";
code: string;
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
language?: CodeLanguage;
}

export type Block =
Expand Down
6 changes: 5 additions & 1 deletion packages/blocks/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ const ELEMENT_TYPES = new Set([

const COLUMN_FORMATS = new Set(["text", "badge", "relative_time", "number", "code"]);

const CODE_LANGUAGES = new Set(["ts", "tsx", "jsonc", "bash", "css"]);
const CODE_LANGUAGES = new Set([
"ts", "tsx", "jsonc", "bash", "css",
// Aliases accepted by the renderer's normalizeLang():
"json", "javascript", "typescript", "js", "sh", "shell",
]);

const BUTTON_STYLES = new Set(["primary", "danger", "secondary"]);
const TREND_VALUES = new Set(["up", "down", "neutral"]);
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@
"citty": "^0.1.6",
"consola": "^3.4.2",
"croner": "^10.0.1",
"esbuild": "^0.24.0",
"image-size": "^2.0.2",
"jose": "^6.1.3",
"jpeg-js": "^0.4.4",
Expand Down
77 changes: 73 additions & 4 deletions packages/core/src/astro/integration/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* so Vite can properly resolve and bundle them.
*/

import { build } from "esbuild";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve } from "node:path";
Expand Down Expand Up @@ -411,16 +412,80 @@ function resolveModulePathFromProject(specifier: string, projectRoot: string): s
return require.resolve(specifier);
}

/** File extensions that require TypeScript/JSX transpilation. */
const TS_LOADERS: Record<string, "ts" | "tsx" | "jsx"> = {
".ts": "ts",
".tsx": "tsx",
".mts": "ts",
".cts": "ts",
".jsx": "jsx",
};

/**
* Bundle a sandbox entry into a self-contained ES module.
*
* Worker Loader isolates only have access to the generated wrapper and this
* plugin code — bare specifiers like `"emdash"` cannot be resolved at runtime.
* We use esbuild to bundle all imports, shimming `"emdash"` with a virtual
* module that provides `definePlugin` as an identity function (which is all
* standard-format sandboxed plugins need).
*/
async function bundleSandboxEntry(filePath: string, pluginId: string): Promise<string> {
const ext = filePath.slice(filePath.lastIndexOf("."));
const loader = TS_LOADERS[ext];

try {
const result = await build({
entryPoints: [filePath],
bundle: true,
write: false,
format: "esm",
target: "es2022",
platform: "neutral",
plugins: [
{
name: "emdash-sandbox-shim",
setup(b) {
// Intercept bare "emdash" imports and replace with a
// lightweight shim. definePlugin is an identity function
// for standard-format plugins (see define-plugin.ts).
b.onResolve({ filter: /^emdash$/ }, () => ({
path: "emdash",
namespace: "emdash-shim",
}));
b.onLoad({ filter: /.*/, namespace: "emdash-shim" }, () => ({
contents: "export const definePlugin = (d) => d;",
loader: "js",
}));
},
},
],
...(loader ? { loader: { [ext]: loader } } : {}),
});

if (result.outputFiles.length === 0) {
throw new Error("esbuild produced no output");
}

return result.outputFiles[0].text;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to bundle sandboxed plugin "${pluginId}" (${filePath}): ${msg}`,
);
}
}

/**
* 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(
export async function generateSandboxedPluginsModule(
sandboxed: PluginDescriptor[],
projectRoot: string,
): string {
): Promise<string> {
if (sandboxed.length === 0) {
return `
// No sandboxed plugins configured
Expand All @@ -435,8 +500,12 @@ export const sandboxedPlugins = [];

// Resolve the bundle to a file path using project's require context
const filePath = resolveModulePathFromProject(bundleSpecifier, projectRoot);
// Read the source code
const code = readFileSync(filePath, "utf-8");
// Bundle the plugin into a self-contained ES module.
// Worker Loader isolates only have access to the wrapper and this code —
// bare imports like "emdash" must be resolved at build time.
// We alias "emdash" to a shim since definePlugin is an identity function
// for standard-format plugins (the only format sandboxed plugins use).
const code = await bundleSandboxEntry(filePath, descriptor.id);

// Create the plugin entry with embedded code and sandbox config
pluginEntries.push(`{
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/sandboxed-test/src/sandbox-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1259,7 +1259,7 @@ async function runSingleTestAdmin(ctx: PluginContext, testId: string) {
style: result.passed ? "success" : "error",
text: `${result.passed ? "PASS" : "FAIL"}: ${result.message}`,
},
...(result.details ? [{ type: "code", code: result.details, language: "json" }] : []),
...(result.details ? [{ type: "code", code: result.details, language: "jsonc" }] : []),
],
toast: {
message: `${testId}: ${result.passed ? "passed" : "failed"}`,
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/webhook-notifier/src/sandbox-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ async function buildSettingsPage(ctx: PluginContext) {
},
{ type: "divider" },
{ type: "section", text: "**Payload Preview**" },
{ type: "code", code: payloadPreview, language: "json" },
{ type: "code", code: payloadPreview, language: "jsonc" },
{
type: "actions",
elements: [
Expand Down
Loading
Loading