first commit
This commit is contained in:
33
demos/cloudflare/src/pages/404.astro
Normal file
33
demos/cloudflare/src/pages/404.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base title="Page not found">
|
||||
<div class="not-found">
|
||||
<h1>404</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="/">Go back home</a>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: var(--spacing-24) var(--spacing-6);
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: var(--font-size-5xl);
|
||||
margin-bottom: var(--spacing-2);
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
.not-found p {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.not-found a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
31
demos/cloudflare/src/pages/als-test.astro
Normal file
31
demos/cloudflare/src/pages/als-test.astro
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
/**
|
||||
* ALS request context test — validates AsyncLocalStorage propagation
|
||||
* from EmDash middleware through Astro's render pipeline on workerd.
|
||||
*
|
||||
* Test:
|
||||
* curl http://localhost:4321/als-test
|
||||
* → hasContext: false (fast path, no ALS)
|
||||
*
|
||||
* curl -b "emdash-edit-mode=true" http://localhost:4321/als-test
|
||||
* → hasContext: true, editMode: false (no auth)
|
||||
*
|
||||
* Remove this page once validated.
|
||||
*/
|
||||
import { getRequestContext } from "emdash/request-context";
|
||||
|
||||
const ctx = getRequestContext();
|
||||
---
|
||||
|
||||
<html>
|
||||
<head><title>ALS Test (Cloudflare)</title></head>
|
||||
<body>
|
||||
<h1>ALS Request Context Test</h1>
|
||||
<pre
|
||||
id="result">{JSON.stringify({
|
||||
hasContext: ctx !== undefined,
|
||||
editMode: ctx?.editMode ?? false,
|
||||
preview: ctx?.preview ?? null,
|
||||
}, null, 2)}</pre>
|
||||
</body>
|
||||
</html>
|
||||
117
demos/cloudflare/src/pages/category/[slug].astro
Normal file
117
demos/cloudflare/src/pages/category/[slug].astro
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
import { getTerm, getEmDashCollection, getEntryTerms } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import PostCard from "../../components/PostCard.astro";
|
||||
import { getReadingTime } from "../../utils/reading-time";
|
||||
|
||||
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" },
|
||||
});
|
||||
|
||||
// Fetch tags for display on each post card
|
||||
const filteredPosts = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
const tags = await getEntryTerms("posts", post.data.id, "tag");
|
||||
return { post, tags };
|
||||
})
|
||||
);
|
||||
---
|
||||
|
||||
<Base title={`${term.label} posts`} description={`All posts in ${term.label}`}>
|
||||
<section class="archive-section">
|
||||
<header class="archive-header">
|
||||
<span class="archive-label">Category</span>
|
||||
<h1 class="archive-title">{term.label}</h1>
|
||||
<p class="archive-count">
|
||||
{filteredPosts.length}
|
||||
{filteredPosts.length === 1 ? "post" : "posts"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{
|
||||
filteredPosts.length === 0 ? (
|
||||
<p class="no-posts">No posts in this category yet.</p>
|
||||
) : (
|
||||
<div class="posts-grid">
|
||||
{filteredPosts.map(({ post, tags }) => (
|
||||
<PostCard
|
||||
title={post.data.title}
|
||||
excerpt={post.data.excerpt}
|
||||
featuredImage={post.data.featured_image}
|
||||
href={`/posts/${post.id}`}
|
||||
date={post.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(post.data.content)}
|
||||
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.archive-section {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
}
|
||||
|
||||
.archive-header {
|
||||
margin-bottom: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.archive-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.archive-title {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.archive-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-12) var(--spacing-8);
|
||||
}
|
||||
|
||||
.no-posts {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
448
demos/cloudflare/src/pages/index.astro
Normal file
448
demos/cloudflare/src/pages/index.astro
Normal file
@@ -0,0 +1,448 @@
|
||||
---
|
||||
import { getEmDashCollection, getEntryTerms } from "emdash";
|
||||
import { Image } from "emdash/ui";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import PostCard from "../components/PostCard.astro";
|
||||
import { getReadingTime } from "../utils/reading-time";
|
||||
|
||||
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Find the first post with a featured image for the hero
|
||||
const featuredPost = sortedPosts.find((p) => p.data.featured_image);
|
||||
const featuredIndex = featuredPost ? sortedPosts.indexOf(featuredPost) : -1;
|
||||
|
||||
// Get remaining posts (exclude featured if found, limit to 6 for grid)
|
||||
const gridPosts = sortedPosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
|
||||
|
||||
// Total posts shown = featured (if any) + grid posts
|
||||
const totalShown = (featuredPost ? 1 : 0) + gridPosts.length;
|
||||
const hasMorePosts = sortedPosts.length > totalShown;
|
||||
|
||||
// Fetch tags for featured post (bylines are already hydrated by getEmDashCollection)
|
||||
let featuredTags: Array<{ slug: string; label: string }> = [];
|
||||
const featuredBylines = featuredPost?.data.bylines ?? [];
|
||||
if (featuredPost) {
|
||||
const tags = await getEntryTerms("posts", featuredPost.data.id, "tag");
|
||||
featuredTags = tags.map((t) => ({ slug: t.slug, label: t.label }));
|
||||
}
|
||||
|
||||
// Fetch tags for grid posts (bylines are already hydrated by getEmDashCollection)
|
||||
const gridPostsWithTags = await Promise.all(
|
||||
gridPosts.map(async (post) => {
|
||||
const tags = await getEntryTerms("posts", post.data.id, "tag");
|
||||
const bylines = post.data.bylines ?? [];
|
||||
return {
|
||||
post,
|
||||
tags: tags.map((t) => ({ slug: t.slug, label: t.label })),
|
||||
bylines,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Format date helper
|
||||
function formatDate(date: Date | null | undefined) {
|
||||
if (!date) return null;
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
---
|
||||
|
||||
<Base title="My Blog" description="Welcome to my blog">
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<section class="empty-state">
|
||||
<h2>No posts yet</h2>
|
||||
<p>Create your first post in the admin panel.</p>
|
||||
<a href="/_emdash/admin/content/posts/new" class="btn">
|
||||
Create a post
|
||||
</a>
|
||||
</section>
|
||||
) : (
|
||||
<div class="home-content">
|
||||
{/* Featured Post - Side by side */}
|
||||
{featuredPost && (
|
||||
<section class="featured-section">
|
||||
<div class="featured-grid">
|
||||
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
|
||||
<div class="featured-image">
|
||||
<Image image={featuredPost.data.featured_image} />
|
||||
</div>
|
||||
</a>
|
||||
<div class="featured-content">
|
||||
<div class="featured-meta">
|
||||
{featuredBylines.length > 0 && (
|
||||
<>
|
||||
<div class="featured-bylines">
|
||||
{featuredBylines.slice(0, 2).map((credit, index) => (
|
||||
<>
|
||||
{index > 0 && <span class="byline-sep">,</span>}
|
||||
<span class="featured-byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="featured-byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<span class="featured-byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
{featuredBylines.length > 2 && (
|
||||
<span class="byline-more">
|
||||
+{featuredBylines.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="meta-dot" />
|
||||
</>
|
||||
)}
|
||||
{formatDate(featuredPost.data.publishedAt) && (
|
||||
<time>{formatDate(featuredPost.data.publishedAt)}</time>
|
||||
)}
|
||||
<span class="meta-dot" />
|
||||
<span>
|
||||
{getReadingTime(featuredPost.data.content)} min read
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/posts/${featuredPost.id}`}
|
||||
class="featured-title-link"
|
||||
>
|
||||
<h1 class="featured-title">{featuredPost.data.title}</h1>
|
||||
</a>
|
||||
{featuredPost.data.excerpt && (
|
||||
<p class="featured-excerpt">{featuredPost.data.excerpt}</p>
|
||||
)}
|
||||
{featuredTags.length > 0 && (
|
||||
<div class="featured-tags">
|
||||
{featuredTags.map((tag) => (
|
||||
<a href={`/tag/${tag.slug}`} class="featured-tag">
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Latest Posts */}
|
||||
{gridPostsWithTags.length > 0 && (
|
||||
<section class="posts-section">
|
||||
<header class="section-header">
|
||||
<h2 class="section-title">Latest</h2>
|
||||
{hasMorePosts && (
|
||||
<a href="/posts" class="section-link">
|
||||
View all
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
<div class="posts-grid">
|
||||
{gridPostsWithTags.map(({ post, tags, bylines }) => (
|
||||
<PostCard
|
||||
title={post.data.title ?? "Untitled"}
|
||||
excerpt={post.data.excerpt}
|
||||
featuredImage={post.data.featured_image}
|
||||
href={`/posts/${post.id}`}
|
||||
date={post.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(post.data.content)}
|
||||
tags={tags}
|
||||
bylines={bylines}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.home-content {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-16) var(--spacing-6);
|
||||
}
|
||||
|
||||
/* Featured Section - Side by side */
|
||||
.featured-section {
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
.featured-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.featured-image-link {
|
||||
grid-column: 1 / 3;
|
||||
display: block;
|
||||
/* Extend to viewport edge, but cap at -6rem minimum extension */
|
||||
margin-left: min(
|
||||
-6rem,
|
||||
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
|
||||
);
|
||||
}
|
||||
|
||||
.featured-image {
|
||||
overflow: hidden;
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.featured-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.featured-image-link:hover .featured-image img,
|
||||
.featured-grid:has(.featured-title-link:hover) .featured-image img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.featured-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.featured-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-3);
|
||||
row-gap: var(--spacing-1);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Featured bylines */
|
||||
.featured-bylines {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.featured-byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.featured-byline-avatar {
|
||||
width: var(--avatar-size-md);
|
||||
height: var(--avatar-size-md);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.featured-byline-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.byline-sep {
|
||||
color: var(--color-muted);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.byline-more {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.featured-title-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.featured-title {
|
||||
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
|
||||
font-weight: 700;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.featured-title-link:hover .featured-title,
|
||||
.featured-grid:has(.featured-image-link:hover) .featured-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.featured-excerpt {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.featured-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.featured-tag {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.featured-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Section header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: var(--spacing-8);
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.section-link {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.section-link:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Posts Grid */
|
||||
.posts-section {
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-12) var(--spacing-8);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
text-align: center;
|
||||
padding: var(--spacing-20) var(--spacing-6);
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
margin-top: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.home-content {
|
||||
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
}
|
||||
|
||||
.featured-image-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.featured-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.featured-image {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.featured-image img {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-8) var(--spacing-6);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.featured-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
demos/cloudflare/src/pages/pages/[slug].astro
Normal file
108
demos/cloudflare/src/pages/pages/[slug].astro
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
|
||||
if (!slug) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);
|
||||
|
||||
if (!page) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
Astro.cache.set(cacheHint);
|
||||
---
|
||||
|
||||
<Base
|
||||
title={page.data.title}
|
||||
content={{ collection: "pages", id: page.data.id, slug }}
|
||||
>
|
||||
<article class="page-article">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title" {...page.edit.title}>{page.data.title}</h1>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
<PortableText value={page.data.content} />
|
||||
</div>
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.page-article {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-16) var(--spacing-6) var(--spacing-16);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
|
||||
font-weight: 700;
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.page-content :global(p) {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.page-content :global(h2) {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.page-content :global(h3) {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.page-content :global(blockquote) {
|
||||
margin: 1.5em 0;
|
||||
padding-left: var(--spacing-6);
|
||||
border-left: 3px solid var(--color-border);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.page-content :global(pre) {
|
||||
margin: 1.5em 0;
|
||||
padding: var(--spacing-4);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.page-content :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--color-surface);
|
||||
padding: 0.15em 0.3em;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.page-content :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-content :global(ul),
|
||||
.page-content :global(ol) {
|
||||
margin-bottom: 1.5em;
|
||||
padding-left: var(--spacing-5);
|
||||
}
|
||||
|
||||
.page-content :global(li) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
</style>
|
||||
958
demos/cloudflare/src/pages/posts/[slug].astro
Normal file
958
demos/cloudflare/src/pages/posts/[slug].astro
Normal file
@@ -0,0 +1,958 @@
|
||||
---
|
||||
import {
|
||||
getEmDashEntry,
|
||||
getEmDashCollection,
|
||||
getEntryTerms,
|
||||
getSeoMeta,
|
||||
} from "emdash";
|
||||
import {
|
||||
Image,
|
||||
PortableText,
|
||||
Comments,
|
||||
CommentForm,
|
||||
WidgetArea,
|
||||
} from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import PostCard from "../../components/PostCard.astro";
|
||||
import { getReadingTime } from "../../utils/reading-time";
|
||||
|
||||
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);
|
||||
|
||||
// Get featured image URL for OG fallback
|
||||
// The image may have src (external) or meta.storageKey (local)
|
||||
function getImageUrl(img: unknown): string | undefined {
|
||||
if (!img || typeof img !== "object") return undefined;
|
||||
const image = img as Record<string, unknown>;
|
||||
// Check for direct src
|
||||
if (typeof image.src === "string" && image.src) {
|
||||
return image.src.startsWith("http")
|
||||
? image.src
|
||||
: `${Astro.url.origin}${image.src}`;
|
||||
}
|
||||
// Build from storageKey for local images
|
||||
const meta = image.meta as Record<string, unknown> | undefined;
|
||||
const storageKey =
|
||||
(typeof meta?.storageKey === "string" ? meta.storageKey : undefined) ||
|
||||
(typeof image.id === "string" ? image.id : undefined);
|
||||
if (storageKey) {
|
||||
return `${Astro.url.origin}/_emdash/api/media/file/${storageKey}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const featuredImageUrl = getImageUrl(post.data.featured_image);
|
||||
|
||||
// Generate SEO meta from content
|
||||
const seo = getSeoMeta(post, {
|
||||
siteTitle: "My Blog",
|
||||
siteUrl: Astro.url.origin,
|
||||
path: `/posts/${slug}`,
|
||||
defaultOgImage: featuredImageUrl,
|
||||
});
|
||||
|
||||
// Get tags for this post
|
||||
// Note: post.id is the slug, post.data.id is the database ULID
|
||||
const tags = await getEntryTerms("posts", post.data.id, "tag");
|
||||
|
||||
// Bylines are already hydrated by getEmDashEntry
|
||||
const bylines = post.data.bylines ?? [];
|
||||
|
||||
// Get reading time
|
||||
const readingTime = getReadingTime(post.data.content);
|
||||
|
||||
// Get other posts for "More posts" section, with their tags
|
||||
// Fetch a few extra in case the current post is among them
|
||||
const { entries: recentPosts } = await getEmDashCollection("posts", {
|
||||
orderBy: { published_at: "desc" },
|
||||
limit: 4,
|
||||
});
|
||||
const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3);
|
||||
|
||||
// Fetch tags for related posts (bylines are already hydrated by getEmDashCollection)
|
||||
const otherPostsWithTags = await Promise.all(
|
||||
otherPosts.map(async (p) => {
|
||||
const postTags = await getEntryTerms("posts", p.data.id, "tag");
|
||||
const postBylines = p.data.bylines ?? [];
|
||||
return { post: p, tags: postTags, bylines: postBylines };
|
||||
})
|
||||
);
|
||||
|
||||
const publishDate =
|
||||
post.data.publishedAt?.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}) ?? null;
|
||||
---
|
||||
|
||||
<Base
|
||||
title={seo.title}
|
||||
description={seo.description}
|
||||
image={seo.ogImage}
|
||||
canonical={seo.canonical}
|
||||
robots={seo.robots}
|
||||
type="article"
|
||||
publishedTime={post.data.publishedAt?.toISOString() ?? null}
|
||||
modifiedTime={post.data.updatedAt.toISOString()}
|
||||
content={{ collection: "posts", id: post.data.id, slug }}
|
||||
>
|
||||
<article class="article">
|
||||
{/* Hero: Full-width featured image */}
|
||||
{
|
||||
post.data.featured_image && (
|
||||
<div class="article-hero" {...post.edit.featured_image}>
|
||||
<Image image={post.data.featured_image} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Three-column layout */}
|
||||
<div class="article-grid">
|
||||
{/* Left gutter: Meta information */}
|
||||
<aside class="article-meta-col">
|
||||
<div class="meta-sticky">
|
||||
{
|
||||
bylines.length > 0 && (
|
||||
<div class="meta-block byline-block">
|
||||
<span class="meta-label">
|
||||
{bylines.length === 1 ? "Author" : "Authors"}
|
||||
</span>
|
||||
<div class="bylines">
|
||||
{bylines.map((credit) => (
|
||||
<div class="byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<div class="byline-info">
|
||||
<span class="byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
{credit.roleLabel && (
|
||||
<span class="byline-role">{credit.roleLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
publishDate && (
|
||||
<div class="meta-block">
|
||||
<span class="meta-label">Published</span>
|
||||
<time class="meta-value">{publishDate}</time>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="meta-block">
|
||||
<span class="meta-label">Reading time</span>
|
||||
<span class="meta-value">{readingTime} min</span>
|
||||
</div>
|
||||
{
|
||||
tags.length > 0 && (
|
||||
<div class="meta-block">
|
||||
<span class="meta-label">Tags</span>
|
||||
<div class="meta-tags">
|
||||
{tags.map((t) => (
|
||||
<a href={`/tag/${t.slug}`} class="meta-tag">
|
||||
{t.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div class="article-main">
|
||||
<header class="article-header">
|
||||
<div class="article-meta">
|
||||
{
|
||||
bylines.length > 0 && (
|
||||
<>
|
||||
<span class="article-meta-byline">
|
||||
{bylines.map((credit, i) => (
|
||||
<>
|
||||
{i > 0 && ", "}
|
||||
{credit.byline.displayName}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
<span class="meta-dot" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
publishDate && (
|
||||
<>
|
||||
<time>{publishDate}</time>
|
||||
<span class="meta-dot" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span>{readingTime} min read</span>
|
||||
</div>
|
||||
<h1 class="article-title" {...post.edit.title}>{post.data.title}</h1>
|
||||
{
|
||||
post.data.excerpt && (
|
||||
<p class="article-excerpt">{post.data.excerpt}</p>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="article-content">
|
||||
<PortableText value={post.data.content} />
|
||||
</div>
|
||||
|
||||
<div class="article-comments">
|
||||
<Comments collection="posts" contentId={post.data.id} threaded />
|
||||
<CommentForm collection="posts" contentId={post.data.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right gutter: TOC + Sidebar widgets */}
|
||||
<aside class="article-sidebar">
|
||||
<div class="sidebar-sticky">
|
||||
<nav class="toc" aria-label="Table of contents">
|
||||
<h4 class="toc-title">On this page</h4>
|
||||
<div class="toc-content" id="toc-content">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-widgets">
|
||||
<WidgetArea name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{
|
||||
otherPostsWithTags.length > 0 && (
|
||||
<section class="more-posts">
|
||||
<div class="more-inner">
|
||||
<h2 class="more-title">Continue reading</h2>
|
||||
<div class="more-grid">
|
||||
{otherPostsWithTags.map(
|
||||
({ post: p, tags: postTags, bylines: postBylines }) => (
|
||||
<PostCard
|
||||
title={p.data.title}
|
||||
excerpt={p.data.excerpt}
|
||||
featuredImage={p.data.featured_image}
|
||||
href={`/posts/${p.id}`}
|
||||
date={p.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(p.data.content)}
|
||||
tags={postTags.map((t) => ({ slug: t.slug, label: t.label }))}
|
||||
bylines={postBylines}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
// Build table of contents from h2/h3 headings
|
||||
function buildToc() {
|
||||
const content = document.querySelector(".article-content");
|
||||
const tocContainer = document.getElementById("toc-content");
|
||||
if (!content || !tocContainer) return;
|
||||
|
||||
const headings = content.querySelectorAll("h2, h3");
|
||||
if (headings.length === 0) {
|
||||
// Hide TOC if no headings
|
||||
const toc = document.querySelector(".toc") as HTMLElement | null;
|
||||
if (toc) toc.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "toc-list";
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
// Add ID if missing
|
||||
if (!heading.id) {
|
||||
heading.id = `heading-${index}`;
|
||||
}
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.className =
|
||||
heading.tagName === "H3" ? "toc-item toc-item--nested" : "toc-item";
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = `#${heading.id}`;
|
||||
link.className = "toc-link";
|
||||
link.textContent = heading.textContent;
|
||||
|
||||
li.appendChild(link);
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(list);
|
||||
|
||||
// Highlight current section on scroll
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const id = entry.target.id;
|
||||
const link = tocContainer.querySelector(`a[href="#${id}"]`);
|
||||
if (link) {
|
||||
if (entry.isIntersecting) {
|
||||
tocContainer
|
||||
.querySelectorAll(".toc-link")
|
||||
.forEach((l) => l.classList.remove("active"));
|
||||
link.classList.add("active");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-80px 0px -80% 0px" }
|
||||
);
|
||||
|
||||
headings.forEach((heading) => observer.observe(heading));
|
||||
}
|
||||
|
||||
buildToc();
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* Article container */
|
||||
.article {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Hero image - full width within container */
|
||||
.article-hero {
|
||||
margin: var(--spacing-16) var(--spacing-6);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-hero img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 500px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Three-column grid */
|
||||
.article-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--meta-col-width) minmax(0, var(--content-width))
|
||||
var(--gutter-width);
|
||||
gap: var(--spacing-10);
|
||||
justify-content: center;
|
||||
padding: 0 var(--spacing-6);
|
||||
margin: var(--spacing-16) 0;
|
||||
}
|
||||
|
||||
/* Left column: Meta */
|
||||
.article-meta-col {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.meta-sticky {
|
||||
position: sticky;
|
||||
top: calc(var(--nav-height) + var(--spacing-8));
|
||||
}
|
||||
|
||||
.meta-block {
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.meta-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Byline styles */
|
||||
.bylines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.byline-avatar {
|
||||
width: var(--avatar-size-lg);
|
||||
height: var(--avatar-size-lg);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.byline-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.byline-name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.byline-role {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Main content column */
|
||||
.article-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-header {
|
||||
margin-bottom: var(--spacing-10);
|
||||
}
|
||||
|
||||
.article-header .article-meta {
|
||||
display: none;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-3);
|
||||
row-gap: var(--spacing-1);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.article-meta-byline {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: clamp(2rem, 5vw, var(--font-size-5xl));
|
||||
font-weight: 700;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.article-excerpt {
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Article content typography */
|
||||
.article-content {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.article-content :global(p) {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.article-content :global(h2) {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-top: 2.5em;
|
||||
margin-bottom: 0.75em;
|
||||
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
|
||||
}
|
||||
|
||||
.article-content :global(h3) {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.5em;
|
||||
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
|
||||
}
|
||||
|
||||
.article-content :global(blockquote) {
|
||||
margin: 2em 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border-left: 3px solid var(--color-border);
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.article-content :global(pre) {
|
||||
margin: 2em 0;
|
||||
padding: var(--spacing-5);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.article-content :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--color-surface);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.article-content :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.article-content :global(ul),
|
||||
.article-content :global(ol) {
|
||||
margin-bottom: 1.5em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.article-content :global(li) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.article-content :global(img) {
|
||||
margin: 2em 0;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.article-content :global(hr) {
|
||||
margin: 3em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.article-content :global(a) {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
.article-content :global(a:hover) {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Right column: TOC + Sidebar */
|
||||
.article-sidebar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: sticky;
|
||||
top: calc(var(--nav-height) + var(--spacing-8));
|
||||
}
|
||||
|
||||
.toc {
|
||||
margin-bottom: var(--spacing-8);
|
||||
padding-bottom: var(--spacing-6);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.toc-title {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-list) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-item) {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-item--nested) {
|
||||
padding-left: var(--spacing-3);
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-link) {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
padding: var(--spacing-1) 0;
|
||||
transition: color var(--transition-fast);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.toc-content :global(.toc-link:hover),
|
||||
.toc-content :global(.toc-link.active) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Sidebar widgets */
|
||||
.sidebar-widgets :global(.widget-area) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget) {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget__title) {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget__content) {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* Sidebar search widget */
|
||||
.sidebar-widgets :global(.widget-search) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-search__input) {
|
||||
width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-search__input)::placeholder {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-search__input):focus,
|
||||
.sidebar-widgets :global(.widget-search__input):focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-ring);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-search__button) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sidebar categories widget */
|
||||
.sidebar-widgets :global(.widget-categories) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories li) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-2) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories li:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories__link) {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories__link:hover) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-categories__count) {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface);
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Sidebar tags widget - pill style */
|
||||
.sidebar-widgets :global(.widget-tags__cloud) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-tags__cloud li) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-tags__link) {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-tags__link:hover) {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-tags__count) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sidebar recent posts widget */
|
||||
.sidebar-widgets :global(.widget-recent-posts) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-recent-posts li) {
|
||||
padding: var(--spacing-2) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-recent-posts li:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-recent-posts a) {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-recent-posts a:hover) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Sidebar archives widget */
|
||||
.sidebar-widgets :global(.widget-archives) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives li) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-2) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives li:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives__link) {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives__link:hover) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar-widgets :global(.widget-archives__count) {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface);
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Comments section */
|
||||
.article-comments {
|
||||
margin-top: var(--spacing-16);
|
||||
padding-top: var(--spacing-10);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comments) {
|
||||
--ec-comment-border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comments-heading) {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-author) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-date) {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-body) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-form-field input),
|
||||
.article-comments :global(.ec-comment-form-field textarea) {
|
||||
background: var(--color-surface) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-user-info) {
|
||||
background: var(--color-surface) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
.article-comments :global(.ec-comment-form-submit) {
|
||||
background: var(--color-accent) !important;
|
||||
color: var(--color-on-accent) !important;
|
||||
}
|
||||
|
||||
/* More posts section */
|
||||
.more-posts {
|
||||
background: var(--color-bg-subtle);
|
||||
padding: var(--spacing-16) 0;
|
||||
margin-top: var(--spacing-16);
|
||||
}
|
||||
|
||||
.more-inner {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-6);
|
||||
}
|
||||
|
||||
.more-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-10);
|
||||
}
|
||||
|
||||
.more-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(5, auto);
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1100px) {
|
||||
.article-grid {
|
||||
grid-template-columns: minmax(0, var(--content-width));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.article-meta-col,
|
||||
.article-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.article-header .article-meta {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.article-hero {
|
||||
margin: var(--spacing-4) var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.article-grid {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.more-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.article-title {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.more-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
268
demos/cloudflare/src/pages/posts/index.astro
Normal file
268
demos/cloudflare/src/pages/posts/index.astro
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
import { getEmDashCollection, getEntryTerms } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import { getReadingTime } from "../../utils/reading-time";
|
||||
|
||||
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Fetch tags for each post (bylines are already hydrated by getEmDashCollection)
|
||||
const postsWithTags = await Promise.all(
|
||||
sortedPosts.map(async (post) => {
|
||||
const tags = await getEntryTerms("posts", post.data.id, "tag");
|
||||
const bylines = post.data.bylines ?? [];
|
||||
return { post, tags, bylines };
|
||||
})
|
||||
);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
---
|
||||
|
||||
<Base title="All Posts" description="Browse all blog posts">
|
||||
<div class="posts-page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">All Posts</h1>
|
||||
<p class="page-description">
|
||||
{posts.length}
|
||||
{posts.length === 1 ? "article" : "articles"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{
|
||||
sortedPosts.length === 0 ? (
|
||||
<p class="empty">No posts yet.</p>
|
||||
) : (
|
||||
<div class="posts-list">
|
||||
{postsWithTags.map(({ post, tags, bylines }) => (
|
||||
<article class="post-item">
|
||||
<a href={`/posts/${post.id}`} class="post-link">
|
||||
<div class="post-meta">
|
||||
{bylines.length > 0 && (
|
||||
<>
|
||||
<div class="post-bylines">
|
||||
{bylines.slice(0, 2).map((credit, index) => (
|
||||
<>
|
||||
{index > 0 && <span class="byline-sep">,</span>}
|
||||
<span class="post-byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="post-byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<span class="post-byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
{bylines.length > 2 && (
|
||||
<span class="byline-more">+{bylines.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="meta-dot" />
|
||||
</>
|
||||
)}
|
||||
{post.data.publishedAt && (
|
||||
<time>{formatDate(post.data.publishedAt)}</time>
|
||||
)}
|
||||
{post.data.publishedAt && <span class="meta-dot" />}
|
||||
<span>{getReadingTime(post.data.content)} min read</span>
|
||||
</div>
|
||||
<h2 class="post-title">{post.data.title}</h2>
|
||||
{post.data.excerpt && (
|
||||
<p class="post-excerpt">{post.data.excerpt}</p>
|
||||
)}
|
||||
</a>
|
||||
{tags.length > 0 && (
|
||||
<div class="post-tags">
|
||||
{tags.slice(0, 3).map((t) => (
|
||||
<a href={`/tag/${t.slug}`} class="post-tag">
|
||||
{t.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.posts-page {
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-12);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
padding: var(--spacing-8) 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.post-item:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.post-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.post-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Post bylines */
|
||||
.post-bylines {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.post-byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.post-byline-avatar {
|
||||
width: var(--avatar-size-sm);
|
||||
height: var(--avatar-size-sm);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.post-byline-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.byline-sep {
|
||||
color: var(--color-muted);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.byline-more {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-snug);
|
||||
letter-spacing: var(--tracking-snug);
|
||||
margin-bottom: var(--spacing-2);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.post-link:hover .post-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.post-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.post-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.posts-page {
|
||||
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
70
demos/cloudflare/src/pages/rss.xml.ts
Normal file
70
demos/cloudflare/src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
const siteTitle = "My Blog";
|
||||
const siteDescription = "A blog about software, design, and the occasional stray thought.";
|
||||
|
||||
export const GET: APIRoute = async ({ site, url }) => {
|
||||
const siteUrl = site?.toString() || url.origin;
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
orderBy: { published_at: "desc" },
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const items = posts
|
||||
.map((post) => {
|
||||
if (!post.data.publishedAt) return null;
|
||||
const pubDate = post.data.publishedAt.toUTCString();
|
||||
|
||||
const postUrl = `${siteUrl}/posts/${post.id}`;
|
||||
const title = escapeXml(post.data.title || "Untitled");
|
||||
const description = escapeXml(post.data.excerpt || "");
|
||||
|
||||
return ` <item>
|
||||
<title>${title}</title>
|
||||
<link>${postUrl}</link>
|
||||
<guid isPermaLink="true">${postUrl}</guid>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<description>${description}</description>
|
||||
</item>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const rss = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escapeXml(siteTitle)}</title>
|
||||
<description>${escapeXml(siteDescription)}</description>
|
||||
<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>`;
|
||||
|
||||
return new Response(rss, {
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const XML_ESCAPE_PATTERNS = [
|
||||
[/&/g, "&"],
|
||||
[/</g, "<"],
|
||||
[/>/g, ">"],
|
||||
[/"/g, """],
|
||||
[/'/g, "'"],
|
||||
] as const;
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
let result = str;
|
||||
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
481
demos/cloudflare/src/pages/sandbox-plugin-test.astro
Normal file
481
demos/cloudflare/src/pages/sandbox-plugin-test.astro
Normal file
@@ -0,0 +1,481 @@
|
||||
---
|
||||
/**
|
||||
* Sandbox Plugin Test Page
|
||||
*
|
||||
* Tests the full sandbox architecture:
|
||||
* 1. PluginBridge WorkerEntrypoint provides controlled DB access
|
||||
* 2. Sandbox gets a SERVICE BINDING to the bridge (not direct DB access)
|
||||
* 3. Bridge validates capabilities and scopes operations
|
||||
*/
|
||||
|
||||
interface TestResult {
|
||||
step: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
// Get Cloudflare context
|
||||
const cfContext = (Astro.locals as unknown as Record<string, unknown>).cfContext;
|
||||
// @ts-ignore - env typing
|
||||
const env = (await import("cloudflare:workers")).env;
|
||||
|
||||
// @ts-ignore
|
||||
const loader = env.LOADER;
|
||||
|
||||
if (!loader) {
|
||||
results.push({ step: "Check LOADER binding", success: false, error: "LOADER not available" });
|
||||
} else {
|
||||
results.push({ step: "Check LOADER binding", success: true });
|
||||
}
|
||||
|
||||
if (!cfContext) {
|
||||
results.push({ step: "Check cfContext", success: false, error: "cfContext not available" });
|
||||
} else {
|
||||
results.push({ step: "Check cfContext", success: true });
|
||||
}
|
||||
|
||||
// Check for ctx.exports (requires enable_ctx_exports compatibility flag)
|
||||
// @ts-ignore
|
||||
const exports = cfContext?.exports;
|
||||
if (!exports) {
|
||||
results.push({ step: "Check ctx.exports", success: false, error: "ctx.exports not available - need enable_ctx_exports flag" });
|
||||
} else {
|
||||
results.push({ step: "Check ctx.exports", success: true });
|
||||
}
|
||||
|
||||
// Check for PluginBridge export
|
||||
// @ts-ignore
|
||||
const PluginBridge = exports?.PluginBridge;
|
||||
if (!PluginBridge) {
|
||||
results.push({ step: "Check PluginBridge export", success: false, error: "PluginBridge not in ctx.exports" });
|
||||
} else {
|
||||
results.push({ step: "Check PluginBridge export", success: true });
|
||||
}
|
||||
|
||||
// Test the bridge directly (without sandbox first)
|
||||
if (PluginBridge) {
|
||||
try {
|
||||
// Create a bridge instance with props
|
||||
const bridge = PluginBridge({
|
||||
props: {
|
||||
pluginId: "test-plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
capabilities: ["read:content"],
|
||||
allowedHosts: [],
|
||||
storageCollections: ["logs"],
|
||||
}
|
||||
});
|
||||
|
||||
results.push({ step: "Create bridge instance", success: true });
|
||||
|
||||
// Test KV operations
|
||||
try {
|
||||
await bridge.kvSet("test-key", { hello: "world" });
|
||||
const value = await bridge.kvGet("test-key");
|
||||
await bridge.kvDelete("test-key");
|
||||
results.push({
|
||||
step: "Bridge KV operations",
|
||||
success: value?.hello === "world",
|
||||
data: { stored: { hello: "world" }, retrieved: value }
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Bridge KV operations", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// Test storage operations
|
||||
try {
|
||||
await bridge.storagePut("logs", "test-id", { message: "test log" });
|
||||
const value = await bridge.storageGet("logs", "test-id");
|
||||
await bridge.storageDelete("logs", "test-id");
|
||||
results.push({
|
||||
step: "Bridge storage operations",
|
||||
success: value?.message === "test log",
|
||||
data: value
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Bridge storage operations", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// Test undeclared storage (should fail)
|
||||
try {
|
||||
await bridge.storageGet("undeclared", "test");
|
||||
results.push({ step: "Block undeclared storage", success: false, error: "Should have thrown" });
|
||||
} catch (e) {
|
||||
results.push({
|
||||
step: "Block undeclared storage",
|
||||
success: true,
|
||||
data: { blocked: true, error: e instanceof Error ? e.message : String(e) }
|
||||
});
|
||||
}
|
||||
|
||||
// Test network without capability (should fail)
|
||||
try {
|
||||
await bridge.httpFetch("https://example.com");
|
||||
results.push({ step: "Block network without capability", success: false, error: "Should have thrown" });
|
||||
} catch (e) {
|
||||
results.push({
|
||||
step: "Block network without capability",
|
||||
success: true,
|
||||
data: { blocked: true, error: e instanceof Error ? e.message : String(e) }
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
results.push({ step: "Create bridge instance", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// Now test the full sandbox with Worker Loader
|
||||
if (loader && PluginBridge) {
|
||||
try {
|
||||
// Create a bridge binding for this specific plugin
|
||||
const bridgeBinding = PluginBridge({
|
||||
props: {
|
||||
pluginId: "sandbox-test",
|
||||
pluginVersion: "1.0.0",
|
||||
capabilities: ["read:content"],
|
||||
allowedHosts: [],
|
||||
storageCollections: ["logs"],
|
||||
}
|
||||
});
|
||||
|
||||
// Sandbox code that uses the bridge
|
||||
const sandboxCode = `
|
||||
import { WorkerEntrypoint } from "cloudflare:workers";
|
||||
|
||||
export default class PluginEntrypoint extends WorkerEntrypoint {
|
||||
async test() {
|
||||
return {
|
||||
success: true,
|
||||
message: "Hello from sandbox!",
|
||||
pluginId: this.env.PLUGIN_ID,
|
||||
};
|
||||
}
|
||||
|
||||
async testKv() {
|
||||
const bridge = this.env.BRIDGE;
|
||||
await bridge.kvSet("sandbox-test", { from: "sandbox" });
|
||||
const value = await bridge.kvGet("sandbox-test");
|
||||
await bridge.kvDelete("sandbox-test");
|
||||
return { success: true, value };
|
||||
}
|
||||
|
||||
async testStorage() {
|
||||
const bridge = this.env.BRIDGE;
|
||||
await bridge.storagePut("logs", "sandbox-log", { ts: Date.now() });
|
||||
const value = await bridge.storageGet("logs", "sandbox-log");
|
||||
await bridge.storageDelete("logs", "sandbox-log");
|
||||
return { success: true, value };
|
||||
}
|
||||
|
||||
async testBlockedStorage() {
|
||||
const bridge = this.env.BRIDGE;
|
||||
try {
|
||||
await bridge.storageGet("undeclared", "test");
|
||||
return { success: false, error: "Should have been blocked" };
|
||||
} catch (e) {
|
||||
return { success: true, blocked: true, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async testBlockedNetwork() {
|
||||
const bridge = this.env.BRIDGE;
|
||||
try {
|
||||
await bridge.httpFetch("https://example.com");
|
||||
return { success: false, error: "Should have been blocked" };
|
||||
} catch (e) {
|
||||
return { success: true, blocked: true, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ISOLATION TESTS - verify sandbox can't bypass bridge
|
||||
|
||||
async testDirectFetchBlocked() {
|
||||
// Sandbox has globalOutbound: null, so fetch should fail
|
||||
try {
|
||||
const resp = await fetch("https://example.com");
|
||||
return { success: false, error: "Direct fetch should be blocked but got: " + resp.status };
|
||||
} catch (e) {
|
||||
return { success: true, blocked: true, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async testNoDbBinding() {
|
||||
// Sandbox should NOT have DB binding - only BRIDGE
|
||||
const hasDb = !!this.env.DB;
|
||||
const hasMedia = !!this.env.MEDIA;
|
||||
const bindings = Object.keys(this.env);
|
||||
return {
|
||||
success: !hasDb && !hasMedia,
|
||||
hasDb,
|
||||
hasMedia,
|
||||
bindings,
|
||||
error: hasDb || hasMedia ? "Sandbox should not have direct DB/MEDIA bindings" : null
|
||||
};
|
||||
}
|
||||
|
||||
async testNoGlobals() {
|
||||
// Check that dangerous globals are not available
|
||||
const checks = {
|
||||
hasGlobalFetch: typeof globalThis.fetch === "function",
|
||||
// After globalOutbound: null, fetch exists but should fail
|
||||
};
|
||||
return { success: true, checks };
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Spawn the sandbox with bridge binding
|
||||
const worker = loader.get("sandbox-full-test-" + Date.now(), () => ({
|
||||
compatibilityDate: "2025-01-01",
|
||||
mainModule: "plugin.js",
|
||||
modules: {
|
||||
"plugin.js": { js: sandboxCode },
|
||||
},
|
||||
globalOutbound: null, // Block direct network
|
||||
env: {
|
||||
PLUGIN_ID: "sandbox-test",
|
||||
BRIDGE: bridgeBinding, // Pass bridge as service binding
|
||||
},
|
||||
}));
|
||||
|
||||
results.push({ step: "Spawn sandbox with bridge", success: true });
|
||||
|
||||
// Worker Loader RPC methods are dynamically defined in sandbox code.
|
||||
// Cast entrypoint to allow calling them without TS errors.
|
||||
type SandboxRpc = Record<string, (...args: unknown[]) => Promise<Record<string, unknown>>>;
|
||||
const getEp = () => worker.getEntrypoint("default") as unknown as SandboxRpc;
|
||||
|
||||
// Test basic RPC
|
||||
try {
|
||||
const ep = getEp();
|
||||
const testResult = await ep.test();
|
||||
results.push({
|
||||
step: "Sandbox basic RPC",
|
||||
success: testResult?.success === true,
|
||||
data: testResult
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Sandbox basic RPC", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// Test KV via bridge
|
||||
try {
|
||||
const ep = getEp();
|
||||
const kvResult = await ep.testKv();
|
||||
results.push({
|
||||
step: "Sandbox KV via bridge",
|
||||
success: kvResult?.success === true,
|
||||
data: kvResult
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Sandbox KV via bridge", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// Test storage via bridge
|
||||
try {
|
||||
const ep = getEp();
|
||||
const storageResult = await ep.testStorage();
|
||||
results.push({
|
||||
step: "Sandbox storage via bridge",
|
||||
success: storageResult?.success === true,
|
||||
data: storageResult
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Sandbox storage via bridge", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// Test blocked storage
|
||||
try {
|
||||
const ep = getEp();
|
||||
const blockedResult = await ep.testBlockedStorage();
|
||||
results.push({
|
||||
step: "Sandbox blocked storage",
|
||||
success: blockedResult?.blocked === true,
|
||||
data: blockedResult
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Sandbox blocked storage", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// Test blocked network
|
||||
try {
|
||||
const ep = getEp();
|
||||
const networkResult = await ep.testBlockedNetwork();
|
||||
results.push({
|
||||
step: "Sandbox blocked network",
|
||||
success: networkResult?.blocked === true,
|
||||
data: networkResult
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Sandbox blocked network", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// ISOLATION TESTS - verify sandbox can't bypass bridge
|
||||
|
||||
// Test direct fetch is blocked (globalOutbound: null)
|
||||
try {
|
||||
const ep = getEp();
|
||||
const fetchResult = await ep.testDirectFetchBlocked();
|
||||
results.push({
|
||||
step: "Sandbox direct fetch blocked",
|
||||
success: fetchResult?.blocked === true,
|
||||
data: fetchResult
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Sandbox direct fetch blocked", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// Test sandbox has no DB/MEDIA bindings
|
||||
try {
|
||||
const ep = getEp();
|
||||
const bindingsResult = await ep.testNoDbBinding();
|
||||
results.push({
|
||||
step: "Sandbox no direct DB access",
|
||||
success: bindingsResult?.success === true && !bindingsResult?.hasDb,
|
||||
data: bindingsResult
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({ step: "Sandbox no direct DB access", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
results.push({ step: "Spawn sandbox with bridge", success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
const allPassed = results.every(r => r.success);
|
||||
const passCount = results.filter(r => r.success).length;
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sandbox Plugin Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h1 { color: #fff; }
|
||||
h2 { color: #ccc; margin-top: 2rem; }
|
||||
.result {
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.success { background: #1a3d1a; border: 1px solid #2d5a2d; }
|
||||
.error { background: #3d1a1a; border: 1px solid #5a2d2d; }
|
||||
.step-name { font-weight: bold; margin-bottom: 0.5rem; }
|
||||
pre {
|
||||
background: #2a2a2a;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
.summary {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.summary.pass { background: #1a3d1a; border: 2px solid #4a8a4a; }
|
||||
.summary.fail { background: #3d1a1a; border: 2px solid #8a4a4a; }
|
||||
.stats { font-size: 0.9rem; margin-top: 0.5rem; color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sandbox Plugin Test</h1>
|
||||
|
||||
<div class={`summary ${allPassed ? 'pass' : 'fail'}`}>
|
||||
{allPassed ? 'All Tests Passed!' : 'Some Tests Failed'}
|
||||
<div class="stats">{passCount} passed, {failCount} failed</div>
|
||||
</div>
|
||||
|
||||
<h2>Infrastructure</h2>
|
||||
{results.filter(r => r.step.startsWith("Check")).map(r => (
|
||||
<div class={`result ${r.success ? 'success' : 'error'}`}>
|
||||
<div class="step-name">{r.success ? '✓' : '✗'} {r.step}</div>
|
||||
{r.error && <pre>Error: {r.error}</pre>}
|
||||
{r.data && <pre>{JSON.stringify(r.data, null, 2)}</pre>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h2>Bridge Direct Tests</h2>
|
||||
{results.filter(r => r.step.startsWith("Bridge") || r.step.startsWith("Block") || r.step === "Create bridge instance").map(r => (
|
||||
<div class={`result ${r.success ? 'success' : 'error'}`}>
|
||||
<div class="step-name">{r.success ? '✓' : '✗'} {r.step}</div>
|
||||
{r.error && <pre>Error: {r.error}</pre>}
|
||||
{r.data && <pre>{JSON.stringify(r.data, null, 2)}</pre>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h2>Sandbox Tests (via Worker Loader)</h2>
|
||||
{results.filter(r => (r.step.startsWith("Sandbox") || r.step.startsWith("Spawn")) && !r.step.includes("direct") && !r.step.includes("no direct")).map(r => (
|
||||
<div class={`result ${r.success ? 'success' : 'error'}`}>
|
||||
<div class="step-name">{r.success ? '✓' : '✗'} {r.step}</div>
|
||||
{r.error && <pre>Error: {r.error}</pre>}
|
||||
{r.data && <pre>{JSON.stringify(r.data, null, 2)}</pre>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h2>Isolation Tests (sandbox can't bypass bridge)</h2>
|
||||
{results.filter(r => r.step.includes("direct") || r.step.includes("no direct")).map(r => (
|
||||
<div class={`result ${r.success ? 'success' : 'error'}`}>
|
||||
<div class="step-name">{r.success ? '✓' : '✗'} {r.step}</div>
|
||||
{r.error && <pre>Error: {r.error}</pre>}
|
||||
{r.data && <pre>{JSON.stringify(r.data, null, 2)}</pre>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h2>Architecture</h2>
|
||||
<pre>{`
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HOST WORKER (Astro) │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ PluginBridge │ │ EmDash CMS │ │
|
||||
│ │ (Entrypoint) │ │ │ │
|
||||
│ │ │ │ - Routes/Pages │ │
|
||||
│ │ - kvGet/Set │◄────│ - Middleware │ │
|
||||
│ │ - storageQuery │ │ - API handlers │ │
|
||||
│ │ - contentList │ │ │ │
|
||||
│ │ - httpFetch │ └─────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ (has DB access) │ ┌─────────────────────────────┐ │
|
||||
│ └────────▲─────────┘ │ Worker Loader │ │
|
||||
│ │ │ │ │
|
||||
│ │ RPC │ Spawns sandboxed isolates │ │
|
||||
│ │ └──────────────┬──────────────┘ │
|
||||
└───────────┼──────────────────────────────┼──────────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
┌───────────┴──────────────────────────────────────────────────┐
|
||||
│ SANDBOX ISOLATE │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Plugin Code │ │
|
||||
│ │ │ │
|
||||
│ │ - NO direct DB access │ │
|
||||
│ │ - NO direct network (globalOutbound: null) │ │
|
||||
│ │ - Only has BRIDGE service binding │ │
|
||||
│ │ │ │
|
||||
│ │ ctx.kv.get() ──► env.BRIDGE.kvGet() ──► Host DB │ │
|
||||
│ │ ctx.http.fetch() ──► env.BRIDGE.httpFetch() ──► Host │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</body>
|
||||
</html>
|
||||
149
demos/cloudflare/src/pages/sandbox-test.astro
Normal file
149
demos/cloudflare/src/pages/sandbox-test.astro
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
/**
|
||||
* Sandbox Test Page
|
||||
*
|
||||
* Tests the Worker Loader functionality by spawning a dynamic isolate.
|
||||
*/
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
interface TestResult {
|
||||
loaderAvailable: boolean;
|
||||
isolateSpawned: boolean;
|
||||
rpcWorked: boolean;
|
||||
error?: string;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
const results: TestResult = {
|
||||
loaderAvailable: false,
|
||||
isolateSpawned: false,
|
||||
rpcWorked: false,
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if LOADER binding is available
|
||||
// @ts-ignore - env typing
|
||||
const loader = env.LOADER;
|
||||
results.loaderAvailable = !!loader;
|
||||
|
||||
if (loader) {
|
||||
// Try to spawn a simple isolate
|
||||
const testCode = `
|
||||
import { WorkerEntrypoint } from "cloudflare:workers";
|
||||
|
||||
export default class TestEntrypoint extends WorkerEntrypoint {
|
||||
async test(input) {
|
||||
return {
|
||||
success: true,
|
||||
message: "Hello from sandbox!",
|
||||
received: input,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const worker = loader.get("sandbox-test-" + Date.now(), () => ({
|
||||
compatibilityDate: "2025-01-01",
|
||||
mainModule: "test.js",
|
||||
modules: {
|
||||
"test.js": { js: testCode },
|
||||
},
|
||||
globalOutbound: null, // Block network
|
||||
env: {},
|
||||
}));
|
||||
|
||||
results.isolateSpawned = true;
|
||||
|
||||
// Test RPC call — methods are dynamically defined in sandbox code
|
||||
// @ts-ignore - Worker Loader RPC methods are not statically typed
|
||||
const entrypoint = worker.getEntrypoint("default");
|
||||
// @ts-ignore - dynamic RPC method
|
||||
const rpcResult = await entrypoint.test({ test: "data" });
|
||||
results.rpcWorked = rpcResult?.success === true;
|
||||
results.result = rpcResult;
|
||||
}
|
||||
} catch (e) {
|
||||
results.error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sandbox Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.result {
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.pending {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Worker Loader Sandbox Test</h1>
|
||||
|
||||
<div class={`result ${results.loaderAvailable ? 'success' : 'error'}`}>
|
||||
<strong>LOADER Binding:</strong>
|
||||
{results.loaderAvailable ? 'Available' : 'Not available'}
|
||||
</div>
|
||||
|
||||
<div class={`result ${results.isolateSpawned ? 'success' : results.loaderAvailable ? 'error' : 'pending'}`}>
|
||||
<strong>Isolate Spawned:</strong>
|
||||
{results.isolateSpawned ? 'Yes' : 'No'}
|
||||
</div>
|
||||
|
||||
<div class={`result ${results.rpcWorked ? 'success' : results.isolateSpawned ? 'error' : 'pending'}`}>
|
||||
<strong>RPC Call:</strong>
|
||||
{results.rpcWorked ? 'Success' : 'Failed'}
|
||||
</div>
|
||||
|
||||
{results.error && (
|
||||
<div class="result error">
|
||||
<strong>Error:</strong>
|
||||
<pre>{results.error}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.result && (
|
||||
<div class="result success">
|
||||
<strong>Result from Sandbox:</strong>
|
||||
<pre>{JSON.stringify(results.result, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>Next Steps</h2>
|
||||
<p>
|
||||
If all tests pass, the Worker Loader is working correctly.
|
||||
This means we can run sandboxed plugins in isolated V8 isolates.
|
||||
</p>
|
||||
|
||||
<h2>Raw Results</h2>
|
||||
<pre>{JSON.stringify(results, null, 2)}</pre>
|
||||
</body>
|
||||
</html>
|
||||
141
demos/cloudflare/src/pages/search.astro
Normal file
141
demos/cloudflare/src/pages/search.astro
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import PostCard from "../components/PostCard.astro";
|
||||
import { getReadingTime, extractText } from "../utils/reading-time";
|
||||
|
||||
const query = Astro.url.searchParams.get("q")?.trim() || "";
|
||||
|
||||
const { entries: allPosts } = await getEmDashCollection("posts");
|
||||
|
||||
// Simple search: match query against title, excerpt, and content
|
||||
function matchesQuery(post: (typeof allPosts)[0], q: string): boolean {
|
||||
if (!q) return false;
|
||||
const lower = q.toLowerCase();
|
||||
const title = (post.data.title || "").toLowerCase();
|
||||
const excerpt = (post.data.excerpt || "").toLowerCase();
|
||||
// Extract plain text from portable text blocks (avoids matching on _type, _key, etc.)
|
||||
const content = extractText(post.data.content).toLowerCase();
|
||||
return (
|
||||
title.includes(lower) || excerpt.includes(lower) || content.includes(lower)
|
||||
);
|
||||
}
|
||||
|
||||
const results = query ? allPosts.filter((p) => matchesQuery(p, query)) : [];
|
||||
---
|
||||
|
||||
<Base
|
||||
title={query ? `Search: ${query}` : "Search"}
|
||||
description="Search blog posts"
|
||||
>
|
||||
<section class="search-page">
|
||||
<h1 class="search-title">Search</h1>
|
||||
|
||||
<form method="get" action="/search" class="search-form">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={query}
|
||||
placeholder="Search posts..."
|
||||
class="search-input"
|
||||
autofocus
|
||||
/>
|
||||
<button type="submit" class="search-button">Search</button>
|
||||
</form>
|
||||
|
||||
{
|
||||
query && (
|
||||
<p class="search-summary">
|
||||
{results.length === 0
|
||||
? `No results for "${query}"`
|
||||
: `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
results.length > 0 && (
|
||||
<div class="search-results">
|
||||
{results.map((post) => (
|
||||
<PostCard
|
||||
title={post.data.title}
|
||||
excerpt={post.data.excerpt}
|
||||
featuredImage={post.data.featured_image}
|
||||
href={`/posts/${post.id}`}
|
||||
date={post.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(post.data.content)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{!query && <p class="search-hint">Enter a search term to find posts.</p>}
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.search-page {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--font-size-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.search-button {
|
||||
padding: var(--spacing-2) var(--spacing-6);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.search-summary {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.search-hint {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
</style>
|
||||
120
demos/cloudflare/src/pages/tag/[slug].astro
Normal file
120
demos/cloudflare/src/pages/tag/[slug].astro
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
import { getTerm, getEmDashCollection, getEntryTerms } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import PostCard from "../../components/PostCard.astro";
|
||||
import { getReadingTime } from "../../utils/reading-time";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const term = slug ? await getTerm("tag", slug) : null;
|
||||
|
||||
if (!term) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
where: { tag: term.slug },
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
|
||||
// Fetch tags for display on each post card
|
||||
const filteredPosts = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
const tags = await getEntryTerms("posts", post.data.id, "tag");
|
||||
return { post, tags };
|
||||
})
|
||||
);
|
||||
---
|
||||
|
||||
<Base
|
||||
title={`Posts tagged "${term.label}"`}
|
||||
description={`All posts tagged with ${term.label}`}
|
||||
>
|
||||
<section class="archive-section">
|
||||
<header class="archive-header">
|
||||
<span class="archive-label">Tag</span>
|
||||
<h1 class="archive-title">{term.label}</h1>
|
||||
<p class="archive-count">
|
||||
{filteredPosts.length}
|
||||
{filteredPosts.length === 1 ? "post" : "posts"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{
|
||||
filteredPosts.length === 0 ? (
|
||||
<p class="no-posts">No posts with this tag yet.</p>
|
||||
) : (
|
||||
<div class="posts-grid">
|
||||
{filteredPosts.map(({ post, tags }) => (
|
||||
<PostCard
|
||||
title={post.data.title}
|
||||
excerpt={post.data.excerpt}
|
||||
featuredImage={post.data.featured_image}
|
||||
href={`/posts/${post.id}`}
|
||||
date={post.data.publishedAt ?? undefined}
|
||||
readingTime={getReadingTime(post.data.content)}
|
||||
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.archive-section {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
}
|
||||
|
||||
.archive-header {
|
||||
margin-bottom: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.archive-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.archive-title {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.archive-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-12) var(--spacing-8);
|
||||
}
|
||||
|
||||
.no-posts {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user