Emdash source with visual editor image upload fix

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

View File

@@ -0,0 +1,23 @@
# emdash-plugins-demo
## 0.0.3
### Patch Changes
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
- emdash@0.0.3
- @emdash-cms/plugin-api-test@0.0.3
- @emdash-cms/plugin-audit-log@0.0.3
- @emdash-cms/plugin-embeds@0.0.3
- @emdash-cms/plugin-webhook-notifier@0.0.3
## 0.0.2
### Patch Changes
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
- emdash@0.0.2
- @emdash-cms/plugin-api-test@0.0.2
- @emdash-cms/plugin-audit-log@0.0.2
- @emdash-cms/plugin-embeds@0.0.2
- @emdash-cms/plugin-webhook-notifier@0.0.2

View File

@@ -0,0 +1,70 @@
# EmDash Plugins Demo
This demo showcases EmDash's plugin system with plugins that demonstrate the hook architecture.
## Plugins Included
### 1. Audit Log Plugin (`@emdash-cms/plugin-audit-log`)
Tracks all content changes for compliance.
- **Hooks:**
- `content:beforeSave` (priority 1) - captures "before" state
- `content:afterSave` (priority 200) - logs final state
- `content:beforeDelete` (priority 200) - logs deletions
- `media:afterUpload` (priority 200) - logs uploads
- **Features:**
- Create/update/delete tracking
- Before/after state comparison
- Admin history page
### 2. Webhook Notifier Plugin (`@emdash-cms/plugin-webhook-notifier`)
Posts JSON payloads to external webhook URLs on content/media events.
- **Hooks:** `content:afterSave`, `content:afterDelete`, `media:afterUpload` (priority 210)
- **Features:**
- Retry with exponential backoff
- Admin-configurable settings (URL, secret token)
- SSRF protection
- Delivery tracking
### 3. Embeds Plugin (`@emdash-cms/plugin-embeds`)
Provides Portable Text block types for embedding external content.
- **Features:**
- YouTube, Vimeo, Twitter/X, Bluesky, Mastodon embeds
- Link previews (Open Graph)
- GitHub Gist embeds
### 4. API Test Plugin (`@emdash-cms/plugin-api-test`)
Exercises all v2 plugin APIs for testing.
- **Features:**
- Routes for each plugin API (kv, storage, content, media, http)
- Combined `test/all` route
## Running the Demo
```bash
# Install dependencies
pnpm install
# Seed sample content
pnpm seed
# Start development server
pnpm dev
# Open browser
open http://localhost:4321
```
## Testing Plugin Hooks
1. Open the admin at `http://localhost:4321/_emdash/admin`
2. Create a new post with a title like "Hello World! Testing Plugins"
3. Watch the console output to see hooks executing:
- `[audit-log] + create content/posts/post-xxx`

View File

@@ -0,0 +1,44 @@
import node from "@astrojs/node";
import react from "@astrojs/react";
import { apiTestPlugin } from "@emdash-cms/plugin-api-test";
import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
import { embedsPlugin } from "@emdash-cms/plugin-embeds";
import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
integrations: [
react(),
emdash({
// SQLite database for demo
database: sqlite({ url: "file:./data.db" }),
// Register plugins - order matters for hook execution!
plugins: [
// 1. Audit log runs last (priority 200) to capture final state
// Settings (retention, data changes, excluded collections) are
// configured at runtime via the admin UI, not constructor options.
auditLogPlugin(),
// 2. Webhook notifier sends events to external URLs
// Demonstrates: network:fetch:any, apiRoutes, settings.secret(),
// hook dependencies, errorPolicy: "continue"
// Webhook URL, collections, and actions are configured via admin settings.
webhookNotifierPlugin(),
// 3. Embeds plugin for YouTube, Vimeo, Twitter, etc.
// Components are auto-registered with PortableText
embedsPlugin(),
// 4. API Test plugin - exercises all v2 APIs
apiTestPlugin(),
],
}),
],
});

39
demos/plugins-demo/emdash-env.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
// Generated by EmDash on dev server start
// Do not edit manually
/// <reference types="emdash/locals" />
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
export interface Page {
id: string;
slug: string | null;
status: string;
title: string;
content?: PortableTextBlock[];
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
}
export interface Post {
id: string;
slug: string | null;
status: string;
title: string;
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
}
declare module "emdash" {
interface EmDashCollections {
pages: Page;
posts: Post;
}
}

View File

@@ -0,0 +1,34 @@
{
"name": "emdash-plugins-demo",
"version": "0.0.3",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
"seed": "emdash seed",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/node": "catalog:",
"@astrojs/react": "catalog:",
"@emdash-cms/plugin-audit-log": "workspace:*",
"@emdash-cms/plugin-api-test": "workspace:*",
"@emdash-cms/plugin-webhook-notifier": "workspace:*",
"@emdash-cms/plugin-embeds": "workspace:*",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"astro": "catalog:",
"better-sqlite3": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:"
},
"peerDependencies": {},
"optionalDependencies": {}
}

