first commit
This commit is contained in:
150
templates/marketing/.agents/skills/building-emdash-site/SKILL.md
Normal file
150
templates/marketing/.agents/skills/building-emdash-site/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: building-emdash-site
|
||||
description: Build and customize EmDash CMS sites on Astro. Use when creating pages, defining collections, writing seed files, querying content, rendering Portable Text, setting up menus/taxonomies/widgets, configuring deployment, or any task involving an EmDash-powered Astro site. Assumes basic Astro knowledge but provides all EmDash-specific patterns.
|
||||
---
|
||||
|
||||
# Building an EmDash Site
|
||||
|
||||
EmDash is a CMS built on Astro. It stores schema in the database (not in code), serves content via live content collections, and provides a full admin UI at `/_emdash/admin`. Sites are standard Astro projects with the `emdash` integration.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
These are the things that silently break sites. Know them before you start.
|
||||
|
||||
1. **Image fields are objects, not strings.** `post.data.featured_image` is `{ id, src, alt }`. Writing `<img src={post.data.featured_image} />` renders `[object Object]`. Use `<Image image={post.data.featured_image} />` from `"emdash/ui"`.
|
||||
|
||||
2. **`entry.id` vs `entry.data.id` are different things.** `entry.id` is the slug (use in URLs). `entry.data.id` is the database ULID (use for `getEntryTerms`, `Comments`, and other API calls that need the real ID). Mixing them up causes silent empty results.
|
||||
|
||||
3. **Taxonomy names must match the seed exactly.** If your seed defines `"name": "category"`, you must query `getTerm("category", slug)` -- not `"categories"`. Wrong name = empty results, no error.
|
||||
|
||||
4. **Always pass `cacheHint` to `Astro.cache.set()`.** Every query returns a `cacheHint`. Call `Astro.cache.set(cacheHint)` on every page that queries content, or cache invalidation won't work when editors publish changes.
|
||||
|
||||
5. **No `getStaticPaths` for CMS content.** EmDash content is dynamic. Pages must be server-rendered (`output: "server"` in `astro.config.mjs`).
|
||||
|
||||
## File Structure
|
||||
|
||||
Every EmDash site has these key files:
|
||||
|
||||
```
|
||||
my-site/
|
||||
├── astro.config.mjs # Astro config with emdash() integration
|
||||
├── src/
|
||||
│ ├── live.config.ts # EmDash loader registration (boilerplate)
|
||||
│ ├── pages/ # Astro pages (all server-rendered)
|
||||
│ ├── layouts/ # Layout components
|
||||
│ └── components/ # Reusable components
|
||||
├── seed/
|
||||
│ └── seed.json # Schema + demo content
|
||||
├── emdash-env.d.ts # Generated types (from `emdash types`)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Configure the project
|
||||
|
||||
Read **[references/configuration.md](references/configuration.md)** for `astro.config.mjs`, `live.config.ts`, deployment targets (Node vs Cloudflare), and type generation.
|
||||
|
||||
### 2. Design the schema
|
||||
|
||||
Read **[references/schema-and-seed.md](references/schema-and-seed.md)** for collection definitions, field types, taxonomies, menus, widget areas, sections, bylines, and the complete seed file format.
|
||||
|
||||
### 3. Build the pages
|
||||
|
||||
Read **[references/querying-and-rendering.md](references/querying-and-rendering.md)** for content queries, Portable Text rendering, the Image component, visual editing attributes, caching, and common page patterns (list, detail, taxonomy archive, RSS, search, 404).
|
||||
|
||||
### 4. Wire up site features
|
||||
|
||||
Read **[references/site-features.md](references/site-features.md)** for site settings, navigation menus, taxonomies, widget areas, search, SEO meta, comments, and page contributions.
|
||||
|
||||
### 5. Create the seed file
|
||||
|
||||
Write `seed/seed.json` with collections, fields, taxonomies, menus, widgets, and sample content. Validate with:
|
||||
|
||||
```bash
|
||||
npx emdash seed seed/seed.json --validate
|
||||
```
|
||||
|
||||
### 6. Run and verify
|
||||
|
||||
```bash
|
||||
npx emdash dev # Start dev server (runs migrations + seeds, and generates types)
|
||||
```
|
||||
|
||||
The admin UI is at `http://localhost:4321/_emdash/admin`.
|
||||
|
||||
## Quick API Cheat Sheet
|
||||
|
||||
```typescript
|
||||
// Content (entries have .data.byline and .data.bylines eagerly loaded)
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
const { entries, nextCursor, cacheHint } = await getEmDashCollection("posts", {
|
||||
limit: 10,
|
||||
cursor,
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
|
||||
|
||||
// Site features
|
||||
import {
|
||||
getSiteSettings,
|
||||
getMenu,
|
||||
getTaxonomyTerms,
|
||||
getTerm,
|
||||
getEntryTerms,
|
||||
getEntriesByTerm,
|
||||
getWidgetArea,
|
||||
search,
|
||||
getSection,
|
||||
getSeoMeta,
|
||||
} from "emdash";
|
||||
|
||||
// Bylines (standalone queries -- usually not needed since entries have bylines attached)
|
||||
import { getEntryBylines, getBylinesForEntries, getByline, getBylineBySlug } from "emdash";
|
||||
|
||||
// UI components
|
||||
import {
|
||||
PortableText,
|
||||
Image,
|
||||
Comments,
|
||||
CommentForm,
|
||||
WidgetArea,
|
||||
EmDashHead,
|
||||
EmDashBodyStart,
|
||||
EmDashBodyEnd,
|
||||
} from "emdash/ui";
|
||||
import LiveSearch from "emdash/ui/search";
|
||||
|
||||
// Page context (for plugin contributions)
|
||||
import { createPublicPageContext } from "emdash/page";
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
EmDash supports plugins for extending the CMS with hooks, storage, settings, admin UI, API routes, and custom Portable Text block types. Consider a plugin when you need to:
|
||||
|
||||
- React to content lifecycle events (e.g., send a notification on publish, sync to an external service)
|
||||
- Add custom admin pages or dashboard widgets
|
||||
- Add custom block types to the Portable Text editor (e.g., embedded maps, code playgrounds, CTAs)
|
||||
- Provide a reusable service (e.g., analytics, forms, comments via a third-party provider)
|
||||
|
||||
Plugins are registered in `astro.config.mjs`:
|
||||
|
||||
```javascript
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
|
||||
plugins: [myPlugin()],
|
||||
}),
|
||||
```
|
||||
|
||||
**To build a plugin, load the `creating-plugins` skill** (in `.agents/skills/creating-plugins/`). It covers plugin anatomy, hooks, storage, admin UI, API routes, Portable Text blocks, capabilities, and the full `definePlugin()` API.
|
||||
|
||||
## Reference Documents
|
||||
|
||||
| File | Contents |
|
||||
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| [references/configuration.md](references/configuration.md) | Project setup, astro.config, live.config, deployment, types |
|
||||
| [references/schema-and-seed.md](references/schema-and-seed.md) | Collections, fields, taxonomies, menus, widgets, seed format |
|
||||
| [references/querying-and-rendering.md](references/querying-and-rendering.md) | Content APIs, PortableText, Image, caching, page patterns |
|
||||
| [references/site-features.md](references/site-features.md) | Settings, menus, widgets, search, SEO, comments, page contributions |
|
||||
@@ -0,0 +1,193 @@
|
||||
# Configuration
|
||||
|
||||
## astro.config.mjs
|
||||
|
||||
### Node.js (local development / self-hosted)
|
||||
|
||||
```javascript
|
||||
import node from "@astrojs/node";
|
||||
import react from "@astrojs/react";
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
image: {
|
||||
layout: "constrained",
|
||||
responsiveStyles: true,
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
devToolbar: { enabled: false },
|
||||
});
|
||||
```
|
||||
|
||||
### Cloudflare (D1 + R2)
|
||||
|
||||
```javascript
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import react from "@astrojs/react";
|
||||
import { d1, r2 } from "@emdashcms/cloudflare";
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: cloudflare(),
|
||||
image: {
|
||||
layout: "constrained",
|
||||
responsiveStyles: true,
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: d1({ binding: "DB", session: "auto" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
}),
|
||||
],
|
||||
devToolbar: { enabled: false },
|
||||
});
|
||||
```
|
||||
|
||||
Requires a `wrangler.jsonc` with D1 and R2 bindings:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "my-site",
|
||||
"compatibility_date": "2026-02-24",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"assets": { "directory": "./dist" },
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "my-site",
|
||||
"database_id": "", // from `wrangler d1 create my-site`
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "my-site-media",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Plugins
|
||||
|
||||
Register plugins in `astro.config.mjs`:
|
||||
|
||||
```javascript
|
||||
import { auditLogPlugin } from "@emdashcms/plugin-audit-log";
|
||||
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
|
||||
plugins: [auditLogPlugin()],
|
||||
}),
|
||||
```
|
||||
|
||||
## live.config.ts
|
||||
|
||||
Every EmDash site needs this file at `src/live.config.ts`. It's boilerplate -- the same in every project:
|
||||
|
||||
```typescript
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
```
|
||||
|
||||
This registers EmDash's live content collections with Astro. All content types are served through the single `_emdash` collection -- you query specific types using `getEmDashCollection("posts")` etc.
|
||||
|
||||
## emdash-env.d.ts
|
||||
|
||||
Auto-generated at the project root when the dev server starts. Provides TypeScript types for your collections. This is the file your `tsconfig.json` includes.
|
||||
|
||||
```typescript
|
||||
/// <reference types="emdash/locals" />
|
||||
|
||||
import type { PortableTextBlock } from "emdash";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
posts: Post;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The dev server regenerates this file automatically when schema changes. You can also generate it manually:
|
||||
|
||||
## Type Generation
|
||||
|
||||
```bash
|
||||
# From local dev server (writes emdash-env.d.ts at project root)
|
||||
npx emdash types
|
||||
|
||||
# From remote instance
|
||||
npx emdash types --url https://my-site.pages.dev
|
||||
|
||||
# Custom output path
|
||||
npx emdash types --output src/types/cms.ts
|
||||
```
|
||||
|
||||
The CLI also writes `.emdash/schema.json` with the raw schema for tooling.
|
||||
|
||||
## package.json
|
||||
|
||||
Key dependencies for a Node.js site:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"astro": "^6.0.0",
|
||||
"emdash": "workspace:*",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@astrojs/react": "^4.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Cloudflare, replace `@astrojs/node` with `@astrojs/cloudflare` and add `@emdashcms/cloudflare`.
|
||||
|
||||
## Dev Server
|
||||
|
||||
```bash
|
||||
npx emdash dev # Start dev server (runs migrations, applies seed)
|
||||
npx emdash dev --types # Start and generate types from schema
|
||||
```
|
||||
|
||||
The admin UI is at `http://localhost:4321/_emdash/admin`. On first run, you'll go through setup to create an admin account.
|
||||
@@ -0,0 +1,388 @@
|
||||
# Querying and Rendering Content
|
||||
|
||||
## Content Queries
|
||||
|
||||
All query functions are imported from `"emdash"`.
|
||||
|
||||
### getEmDashCollection
|
||||
|
||||
Fetch multiple entries from a collection. Returns `{ entries, error, cacheHint, nextCursor }`.
|
||||
|
||||
```typescript
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
// Basic
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
|
||||
// With options
|
||||
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
limit: 10,
|
||||
orderBy: { published_at: "desc" },
|
||||
where: { category: "news" },
|
||||
});
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `status` -- filter by status (`"published"`, `"draft"`, etc.)
|
||||
- `limit` -- max entries
|
||||
- `cursor` -- opaque cursor for keyset pagination (pass `nextCursor` from a previous result)
|
||||
- `orderBy` -- `{ field: "asc" | "desc" }` (default: `{ created_at: "desc" }`)
|
||||
- `where` -- filter by field values or taxonomy terms. Supports arrays for OR: `{ category: ["news", "featured"] }`
|
||||
- `locale` -- filter by locale (when i18n is configured)
|
||||
|
||||
### getEmDashEntry
|
||||
|
||||
Fetch a single entry by slug. Returns `{ entry, error, isPreview, cacheHint }`.
|
||||
|
||||
```typescript
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
```
|
||||
|
||||
### Entry Shape
|
||||
|
||||
```typescript
|
||||
interface ContentEntry<T> {
|
||||
id: string; // The slug (used in URLs)
|
||||
data: T; // All fields, including system fields
|
||||
edit: EditProxy; // Visual editing attributes (spread onto elements)
|
||||
}
|
||||
|
||||
// data includes system fields plus your custom fields:
|
||||
interface PostData {
|
||||
id: string; // Database ULID (use for taxonomy lookups, etc.)
|
||||
slug: string;
|
||||
status: string;
|
||||
title: string;
|
||||
featured_image?: {
|
||||
id: string;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
content?: PortableTextBlock[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
// Bylines (eagerly loaded)
|
||||
byline: BylineSummary | null; // Primary author
|
||||
bylines: ContentBylineCredit[]; // All credits (with roleLabel, source)
|
||||
// ... your custom fields
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** `entry.id` is the slug (for URLs), `entry.data.id` is the database ULID (for API calls like `getEntryTerms`).
|
||||
|
||||
### Caching
|
||||
|
||||
Query results include a `cacheHint` for Astro's Route Caching:
|
||||
|
||||
```astro
|
||||
---
|
||||
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
|
||||
Astro.cache.set(cacheHint);
|
||||
---
|
||||
```
|
||||
|
||||
Always call `Astro.cache.set(cacheHint)` -- it enables automatic cache invalidation when content changes.
|
||||
|
||||
## Rendering Portable Text
|
||||
|
||||
### PortableText component
|
||||
|
||||
```astro
|
||||
---
|
||||
import { PortableText } from "emdash/ui";
|
||||
---
|
||||
<PortableText value={post.data.content} />
|
||||
```
|
||||
|
||||
Renders standard blocks (paragraphs, headings, lists, blockquotes, code blocks, images) and inline marks (bold, italic, code, strikethrough, links).
|
||||
|
||||
### Custom block types
|
||||
|
||||
For custom PT blocks (e.g., marketing components), pass a `components` prop:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Hero from "./blocks/Hero.astro";
|
||||
import Features from "./blocks/Features.astro";
|
||||
|
||||
const customTypes = {
|
||||
"marketing.hero": Hero,
|
||||
"marketing.features": Features,
|
||||
};
|
||||
---
|
||||
<PortableText value={page.data.content} components={{ type: customTypes }} />
|
||||
```
|
||||
|
||||
Each custom component receives the block data as props.
|
||||
|
||||
## Image Component
|
||||
|
||||
**Always use the EmDash Image component for CMS images.** Image fields are objects, not strings.
|
||||
|
||||
```astro
|
||||
---
|
||||
import { Image } from "emdash/ui";
|
||||
---
|
||||
|
||||
{/* Correct -- passes the image object */}
|
||||
<Image image={post.data.featured_image} />
|
||||
|
||||
{/* Also works with explicit props */}
|
||||
{post.data.featured_image?.src && (
|
||||
<img src={post.data.featured_image.src} alt={post.data.featured_image.alt || ""} />
|
||||
)}
|
||||
```
|
||||
|
||||
**Common mistake:**
|
||||
|
||||
```astro
|
||||
{/* WRONG -- image is an object, not a string */}
|
||||
<img src={post.data.featured_image} />
|
||||
```
|
||||
|
||||
## Visual Editing Attributes
|
||||
|
||||
Entries include `edit` attributes for inline editing. Spread them onto the element that displays the field:
|
||||
|
||||
```astro
|
||||
<h1 {...post.edit.title}>{post.data.title}</h1>
|
||||
<p {...post.edit.excerpt}>{post.data.excerpt}</p>
|
||||
<div {...post.edit.featured_image}>
|
||||
<Image image={post.data.featured_image} />
|
||||
</div>
|
||||
```
|
||||
|
||||
When an admin is logged in and views the site, these attributes enable click-to-edit functionality.
|
||||
|
||||
## Common Page Patterns
|
||||
|
||||
### List page (e.g., `/posts/index.astro`)
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getEmDashCollection, getEntryTerms } from "emdash";
|
||||
import { Image } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const sortedPosts = posts.toSorted((a, b) => {
|
||||
const dateA = a.data.publishedAt?.getTime() ?? 0;
|
||||
const dateB = b.data.publishedAt?.getTime() ?? 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
---
|
||||
<Base title="Posts">
|
||||
{sortedPosts.map(post => (
|
||||
<article>
|
||||
{post.data.featured_image && <Image image={post.data.featured_image} />}
|
||||
<a href={`/posts/${post.id}`}>{post.data.title}</a>
|
||||
{post.data.excerpt && <p>{post.data.excerpt}</p>}
|
||||
</article>
|
||||
))}
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Detail page (e.g., `/posts/[slug].astro`)
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getEmDashEntry, getEntryTerms, getSeoMeta } from "emdash";
|
||||
import { Image, PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
if (!slug) return Astro.redirect("/404");
|
||||
|
||||
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
|
||||
if (!post) return Astro.redirect("/404");
|
||||
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const seo = getSeoMeta(post, {
|
||||
siteTitle: "My Blog",
|
||||
siteUrl: Astro.url.origin,
|
||||
path: `/posts/${slug}`,
|
||||
});
|
||||
|
||||
const tags = await getEntryTerms("posts", post.data.id, "tag");
|
||||
---
|
||||
<Base title={seo.title} description={seo.description}>
|
||||
<article>
|
||||
{post.data.featured_image && (
|
||||
<div {...post.edit.featured_image}>
|
||||
<Image image={post.data.featured_image} />
|
||||
</div>
|
||||
)}
|
||||
<h1 {...post.edit.title}>{post.data.title}</h1>
|
||||
<PortableText value={post.data.content} />
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
{tags.map(t => <a href={`/tag/${t.slug}`}>{t.label}</a>)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Taxonomy archive (e.g., `/category/[slug].astro`)
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getTerm, getEmDashCollection } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const term = slug ? await getTerm("category", slug) : null;
|
||||
if (!term) return Astro.redirect("/404");
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
where: { category: term.slug },
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
---
|
||||
<Base title={`${term.label} posts`}>
|
||||
<h1>{term.label}</h1>
|
||||
{posts.map(post => (
|
||||
<a href={`/posts/${post.id}`}>{post.data.title}</a>
|
||||
))}
|
||||
</Base>
|
||||
```
|
||||
|
||||
### RSS feed (e.g., `/rss.xml.ts`)
|
||||
|
||||
```typescript
|
||||
import type { APIRoute } from "astro";
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
const siteTitle = "My Site";
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
const siteUrl = url.origin;
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
orderBy: { published_at: "desc" },
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const items = posts
|
||||
.filter((p) => p.data.publishedAt)
|
||||
.map((post) => {
|
||||
const postUrl = `${siteUrl}/posts/${post.id}`;
|
||||
return ` <item>
|
||||
<title>${escapeXml(post.data.title)}</title>
|
||||
<link>${postUrl}</link>
|
||||
<guid isPermaLink="true">${postUrl}</guid>
|
||||
<pubDate>${post.data.publishedAt!.toUTCString()}</pubDate>
|
||||
<description>${escapeXml(post.data.excerpt || "")}</description>
|
||||
</item>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return new Response(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escapeXml(siteTitle)}</title>
|
||||
<link>${siteUrl}</link>
|
||||
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
```
|
||||
|
||||
### 404 page (`/404.astro`)
|
||||
|
||||
```astro
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
<Base title="Not Found">
|
||||
<h1>Page not found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="/">Go home</a>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Empty state
|
||||
|
||||
When a collection has no content, show a helpful empty state:
|
||||
|
||||
```astro
|
||||
{posts.length === 0 ? (
|
||||
<section>
|
||||
<h2>No posts yet</h2>
|
||||
<p>Create your first post in the admin panel.</p>
|
||||
<a href="/_emdash/admin/content/posts/new">Create a post</a>
|
||||
</section>
|
||||
) : (
|
||||
/* ... render posts ... */
|
||||
)}
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
`getEmDashCollection` supports cursor-based keyset pagination. Pass `cursor` from a previous result's `nextCursor` to get the next page:
|
||||
|
||||
```astro
|
||||
---
|
||||
const cursor = Astro.url.searchParams.get("cursor") ?? undefined;
|
||||
const { entries, nextCursor, cacheHint } = await getEmDashCollection("posts", {
|
||||
limit: 10,
|
||||
cursor,
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
Astro.cache.set(cacheHint);
|
||||
---
|
||||
{entries.map(post => (
|
||||
<a href={`/posts/${post.id}`}>{post.data.title}</a>
|
||||
))}
|
||||
{nextCursor && <a href={`?cursor=${nextCursor}`}>Next page</a>}
|
||||
```
|
||||
|
||||
`nextCursor` is `undefined` when there are no more results.
|
||||
|
||||
## Date Formatting
|
||||
|
||||
Dates come as `Date` objects. Use `toLocaleDateString` or `Intl.DateTimeFormat`:
|
||||
|
||||
```typescript
|
||||
const formatted = post.data.publishedAt?.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,469 @@
|
||||
# Schema and Seed Files
|
||||
|
||||
The seed file (`seed/seed.json`) defines the site's entire schema and optional demo content. It's applied on first run or via `npx emdash seed seed/seed.json`.
|
||||
|
||||
## Seed File Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {
|
||||
"name": "My Site",
|
||||
"description": "A description of this site",
|
||||
"author": "Author Name"
|
||||
},
|
||||
"settings": { ... },
|
||||
"collections": [ ... ],
|
||||
"taxonomies": [ ... ],
|
||||
"menus": [ ... ],
|
||||
"widgetAreas": [ ... ],
|
||||
"sections": [ ... ],
|
||||
"bylines": [ ... ],
|
||||
"content": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Collections
|
||||
|
||||
Collections define content types. Each collection becomes a database table (`ec_{slug}`).
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Posts",
|
||||
"labelSingular": "Post",
|
||||
"supports": ["drafts", "revisions", "search", "seo"],
|
||||
"commentsEnabled": true,
|
||||
"fields": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### Collection Supports
|
||||
|
||||
| Support | Description |
|
||||
| ----------- | ------------------------- |
|
||||
| `drafts` | Draft/published workflow |
|
||||
| `revisions` | Revision history |
|
||||
| `search` | Full-text search indexing |
|
||||
| `seo` | SEO meta fields in admin |
|
||||
|
||||
### Slug Rules
|
||||
|
||||
- Lowercase alphanumeric + underscores: `/^[a-z][a-z0-9_]*$/`
|
||||
- Max 63 characters
|
||||
- Cannot conflict with reserved slugs
|
||||
|
||||
## Field Types
|
||||
|
||||
| Type | Column type | Runtime shape | Notes |
|
||||
| -------------- | ----------- | ------------------------------------- | ---------------------------- |
|
||||
| `string` | TEXT | `string` | Single line text |
|
||||
| `text` | TEXT | `string` | Multi-line text (textarea) |
|
||||
| `number` | REAL | `number` | Floating point |
|
||||
| `integer` | INTEGER | `number` | Whole numbers |
|
||||
| `boolean` | INTEGER | `boolean` | Stored as 0/1 |
|
||||
| `datetime` | TEXT | `Date` | ISO 8601 string in DB |
|
||||
| `image` | TEXT | `{ id, src?, alt?, width?, height? }` | **Object, not a string** |
|
||||
| `reference` | TEXT | `string` (ID) | Reference to another entry |
|
||||
| `portableText` | JSON | `PortableTextBlock[]` | Rich text as structured JSON |
|
||||
| `json` | JSON | `any` | Arbitrary JSON data |
|
||||
|
||||
### Field Definition
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"searchable": true
|
||||
}
|
||||
```
|
||||
|
||||
Fields can have:
|
||||
|
||||
- `slug` (required) -- field identifier
|
||||
- `label` (required) -- display label in admin
|
||||
- `type` (required) -- one of the types above
|
||||
- `required` -- validation
|
||||
- `searchable` -- include in full-text search index
|
||||
|
||||
### Common Field Patterns
|
||||
|
||||
**Blog post:**
|
||||
|
||||
```json
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
|
||||
{ "slug": "featured_image", "label": "Featured Image", "type": "image" },
|
||||
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true },
|
||||
{ "slug": "excerpt", "label": "Excerpt", "type": "text" }
|
||||
]
|
||||
```
|
||||
|
||||
**Portfolio project:**
|
||||
|
||||
```json
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
|
||||
{ "slug": "featured_image", "label": "Featured Image", "type": "image", "required": true },
|
||||
{ "slug": "client", "label": "Client", "type": "string" },
|
||||
{ "slug": "year", "label": "Year", "type": "string" },
|
||||
{ "slug": "summary", "label": "Summary", "type": "text", "searchable": true },
|
||||
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true },
|
||||
{ "slug": "gallery", "label": "Gallery", "type": "json" },
|
||||
{ "slug": "url", "label": "Project URL", "type": "string" }
|
||||
]
|
||||
```
|
||||
|
||||
**Page (minimal):**
|
||||
|
||||
```json
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
|
||||
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true }
|
||||
]
|
||||
```
|
||||
|
||||
## Taxonomies
|
||||
|
||||
Taxonomies are tag/category systems attached to collections.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "category",
|
||||
"label": "Categories",
|
||||
"labelSingular": "Category",
|
||||
"hierarchical": true,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "development", "label": "Development" },
|
||||
{ "slug": "design", "label": "Design" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `hierarchical: true` -- tree structure (like WordPress categories)
|
||||
- `hierarchical: false` -- flat list (like WordPress tags)
|
||||
- `collections` -- which collections this taxonomy applies to
|
||||
- `terms` -- pre-defined terms to create
|
||||
|
||||
## Menus
|
||||
|
||||
Navigation menus, managed from the admin UI.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "primary",
|
||||
"label": "Primary Navigation",
|
||||
"items": [
|
||||
{ "type": "custom", "label": "Home", "url": "/" },
|
||||
{ "type": "custom", "label": "About", "url": "/pages/about" },
|
||||
{ "type": "custom", "label": "Posts", "url": "/posts" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Menu item types:
|
||||
|
||||
- `custom` -- arbitrary URL
|
||||
- Content references are resolved at render time
|
||||
|
||||
## Widget Areas
|
||||
|
||||
Named regions where editors can add configurable widgets.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "sidebar",
|
||||
"label": "Sidebar",
|
||||
"description": "Widget area displayed on single post pages",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:search",
|
||||
"title": "Search"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:categories",
|
||||
"title": "Categories"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:tags",
|
||||
"title": "Tags"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:recent-posts",
|
||||
"title": "Recent Posts",
|
||||
"settings": { "count": 5, "showDate": true }
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:archives",
|
||||
"title": "Archives",
|
||||
"settings": { "type": "monthly", "limit": 6 }
|
||||
},
|
||||
{
|
||||
"type": "content",
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "Some rich text content." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Widget types
|
||||
|
||||
| Type | Description | Key fields |
|
||||
| ----------- | ------------------------- | ------------------------- |
|
||||
| `content` | Rich text (Portable Text) | `content` |
|
||||
| `menu` | Navigation menu | `menuName` |
|
||||
| `component` | Core or custom component | `componentId`, `settings` |
|
||||
|
||||
### Core widget components
|
||||
|
||||
- `core:search` -- search form
|
||||
- `core:categories` -- category list with counts
|
||||
- `core:tags` -- tag cloud
|
||||
- `core:recent-posts` -- latest posts list
|
||||
- `core:archives` -- monthly archive links
|
||||
|
||||
## Sections (Reusable Blocks)
|
||||
|
||||
Reusable content blocks that editors can insert via `/section` slash command in the editor.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "newsletter-signup",
|
||||
"title": "Newsletter Signup",
|
||||
"description": "A call-to-action block for newsletter subscriptions",
|
||||
"keywords": ["newsletter", "subscribe", "email", "cta"],
|
||||
"source": "theme",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h3",
|
||||
"children": [{ "_type": "span", "text": "Stay in the loop" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "Get notified when new posts are published." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Bylines
|
||||
|
||||
Named author profiles, independent of user accounts.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "byline-editorial",
|
||||
"slug": "emdash-editorial",
|
||||
"displayName": "EmDash Editorial"
|
||||
}
|
||||
```
|
||||
|
||||
Guest bylines:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "byline-guest",
|
||||
"slug": "guest-contributor",
|
||||
"displayName": "Guest Contributor",
|
||||
"isGuest": true
|
||||
}
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
Site-wide settings:
|
||||
|
||||
```json
|
||||
"settings": {
|
||||
"title": "My Blog",
|
||||
"tagline": "Thoughts on building for the web"
|
||||
}
|
||||
```
|
||||
|
||||
Available keys: `title`, `tagline`, `logo`, `favicon`, `social`, `timezone`, `dateFormat`.
|
||||
|
||||
## Content
|
||||
|
||||
Sample content organized by collection slug:
|
||||
|
||||
```json
|
||||
"content": {
|
||||
"posts": [
|
||||
{
|
||||
"id": "post-1",
|
||||
"slug": "hello-world",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Hello World",
|
||||
"excerpt": "My first post.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-xxx?w=1200&h=800&fit=crop",
|
||||
"alt": "Description of image",
|
||||
"filename": "hello-world.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "This is the body text." }]
|
||||
}
|
||||
]
|
||||
},
|
||||
"bylines": [
|
||||
{ "byline": "byline-editorial" }
|
||||
],
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["webdev", "opinion"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"id": "about",
|
||||
"slug": "about",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "About this site." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Media references in seed content
|
||||
|
||||
Use `$media` for image fields -- EmDash downloads and stores the image:
|
||||
|
||||
```json
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-xxx?w=1200&h=800&fit=crop",
|
||||
"alt": "Description",
|
||||
"filename": "my-image.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For external images without downloading:
|
||||
|
||||
```json
|
||||
"featured_image": "https://images.unsplash.com/photo-xxx?w=1200"
|
||||
```
|
||||
|
||||
### Reference fields in seed content
|
||||
|
||||
Use `$ref:id` format to reference other entries:
|
||||
|
||||
```json
|
||||
"author": "$ref:byline-editorial"
|
||||
```
|
||||
|
||||
### Portable Text in seed content
|
||||
|
||||
Content fields of type `portableText` are arrays of blocks:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "A paragraph." }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "A heading" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "blockquote",
|
||||
"children": [{ "_type": "span", "text": "A quote." }]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Inline marks (bold, italic, links):
|
||||
|
||||
```json
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{ "_type": "span", "text": "This is " },
|
||||
{ "_type": "span", "text": "bold", "marks": ["strong"] },
|
||||
{ "_type": "span", "text": " and " },
|
||||
{ "_type": "span", "text": "italic", "marks": ["em"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Block styles: `normal`, `h1`-`h6`, `blockquote`.
|
||||
|
||||
### Draft content
|
||||
|
||||
Set `"status": "draft"` to create unpublished content:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "post-draft",
|
||||
"slug": "work-in-progress",
|
||||
"status": "draft",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npx emdash seed seed/seed.json --validate
|
||||
```
|
||||
|
||||
Catches:
|
||||
|
||||
- Image fields with raw URLs (should use `$media`)
|
||||
- Reference fields with raw IDs (should use `$ref:id`)
|
||||
- PortableText not an array or missing `_type`
|
||||
- Type mismatches (string vs number, etc.)
|
||||
|
||||
## Applying Seeds
|
||||
|
||||
```bash
|
||||
npx emdash seed seed/seed.json # Apply with content
|
||||
npx emdash seed seed/seed.json --no-content # Schema only (no sample content)
|
||||
```
|
||||
|
||||
## Exporting Seeds
|
||||
|
||||
```bash
|
||||
npx emdash export-seed # Schema only
|
||||
npx emdash export-seed --with-content # Schema + all content
|
||||
npx emdash export-seed --with-content=posts,pages # Specific collections
|
||||
```
|
||||
@@ -0,0 +1,495 @@
|
||||
# Site Features
|
||||
|
||||
## Site Settings
|
||||
|
||||
```typescript
|
||||
import { getSiteSettings, getSiteSetting } from "emdash";
|
||||
|
||||
// All settings
|
||||
const settings = await getSiteSettings();
|
||||
settings.title; // "My Site"
|
||||
settings.tagline; // "A description"
|
||||
settings.logo?.url; // Resolved media URL
|
||||
settings.favicon?.url;
|
||||
|
||||
// Single setting
|
||||
const title = await getSiteSetting("title");
|
||||
```
|
||||
|
||||
Available keys: `title`, `tagline`, `logo`, `favicon`, `social`, `timezone`, `dateFormat`.
|
||||
|
||||
Use these instead of hard-coding site name, logo, etc.
|
||||
|
||||
## Navigation Menus
|
||||
|
||||
```typescript
|
||||
import { getMenu, getMenus } from "emdash";
|
||||
|
||||
// Fetch a named menu
|
||||
const menu = await getMenu("primary");
|
||||
|
||||
// List all menus
|
||||
const menus = await getMenus();
|
||||
```
|
||||
|
||||
### Rendering a menu
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getMenu } from "emdash";
|
||||
const primaryMenu = await getMenu("primary");
|
||||
---
|
||||
<nav>
|
||||
{primaryMenu?.items.map(item => (
|
||||
<a href={item.url} target={item.target}>{item.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
```
|
||||
|
||||
### Nested menus (dropdowns)
|
||||
|
||||
```astro
|
||||
{primaryMenu?.items.map(item => (
|
||||
<li>
|
||||
<a href={item.url}>{item.label}</a>
|
||||
{item.children.length > 0 && (
|
||||
<ul class="submenu">
|
||||
{item.children.map(child => (
|
||||
<li><a href={child.url}>{child.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
```
|
||||
|
||||
### MenuItem shape
|
||||
|
||||
```typescript
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string; // Resolved URL
|
||||
target?: string; // "_blank" etc.
|
||||
children: MenuItem[];
|
||||
}
|
||||
```
|
||||
|
||||
## Taxonomies
|
||||
|
||||
```typescript
|
||||
import { getTaxonomyTerms, getTerm, getEntryTerms, getEntriesByTerm } from "emdash";
|
||||
|
||||
// All terms in a taxonomy (name must match your seed's "name" field exactly)
|
||||
const categories = await getTaxonomyTerms("category");
|
||||
const tags = await getTaxonomyTerms("tag");
|
||||
|
||||
// Single term by slug
|
||||
const term = await getTerm("category", "news");
|
||||
// { id, name, slug, label, children, count }
|
||||
|
||||
// Terms for a specific entry (use data.id, not entry.id!)
|
||||
const postCategories = await getEntryTerms("posts", post.data.id, "category");
|
||||
const postTags = await getEntryTerms("posts", post.data.id, "tag");
|
||||
|
||||
// Entries with a specific term
|
||||
const newsPosts = await getEntriesByTerm("posts", "category", "news");
|
||||
```
|
||||
|
||||
**Important:** The taxonomy name argument must match exactly what your seed defines in `"name"`. The blog seed uses `"category"` and `"tag"` (singular). Using `"categories"` returns empty results with no error.
|
||||
|
||||
**Important:** `getEntryTerms` takes the database ULID (`post.data.id`), not the slug (`post.id`).
|
||||
|
||||
### Displaying post terms
|
||||
|
||||
```astro
|
||||
---
|
||||
const tags = await getEntryTerms("posts", post.data.id, "tag");
|
||||
---
|
||||
{tags.map(t => (
|
||||
<a href={`/tag/${t.slug}`}>{t.label}</a>
|
||||
))}
|
||||
```
|
||||
|
||||
### Filtering by taxonomy
|
||||
|
||||
```astro
|
||||
---
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
where: { category: term.slug },
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
---
|
||||
```
|
||||
|
||||
## Widget Areas
|
||||
|
||||
Render a named widget area:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { WidgetArea } from "emdash/ui";
|
||||
---
|
||||
<aside>
|
||||
<WidgetArea name="sidebar" />
|
||||
</aside>
|
||||
```
|
||||
|
||||
The `WidgetArea` component automatically renders all widgets in the area (search, categories, tags, recent posts, rich text, etc.) with appropriate HTML and CSS classes.
|
||||
|
||||
### Manual widget rendering
|
||||
|
||||
For more control, use the `getWidgetArea` function:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getWidgetArea } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
const sidebar = await getWidgetArea("sidebar");
|
||||
---
|
||||
{sidebar?.widgets.map(widget => (
|
||||
<div class="widget">
|
||||
{widget.title && <h3>{widget.title}</h3>}
|
||||
{widget.type === "content" && widget.content && (
|
||||
<PortableText value={widget.content} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
## Search
|
||||
|
||||
### LiveSearch component (instant search)
|
||||
|
||||
```astro
|
||||
---
|
||||
import LiveSearch from "emdash/ui/search";
|
||||
---
|
||||
<LiveSearch
|
||||
placeholder="Search..."
|
||||
collections={["posts", "pages"]}
|
||||
/>
|
||||
```
|
||||
|
||||
Customizable CSS classes:
|
||||
|
||||
```astro
|
||||
<LiveSearch
|
||||
placeholder="Search..."
|
||||
class="site-search"
|
||||
inputClass="site-search-input"
|
||||
resultsClass="site-search-results"
|
||||
resultClass="site-search-result"
|
||||
collections={["posts", "pages"]}
|
||||
expandOnFocus={{ collapsed: "180px", expanded: "280px" }}
|
||||
/>
|
||||
```
|
||||
|
||||
Theme via CSS variables:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--emdash-search-bg: var(--color-bg);
|
||||
--emdash-search-text: var(--color-text);
|
||||
--emdash-search-muted: var(--color-muted);
|
||||
--emdash-search-border: var(--color-border);
|
||||
--emdash-search-hover: var(--color-surface);
|
||||
--emdash-search-highlight: var(--color-text);
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic search
|
||||
|
||||
```typescript
|
||||
import { search } from "emdash";
|
||||
|
||||
const results = await search("hello world", {
|
||||
collections: ["posts", "pages"],
|
||||
status: "published",
|
||||
limit: 20,
|
||||
});
|
||||
// { results: SearchResult[], total, nextCursor? }
|
||||
```
|
||||
|
||||
Each result has: `collection`, `id`, `title`, `slug`, `snippet` (HTML with `<mark>` highlights), `score`.
|
||||
|
||||
### Search page
|
||||
|
||||
```astro
|
||||
---
|
||||
import LiveSearch from "emdash/ui/search";
|
||||
import Base from "../layouts/Base.astro";
|
||||
|
||||
const query = Astro.url.searchParams.get("q") || "";
|
||||
---
|
||||
<Base title="Search">
|
||||
<h1>Search</h1>
|
||||
<LiveSearch
|
||||
placeholder="Search posts..."
|
||||
collections={["posts", "pages"]}
|
||||
/>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Keyboard shortcut
|
||||
|
||||
Add Cmd+K / Ctrl+K to focus search:
|
||||
|
||||
```html
|
||||
<script>
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
document.querySelector(".site-search-input")?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Search prerequisites
|
||||
|
||||
Search requires per-collection enablement:
|
||||
|
||||
1. In admin: Edit Content Type -> check "Search" in Features
|
||||
2. Mark fields as `"searchable": true` in the seed file
|
||||
3. Only searchable fields of searchable collections are indexed
|
||||
|
||||
## SEO Meta
|
||||
|
||||
Generate SEO meta from content entries:
|
||||
|
||||
```typescript
|
||||
import { getSeoMeta } from "emdash";
|
||||
|
||||
const seo = getSeoMeta(post, {
|
||||
siteTitle: "My Blog",
|
||||
siteUrl: Astro.url.origin,
|
||||
path: `/posts/${slug}`,
|
||||
defaultOgImage: featuredImageUrl, // Optional fallback
|
||||
});
|
||||
|
||||
// Returns: { title, description, canonical, ogImage, robots }
|
||||
```
|
||||
|
||||
Use in your layout's `<head>`:
|
||||
|
||||
```astro
|
||||
<title>{seo.title}</title>
|
||||
<meta name="description" content={seo.description} />
|
||||
<link rel="canonical" href={seo.canonical} />
|
||||
<meta property="og:image" content={seo.ogImage} />
|
||||
{seo.robots && <meta name="robots" content={seo.robots} />}
|
||||
```
|
||||
|
||||
## Comments
|
||||
|
||||
Built-in comments system:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { Comments, CommentForm } from "emdash/ui";
|
||||
---
|
||||
<Comments collection="posts" contentId={post.data.id} threaded />
|
||||
<CommentForm collection="posts" contentId={post.data.id} />
|
||||
```
|
||||
|
||||
Comments are enabled per-collection in the seed: `"commentsEnabled": true`.
|
||||
|
||||
## Page Contributions (Plugin Head/Body Injection)
|
||||
|
||||
Plugins can inject content into the `<head>` and `<body>` of pages. To support this, use the page contribution components:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
|
||||
import { createPublicPageContext } from "emdash/page";
|
||||
|
||||
const pageCtx = createPublicPageContext({
|
||||
Astro,
|
||||
kind: content ? "content" : "custom",
|
||||
pageType: "article",
|
||||
title: fullTitle,
|
||||
description,
|
||||
canonical,
|
||||
image,
|
||||
content: { collection: "posts", id: post.data.id, slug },
|
||||
});
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<!-- your meta tags -->
|
||||
<EmDashHead page={pageCtx} />
|
||||
</head>
|
||||
<body>
|
||||
<EmDashBodyStart page={pageCtx} />
|
||||
<!-- your content -->
|
||||
<EmDashBodyEnd page={pageCtx} />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
This enables plugins (analytics, tracking pixels, structured data, etc.) to contribute to any page.
|
||||
|
||||
## Bylines
|
||||
|
||||
Bylines are author profiles, independent of user accounts. They support guest authors and multi-author attribution with role labels.
|
||||
|
||||
### Eagerly loaded on entries
|
||||
|
||||
Bylines are automatically attached to every entry by the query layer:
|
||||
|
||||
```astro
|
||||
{/* Primary author */}
|
||||
{post.data.byline && (
|
||||
<span>{post.data.byline.displayName}</span>
|
||||
)}
|
||||
|
||||
{/* All credits (includes roleLabel for co-authors, guest essays, etc.) */}
|
||||
{post.data.bylines?.map(credit => (
|
||||
<span>
|
||||
{credit.byline.displayName}
|
||||
{credit.roleLabel && <em> ({credit.roleLabel})</em>}
|
||||
</span>
|
||||
))}
|
||||
```
|
||||
|
||||
- `entry.data.byline` -- primary `BylineSummary` or `null`
|
||||
- `entry.data.bylines` -- array of `ContentBylineCredit` (each has `.byline`, `.roleLabel`, `.source`)
|
||||
|
||||
### Standalone query functions
|
||||
|
||||
```typescript
|
||||
import { getEntryBylines, getByline, getBylineBySlug, getBylinesForEntries } from "emdash";
|
||||
|
||||
// Bylines for a single entry
|
||||
const credits = await getEntryBylines("posts", post.data.id);
|
||||
|
||||
// Batch-fetch for a list page (avoids N+1)
|
||||
const ids = entries.map((e) => e.data.id);
|
||||
const bylinesMap = await getBylinesForEntries("posts", ids);
|
||||
// bylinesMap.get(entryId) => ContentBylineCredit[]
|
||||
|
||||
// Look up a specific byline
|
||||
const byline = await getBylineBySlug("jane-doe");
|
||||
```
|
||||
|
||||
### BylineSummary shape
|
||||
|
||||
```typescript
|
||||
interface BylineSummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio: string | null;
|
||||
avatarMediaId: string | null;
|
||||
websiteUrl: string | null;
|
||||
isGuest: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### ContentBylineCredit shape
|
||||
|
||||
```typescript
|
||||
interface ContentBylineCredit {
|
||||
byline: BylineSummary;
|
||||
sortOrder: number;
|
||||
roleLabel: string | null; // e.g., "Guest essay", "Photographer"
|
||||
source?: "explicit" | "inferred"; // "inferred" = fallback from author_id
|
||||
}
|
||||
```
|
||||
|
||||
## Dark Mode Pattern
|
||||
|
||||
Cookie-based theme switching (no flash on load):
|
||||
|
||||
```html
|
||||
<!-- In <head>, before styles load -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var c = document.cookie;
|
||||
var i = c.indexOf("theme=");
|
||||
var theme = i >= 0 ? c.slice(i + 6).split(";")[0] : null;
|
||||
if (theme === "dark" || theme === "light") {
|
||||
document.documentElement.classList.add(theme);
|
||||
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
Then use CSS variables that change based on `.dark` class:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-text: #1a1a1a;
|
||||
}
|
||||
:root.dark {
|
||||
--color-bg: #0d0d0d;
|
||||
--color-text: #ededed;
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Pattern
|
||||
|
||||
A typical base layout:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getMenu, getEmDashCollection } from "emdash";
|
||||
import { WidgetArea, EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
|
||||
import { createPublicPageContext } from "emdash/page";
|
||||
import LiveSearch from "emdash/ui/search";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
image?: string | null;
|
||||
content?: { collection: string; id: string; slug?: string | null };
|
||||
}
|
||||
|
||||
const { title, description, image, content } = Astro.props;
|
||||
const menu = await getMenu("primary");
|
||||
|
||||
const pageCtx = createPublicPageContext({
|
||||
Astro,
|
||||
kind: content ? "content" : "custom",
|
||||
pageType: "website",
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
content,
|
||||
});
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
<EmDashHead page={pageCtx} />
|
||||
</head>
|
||||
<body>
|
||||
<EmDashBodyStart page={pageCtx} />
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">My Site</a>
|
||||
<LiveSearch placeholder="Search..." collections={["posts", "pages"]} />
|
||||
{menu?.items.map(item => (
|
||||
<a href={item.url}>{item.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<footer>
|
||||
<WidgetArea name="footer" />
|
||||
</footer>
|
||||
<EmDashBodyEnd page={pageCtx} />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
457
templates/marketing/.agents/skills/creating-plugins/SKILL.md
Normal file
457
templates/marketing/.agents/skills/creating-plugins/SKILL.md
Normal file
@@ -0,0 +1,457 @@
|
||||
---
|
||||
name: creating-plugins
|
||||
description: Create EmDash CMS plugins with hooks, storage, settings, admin UI, API routes, and Portable Text block types. Use this skill when asked to build, scaffold, or implement an EmDash plugin, or when creating plugin features like custom block types, admin pages, or content hooks.
|
||||
---
|
||||
|
||||
# Creating EmDash Plugins
|
||||
|
||||
EmDash plugins extend the CMS with hooks, storage, settings, admin UI, API routes, and custom Portable Text block types. All plugins are TypeScript packages.
|
||||
|
||||
## Plugin Types
|
||||
|
||||
EmDash has two plugin formats:
|
||||
|
||||
| Type | Format | Admin UI | Where it runs |
|
||||
| ------------ | ------------------------------------------------------- | ------------------ | ------------------------------------------- |
|
||||
| **Standard** | `definePlugin({ hooks, routes })` | Block Kit | Isolate on Cloudflare, in-process elsewhere |
|
||||
| **Native** | `createPlugin()` / `definePlugin()` with `id`+`version` | React or Block Kit | Always in host isolate |
|
||||
|
||||
**Standard is the default.** Most plugins should use it. Standard plugins can be published to the marketplace and work in both trusted and sandboxed modes.
|
||||
|
||||
**Native is an escape hatch** for plugins that need React admin components, direct DB access, or custom Astro components. Native plugins can only run in `plugins: []` -- they cannot be sandboxed or published to the marketplace.
|
||||
|
||||
## Plugin Anatomy
|
||||
|
||||
Every plugin has two parts that **run in different contexts**:
|
||||
|
||||
1. **Plugin descriptor** (`PluginDescriptor`) — returned by the factory function in `index.ts`. Declares metadata (id, version, capabilities, storage). **Runs at build time in Vite** (imported in `astro.config.mjs`). Must be side-effect-free.
|
||||
2. **Plugin definition** (`definePlugin()`) — contains the runtime logic (hooks, routes). **Runs at request time on the deployed server.** Has access to the full plugin context (`ctx`). Lives in a separate file (typically `sandbox-entry.ts`).
|
||||
|
||||
These must be in **separate entrypoints** because they execute in completely different environments:
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
├── src/
|
||||
│ ├── index.ts # Descriptor factory (runs in Vite at build time)
|
||||
│ ├── sandbox-entry.ts # Plugin definition with definePlugin() (runs at deploy time)
|
||||
│ ├── admin.tsx # Admin UI exports (React) — optional, native only
|
||||
│ └── astro/ # Site-side rendering components — optional, native only
|
||||
│ └── index.ts # Must export `blockComponents`
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Minimal Plugin (Standard Format)
|
||||
|
||||
The simplest possible plugin -- just hooks:
|
||||
|
||||
```typescript
|
||||
// src/index.ts — descriptor factory, runs in Vite at build time
|
||||
import type { PluginDescriptor } from "emdash";
|
||||
|
||||
export function myPlugin(): PluginDescriptor {
|
||||
return {
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
format: "standard",
|
||||
entrypoint: "@my-org/my-plugin/sandbox",
|
||||
options: {},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/sandbox-entry.ts — plugin definition, runs at request time
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginContext } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler: async (event: any, ctx: PluginContext) => {
|
||||
ctx.log.info(`Saved ${event.collection}/${event.content.id}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The descriptor is what gets imported in `astro.config.mjs`. The `entrypoint` field points to the module containing the `definePlugin()` default export. For standard plugins, this is the `./sandbox` export from `package.json`.
|
||||
|
||||
Key differences from native format:
|
||||
|
||||
- No `id`, `version`, or `capabilities` in `definePlugin()` -- those live in the descriptor
|
||||
- `definePlugin()` is an identity function providing type inference
|
||||
- Hook handlers use `(event, ctx)` two-arg pattern
|
||||
- Route handlers use `(routeCtx, ctx)` two-arg pattern
|
||||
- Exported as `default` (not a factory function)
|
||||
|
||||
## Plugin ID Rules
|
||||
|
||||
- Lowercase alphanumeric + hyphens only
|
||||
- Simple (`my-plugin`) or scoped (`@my-org/my-plugin`)
|
||||
- Unique across all installed plugins
|
||||
|
||||
## Registration
|
||||
|
||||
The descriptor is imported in `astro.config.mjs` (Vite context):
|
||||
|
||||
```typescript
|
||||
import { myPlugin } from "@my-org/my-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [myPlugin()], // runs in-process
|
||||
// OR
|
||||
sandboxed: [myPlugin()], // runs in isolate on Cloudflare
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Standard plugins work in either array. Native plugins only work in `plugins: []`.
|
||||
|
||||
## Trusted vs Sandboxed Plugins
|
||||
|
||||
EmDash has two execution modes. Plugin code is identical in both — only the enforcement changes.
|
||||
|
||||
| | Trusted | Sandboxed |
|
||||
| ------------------- | ----------------------------------------- | ------------------------------------------------------ |
|
||||
| **Runs in** | Main process | Isolated V8 isolate (Dynamic Worker Loader) |
|
||||
| **Install method** | `astro.config.mjs` (code change + deploy) | Admin UI (one-click from marketplace) |
|
||||
| **Capabilities** | Advisory (not enforced) | Enforced at runtime via RPC bridge |
|
||||
| **Resource limits** | None | CPU 50ms, 10 subrequests, 30s wall-time, ~128MB memory |
|
||||
| **Network access** | Unrestricted | Blocked; only via `ctx.http` with `allowedHosts` |
|
||||
| **Data access** | Full database access | Scoped to declared capabilities |
|
||||
| **Node.js APIs** | Full access | Not available (V8 isolate only) |
|
||||
| **Available on** | All platforms | Cloudflare Workers only |
|
||||
| **Best for** | First-party code, reviewed npm packages | Third-party extensions, marketplace plugins |
|
||||
|
||||
### Trusted Mode
|
||||
|
||||
Trusted plugins are npm packages or local files added in `astro.config.mjs`. They run in-process with your Astro site.
|
||||
|
||||
- **Capabilities are documentation only.** Declaring `["read:content"]` documents intent but isn't enforced — the plugin has full process access.
|
||||
- Only install from sources you trust. A malicious trusted plugin has the same access as your application code.
|
||||
|
||||
### Sandboxed Mode
|
||||
|
||||
Sandboxed plugins run in isolated V8 isolates on Cloudflare Workers via [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/). Each plugin gets its own isolate.
|
||||
|
||||
- **Capabilities are enforced.** If a plugin declares `["read:content"]`, it can only call `ctx.content.get()` and `ctx.content.list()`. Attempting `ctx.content.create()` throws a permission error.
|
||||
- **Network is blocked by default.** Direct `fetch()` calls fail. Plugins must use `ctx.http.fetch()`, which validates against `allowedHosts`.
|
||||
- **Storage is scoped.** A plugin can only access its own KV and storage collections.
|
||||
- **Admin UI uses Block Kit.** Sandboxed plugins describe their UI as JSON blocks -- no plugin JavaScript runs in the browser. See [Block Kit reference](./references/block-kit.md).
|
||||
- **No Portable Text block types.** PT blocks require Astro components for site-side rendering (`componentsEntry`), which are loaded at build time from npm. Sandboxed plugins are installed at runtime and can't ship components. PT blocks are a native-plugin-only feature.
|
||||
- **Routes work.** Standard plugin routes are available in both trusted and sandboxed modes via the sandbox runner's `invokeRoute()` RPC.
|
||||
|
||||
Sandboxing is not available on Node.js. All plugins run in trusted mode on non-Cloudflare platforms.
|
||||
|
||||
### Developing for Both Modes
|
||||
|
||||
Write the same code. Develop locally in trusted mode (faster iteration, easier debugging). Deploy to sandboxed mode in production without code changes. With the standard format, the same entrypoint serves both modes -- no separate sandbox entry needed.
|
||||
|
||||
```typescript
|
||||
// src/sandbox-entry.ts -- works in both trusted and sandboxed modes
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginContext } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler: async (event: any, ctx: PluginContext) => {
|
||||
// Trusted: ctx.http present because descriptor declares network:fetch
|
||||
// Sandboxed: ctx.http present and enforced via RPC bridge
|
||||
if (!ctx.http) return;
|
||||
await ctx.http.fetch("https://api.analytics.example.com/track", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ contentId: event.content.id }),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Key constraint for sandbox compatibility: **no Node.js built-ins** (`fs`, `path`, `child_process`, etc.) in backend code. Use Web APIs instead.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Capabilities control what APIs are available on `ctx`. Always declare what your plugin needs — even in trusted mode, they document intent and are required for sandboxed execution.
|
||||
|
||||
| Capability | Grants | `ctx` property |
|
||||
| ----------------- | ---------------------------------------------------------------------- | -------------- |
|
||||
| `read:content` | `ctx.content.get()`, `ctx.content.list()` | `content` |
|
||||
| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` |
|
||||
| `read:media` | `ctx.media.get()`, `ctx.media.list()` | `media` |
|
||||
| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` |
|
||||
| `network:fetch` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` |
|
||||
| `read:users` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` |
|
||||
| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` |
|
||||
| `email:provide` | Can register `email:deliver` exclusive hook (transport provider) | — |
|
||||
| `email:intercept` | Can register `email:beforeSend` / `email:afterSend` hooks | — |
|
||||
|
||||
Storage (`ctx.storage`) and KV (`ctx.kv`) are **always available** — no capability needed. They're automatically scoped to the plugin.
|
||||
|
||||
**Email capabilities are distinct:**
|
||||
|
||||
- `email:send` — for plugins that _consume_ email (call `ctx.email.send()`)
|
||||
- `email:provide` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP)
|
||||
- `email:intercept` — for plugins that _observe or transform_ email (middleware hooks)
|
||||
|
||||
```typescript
|
||||
// In the descriptor (index.ts)
|
||||
export function myPlugin(): PluginDescriptor {
|
||||
return {
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
format: "standard",
|
||||
entrypoint: "@my-org/my-plugin/sandbox",
|
||||
options: {},
|
||||
capabilities: ["read:content", "network:fetch"],
|
||||
allowedHosts: ["api.example.com", "*.googleapis.com"], // Wildcards supported
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
When a marketplace plugin is installed, the admin sees a capability consent dialog listing what the plugin can access. Users must approve before installation.
|
||||
|
||||
## Publishing to the Marketplace
|
||||
|
||||
Standard plugins can be published to the EmDash Marketplace for one-click installation:
|
||||
|
||||
```bash
|
||||
emdash plugin bundle --dir packages/plugins/my-plugin # creates .tar.gz
|
||||
emdash plugin login # authenticate via GitHub
|
||||
emdash plugin publish --tarball dist/my-plugin-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
See [Publishing Reference](./references/publishing.md) for bundle format, validation, and security audit details.
|
||||
|
||||
## Package Exports
|
||||
|
||||
Configure `package.json` exports so EmDash can load each entry point:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@my-org/my-plugin",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./sandbox": "./src/sandbox-entry.ts",
|
||||
"./admin": "./src/admin.tsx"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"emdash": "^0.1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Export | Context | Purpose |
|
||||
| ------------- | ----------------- | ---------------------------------------------------------------------- |
|
||||
| `"."` | Vite (build time) | Descriptor factory -- imported in `astro.config.mjs` |
|
||||
| `"./sandbox"` | Server (runtime) | `definePlugin({ hooks, routes })` -- loaded by `entrypoint` at runtime |
|
||||
| `"./admin"` | Browser | React components for admin pages/widgets (native plugins only) |
|
||||
| `"./astro"` | Server (SSR) | Astro components for site-side block rendering (native plugins only) |
|
||||
|
||||
The `"."` export has the descriptor. The `"./sandbox"` export has the implementation. The descriptor's `entrypoint` field points to `"./sandbox"`. Only include `./admin` and `./astro` exports for native-format plugins.
|
||||
|
||||
## Plugin Features
|
||||
|
||||
Each feature is optional. Add only what your plugin needs:
|
||||
|
||||
| Feature | Where | Standard | Native | Purpose |
|
||||
| ------------------- | ---------------------------- | -------- | ------ | ------------------------------------------------------- |
|
||||
| **Hooks** | `definePlugin({ hooks })` | Yes | Yes | React to content/media/lifecycle events |
|
||||
| **Storage** | descriptor `storage` | Yes | Yes | Document collections with indexed queries |
|
||||
| **KV** | `ctx.kv` in hooks/routes | Yes | Yes | Key-value store for internal state |
|
||||
| **API Routes** | `definePlugin({ routes })` | Yes | Yes | REST endpoints at `/_emdash/api/plugins/<id>/<route>` |
|
||||
| **Admin Pages** | Block Kit `admin` route | Yes | Yes | Admin pages via Block Kit (JSON blocks) |
|
||||
| **Widgets** | Block Kit `admin` route | Yes | Yes | Dashboard cards via Block Kit |
|
||||
| **React Admin** | `admin.entry` + React export | No | Yes | React-based admin pages and widgets (native only) |
|
||||
| **PT Blocks** | `admin.portableTextBlocks` | No | Yes | Custom block types in the Portable Text editor |
|
||||
| **Site Components** | `componentsEntry` | No | Yes | Astro components for rendering blocks on the site |
|
||||
|
||||
See the reference files for detailed syntax:
|
||||
|
||||
- **[Hooks Reference](./references/hooks.md)** — All hook types, signatures, configuration
|
||||
- **[Storage & Settings](./references/storage.md)** — Collections, KV, settings schema
|
||||
- **[Admin UI](./references/admin-ui.md)** — Pages, widgets, entry point structure
|
||||
- **[API Routes](./references/api-routes.md)** — Route handlers, validation, context
|
||||
- **[Block Kit](./references/block-kit.md)** — Declarative UI for sandboxed plugins (similar to Slack Block Kit but not identical)
|
||||
- **[Portable Text Blocks](./references/portable-text-blocks.md)** — Custom block types + frontend rendering
|
||||
- **[Publishing](./references/publishing.md)** — Bundle format, validation, marketplace publishing
|
||||
|
||||
## Complete Example: Standard Plugin with Hooks, Routes, and Storage
|
||||
|
||||
```typescript
|
||||
// src/index.ts — descriptor factory, runs in Vite at build time
|
||||
import type { PluginDescriptor } from "emdash";
|
||||
|
||||
export function submissionsPlugin(): PluginDescriptor {
|
||||
return {
|
||||
id: "submissions",
|
||||
version: "1.0.0",
|
||||
format: "standard",
|
||||
entrypoint: "@my-org/plugin-submissions/sandbox",
|
||||
options: {},
|
||||
capabilities: ["read:content"],
|
||||
storage: {
|
||||
submissions: {
|
||||
indexes: ["formId", "status", "createdAt"],
|
||||
},
|
||||
},
|
||||
adminPages: [{ path: "/submissions", label: "Submissions", icon: "list" }],
|
||||
adminWidgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/sandbox-entry.ts — plugin definition, runs at request time
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginContext } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
hooks: {
|
||||
"plugin:install": {
|
||||
handler: async (_event: any, ctx: PluginContext) => {
|
||||
ctx.log.info("Submissions plugin installed");
|
||||
await ctx.kv.set("settings:maxSubmissions", 1000);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
routes: {
|
||||
submit: {
|
||||
public: true, // No auth required
|
||||
handler: async (routeCtx: any, ctx: PluginContext) => {
|
||||
const { formId, ...data } = routeCtx.input as Record<string, unknown>;
|
||||
|
||||
const count = await ctx.storage.submissions.count({ formId });
|
||||
const max = (await ctx.kv.get<number>("settings:maxSubmissions")) ?? 1000;
|
||||
|
||||
if (count >= max) {
|
||||
return { success: false, error: "Submission limit reached" };
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
await ctx.storage.submissions.put(id, {
|
||||
formId,
|
||||
data,
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { success: true, id };
|
||||
},
|
||||
},
|
||||
|
||||
list: {
|
||||
handler: async (routeCtx: any, ctx: PluginContext) => {
|
||||
const url = new URL(routeCtx.request.url);
|
||||
const limit = Math.max(
|
||||
1,
|
||||
Math.min(parseInt(url.searchParams.get("limit") || "50", 10) || 50, 100),
|
||||
);
|
||||
const cursor = url.searchParams.get("cursor") || undefined;
|
||||
|
||||
const result = await ctx.storage.submissions.query({
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item: any) => ({ id: item.id, ...item.data })),
|
||||
cursor: result.cursor,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Block Kit admin handler for pages and widgets
|
||||
admin: {
|
||||
handler: async (routeCtx: any, ctx: PluginContext) => {
|
||||
const interaction = routeCtx.input as { type: string; page?: string };
|
||||
|
||||
if (interaction.type === "page_load" && interaction.page === "/submissions") {
|
||||
const result = await ctx.storage.submissions.query({
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 50,
|
||||
});
|
||||
return {
|
||||
blocks: [
|
||||
{ type: "header", text: "Submissions" },
|
||||
{
|
||||
type: "table",
|
||||
blockId: "submissions-table",
|
||||
columns: [
|
||||
{ key: "formId", label: "Form", format: "text" },
|
||||
{ key: "status", label: "Status", format: "badge" },
|
||||
{ key: "createdAt", label: "Date", format: "relative_time" },
|
||||
],
|
||||
rows: result.items.map((item: any) => item.data),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { blocks: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Plugin Context
|
||||
|
||||
All hooks and routes receive `ctx` (PluginContext):
|
||||
|
||||
```typescript
|
||||
interface PluginContext {
|
||||
plugin: { id: string; version: string };
|
||||
storage: Record<string, StorageCollection>; // Declared collections
|
||||
kv: KVAccess; // Key-value store
|
||||
log: LogAccess; // Structured logger
|
||||
content?: ContentAccess; // If "read:content" capability
|
||||
media?: MediaAccess; // If "read:media" capability
|
||||
http?: HttpAccess; // If "network:fetch" capability
|
||||
users?: UserAccess; // If "read:users" capability
|
||||
cron?: CronAccess; // Always available — scoped to plugin
|
||||
email?: EmailAccess; // If "email:send" capability AND a provider is configured
|
||||
}
|
||||
```
|
||||
|
||||
Capabilities are declared in the **descriptor** (not in `definePlugin()` for standard format):
|
||||
|
||||
```typescript
|
||||
// In the descriptor
|
||||
export function myPlugin(): PluginDescriptor {
|
||||
return {
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
format: "standard",
|
||||
entrypoint: "@my-org/my-plugin/sandbox",
|
||||
options: {},
|
||||
capabilities: ["read:content", "network:fetch"],
|
||||
allowedHosts: ["api.example.com"],
|
||||
storage: { events: { indexes: ["timestamp"] } },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Output Checklist
|
||||
|
||||
When creating a standard-format plugin, provide:
|
||||
|
||||
1. **`src/index.ts`** -- Descriptor factory (runs in Vite at build time)
|
||||
2. **`src/sandbox-entry.ts`** -- `definePlugin({ hooks, routes })` as default export (runs at request time)
|
||||
3. **`package.json`** -- With exports `"."` (descriptor) and `"./sandbox"` (implementation)
|
||||
4. **`tsconfig.json`** -- Standard TypeScript config
|
||||
|
||||
For native-format plugins (React admin, PT blocks, Astro components), also provide:
|
||||
|
||||
5. **`src/admin.tsx`** -- Admin entry point with React components
|
||||
6. **`src/astro/index.ts`** -- Block components export (if PT blocks)
|
||||
@@ -0,0 +1,191 @@
|
||||
# Admin UI
|
||||
|
||||
Plugins extend the admin panel with React pages and dashboard widgets.
|
||||
|
||||
## Entry Point
|
||||
|
||||
Export pages and widgets from `src/admin.tsx`:
|
||||
|
||||
```typescript
|
||||
// src/admin.tsx
|
||||
import { SettingsPage } from "./components/SettingsPage";
|
||||
import { ReportsPage } from "./components/ReportsPage";
|
||||
import { StatusWidget } from "./components/StatusWidget";
|
||||
|
||||
// Pages keyed by path (must match admin.pages paths)
|
||||
export const pages = {
|
||||
"/settings": SettingsPage,
|
||||
"/reports": ReportsPage,
|
||||
};
|
||||
|
||||
// Widgets keyed by ID (must match admin.widgets IDs)
|
||||
export const widgets = {
|
||||
status: StatusWidget,
|
||||
};
|
||||
```
|
||||
|
||||
Reference in plugin definition:
|
||||
|
||||
```typescript
|
||||
definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
|
||||
admin: {
|
||||
entry: "@my-org/my-plugin/admin",
|
||||
pages: [
|
||||
{ path: "/settings", label: "Settings", icon: "settings" },
|
||||
{ path: "/reports", label: "Reports", icon: "chart" },
|
||||
],
|
||||
widgets: [{ id: "status", title: "Status", size: "half" }],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Pages mount at `/_emdash/admin/plugins/<plugin-id>/<path>`.
|
||||
|
||||
## Pages
|
||||
|
||||
React components. Use `usePluginAPI()` to call plugin routes.
|
||||
|
||||
```typescript
|
||||
// src/components/SettingsPage.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePluginAPI } from "@emdashcms/admin";
|
||||
|
||||
export function SettingsPage() {
|
||||
const api = usePluginAPI();
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("settings").then(setSettings);
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await api.post("settings/save", settings);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<label>
|
||||
Site Title
|
||||
<input
|
||||
type="text"
|
||||
value={settings.siteTitle || ""}
|
||||
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Widgets
|
||||
|
||||
Dashboard cards with at-a-glance info.
|
||||
|
||||
```typescript
|
||||
// src/components/StatusWidget.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePluginAPI } from "@emdashcms/admin";
|
||||
|
||||
export function StatusWidget() {
|
||||
const api = usePluginAPI();
|
||||
const [data, setData] = useState({ count: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
api.get("status").then(setData);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="widget-content">
|
||||
<div className="score">{data.count}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Sizes
|
||||
|
||||
| Size | Width |
|
||||
| ------- | -------------------- |
|
||||
| `full` | Full dashboard width |
|
||||
| `half` | Half width |
|
||||
| `third` | One-third width |
|
||||
|
||||
## usePluginAPI()
|
||||
|
||||
Auto-prefixes plugin ID to route URLs:
|
||||
|
||||
```typescript
|
||||
const api = usePluginAPI();
|
||||
|
||||
const data = await api.get("status"); // GET /.../plugins/<id>/status
|
||||
await api.post("settings/save", { enabled: true }); // POST with body
|
||||
const result = await api.get("history?limit=50"); // Query params
|
||||
```
|
||||
|
||||
## Admin Components
|
||||
|
||||
Pre-built components from `@emdashcms/admin`:
|
||||
|
||||
```typescript
|
||||
import { Card, Button, Input, Select, Toggle, Table, Loading, Alert } from "@emdashcms/admin";
|
||||
```
|
||||
|
||||
## Auto-Generated Settings
|
||||
|
||||
If your plugin only needs settings, skip custom pages — use `settingsSchema` and EmDash generates the form:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
apiKey: { type: "secret", label: "API Key" },
|
||||
enabled: { type: "boolean", label: "Enabled", default: true },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
Admin components need a separate build entry:
|
||||
|
||||
```typescript
|
||||
// tsdown.config.ts
|
||||
export default {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
admin: "src/admin.tsx",
|
||||
},
|
||||
format: "esm",
|
||||
dts: true,
|
||||
external: ["react", "react-dom", "emdash", "@emdashcms/admin"],
|
||||
};
|
||||
```
|
||||
|
||||
Keep React and `@emdashcms/admin` as externals to avoid bundling duplicates.
|
||||
|
||||
## Plugin Descriptor
|
||||
|
||||
The descriptor (returned by factory function) also declares admin metadata:
|
||||
|
||||
```typescript
|
||||
export function myPlugin(options = {}): PluginDescriptor {
|
||||
return {
|
||||
id: "my-plugin",
|
||||
entrypoint: "@my-org/my-plugin",
|
||||
version: "1.0.0",
|
||||
options,
|
||||
adminEntry: "@my-org/my-plugin/admin",
|
||||
adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }],
|
||||
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,265 @@
|
||||
# API Routes
|
||||
|
||||
Plugin routes work in both standard and native plugins, and in both trusted and sandboxed modes. Sandboxed plugin routes are invoked via the sandbox runner's `invokeRoute()` RPC.
|
||||
|
||||
Plugin routes expose REST endpoints at `/_emdash/api/plugins/<plugin-id>/<route-name>`.
|
||||
|
||||
## Defining Routes
|
||||
|
||||
```typescript
|
||||
import { definePlugin } from "emdash";
|
||||
import { z } from "astro/zod";
|
||||
|
||||
definePlugin({
|
||||
id: "forms",
|
||||
version: "1.0.0",
|
||||
|
||||
routes: {
|
||||
// Simple route
|
||||
status: {
|
||||
handler: async (ctx) => {
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
|
||||
// Route with input validation
|
||||
submissions: {
|
||||
input: z.object({
|
||||
formId: z.string().optional(),
|
||||
limit: z.number().default(50),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const { formId, limit, cursor } = ctx.input;
|
||||
const result = await ctx.storage.submissions!.query({
|
||||
where: formId ? { formId } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
return {
|
||||
items: result.items,
|
||||
cursor: result.cursor,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Nested path
|
||||
"settings/save": {
|
||||
input: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
for (const [key, value] of Object.entries(ctx.input)) {
|
||||
if (value !== undefined) {
|
||||
await ctx.kv.set(`settings:${key}`, value);
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Route URLs
|
||||
|
||||
| Plugin ID | Route Name | URL |
|
||||
| --------- | --------------- | ------------------------------------------ |
|
||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||
|
||||
## Handler Context
|
||||
|
||||
```typescript
|
||||
interface RouteContext<TInput = unknown> extends PluginContext {
|
||||
input: TInput; // Validated input
|
||||
request: Request; // Original request
|
||||
plugin: { id: string; version: string };
|
||||
storage: Record<string, StorageCollection>;
|
||||
kv: KVAccess;
|
||||
content?: ContentAccess; // If capability declared
|
||||
media?: MediaAccess;
|
||||
http?: HttpAccess;
|
||||
log: LogAccess;
|
||||
}
|
||||
```
|
||||
|
||||
## Input Validation
|
||||
|
||||
Use Zod schemas. Invalid input returns 400.
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
create: {
|
||||
input: z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
priority: z.enum(["low", "medium", "high"]).default("medium"),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
// ctx.input is typed and validated
|
||||
const { title, email, priority } = ctx.input;
|
||||
// ...
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Input sources:
|
||||
|
||||
- **POST/PUT/PATCH** — Request body (JSON)
|
||||
- **GET/DELETE** — URL query parameters
|
||||
|
||||
## Return Values
|
||||
|
||||
Return any JSON-serializable value. Response is always `Content-Type: application/json`.
|
||||
|
||||
```typescript
|
||||
return { success: true, data: items }; // Object
|
||||
return items; // Array
|
||||
return 42; // Primitive
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
Throw to return error response:
|
||||
|
||||
```typescript
|
||||
throw new Error("Item not found"); // 500 with { error: "Item not found" }
|
||||
|
||||
// Custom status code
|
||||
throw new Response(JSON.stringify({ error: "Not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
```
|
||||
|
||||
## HTTP Methods
|
||||
|
||||
Routes respond to all methods. Check `ctx.request.method`:
|
||||
|
||||
```typescript
|
||||
handler: async (ctx) => {
|
||||
switch (ctx.request.method) {
|
||||
case "GET":
|
||||
return await ctx.storage.items!.get(ctx.input.id);
|
||||
case "DELETE":
|
||||
await ctx.storage.items!.delete(ctx.input.id);
|
||||
return { deleted: true };
|
||||
default:
|
||||
throw new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Settings CRUD
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
settings: {
|
||||
handler: async (ctx) => {
|
||||
const settings = await ctx.kv.list("settings:");
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const entry of settings) {
|
||||
result[entry.key.replace("settings:", "")] = entry.value;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
"settings/save": {
|
||||
handler: async (ctx) => {
|
||||
const input = await ctx.request.json();
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) await ctx.kv.set(`settings:${key}`, value);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Paginated List
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
list: {
|
||||
input: z.object({
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
cursor: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const { limit, cursor, status } = ctx.input;
|
||||
const result = await ctx.storage.items!.query({
|
||||
where: status ? { status } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
return {
|
||||
items: result.items.map((item) => ({ id: item.id, ...item.data })),
|
||||
cursor: result.cursor,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### External API Proxy
|
||||
|
||||
Requires `network:fetch` capability and `allowedHosts`:
|
||||
|
||||
```typescript
|
||||
definePlugin({
|
||||
capabilities: ["network:fetch"],
|
||||
allowedHosts: ["api.weather.example.com"],
|
||||
|
||||
routes: {
|
||||
forecast: {
|
||||
input: z.object({ city: z.string() }),
|
||||
handler: async (ctx) => {
|
||||
const apiKey = await ctx.kv.get<string>("settings:apiKey");
|
||||
if (!apiKey) throw new Error("API key not configured");
|
||||
|
||||
const response = await ctx.http!.fetch(
|
||||
`https://api.weather.example.com/forecast?city=${ctx.input.city}`,
|
||||
{ headers: { "X-API-Key": apiKey } },
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`API error: ${response.status}`);
|
||||
return response.json();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Calling from Admin UI
|
||||
|
||||
```typescript
|
||||
import { usePluginAPI } from "@emdashcms/admin";
|
||||
|
||||
const api = usePluginAPI();
|
||||
const data = await api.get("status");
|
||||
await api.post("settings/save", { enabled: true });
|
||||
```
|
||||
|
||||
## Calling Externally
|
||||
|
||||
```bash
|
||||
curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10
|
||||
|
||||
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Hello"}'
|
||||
```
|
||||
|
||||
Plugin routes don't have built-in auth. Admin-only routes are protected by the admin session middleware.
|
||||
@@ -0,0 +1,415 @@
|
||||
# Block Kit
|
||||
|
||||
Declarative JSON UI for sandboxed plugin admin pages. The host renders blocks — no plugin JavaScript runs in the browser. Inspired by Slack's Block Kit but not identical — similar concepts and naming, different block/element types and capabilities.
|
||||
|
||||
Trusted plugins (declared in `astro.config.ts`) can ship custom React components instead. Block Kit is for runtime-installed sandboxed plugins.
|
||||
|
||||
Block Kit elements are also used for [Portable Text block editing fields](./portable-text-blocks.md). When a plugin declares `fields` on a block type, the editor renders a Block Kit form.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User navigates to plugin admin page
|
||||
2. Admin sends `page_load` interaction to plugin's admin route
|
||||
3. Plugin returns `BlockResponse` with array of blocks
|
||||
4. Admin renders blocks using `BlockRenderer`
|
||||
5. User interacts (button click, form submit) → interaction sent back
|
||||
6. Plugin returns new blocks
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
admin: {
|
||||
handler: async (ctx) => {
|
||||
const interaction = await ctx.request.json();
|
||||
|
||||
if (interaction.type === "page_load") {
|
||||
return {
|
||||
blocks: [
|
||||
{ type: "header", text: "My Plugin Settings" },
|
||||
{
|
||||
type: "form",
|
||||
block_id: "settings",
|
||||
fields: [
|
||||
{ type: "text_input", action_id: "api_url", label: "API URL" },
|
||||
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
|
||||
],
|
||||
submit: { label: "Save", action_id: "save" },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (interaction.type === "form_submit" && interaction.action_id === "save") {
|
||||
await ctx.kv.set("settings", interaction.values);
|
||||
return {
|
||||
blocks: [/* updated blocks */],
|
||||
toast: { message: "Settings saved", type: "success" },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Block Types
|
||||
|
||||
| Type | Description |
|
||||
| --------- | --------------------------------------------------- |
|
||||
| `header` | Large bold heading |
|
||||
| `section` | Text with optional accessory element |
|
||||
| `divider` | Horizontal rule |
|
||||
| `fields` | Two-column label/value grid |
|
||||
| `table` | Data table with formatting, sorting, pagination |
|
||||
| `actions` | Horizontal row of buttons and controls |
|
||||
| `stats` | Dashboard metric cards with trend indicators |
|
||||
| `form` | Input fields with conditional visibility and submit |
|
||||
| `image` | Block-level image with caption |
|
||||
| `context` | Small muted help text |
|
||||
| `columns` | 2-3 column layout with nested blocks |
|
||||
| `chart` | Charts (timeseries line/bar, pie, custom ECharts) |
|
||||
| `code` | Syntax-highlighted code block |
|
||||
| `meter` | Progress/quota meter bar |
|
||||
| `banner` | Info, warning, or error inline messages |
|
||||
|
||||
## Element Types
|
||||
|
||||
| Type | Description |
|
||||
| -------------- | ----------------------------------------------- |
|
||||
| `button` | Action button with optional confirmation dialog |
|
||||
| `text_input` | Single-line or multiline text input |
|
||||
| `number_input` | Numeric input with min/max |
|
||||
| `select` | Dropdown select |
|
||||
| `toggle` | On/off switch |
|
||||
| `secret_input` | Masked input for API keys and tokens |
|
||||
| `checkbox` | Multi-select checkboxes |
|
||||
| `radio` | Single-select radio buttons |
|
||||
| `date_input` | Date picker |
|
||||
| `combobox` | Searchable dropdown select |
|
||||
|
||||
## Block Syntax
|
||||
|
||||
### Header
|
||||
|
||||
```json
|
||||
{ "type": "header", "text": "Settings" }
|
||||
```
|
||||
|
||||
### Section
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "section",
|
||||
"text": "Configure your plugin settings below.",
|
||||
"accessory": { "type": "button", "text": "Refresh", "action_id": "refresh" }
|
||||
}
|
||||
```
|
||||
|
||||
### Divider
|
||||
|
||||
```json
|
||||
{ "type": "divider" }
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "fields",
|
||||
"fields": [
|
||||
{ "label": "Status", "value": "Active" },
|
||||
{ "label": "Last Sync", "value": "2 hours ago" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Stats
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stats",
|
||||
"stats": [
|
||||
{ "label": "Total", "value": "1,234", "trend": "+12%", "trend_direction": "up" },
|
||||
{ "label": "Active", "value": "567" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Table
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "table",
|
||||
"columns": [
|
||||
{ "key": "name", "label": "Name" },
|
||||
{ "key": "status", "label": "Status" },
|
||||
{ "key": "date", "label": "Date" }
|
||||
],
|
||||
"rows": [{ "name": "Item 1", "status": "Active", "date": "2025-01-01" }]
|
||||
}
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{ "type": "button", "text": "Save", "action_id": "save", "style": "primary" },
|
||||
{ "type": "button", "text": "Cancel", "action_id": "cancel" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Form
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "form",
|
||||
"block_id": "settings",
|
||||
"fields": [
|
||||
{ "type": "text_input", "action_id": "name", "label": "Name" },
|
||||
{ "type": "number_input", "action_id": "count", "label": "Count", "min": 0, "max": 100 },
|
||||
{
|
||||
"type": "select",
|
||||
"action_id": "theme",
|
||||
"label": "Theme",
|
||||
"options": [
|
||||
{ "label": "Light", "value": "light" },
|
||||
{ "label": "Dark", "value": "dark" }
|
||||
]
|
||||
},
|
||||
{ "type": "toggle", "action_id": "enabled", "label": "Enabled", "initial_value": true },
|
||||
{ "type": "secret_input", "action_id": "api_key", "label": "API Key" }
|
||||
],
|
||||
"submit": { "label": "Save", "action_id": "save_settings" }
|
||||
}
|
||||
```
|
||||
|
||||
### Columns
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "columns",
|
||||
"columns": [
|
||||
{ "blocks": [{ "type": "header", "text": "Left" }] },
|
||||
{ "blocks": [{ "type": "header", "text": "Right" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Chart (Timeseries)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "chart",
|
||||
"config": {
|
||||
"chart_type": "timeseries",
|
||||
"series": [
|
||||
{
|
||||
"name": "Requests",
|
||||
"data": [
|
||||
[1709596800000, 42],
|
||||
[1709600400000, 67],
|
||||
[1709604000000, 53]
|
||||
],
|
||||
"color": "#086FFF"
|
||||
},
|
||||
{
|
||||
"name": "Errors",
|
||||
"data": [
|
||||
[1709596800000, 2],
|
||||
[1709600400000, 5],
|
||||
[1709604000000, 1]
|
||||
]
|
||||
}
|
||||
],
|
||||
"x_axis_name": "Time",
|
||||
"y_axis_name": "Count",
|
||||
"style": "line",
|
||||
"gradient": true,
|
||||
"height": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `series[].data` — array of `[timestamp_ms, value]` tuples
|
||||
- `series[].color` — hex color (optional, auto-assigned from Kumo palette)
|
||||
- `style` — `"line"` (default) or `"bar"`
|
||||
- `gradient` — fill gradient beneath lines (default false)
|
||||
- `height` — chart height in pixels (default 350)
|
||||
|
||||
### Chart (Custom)
|
||||
|
||||
For pie charts, gauges, or any ECharts visualization:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "chart",
|
||||
"config": {
|
||||
"chart_type": "custom",
|
||||
"options": {
|
||||
"series": [
|
||||
{
|
||||
"type": "pie",
|
||||
"data": [
|
||||
{ "value": 335, "name": "Published" },
|
||||
{ "value": 234, "name": "Draft" },
|
||||
{ "value": 120, "name": "Scheduled" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"height": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `options` — raw ECharts option object passed through to `chart.setOption()`
|
||||
|
||||
### Code
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "code",
|
||||
"code": "const greeting = \"Hello!\";\nconsole.log(greeting);",
|
||||
"language": "ts"
|
||||
}
|
||||
```
|
||||
|
||||
- `language` — `"ts"`, `"tsx"`, `"jsonc"`, `"bash"`, or `"css"` (defaults to `"ts"`)
|
||||
|
||||
### Meter
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "meter",
|
||||
"label": "Storage used",
|
||||
"value": 65,
|
||||
"custom_value": "6.5 GB / 10 GB"
|
||||
}
|
||||
```
|
||||
|
||||
- `value` — numeric value (default range 0-100)
|
||||
- `max` / `min` — custom range (defaults to 0-100)
|
||||
- `custom_value` — display string instead of percentage (e.g. "750 / 1,000")
|
||||
|
||||
### Banner
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "banner",
|
||||
"title": "API key invalid",
|
||||
"description": "Please check your API key in settings.",
|
||||
"variant": "error"
|
||||
}
|
||||
```
|
||||
|
||||
- `variant` — `"default"` (info, default), `"alert"` (warning), or `"error"`
|
||||
- At least one of `title` or `description` is required
|
||||
|
||||
## Conditional Fields
|
||||
|
||||
Show/hide fields based on other field values. Evaluated client-side, no round-trip.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "toggle",
|
||||
"action_id": "auth_enabled",
|
||||
"label": "Enable Authentication"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "secret_input",
|
||||
"action_id": "api_key",
|
||||
"label": "API Key",
|
||||
"condition": { "field": "auth_enabled", "eq": true }
|
||||
}
|
||||
```
|
||||
|
||||
## Builder Helpers
|
||||
|
||||
`@emdashcms/blocks` provides TypeScript helpers:
|
||||
|
||||
```typescript
|
||||
import { blocks, elements } from "@emdashcms/blocks";
|
||||
|
||||
const { header, form, section, stats, timeseriesChart, customChart, banner: bannerBlock } = blocks;
|
||||
const { textInput, toggle, select, button } = elements;
|
||||
|
||||
return {
|
||||
blocks: [
|
||||
header("Settings"),
|
||||
form({
|
||||
blockId: "settings",
|
||||
fields: [
|
||||
textInput("site_title", "Site Title", { initialValue: "My Site" }),
|
||||
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
|
||||
select("robots", "Default Robots", [
|
||||
{ label: "Index, Follow", value: "index,follow" },
|
||||
{ label: "No Index", value: "noindex,follow" },
|
||||
]),
|
||||
],
|
||||
submit: { label: "Save", actionId: "save" },
|
||||
}),
|
||||
// Timeseries chart
|
||||
timeseriesChart({
|
||||
series: [
|
||||
{
|
||||
name: "Page Views",
|
||||
data: [
|
||||
[Date.now() - 3600000, 100],
|
||||
[Date.now(), 150],
|
||||
],
|
||||
},
|
||||
],
|
||||
yAxisName: "Views",
|
||||
gradient: true,
|
||||
}),
|
||||
// Pie chart via custom ECharts options
|
||||
customChart({
|
||||
options: {
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
data: [
|
||||
{ value: 335, name: "Published" },
|
||||
{ value: 234, name: "Draft" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Button Confirmations
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "button",
|
||||
"text": "Delete All",
|
||||
"action_id": "delete_all",
|
||||
"style": "danger",
|
||||
"confirm": {
|
||||
"title": "Are you sure?",
|
||||
"text": "This cannot be undone.",
|
||||
"confirm": "Delete",
|
||||
"deny": "Cancel"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Toast Responses
|
||||
|
||||
Return a `toast` alongside blocks to show a notification:
|
||||
|
||||
```typescript
|
||||
return {
|
||||
blocks: [
|
||||
/* ... */
|
||||
],
|
||||
toast: { message: "Settings saved", type: "success" }, // "success" | "error" | "info"
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,412 @@
|
||||
# Hooks Reference
|
||||
|
||||
Hooks let plugins run code in response to events. Declared in `definePlugin({ hooks })`.
|
||||
|
||||
## Signature
|
||||
|
||||
```typescript
|
||||
async (event: EventType, ctx: PluginContext) => ReturnType;
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Simple handler or full config:
|
||||
|
||||
```typescript
|
||||
// Simple
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
ctx.log.info("Saved");
|
||||
}
|
||||
}
|
||||
|
||||
// Full config
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
priority: 100, // Lower runs first (default: 100)
|
||||
timeout: 5000, // Max execution time ms (default: 5000)
|
||||
dependencies: [], // Plugin IDs that must run first
|
||||
errorPolicy: "abort", // "abort" | "continue"
|
||||
handler: async (event, ctx) => {
|
||||
ctx.log.info("Saved");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
### `plugin:install`
|
||||
|
||||
Runs once on first install. Use to seed defaults.
|
||||
|
||||
```typescript
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
await ctx.kv.set("settings:enabled", true);
|
||||
await ctx.storage.items!.put("default", { name: "Default" });
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{}`
|
||||
Returns: `void`
|
||||
|
||||
### `plugin:activate`
|
||||
|
||||
Runs when plugin is enabled (after install or re-enable).
|
||||
|
||||
```typescript
|
||||
"plugin:activate": async (_event, ctx) => {
|
||||
ctx.log.info("Activated");
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{}`
|
||||
Returns: `void`
|
||||
|
||||
### `plugin:deactivate`
|
||||
|
||||
Runs when plugin is disabled (not removed).
|
||||
|
||||
```typescript
|
||||
"plugin:deactivate": async (_event, ctx) => {
|
||||
ctx.log.info("Deactivated");
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{}`
|
||||
Returns: `void`
|
||||
|
||||
### `plugin:uninstall`
|
||||
|
||||
Runs when plugin is removed. Only delete data if `event.deleteData` is true.
|
||||
|
||||
```typescript
|
||||
"plugin:uninstall": async (event, ctx) => {
|
||||
if (event.deleteData) {
|
||||
const result = await ctx.storage.items!.query({ limit: 1000 });
|
||||
await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ deleteData: boolean }`
|
||||
Returns: `void`
|
||||
|
||||
## Content Hooks
|
||||
|
||||
### `content:beforeSave`
|
||||
|
||||
Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
|
||||
|
||||
```typescript
|
||||
"content:beforeSave": async (event, ctx) => {
|
||||
const { content, collection, isNew } = event;
|
||||
|
||||
if (collection === "posts" && !content.title) {
|
||||
throw new Error("Posts require a title");
|
||||
}
|
||||
|
||||
// Transform
|
||||
if (content.slug) {
|
||||
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean }`
|
||||
Returns: `Record<string, unknown> | void`
|
||||
|
||||
### `content:afterSave`
|
||||
|
||||
Runs after successful save. Side effects only — logging, notifications, syncing.
|
||||
|
||||
```typescript
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
const { content, collection, isNew } = event;
|
||||
ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean }`
|
||||
Returns: `void`
|
||||
|
||||
### `content:beforeDelete`
|
||||
|
||||
Runs before delete. Return `false` to cancel, `true` or void to allow.
|
||||
|
||||
```typescript
|
||||
"content:beforeDelete": async (event, ctx) => {
|
||||
if (event.collection === "pages" && event.id === "home") {
|
||||
ctx.log.warn("Cannot delete home page");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ id: string, collection: string }`
|
||||
Returns: `boolean | void`
|
||||
|
||||
### `content:afterDelete`
|
||||
|
||||
Runs after successful delete.
|
||||
|
||||
```typescript
|
||||
"content:afterDelete": async (event, ctx) => {
|
||||
ctx.log.info(`Deleted ${event.collection}/${event.id}`);
|
||||
await ctx.storage.cache!.delete(`${event.collection}:${event.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ id: string, collection: string }`
|
||||
Returns: `void`
|
||||
|
||||
## Media Hooks
|
||||
|
||||
### `media:beforeUpload`
|
||||
|
||||
Runs before upload. Return modified file info, void to keep, or throw to cancel.
|
||||
|
||||
```typescript
|
||||
"media:beforeUpload": async (event, ctx) => {
|
||||
const { file } = event;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("Only images allowed");
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
throw new Error("Max 10MB");
|
||||
}
|
||||
|
||||
return { ...file, name: `${Date.now()}-${file.name}` };
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ file: { name: string, type: string, size: number } }`
|
||||
Returns: `{ name: string, type: string, size: number } | void`
|
||||
|
||||
### `media:afterUpload`
|
||||
|
||||
Runs after successful upload.
|
||||
|
||||
```typescript
|
||||
"media:afterUpload": async (event, ctx) => {
|
||||
ctx.log.info(`Uploaded ${event.media.filename}`, { id: event.media.id });
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ media: { id: string, filename: string, mimeType: string, size: number | null, url: string, createdAt: string } }`
|
||||
Returns: `void`
|
||||
|
||||
## Email Hooks
|
||||
|
||||
Email hooks require specific capabilities. Without the required capability, hooks are silently skipped.
|
||||
|
||||
### `email:beforeSend`
|
||||
|
||||
**Requires:** `email:intercept` capability.
|
||||
|
||||
Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one.
|
||||
|
||||
```typescript
|
||||
definePlugin({
|
||||
id: "email-footer",
|
||||
capabilities: ["email:intercept"],
|
||||
hooks: {
|
||||
"email:beforeSend": async (event, ctx) => {
|
||||
return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Event: `{ message: EmailMessage, source: string }`
|
||||
Returns: `EmailMessage | false`
|
||||
|
||||
### `email:deliver`
|
||||
|
||||
**Requires:** `email:provide` capability. **Exclusive hook** — exactly one provider is active.
|
||||
|
||||
Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.
|
||||
|
||||
```typescript
|
||||
definePlugin({
|
||||
id: "emdash-resend",
|
||||
capabilities: ["email:provide", "network:fetch"],
|
||||
allowedHosts: ["api.resend.com"],
|
||||
hooks: {
|
||||
"email:deliver": {
|
||||
exclusive: true,
|
||||
handler: async ({ message }, ctx) => {
|
||||
const apiKey = await ctx.kv.get("settings:apiKey");
|
||||
await ctx.http!.fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({ to: message.to, subject: message.subject, text: message.text }),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Event: `{ message: EmailMessage, source: string }`
|
||||
Returns: `void`
|
||||
|
||||
### `email:afterSend`
|
||||
|
||||
**Requires:** `email:intercept` capability.
|
||||
|
||||
Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.
|
||||
|
||||
```typescript
|
||||
definePlugin({
|
||||
id: "email-logger",
|
||||
capabilities: ["email:intercept"],
|
||||
hooks: {
|
||||
"email:afterSend": async (event, ctx) => {
|
||||
ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Event: `{ message: EmailMessage, source: string }`
|
||||
Returns: `void`
|
||||
|
||||
## Cron Hook
|
||||
|
||||
### `cron`
|
||||
|
||||
Runs on a schedule. Configure schedules via `ctx.cron.schedule()` in `plugin:activate`.
|
||||
|
||||
```typescript
|
||||
definePlugin({
|
||||
id: "cleanup",
|
||||
hooks: {
|
||||
"plugin:activate": async (_event, ctx) => {
|
||||
await ctx.cron!.schedule("daily-cleanup", { schedule: "0 2 * * *" });
|
||||
},
|
||||
cron: async (event, ctx) => {
|
||||
if (event.name === "daily-cleanup") {
|
||||
// ... cleanup logic
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Event: `{ name: string, data?: Record<string, unknown> }`
|
||||
Returns: `void`
|
||||
|
||||
## Public Page Hooks
|
||||
|
||||
Public page hooks let plugins contribute to the rendered output of public site pages. Templates opt in to these contributions with `<EmDashHead>`, `<EmDashBodyStart>`, and `<EmDashBodyEnd>` components.
|
||||
|
||||
### `page:metadata`
|
||||
|
||||
Contributes typed metadata to `<head>` — meta tags, OG properties, canonical/alternate links, and JSON-LD. Works in both trusted and sandboxed modes.
|
||||
|
||||
Returns structured contributions that core validates, dedupes (first-wins), and renders. Plugins never emit raw HTML through this hook.
|
||||
|
||||
```typescript
|
||||
"page:metadata": async (event, ctx) => {
|
||||
if (event.page.kind !== "content") return null;
|
||||
|
||||
return [
|
||||
{ kind: "meta", name: "author", content: "My Blog" },
|
||||
{
|
||||
kind: "jsonld",
|
||||
id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
|
||||
graph: {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: event.page.title,
|
||||
description: event.page.description,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ page: PublicPageContext }`
|
||||
Returns: `PageMetadataContribution | PageMetadataContribution[] | null`
|
||||
|
||||
Contribution types:
|
||||
|
||||
- `{ kind: "meta", name: string, content: string, key?: string }` — `<meta name="..." content="...">`
|
||||
- `{ kind: "property", property: string, content: string, key?: string }` — `<meta property="..." content="...">` (OpenGraph)
|
||||
- `{ kind: "link", rel: "canonical" | "alternate", href: string, hreflang?: string, key?: string }` — `<link>` tag (HTTP/HTTPS URLs only)
|
||||
- `{ kind: "jsonld", id?: string, graph: object | object[] }` — `<script type="application/ld+json">`
|
||||
|
||||
Dedupe rules: first contribution wins per key. Canonical is singleton.
|
||||
|
||||
### `page:fragments` (Trusted Only)
|
||||
|
||||
Contributes raw HTML, scripts, or markup to `head`, `body:start`, or `body:end`. **Trusted plugins only.** Sandboxed plugins cannot register this hook — the manifest schema rejects it.
|
||||
|
||||
```typescript
|
||||
"page:fragments": async (event, ctx) => {
|
||||
return [
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "head",
|
||||
src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
|
||||
async: true,
|
||||
},
|
||||
{
|
||||
kind: "html",
|
||||
placement: "body:start",
|
||||
html: '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Event: `{ page: PublicPageContext }`
|
||||
Returns: `PageFragmentContribution | PageFragmentContribution[] | null`
|
||||
|
||||
Contribution types:
|
||||
|
||||
- `{ kind: "external-script", placement, src, async?, defer?, attributes?, key? }`
|
||||
- `{ kind: "inline-script", placement, code, attributes?, key? }`
|
||||
- `{ kind: "html", placement, html, key? }`
|
||||
|
||||
Placements: `"head"`, `"body:start"`, `"body:end"`
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Lower `priority` values run first
|
||||
2. Equal priorities: plugin registration order
|
||||
3. `dependencies` array forces ordering regardless of priority
|
||||
|
||||
## Error Handling
|
||||
|
||||
- `errorPolicy: "abort"` (default) — pipeline stops, operation may fail
|
||||
- `errorPolicy: "continue"` — error logged, remaining hooks still run
|
||||
|
||||
Use `"continue"` for non-critical operations (analytics, notifications, external syncs).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Hook | Trigger | Capability Required | Return |
|
||||
| ---------------------- | -------------------- | ------------------- | ---------------------------- |
|
||||
| `plugin:install` | First install | — | `void` |
|
||||
| `plugin:activate` | Plugin enabled | — | `void` |
|
||||
| `plugin:deactivate` | Plugin disabled | — | `void` |
|
||||
| `plugin:uninstall` | Plugin removed | — | `void` |
|
||||
| `content:beforeSave` | Before save | — | Modified content or `void` |
|
||||
| `content:afterSave` | After save | — | `void` |
|
||||
| `content:beforeDelete` | Before delete | — | `false` to cancel |
|
||||
| `content:afterDelete` | After delete | — | `void` |
|
||||
| `media:beforeUpload` | Before upload | — | Modified file info or `void` |
|
||||
| `media:afterUpload` | After upload | — | `void` |
|
||||
| `email:beforeSend` | Before email send | `email:intercept` | Modified message or `false` |
|
||||
| `email:deliver` | Email delivery | `email:provide` | `void` (exclusive) |
|
||||
| `email:afterSend` | After email send | `email:intercept` | `void` |
|
||||
| `cron` | Scheduled task fires | — | `void` |
|
||||
| `page:metadata` | Page render | — | Metadata contributions |
|
||||
| `page:fragments` | Page render | — (trusted only) | Fragment contributions |
|
||||
@@ -0,0 +1,251 @@
|
||||
# Portable Text Block Types
|
||||
|
||||
**Trusted plugins only.** PT blocks require Astro components for site-side rendering (`componentsEntry`), loaded at build time from an npm package. Sandboxed/marketplace plugins cannot define PT blocks.
|
||||
|
||||
Plugins can add custom block types to the Portable Text editor. These appear in the slash command menu and can be inserted into any `portableText` field.
|
||||
|
||||
## Declaring Block Types
|
||||
|
||||
In `definePlugin()`, declare blocks under `admin.portableTextBlocks`:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
portableTextBlocks: [
|
||||
{
|
||||
type: "youtube",
|
||||
label: "YouTube Video",
|
||||
icon: "video",
|
||||
placeholder: "Paste YouTube URL...",
|
||||
fields: [
|
||||
{ type: "text_input", action_id: "id", label: "YouTube URL" },
|
||||
{ type: "text_input", action_id: "title", label: "Title" },
|
||||
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "codepen",
|
||||
label: "CodePen",
|
||||
icon: "code",
|
||||
placeholder: "Paste CodePen URL...",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Block Config Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | -------- | ----------------------------------------------- |
|
||||
| `type` | `string` | Block type name (used in PT `_type`). Required. |
|
||||
| `label` | `string` | Display name in slash command menu. Required. |
|
||||
| `icon` | `string` | Icon key. Optional. |
|
||||
| `description` | `string` | Description in slash command menu. Optional. |
|
||||
| `placeholder` | `string` | Input placeholder text. Optional. |
|
||||
| `fields` | `array` | Block Kit form fields for editing UI. Optional. |
|
||||
|
||||
### Icons
|
||||
|
||||
Named icons: `video`, `code`, `link`, `link-external`. Unknown or missing falls back to a generic cube icon.
|
||||
|
||||
### Fields
|
||||
|
||||
When `fields` is declared, the editor renders a Block Kit form for editing. When omitted, a simple URL input is shown.
|
||||
|
||||
Fields use Block Kit element syntax:
|
||||
|
||||
```typescript
|
||||
fields: [
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "id",
|
||||
label: "URL",
|
||||
placeholder: "https://...",
|
||||
},
|
||||
{ type: "text_input", action_id: "title", label: "Title" },
|
||||
{ type: "text_input", action_id: "poster", label: "Poster Image" },
|
||||
{ type: "number_input", action_id: "start", label: "Start Time (seconds)" },
|
||||
{ type: "toggle", action_id: "autoplay", label: "Autoplay" },
|
||||
{
|
||||
type: "select",
|
||||
action_id: "size",
|
||||
label: "Size",
|
||||
options: [
|
||||
{ label: "Small", value: "small" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "Large", value: "large" },
|
||||
],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
See [Block Kit reference](./block-kit.md) for all element types.
|
||||
|
||||
The `action_id` of each field becomes a key in the Portable Text block data. The field with `action_id: "id"` is treated as the primary identifier (typically the URL).
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. User types `/` in the editor and selects a block type
|
||||
2. Modal opens with Block Kit form (or simple URL input if no fields)
|
||||
3. User fills in fields and submits
|
||||
4. Block is inserted with `_type` set to the block type and field values as properties
|
||||
5. Editing an existing block re-opens the modal pre-populated
|
||||
|
||||
Portable Text output:
|
||||
|
||||
```json
|
||||
{
|
||||
"_type": "youtube",
|
||||
"_key": "abc123",
|
||||
"id": "https://youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"title": "Never Gonna Give You Up",
|
||||
"poster": "https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
## Site-Side Rendering
|
||||
|
||||
To render block types on the site, export Astro components from a `componentsEntry`.
|
||||
|
||||
### Component File
|
||||
|
||||
```typescript
|
||||
// src/astro/index.ts
|
||||
import YouTube from "./YouTube.astro";
|
||||
import CodePen from "./CodePen.astro";
|
||||
|
||||
// This export name is required
|
||||
export const blockComponents = {
|
||||
youtube: YouTube,
|
||||
codepen: CodePen,
|
||||
};
|
||||
```
|
||||
|
||||
### Astro Component
|
||||
|
||||
```astro
|
||||
---
|
||||
// src/astro/YouTube.astro
|
||||
const { id, title, poster } = Astro.props.node;
|
||||
|
||||
// Extract video ID from URL
|
||||
const videoId = id?.match(/(?:v=|youtu\.be\/)([^&]+)/)?.[1] ?? id;
|
||||
---
|
||||
|
||||
<div class="youtube-embed">
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
|
||||
title={title || "YouTube Video"}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
```
|
||||
|
||||
Component receives `Astro.props.node` with the full block data.
|
||||
|
||||
### Plugin Descriptor
|
||||
|
||||
Set `componentsEntry` in the descriptor:
|
||||
|
||||
```typescript
|
||||
export function myPlugin(options = {}): PluginDescriptor {
|
||||
return {
|
||||
id: "my-plugin",
|
||||
entrypoint: "@my-org/my-plugin",
|
||||
componentsEntry: "@my-org/my-plugin/astro",
|
||||
version: "1.0.0",
|
||||
options,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Package Exports
|
||||
|
||||
Add the `./astro` export:
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
||||
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
|
||||
"./astro": {
|
||||
"types": "./dist/astro/index.d.ts",
|
||||
"import": "./dist/astro/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Wiring
|
||||
|
||||
Plugin block components are automatically merged into `<PortableText>` on the site. Merge order:
|
||||
|
||||
1. EmDash defaults (lowest priority)
|
||||
2. Plugin block components
|
||||
3. User-provided components (highest priority)
|
||||
|
||||
Site authors don't need to import anything. User components take precedence over plugin defaults.
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
// src/index.ts
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginDescriptor } from "emdash";
|
||||
|
||||
export function embedsPlugin(options = {}): PluginDescriptor {
|
||||
return {
|
||||
id: "embeds",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@my-org/plugin-embeds",
|
||||
componentsEntry: "@my-org/plugin-embeds/astro",
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlugin() {
|
||||
return definePlugin({
|
||||
id: "embeds",
|
||||
version: "1.0.0",
|
||||
|
||||
admin: {
|
||||
portableTextBlocks: [
|
||||
{
|
||||
type: "youtube",
|
||||
label: "YouTube Video",
|
||||
icon: "video",
|
||||
placeholder: "Paste YouTube URL...",
|
||||
fields: [
|
||||
{ type: "text_input", action_id: "id", label: "YouTube URL" },
|
||||
{ type: "text_input", action_id: "title", label: "Title" },
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "poster",
|
||||
label: "Poster Image URL",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "linkPreview",
|
||||
label: "Link Preview",
|
||||
icon: "link-external",
|
||||
placeholder: "Paste any URL...",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createPlugin;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/astro/index.ts
|
||||
import YouTube from "./YouTube.astro";
|
||||
import LinkPreview from "./LinkPreview.astro";
|
||||
|
||||
export const blockComponents = {
|
||||
youtube: YouTube,
|
||||
linkPreview: LinkPreview,
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# Publishing to the Marketplace
|
||||
|
||||
Sandboxed plugins can be published to the EmDash Marketplace for one-click installation from the admin UI.
|
||||
|
||||
## Bundle Format
|
||||
|
||||
Published plugins are `.tar.gz` tarballs:
|
||||
|
||||
| File | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------- |
|
||||
| `manifest.json` | Yes | Metadata extracted from `definePlugin()` |
|
||||
| `backend.js` | No | Bundled sandbox code (self-contained ES module) |
|
||||
| `admin.js` | No | Bundled admin UI code |
|
||||
| `README.md` | No | Plugin documentation |
|
||||
| `icon.png` | No | Plugin icon (256x256 PNG) |
|
||||
| `screenshots/` | No | Up to 5 screenshots (PNG/JPEG, max 1920x1080) |
|
||||
|
||||
## Package Exports for Bundling
|
||||
|
||||
The bundle command uses `package.json` exports to find entrypoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": { "import": "./dist/index.mjs" },
|
||||
"./sandbox": { "import": "./dist/sandbox-entry.mjs" },
|
||||
"./admin": { "import": "./dist/admin.mjs" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Export | Purpose | Built as |
|
||||
| ------------- | ----------------------------- | ------------------------------------ |
|
||||
| `"."` | Main entry — extract manifest | Externals: `emdash`, `@emdashcms/*` |
|
||||
| `"./sandbox"` | Backend code for the sandbox | Fully self-contained (no externals) |
|
||||
| `"./admin"` | Admin UI components | Fully self-contained |
|
||||
|
||||
If `"./sandbox"` is missing, the command looks for `src/sandbox-entry.ts`.
|
||||
|
||||
## Build and Publish
|
||||
|
||||
```bash
|
||||
# Bundle only (inspect first)
|
||||
emdash plugin bundle
|
||||
tar tzf dist/my-plugin-1.0.0.tar.gz
|
||||
|
||||
# Publish (uploads to marketplace)
|
||||
emdash plugin publish
|
||||
|
||||
# Build + publish in one step
|
||||
emdash plugin publish --build
|
||||
```
|
||||
|
||||
First-time publish authenticates via GitHub device authorization. Token stored in `~/.config/emdash/auth.json` (30-day expiry).
|
||||
|
||||
## Validation
|
||||
|
||||
The bundle command checks:
|
||||
|
||||
- **Size limit** — Total bundle under 5MB
|
||||
- **No Node.js built-ins** — `backend.js` cannot import `fs`, `path`, etc.
|
||||
- **Sandbox-incompatible features** — Warns if the plugin declares `portableTextBlocks`, `admin.entry` (React components), or API `routes`, since these require trusted mode
|
||||
- **Icon dimensions** — 256x256 PNG (warns if wrong)
|
||||
- **Screenshot limits** — Max 5, max 1920x1080
|
||||
|
||||
## Security Audit
|
||||
|
||||
Every published version is automatically audited for:
|
||||
|
||||
- Data exfiltration patterns
|
||||
- Credential harvesting via settings
|
||||
- Obfuscated code
|
||||
- Resource abuse (crypto mining, etc.)
|
||||
- Suspicious network activity
|
||||
|
||||
Verdict: **pass**, **warn**, or **fail** — displayed on marketplace listing.
|
||||
|
||||
## Version Requirements
|
||||
|
||||
- Each version must have higher semver than the last
|
||||
- Cannot overwrite or republish an existing version
|
||||
- Plugin ID is auto-registered on first publish
|
||||
@@ -0,0 +1,264 @@
|
||||
# Storage, KV & Settings
|
||||
|
||||
Plugins have three data mechanisms:
|
||||
|
||||
| Mechanism | Purpose | Access |
|
||||
| ------------------- | ----------------------------------------- | ---------------------- |
|
||||
| **Storage** | Document collections with indexed queries | `ctx.storage` |
|
||||
| **KV** | Key-value pairs for state and settings | `ctx.kv` |
|
||||
| **Settings Schema** | Auto-generated admin UI for configuration | `admin.settingsSchema` |
|
||||
|
||||
## Storage Collections
|
||||
|
||||
Declare in `definePlugin({ storage })`. EmDash creates the schema automatically — no migrations.
|
||||
|
||||
```typescript
|
||||
definePlugin({
|
||||
id: "forms",
|
||||
version: "1.0.0",
|
||||
|
||||
storage: {
|
||||
submissions: {
|
||||
indexes: [
|
||||
"formId", // Single-field index
|
||||
"status",
|
||||
"createdAt",
|
||||
["formId", "createdAt"], // Composite index
|
||||
],
|
||||
},
|
||||
forms: {
|
||||
indexes: ["slug"],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Storage is scoped to the plugin — `submissions` in plugin `forms` is separate from `submissions` in another plugin.
|
||||
|
||||
### CRUD
|
||||
|
||||
```typescript
|
||||
const { submissions } = ctx.storage;
|
||||
|
||||
await submissions.put("sub_123", { formId: "contact", email: "user@example.com" });
|
||||
const item = await submissions.get("sub_123");
|
||||
const exists = await submissions.exists("sub_123");
|
||||
await submissions.delete("sub_123");
|
||||
```
|
||||
|
||||
### Batch Operations
|
||||
|
||||
```typescript
|
||||
const items = await submissions.getMany(["sub_1", "sub_2"]); // Map<string, T>
|
||||
|
||||
await submissions.putMany([
|
||||
{ id: "sub_1", data: { formId: "contact", status: "new" } },
|
||||
{ id: "sub_2", data: { formId: "contact", status: "new" } },
|
||||
]);
|
||||
|
||||
const deletedCount = await submissions.deleteMany(["sub_1", "sub_2"]);
|
||||
```
|
||||
|
||||
### Querying
|
||||
|
||||
Only indexed fields can be queried. Non-indexed queries throw.
|
||||
|
||||
```typescript
|
||||
const result = await ctx.storage.submissions.query({
|
||||
where: {
|
||||
formId: "contact",
|
||||
status: "pending",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// result.items - Array of { id, data }
|
||||
// result.cursor - Pagination cursor
|
||||
// result.hasMore - Boolean
|
||||
```
|
||||
|
||||
### Where Operators
|
||||
|
||||
```typescript
|
||||
// Exact match
|
||||
where: { status: "pending" }
|
||||
|
||||
// Range
|
||||
where: { createdAt: { gte: "2024-01-01" } }
|
||||
where: { score: { gt: 50, lte: 100 } }
|
||||
|
||||
// In
|
||||
where: { status: { in: ["pending", "approved"] } }
|
||||
|
||||
// Starts with
|
||||
where: { slug: { startsWith: "blog-" } }
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```typescript
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const result = await ctx.storage.submissions!.query({
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
// process result.items
|
||||
cursor = result.cursor;
|
||||
} while (cursor);
|
||||
```
|
||||
|
||||
### Counting
|
||||
|
||||
```typescript
|
||||
const total = await ctx.storage.submissions!.count();
|
||||
const pending = await ctx.storage.submissions!.count({ status: "pending" });
|
||||
```
|
||||
|
||||
### Index Design
|
||||
|
||||
| Query Pattern | Index Needed |
|
||||
| ---------------------------------------- | ------------------------- |
|
||||
| Filter by `formId` | `"formId"` |
|
||||
| Filter by `formId`, order by `createdAt` | `["formId", "createdAt"]` |
|
||||
| Order by `createdAt` only | `"createdAt"` |
|
||||
|
||||
Composite indexes support filtering on the first field + ordering by the second.
|
||||
|
||||
### Type Safety
|
||||
|
||||
```typescript
|
||||
interface Submission {
|
||||
formId: string;
|
||||
status: "pending" | "approved" | "spam";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Cast in hook/route handlers
|
||||
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
|
||||
```
|
||||
|
||||
### Full API
|
||||
|
||||
```typescript
|
||||
interface StorageCollection<T = unknown> {
|
||||
get(id: string): Promise<T | null>;
|
||||
put(id: string, data: T): Promise<void>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
getMany(ids: string[]): Promise<Map<string, T>>;
|
||||
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
|
||||
deleteMany(ids: string[]): Promise<number>;
|
||||
query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
|
||||
count(where?: WhereClause): Promise<number>;
|
||||
}
|
||||
```
|
||||
|
||||
## KV Store
|
||||
|
||||
General-purpose key-value store. Use for internal state, cached computations, or programmatic access to settings.
|
||||
|
||||
```typescript
|
||||
interface KVAccess {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set(key: string, value: unknown): Promise<void>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Naming Conventions
|
||||
|
||||
| Prefix | Purpose | Example |
|
||||
| ----------- | ----------------------------- | ----------------- |
|
||||
| `settings:` | User-configurable preferences | `settings:apiKey` |
|
||||
| `state:` | Internal plugin state | `state:lastSync` |
|
||||
| `cache:` | Cached data | `cache:results` |
|
||||
|
||||
```typescript
|
||||
await ctx.kv.set("settings:webhookUrl", url);
|
||||
await ctx.kv.set("state:lastRun", new Date().toISOString());
|
||||
const allSettings = await ctx.kv.list("settings:");
|
||||
```
|
||||
|
||||
## Settings Schema
|
||||
|
||||
Declare `admin.settingsSchema` to auto-generate a settings form in the admin UI:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
siteTitle: {
|
||||
type: "string",
|
||||
label: "Site Title",
|
||||
description: "Used in title tags",
|
||||
default: "",
|
||||
},
|
||||
maxItems: {
|
||||
type: "number",
|
||||
label: "Max Items",
|
||||
default: 100,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
default: true,
|
||||
},
|
||||
theme: {
|
||||
type: "select",
|
||||
label: "Theme",
|
||||
options: [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
],
|
||||
default: "light",
|
||||
},
|
||||
apiKey: {
|
||||
type: "secret",
|
||||
label: "API Key",
|
||||
description: "Encrypted at rest",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Setting Types
|
||||
|
||||
| Type | UI | Notes |
|
||||
| --------- | ------------ | ----------------------------------------- |
|
||||
| `string` | Text input | Optional `multiline: true` for textarea |
|
||||
| `number` | Number input | Optional `min`, `max` |
|
||||
| `boolean` | Toggle | |
|
||||
| `select` | Dropdown | Requires `options: [{ value, label }]` |
|
||||
| `secret` | Masked input | Encrypted at rest, never shown after save |
|
||||
|
||||
### Reading Settings
|
||||
|
||||
Settings are accessed via KV with `settings:` prefix:
|
||||
|
||||
```typescript
|
||||
const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
|
||||
const apiKey = await ctx.kv.get<string>("settings:apiKey");
|
||||
```
|
||||
|
||||
Schema defaults are UI defaults only — not auto-persisted. Handle missing values with `??` or persist defaults in `plugin:install`:
|
||||
|
||||
```typescript
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
await ctx.kv.set("settings:enabled", true);
|
||||
await ctx.kv.set("settings:maxItems", 100);
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use What
|
||||
|
||||
| Use Case | Mechanism |
|
||||
| -------------------------------------------- | --------------------------------- |
|
||||
| Admin-editable preferences | `settingsSchema` + KV `settings:` |
|
||||
| Internal state (timestamps, cursors) | KV `state:` |
|
||||
| Collections of documents (logs, submissions) | Storage |
|
||||
| Cached computations | KV `cache:` |
|
||||
164
templates/marketing/.agents/skills/emdash-cli/EDITING-FLOW.md
Normal file
164
templates/marketing/.agents/skills/emdash-cli/EDITING-FLOW.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Editing Flow
|
||||
|
||||
How content editing works through the CLI. Covers Portable Text conversion, `_rev` tokens, and raw mode.
|
||||
|
||||
## Portable Text and Markdown
|
||||
|
||||
EmDash stores rich text as [Portable Text](https://portabletext.org/) (PT) — a structured JSON format. The CLI automatically converts between PT and markdown so you work with a familiar text format.
|
||||
|
||||
### Automatic Conversion
|
||||
|
||||
- **On read**: PT arrays in `portableText` fields are converted to markdown strings
|
||||
- **On write**: markdown strings in `portableText` fields are converted back to PT arrays
|
||||
- **Non-PT fields** (string, text, number, etc.) pass through unchanged
|
||||
|
||||
The CLI detects which fields need conversion by fetching the collection's field schema.
|
||||
|
||||
### Supported Markdown Syntax
|
||||
|
||||
Standard blocks (lossless round-trip):
|
||||
|
||||
| Markdown | PT block |
|
||||
| ---------------------------- | ------------------------------------------ |
|
||||
| `# Heading` through `######` | h1-h6 blocks |
|
||||
| Plain paragraph | normal block |
|
||||
| `> Quote` | blockquote |
|
||||
| `- item` / `* item` | bullet list (nesting via 2-space indent) |
|
||||
| `1. item` | numbered list (nesting via 2-space indent) |
|
||||
| ` ``` ```lang``` ` | code block with language |
|
||||
| `` | image block |
|
||||
|
||||
Inline marks:
|
||||
|
||||
| Markdown | PT mark |
|
||||
| ------------- | --------------- |
|
||||
| `**bold**` | `strong` |
|
||||
| `_italic_` | `em` |
|
||||
| `` `code` `` | `code` |
|
||||
| `~~strike~~` | `strikethrough` |
|
||||
| `[text](url)` | link annotation |
|
||||
|
||||
### Unknown Blocks (Opaque Fences)
|
||||
|
||||
Blocks the converter doesn't recognize (custom blocks, embeds, etc.) are serialized as HTML comments:
|
||||
|
||||
```markdown
|
||||
<!--ec:block {"_type":"callout","level":"warning","text":"Be careful"} -->
|
||||
```
|
||||
|
||||
These survive round-trips intact. You can see and move them, but editing the JSON risks corruption. On write, they're deserialized back to the original PT block.
|
||||
|
||||
### Raw Mode
|
||||
|
||||
Skip markdown conversion entirely to work with raw PT JSON:
|
||||
|
||||
```bash
|
||||
npx emdash content get posts 01ABC123 --raw
|
||||
```
|
||||
|
||||
Use raw mode when:
|
||||
|
||||
- You need exact control over PT structure
|
||||
- You're working with custom block types
|
||||
- You're copying PT between items without transformation
|
||||
|
||||
### Writing Content
|
||||
|
||||
When creating or updating content, each field is checked:
|
||||
|
||||
- `portableText` field + **string value** → converts markdown to PT before sending
|
||||
- `portableText` field + **array value** → sends as raw PT (no conversion)
|
||||
- Any other field type → sends as-is
|
||||
|
||||
```bash
|
||||
# Markdown string — converted to PT automatically
|
||||
npx emdash content create posts --data '{"title": "Hello", "body": "# Welcome\n\nThis is **bold**."}'
|
||||
|
||||
# Raw PT array — passed through as-is
|
||||
npx emdash content create posts --data '{"title": "Hello", "body": [{"_type": "block", "children": [{"_type": "span", "text": "Welcome"}]}]}'
|
||||
```
|
||||
|
||||
## Auto-Publishing
|
||||
|
||||
The CLI is designed for agents. It auto-publishes on `create` and `update` by default so agents get read-after-write consistency without managing the draft/publish lifecycle.
|
||||
|
||||
### How It Works
|
||||
|
||||
- **`create`** — creates the item, then publishes it. The returned item is in `published` status.
|
||||
- **`update`** — updates the item. If the collection uses revisions and the update created a draft revision, it auto-publishes to promote the draft to the content table. The returned item reflects the updated data.
|
||||
- **`get`** — returns the latest state. If a pending draft exists (e.g. someone edited in the admin UI but didn't publish), the draft data is returned instead of the published data. Use `--published` to see only published data.
|
||||
|
||||
Use `--draft` on create/update to skip auto-publishing.
|
||||
|
||||
### Why Auto-Publish?
|
||||
|
||||
EmDash collections can support draft revisions. When they do, `update` writes data to a draft revision instead of the content table. Without auto-publish, an agent would update, then `get` the item, and see stale published data — not the changes it just made. Auto-publish eliminates this confusion.
|
||||
|
||||
## Read-Before-Write
|
||||
|
||||
Updates use `_rev` tokens for optimistic concurrency — the same principle as a file editing tool that requires you to read a file before you can edit it. You must see what you're overwriting.
|
||||
|
||||
### The Analogy
|
||||
|
||||
Think of it like a filesystem edit tool:
|
||||
|
||||
1. You **read** the file to see its current contents
|
||||
2. You decide what to change
|
||||
3. You **write** with a reference to the version you read
|
||||
|
||||
If someone else changed the file between your read and your write, the write fails — you can't overwrite changes you haven't seen. The `_rev` token is your proof that you've seen the current state.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. `content get` returns the item with a `_rev` token in the output
|
||||
2. You pass that `_rev` back to `content update` via `--rev`
|
||||
3. The server checks: if the item has changed since your read, it returns **409 Conflict**
|
||||
4. A successful update returns a new `_rev` for subsequent edits
|
||||
|
||||
### What Is a `_rev` Token?
|
||||
|
||||
An opaque base64 string. Don't parse it — just pass it back.
|
||||
|
||||
### CLI Workflow
|
||||
|
||||
The CLI **requires** `--rev` on updates. The typical workflow:
|
||||
|
||||
```bash
|
||||
# 1. Read the item — note the _rev in the output
|
||||
npx emdash content get posts 01ABC123
|
||||
# Output includes: _rev: MToyMDI2LTAyLTE0...
|
||||
|
||||
# 2. Update with the _rev you received — auto-publishes by default
|
||||
npx emdash content update posts 01ABC123 \
|
||||
--rev MToyMDI2LTAyLTE0... \
|
||||
--data '{"title": "New Title"}'
|
||||
# Output shows updated item with new _rev
|
||||
```
|
||||
|
||||
If you try to update without `--rev`, the CLI rejects the command. This ensures you always know what you're overwriting.
|
||||
|
||||
### Conflict Handling
|
||||
|
||||
If someone else updated the item between your read and write:
|
||||
|
||||
```
|
||||
EmDashApiError: Content has been modified since last read (version conflict)
|
||||
status: 409
|
||||
code: CONFLICT
|
||||
```
|
||||
|
||||
Resolution: re-read with `get`, inspect the new state, then `update` with the fresh `_rev`.
|
||||
|
||||
### Which Operations Need `_rev`?
|
||||
|
||||
Only `update`. All other operations are either idempotent or non-destructive:
|
||||
|
||||
| Command | `--rev` needed? | Why |
|
||||
| ------------------- | --------------- | ------------------------ |
|
||||
| `content create` | No | Nothing exists yet |
|
||||
| `content update` | **Yes** | Overwrites existing data |
|
||||
| `content delete` | No | Soft delete, reversible |
|
||||
| `content publish` | No | Idempotent status change |
|
||||
| `content unpublish` | No | Idempotent status change |
|
||||
| `content schedule` | No | Only changes metadata |
|
||||
| `content restore` | No | Restores from trash |
|
||||
246
templates/marketing/.agents/skills/emdash-cli/SKILL.md
Normal file
246
templates/marketing/.agents/skills/emdash-cli/SKILL.md
Normal file
@@ -0,0 +1,246 @@
|
||||
---
|
||||
name: emdash-cli
|
||||
description: Use the EmDash CLI to manage content, schema, media, and more. Use this skill when you need to interact with a running EmDash instance from the command line — creating content, managing collections, uploading media, generating types, or scripting CMS operations.
|
||||
---
|
||||
|
||||
# EmDash CLI
|
||||
|
||||
The EmDash CLI (`emdash` or `ec`) manages EmDash CMS instances. Commands fall into two categories:
|
||||
|
||||
- **Local commands** — work directly on a SQLite file, no running server needed: `init`, `dev`, `seed`, `export-seed`, `auth secret`
|
||||
- **Remote commands** — talk to a running EmDash instance via HTTP: `types`, `login`, `logout`, `whoami`, `content`, `schema`, `media`, `search`, `taxonomy`, `menu`
|
||||
|
||||
## Authentication
|
||||
|
||||
Remote commands resolve auth automatically:
|
||||
|
||||
1. `--token` flag
|
||||
2. `EMDASH_TOKEN` env var
|
||||
3. Stored credentials from `emdash login`
|
||||
4. Dev bypass (localhost only — no token needed)
|
||||
|
||||
For local dev servers, just run the command — auth is handled automatically. For remote instances, run `emdash login --url https://my-site.pages.dev` first.
|
||||
|
||||
## Custom Headers & Reverse Proxies
|
||||
|
||||
Sites behind Cloudflare Access or other reverse proxies need auth headers on every request. The CLI supports this via `--header` flags and environment variables.
|
||||
|
||||
### Service Tokens (Recommended for CI/Automation)
|
||||
|
||||
```bash
|
||||
# Single header
|
||||
npx emdash login --url https://my-site.pages.dev \
|
||||
--header "CF-Access-Client-Id: xxx.access" \
|
||||
--header "CF-Access-Client-Secret: yyy"
|
||||
|
||||
# Short form
|
||||
npx emdash login -H "CF-Access-Client-Id: xxx" -H "CF-Access-Client-Secret: yyy"
|
||||
|
||||
# Via environment (newline-separated)
|
||||
export EMDASH_HEADERS="CF-Access-Client-Id: xxx
|
||||
CF-Access-Client-Secret: yyy"
|
||||
npx emdash login --url https://my-site.pages.dev
|
||||
```
|
||||
|
||||
Headers are persisted to `~/.config/emdash/auth.json` after login, so subsequent commands inherit them automatically.
|
||||
|
||||
### Cloudflare Access Browser Flow
|
||||
|
||||
If you don't have service tokens and `cloudflared` is installed, the CLI will automatically:
|
||||
|
||||
1. Detect when Access blocks the request
|
||||
2. Try to get a cached JWT via `cloudflared access token`
|
||||
3. Fall back to `cloudflared access login` for browser-based auth
|
||||
|
||||
This works for interactive use but isn't suitable for CI. Use service tokens for automation.
|
||||
|
||||
### Generic Reverse Proxy Auth
|
||||
|
||||
The `--header` flag works with any auth scheme:
|
||||
|
||||
```bash
|
||||
# Basic auth
|
||||
npx emdash login --url https://example.com -H "Authorization: Basic dXNlcjpwYXNz"
|
||||
|
||||
# Custom auth header
|
||||
npx emdash login --url https://example.com -H "X-API-Key: secret123"
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Database Setup
|
||||
|
||||
```bash
|
||||
# Initialize database with migrations
|
||||
npx emdash init
|
||||
|
||||
# Start dev server (runs migrations, starts Astro)
|
||||
npx emdash dev
|
||||
|
||||
# Start dev server and generate types from remote
|
||||
npx emdash dev --types
|
||||
|
||||
# Apply a seed file
|
||||
npx emdash seed .emdash/seed.json
|
||||
|
||||
# Export database as seed
|
||||
npx emdash export-seed > seed.json
|
||||
npx emdash export-seed --with-content > seed.json
|
||||
```
|
||||
|
||||
### Type Generation
|
||||
|
||||
```bash
|
||||
# Generate types from local dev server
|
||||
npx emdash types
|
||||
|
||||
# Generate from remote
|
||||
npx emdash types --url https://my-site.pages.dev
|
||||
|
||||
# Custom output path
|
||||
npx emdash types --output src/types/cms.ts
|
||||
```
|
||||
|
||||
Writes `.emdash/types.ts` (TypeScript interfaces) and `.emdash/schema.json`.
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
# Login (OAuth Device Flow)
|
||||
npx emdash login --url https://my-site.pages.dev
|
||||
|
||||
# Check current user
|
||||
npx emdash whoami
|
||||
|
||||
# Logout
|
||||
npx emdash logout
|
||||
|
||||
# Generate auth secret for deployment
|
||||
npx emdash auth secret
|
||||
```
|
||||
|
||||
### Content CRUD
|
||||
|
||||
The CLI is designed for agents. Create and update auto-publish by default so agents get read-after-write consistency without managing drafts.
|
||||
|
||||
```bash
|
||||
# List content
|
||||
npx emdash content list posts
|
||||
npx emdash content list posts --status published --limit 10
|
||||
|
||||
# Get a single item (Portable Text fields converted to markdown)
|
||||
# Returns draft data if a pending draft exists
|
||||
npx emdash content get posts 01ABC123
|
||||
npx emdash content get posts 01ABC123 --raw # skip PT->markdown conversion
|
||||
npx emdash content get posts 01ABC123 --published # ignore pending drafts
|
||||
|
||||
# Create content (auto-publishes by default)
|
||||
npx emdash content create posts --data '{"title": "Hello", "body": "# World"}'
|
||||
npx emdash content create posts --file post.json --slug hello-world
|
||||
npx emdash content create posts --draft --data '...' # keep as draft
|
||||
cat post.json | npx emdash content create posts --stdin
|
||||
|
||||
# Update (requires --rev from a prior get, auto-publishes by default)
|
||||
npx emdash content update posts 01ABC123 --rev MToyMDI2... --data '{"title": "Updated"}'
|
||||
npx emdash content update posts 01ABC123 --rev MToyMDI2... --draft --data '...' # keep as draft
|
||||
|
||||
# Delete (soft delete)
|
||||
npx emdash content delete posts 01ABC123
|
||||
|
||||
# Lifecycle
|
||||
npx emdash content publish posts 01ABC123
|
||||
npx emdash content unpublish posts 01ABC123
|
||||
npx emdash content schedule posts 01ABC123 --at 2026-03-01T09:00:00Z
|
||||
npx emdash content restore posts 01ABC123
|
||||
```
|
||||
|
||||
### Schema Management
|
||||
|
||||
```bash
|
||||
# List collections
|
||||
npx emdash schema list
|
||||
|
||||
# Get collection with fields
|
||||
npx emdash schema get posts
|
||||
|
||||
# Create collection
|
||||
npx emdash schema create articles --label Articles --description "Blog articles"
|
||||
|
||||
# Delete collection
|
||||
npx emdash schema delete articles --force
|
||||
|
||||
# Add field
|
||||
npx emdash schema add-field posts body --type portableText --label "Body Content"
|
||||
npx emdash schema add-field posts featured --type boolean --required
|
||||
|
||||
# Remove field
|
||||
npx emdash schema remove-field posts featured
|
||||
```
|
||||
|
||||
Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `image`, `reference`, `portableText`, `json`.
|
||||
|
||||
### Media
|
||||
|
||||
```bash
|
||||
# List media
|
||||
npx emdash media list
|
||||
npx emdash media list --mime image/png
|
||||
|
||||
# Upload
|
||||
npx emdash media upload ./photo.jpg --alt "A sunset" --caption "Bristol, 2026"
|
||||
|
||||
# Get / delete
|
||||
npx emdash media get 01MEDIA123
|
||||
npx emdash media delete 01MEDIA123
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
npx emdash search "hello world"
|
||||
npx emdash search "hello" --collection posts --limit 5
|
||||
```
|
||||
|
||||
### Taxonomies
|
||||
|
||||
```bash
|
||||
npx emdash taxonomy list
|
||||
npx emdash taxonomy terms categories
|
||||
npx emdash taxonomy add-term categories --name "Tech" --slug tech
|
||||
npx emdash taxonomy add-term categories --name "Frontend" --parent 01PARENT123
|
||||
```
|
||||
|
||||
### Menus
|
||||
|
||||
```bash
|
||||
npx emdash menu list
|
||||
npx emdash menu get primary
|
||||
```
|
||||
|
||||
## Drafts and Publishing
|
||||
|
||||
The CLI auto-publishes on `create` and `update` by default. This means:
|
||||
|
||||
- **`create`** creates the item and immediately publishes it
|
||||
- **`update`** updates the item and publishes if a draft revision was created
|
||||
- **`get`** returns draft data if a pending draft exists (e.g. from the admin UI)
|
||||
|
||||
Use `--draft` on create/update to skip auto-publishing. Use `--published` on get to ignore pending drafts.
|
||||
|
||||
Collections that support revisions store edits as draft revisions. The CLI handles this transparently — agents don't need to know whether a collection uses revisions or not.
|
||||
|
||||
## JSON Output
|
||||
|
||||
All remote commands support `--json` for machine-readable output. It's auto-enabled when stdout is piped.
|
||||
|
||||
```bash
|
||||
# Pipe to jq
|
||||
npx emdash content list posts --json | jq '.items[].slug'
|
||||
|
||||
# Use in scripts
|
||||
ID=$(npx emdash content create posts --data '{"title":"Hello"}' --json | jq -r '.id')
|
||||
```
|
||||
|
||||
## Editing Flow
|
||||
|
||||
For details on how content editing works — Portable Text/markdown conversion, `_rev` tokens, and raw mode — see **[EDITING-FLOW.md](./EDITING-FLOW.md)**.
|
||||
1
templates/marketing/.claude/skills
Symbolic link
1
templates/marketing/.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
33
templates/marketing/.gitignore
vendored
Normal file
33
templates/marketing/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Astro
|
||||
.astro/
|
||||
|
||||
# Data
|
||||
data.db
|
||||
data.db-shm
|
||||
data.db-wal
|
||||
uploads/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
38
templates/marketing/AGENTS.md
Normal file
38
templates/marketing/AGENTS.md
Normal file
@@ -0,0 +1,38 @@
|
||||
This is an EmDash site -- a CMS built on Astro with a full admin UI.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx emdash dev # Start dev server (runs migrations, seeds, generates types)
|
||||
npx emdash types # Regenerate TypeScript types from schema
|
||||
npx emdash seed seed/seed.json --validate # Validate seed file
|
||||
```
|
||||
|
||||
The admin UI is at `http://localhost:4321/_emdash/admin`.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `astro.config.mjs` | Astro config with `emdash()` integration, database, and storage |
|
||||
| `src/live.config.ts` | EmDash loader registration (boilerplate -- don't modify) |
|
||||
| `seed/seed.json` | Schema definition + demo content (collections, fields, taxonomies, menus, widgets) |
|
||||
| `emdash-env.d.ts` | Generated types for collections (auto-regenerated on dev server start) |
|
||||
| `src/layouts/Base.astro` | Base layout with EmDash wiring (menus, search, page contributions) |
|
||||
| `src/pages/` | Astro pages -- all server-rendered |
|
||||
|
||||
## Skills
|
||||
|
||||
Agent skills are in `.agents/skills/`. Load them when working on specific tasks:
|
||||
|
||||
- **building-emdash-site** -- Querying content, rendering Portable Text, schema design, seed files, site features (menus, widgets, search, SEO, comments, bylines). Start here.
|
||||
- **creating-plugins** -- Building EmDash plugins with hooks, storage, admin UI, API routes, and Portable Text block types.
|
||||
- **emdash-cli** -- CLI commands for content management, seeding, type generation, and visual editing flow.
|
||||
|
||||
## Rules
|
||||
|
||||
- All content pages must be server-rendered (`output: "server"`). No `getStaticPaths()` for CMS content.
|
||||
- Image fields are objects (`{ src, alt }`), not strings. Use `<Image image={...} />` from `"emdash/ui"`.
|
||||
- `entry.id` is the slug (for URLs). `entry.data.id` is the database ULID (for API calls like `getEntryTerms`).
|
||||
- Always call `Astro.cache.set(cacheHint)` on pages that query content.
|
||||
- Taxonomy names in queries must match the seed's `"name"` field exactly (e.g., `"category"` not `"categories"`).
|
||||
1
templates/marketing/CLAUDE.md
Symbolic link
1
templates/marketing/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
27
templates/marketing/astro.config.mjs
Normal file
27
templates/marketing/astro.config.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import node from "@astrojs/node";
|
||||
import react from "@astrojs/react";
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
image: {
|
||||
layout: "constrained",
|
||||
responsiveStyles: true,
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
devToolbar: { enabled: false },
|
||||
});
|
||||
39
templates/marketing/emdash-env.d.ts
vendored
Normal file
39
templates/marketing/emdash-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
30
templates/marketing/package.json
Normal file
30
templates/marketing/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@emdashcms/template-marketing",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"emdash": {
|
||||
"seed": "seed/seed.json"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"bootstrap": "emdash init && emdash seed",
|
||||
"seed": "emdash seed",
|
||||
"typecheck": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "catalog:",
|
||||
"@astrojs/react": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"better-sqlite3": "catalog:",
|
||||
"emdash": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:"
|
||||
}
|
||||
}
|
||||
46
templates/marketing/public/hero-visual-alt.svg
Normal file
46
templates/marketing/public/hero-visual-alt.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="2048" preserveAspectRatio="none" style="display: block;" version="1.1" viewBox="240 240 1570 1570" width="2048">
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="Gradient1" x1="893.795" x2="654.653" y1="903.654" y2="875.086">
|
||||
<stop class="stop0" offset="0" stop-color="rgb(51,93,228)" stop-opacity="1"></stop>
|
||||
<stop class="stop1" offset="1" stop-color="rgb(85,132,233)" stop-opacity="1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="Gradient2" x1="1008.78" x2="766.76" y1="907.23" y2="1150.73">
|
||||
<stop class="stop0" offset="0" stop-color="rgb(53,97,230)" stop-opacity="1"></stop>
|
||||
<stop class="stop1" offset="1" stop-color="rgb(85,131,231)" stop-opacity="1"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 639.213 985.822 C 599.029 1095.51 592.565 1205.47 627.414 1317 C 639.256 1356.84 654.688 1394.98 668.42 1434.25 C 687.28 1488.17 708.616 1559.35 653.349 1599.75 C 635.419 1612.86 607.014 1618.82 584.527 1613.8 C 513.029 1597.83 454.824 1528.62 411.284 1473.37 C 294.269 1310.4 338.141 1152.5 416.252 984.404 C 458.873 893.547 508.527 806.158 564.76 723.035 C 597.957 673.1 649.465 610.726 664.569 550.187 C 665.627 543.421 663.463 531.286 658.54 526.492 C 637.911 506.403 597.661 525.235 579.593 539.37 C 502.201 599.913 486.798 701.625 460.981 790.216 C 447.995 834.779 435.132 878.97 414.075 920.55 C 401.901 944.87 388.325 968.523 367.484 986.397 C 341.993 1008.26 308.482 1009.58 287.104 981.073 C 265.374 952.095 263.431 909.764 267.909 875.108 C 279.983 781.672 329.825 691.973 405.979 635.79 C 446.235 606.594 481.1 589.088 514.675 549.535 C 539.676 520.082 546.108 502.575 562.825 470.032 C 582.223 435.231 611.765 403.432 643.08 379.188 C 690.507 342.47 746.661 314.732 803.289 295.722 C 863.417 276.108 923.276 262.042 987.102 268.945 C 1033.84 273.999 1094.95 303.842 1087.98 360.48 C 1084.71 387.066 1066.48 410.709 1046.13 427.395 C 1019.77 449.022 989.265 461.264 958.316 475.187 C 940.483 483.258 922.8 491.653 905.274 500.369 C 844.055 529.864 786.008 565.532 732.035 606.819 C 772.375 606.208 790.658 604.266 828.817 623.286 L 832.297 619.92 C 839.486 610.411 850.115 599.519 858.887 591.474 C 916.423 531.82 996.616 476.07 1079.06 460.442 C 1159.26 445.241 1244.92 471.383 1303.86 527.606 C 1360.35 581.494 1360.44 641.059 1306.06 696.347 L 1305.32 697.091 C 1316.13 700.9 1333.88 700.434 1345.09 697.63 C 1333.07 715.585 1318.27 724.643 1299.82 734.606 C 1295.13 734.835 1291.95 736.721 1287.99 737.442 C 1269.36 740.835 1254.65 750 1237.3 757.126 C 1233.65 758.629 1218.13 763.703 1216.01 765.293 C 1210.27 769.752 1204.94 774.355 1199.1 778.731 C 1184.79 788.851 1166.12 805.986 1153.65 818.234 L 1134.95 837.21 L 1134.96 837.792 C 1125.52 847.107 1109.89 865.731 1102.24 876.899 L 1101.57 877.412 C 1073.64 916.496 1054.48 956.372 1045.92 1004.29 C 1041.9 1026.82 1043.03 1043.12 1037.08 1067.72 C 1034.19 1077.08 1030.95 1087.15 1022.01 1092.36 C 1022.64 1093.97 1024 1097.73 1024.7 1099.1 C 1026.43 1099.82 1026.47 1099.64 1027.25 1101.2 C 1025.98 1104.19 1026.21 1106.66 1025.44 1109.9 C 1023.63 1121.36 1020.74 1131.25 1014.46 1141.09 C 1003.88 1157.4 987.204 1168.78 968.166 1172.68 C 901.408 1186.71 847.367 1122.1 887.744 1061.19 C 878.697 1062.14 861.157 1081.5 856.264 1088.02 C 834.32 1117.29 811.357 1146.58 771.829 1148.81 C 770.311 1148.9 762.115 1149.52 761.068 1149.76 L 760.787 1149.09 C 722.02 1144.65 694.426 1120.16 672.926 1089.4 L 672.899 1088.89 C 653.461 1059.98 644.486 1019.9 639.213 985.822 z" fill="rgb(64,6,183)" transform="translate(0,0)"></path>
|
||||
<path d="M 858.887 591.474 C 916.423 531.82 996.616 476.07 1079.06 460.442 C 1159.26 445.241 1244.92 471.383 1303.86 527.606 C 1360.35 581.494 1360.44 641.059 1306.06 696.347 L 1305.32 697.091 C 1316.13 700.9 1333.88 700.434 1345.09 697.63 C 1333.07 715.585 1318.27 724.643 1299.82 734.606 C 1295.13 734.835 1291.95 736.721 1287.99 737.442 C 1269.36 740.835 1254.65 750 1237.3 757.126 C 1233.65 758.629 1218.13 763.703 1216.01 765.293 C 1210.27 769.752 1204.94 774.355 1199.1 778.731 C 1184.79 788.851 1166.12 805.986 1153.65 818.234 L 1134.95 837.21 L 1134.96 837.792 C 1125.52 847.107 1109.89 865.731 1102.24 876.899 L 1101.57 877.412 C 1087.2 861.428 1050.22 849.983 1028.82 849.315 C 986.679 848 958.121 862.47 923.154 881.831 C 923.154 857.47 921.806 838.806 919.841 814.651 L 920.018 813.946 C 917.752 799.189 915.143 784.485 912.193 769.849 L 912.699 769.325 C 905.469 740.376 899.906 721.08 888.013 693.861 L 887.286 694.02 C 880.808 670.863 850.334 635.896 828.856 623.619 L 828.817 623.286 L 832.297 619.92 C 839.486 610.411 850.115 599.519 858.887 591.474 z" fill="rgb(64,6,183)" transform="translate(0,0)"></path>
|
||||
<path d="M 858.887 591.474 C 916.423 531.82 996.616 476.07 1079.06 460.442 C 1159.26 445.241 1244.92 471.383 1303.86 527.606 C 1360.35 581.494 1360.44 641.059 1306.06 696.347 L 1305.32 697.091 C 1316.13 700.9 1333.88 700.434 1345.09 697.63 C 1333.07 715.585 1318.27 724.643 1299.82 734.606 C 1295.13 734.835 1291.95 736.721 1287.99 737.442 C 1269.36 740.835 1254.65 750 1237.3 757.126 C 1233.65 758.629 1218.13 763.703 1216.01 765.293 C 1210.27 769.752 1204.94 774.355 1199.1 778.731 C 1184.79 788.851 1166.12 805.986 1153.65 818.234 C 1145.36 796.242 1138.91 760.189 1138.95 736.583 L 1138.81 735.785 C 1137.25 726.936 1135.72 723.414 1135.26 713.59 C 1133.99 686.657 1147.14 664.849 1150.08 638.181 C 1153.29 609.121 1147.74 582.039 1129.41 558.848 C 1111.46 536.139 1083.8 522.915 1055.45 519.765 C 988.815 512.361 915.168 553.207 864.108 593.544 C 857.144 599.058 837.152 616.799 832.297 619.92 C 839.486 610.411 850.115 599.519 858.887 591.474 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 1138.81 735.785 C 1139.01 718.081 1141.43 703.875 1153.62 689.829 C 1160.72 681.775 1169.78 675.683 1179.92 672.135 C 1220.6 657.885 1251.4 686.963 1289.82 694.85 C 1294.44 695.799 1300.7 697.745 1305.32 697.091 C 1316.13 700.9 1333.88 700.434 1345.09 697.63 C 1333.07 715.585 1318.27 724.643 1299.82 734.606 C 1295.13 734.835 1291.95 736.721 1287.99 737.442 C 1269.36 740.835 1254.65 750 1237.3 757.126 C 1233.65 758.629 1218.13 763.703 1216.01 765.293 C 1210.27 769.752 1204.94 774.355 1199.1 778.731 C 1184.79 788.851 1166.12 805.986 1153.65 818.234 C 1145.36 796.242 1138.91 760.189 1138.95 736.583 L 1138.81 735.785 z" fill="rgb(202,176,220)" transform="translate(0,0)"></path>
|
||||
<path d="M 1138.95 736.583 C 1141.24 739.468 1142.83 743.159 1145.3 745.729 C 1155.51 756.334 1169.57 762.522 1184 764.632 C 1195.82 766.895 1207.17 763.888 1216.01 765.293 C 1210.27 769.752 1204.94 774.355 1199.1 778.731 C 1184.79 788.851 1166.12 805.986 1153.65 818.234 C 1145.36 796.242 1138.91 760.189 1138.95 736.583 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 920.018 813.946 C 997.397 769.192 1076.14 757.168 1134.95 837.21 L 1134.96 837.792 C 1125.52 847.107 1109.89 865.731 1102.24 876.899 L 1101.57 877.412 C 1087.2 861.428 1050.22 849.983 1028.82 849.315 C 986.679 848 958.121 862.47 923.154 881.831 C 923.154 857.47 921.806 838.806 919.841 814.651 L 920.018 813.946 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 888.013 693.861 C 917.679 680.832 966.645 665.926 999.116 675.687 C 1010.24 679.031 1017.56 692.523 1012.41 703.766 C 1002.82 724.722 977.156 735.468 957.694 745.547 C 942.608 753.307 927.609 761.233 912.699 769.325 C 905.469 740.376 899.906 721.08 888.013 693.861 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 760.787 1149.09 C 759.538 1137.59 757.111 1125.22 755.489 1113.5 C 751.102 1081.81 752.329 1051.57 758.833 1020.27 C 776.343 936 848.758 859.828 919.841 814.651 C 921.806 838.806 923.154 857.47 923.154 881.831 C 958.121 862.47 986.679 848 1028.82 849.315 C 1050.22 849.983 1087.2 861.428 1101.57 877.412 C 1073.64 916.496 1054.48 956.372 1045.92 1004.29 C 1041.9 1026.82 1043.03 1043.12 1037.08 1067.72 C 1034.19 1077.08 1030.95 1087.15 1022.01 1092.36 C 1022.64 1093.97 1024 1097.73 1024.7 1099.1 C 1026.43 1099.82 1026.47 1099.64 1027.25 1101.2 C 1025.98 1104.19 1026.21 1106.66 1025.44 1109.9 C 1023.63 1121.36 1020.74 1131.25 1014.46 1141.09 C 1003.88 1157.4 987.204 1168.78 968.166 1172.68 C 901.408 1186.71 847.367 1122.1 887.744 1061.19 C 878.697 1062.14 861.157 1081.5 856.264 1088.02 C 834.32 1117.29 811.357 1146.58 771.829 1148.81 C 770.311 1148.9 762.115 1149.52 761.068 1149.76 L 760.787 1149.09 z" fill="url(#Gradient2)" transform="translate(0,0)"></path>
|
||||
<path d="M 923.154 881.831 C 958.121 862.47 986.679 848 1028.82 849.315 C 1050.22 849.983 1087.2 861.428 1101.57 877.412 C 1073.64 916.496 1054.48 956.372 1045.92 1004.29 C 1041.9 1026.82 1043.03 1043.12 1037.08 1067.72 C 1015.27 997.98 968.957 932.885 895.253 991.977 C 912.066 954.44 920.707 924.246 922.313 882.58 L 923.154 881.831 z" fill="rgb(64,6,183)" transform="translate(0,0)"></path>
|
||||
<path d="M 888.871 1060.4 C 901.294 1063.4 1015.98 1090.52 1020.64 1089.14 L 1022.01 1092.36 C 1022.64 1093.97 1024 1097.73 1024.7 1099.1 C 1026.43 1099.82 1026.47 1099.64 1027.25 1101.2 C 1025.98 1104.19 1026.21 1106.66 1025.44 1109.9 C 1023.63 1121.36 1020.74 1131.25 1014.46 1141.09 C 1003.88 1157.4 987.204 1168.78 968.166 1172.68 C 901.408 1186.71 847.367 1122.1 887.744 1061.19 L 888.871 1060.4 z" fill="rgb(64,6,183)" transform="translate(0,0)"></path>
|
||||
<path d="M 1024.7 1099.1 C 1026.43 1099.82 1026.47 1099.64 1027.25 1101.2 C 1025.98 1104.19 1026.21 1106.66 1025.44 1109.9 C 1025.12 1106.26 1025.02 1102.74 1024.7 1099.1 z" fill="rgb(229,216,238)" transform="translate(0,0)"></path>
|
||||
<path d="M 922.313 882.58 C 920.707 924.246 912.066 954.44 895.253 991.977 L 892.769 995.34 C 867.289 1019.76 856.592 1052.93 827.448 1069.87 C 824.051 1071.84 818.471 1072.19 814.586 1072.64 C 795.303 1063.39 795.421 1035.6 800.218 1017.81 C 815.66 960.556 873.72 912.733 922.313 882.58 z" fill="rgb(23,1,122)" transform="translate(0,0)"></path>
|
||||
<path d="M 888.871 1060.4 C 899.229 1046.61 914.682 1037.54 931.775 1035.23 C 953.308 1032.13 974.629 1039.3 991.73 1052.31 C 1004.4 1061.87 1014.37 1074.56 1020.64 1089.14 C 1015.98 1090.52 901.294 1063.4 888.871 1060.4 z" fill="rgb(23,1,122)" transform="translate(0,0)"></path>
|
||||
<path d="M 892.769 995.34 C 882.999 1019.65 868.937 1045.53 849.009 1063.07 C 842.214 1069.05 835.917 1073.69 826.983 1075.13 C 821.428 1076.03 819.387 1073.94 814.586 1072.64 C 818.471 1072.19 824.051 1071.84 827.448 1069.87 C 856.592 1052.93 867.289 1019.76 892.769 995.34 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 828.856 623.619 C 850.334 635.896 880.808 670.863 887.286 694.02 L 888.013 693.861 C 899.906 721.08 905.469 740.376 912.699 769.325 L 912.193 769.849 C 915.143 784.485 917.752 799.189 920.018 813.946 L 919.841 814.651 C 848.758 859.828 776.343 936 758.833 1020.27 C 752.329 1051.57 751.102 1081.81 755.489 1113.5 C 757.111 1125.22 759.538 1137.59 760.787 1149.09 C 722.02 1144.65 694.426 1120.16 672.926 1089.4 L 672.899 1088.89 C 653.461 1059.98 644.486 1019.9 639.213 985.822 L 640.411 981.847 C 637.689 976.461 637.447 960.077 637.373 953.568 C 636.799 902.721 643.795 861.172 658.732 812.987 C 676.216 796.335 693.233 783.713 709.657 764.219 C 747.989 718.723 786.395 664.966 828.856 623.619 z" fill="url(#Gradient1)" transform="translate(0,0)"></path>
|
||||
<path d="M 672.899 1088.89 C 676.856 1080.46 681.087 1068.26 684.828 1059.25 C 691.652 1043.25 698.809 1027.39 706.294 1011.69 C 752.836 914.834 817.01 823.939 912.193 769.849 C 915.143 784.485 917.752 799.189 920.018 813.946 L 919.841 814.651 C 848.758 859.828 776.343 936 758.833 1020.27 C 752.329 1051.57 751.102 1081.81 755.489 1113.5 C 757.111 1125.22 759.538 1137.59 760.787 1149.09 C 722.02 1144.65 694.426 1120.16 672.926 1089.4 L 672.899 1088.89 z" fill="rgb(64,6,183)" transform="translate(0,0)"></path>
|
||||
<path d="M 828.856 623.619 C 850.334 635.896 880.808 670.863 887.286 694.02 C 755.488 759.815 688.237 844.262 640.411 981.847 C 637.689 976.461 637.447 960.077 637.373 953.568 C 636.799 902.721 643.795 861.172 658.732 812.987 C 676.216 796.335 693.233 783.713 709.657 764.219 C 747.989 718.723 786.395 664.966 828.856 623.619 z" fill="rgb(23,1,122)" transform="translate(0,0)"></path>
|
||||
<path d="M 732.035 606.819 C 772.375 606.208 790.658 604.266 828.817 623.286 L 828.856 623.619 C 786.395 664.966 747.989 718.723 709.657 764.219 C 693.233 783.713 676.216 796.335 658.732 812.987 C 649.093 818.915 640.406 823.455 628.511 823.227 C 620.561 823.07 613.01 819.716 607.564 813.923 C 591.598 796.798 601.194 766.203 609.532 747.633 C 628.131 706.209 657.139 673.963 689.224 642.788 C 700.989 632.864 716.568 619.723 728.025 609.662 L 732.035 606.819 z" fill="rgb(249,117,154)" transform="translate(0,0)"></path>
|
||||
<path d="M 689.224 642.788 C 690.407 630.074 714.845 611.995 728.025 609.662 C 716.568 619.723 700.989 632.864 689.224 642.788 z" fill="rgb(23,1,122)" transform="translate(0,0)"></path>
|
||||
<path d="M 1216.01 765.293 C 1218.13 763.703 1233.65 758.629 1237.3 757.126 C 1254.65 750 1269.36 740.835 1287.99 737.442 C 1291.95 736.721 1295.13 734.835 1299.82 734.606 C 1387.5 771.747 1433.97 845.724 1444.31 938.595 C 1446.06 951.208 1448.69 984.967 1446.49 996.676 C 1442.65 1046.68 1422.47 1095.9 1406.52 1143.14 C 1402.94 1153.71 1398.98 1164.88 1394.74 1175.2 L 1394.42 1176.11 C 1377.89 1210.36 1360.82 1234.3 1341.02 1266.06 C 1324.6 1292.39 1311.58 1323.2 1294.82 1350.63 C 1251.15 1422.11 1186 1496.42 1112.14 1537.7 C 1109.68 1540.37 1100.67 1571.32 1098.37 1577.43 C 1071.92 1647.67 1029.96 1718.72 962.518 1756.11 C 920.661 1779.32 873.578 1786.92 827.534 1773.29 C 781.774 1759.7 743.365 1728.35 720.894 1686.23 C 697.871 1643.37 692.857 1593.1 706.96 1546.53 C 725.255 1485.4 773.635 1432.54 829.98 1402.98 C 850.624 1392.15 879.86 1383.83 900.953 1372.22 L 901.968 1371.65 C 911.067 1369.89 964.558 1343.51 967.282 1337.14 C 967.963 1337.26 968.643 1337.39 969.323 1337.52 C 1012.59 1312 1065.51 1271.24 1070.07 1217.52 C 1073.44 1177.95 1051.9 1131.09 1027.25 1101.2 C 1026.47 1099.64 1026.43 1099.82 1024.7 1099.1 C 1024 1097.73 1022.64 1093.97 1022.01 1092.36 C 1030.95 1087.15 1034.19 1077.08 1037.08 1067.72 C 1043.03 1043.12 1041.9 1026.82 1045.92 1004.29 C 1054.48 956.372 1073.64 916.496 1101.57 877.412 L 1102.24 876.899 C 1109.89 865.731 1125.52 847.107 1134.96 837.792 L 1134.95 837.21 L 1153.65 818.234 C 1166.12 805.986 1184.79 788.851 1199.1 778.731 C 1204.94 774.355 1210.27 769.752 1216.01 765.293 z" fill="rgb(202,176,220)" transform="translate(0,0)"></path>
|
||||
<path d="M 1216.01 765.293 C 1218.13 763.703 1233.65 758.629 1237.3 757.126 C 1254.65 750 1269.36 740.835 1287.99 737.442 C 1291.95 736.721 1295.13 734.835 1299.82 734.606 C 1387.5 771.747 1433.97 845.724 1444.31 938.595 C 1446.06 951.208 1448.69 984.967 1446.49 996.676 C 1442.65 1046.68 1422.47 1095.9 1406.52 1143.14 C 1402.94 1153.71 1398.98 1164.88 1394.74 1175.2 L 1394.42 1176.11 C 1377.89 1210.36 1360.82 1234.3 1341.02 1266.06 C 1324.6 1292.39 1311.58 1323.2 1294.82 1350.63 C 1251.15 1422.11 1186 1496.42 1112.14 1537.7 C 1111.88 1532.04 1119.08 1508.57 1120.51 1500.65 C 1125.62 1472.48 1130.13 1445.96 1130.58 1417.21 C 1130.69 1410 1127.87 1377.17 1130.01 1373.54 C 1128.88 1371.78 1128.5 1366.51 1128.04 1363.83 L 1127.92 1363.21 C 1120.94 1337.3 1119.96 1291.49 1113.69 1264.99 L 1115.14 1263.71 C 1112.49 1257.66 1113.51 1233.23 1113.97 1225.96 C 1115.56 1200.6 1125.66 1176.81 1133.45 1152.9 C 1139.34 1135.05 1144.41 1116.93 1148.64 1098.6 C 1160.85 1044.65 1166.9 984.37 1144.69 931.952 C 1135.45 910.136 1119.17 893.026 1102.24 876.899 C 1109.89 865.731 1125.52 847.107 1134.96 837.792 L 1134.95 837.21 L 1153.65 818.234 C 1166.12 805.986 1184.79 788.851 1199.1 778.731 C 1204.94 774.355 1210.27 769.752 1216.01 765.293 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1115.14 1263.71 C 1132.03 1249.66 1150.88 1237.42 1167.96 1223.45 C 1223.92 1177.71 1263.52 1120.2 1270.23 1046.36 C 1273.49 1010.66 1269.01 975.777 1265.54 940.27 C 1261.39 897.75 1246.82 863.741 1266.82 822.714 C 1287.69 779.912 1337.7 773.964 1367.06 810.964 C 1412.67 868.455 1389.21 947.884 1361.4 1008.16 C 1348.43 1035.84 1333.95 1062.79 1318.03 1088.87 C 1307.98 1105.11 1297.05 1120.79 1286.95 1137.2 C 1252.17 1193.69 1218.23 1251.39 1178.08 1304.27 C 1163.59 1323.35 1145.35 1346.74 1127.92 1363.21 C 1120.94 1337.3 1119.96 1291.49 1113.69 1264.99 L 1115.14 1263.71 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 1444.31 938.595 C 1446.06 951.208 1448.69 984.967 1446.49 996.676 C 1442.65 1046.68 1422.47 1095.9 1406.52 1143.14 C 1402.94 1153.71 1398.98 1164.88 1394.74 1175.2 L 1394.42 1176.11 C 1377.89 1210.36 1360.82 1234.3 1341.02 1266.06 C 1324.6 1292.39 1311.58 1323.2 1294.82 1350.63 C 1251.15 1422.11 1186 1496.42 1112.14 1537.7 C 1111.88 1532.04 1119.08 1508.57 1120.51 1500.65 C 1125.62 1472.48 1130.13 1445.96 1130.58 1417.21 C 1130.69 1410 1127.87 1377.17 1130.01 1373.54 C 1133.96 1374.39 1137.88 1374.8 1141.91 1375.02 C 1173.38 1376.73 1205.34 1361.23 1230.71 1344 C 1398.19 1230.29 1322.24 1046.86 1444.31 938.595 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 1394.74 1175.2 C 1374.21 1137.37 1379.95 1109.85 1390.55 1069.28 C 1395.62 1049.91 1416.02 981.91 1445.66 994.389 L 1446.49 996.676 C 1442.65 1046.68 1422.47 1095.9 1406.52 1143.14 C 1402.94 1153.71 1398.98 1164.88 1394.74 1175.2 z" fill="rgb(56,52,211)" transform="translate(0,0)"></path>
|
||||
<path d="M 1199.1 778.731 C 1240.86 841.33 1241.13 906.761 1227.99 978.308 C 1216.81 1039.17 1179.24 1131.45 1138.42 1178.72 C 1137.14 1175.74 1137.51 1175.04 1138.02 1171.68 L 1138.25 1169.73 C 1141.77 1158.15 1147.83 1143.66 1151.9 1131.54 C 1158.52 1111.29 1163.73 1090.62 1167.5 1069.66 C 1180.68 998.298 1176.96 898.664 1134.96 837.792 L 1134.95 837.21 L 1153.65 818.234 C 1166.12 805.986 1184.79 788.851 1199.1 778.731 z" fill="rgb(249,117,154)" transform="translate(0,0)"></path>
|
||||
<path d="M 1129.24 1189.94 C 1131.71 1184.1 1135.37 1175.29 1138.25 1169.73 L 1138.02 1171.68 C 1137.51 1175.04 1137.14 1175.74 1138.42 1178.72 C 1136.08 1182.52 1132.24 1186.5 1129.24 1189.94 z" fill="rgb(239,147,173)" transform="translate(0,0)"></path>
|
||||
<path d="M 1113.69 1264.99 C 1119.96 1291.49 1120.94 1337.3 1127.92 1363.21 L 1128.04 1363.83 L 1120.42 1370.44 C 1098.73 1359.92 1080.64 1344.45 1081.67 1317.62 C 1082.55 1294.65 1097.56 1279.3 1113.69 1264.99 z" fill="rgb(56,52,211)" transform="translate(0,0)"></path>
|
||||
<path d="M 1345.09 697.63 L 1346.26 697.27 C 1362.78 692.037 1375.08 681.015 1382.71 665.999 C 1408.19 615.811 1372.2 566.619 1343.32 526.964 C 1313.75 486.377 1307.63 418.506 1367.12 400.609 C 1420.43 384.572 1471.73 412.736 1512.05 445.752 C 1587.28 503.852 1665.46 587.38 1693.95 679.538 C 1702.27 706.334 1705.82 734.38 1704.46 762.403 C 1701.99 806.624 1692.42 830.644 1699.82 879.075 C 1704.52 910.362 1716.62 940.076 1735.11 965.747 C 1742.58 976.163 1751.27 985.972 1757.93 996.742 C 1788.92 1046.91 1786.14 1095.75 1758.32 1146.63 C 1734.48 1190.24 1709.95 1204.85 1692.72 1255.08 C 1676.46 1296.91 1685.9 1340.43 1676.45 1383.02 C 1659.13 1461.02 1613.07 1521.42 1553.86 1573.63 C 1512.86 1609.78 1439.22 1653.61 1410.92 1575.36 C 1397.3 1537.72 1420.9 1484.15 1439.72 1449.94 C 1462.21 1409.56 1490.16 1372.49 1522.78 1339.76 C 1544.94 1317.1 1570.96 1298.88 1592.17 1278.05 C 1643.49 1227.64 1718.61 1154.79 1703.95 1076.17 C 1702.82 1069.96 1696.27 1060.48 1690.69 1057.68 C 1659.72 1042.13 1619.65 1102.1 1602.68 1122.47 C 1585.52 1143.19 1567.74 1163.38 1549.34 1183.01 C 1564.77 1142.56 1584.97 1098.12 1586.9 1054.09 L 1587.19 1052.94 C 1586.88 1000.31 1567.9 930.572 1507.02 919.916 C 1490.75 917.068 1477.11 921.511 1462.02 927.197 C 1462.02 951.348 1460.33 979.792 1460.77 1002.52 C 1456.46 1055.76 1446.8 1105.68 1427.05 1155.54 C 1423.48 1164.57 1416.17 1187.04 1404.98 1187.84 C 1400.24 1185.73 1397.54 1180.58 1394.42 1176.11 L 1394.74 1175.2 C 1398.98 1164.88 1402.94 1153.71 1406.52 1143.14 C 1422.47 1095.9 1442.65 1046.68 1446.49 996.676 C 1448.69 984.967 1446.06 951.208 1444.31 938.595 C 1433.97 845.724 1387.5 771.747 1299.82 734.606 C 1318.27 724.643 1333.07 715.585 1345.09 697.63 z" fill="rgb(249,117,154)" transform="translate(0,0)"></path>
|
||||
<path d="M 1462.02 927.197 L 1457.51 841.463 C 1456.01 813.053 1445.07 714.939 1491.98 713.273 C 1517.76 712.358 1549.86 742.595 1566.71 759.767 C 1600.77 795.084 1653.94 856.139 1653.03 906.961 C 1651.84 972.966 1621.45 1004.98 1587.19 1052.94 C 1586.88 1000.31 1567.9 930.572 1507.02 919.916 C 1490.75 917.068 1477.11 921.511 1462.02 927.197 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1462.02 927.197 C 1477.11 921.511 1490.75 917.068 1507.02 919.916 C 1567.9 930.572 1586.88 1000.31 1587.19 1052.94 L 1586.9 1054.09 C 1584.97 1098.12 1564.77 1142.56 1549.34 1183.01 L 1547.2 1186.8 C 1527.64 1231.8 1502.54 1274.19 1472.48 1312.98 C 1469.03 1317.41 1465.55 1321.76 1461.92 1326.04 C 1433.55 1359.52 1404.57 1394.4 1382.38 1432.4 C 1367.83 1457.13 1355.41 1483.05 1345.26 1509.88 C 1337.71 1529.8 1331.78 1551.81 1324.63 1572.31 C 1302.86 1634.66 1274.38 1698.3 1224.05 1743.02 C 1198.21 1765.95 1167.37 1781.43 1132.52 1779.87 C 1091.11 1778.03 1063.43 1738.44 1068.89 1698.6 C 1080.94 1610.71 1181.59 1551.32 1246.34 1502.56 C 1280.27 1477 1310.41 1451.48 1338.55 1420.05 C 1359.65 1396.48 1374.49 1371.66 1390.45 1345.41 C 1437.56 1267.95 1472.33 1184 1479.88 1092.96 C 1482.55 1061.61 1481.84 1027.73 1460.77 1002.52 C 1460.33 979.792 1462.02 951.348 1462.02 927.197 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 1547.2 1186.8 C 1531.67 1198.28 1522.1 1215.18 1501.59 1220.39 C 1494.06 1222.3 1468.98 1229.18 1467.58 1216.06 C 1467.27 1213.2 1472.87 1204.01 1474.86 1201.15 C 1500.64 1164.31 1528.29 1128.62 1555.79 1093.03 C 1561.03 1086.26 1581.87 1058.57 1586.9 1054.09 C 1584.97 1098.12 1564.77 1142.56 1549.34 1183.01 L 1547.2 1186.8 z" fill="rgb(56,52,211)" transform="translate(0,0)"></path>
|
||||
<path d="M 761.068 1149.76 C 765.435 1165.26 768.768 1184.32 773.557 1201.63 L 787.727 1252.25 C 796.663 1283.98 808.558 1320.2 795.644 1352.25 C 789.593 1367.39 779.203 1380.54 763.648 1386.77 C 717.495 1405.24 677.688 1364.97 661.847 1325.37 C 636.458 1261.91 642.648 1193.27 660.383 1128.92 C 663.909 1116.12 667.904 1101.52 672.926 1089.4 C 694.426 1120.16 722.02 1144.65 760.787 1149.09 L 761.068 1149.76 z" fill="rgb(77,123,230)" transform="translate(0,0)"></path>
|
||||
<path d="M 901.968 1371.65 C 788.969 1328.18 785.295 1197.74 915.548 1185.29 C 945.196 1182.46 991.29 1193 1013.52 1213.91 C 1047 1245.38 1008.66 1266.9 986.906 1286.67 C 972.327 1300.44 966.834 1317.98 967.282 1337.14 C 964.558 1343.51 911.067 1369.89 901.968 1371.65 z" fill="rgb(249,117,154)" transform="translate(0,0)"></path>
|
||||
<path d="M 1174.95 294.296 C 1229.63 288.727 1293.85 327.801 1301.22 384.465 C 1306.13 422.228 1278.5 443.636 1244.14 446.445 C 1196.69 447.199 1127.41 409.667 1116.78 360.537 C 1108.31 321.355 1141.72 299.577 1174.95 294.296 z" fill="rgb(249,117,154)" transform="translate(0,0)"></path>
|
||||
<metadata><recraft-signature>{"signed_by": "recraft", "signature_b64": "2dVpMMvJScfQFf9AD+JvQSNialO9x3ouUIfWJR7K2YXhu8VQ5hMzUyx6+p5vkXTadEDx8iAWxrr72oPYmRSbCg==", "signing_algo": "Ed25519", "generation_timestamp": 1773854554, "identifier": "da61a45e-a820-4101-924a-2ab27b6231f4"}</recraft-signature></metadata></svg>
|
||||
|
After Width: | Height: | Size: 23 KiB |
135
templates/marketing/public/hero-visual.svg
Normal file
135
templates/marketing/public/hero-visual.svg
Normal file
@@ -0,0 +1,135 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="2048" preserveAspectRatio="none" style="display: block;" version="1.1" viewBox="100 120 1800 1800" width="2048"><g transform="translate(2000,0) scale(-1,1)">
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="Gradient1" x1="1151.86" x2="1300.64" y1="888.005" y2="634.111">
|
||||
<stop class="stop0" offset="0" stop-color="rgb(231,153,211)" stop-opacity="1"></stop>
|
||||
<stop class="stop1" offset="1" stop-color="rgb(202,127,238)" stop-opacity="1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="Gradient2" x1="1192.53" x2="1265.37" y1="908.148" y2="551.989">
|
||||
<stop class="stop0" offset="0" stop-color="rgb(240,164,210)" stop-opacity="1"></stop>
|
||||
<stop class="stop1" offset="1" stop-color="rgb(202,127,236)" stop-opacity="1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="Gradient3" x1="864.367" x2="1053.08" y1="1211.66" y2="1485.1">
|
||||
<stop class="stop0" offset="0" stop-color="rgb(205,68,187)" stop-opacity="1"></stop>
|
||||
<stop class="stop1" offset="1" stop-color="rgb(174,55,192)" stop-opacity="1"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 377.991 738.898 L 377.971 599.678 C 377.968 576.071 377.479 552.392 378.173 528.801 C 380.411 452.726 441.255 421.005 495.977 383.899 L 624.785 296.335 L 765.037 200.983 C 788.846 184.755 813.717 167.13 838.233 151.993 C 848.947 145.378 863.861 141.312 876.613 142.251 C 882.763 142.756 888.794 144.236 894.479 146.636 C 921.696 157.9 954.105 180.572 981.123 194.09 C 992.047 199.556 1001.93 216.497 1003.77 228.723 C 1006.46 246.567 1005.07 266.433 1005.47 284.592 C 1005.11 311.192 1006.89 339.638 1003.75 365.905 L 1004.47 366.277 C 1003.86 366.847 1020.28 334.136 1022.34 330.729 C 1032.07 314.811 1043.65 300.099 1056.84 286.901 C 1113.75 229.531 1219.69 185.562 1285.96 252.589 L 1287.53 253.226 C 1319.04 265.812 1326.94 275.255 1345.19 302.744 C 1371.7 284.833 1395.99 269.094 1430.03 275.564 C 1453.87 280.251 1474.82 294.345 1488.14 314.663 C 1513.01 351.908 1506.85 400.103 1507.09 443.009 C 1507.34 488.901 1507.46 535.724 1506.83 581.597 C 1517.5 574.216 1548.03 551.271 1558.96 550.403 C 1576.59 549.002 1613.33 570.604 1624.77 583.9 C 1630.98 591.109 1628.61 630.087 1628.65 639.352 C 1643.98 628.851 1678.95 600.598 1697.32 607.49 C 1721.11 616.418 1748.35 634.836 1771.15 647.459 C 1781.22 653.033 1785.37 663.984 1785.7 674.901 C 1786.31 694.976 1786.06 715.073 1786.06 735.151 L 1785.94 851.049 C 1801.11 844.35 1813.99 839.649 1830.78 845.862 C 1841.87 849.905 1850.82 858.315 1855.55 869.132 C 1875.65 915.103 1814.75 936.66 1785.85 958.142 C 1786.59 986.248 1786 1014.97 1786.03 1043.28 C 1786.08 1098.59 1791.88 1144.92 1750.71 1189.29 C 1738.06 1202.93 1719.83 1213.69 1704.13 1224.14 L 1704.27 1304.12 C 1704.32 1320.5 1706.15 1340.69 1700.62 1355.82 C 1691.52 1395.74 1673.72 1433.3 1640.5 1458.39 C 1595.8 1492.16 1546.12 1521.36 1500.8 1554.25 C 1500.85 1603.81 1505.35 1640.03 1466.92 1679.37 C 1455.66 1690.89 1431.49 1705.99 1417.28 1715.66 L 1338.98 1768.9 L 1238.57 1837.31 C 1219.38 1850.47 1199.19 1865.17 1179.89 1876.93 C 1154.6 1892.33 1135 1867.42 1137.69 1842.56 C 1138.6 1834.1 1137.79 1819.73 1137.82 1810.9 C 1104.52 1833.18 1071.38 1855.68 1038.4 1878.41 C 1027.63 1885.76 982.528 1920.1 971.102 1915.68 C 942.995 1904.81 913.661 1884.64 886.688 1870.01 C 868.962 1860.4 868.69 1836.42 868.991 1819.43 C 860.247 1821.76 851.394 1823.66 842.463 1825.12 C 779.03 1835.26 714.172 1819.75 662.19 1782.01 C 609.231 1743.46 573.581 1682.88 567.968 1617.3 C 566.581 1601.09 569.568 1574.11 566.908 1560.23 C 517.33 1591.59 511.341 1597.99 457.982 1567.71 C 451.053 1571 436.797 1581.05 429.831 1585.73 L 385.034 1615.61 C 371.271 1624.73 357.149 1636.5 339.73 1632.41 C 298.633 1622.4 310.953 1571.42 309.218 1540.51 C 308.449 1526.8 309.816 1511.82 309.108 1498.37 C 305.684 1433.4 323.772 1404.54 377.987 1370.09 L 377.944 1172.61 C 344.749 1193.5 313.102 1217.68 279.966 1238.73 C 275.276 1241.71 268.788 1241.13 264.614 1243.59 C 248.773 1252.93 237.123 1267 216.911 1263.67 C 207.88 1262.18 199.829 1257.12 194.569 1249.63 C 184.324 1235.34 186.225 1210.84 186.196 1193.82 L 186.191 1140.28 L 186.253 1081.37 C 186.32 1056.53 184.789 1039.2 195.44 1016.2 C 207.221 990.769 223.663 981.334 245.848 966.196 C 242.791 890.459 240.496 838.419 306.242 788.288 C 327.103 772.382 359.673 755.598 377.991 738.898 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1003.75 365.905 L 1004.47 366.277 C 1003.86 366.847 1020.28 334.136 1022.34 330.729 C 1032.07 314.811 1043.65 300.099 1056.84 286.901 C 1113.75 229.531 1219.69 185.562 1285.96 252.589 L 1287.53 253.226 C 1319.04 265.812 1326.94 275.255 1345.19 302.744 L 1346.45 306.928 C 1346.72 307.177 1346.98 307.426 1347.24 307.676 C 1350.96 314.76 1353.32 322.698 1354.96 330.503 C 1352.36 329.27 1349.81 327.85 1347.26 326.683 C 1348.78 322.488 1344.47 314.855 1342.58 310.044 C 1338.95 312.464 1328.89 318.017 1328.11 321.539 C 1323.68 321.23 1321.17 321.072 1316.71 321.048 C 1313.64 322.062 1309.54 322.55 1306.21 323.305 C 1304.48 323.867 1302.71 324.199 1300.94 324.619 C 1278.73 333.43 1223.99 372.812 1201.54 388.05 L 1037.37 499.862 L 1031.95 503.7 L 1003.35 523.114 C 1002.59 514.554 1003.05 499.035 1003.04 490.072 L 1003.02 428.732 C 1003.02 421.227 1002.77 370.165 1003.75 365.905 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1003.75 365.905 L 1004.47 366.277 C 1003.86 366.847 1020.28 334.136 1022.34 330.729 C 1032.07 314.811 1043.65 300.099 1056.84 286.901 C 1113.75 229.531 1219.69 185.562 1285.96 252.589 C 1281.8 251.492 1278.29 250.281 1274.28 249.11 C 1271.45 249.026 1268.62 248.896 1265.79 248.722 C 1254.87 245.409 1228.11 249.514 1217.01 252.602 C 1134.95 275.416 1065.45 355.553 1041.56 435.257 C 1034.82 457.433 1031.58 480.525 1031.95 503.7 L 1003.35 523.114 C 1002.59 514.554 1003.05 499.035 1003.04 490.072 L 1003.02 428.732 C 1003.02 421.227 1002.77 370.165 1003.75 365.905 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1003.75 365.905 L 1004.47 366.277 C 1003.86 366.847 1020.28 334.136 1022.34 330.729 C 1032.07 314.811 1043.65 300.099 1056.84 286.901 C 1113.75 229.531 1219.69 185.562 1285.96 252.589 C 1281.8 251.492 1278.29 250.281 1274.28 249.11 C 1201.18 190.325 1091.28 247.54 1042.57 311.571 C 1026.16 333.134 1012.79 356.596 1005 382.828 C 1002.62 391.164 1003.71 421.342 1003.65 431.913 C 1003.46 462.166 1004.27 492.785 1003.35 523.114 C 1002.59 514.554 1003.05 499.035 1003.04 490.072 L 1003.02 428.732 C 1003.02 421.227 1002.77 370.165 1003.75 365.905 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1031.95 503.7 C 1031.58 480.525 1034.82 457.433 1041.56 435.257 C 1065.45 355.553 1134.95 275.416 1217.01 252.602 C 1228.11 249.514 1254.87 245.409 1265.79 248.722 C 1269.24 250.721 1292.11 254.597 1287.56 256.685 L 1287.55 256.661 C 1283.23 257.43 1273.01 250.885 1265.87 253.429 C 1253.45 254.316 1240.13 253.082 1227.85 255.946 C 1121.52 280.74 1039.32 393.696 1037.37 499.862 L 1031.95 503.7 z" fill="rgb(156,105,225)" transform="translate(0,0)"></path>
|
||||
<path d="M 1265.79 248.722 C 1268.62 248.896 1271.45 249.026 1274.28 249.11 C 1278.29 250.281 1281.8 251.492 1285.96 252.589 L 1287.53 253.226 C 1319.04 265.812 1326.94 275.255 1345.19 302.744 L 1346.45 306.928 C 1346.72 307.177 1346.98 307.426 1347.24 307.676 C 1350.96 314.76 1353.32 322.698 1354.96 330.503 C 1352.36 329.27 1349.81 327.85 1347.26 326.683 C 1348.78 322.488 1344.47 314.855 1342.58 310.044 C 1338.95 312.464 1328.89 318.017 1328.11 321.539 C 1323.68 321.23 1321.17 321.072 1316.71 321.048 C 1313.64 322.062 1309.54 322.55 1306.21 323.305 C 1306.97 290.926 1305.1 289.396 1293.23 261.281 C 1289.46 259.197 1287.07 258.695 1282.98 257.538 C 1282.46 257.409 1281.95 257.286 1281.43 257.169 C 1276.39 256.04 1270.69 255.101 1265.87 253.429 C 1273.01 250.885 1283.23 257.43 1287.55 256.661 L 1287.56 256.685 C 1292.11 254.597 1269.24 250.721 1265.79 248.722 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1265.79 248.722 C 1268.62 248.896 1271.45 249.026 1274.28 249.11 C 1278.29 250.281 1281.8 251.492 1285.96 252.589 L 1287.53 253.226 C 1319.04 265.812 1326.94 275.255 1345.19 302.744 L 1346.45 306.928 C 1346.72 307.177 1346.98 307.426 1347.24 307.676 C 1350.96 314.76 1353.32 322.698 1354.96 330.503 C 1352.36 329.27 1349.81 327.85 1347.26 326.683 C 1348.78 322.488 1344.47 314.855 1342.58 310.044 L 1341.68 307.866 C 1341.21 307.296 1340.73 306.726 1340.25 306.157 C 1331.7 286.835 1311.98 270.223 1293.23 261.281 C 1289.46 259.197 1287.07 258.695 1282.98 257.538 C 1282.46 257.409 1281.95 257.286 1281.43 257.169 C 1276.39 256.04 1270.69 255.101 1265.87 253.429 C 1273.01 250.885 1283.23 257.43 1287.55 256.661 L 1287.56 256.685 C 1292.11 254.597 1269.24 250.721 1265.79 248.722 z" fill="rgb(141,58,208)" transform="translate(0,0)"></path>
|
||||
<path d="M 1340.25 306.157 C 1340.73 306.726 1341.21 307.296 1341.68 307.866 L 1342.58 310.044 C 1338.95 312.464 1328.89 318.017 1328.11 321.539 C 1323.68 321.23 1321.17 321.072 1316.71 321.048 C 1322.19 317.125 1334.67 308.712 1340.25 306.157 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1282.98 257.538 C 1287.07 258.695 1289.46 259.197 1293.23 261.281 C 1305.1 289.396 1306.97 290.926 1306.21 323.305 C 1304.48 323.867 1302.71 324.199 1300.94 324.619 C 1301.07 295.531 1299.91 282.044 1282.98 257.538 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 572.798 1556.16 C 586.378 1546.58 602.269 1536.7 615.13 1527.12 C 614.842 1592.26 607.616 1639.16 658.239 1690.69 C 705.639 1738.95 778.254 1757 844.312 1756.98 C 852.548 1756.91 860.782 1756.72 869.011 1756.38 L 869.014 1813.61 L 868.991 1819.43 C 860.247 1821.76 851.394 1823.66 842.463 1825.12 C 779.03 1835.26 714.172 1819.75 662.19 1782.01 C 609.231 1743.46 573.581 1682.88 567.968 1617.3 C 566.581 1601.09 569.568 1574.11 566.908 1560.23 L 572.798 1556.16 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 572.798 1556.16 C 572.743 1574.66 571.956 1601.59 573.661 1619.38 C 578.957 1670.05 601.602 1717.34 637.763 1753.24 C 683.337 1798.99 740.948 1822.85 805.444 1822.22 C 826.601 1822.01 848.702 1819.78 869.014 1813.61 L 868.991 1819.43 C 860.247 1821.76 851.394 1823.66 842.463 1825.12 C 779.03 1835.26 714.172 1819.75 662.19 1782.01 C 609.231 1743.46 573.581 1682.88 567.968 1617.3 C 566.581 1601.09 569.568 1574.11 566.908 1560.23 L 572.798 1556.16 z" fill="rgb(156,105,225)" transform="translate(0,0)"></path>
|
||||
<path d="M 1345.19 302.744 C 1371.7 284.833 1395.99 269.094 1430.03 275.564 C 1453.87 280.251 1474.82 294.345 1488.14 314.663 C 1513.01 351.908 1506.85 400.103 1507.09 443.009 C 1507.34 488.901 1507.46 535.724 1506.83 581.597 L 1502.03 584.909 C 1485.11 596.62 1468.12 608.231 1451.05 619.742 L 1451.06 475.493 C 1451.07 454.307 1451.27 433.01 1450.95 411.835 C 1450.43 378.215 1428.57 370.896 1403.44 357.136 L 1354.96 330.503 C 1353.32 322.698 1350.96 314.76 1347.24 307.676 C 1346.98 307.426 1346.72 307.177 1346.45 306.928 L 1345.19 302.744 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1345.19 302.744 C 1371.7 284.833 1395.99 269.094 1430.03 275.564 C 1453.87 280.251 1474.82 294.345 1488.14 314.663 C 1513.01 351.908 1506.85 400.103 1507.09 443.009 C 1507.34 488.901 1507.46 535.724 1506.83 581.597 L 1502.03 584.909 C 1500.86 535.052 1502.35 484.733 1501.72 434.651 C 1501.36 386.01 1507.77 328.659 1462.77 295.946 C 1420.77 265.406 1384.88 282.234 1347.24 307.676 C 1346.98 307.426 1346.72 307.177 1346.45 306.928 L 1345.19 302.744 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 377.987 1370.09 L 377.97 1376.63 L 377.982 1447.36 C 377.985 1528.21 381.401 1526.44 451.804 1564.41 L 457.982 1567.71 C 451.053 1571 436.797 1581.05 429.831 1585.73 L 385.034 1615.61 C 371.271 1624.73 357.149 1636.5 339.73 1632.41 C 298.633 1622.4 310.953 1571.42 309.218 1540.51 C 308.449 1526.8 309.816 1511.82 309.108 1498.37 C 305.684 1433.4 323.772 1404.54 377.987 1370.09 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 377.987 1370.09 L 377.97 1376.63 C 330.507 1408.25 310.433 1435.51 314.649 1495.58 C 316.388 1520.37 308.984 1595.83 321.565 1613.83 C 326.637 1621.1 334.443 1625.98 343.19 1627.38 C 359.088 1629.8 379.577 1612.63 392.775 1603.85 L 451.804 1564.41 L 457.982 1567.71 C 451.053 1571 436.797 1581.05 429.831 1585.73 L 385.034 1615.61 C 371.271 1624.73 357.149 1636.5 339.73 1632.41 C 298.633 1622.4 310.953 1571.42 309.218 1540.51 C 308.449 1526.8 309.816 1511.82 309.108 1498.37 C 305.684 1433.4 323.772 1404.54 377.987 1370.09 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 377.991 738.898 L 377.97 746.043 L 377.903 877.136 L 295.287 932.883 C 281.478 942.242 264.888 954.124 250.972 962.764 L 245.848 966.196 C 242.791 890.459 240.496 838.419 306.242 788.288 C 327.103 772.382 359.673 755.598 377.991 738.898 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 377.991 738.898 L 377.97 746.043 C 316.233 787.329 247.446 823.267 250.578 907.152 C 251.216 924.249 249.063 946.275 250.972 962.764 L 245.848 966.196 C 242.791 890.459 240.496 838.419 306.242 788.288 C 327.103 772.382 359.673 755.598 377.991 738.898 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1698.67 1227.95 L 1704.13 1224.14 L 1704.27 1304.12 C 1704.32 1320.5 1706.15 1340.69 1700.62 1355.82 C 1700.51 1352.08 1699.91 1350.16 1699.04 1346.58 C 1698.59 1350.3 1698.02 1354 1697.33 1357.68 C 1687.7 1408.76 1661.72 1441.71 1619.94 1470.18 L 1620 1281.61 C 1646.01 1263.41 1672.23 1245.52 1698.67 1227.95 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1698.67 1227.95 L 1704.13 1224.14 L 1704.27 1304.12 C 1704.32 1320.5 1706.15 1340.69 1700.62 1355.82 C 1700.51 1352.08 1699.91 1350.16 1699.04 1346.58 C 1697.93 1340.05 1698.95 1322.33 1698.71 1314.71 C 1697.86 1287.9 1700.64 1254.01 1698.67 1227.95 z" fill="rgb(156,105,225)" transform="translate(0,0)"></path>
|
||||
<path d="M 1785.94 851.049 C 1801.11 844.35 1813.99 839.649 1830.78 845.862 C 1841.87 849.905 1850.82 858.315 1855.55 869.132 C 1875.65 915.103 1814.75 936.66 1785.85 958.142 L 1785.9 950.864 C 1786.99 923.049 1786.21 886.361 1785.99 857.727 L 1785.94 851.049 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1785.94 851.049 C 1801.11 844.35 1813.99 839.649 1830.78 845.862 C 1841.87 849.905 1850.82 858.315 1855.55 869.132 C 1875.65 915.103 1814.75 936.66 1785.85 958.142 L 1785.9 950.864 C 1804.06 939.213 1823.9 928.008 1840.84 914.827 C 1853.55 904.933 1856.55 885.757 1850.23 871.428 C 1846.09 861.9 1838.27 854.449 1828.56 850.765 C 1812.58 844.657 1800.15 851.134 1785.99 857.727 L 1785.94 851.049 z" fill="rgb(156,105,225)" transform="translate(0,0)"></path>
|
||||
<path d="M 1328.11 321.539 C 1328.89 318.017 1338.95 312.464 1342.58 310.044 C 1344.47 314.855 1348.78 322.488 1347.26 326.683 C 1340.52 323.786 1335.4 322.448 1328.11 321.539 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1775.9 690.502 C 1778.21 688.637 1780.83 686.421 1783.19 684.697 L 1783.03 799.408 C 1782.99 838.087 1784.28 882.945 1783.02 921.125 C 1783.28 931.557 1782.89 942.125 1783.25 952.499 L 1783.25 959.809 L 1783.07 1050.22 C 1783.08 1105.14 1788.82 1146.48 1745.72 1190.77 C 1732.88 1203.96 1700.3 1223.6 1683.66 1234.8 C 1656.73 1252.94 1626.84 1275.14 1598.91 1290.87 C 1577.78 1304.65 1553.94 1306.07 1529.22 1303.01 C 1507.11 1300.14 1492.08 1294.54 1472.35 1284.65 C 1469.36 1283.22 1461.46 1278.28 1458.27 1276.39 C 1457.56 1276.09 1457.34 1276.15 1457.05 1275.53 C 1451.05 1271.41 1444.97 1266.6 1439.15 1262.16 C 1436.78 1261.25 1436.64 1260.55 1435.04 1258.52 L 1428.06 1251.75 C 1416.83 1240.14 1406.64 1227.32 1399.26 1212.89 L 1396.78 1208.15 C 1374.84 1160.2 1377.11 1120.96 1377.52 1070.03 C 1377.66 1052.54 1376.93 1036.42 1380.16 1019.02 C 1385 991.42 1397.48 965.733 1416.18 944.867 C 1430.52 928.932 1449.2 917.043 1466.77 904.803 L 1519.21 868.379 L 1678.49 758.195 C 1680.2 756.428 1699.72 742.956 1702.79 740.802 C 1726.89 723.648 1751.26 706.879 1775.9 690.502 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1783.25 952.499 L 1783.25 959.809 L 1783.07 1050.22 C 1783.08 1105.14 1788.82 1146.48 1745.72 1190.77 C 1732.88 1203.96 1700.3 1223.6 1683.66 1234.8 C 1656.73 1252.94 1626.84 1275.14 1598.91 1290.87 C 1577.78 1304.65 1553.94 1306.07 1529.22 1303.01 C 1507.11 1300.14 1492.08 1294.54 1472.35 1284.65 C 1469.36 1283.22 1461.46 1278.28 1458.27 1276.39 C 1457.56 1276.09 1457.34 1276.15 1457.05 1275.53 C 1451.05 1271.41 1444.97 1266.6 1439.15 1262.16 C 1436.78 1261.25 1436.64 1260.55 1435.04 1258.52 L 1428.06 1251.75 C 1416.83 1240.14 1406.64 1227.32 1399.26 1212.89 L 1396.78 1208.15 C 1405.41 1202.54 1474.35 1157.89 1477.48 1153.78 C 1483.08 1152.29 1566.21 1096.04 1570.39 1091.62 C 1578.26 1089.27 1655.01 1038.04 1667.36 1028.9 C 1678.82 1022.05 1756.02 972.392 1760.43 965.81 C 1763.56 967.519 1780.03 955.012 1783.25 952.499 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1754.74 978.399 C 1760.75 973.222 1775.94 964.348 1783.25 959.809 L 1783.07 1050.22 C 1783.08 1105.14 1788.82 1146.48 1745.72 1190.77 C 1732.88 1203.96 1700.3 1223.6 1683.66 1234.8 C 1656.73 1252.94 1626.84 1275.14 1598.91 1290.87 C 1599.52 1285.05 1657.54 1172.77 1664.49 1159.06 C 1695.03 1099.07 1725.11 1038.85 1754.74 978.399 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1661.67 1040.38 C 1665.27 1036.39 1697.68 1015.79 1704.89 1011.17 C 1720.98 1000.88 1738.62 988.429 1754.74 978.399 C 1725.11 1038.85 1695.03 1099.07 1664.49 1159.06 C 1657.54 1172.77 1599.52 1285.05 1598.91 1290.87 C 1577.78 1304.65 1553.94 1306.07 1529.22 1303.01 C 1534.52 1293.78 1540.13 1281.4 1545.03 1271.54 L 1572.57 1216.94 C 1602.57 1158.24 1632.27 1099.39 1661.67 1040.38 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1783.25 952.499 L 1783.25 959.809 C 1775.94 964.348 1760.75 973.222 1754.74 978.399 C 1738.62 988.429 1720.98 1000.88 1704.89 1011.17 C 1697.68 1015.79 1665.27 1036.39 1661.67 1040.38 C 1652.5 1044.97 1570.71 1098.11 1564.72 1104.31 C 1534.02 1162.9 1504.04 1227.33 1472.35 1284.65 C 1469.36 1283.22 1461.46 1278.28 1458.27 1276.39 C 1457.56 1276.09 1457.34 1276.15 1457.05 1275.53 C 1451.05 1271.41 1444.97 1266.6 1439.15 1262.16 C 1436.78 1261.25 1436.64 1260.55 1435.04 1258.52 L 1428.06 1251.75 C 1416.83 1240.14 1406.64 1227.32 1399.26 1212.89 L 1396.78 1208.15 C 1405.41 1202.54 1474.35 1157.89 1477.48 1153.78 C 1483.08 1152.29 1566.21 1096.04 1570.39 1091.62 C 1578.26 1089.27 1655.01 1038.04 1667.36 1028.9 C 1678.82 1022.05 1756.02 972.392 1760.43 965.81 C 1763.56 967.519 1780.03 955.012 1783.25 952.499 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1783.25 952.499 L 1783.25 959.809 C 1775.94 964.348 1760.75 973.222 1754.74 978.399 C 1738.62 988.429 1720.98 1000.88 1704.89 1011.17 C 1697.68 1015.79 1665.27 1036.39 1661.67 1040.38 C 1652.5 1044.97 1570.71 1098.11 1564.72 1104.31 C 1554.95 1109.61 1475.17 1161.64 1470.47 1167.39 C 1466.83 1168.45 1466.17 1169.29 1462.95 1171.39 C 1456.46 1174.7 1443.68 1184.08 1437.12 1188.52 C 1424.77 1197.06 1412.14 1205.19 1399.26 1212.89 L 1396.78 1208.15 C 1405.41 1202.54 1474.35 1157.89 1477.48 1153.78 C 1483.08 1152.29 1566.21 1096.04 1570.39 1091.62 C 1578.26 1089.27 1655.01 1038.04 1667.36 1028.9 C 1678.82 1022.05 1756.02 972.392 1760.43 965.81 C 1763.56 967.519 1780.03 955.012 1783.25 952.499 z" fill="rgb(141,58,208)" transform="translate(0,0)"></path>
|
||||
<path d="M 1399.26 1212.89 C 1412.14 1205.19 1424.77 1197.06 1437.12 1188.52 C 1443.68 1184.08 1456.46 1174.7 1462.95 1171.39 C 1463.57 1174.71 1463.6 1174.65 1465.6 1177.3 C 1454.27 1199.83 1437.17 1228.83 1428.06 1251.75 C 1416.83 1240.14 1406.64 1227.32 1399.26 1212.89 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1462.95 1171.39 C 1466.17 1169.29 1466.83 1168.45 1470.47 1167.39 C 1468.91 1170.72 1467.28 1174.03 1465.6 1177.3 C 1463.6 1174.65 1463.57 1174.71 1462.95 1171.39 z" fill="rgb(251,122,171)" transform="translate(0,0)"></path>
|
||||
<path d="M 1435.04 1258.52 L 1436.59 1259.73 C 1437.56 1260.47 1438.44 1261.17 1439.15 1262.16 C 1436.78 1261.25 1436.64 1260.55 1435.04 1258.52 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1678.49 758.195 C 1676.58 768.122 1643.84 827.324 1637.15 840.459 L 1530.94 1048.51 C 1514.09 1081.53 1492.47 1120.56 1477.48 1153.78 C 1474.35 1157.89 1405.41 1202.54 1396.78 1208.15 C 1374.84 1160.2 1377.11 1120.96 1377.52 1070.03 C 1377.66 1052.54 1376.93 1036.42 1380.16 1019.02 C 1385 991.42 1397.48 965.733 1416.18 944.867 C 1430.52 928.932 1449.2 917.043 1466.77 904.803 L 1519.21 868.379 L 1678.49 758.195 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1775.9 690.502 C 1714.66 813.718 1648.45 935.096 1587.61 1058.58 C 1582.21 1069.53 1576.22 1080.89 1570.39 1091.62 C 1566.21 1096.04 1483.08 1152.29 1477.48 1153.78 C 1492.47 1120.56 1514.09 1081.53 1530.94 1048.51 L 1637.15 840.459 C 1643.84 827.324 1676.58 768.122 1678.49 758.195 C 1680.2 756.428 1699.72 742.956 1702.79 740.802 C 1726.89 723.648 1751.26 706.879 1775.9 690.502 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1783.03 799.408 C 1782.99 838.087 1784.28 882.945 1783.02 921.125 C 1783.28 931.557 1782.89 942.125 1783.25 952.499 C 1780.03 955.012 1763.56 967.519 1760.43 965.81 C 1756.02 972.392 1678.82 1022.05 1667.36 1028.9 C 1684.78 992.114 1703.95 955.431 1722.23 919.013 C 1742.1 878.944 1762.37 839.074 1783.03 799.408 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1783.02 921.125 C 1783.28 931.557 1782.89 942.125 1783.25 952.499 C 1780.03 955.012 1763.56 967.519 1760.43 965.81 C 1765.47 955.671 1777.35 929.791 1783.02 921.125 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 952.067 1406.16 L 952.036 1315.94 C 951.976 1274.09 948.402 1239.45 967.415 1200.37 C 985.171 1163.87 1005.21 1150.95 1037.29 1128.96 L 1089.83 1093.05 L 1263.78 973.881 L 1582.89 754.441 L 1678.8 688.454 C 1694.55 677.602 1710.38 666.61 1726.21 655.876 C 1753.38 637.449 1783.54 642.347 1783.6 680.377 C 1770.01 693.942 1736.15 714.524 1719.29 726.342 L 1537.58 852.658 L 1479.22 893.157 C 1455.35 909.662 1426.34 927.298 1408.51 949.752 C 1386.78 977.105 1375.73 1010.33 1374.89 1045.11 C 1374.16 1062.02 1375.43 1080.26 1374.85 1097.08 C 1372.9 1153.93 1382.67 1209.44 1424.12 1251.62 C 1466.82 1295.07 1518.11 1313.07 1577.84 1303.85 C 1562.99 1316.1 1525.26 1340.05 1507.93 1351.91 L 1464.13 1382.09 C 1458.41 1386.05 1447.55 1394.01 1441.74 1397.37 C 1395.71 1428.73 1349.85 1460.33 1304.16 1492.17 C 1305.68 1416.67 1303.57 1340.69 1304.29 1265.15 C 1304.42 1251.61 1303.68 1237.72 1304.53 1224.23 C 1302.36 1195.49 1281.9 1189.03 1259.39 1203.47 C 1242.67 1214.19 1226.52 1225.43 1210.18 1236.67 L 1074.57 1329.99 C 1040.79 1353.25 988.104 1392.92 952.067 1406.16 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1304.53 1224.23 C 1306.56 1229.85 1305.23 1326.46 1305.2 1341.26 C 1317.61 1341.36 1330.6 1340.81 1342.68 1344.08 C 1355.96 1347.68 1429.98 1388.61 1441.74 1397.37 C 1395.71 1428.73 1349.85 1460.33 1304.16 1492.17 C 1305.68 1416.67 1303.57 1340.69 1304.29 1265.15 C 1304.42 1251.61 1303.68 1237.72 1304.53 1224.23 z" fill="rgb(199,157,233)" transform="translate(0,0)"></path>
|
||||
<path d="M 1303.3 1017.14 C 1307.12 1016.89 1318.92 1017.09 1319.93 1022.69 C 1324.44 1044.47 1327.85 1104.7 1307.35 1119.81 C 1265.67 1150.51 1215.62 1187.82 1171.45 1214.81 C 1168.53 1215.19 1165.18 1215.45 1162.29 1214.7 C 1159.01 1213.86 1156.23 1211.89 1154.55 1208.92 C 1149.08 1199.27 1151.01 1146.38 1154.41 1134.52 C 1156.88 1125.93 1160.49 1118.75 1166.81 1112.31 C 1182.02 1096.83 1212.57 1078.62 1231.73 1065.4 C 1255.03 1049.31 1278.55 1030.79 1303.3 1017.14 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1306.25 1019 C 1308.5 1019.04 1310.55 1019.25 1312.66 1020.05 C 1315.38 1021.08 1317.42 1023.21 1318.58 1025.85 C 1321.54 1032.53 1320.69 1076.52 1319.61 1085.84 C 1318.93 1091.66 1317.68 1097.34 1315.36 1102.75 C 1312.63 1109.1 1308.4 1116.11 1303.26 1120.73 C 1290.7 1132.04 1177.97 1209.07 1167.7 1213.03 C 1150.14 1211.55 1154.52 1194.99 1154.23 1181.5 C 1153.98 1169.69 1153.35 1155.18 1154.95 1143.74 C 1155.8 1137.63 1157.53 1131.67 1160.11 1126.06 C 1167.71 1109.95 1214.63 1079.44 1232.16 1068.15 C 1250.12 1056.57 1289.18 1025.55 1306.25 1019 z" fill="rgb(199,157,233)" transform="translate(0,0)"></path>
|
||||
<path d="M 1093.25 1156.05 C 1112.76 1156.08 1111.99 1172.98 1111.94 1188.46 C 1111.96 1204.36 1112.62 1220.6 1108.77 1236.09 C 1102.19 1262.53 1058.82 1288.59 1036.06 1301.23 C 1018.55 1302.2 1016.36 1292.47 1016.58 1276.74 C 1016.92 1250.99 1012.61 1211.86 1036.45 1194.28 C 1049.55 1184.61 1079.08 1161.41 1093.25 1156.05 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1092.38 1158.98 C 1096.74 1158.73 1105.26 1159.43 1106.45 1164.66 C 1110.95 1183.59 1110.18 1216.88 1106.43 1235.67 C 1101.77 1259.07 1055.05 1287.74 1035.21 1298.58 C 1033.14 1298.78 1030.89 1299.07 1028.84 1298.63 C 1025.76 1297.98 1022.92 1295.91 1021.4 1293.14 C 1016.72 1284.59 1018.68 1233.61 1021.88 1222.43 C 1028.34 1199.91 1045.96 1189.51 1064.24 1177.18 C 1073.49 1170.94 1082.64 1164.45 1092.38 1158.98 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 909.881 210.277 C 931.85 195.527 951.123 184.065 978.387 195.25 C 992.758 204.948 1002.14 219.637 1002.3 237.265 C 1003.39 268.899 1002.89 301.916 1002.88 333.69 C 1003.3 396.139 978.461 427.423 927.213 459.654 C 906.825 473.743 886.332 487.68 865.736 501.464 C 854.122 509.379 836.89 521.92 825.01 528.592 C 812.755 538.328 793.798 550.427 780.504 559.313 C 769.596 566.604 753.237 578.313 742.201 584.793 C 730.956 593.193 652.278 647.608 642.415 651.29 C 639.207 655.687 568.057 702.733 559.291 707.771 C 537.733 724.097 514.038 740.218 485.935 730.997 C 469.588 721.154 463.107 706.907 463.015 688.23 C 462.096 657.202 463.644 625.651 462.578 594.571 C 462.416 589.841 461.564 579.265 462.628 575.2 C 464.812 522.554 493.454 491.533 534.692 463.586 C 564.523 444.209 596.653 421.971 625.907 401.613 C 626.502 400.99 626.363 401.081 627.138 400.722 C 631.631 398.047 637.225 394.087 641.681 391.12 C 643.467 389.38 661.083 377.772 664.247 375.63 C 683.708 362.366 703.255 349.227 722.886 336.215 C 722.654 336.586 820.115 270.29 827.459 266.201 C 832.331 260.919 900.487 216.324 909.881 210.277 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 722.886 336.215 C 720.929 346.672 618.315 511.179 605.229 533.071 L 521.935 672.036 C 513.611 685.739 494.614 719.161 485.935 730.997 C 469.588 721.154 463.107 706.907 463.015 688.23 C 462.096 657.202 463.644 625.651 462.578 594.571 C 462.416 589.841 461.564 579.265 462.628 575.2 C 464.812 522.554 493.454 491.533 534.692 463.586 C 564.523 444.209 596.653 421.971 625.907 401.613 C 626.502 400.99 626.363 401.081 627.138 400.722 C 631.631 398.047 637.225 394.087 641.681 391.12 C 643.467 389.38 661.083 377.772 664.247 375.63 C 683.708 362.366 703.255 349.227 722.886 336.215 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 627.138 400.722 C 631.631 398.047 637.225 394.087 641.681 391.12 C 599.564 464.235 553.319 536.582 510.697 609.544 C 495.599 635.388 477.425 662.403 463.015 688.23 C 462.096 657.202 463.644 625.651 462.578 594.571 C 462.416 589.841 461.564 579.265 462.628 575.2 C 464.812 522.554 493.454 491.533 534.692 463.586 C 564.523 444.209 596.653 421.971 625.907 401.613 C 626.502 400.99 626.363 401.081 627.138 400.722 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 534.692 463.586 C 533.102 470.177 497.718 527.096 490.961 538.862 C 483.295 552.211 470.792 574.37 462.717 588.162 L 462.628 575.2 C 464.812 522.554 493.454 491.533 534.692 463.586 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 978.387 195.25 C 992.758 204.948 1002.14 219.637 1002.3 237.265 C 1003.39 268.899 1002.89 301.916 1002.88 333.69 C 1003.3 396.139 978.461 427.423 927.213 459.654 C 906.825 473.743 886.332 487.68 865.736 501.464 C 854.122 509.379 836.89 521.92 825.01 528.592 C 812.755 538.328 793.798 550.427 780.504 559.313 C 769.596 566.604 753.237 578.313 742.201 584.793 C 742.952 578.084 957.878 230.426 978.387 195.25 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1002.3 237.265 C 1003.39 268.899 1002.89 301.916 1002.88 333.69 C 1003.3 396.139 978.461 427.423 927.213 459.654 C 906.825 473.743 886.332 487.68 865.736 501.464 C 854.122 509.379 836.89 521.92 825.01 528.592 C 831.304 516.557 844.493 496.314 851.876 484.267 L 901.726 402.675 L 969.432 290.809 C 980.077 273.326 991.054 254.228 1002.3 237.265 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 927.213 459.654 C 927.565 456.702 996.145 344.031 1002.88 333.69 C 1003.3 396.139 978.461 427.423 927.213 459.654 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 909.881 210.277 C 931.85 195.527 951.123 184.065 978.387 195.25 C 957.878 230.426 742.952 578.084 742.201 584.793 C 730.956 593.193 652.278 647.608 642.415 651.29 C 649.057 638.254 661.087 620.137 669.05 607.102 L 719.736 523.913 L 847.863 312.869 C 868.115 279.35 890.587 244.127 909.881 210.277 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 909.881 210.277 C 890.587 244.127 868.115 279.35 847.863 312.869 L 719.736 523.913 L 669.05 607.102 C 661.087 620.137 649.057 638.254 642.415 651.29 C 639.207 655.687 568.057 702.733 559.291 707.771 C 560.8 703.684 575.106 680.925 578.311 675.629 L 624.758 598.827 L 827.459 266.201 C 832.331 260.919 900.487 216.324 909.881 210.277 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 999.178 381.862 L 999.27 381.426 L 999.179 381.32 L 999.812 380.611 L 999.273 380.943 C 999.522 380.396 999.542 380.283 1000.11 379.923 C 1000.21 379.864 1000.39 379.873 1000.41 379.952 C 1001.53 383.654 1001 398.891 1000.83 402.335 L 1000.78 524.534 C 968.367 548.042 934.149 569.526 901.503 592.775 C 848.082 630.819 796.865 653.95 765.394 715.163 C 748.06 756.797 749.203 772.494 749.222 817.035 C 749.249 880.805 749.989 944.931 749.311 1008.67 C 717.156 1033.36 685.769 1061.95 655.899 1089.27 C 654.987 1054.68 656.049 1018.15 655.558 983.13 C 684.031 964.083 711.347 950.055 719.444 913.908 C 722.587 899.879 722.788 877.104 705.123 873.892 C 689.014 870.964 672.372 885.574 659.765 894.195 C 595.333 939.063 528.072 985.402 462.845 1029.02 L 462.863 859.387 C 462.845 820.81 462.255 780.695 463.082 742.222 C 462.968 738.918 462.313 710.191 463.558 709.445 C 464.846 710.27 465.276 712.581 465.636 713.955 C 483.77 741.566 508.49 743.199 535.278 726.575 C 564.071 708.707 592.163 689.284 620.289 670.339 L 779.9 562.617 L 891.404 487.163 C 909.647 474.937 947.444 450.781 962.633 437.244 C 962.965 436.306 963.217 436.202 964.161 435.866 C 980.556 421.092 991.981 402.602 999.178 381.862 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 685.838 808.28 C 660.052 823.919 553.332 903.158 538.252 906.106 C 533.304 907.074 527.736 906.483 523.616 903.364 C 519.312 900.108 517.39 894.95 516.671 889.767 C 515.681 882.633 516.205 874.727 516.136 867.467 C 515.931 845.841 515.435 825.67 529.692 807.916 C 532.626 804.261 535.792 801.125 539.503 798.28 C 566.114 777.876 595.372 759.85 623.063 740.869 C 642.746 727.376 667.852 707.965 688.123 696.814 C 691.002 695.23 693.814 694.343 697.108 694.101 C 701.655 693.769 706.457 694.837 709.777 698.129 C 719.881 708.147 716.1 745.857 715.933 760.392 C 710.839 785.833 705.386 792.127 685.838 808.28 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 692.083 697.255 C 717.109 693.054 713.635 714.473 713.569 730.897 C 713.49 750.621 715.285 769.011 704.111 786.312 C 694.68 800.916 677.158 810.941 663.311 820.428 L 614.712 853.711 L 569.576 884.676 C 560.971 890.574 549.309 899.162 540.239 903.633 C 516.947 907.196 519.388 889.079 518.927 873.4 C 517.857 838.769 517.181 815.542 549.13 794.493 C 597.036 762.93 643.721 727.942 692.083 697.255 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 715.933 760.392 C 715.924 760.399 728.951 746.733 730.086 745.549 C 741.994 733.119 751.442 725.113 765.394 715.163 C 748.06 756.797 749.203 772.494 749.222 817.035 C 749.249 880.805 749.989 944.931 749.311 1008.67 C 717.156 1033.36 685.769 1061.95 655.899 1089.27 C 654.987 1054.68 656.049 1018.15 655.558 983.13 C 684.031 964.083 711.347 950.055 719.444 913.908 C 722.587 899.879 722.788 877.104 705.123 873.892 C 689.014 870.964 672.372 885.574 659.765 894.195 C 660.006 875.711 667.975 848.544 675.852 831.68 C 678.337 826.358 684.92 813.305 685.838 808.28 C 705.386 792.127 710.839 785.833 715.933 760.392 z" fill="rgb(199,157,233)" transform="translate(0,0)"></path>
|
||||
<path d="M 463.082 742.222 C 462.968 738.918 462.313 710.191 463.558 709.445 C 464.846 710.27 465.276 712.581 465.636 713.955 C 463.304 719.104 464.926 733.347 463.082 742.222 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 999.178 381.862 L 999.27 381.426 L 999.179 381.32 L 999.812 380.611 L 999.273 380.943 C 999.522 380.396 999.542 380.283 1000.11 379.923 C 1000.21 379.864 1000.39 379.873 1000.41 379.952 C 1001.53 383.654 1001 398.891 1000.83 402.335 C 1000.07 395.117 1000.73 388.82 999.178 381.862 z" fill="rgb(166,158,170)" transform="translate(0,0)"></path>
|
||||
<path d="M 1320.12 414.49 C 1366.53 418.781 1409.54 440.455 1448.69 464.411 L 1448.45 545.968 C 1381.56 513.555 1317.66 513.804 1250.21 545.877 C 1112.33 611.439 1011.28 743.25 959.255 884.281 C 948.798 913.564 938.281 944.623 932.969 975.391 C 909.404 991.801 885.739 1008.07 861.978 1024.19 C 852.662 1030.57 836.028 1041.24 827.908 1047.89 C 827.855 1020.98 827.245 992.025 827.833 965.29 C 827.671 936.208 827.671 907.125 827.833 878.043 C 827.808 854.734 826.406 827.808 830.245 805.029 C 837.686 760.879 867.284 722.932 903.808 698.382 C 914.788 691.002 925.831 683.335 936.842 675.828 L 1021.62 618.182 L 1320.12 414.49 z" fill="rgb(199,157,233)" transform="translate(0,0)"></path>
|
||||
<path d="M 827.833 965.29 C 831.644 962.67 843.918 948.151 849.534 943.055 C 879.253 916.088 920.086 894.073 959.255 884.281 C 948.798 913.564 938.281 944.623 932.969 975.391 C 909.404 991.801 885.739 1008.07 861.978 1024.19 C 852.662 1030.57 836.028 1041.24 827.908 1047.89 C 827.855 1020.98 827.245 992.025 827.833 965.29 z" fill="rgb(207,128,231)" transform="translate(0,0)"></path>
|
||||
<path d="M 1381.73 1441.74 C 1412.72 1419.01 1448.88 1395.72 1480.89 1373.56 C 1525.71 1342.54 1570.64 1311.17 1617.5 1283.45 C 1619.35 1305.74 1618.12 1350.03 1618.12 1373.8 L 1618.04 1433.51 C 1618.04 1443.05 1618.46 1456.93 1617.85 1466.2 C 1617.92 1467.08 1618.08 1470.96 1617.36 1471.49 C 1579.34 1498.96 1539.34 1524.1 1500.78 1550.79 C 1500.46 1532.19 1505.49 1496.59 1492.72 1482.47 C 1483.27 1472.03 1469.55 1476.73 1459.12 1483.4 C 1438.56 1496.55 1418.56 1510.8 1398.38 1524.68 L 1302.44 1590.05 C 1274.93 1608.63 1247.51 1627.35 1220.2 1646.22 C 1137.06 1702.92 1137.42 1708.86 1137.79 1807.74 C 1116.15 1822.22 992.595 1909.9 978.91 1912.88 C 970.695 1914.66 963.635 1910.73 957.002 1906.45 C 949.252 1891.41 951.712 1858.52 951.701 1840.4 L 951.679 1742.33 C 963.157 1738.53 985.376 1730.44 995.22 1724.74 C 1020.24 1710.24 1046.18 1691.45 1070.16 1674.94 L 1181.78 1598.98 L 1249.66 1552.83 C 1276.98 1534.25 1293.36 1528.84 1303.7 1495.55 L 1372.9 1447.63 C 1373.53 1447.02 1373.39 1447.1 1374.19 1446.76 L 1381.73 1441.74 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1381.73 1441.74 C 1412.72 1419.01 1448.88 1395.72 1480.89 1373.56 C 1525.71 1342.54 1570.64 1311.17 1617.5 1283.45 C 1619.35 1305.74 1618.12 1350.03 1618.12 1373.8 L 1618.04 1433.51 C 1618.04 1443.05 1618.46 1456.93 1617.85 1466.2 C 1617.35 1462.32 1617.35 1457.26 1617.34 1453.25 C 1617.28 1428.76 1616.5 1403.97 1617.14 1379.51 C 1606.84 1389.72 1585.02 1402.62 1572.63 1410.84 C 1532.81 1437.28 1508.01 1460.85 1457.05 1459.88 C 1431.66 1459.4 1404.4 1453.93 1381.73 1441.74 z" fill="rgb(136,30,185)" transform="translate(0,0)"></path>
|
||||
<path d="M 1617.5 1283.45 C 1619.35 1305.74 1618.12 1350.03 1618.12 1373.8 L 1618.04 1433.51 C 1618.04 1443.05 1618.46 1456.93 1617.85 1466.2 C 1617.35 1462.32 1617.35 1457.26 1617.34 1453.25 C 1617.28 1428.76 1616.5 1403.97 1617.14 1379.51 C 1617.85 1372.14 1617.06 1362.12 1617.26 1354.54 C 1617.82 1332.62 1615.51 1305 1617.5 1283.45 z" fill="rgb(62,6,116)" transform="translate(0,0)"></path>
|
||||
<path d="M 717.15 1163.99 L 719.84 1165.64 L 719.727 1170.27 C 720.062 1176.18 719.769 1182.02 719.582 1187.94 C 734.724 1196.82 759.377 1210.26 773.045 1219.49 L 773.062 1418.82 C 759.743 1415.74 729.518 1407.96 718.707 1401.02 L 714.927 1402.44 C 793.494 1439.65 914.309 1437.4 989.672 1390.79 C 1014.7 1375.32 1040.66 1356.28 1065.15 1339.5 L 1225.92 1228.91 C 1242.2 1217.66 1281.13 1183.03 1297.32 1207.42 C 1303.3 1216.43 1302.01 1235 1301.9 1245.84 C 1289.4 1252.6 1254.54 1278.05 1241.79 1286.88 L 1069.54 1406.01 C 1014.03 1444.26 971.862 1480.79 901.969 1490.85 C 826.623 1502.03 741.168 1494.51 678.086 1448.67 C 628.965 1412.98 616.142 1378.91 617.852 1320.89 C 619.163 1276.45 616.524 1260.3 644.677 1222 C 645.384 1220.83 647.883 1218.3 648.941 1217.15 L 647.772 1214.99 C 653.874 1206.28 705.738 1171.5 717.15 1163.99 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 717.15 1163.99 L 719.84 1165.64 L 719.727 1170.27 C 720.062 1176.18 719.769 1182.02 719.582 1187.94 C 734.724 1196.82 759.377 1210.26 773.045 1219.49 L 773.062 1418.82 C 759.743 1415.74 729.518 1407.96 718.707 1401.02 L 714.927 1402.44 C 674.582 1382.46 620.359 1345.1 631.019 1290.45 C 634.507 1272.57 641.206 1256.39 641.459 1237.4 C 641.506 1233.88 643.879 1227.63 644.448 1223.69 L 644.677 1222 C 645.384 1220.83 647.883 1218.3 648.941 1217.15 L 647.772 1214.99 C 653.874 1206.28 705.738 1171.5 717.15 1163.99 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 718.707 1401.02 C 720.017 1386.69 719.126 1354.69 719.126 1339.69 L 719.051 1222.57 C 719.039 1215.03 718.484 1175.9 719.727 1170.27 C 720.062 1176.18 719.769 1182.02 719.582 1187.94 C 734.724 1196.82 759.377 1210.26 773.045 1219.49 L 773.062 1418.82 C 759.743 1415.74 729.518 1407.96 718.707 1401.02 z" fill="rgb(207,128,231)" transform="translate(0,0)"></path>
|
||||
<path d="M 1196.77 1665.49 L 1387.15 1535.62 C 1412.74 1518.44 1437.95 1500.44 1463.56 1483.86 C 1479.69 1473.41 1497.7 1482.62 1497.62 1500.02 C 1497.48 1532.9 1499.57 1571.83 1497.81 1604.3 C 1497.5 1610.66 1496.66 1616.99 1495.3 1623.22 C 1490.61 1644.25 1473.68 1675.1 1454.86 1686.48 C 1453.32 1687.76 1451.75 1689 1450.14 1690.2 C 1435.2 1701.53 1411.54 1716.56 1395.42 1727.53 L 1285.19 1802.47 L 1216.01 1849.8 C 1203.37 1858.48 1175.9 1880.86 1161.64 1878.67 C 1134.01 1874.43 1140.43 1833.9 1140.15 1814.6 C 1139.19 1747.26 1133.83 1705.54 1196.77 1665.49 z" fill="rgb(238,160,208)" transform="translate(0,0)"></path>
|
||||
<path d="M 1454.86 1686.48 C 1453.32 1687.76 1451.75 1689 1450.14 1690.2 C 1435.2 1701.53 1411.54 1716.56 1395.42 1727.53 L 1285.19 1802.47 L 1216.01 1849.8 C 1203.37 1858.48 1175.9 1880.86 1161.64 1878.67 C 1134.01 1874.43 1140.43 1833.9 1140.15 1814.6 C 1139.19 1747.26 1133.83 1705.54 1196.77 1665.49 C 1204.78 1670.83 1222.13 1677.25 1231.9 1680.71 C 1292.09 1702.02 1356.04 1707.17 1418.95 1696.43 C 1428.59 1694.78 1450.13 1686.94 1454.86 1686.48 z" fill="rgb(199,157,233)" transform="translate(0,0)"></path>
|
||||
<path d="M 912.388 1135.91 C 936.992 1109.89 981.803 1083.42 1012.39 1062.63 L 1168.42 956.216 L 1495.87 732.99 L 1604.32 659.119 C 1620.71 647.935 1670.27 610.218 1688.37 609.274 C 1703.36 608.493 1743.01 633.648 1757.93 642.71 C 1746.68 643.203 1738.51 645.07 1728.9 651.351 C 1710.73 663.226 1692.62 675.942 1674.74 688.25 L 1577.73 754.905 L 1289.75 953.01 L 1094.98 1086.48 L 1041.68 1122.84 C 1023.93 1134.96 1003.46 1147.65 989.171 1163.39 C 970.51 1184.23 957.734 1209.66 952.154 1237.08 C 940.39 1233.06 927.065 1220.56 920.799 1209.88 C 906.848 1186.11 906.143 1161.87 912.388 1135.91 z" fill="rgb(238,160,208)" transform="translate(0,0)"></path>
|
||||
<path d="M 879.613 1069.03 L 1362.04 737.256 L 1519.14 629.912 L 1568.94 595.701 C 1580.5 587.738 1609.53 561.398 1621.32 582.96 C 1628.48 596.048 1626.07 624.917 1626.03 640.839 L 1212.43 922.97 L 1064.22 1023.99 C 1024.67 1051.13 984.066 1079.55 943.949 1105.81 C 934.245 1097.49 892.847 1076.04 879.613 1069.03 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 719.84 1165.64 C 726.866 1126.67 743.924 1108.64 775.225 1086.89 L 1282.36 738.613 L 1452.97 621.779 L 1512.4 580.889 C 1557.73 549.712 1555.58 541.688 1604.98 571.989 C 1599.37 573.103 1596.76 573.773 1592.02 576.961 C 1551.4 604.294 1511.19 632.491 1470.68 659.988 L 1145.74 882.82 L 931.695 1030.05 C 901.54 1050.84 860.826 1076.89 832.678 1098.29 C 818.929 1108.63 806.983 1121.17 797.322 1135.41 C 780.064 1161.35 773.411 1188.69 773.045 1219.49 C 759.377 1210.26 734.724 1196.82 719.582 1187.94 C 719.769 1182.02 720.062 1176.18 719.727 1170.27 L 719.84 1165.64 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 620.056 1444.61 C 664.982 1569.94 845.142 1588.24 954.789 1549.18 C 992.416 1535.78 1025.72 1509.89 1058.45 1487.33 L 1147.26 1426.12 L 1244.45 1358.92 C 1261.45 1347.24 1285.99 1331.52 1301.89 1319.31 L 1301.87 1388.94 C 1277.61 1404.27 1250.91 1423.56 1226.87 1439.99 L 1077.78 1541.88 C 1043.46 1565.26 1003.04 1596.59 965.134 1611.81 C 866.329 1651.47 699.164 1641.45 637.858 1543.99 C 636.685 1542.12 635.596 1540.19 634.597 1538.22 C 614.695 1507.14 617.587 1476.77 618.248 1441.86 C 618.254 1441.56 618.364 1441.54 618.533 1441.46 C 619.312 1442.2 619.709 1443.59 620.056 1444.61 z" fill="rgb(136,30,185)" transform="translate(0,0)"></path>
|
||||
<path d="M 634.597 1538.22 C 614.695 1507.14 617.587 1476.77 618.248 1441.86 C 618.254 1441.56 618.364 1441.54 618.533 1441.46 C 619.312 1442.2 619.709 1443.59 620.056 1444.61 C 619.11 1450.16 619.938 1451.79 619.526 1456.7 C 618.548 1468.38 619.464 1478.93 620.774 1490.1 C 622.068 1501.14 621.595 1505.36 626.059 1516.56 C 628.395 1522.43 634.442 1533.68 634.597 1538.22 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<path d="M 620.313 1378.03 C 663.909 1499.08 846.128 1518.97 952.861 1480.21 C 986.063 1468.15 1010.32 1450.13 1038.8 1430.39 L 1090.9 1394.41 L 1224.21 1302.21 C 1249.48 1284.7 1276.31 1265.32 1301.89 1248.62 L 1301.89 1316.43 L 1103.43 1453.15 L 1042.19 1495.38 C 1014.89 1514.31 987.495 1534.97 955.168 1546.24 C 853.414 1581.74 706.842 1571.64 639.172 1477.9 C 617.676 1448.13 617.4 1422.08 618.12 1387.17 C 618.03 1384.85 617.464 1374 618.517 1372.56 C 619.422 1374.83 619.78 1375.27 620.313 1378.03 z" fill="url(#Gradient3)" transform="translate(0,0)"></path>
|
||||
<path d="M 618.12 1387.17 C 618.03 1384.85 617.464 1374 618.517 1372.56 C 619.422 1374.83 619.78 1375.27 620.313 1378.03 C 618.086 1381.39 618.878 1383.12 618.12 1387.17 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<path d="M 1301.01 1392.13 C 1302.78 1395.17 1301.97 1449.61 1301.95 1457.16 C 1242.27 1496.6 1180.49 1538.82 1121.31 1579.19 L 1053.19 1625.21 C 1014.57 1651.46 986.749 1673.62 940.759 1687.11 C 831.787 1719.06 659.351 1697.69 621.464 1571.91 C 616.131 1553.99 617.884 1525.14 618.188 1505.84 C 660.378 1640.39 844.834 1657.35 958.915 1617.05 C 992.709 1605.11 1016.79 1586.79 1045.68 1566.93 L 1106.27 1525.46 L 1301.01 1392.13 z" fill="rgb(136,30,185)" transform="translate(0,0)"></path>
|
||||
<path d="M 376.883 881.106 C 378.761 883 377.911 1071.44 377.887 1087.13 C 355.673 1102.61 333.326 1117.91 310.849 1133 C 300.368 1140.12 283.213 1151.08 274.293 1159 C 267.532 1164.99 261.798 1172.04 257.318 1179.88 C 246.618 1198.6 239.226 1229.6 262.867 1241.75 C 243.854 1253.48 234.22 1266.43 209.801 1259.41 C 184.169 1245.48 189.142 1217.51 188.943 1193.2 C 188.851 1182.05 189.061 1170.84 189.047 1159.67 L 188.933 1089.97 C 188.859 1028.86 189.312 1006.21 244.321 970.357 C 288.484 941.57 332.27 909.619 376.883 881.106 z" fill="rgb(199,157,233)" transform="translate(0,0)"></path>
|
||||
<path d="M 693.433 876.139 C 720.632 874.538 722.187 896.835 715.959 917.618 C 711.675 931.745 703.532 944.396 692.448 954.147 C 679.989 964.93 642.642 988.894 626.618 999.854 L 488.37 1094.24 L 345.857 1191.12 C 329.395 1202.3 290.014 1230.53 274.471 1238.81 C 245.396 1243.47 247.085 1209.34 254.853 1191.1 C 259.88 1179.3 266.676 1167.59 277.178 1159.87 C 301.476 1142.01 326.772 1125.3 351.682 1108.27 L 505.151 1003.41 L 631.644 916.682 C 644.088 908.164 682.044 881.149 693.433 876.139 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 618.804 1572.47 C 658.577 1701.26 832.833 1722.62 944.953 1688.46 C 983.753 1676.63 1003.84 1661.91 1036.56 1639.5 L 1099.03 1597.32 L 1215.28 1518.93 C 1243.7 1499.6 1272.97 1479.04 1301.79 1460.47 C 1302.2 1517.43 1295.98 1518.2 1250.23 1549.32 L 1188.92 1591.01 L 1073.63 1669.54 C 1043.59 1690.09 1007.55 1717.64 974.435 1731.42 C 930.364 1749.76 878.663 1755.26 831.355 1753.96 C 737.256 1751.86 627.086 1703.99 617.854 1598.06 C 616.226 1590.1 617.182 1578.18 617.17 1569.92 C 617.168 1568.77 618.983 1569.05 618.804 1572.47 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 617.854 1598.06 C 616.226 1590.1 617.182 1578.18 617.17 1569.92 C 617.168 1568.77 618.983 1569.05 618.804 1572.47 C 617.248 1579.4 618.181 1590.23 617.854 1598.06 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 879.613 1069.03 C 892.847 1076.04 934.245 1097.49 943.949 1105.81 C 868.374 1159.37 869.769 1209.8 869.879 1293.21 L 870.008 1424.73 C 857.013 1425.39 835.359 1426.96 822.819 1425.15 C 809.852 1425.45 788.678 1421.82 775.529 1419.49 L 775.491 1275.2 C 775.473 1254.74 774.507 1213.92 777.374 1195.14 C 777.568 1193.23 777.805 1191.86 778.158 1190.04 C 790.855 1124.65 829.786 1102.93 879.613 1069.03 z" fill="rgb(207,128,231)" transform="translate(0,0)"></path>
|
||||
<path d="M 777.374 1195.14 C 779.697 1199.79 780.309 1207.43 781.682 1212.98 C 784.725 1225.27 789.199 1236.67 795.296 1247.76 C 803.838 1263.29 820.826 1271.67 831.22 1284.71 C 851.108 1309.68 847.908 1340.09 847.779 1369.98 C 847.622 1388.3 847.67 1406.63 847.924 1424.95 C 841.013 1424.95 829.363 1424.59 822.819 1425.15 C 809.852 1425.45 788.678 1421.82 775.529 1419.49 L 775.491 1275.2 C 775.473 1254.74 774.507 1213.92 777.374 1195.14 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 520.775 1332.25 C 561.138 1344.5 582.771 1328.32 615.148 1306.24 L 615.183 1319.96 L 615.047 1523.97 C 596.331 1537.89 576.827 1549.43 558.252 1562.64 C 536.722 1577.68 507.111 1595.06 481.223 1576.76 C 455.477 1558.57 464.198 1516.7 463.051 1489.08 C 464.85 1474.94 459.896 1441.61 463.28 1429.57 C 471.331 1400.94 504.319 1355.77 520.775 1332.25 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 558.252 1562.64 C 556.254 1537.96 557.895 1500.93 557.534 1474.45 C 557.264 1454.62 556.18 1410.36 558.828 1391.8 C 564.213 1354.04 588.044 1339.65 615.183 1319.96 L 615.047 1523.97 C 596.331 1537.89 576.827 1549.43 558.252 1562.64 z" fill="rgb(199,157,233)" transform="translate(0,0)"></path>
|
||||
<path d="M 380.256 1171.11 C 405.482 1153.89 436.017 1134.42 460.063 1116.86 L 460.268 1405.11 L 459.98 1494.66 C 459.965 1513.28 458.758 1535.03 462.493 1553.4 C 463.514 1558.42 470.006 1565.55 470.623 1571.18 C 468.474 1570.5 468.001 1570.37 466.022 1569.32 C 464.927 1569.01 465.061 1569.12 464.214 1568.34 C 456.979 1563.99 447.744 1559.32 440.194 1555.24 C 438.592 1554.77 437.705 1555.04 437.064 1553.45 L 429.189 1549.26 C 427.587 1548.77 426.715 1549.03 426.036 1547.46 L 418.185 1543.27 C 415.407 1542.27 415.856 1543.76 415.26 1541.55 C 390.955 1526.89 385.655 1517.51 380.871 1489.53 C 379.406 1477.92 380.041 1447.68 380.016 1435.23 L 379.93 1327.86 L 379.942 1231.38 C 379.843 1216.53 378.836 1185.22 380.256 1171.11 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 380.871 1489.53 C 379.406 1477.92 380.041 1447.68 380.016 1435.23 L 379.93 1327.86 L 379.942 1231.38 C 379.843 1216.53 378.836 1185.22 380.256 1171.11 C 381.638 1187.35 380.985 1213.25 380.98 1230.1 L 380.906 1332.56 L 381.026 1434.73 C 381.064 1450.48 381.857 1474.61 380.871 1489.53 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 415.26 1541.55 C 416.784 1542.09 417.17 1542.11 418.185 1543.27 C 415.407 1542.27 415.856 1543.76 415.26 1541.55 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 462.956 1114.92 C 482.051 1100.4 508.929 1083.19 529.148 1069.41 L 655.558 983.13 C 656.049 1018.15 654.987 1054.68 655.899 1089.27 C 602.107 1138.1 552.235 1191.07 506.743 1247.71 C 491.04 1267.48 477.725 1287.9 462.827 1307.01 L 462.956 1114.92 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 609.763 309.66 C 683.827 257.147 761.414 207.655 836.06 156.165 C 871.598 131.65 900.969 151.613 932.505 169.813 C 941.61 175.068 951.083 179.758 960.011 185.395 C 961.257 185.876 964.041 186.602 964.235 188.276 C 963.705 188.754 952.279 188.589 950.505 188.692 C 929.112 192.278 914.902 203.479 897.25 215.275 C 878.833 227.583 860.508 240.033 842.151 252.429 L 690.304 354.951 C 684.664 351.073 672.296 344.41 665.893 340.755 C 647.287 330.203 628.576 319.837 609.763 309.66 z" fill="rgb(238,160,208)" transform="translate(0,0)"></path>
|
||||
<path d="M 960.011 185.395 C 961.257 185.876 964.041 186.602 964.235 188.276 C 963.705 188.754 952.279 188.589 950.505 188.692 C 955.215 185.153 955.209 187.561 960.011 185.395 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 835.867 640.622 C 860.813 652.86 893.823 671.704 918.317 685.666 C 882.255 709.056 859.312 727.174 839.632 767.273 C 822.338 807.311 825.03 829.183 825.037 871.636 L 824.962 974.987 C 801.361 961.612 774.849 948.542 752.107 934.555 L 752.105 827.539 C 752.089 789.554 749.01 761.218 764.133 725.092 C 780.697 687.298 802.236 664.166 835.867 640.622 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<path d="M 963.14 881.238 C 970.577 861.349 978.937 841.818 988.193 822.707 C 1045.23 703.787 1150.62 584.373 1277.6 537.506 C 1337.33 515.458 1391.85 521.542 1448.49 548.878 L 1448.33 599.742 C 1438.51 593.644 1428.22 588.339 1417.56 583.877 C 1365.4 561.691 1314.03 561.899 1261.79 584.071 C 1134.36 638.15 1052.09 750.774 1000.09 874.312 C 991.772 895.869 981.538 923.357 976.169 945.976 C 962.808 955.069 948.861 964.285 935.688 973.567 C 944.424 935.981 950.302 917.17 963.14 881.238 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<path d="M 382.448 510.725 C 409.241 524.172 435.991 540.139 462.698 554.122 C 458.152 578.231 459.922 628.902 459.974 654.969 L 460.098 817.416 C 434.629 817.893 402.196 829.367 380.632 842.732 L 380.149 847.507 C 378.913 833.902 379.806 802.756 379.803 787.647 L 379.773 668.116 L 379.732 569.599 C 379.73 560.421 379.097 529.975 380.736 522.977 L 382.448 510.725 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<path d="M 380.736 522.977 L 380.781 719.266 C 380.704 759.699 379.9 802.467 380.632 842.732 L 380.149 847.507 C 378.913 833.902 379.806 802.756 379.803 787.647 L 379.773 668.116 L 379.732 569.599 C 379.73 560.421 379.097 529.975 380.736 522.977 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1231.74 370.434 C 1232.75 369.797 1233.76 369.16 1234.77 368.523 C 1244.24 375.218 1253.96 379.179 1262.98 385.149 C 1280.71 393.307 1298.94 406.962 1316.54 413.561 C 1250.57 460.076 1181.56 505.76 1114.72 551.357 C 1073.96 552.529 1048.94 541.143 1014.78 518.542 C 1039.21 500.67 1067.72 482.223 1093.05 464.983 L 1231.74 370.434 z" fill="rgb(251,122,171)" transform="translate(0,0)"></path>
|
||||
<path d="M 1231.74 370.434 C 1232.75 369.797 1233.76 369.16 1234.77 368.523 C 1244.24 375.218 1253.96 379.179 1262.98 385.149 C 1256.22 384.364 1241.99 377.456 1236.75 373.888 L 1231.74 370.434 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 380.632 842.732 C 402.196 829.367 434.629 817.893 460.098 817.416 L 460.075 990.717 L 460.092 1030.63 C 433.589 1049.56 406.748 1067.27 380.189 1085.92 L 380.2 1056.23 C 379.578 1047.21 379.526 1035.41 379.927 1026.32 C 382.299 972.646 377.049 915.238 380.19 861.819 C 380.369 857.419 380.179 851.993 380.149 847.507 L 380.632 842.732 z" fill="rgb(136,30,185)" transform="translate(0,0)"></path>
|
||||
<path d="M 380.19 861.819 C 381.601 887.345 380.66 921.38 380.613 947.41 C 402.857 959.463 439.404 977.779 460.075 990.717 L 460.092 1030.63 C 433.589 1049.56 406.748 1067.27 380.189 1085.92 L 380.2 1056.23 C 379.578 1047.21 379.526 1035.41 379.927 1026.32 C 382.299 972.646 377.049 915.238 380.19 861.819 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 380.19 861.819 C 381.601 887.345 380.66 921.38 380.613 947.41 C 380.98 972.275 381.077 997.143 380.904 1022.01 C 380.843 1030.77 381.111 1048.11 380.2 1056.23 C 379.578 1047.21 379.526 1035.41 379.927 1026.32 C 382.299 972.646 377.049 915.238 380.19 861.819 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 838.905 638.965 C 894.762 599.618 957.596 556.093 1014.78 518.542 C 1048.94 541.143 1073.96 552.529 1114.72 551.357 C 1050.74 596.092 985.02 639.971 920.447 684.034 C 895.521 669.076 864.781 653.071 838.905 638.965 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1003.5 872.819 C 1016.33 840.966 1031.69 810.191 1049.43 780.789 C 1103.44 691.803 1192.76 602.358 1296.88 575.625 C 1351.08 561.711 1401.23 573.92 1448.46 603.052 L 1448.46 621.576 C 1441.94 626.165 1426.82 637.255 1420.26 640.627 C 1354.26 601.74 1295.95 596.39 1228.1 635.88 C 1138.91 687.793 1078.98 769.023 1039.45 862.924 C 1036.84 863.449 1034.93 863.779 1032.38 864.496 L 1023.03 867.055 C 1016.26 868.838 1010.36 871.19 1003.5 872.819 z" fill="url(#Gradient2)" transform="translate(0,0)"></path>
|
||||
<path d="M 872.674 1235.21 C 874.196 1197.26 887.607 1164.74 912.388 1135.91 C 906.143 1161.87 906.848 1186.11 920.799 1209.88 C 927.065 1220.56 940.39 1233.06 952.154 1237.08 L 950.161 1250.69 C 947.727 1298.03 950.092 1358.42 949.723 1407 C 923.331 1416.27 900.965 1421.91 872.568 1424.26 C 870.76 1416.09 872.233 1236.6 872.674 1235.21 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 872.674 1235.21 C 874.196 1197.26 887.607 1164.74 912.388 1135.91 C 906.143 1161.87 906.848 1186.11 920.799 1209.88 C 927.065 1220.56 940.39 1233.06 952.154 1237.08 L 950.161 1250.69 C 921.541 1272.04 898.82 1300.31 884.134 1332.85 C 880.159 1341.48 876.5 1352.74 872.973 1360.12 C 872.869 1374.84 874.048 1411.94 872.568 1424.26 C 870.76 1416.09 872.233 1236.6 872.674 1235.21 z" fill="rgb(141,58,208)" transform="translate(0,0)"></path>
|
||||
<path d="M 872.568 1424.26 C 870.76 1416.09 872.233 1236.6 872.674 1235.21 C 873.261 1251.51 872.683 1270.03 872.634 1286.49 L 872.973 1360.12 C 872.869 1374.84 874.048 1411.94 872.568 1424.26 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 642.716 1104.95 C 644.804 1131.23 641.489 1162.78 643.545 1188.77 C 581.95 1249.6 524.391 1315.09 479.826 1389.56 C 474.042 1399.22 468.988 1409.55 462.943 1418.99 L 462.932 1312.09 C 514.905 1236.52 575.212 1167.04 642.716 1104.95 z" fill="rgb(238,160,208)" transform="translate(0,0)"></path>
|
||||
<path d="M 446.67 420.617 L 558.626 344.274 C 574.89 333.292 593.432 319.875 609.763 309.66 C 628.576 319.837 647.287 330.203 665.893 340.755 C 672.296 344.41 684.664 351.073 690.304 354.951 C 636.015 392.046 581.539 428.864 526.876 465.404 C 504.962 451.249 469.996 433.353 446.67 420.617 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 1042.23 863.239 C 1052.52 837.809 1064.73 813.197 1078.76 789.618 C 1121.27 718.886 1199.84 637.926 1282.36 616.211 C 1331.13 603.376 1376 617.061 1418.15 642.534 C 1403.61 652.142 1389.23 661.984 1375 672.055 C 1372.49 670.752 1369.96 669.508 1367.39 668.325 C 1331.33 651.826 1297.46 649.585 1260.46 664.336 C 1176.67 697.734 1117.1 775.066 1080.62 854.21 C 1078.86 855.277 1050.89 859.601 1044.05 862.488 L 1042.23 863.239 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<path d="M 1084.19 852.837 C 1084.47 852.123 1084.77 851.413 1085.08 850.706 C 1117.35 777.438 1184.15 696.349 1260.13 667.258 C 1303.45 650.673 1331.83 655.052 1372.72 673.399 L 1330.85 702.153 C 1326.9 701.201 1322.89 700.536 1318.85 700.165 C 1253.31 694.025 1180.65 753.137 1146.12 804.779 C 1140.7 812.886 1122.88 845.025 1119.05 847.751 C 1105.28 857.98 1086.98 869.5 1072.38 879.715 C 1076.28 871.241 1080.6 861.433 1084.19 852.837 z" fill="url(#Gradient1)" transform="translate(0,0)"></path>
|
||||
<path d="M 1084.19 852.837 C 1096.64 852.761 1106.57 848.17 1119.05 847.751 C 1105.28 857.98 1086.98 869.5 1072.38 879.715 C 1076.28 871.241 1080.6 861.433 1084.19 852.837 z" fill="rgb(207,128,231)" transform="translate(0,0)"></path>
|
||||
<path d="M 871.548 1756.08 C 897.722 1754.51 923.658 1750.19 948.928 1743.19 L 948.914 1837.58 C 948.907 1856.28 947.674 1886.13 951.4 1903.19 C 947.83 1901.78 946.488 1901.18 943.169 1899.28 C 941.585 1898.74 941.699 1898.95 940.827 1897.78 C 936.402 1895.21 931.703 1892.72 927.2 1890.25 C 925.619 1889.7 924.699 1890.03 924.065 1888.44 L 916.385 1884.23 C 915.259 1883.89 915.639 1884.24 915.084 1883.46 C 911.165 1881 906.291 1878.52 902.167 1876.29 C 900.586 1875.76 899.707 1876.03 899.058 1874.46 C 898.391 1874.1 897.728 1873.73 897.068 1873.36 C 882.634 1865.16 876.385 1859.28 872.024 1843.07 C 870.178 1836.28 870.366 1764.59 871.548 1756.08 z" fill="rgb(91,3,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 872.024 1843.07 C 870.178 1836.28 870.366 1764.59 871.548 1756.08 C 872.421 1764.39 871.984 1777.81 871.989 1786.56 L 872.024 1843.07 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 444.176 421.995 C 451.527 425.359 465.848 433.799 473.486 438.004 C 490.413 447.325 508.448 457.161 524.88 467.21 C 494.02 486.241 471.827 516.587 463.043 551.763 C 439.207 537.657 407.819 521.234 383.323 508.106 C 391.86 469.689 412.831 444.609 444.176 421.995 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<path d="M 642.716 1104.95 C 677.124 1072.47 712.741 1041.3 749.494 1011.5 L 749.552 1098.49 C 722.119 1115.81 666.842 1165.44 643.545 1188.77 C 641.489 1162.78 644.804 1131.23 642.716 1104.95 z" fill="rgb(242,88,164)" transform="translate(0,0)"></path>
|
||||
<path d="M 752.107 934.555 C 774.849 948.542 801.361 961.612 824.962 974.987 L 825.11 1049.26 C 801.105 1066.93 775.455 1082.38 752.041 1101.14 L 752.107 934.555 z" fill="rgb(136,30,185)" transform="translate(0,0)"></path>
|
||||
<path d="M 522.388 1330.39 C 533.728 1313.83 550.1 1294.33 563.039 1278.97 C 610.205 1222.75 662.525 1171.05 719.315 1124.57 C 723.981 1120.78 743.229 1104.64 748.108 1103.04 L 748.237 1103.89 C 747.143 1105.68 747.214 1105.72 745.642 1107.02 C 728.491 1127.31 723.021 1138.16 717.15 1163.99 C 705.738 1171.5 653.874 1206.28 647.772 1214.99 C 623.985 1239.29 615.259 1270.08 615.229 1303.22 C 583.182 1325.04 562.455 1341.54 522.388 1330.39 z" fill="rgb(156,105,225)" transform="translate(0,0)"></path>
|
||||
<path d="M 1234.77 368.523 C 1251.74 356.685 1280.2 336.576 1298.27 328.635 C 1338.53 310.944 1371.83 346.723 1408.89 362.757 L 1409.75 363.117 C 1410.43 363.833 1411.61 364.783 1412.14 365.559 L 1412.15 365.815 C 1410.99 366.822 1404.58 366.172 1402.74 366.169 C 1396.63 367.623 1386.01 369.662 1380.54 372.242 C 1358.42 382.666 1337.21 400.885 1316.54 413.561 C 1298.94 406.962 1280.71 393.307 1262.98 385.149 C 1253.96 379.179 1244.24 375.218 1234.77 368.523 z" fill="rgb(238,160,208)" transform="translate(0,0)"></path>
|
||||
<path d="M 1409.75 363.117 C 1410.43 363.833 1411.61 364.783 1412.14 365.559 L 1412.15 365.815 C 1410.99 366.822 1404.58 366.172 1402.74 366.169 C 1405.07 365.135 1407.4 364.118 1409.75 363.117 z" fill="none" transform="translate(0,0)"></path>
|
||||
<path d="M 1306.6 702.428 C 1312.8 701.929 1322.15 703.244 1328.4 703.984 L 1141.16 832.29 L 1125.71 843.081 C 1164.44 775.033 1223.24 708.627 1306.6 702.428 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<path d="M 1401.26 369.297 C 1403.78 369.133 1406.31 369.025 1408.84 368.973 C 1457.48 368.284 1448.71 429.6 1448.49 461.532 C 1432.19 451.391 1419.06 443.883 1401.75 435.589 C 1375.27 423.529 1352.1 416.099 1323.14 412.441 C 1346.24 396.328 1373.73 373.794 1401.26 369.297 z" fill="rgb(199,157,233)" transform="translate(0,0)"></path>
|
||||
<path d="M 1003.5 872.819 C 1010.36 871.19 1016.26 868.838 1023.03 867.055 L 1032.38 864.496 C 1034.93 863.779 1036.84 863.449 1039.45 862.924 C 1033.15 877.766 1025.94 899.049 1021.21 914.65 L 1018.38 916.604 L 1013.92 919.797 C 1002.34 928.032 990.463 935.911 978.774 944.058 C 986.137 919.458 995.223 896.78 1003.5 872.819 z" fill="rgb(207,128,231)" transform="translate(0,0)"></path>
|
||||
<path d="M 1042.23 863.239 L 1044.05 862.488 C 1050.89 859.601 1078.86 855.277 1080.62 854.21 C 1077.61 862.312 1073.02 872.746 1069.61 881.313 C 1054.73 892.054 1039.6 902.442 1024.23 912.468 C 1030.3 896.298 1036.33 879.514 1042.23 863.239 z" fill="rgb(181,53,186)" transform="translate(0,0)"></path>
|
||||
<metadata><recraft-signature>{"signed_by": "recraft", "signature_b64": "TvXBKFiryEsx4pvT8mb+MvxD2XfP4o2Qtt3AEJ0oTZLubHbiF+SxyMTvm8iHgS9fH1SyT+egImArAa0DSPO9DA==", "signing_algo": "Ed25519", "generation_timestamp": 1773854565, "identifier": "6511bb9a-5ac8-41d0-a470-9cafee6224ab"}</recraft-signature></metadata></g></svg>
|
||||
|
After Width: | Height: | Size: 60 KiB |
260
templates/marketing/seed/seed.json
Normal file
260
templates/marketing/seed/seed.json
Normal file
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {
|
||||
"name": "Marketing Starter",
|
||||
"description": "A conversion-focused marketing site with landing pages",
|
||||
"author": "EmDash"
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"title": "Acme",
|
||||
"tagline": "Build products people actually want"
|
||||
},
|
||||
|
||||
"collections": [
|
||||
{
|
||||
"slug": "pages",
|
||||
"label": "Pages",
|
||||
"labelSingular": "Page",
|
||||
"supports": ["drafts", "revisions", "seo"],
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"slug": "content",
|
||||
"label": "Content",
|
||||
"type": "portableText"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"menus": [
|
||||
{
|
||||
"name": "primary",
|
||||
"label": "Primary Navigation",
|
||||
"items": [
|
||||
{ "type": "custom", "label": "Features", "url": "/#features" },
|
||||
{ "type": "custom", "label": "Pricing", "url": "/pricing" },
|
||||
{ "type": "custom", "label": "Contact", "url": "/contact" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"content": {
|
||||
"pages": [
|
||||
{
|
||||
"id": "home",
|
||||
"slug": "home",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Home",
|
||||
"content": [
|
||||
{
|
||||
"_type": "marketing.hero",
|
||||
"_key": "hero",
|
||||
"headline": "Build products people actually want",
|
||||
"subheadline": "The all-in-one platform for modern teams. Ship faster, collaborate better, and focus on what matters.",
|
||||
"primaryCta": { "label": "Start Free Trial", "url": "/signup" },
|
||||
"secondaryCta": { "label": "Watch Demo", "url": "/demo" }
|
||||
},
|
||||
{
|
||||
"_type": "marketing.features",
|
||||
"_key": "features",
|
||||
"headline": "Everything you need to ship",
|
||||
"subheadline": "Powerful features that help your team move faster without sacrificing quality.",
|
||||
"features": [
|
||||
{
|
||||
"icon": "zap",
|
||||
"title": "Lightning Fast",
|
||||
"description": "Built for speed from the ground up. Your team will notice the difference from day one."
|
||||
},
|
||||
{
|
||||
"icon": "shield",
|
||||
"title": "Enterprise Security",
|
||||
"description": "SOC 2 compliant with end-to-end encryption. Your data stays yours."
|
||||
},
|
||||
{
|
||||
"icon": "users",
|
||||
"title": "Team Collaboration",
|
||||
"description": "Real-time collaboration features that make working together feel effortless."
|
||||
},
|
||||
{
|
||||
"icon": "chart",
|
||||
"title": "Powerful Analytics",
|
||||
"description": "Understand how your team works with detailed insights and reporting."
|
||||
},
|
||||
{
|
||||
"icon": "code",
|
||||
"title": "Developer Friendly",
|
||||
"description": "A robust API and CLI tools that integrate with your existing workflow."
|
||||
},
|
||||
{
|
||||
"icon": "globe",
|
||||
"title": "Global Scale",
|
||||
"description": "Deployed to edge locations worldwide. Fast for everyone, everywhere."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "marketing.testimonials",
|
||||
"_key": "testimonials",
|
||||
"headline": "Trusted by teams everywhere",
|
||||
"testimonials": [
|
||||
{
|
||||
"quote": "We cut our deployment time by 80%. The team actually enjoys shipping now.",
|
||||
"author": "Sarah Chen",
|
||||
"role": "CTO",
|
||||
"company": "Streamline"
|
||||
},
|
||||
{
|
||||
"quote": "The best developer experience I've used. Everything just works the way you'd expect.",
|
||||
"author": "Marcus Johnson",
|
||||
"role": "Lead Engineer",
|
||||
"company": "Volt Labs"
|
||||
},
|
||||
{
|
||||
"quote": "Finally, a tool that doesn't get in the way. Our team is more productive than ever.",
|
||||
"author": "Elena Rodriguez",
|
||||
"role": "VP Engineering",
|
||||
"company": "Nexus"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "marketing.faq",
|
||||
"_key": "faq",
|
||||
"headline": "Frequently asked questions",
|
||||
"items": [
|
||||
{
|
||||
"question": "How long does setup take?",
|
||||
"answer": "Most teams are up and running in under 15 minutes. Our onboarding wizard guides you through connecting your existing tools and inviting your team."
|
||||
},
|
||||
{
|
||||
"question": "Can I migrate from my current tool?",
|
||||
"answer": "Yes. We have built-in importers for all major platforms, and our support team can help with custom migrations for larger teams."
|
||||
},
|
||||
{
|
||||
"question": "What kind of support do you offer?",
|
||||
"answer": "All plans include email support with a 24-hour response time. Pro and Enterprise plans include priority support with dedicated account managers."
|
||||
},
|
||||
{
|
||||
"question": "Is there a free trial?",
|
||||
"answer": "Yes, all plans come with a 14-day free trial. No credit card required. You can upgrade, downgrade, or cancel at any time."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pricing",
|
||||
"slug": "pricing",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Pricing",
|
||||
"content": [
|
||||
{
|
||||
"_type": "marketing.hero",
|
||||
"_key": "pricing-hero",
|
||||
"headline": "Simple, transparent pricing",
|
||||
"subheadline": "No hidden fees. No surprises. Start free and scale as you grow.",
|
||||
"centered": true
|
||||
},
|
||||
{
|
||||
"_type": "marketing.pricing",
|
||||
"_key": "pricing-plans",
|
||||
"plans": [
|
||||
{
|
||||
"name": "Starter",
|
||||
"price": "$0",
|
||||
"period": "/month",
|
||||
"description": "Perfect for trying things out",
|
||||
"features": [
|
||||
"Up to 3 team members",
|
||||
"5 projects",
|
||||
"Basic analytics",
|
||||
"Community support",
|
||||
"1GB storage"
|
||||
],
|
||||
"cta": { "label": "Get Started", "url": "/signup" }
|
||||
},
|
||||
{
|
||||
"name": "Pro",
|
||||
"price": "$29",
|
||||
"period": "/user/month",
|
||||
"description": "For growing teams",
|
||||
"features": [
|
||||
"Unlimited team members",
|
||||
"Unlimited projects",
|
||||
"Advanced analytics",
|
||||
"Priority support",
|
||||
"100GB storage",
|
||||
"Custom integrations",
|
||||
"API access"
|
||||
],
|
||||
"cta": { "label": "Start Free Trial", "url": "/signup?plan=pro" },
|
||||
"highlighted": true
|
||||
},
|
||||
{
|
||||
"name": "Enterprise",
|
||||
"price": "Custom",
|
||||
"description": "For large organizations",
|
||||
"features": [
|
||||
"Everything in Pro",
|
||||
"Dedicated support",
|
||||
"Custom contracts",
|
||||
"SLA guarantee",
|
||||
"Unlimited storage"
|
||||
],
|
||||
"cta": { "label": "Contact Sales", "url": "/contact" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "marketing.faq",
|
||||
"_key": "pricing-faq",
|
||||
"headline": "Pricing FAQ",
|
||||
"items": [
|
||||
{
|
||||
"question": "Can I change plans later?",
|
||||
"answer": "Yes, you can upgrade or downgrade at any time. Changes take effect immediately and we'll prorate your billing."
|
||||
},
|
||||
{
|
||||
"question": "What payment methods do you accept?",
|
||||
"answer": "We accept all major credit cards, and Enterprise customers can pay via invoice with NET 30 terms."
|
||||
},
|
||||
{
|
||||
"question": "Is there a discount for annual billing?",
|
||||
"answer": "Yes, annual plans receive a 20% discount compared to monthly billing."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contact",
|
||||
"slug": "contact",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Contact",
|
||||
"content": [
|
||||
{
|
||||
"_type": "marketing.hero",
|
||||
"_key": "contact-hero",
|
||||
"headline": "Get in touch",
|
||||
"subheadline": "Have questions? Want a demo? We'd love to hear from you.",
|
||||
"centered": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
33
templates/marketing/src/components/MarketingBlocks.astro
Normal file
33
templates/marketing/src/components/MarketingBlocks.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
/**
|
||||
* Custom Portable Text renderer for marketing blocks.
|
||||
*
|
||||
* This component maps custom block types (marketing.hero, marketing.features, etc.)
|
||||
* to their corresponding Astro components. Pass it to the PortableText component
|
||||
* via the `components` prop.
|
||||
*/
|
||||
import type { PortableTextBlock } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Hero from "./blocks/Hero.astro";
|
||||
import Features from "./blocks/Features.astro";
|
||||
import Testimonials from "./blocks/Testimonials.astro";
|
||||
import Pricing from "./blocks/Pricing.astro";
|
||||
import FAQ from "./blocks/FAQ.astro";
|
||||
|
||||
interface Props {
|
||||
value: PortableTextBlock[];
|
||||
}
|
||||
|
||||
const { value } = Astro.props;
|
||||
|
||||
// Custom block type components
|
||||
const marketingTypes = {
|
||||
"marketing.hero": Hero,
|
||||
"marketing.features": Features,
|
||||
"marketing.testimonials": Testimonials,
|
||||
"marketing.pricing": Pricing,
|
||||
"marketing.faq": FAQ,
|
||||
};
|
||||
---
|
||||
|
||||
<PortableText value={value} components={{ type: marketingTypes }} />
|
||||
114
templates/marketing/src/components/blocks/FAQ.astro
Normal file
114
templates/marketing/src/components/blocks/FAQ.astro
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
interface Props {
|
||||
node: {
|
||||
_key?: string;
|
||||
headline?: string;
|
||||
items: Array<{
|
||||
question: string;
|
||||
answer: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { _key, headline, items } = node;
|
||||
---
|
||||
|
||||
<section class="faq section" id={_key}>
|
||||
<div class="container">
|
||||
{headline && (
|
||||
<header class="faq-header">
|
||||
<h2 class="faq-headline">{headline}</h2>
|
||||
</header>
|
||||
)}
|
||||
<div class="faq-list">
|
||||
{items.map((item) => (
|
||||
<details class="faq-item" name="faq">
|
||||
<summary class="faq-question">
|
||||
<span>{item.question}</span>
|
||||
<svg class="faq-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="faq-answer">
|
||||
<p>{item.answer}</p>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.faq-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-4xl);
|
||||
}
|
||||
|
||||
.faq-headline {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.faq-list {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.faq-item:hover {
|
||||
border-color: var(--color-muted);
|
||||
}
|
||||
|
||||
.faq-item[open] {
|
||||
border-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.faq-question::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-muted);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.faq-item[open] .faq-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
padding: 0 var(--spacing-lg) var(--spacing-lg);
|
||||
}
|
||||
|
||||
.faq-answer p {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
134
templates/marketing/src/components/blocks/Features.astro
Normal file
134
templates/marketing/src/components/blocks/Features.astro
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
interface Props {
|
||||
node: {
|
||||
_key?: string;
|
||||
headline?: string;
|
||||
subheadline?: string;
|
||||
features: Array<{
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { _key, headline, subheadline, features } = node;
|
||||
|
||||
// Map feature icon names to Phosphor icon names
|
||||
const iconMap: Record<string, string> = {
|
||||
zap: "lightning",
|
||||
shield: "shield-check",
|
||||
users: "users-three",
|
||||
chart: "chart-bar",
|
||||
code: "code",
|
||||
globe: "globe",
|
||||
heart: "heart",
|
||||
star: "star",
|
||||
check: "check-circle",
|
||||
lock: "lock",
|
||||
clock: "clock",
|
||||
cloud: "cloud",
|
||||
};
|
||||
---
|
||||
|
||||
<section class="features section" id={_key}>
|
||||
<div class="container">
|
||||
{(headline || subheadline) && (
|
||||
<header class="features-header">
|
||||
{headline && <h2 class="features-headline">{headline}</h2>}
|
||||
{subheadline && <p class="features-subheadline">{subheadline}</p>}
|
||||
</header>
|
||||
)}
|
||||
<div class="features-grid">
|
||||
{features.map((feature) => (
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class={`ph ph-${iconMap[feature.icon] || "sparkle"}`} aria-hidden="true"></i>
|
||||
</div>
|
||||
<h3 class="feature-title">{feature.title}</h3>
|
||||
<p class="feature-description">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.features-header {
|
||||
text-align: center;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto var(--spacing-4xl);
|
||||
}
|
||||
|
||||
.features-headline {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.features-subheadline {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition:
|
||||
transform var(--transition-base),
|
||||
box-shadow var(--transition-base),
|
||||
border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
templates/marketing/src/components/blocks/Hero.astro
Normal file
167
templates/marketing/src/components/blocks/Hero.astro
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
interface Props {
|
||||
node: {
|
||||
headline: string;
|
||||
subheadline?: string;
|
||||
primaryCta?: { label: string; url: string };
|
||||
secondaryCta?: { label: string; url: string };
|
||||
image?: { url: string; alt?: string };
|
||||
centered?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { headline, subheadline, primaryCta, secondaryCta, image, centered } = node;
|
||||
---
|
||||
|
||||
<section class:list={["hero", { "hero-centered": centered, "hero-with-image": !!image }]}>
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-headline">{headline}</h1>
|
||||
{subheadline && <p class="hero-subheadline">{subheadline}</p>}
|
||||
{(primaryCta || secondaryCta) && (
|
||||
<div class="hero-actions">
|
||||
{primaryCta && (
|
||||
<a href={primaryCta.url} class="btn btn-primary btn-lg">
|
||||
{primaryCta.label}
|
||||
</a>
|
||||
)}
|
||||
{secondaryCta && (
|
||||
<a href={secondaryCta.url} class="btn btn-secondary btn-lg">
|
||||
{secondaryCta.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{image ? (
|
||||
<div class="hero-image">
|
||||
<img src={image.url} alt={image.alt || ""} loading="eager" />
|
||||
</div>
|
||||
) : !centered && (
|
||||
<div class="hero-visual" aria-hidden="true">
|
||||
<img src="/hero-visual.svg" alt="" width="800" height="800" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-4xl) var(--spacing-lg);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-2xl);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-centered {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
max-width: var(--max-width);
|
||||
padding-top: var(--spacing-4xl);
|
||||
padding-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.hero-centered .hero-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero-centered .hero-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.hero-headline {
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, var(--color-text) 0%, var(--color-muted) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-subheadline {
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: 1.6;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.hero-image::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-accent-light) 100%);
|
||||
border-radius: var(--radius-lg);
|
||||
z-index: -1;
|
||||
opacity: 0.3;
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
/* Hero visual (external SVG image) */
|
||||
.hero-visual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 550px;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.hero-visual img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
padding: var(--spacing-2xl) var(--spacing-lg);
|
||||
gap: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.hero-headline {
|
||||
font-size: var(--font-size-4xl);
|
||||
}
|
||||
|
||||
.hero-subheadline {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.hero-with-image {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-with-image .hero-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-image,
|
||||
.hero-visual {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
187
templates/marketing/src/components/blocks/Pricing.astro
Normal file
187
templates/marketing/src/components/blocks/Pricing.astro
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
interface Props {
|
||||
node: {
|
||||
headline?: string;
|
||||
plans: Array<{
|
||||
name: string;
|
||||
price: string;
|
||||
period?: string;
|
||||
description?: string;
|
||||
features: string[];
|
||||
cta: { label: string; url: string };
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { headline, plans } = node;
|
||||
---
|
||||
|
||||
<section class="pricing section">
|
||||
<div class="container">
|
||||
{headline && (
|
||||
<header class="pricing-header">
|
||||
<h2 class="pricing-headline">{headline}</h2>
|
||||
</header>
|
||||
)}
|
||||
<div class="pricing-grid">
|
||||
{plans.map((plan) => (
|
||||
<div class:list={["pricing-card", { "pricing-highlighted": plan.highlighted }]}>
|
||||
{plan.highlighted && <div class="pricing-badge">Most popular</div>}
|
||||
<div class="pricing-plan-header">
|
||||
<h3 class="pricing-name">{plan.name}</h3>
|
||||
<div class="pricing-price">
|
||||
<span class="pricing-amount">{plan.price}</span>
|
||||
{plan.period && <span class="pricing-period">{plan.period}</span>}
|
||||
</div>
|
||||
{plan.description && (
|
||||
<p class="pricing-description">{plan.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<ul class="pricing-features">
|
||||
{plan.features.map((feature) => (
|
||||
<li>
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={plan.cta.url}
|
||||
class:list={["btn", "btn-lg", "pricing-cta", { "btn-primary": plan.highlighted, "btn-secondary": !plan.highlighted }]}
|
||||
>
|
||||
{plan.cta.label}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pricing-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.pricing-headline {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-xl);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
position: relative;
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.pricing-highlighted {
|
||||
background: var(--color-bg);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.pricing-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.pricing-plan-header {
|
||||
text-align: center;
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.pricing-name {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pricing-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pricing-amount {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.pricing-period {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.pricing-description {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.pricing-features {
|
||||
list-style: none;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pricing-features li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-success);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pricing-cta {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pricing-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pricing-highlighted {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
135
templates/marketing/src/components/blocks/Testimonials.astro
Normal file
135
templates/marketing/src/components/blocks/Testimonials.astro
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
interface Props {
|
||||
node: {
|
||||
headline?: string;
|
||||
testimonials: Array<{
|
||||
quote: string;
|
||||
author: string;
|
||||
role?: string;
|
||||
company?: string;
|
||||
avatar?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { headline, testimonials } = node;
|
||||
---
|
||||
|
||||
<section class="testimonials section">
|
||||
<div class="container">
|
||||
{headline && (
|
||||
<header class="testimonials-header">
|
||||
<h2 class="testimonials-headline">{headline}</h2>
|
||||
</header>
|
||||
)}
|
||||
<div class="testimonials-grid">
|
||||
{testimonials.map((testimonial) => (
|
||||
<div class="testimonial-card">
|
||||
<blockquote class="testimonial-quote">
|
||||
"{testimonial.quote}"
|
||||
</blockquote>
|
||||
<div class="testimonial-author">
|
||||
{testimonial.avatar && (
|
||||
<img
|
||||
src={testimonial.avatar}
|
||||
alt={testimonial.author}
|
||||
class="testimonial-avatar"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div class="testimonial-info">
|
||||
<span class="testimonial-name">{testimonial.author}</span>
|
||||
{(testimonial.role || testimonial.company) && (
|
||||
<span class="testimonial-role">
|
||||
{testimonial.role}
|
||||
{testimonial.role && testimonial.company && " at "}
|
||||
{testimonial.company}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.testimonials {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.testimonials-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-4xl);
|
||||
}
|
||||
|
||||
.testimonials-headline {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.testimonials-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.testimonial-quote {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.testimonial-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.testimonial-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.testimonial-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.testimonial-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.testimonial-role {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.testimonials-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.testimonials-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
templates/marketing/src/components/blocks/index.ts
Normal file
5
templates/marketing/src/components/blocks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Hero } from "./Hero.astro";
|
||||
export { default as Features } from "./Features.astro";
|
||||
export { default as Testimonials } from "./Testimonials.astro";
|
||||
export { default as Pricing } from "./Pricing.astro";
|
||||
export { default as FAQ } from "./FAQ.astro";
|
||||
680
templates/marketing/src/layouts/Base.astro
Normal file
680
templates/marketing/src/layouts/Base.astro
Normal file
@@ -0,0 +1,680 @@
|
||||
---
|
||||
import { getMenu, getSiteSettings } from "emdash";
|
||||
import { EmDashHead } from "emdash/ui";
|
||||
import { createPublicPageContext } from "emdash/page";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const { title, description, image } = Astro.props;
|
||||
const settings = await getSiteSettings();
|
||||
const siteTitle = settings?.title || "Acme";
|
||||
const fullTitle = title ? `${title} — ${siteTitle}` : siteTitle;
|
||||
const siteDescription =
|
||||
settings?.tagline || "Build products people actually want";
|
||||
|
||||
const menu = await getMenu("primary");
|
||||
|
||||
const pageCtx = createPublicPageContext({
|
||||
Astro,
|
||||
kind: "custom",
|
||||
pageType: "website",
|
||||
title: fullTitle,
|
||||
description: description || siteDescription,
|
||||
canonical: Astro.url.href,
|
||||
image,
|
||||
seo: { ogImage: image },
|
||||
siteName: siteTitle,
|
||||
});
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{fullTitle}</title>
|
||||
<EmDashHead page={pageCtx} />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="preload"
|
||||
as="style"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://unpkg.com/@phosphor-icons/web@2.1.2/src/regular/style.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
/* Fallback font with metrics adjusted to match Inter */
|
||||
@font-face {
|
||||
font-family: "Inter Fallback";
|
||||
src: local("Arial");
|
||||
size-adjust: 107%;
|
||||
ascent-override: 90%;
|
||||
descent-override: 25%;
|
||||
line-gap-override: 0%;
|
||||
}
|
||||
</style>
|
||||
<script is:inline>
|
||||
// Apply theme immediately to prevent flash
|
||||
(function () {
|
||||
var c = document.cookie;
|
||||
var i = c.indexOf("theme=");
|
||||
var theme = i >= 0 ? c.slice(i + 6).split(";")[0] : null;
|
||||
if (theme === "dark" || theme === "light") {
|
||||
document.documentElement.classList.add(theme);
|
||||
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<nav class="nav">
|
||||
<a href="/" class="site-logo">{siteTitle}</a>
|
||||
<div class="nav-links">
|
||||
{
|
||||
menu?.items.map((item) => (
|
||||
<a href={item.url} target={item.target}>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a href="/_emdash/admin" class="nav-admin">Admin</a>
|
||||
<a href="/signup" class="nav-cta">Get Started</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-brand">
|
||||
<span class="footer-logo">{siteTitle}</span>
|
||||
<p class="footer-tagline">
|
||||
{settings?.tagline || "Build something amazing"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<div class="footer-col">
|
||||
<h4>Product</h4>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/changelog">Changelog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Company</h4>
|
||||
<a href="/about">About</a>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="/careers">Careers</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Support</h4>
|
||||
<a href="/docs">Documentation</a>
|
||||
<a href="/contact">Contact</a>
|
||||
<a href="/status">Status</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>
|
||||
© {new Date().getFullYear()}
|
||||
{siteTitle}. All rights reserved.
|
||||
</p>
|
||||
<div class="theme-switcher">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-btn"
|
||||
data-theme="light"
|
||||
aria-label="Light mode">Light</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-btn"
|
||||
data-theme="dark"
|
||||
aria-label="Dark mode">Dark</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-btn"
|
||||
data-theme="system"
|
||||
aria-label="System theme">System</button
|
||||
>
|
||||
</div>
|
||||
<p class="footer-powered">
|
||||
Powered by <a href="https://emdashcms.com">EmDash</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Theme switcher
|
||||
const THEME_REGEX = /theme=([^;]+)/;
|
||||
const themeBtns =
|
||||
document.querySelectorAll<HTMLButtonElement>(".theme-btn");
|
||||
const root = document.documentElement;
|
||||
|
||||
function setTheme(theme: string) {
|
||||
const secure = location.protocol === "https:" ? "; Secure" : "";
|
||||
if (theme === "system") {
|
||||
document.cookie = `theme=; path=/; max-age=0; SameSite=Lax${secure}`;
|
||||
root.classList.remove("light", "dark");
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
} else {
|
||||
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure}`;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(theme);
|
||||
}
|
||||
updateActiveBtn(theme);
|
||||
}
|
||||
|
||||
function updateActiveBtn(theme: string) {
|
||||
themeBtns.forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.theme === theme);
|
||||
});
|
||||
}
|
||||
|
||||
function getStoredTheme(): string {
|
||||
const match = document.cookie.match(THEME_REGEX);
|
||||
return match ? match[1] : "system";
|
||||
}
|
||||
|
||||
// Initialize - apply stored theme on load
|
||||
const storedTheme = getStoredTheme();
|
||||
setTheme(storedTheme);
|
||||
|
||||
themeBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
setTheme(btn.dataset.theme || "system");
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for system preference changes
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", (e) => {
|
||||
if (getStoredTheme() === "system") {
|
||||
root.classList.toggle("dark", e.matches);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Colors - Playful/Bold palette */
|
||||
--color-bg: #ffffff;
|
||||
--color-text: #0f172a;
|
||||
--color-muted: #64748b;
|
||||
--color-border: #e2e8f0;
|
||||
--color-surface: #f8fafc;
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-dark: #4f46e5;
|
||||
--color-primary-light: #818cf8;
|
||||
--color-accent: #f472b6;
|
||||
--color-accent-light: #f9a8d4;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
|
||||
/* Typography */
|
||||
--font-sans:
|
||||
"Inter", "Inter Fallback", system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", monospace;
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 2rem;
|
||||
--font-size-4xl: 2.5rem;
|
||||
--font-size-5xl: 3.5rem;
|
||||
--font-size-6xl: 4.5rem;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
--spacing-3xl: 4rem;
|
||||
--spacing-4xl: 6rem;
|
||||
--spacing-5xl: 8rem;
|
||||
|
||||
/* Layout */
|
||||
--max-width: 720px;
|
||||
--wide-width: 1200px;
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Dark mode via system preference (when no explicit class) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
--color-bg: #0f172a;
|
||||
--color-text: #f1f5f9;
|
||||
--color-muted: #94a3b8;
|
||||
--color-border: #334155;
|
||||
--color-surface: #1e293b;
|
||||
--color-primary: #818cf8;
|
||||
--color-primary-dark: #6366f1;
|
||||
--color-primary-light: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit dark mode */
|
||||
:root.dark {
|
||||
--color-bg: #0f172a;
|
||||
--color-text: #f1f5f9;
|
||||
--color-muted: #94a3b8;
|
||||
--color-border: #334155;
|
||||
--color-surface: #1e293b;
|
||||
--color-primary: #818cf8;
|
||||
--color-primary-dark: #6366f1;
|
||||
--color-primary-light: #a5b4fc;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: currentColor;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-5xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.container {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: var(--spacing-5xl) 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: white;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-dark),
|
||||
var(--color-accent)
|
||||
);
|
||||
border: none;
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary),
|
||||
var(--color-accent)
|
||||
);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-muted);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--spacing-md) var(--spacing-xl);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.nav {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary),
|
||||
var(--color-accent)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-muted);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.nav-admin {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-dark),
|
||||
var(--color-accent)
|
||||
);
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-cta:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary),
|
||||
var(--color-accent)
|
||||
);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-4xl) var(--spacing-lg) var(--spacing-2xl);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: var(--spacing-4xl);
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.footer-tagline {
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.footer-col h4 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.footer-col a {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
padding: var(--spacing-xs) 0;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.footer-col a:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.footer-powered a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.theme-switcher {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text);
|
||||
}
|
||||
|
||||
.theme-btn.active {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theme-switcher {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-4xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.nav {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--spacing-md);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer-links {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
13
templates/marketing/src/live.config.ts
Normal file
13
templates/marketing/src/live.config.ts
Normal 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() }),
|
||||
};
|
||||
69
templates/marketing/src/pages/404.astro
Normal file
69
templates/marketing/src/pages/404.astro
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base title="Page not found">
|
||||
<div class="not-found">
|
||||
<div class="not-found-code">404</div>
|
||||
<h1>Page not found</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<div class="not-found-actions">
|
||||
<a href="/" class="btn btn-primary btn-lg">Go home</a>
|
||||
<a href="/contact" class="btn btn-secondary btn-lg">Contact us</a>
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: var(--spacing-5xl) var(--spacing-lg);
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.not-found-code {
|
||||
font-size: 10rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.05em;
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
opacity: 0.3;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.not-found p {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.not-found-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.not-found-code {
|
||||
font-size: 6rem;
|
||||
}
|
||||
|
||||
.not-found-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.not-found-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
357
templates/marketing/src/pages/contact.astro
Normal file
357
templates/marketing/src/pages/contact.astro
Normal file
@@ -0,0 +1,357 @@
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import MarketingBlocks from "../components/MarketingBlocks.astro";
|
||||
|
||||
const { entry: page, cacheHint } = await getEmDashEntry("pages", "contact");
|
||||
|
||||
try {
|
||||
Astro.cache.set(cacheHint);
|
||||
} catch {}
|
||||
|
||||
// Handle form submission
|
||||
// NOTE: This is demo code. For production, add:
|
||||
// - CSRF token validation
|
||||
// - Rate limiting (e.g., via Cloudflare or middleware)
|
||||
// - Actual email sending or webhook integration
|
||||
let formStatus: "idle" | "success" | "error" = "idle";
|
||||
let formMessage = "";
|
||||
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const formData = await Astro.request.formData();
|
||||
const name = formData.get("name")?.toString() || "";
|
||||
const email = formData.get("email")?.toString() || "";
|
||||
const company = formData.get("company")?.toString() || "";
|
||||
const message = formData.get("message")?.toString() || "";
|
||||
|
||||
if (!name || !email || !message) {
|
||||
formStatus = "error";
|
||||
formMessage = "Please fill in all required fields.";
|
||||
} else if (!email.includes("@")) {
|
||||
formStatus = "error";
|
||||
formMessage = "Please enter a valid email address.";
|
||||
} else {
|
||||
// TODO: Replace with actual email/webhook integration
|
||||
console.log("Contact form submission:", {
|
||||
name,
|
||||
email,
|
||||
company,
|
||||
message,
|
||||
});
|
||||
formStatus = "success";
|
||||
formMessage =
|
||||
"Thanks for reaching out! We'll get back to you within 24 hours.";
|
||||
}
|
||||
} catch {
|
||||
formStatus = "error";
|
||||
formMessage = "Something went wrong. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
const pageContent = page?.data.content;
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Contact"
|
||||
description="Have questions? Want a demo? We'd love to hear from you."
|
||||
>
|
||||
{pageContent && <MarketingBlocks value={pageContent} />}
|
||||
|
||||
<section class="contact-form-section section">
|
||||
<div class="container">
|
||||
<div class="contact-grid">
|
||||
<div class="contact-info">
|
||||
<h2>Talk to our team</h2>
|
||||
<p>Fill out the form and we'll be in touch within 24 hours.</p>
|
||||
|
||||
<div class="contact-methods">
|
||||
<div class="contact-method">
|
||||
<div class="contact-icon">
|
||||
<i class="ph ph-envelope" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="contact-method-content">
|
||||
<h4>Email</h4>
|
||||
<a href="mailto:hello@acme.example">hello@acme.example</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-method">
|
||||
<div class="contact-icon">
|
||||
<i class="ph ph-lifebuoy" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="contact-method-content">
|
||||
<h4>Support</h4>
|
||||
<a href="mailto:support@acme.example">support@acme.example</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-method">
|
||||
<div class="contact-icon">
|
||||
<i class="ph ph-currency-dollar" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="contact-method-content">
|
||||
<h4>Sales</h4>
|
||||
<a href="mailto:sales@acme.example">sales@acme.example</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-form-wrapper">
|
||||
{
|
||||
formStatus === "success" ? (
|
||||
<div class="form-success">
|
||||
<div class="success-icon">✓</div>
|
||||
<h3>Message Sent!</h3>
|
||||
<p>{formMessage}</p>
|
||||
<a href="/contact" class="btn btn-secondary">
|
||||
Send another message
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form method="POST" class="contact-form">
|
||||
{formStatus === "error" && (
|
||||
<div class="form-error">
|
||||
<p>{formMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label for="name">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="Your name"
|
||||
autocomplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="email">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="you@company.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="company">Company</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company"
|
||||
name="company"
|
||||
placeholder="Your company"
|
||||
autocomplete="organization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="message">Message *</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows="5"
|
||||
placeholder="Tell us about your project or question..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
Send Message
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.contact-form-section {
|
||||
padding-bottom: var(--spacing-5xl);
|
||||
}
|
||||
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr;
|
||||
gap: var(--spacing-4xl);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.contact-info h2 {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.contact-info > p {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.contact-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.contact-method {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.contact-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
color: white;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary),
|
||||
var(--color-accent)
|
||||
);
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-method-content h4 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.contact-method-content a {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.contact-method-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.contact-form-wrapper {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field textarea {
|
||||
padding: var(--spacing-md);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-field input:focus,
|
||||
.form-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.form-field input::placeholder,
|
||||
.form-field textarea::placeholder {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.form-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: #dc2626;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-error {
|
||||
color: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
.form-success {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
color: white;
|
||||
background: var(--color-success);
|
||||
border-radius: var(--radius-full);
|
||||
margin: 0 auto var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-success h3 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-success p {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
templates/marketing/src/pages/index.astro
Normal file
51
templates/marketing/src/pages/index.astro
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import MarketingBlocks from "../components/MarketingBlocks.astro";
|
||||
|
||||
const { entry: page, cacheHint } = await getEmDashEntry("pages", "home");
|
||||
|
||||
try {
|
||||
Astro.cache.set(cacheHint);
|
||||
} catch {}
|
||||
|
||||
const pageTitle = page?.data.title;
|
||||
const pageContent = page?.data.content;
|
||||
---
|
||||
|
||||
<Base
|
||||
title={pageTitle !== "Home" ? pageTitle : undefined}
|
||||
description="Build products people actually want. The all-in-one platform for modern teams."
|
||||
>
|
||||
{
|
||||
pageContent ? (
|
||||
<MarketingBlocks value={pageContent} />
|
||||
) : (
|
||||
<div class="empty-state">
|
||||
<h1>Welcome to Acme</h1>
|
||||
<p>Edit the home page content in the admin to get started.</p>
|
||||
<a href="/_emdash/admin" class="btn btn-primary">
|
||||
Open Admin
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-5xl) var(--spacing-lg);
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-state h1 {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
</style>
|
||||
50
templates/marketing/src/pages/pricing.astro
Normal file
50
templates/marketing/src/pages/pricing.astro
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import MarketingBlocks from "../components/MarketingBlocks.astro";
|
||||
|
||||
const { entry: page, cacheHint } = await getEmDashEntry("pages", "pricing");
|
||||
|
||||
try {
|
||||
Astro.cache.set(cacheHint);
|
||||
} catch {}
|
||||
|
||||
const pageContent = page?.data.content;
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Pricing"
|
||||
description="Simple, transparent pricing. No hidden fees. No surprises."
|
||||
>
|
||||
{
|
||||
pageContent ? (
|
||||
<MarketingBlocks value={pageContent} />
|
||||
) : (
|
||||
<div class="empty-state">
|
||||
<h1>Pricing</h1>
|
||||
<p>Add pricing content in the admin.</p>
|
||||
<a href="/_emdash/admin" class="btn btn-primary">
|
||||
Open Admin
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-5xl) var(--spacing-lg);
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-state h1 {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
</style>
|
||||
5
templates/marketing/tsconfig.json
Normal file
5
templates/marketing/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user