View File

@@ -0,0 +1,13 @@
/**
* EmDash Live Content Collections
*
* Defines the _emdash collection that handles all content types from the database.
* Query specific types using getEmDashCollection() and getEmDashEntry().
*/
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

View File

@@ -0,0 +1,182 @@
---
// Demo homepage showcasing plugin functionality
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>EmDash Plugins Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.subtitle {
color: #666;
font-size: 1.25rem;
margin-bottom: 2rem;
}
h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
color: #333;
}
.plugin-list {
list-style: none;
}
.plugin {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.plugin h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: #2563eb;
}
.plugin p {
color: #555;
margin-bottom: 0.75rem;
}
.plugin code {
background: #e2e8f0;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
}
.hooks {
margin-top: 0.5rem;
font-size: 0.875rem;
}
.hooks strong {
color: #333;
}
.cta {
display: inline-block;
background: #2563eb;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
margin-top: 1.5rem;
}
.cta:hover {
background: #1d4ed8;
}
.order {
background: #dbeafe;
color: #1e40af;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.5rem;
}
</style>
</head>
<body>
<h1>EmDash Plugins Demo</h1>
<p class="subtitle">
Demonstrating the plugin hook system with realistic plugins
</p>
<h2>Active Plugins</h2>
<ul class="plugin-list">
<li class="plugin">
<h3>Auto-Slug <span class="order">Priority 10</span></h3>
<p>
Automatically generates URL-friendly slugs from titles. Handles unicode,
removes special characters, and ensures clean URLs.
</p>
<div class="hooks">
<strong>Hook:</strong> <code>content:beforeSave</code>
</div>
</li>
<li class="plugin">
<h3>SEO Demo <span class="order">Priority 50</span></h3>
<p>
Validates SEO fields (meta title, description) against length limits.
Provides admin UI for SEO settings and dashboard widget.
</p>
<div class="hooks">
<strong>Hook:</strong> <code>content:beforeSave</code>
</div>
</li>
<li class="plugin">
<h3>Reading Time <span class="order">Priority 80</span></h3>
<p>
Calculates reading time based on word count and images. Parses Portable
Text content and stores result in the content data.
</p>
<div class="hooks">
<strong>Hook:</strong> <code>content:beforeSave</code>
</div>
</li>
<li class="plugin">
<h3>Audit Log <span class="order">Priority 200</span></h3>
<p>
Tracks all content changes for compliance and debugging. Logs create,
update, delete operations with timestamps.
</p>
<div class="hooks">
<strong>Hooks:</strong>
<code>content:beforeSave</code>,
<code>content:afterSave</code>,
<code>content:beforeDelete</code>,
<code>media:afterUpload</code>
</div>
</li>
<li class="plugin">
<h3>Image Optimizer <span class="order">Priority 10</span></h3>
<p>
Validates image uploads (file type, size limits). Sanitizes filenames
and adds timestamps for uniqueness.
</p>
<div class="hooks">
<strong>Hooks:</strong>
<code>media:beforeUpload</code>,
<code>media:afterUpload</code>
</div>
</li>
</ul>
<h2>Hook Execution Order</h2>
<p>
When saving content, hooks execute in priority order (lower numbers first):
</p>
<ol style="margin: 1rem 0 1rem 1.5rem;">
<li><strong>Auto-Slug (10)</strong> - Generates slug from title</li>
<li><strong>Audit Log (1)</strong> - Captures "before" state for comparison</li>
<li><strong>SEO (50)</strong> - Validates SEO field lengths</li>
<li><strong>Reading Time (80)</strong> - Calculates reading time</li>
<li><strong>Audit Log (200)</strong> - Logs the final saved state</li>
</ol>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="/_emdash/admin" class="cta">Open Admin Panel</a>
<a href="/posts" class="cta" style="background: #059669;">View Posts</a>
<a href="/test-embeds" class="cta" style="background: #7c3aed;">Test Embeds</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,176 @@
---
/**
* Individual post page with PortableText rendering
*
* This demonstrates the embeds plugin auto-registering components
* for YouTube, Vimeo, etc. with the PortableText renderer.
*/
import { getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import { embedComponents } from "@emdash-cms/plugin-embeds/astro";
const slug = decodeSlug(Astro.params.slug);
const { entry: post } = slug
? await getEmDashEntry("posts", slug)
: { entry: null };
if (!post) {
return Astro.redirect("/404");
}
const title = post.data.title;
const content = post.data.content;
const metaDescription = post.data.excerpt;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title || "Post"} - EmDash Plugins Demo</title>
{metaDescription && <meta name="description" content={metaDescription} />}
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.meta {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 2rem;
}
.back {
display: inline-block;
margin-bottom: 1rem;
color: #2563eb;
}
/* Content styles */
.content {
margin-top: 2rem;
}
.content h1 {
font-size: 1.75rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
.content h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
.content h3 {
font-size: 1.25rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.content p {
margin-bottom: 1rem;
}
.content ul,
.content ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.content li {
margin-bottom: 0.5rem;
}
.content blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1rem 0;
color: #4b5563;
font-style: italic;
}
.content pre {
background: #f3f4f6;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1rem 0;
}
.content code {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875em;
}
.content pre code {
background: none;
padding: 0;
}
.content img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
}
.content a {
color: #2563eb;
}
/* Embed styles */
.content iframe {
max-width: 100%;
border-radius: 8px;
margin: 1rem 0;
}
footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
font-size: 0.875rem;
}
footer a {
color: #2563eb;
}
</style>
</head>
<body>
<a href="/posts" class="back">&larr; Back to posts</a>
<article>
<h1>{title || "Untitled"}</h1>
<div class="meta">
{post.data.status === "draft" && <span>Draft</span>}
</div>
<div class="content">
{
Array.isArray(content) && content.length > 0 ? (
<PortableText
value={content}
components={{ type: embedComponents }}
/>
) : typeof content === "string" && content ? (
<p>{content}</p>
) : (
<p style="color: #6b7280; font-style: italic;">No content yet.</p>
)
}
</div>
</article>
<footer>
<a href={`/_emdash/admin/content/posts/${post.id}`}>Edit in Admin</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,113 @@
---
/**
* Posts listing page
*/
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Posts - EmDash Plugins Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
.back {
display: inline-block;
margin-bottom: 1rem;
color: #2563eb;
}
.post-list {
list-style: none;
}
.post-item {
border-bottom: 1px solid #e5e7eb;
padding: 1.5rem 0;
}
.post-item:last-child {
border-bottom: none;
}
.post-title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.post-title a {
color: #2563eb;
text-decoration: none;
}
.post-title a:hover {
text-decoration: underline;
}
.post-meta {
font-size: 0.875rem;
color: #6b7280;
}
.empty {
color: #6b7280;
padding: 2rem;
text-align: center;
background: #f9fafb;
border-radius: 8px;
}
</style>
</head>
<body>
<a href="/" class="back">&larr; Back to home</a>
<h1>Posts</h1>
{
posts.length === 0 ? (
<div class="empty">
<p>No posts yet.</p>
<p>
<a href="/_emdash/admin/content/posts/new">
Create your first post
</a>
</p>
</div>
) : (
<ul class="post-list">
{posts.map((post) => (
<li class="post-item">
<h2 class="post-title">
<a href={`/posts/${post.data.slug || post.id}`}>
{post.data.title || "Untitled"}
</a>
</h2>
<div class="post-meta">
{post.data.status === "draft" && <span>Draft</span>}
</div>
</li>
))}
</ul>
)
}
<footer
style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #e5e7eb; font-size: 0.875rem;"
>
<a href="/_emdash/admin" style="color: #2563eb;">Open Admin</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,129 @@
---
/**
* Test page for embed components
*
* This page renders hardcoded Portable Text with embed blocks.
*/
import { PortableText } from "emdash/ui";
import { embedComponents } from "@emdash-cms/plugin-embeds/astro";
// Sample Portable Text content with various embed types
const testContent = [
{
_type: "block",
_key: "intro",
style: "normal",
children: [
{
_type: "span",
text: "This page tests the auto-registered embed components. If you see the embeds below, the virtual module system is working!",
},
],
},
{
_type: "block",
_key: "h1",
style: "h2",
children: [{ _type: "span", text: "YouTube Embed" }],
},
{
_type: "youtube",
_key: "yt1",
id: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
},
{
_type: "block",
_key: "h2",
style: "h2",
children: [{ _type: "span", text: "Vimeo Embed" }],
},
{
_type: "vimeo",
_key: "vim1",
id: "https://vimeo.com/76979871",
},
{
_type: "block",
_key: "h3",
style: "h2",
children: [{ _type: "span", text: "Link Preview" }],
},
{
_type: "linkPreview",
_key: "lp1",
id: "https://astro.build",
},
{
_type: "block",
_key: "outro",
style: "normal",
children: [
{
_type: "span",
text: "If you see the embeds above rendered correctly, the plugin system is working! No manual component wiring was needed.",
},
],
},
];
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Test Embeds - EmDash Plugins Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
color: #333;
}
p {
margin-bottom: 1rem;
}
.content {
margin-top: 2rem;
}
a {
color: #2563eb;
}
.back {
display: inline-block;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<a href="/" class="back">&larr; Back to home</a>
<h1>Embed Components Test</h1>
<p>This page tests embed components from the embeds plugin.</p>
<div class="content">
<PortableText
value={testContent}
components={{ type: embedComponents }}
/>
</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true
},
"include": ["src/**/*", "astro.config.mjs", "emdash-env.d.ts"]
}