first commit
This commit is contained in:
18
demos/preview/astro.config.mjs
Normal file
18
demos/preview/astro.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import react from "@astrojs/react";
|
||||
import { previewDatabase } from "@emdashcms/cloudflare";
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: cloudflare(),
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
// DO-backed preview database — populated from source site snapshots
|
||||
database: previewDatabase({ binding: "PREVIEW_DB" }),
|
||||
}),
|
||||
],
|
||||
devToolbar: { enabled: false },
|
||||
});
|
||||
39
demos/preview/emdash-env.d.ts
vendored
Normal file
39
demos/preview/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;
|
||||
}
|
||||
}
|
||||
31
demos/preview/package.json
Normal file
31
demos/preview/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@emdashcms/demo-preview",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"build:all": "pnpm run --filter @emdashcms/demo-preview... build",
|
||||
"preview": "astro preview",
|
||||
"deploy": "pnpm build:all && wrangler deploy",
|
||||
"typecheck": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "catalog:",
|
||||
"@astrojs/react": "catalog:",
|
||||
"@emdashcms/cloudflare": "workspace:*",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-router": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"emdash": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"wrangler": "catalog:"
|
||||
}
|
||||
}
|
||||
47
demos/preview/sign-url.ts
Normal file
47
demos/preview/sign-url.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env -S npx tsx
|
||||
/**
|
||||
* Generate a signed preview URL for local testing.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx sign-url.ts [source] [preview]
|
||||
*
|
||||
* Defaults:
|
||||
* source: http://localhost:4321
|
||||
* preview: http://localhost:4322
|
||||
* secret: reads PREVIEW_SECRET from .dev.vars, falls back to "dev-secret"
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = process.argv[2] || "http://localhost:4321";
|
||||
const preview = process.argv[3] || "http://localhost:4322";
|
||||
|
||||
let secret = "dev-secret";
|
||||
try {
|
||||
const devVars = readFileSync(new URL(".dev.vars", import.meta.url), "utf-8");
|
||||
const match = devVars.match(/^PREVIEW_SECRET\s*=\s*"?([^"\n]+)"?/m);
|
||||
if (match) secret = match[1]!;
|
||||
} catch {
|
||||
// no .dev.vars, use default
|
||||
}
|
||||
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const sigBuffer = await crypto.subtle.sign("HMAC", key, encoder.encode(`${source}:${exp}`));
|
||||
const sig = Array.from(new Uint8Array(sigBuffer), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
const url = new URL(preview);
|
||||
url.searchParams.set("source", source);
|
||||
url.searchParams.set("exp", String(exp));
|
||||
url.searchParams.set("sig", sig);
|
||||
|
||||
console.log(url.toString());
|
||||
279
demos/preview/src/components/PostCard.astro
Normal file
279
demos/preview/src/components/PostCard.astro
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
import type { MediaValue, ContentBylineCredit } from "emdash";
|
||||
import { Image } from "emdash/ui";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
featuredImage?: MediaValue | string;
|
||||
href: string;
|
||||
date?: Date;
|
||||
readingTime?: number;
|
||||
tags?: Array<{ slug: string; label: string }>;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
excerpt,
|
||||
featuredImage,
|
||||
href,
|
||||
date,
|
||||
readingTime,
|
||||
tags,
|
||||
bylines,
|
||||
} = Astro.props;
|
||||
|
||||
const formattedDate = date
|
||||
? date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: null;
|
||||
---
|
||||
|
||||
<article class="post-card">
|
||||
<a href={href} class="card-link">
|
||||
{
|
||||
featuredImage ? (
|
||||
<div class="card-image">
|
||||
<Image image={featuredImage} />
|
||||
</div>
|
||||
) : (
|
||||
<div class="card-placeholder" />
|
||||
)
|
||||
}
|
||||
<div class="card-body">
|
||||
<div class="card-meta">
|
||||
{
|
||||
bylines && bylines.length > 0 && (
|
||||
<>
|
||||
<div class="card-bylines">
|
||||
{bylines.slice(0, 1).map((credit) => (
|
||||
<span class="card-byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="card-byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<span class="card-byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{bylines.length > 1 && (
|
||||
<span
|
||||
class="byline-more"
|
||||
data-tooltip={bylines
|
||||
.slice(1)
|
||||
.map((c) => c.byline.displayName)
|
||||
.join(", ")}
|
||||
title={bylines
|
||||
.slice(1)
|
||||
.map((c) => c.byline.displayName)
|
||||
.join(", ")}
|
||||
tabindex="0"
|
||||
>
|
||||
+{bylines.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(formattedDate || readingTime) && <span class="meta-dot" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{formattedDate && <time>{formattedDate}</time>}
|
||||
{formattedDate && readingTime && <span class="meta-dot" />}
|
||||
{readingTime && <span>{readingTime} min</span>}
|
||||
</div>
|
||||
<h2 class="card-title">{title}</h2>
|
||||
{excerpt && <p class="card-excerpt">{excerpt}</p>}
|
||||
</div>
|
||||
</a>
|
||||
{
|
||||
tags && tags.length > 0 && (
|
||||
<div class="card-tags">
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<a href={`/tag/${tag.slug}`} class="card-tag">
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.post-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .card-image img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.card-placeholder {
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-2);
|
||||
row-gap: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.card-meta time,
|
||||
.card-meta span:not(.meta-dot) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-snug);
|
||||
letter-spacing: var(--tracking-snug);
|
||||
margin-bottom: var(--spacing-2);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-link:hover .card-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.card-excerpt {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-3);
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Byline styles */
|
||||
.card-bylines {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.card-byline-avatar {
|
||||
width: var(--avatar-size-xs);
|
||||
height: var(--avatar-size-xs);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-byline-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.byline-more {
|
||||
position: relative;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
margin-left: 2px;
|
||||
cursor: default;
|
||||
border-radius: var(--radius);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.byline-more:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.byline-more[data-tooltip]:hover::after,
|
||||
.byline-more[data-tooltip]:focus-visible::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
45
demos/preview/src/components/TagList.astro
Normal file
45
demos/preview/src/components/TagList.astro
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
interface Props {
|
||||
tags: Array<{ slug: string; label: string }>;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { tags, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
{tags.length > 0 && (
|
||||
<ul class:list={["tag-list", className]}>
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a href={`/tag/${tag.slug}`} class="tag">{tag.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
985
demos/preview/src/layouts/Base.astro
Normal file
985
demos/preview/src/layouts/Base.astro
Normal file
@@ -0,0 +1,985 @@
|
||||
---
|
||||
import { getMenu, getEmDashCollection } from "emdash";
|
||||
import {
|
||||
WidgetArea,
|
||||
EmDashHead,
|
||||
EmDashBodyStart,
|
||||
EmDashBodyEnd,
|
||||
} from "emdash/ui";
|
||||
import { createPublicPageContext } from "emdash/page";
|
||||
import LiveSearch from "emdash/ui/search";
|
||||
import "../styles/theme.css";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
image?: string | null;
|
||||
canonical?: string | null;
|
||||
robots?: string | null;
|
||||
type?: "website" | "article";
|
||||
publishedTime?: string | null;
|
||||
modifiedTime?: string | null;
|
||||
author?: string | null;
|
||||
/** Pass content reference for plugin page contributions on content pages */
|
||||
content?: { collection: string; id: string; slug?: string | null };
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
canonical,
|
||||
robots,
|
||||
type = "website",
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
author,
|
||||
content,
|
||||
} = Astro.props;
|
||||
const siteTitle = "My Blog";
|
||||
// If title already includes site title (from getSeoMeta), use as-is
|
||||
const fullTitle = title.includes(siteTitle) ? title : `${title} — ${siteTitle}`;
|
||||
|
||||
// Fetch primary menu defined in seed
|
||||
const menu = await getMenu("primary");
|
||||
|
||||
// Fetch pages for footer
|
||||
const { entries: pages } = await getEmDashCollection("pages");
|
||||
|
||||
// Build public page context for plugin contributions
|
||||
// SEO data is passed here and rendered securely by EmDashHead
|
||||
const pageCtx = createPublicPageContext({
|
||||
Astro,
|
||||
kind: content ? "content" : "custom",
|
||||
pageType: type,
|
||||
title: fullTitle,
|
||||
description,
|
||||
canonical,
|
||||
image,
|
||||
content,
|
||||
seo: { ogImage: image, robots },
|
||||
articleMeta: { publishedTime, modifiedTime, author },
|
||||
siteName: siteTitle,
|
||||
});
|
||||
|
||||
// Check if user is logged in (for showing admin link)
|
||||
const isLoggedIn = !!Astro.locals.user;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>{fullTitle}</title>
|
||||
<EmDashHead page={pageCtx} />
|
||||
<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>
|
||||
<EmDashBodyStart page={pageCtx} />
|
||||
<header class="site-header">
|
||||
<nav class="nav">
|
||||
<a href="/" class="site-title">{siteTitle}</a>
|
||||
<div class="nav-right">
|
||||
<LiveSearch
|
||||
placeholder="Search..."
|
||||
class="site-search"
|
||||
inputClass="site-search-input"
|
||||
resultsClass="site-search-results"
|
||||
resultClass="site-search-result"
|
||||
collections={["posts", "pages"]}
|
||||
/>
|
||||
<div class="nav-links">
|
||||
{
|
||||
menu?.items.map((item) => (
|
||||
<a href={item.url} target={item.target}>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isLoggedIn && (
|
||||
<a href="/_emdash/admin" class="nav-admin">
|
||||
Admin
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<a href="/" class="footer-logo">{siteTitle}</a>
|
||||
<p class="footer-tagline">Thoughts, stories, and ideas.</p>
|
||||
</div>
|
||||
<div class="footer-nav">
|
||||
<h4 class="footer-heading">Navigate</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/posts">All Posts</a></li>
|
||||
{
|
||||
pages.slice(0, 3).map((page) => (
|
||||
<li>
|
||||
<a href={`/pages/${page.data.slug || page.id}`}>
|
||||
{page.data.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-nav">
|
||||
<h4 class="footer-heading">Connect</h4>
|
||||
<ul class="footer-links">
|
||||
{
|
||||
menu?.items.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={item.url}
|
||||
target={item.target}
|
||||
rel={
|
||||
item.target === "_blank"
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li><a href="/rss.xml">RSS Feed</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-widgets-section">
|
||||
<WidgetArea name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p class="footer-copyright">
|
||||
Powered by <a href="https://emdashcms.com">EmDash</a>
|
||||
</p>
|
||||
<div class="theme-switcher">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-btn"
|
||||
data-theme="light"
|
||||
aria-label="Light mode"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><circle cx="12" cy="12" r="5"></circle><line
|
||||
x1="12"
|
||||
y1="1"
|
||||
x2="12"
|
||||
y2="3"></line><line x1="12" y1="21" x2="12" y2="23"
|
||||
></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"
|
||||
></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"
|
||||
></line><line x1="1" y1="12" x2="3" y2="12"></line><line
|
||||
x1="21"
|
||||
y1="12"
|
||||
x2="23"
|
||||
y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"
|
||||
></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"
|
||||
></line></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-btn"
|
||||
data-theme="dark"
|
||||
aria-label="Dark mode"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-btn"
|
||||
data-theme="system"
|
||||
aria-label="System theme"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><rect x="2" y="3" width="20" height="14" rx="2" ry="2"
|
||||
></rect><line x1="8" y1="21" x2="16" y2="21"></line><line
|
||||
x1="12"
|
||||
y1="17"
|
||||
x2="12"
|
||||
y2="21"></line></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Theme switcher
|
||||
const THEME_REGEX = /theme=([^;]+)/;
|
||||
const themeBtns =
|
||||
document.querySelectorAll<HTMLButtonElement>(".theme-btn");
|
||||
const root = document.documentElement;
|
||||
|
||||
function setCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
maxAge: number = 31536000
|
||||
) {
|
||||
const secure = location.protocol === "https:" ? "; Secure" : "";
|
||||
if (value === "") {
|
||||
document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax${secure}`;
|
||||
} else {
|
||||
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(theme: string) {
|
||||
if (theme === "system") {
|
||||
setCookie("theme", "");
|
||||
root.classList.remove("light", "dark");
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
} else {
|
||||
setCookie("theme", theme);
|
||||
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
|
||||
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>
|
||||
*:where(:not([class*="emdash"]):not([class*="ec-"])),
|
||||
*:where(:not([class*="emdash"]):not([class*="ec-"]))::before,
|
||||
*:where(:not([class*="emdash"]):not([class*="ec-"]))::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
figure,
|
||||
blockquote,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Colors - Light mode (default) */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-subtle: #fafafa;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-secondary: #525252;
|
||||
--color-muted: #8b8b8b;
|
||||
--color-border: #e5e5e5;
|
||||
--color-border-subtle: #f0f0f0;
|
||||
--color-surface: #f7f7f7;
|
||||
--color-accent: #0066cc;
|
||||
--color-accent-hover: #0052a3;
|
||||
--color-on-accent: white;
|
||||
--color-accent-ring: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 25%,
|
||||
transparent
|
||||
);
|
||||
|
||||
/* EmDash search theming */
|
||||
--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);
|
||||
|
||||
/* Typography */
|
||||
--font-sans:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
/* Type scale - more refined */
|
||||
--font-size-xs: 0.8125rem;
|
||||
--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;
|
||||
|
||||
/* Line heights */
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.7;
|
||||
|
||||
/* Spacing - more generous */
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-3: 0.75rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-5: 1.25rem;
|
||||
--spacing-6: 1.5rem;
|
||||
--spacing-8: 2rem;
|
||||
--spacing-10: 2.5rem;
|
||||
--spacing-12: 3rem;
|
||||
--spacing-16: 4rem;
|
||||
--spacing-20: 5rem;
|
||||
--spacing-24: 6rem;
|
||||
|
||||
/* Legacy spacing aliases */
|
||||
--spacing-xs: var(--spacing-1);
|
||||
--spacing-sm: var(--spacing-2);
|
||||
--spacing-md: var(--spacing-4);
|
||||
--spacing-lg: var(--spacing-6);
|
||||
--spacing-xl: var(--spacing-8);
|
||||
--spacing-2xl: var(--spacing-12);
|
||||
--spacing-3xl: var(--spacing-16);
|
||||
|
||||
/* Layout - wider for three-column */
|
||||
--content-width: 680px;
|
||||
--wide-width: 1200px;
|
||||
--max-width: var(--content-width);
|
||||
--gutter-width: 200px;
|
||||
--radius: 4px;
|
||||
--radius-lg: 8px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 120ms ease;
|
||||
--transition-base: 180ms ease;
|
||||
|
||||
/* Nav */
|
||||
--nav-height: 64px;
|
||||
|
||||
/* Search */
|
||||
--search-input-width: 180px;
|
||||
|
||||
/* Article layout */
|
||||
--meta-col-width: 180px;
|
||||
|
||||
/* Avatar sizes */
|
||||
--avatar-size-xs: 18px;
|
||||
--avatar-size-sm: 20px;
|
||||
--avatar-size-md: 24px;
|
||||
--avatar-size-lg: 32px;
|
||||
|
||||
/* Letter spacing */
|
||||
--tracking-tight: -0.03em;
|
||||
--tracking-snug: -0.02em;
|
||||
--tracking-wide: 0.06em;
|
||||
--tracking-wider: 0.08em;
|
||||
|
||||
/* Tag pill */
|
||||
--tag-padding-y: 2px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Dark mode via system preference (when no explicit class) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
--color-bg: #0d0d0d;
|
||||
--color-bg-subtle: #141414;
|
||||
--color-text: #ededed;
|
||||
--color-text-secondary: #a0a0a0;
|
||||
--color-muted: #6b6b6b;
|
||||
--color-border: #2a2a2a;
|
||||
--color-border-subtle: #1f1f1f;
|
||||
--color-surface: #181818;
|
||||
--color-accent: #4d9fff;
|
||||
--color-accent-hover: #6eb0ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit dark mode */
|
||||
:root.dark {
|
||||
--color-bg: #0d0d0d;
|
||||
--color-bg-subtle: #141414;
|
||||
--color-text: #ededed;
|
||||
--color-text-secondary: #a0a0a0;
|
||||
--color-muted: #6b6b6b;
|
||||
--color-border: #2a2a2a;
|
||||
--color-border-subtle: #1f1f1f;
|
||||
--color-surface: #181818;
|
||||
--color-accent: #4d9fff;
|
||||
--color-accent-hover: #6eb0ff;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a:where(:not([class*="emdash"]):not([class*="ec-"])) {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:where(:not([class*="emdash"]):not([class*="ec-"])):hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-sans);
|
||||
line-height: var(--leading-tight);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: color-mix(in srgb, var(--color-bg) 65%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
height: var(--nav-height);
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
}
|
||||
|
||||
.site-title:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: var(--spacing-5);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-admin {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-admin:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Search styling */
|
||||
.site-search {
|
||||
position: relative;
|
||||
width: var(--search-input-width);
|
||||
--emdash-search-border-focus: var(--color-accent);
|
||||
}
|
||||
|
||||
:global(.site-search-input) {
|
||||
width: var(--search-input-width);
|
||||
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);
|
||||
}
|
||||
|
||||
:global(.site-search-input)::placeholder {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
:global(.site-search-input):focus,
|
||||
:global(.site-search-input):focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-accent) !important;
|
||||
box-shadow: 0 0 0 3px var(--color-accent-ring);
|
||||
}
|
||||
|
||||
:global(.site-search-results) {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: var(--spacing-2);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
:global(.site-search-results .emdash-live-search-loading),
|
||||
:global(.site-search-results .emdash-live-search-no-results) {
|
||||
padding: var(--spacing-4);
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
:global(.site-search-result) {
|
||||
display: block;
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
:global(.site-search-result):last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.site-search-result):hover,
|
||||
:global(.site-search-result):focus,
|
||||
:global(.site-search-result.focused) {
|
||||
background: var(--color-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.site-search-result .emdash-live-search-result-title) {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
:global(.site-search-result .emdash-live-search-result-collection) {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
:global(.site-search-result .emdash-live-search-result-snippet) {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-top: var(--spacing-1);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
:global(.site-search-result .emdash-live-search-result-snippet mark) {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: calc(100vh - var(--nav-height) - 300px);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
background: var(--color-bg-subtle);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-16) var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: var(--spacing-12);
|
||||
margin-bottom: var(--spacing-12);
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
}
|
||||
|
||||
.footer-tagline {
|
||||
margin-top: var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.footer-nav {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.footer-heading {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footer-links li {
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.footer-widgets-section :global(.widget-area) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer-widgets-section :global(.widget) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.footer-widgets-section :global(.widget__title) {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.footer-widgets-section :global(.widget__content) {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-6);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.footer-copyright {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.footer-copyright a {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Theme switcher */
|
||||
.theme-switcher {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-1);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.theme-btn.active {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-btn-active);
|
||||
}
|
||||
|
||||
.theme-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
grid-column: span 2;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.site-header {
|
||||
position: relative;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.site-search {
|
||||
order: 0;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
:global(.site-search-input) {
|
||||
width: 140px !important;
|
||||
padding: var(--spacing-1) var(--spacing-2) !important;
|
||||
font-size: var(--font-size-sm) !important;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
order: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
column-gap: var(--spacing-3);
|
||||
row-gap: var(--spacing-1);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.nav-admin {
|
||||
order: 2;
|
||||
position: absolute;
|
||||
right: var(--spacing-4);
|
||||
top: var(--spacing-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-controls {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// ⌘K / Ctrl+K to focus search
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
const searchInput = document.querySelector(
|
||||
".site-search-input"
|
||||
) as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<EmDashBodyEnd page={pageCtx} />
|
||||
</body>
|
||||
</html>
|
||||
13
demos/preview/src/live.config.ts
Normal file
13
demos/preview/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() }),
|
||||
};
|
||||
16
demos/preview/src/middleware.ts
Normal file
16
demos/preview/src/middleware.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Preview middleware
|
||||
*
|
||||
* Validates signed preview URLs, creates DO-backed sessions,
|
||||
* populates snapshots, and overrides the request-context DB.
|
||||
*/
|
||||
|
||||
import { createPreviewMiddleware } from "@emdashcms/cloudflare/db/do";
|
||||
import { sequence } from "astro:middleware";
|
||||
|
||||
const preview = createPreviewMiddleware({
|
||||
binding: "PREVIEW_DB",
|
||||
secret: import.meta.env.PREVIEW_SECRET,
|
||||
});
|
||||
|
||||
export const onRequest = sequence(preview);
|
||||
33
demos/preview/src/pages/404.astro
Normal file
33
demos/preview/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>
|
||||
117
demos/preview/src/pages/category/[slug].astro
Normal file
117
demos/preview/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/preview/src/pages/index.astro
Normal file
448
demos/preview/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/preview/src/pages/pages/[slug].astro
Normal file
108
demos/preview/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/preview/src/pages/posts/[slug].astro
Normal file
958
demos/preview/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/preview/src/pages/posts/index.astro
Normal file
268
demos/preview/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/preview/src/pages/rss.xml.ts
Normal file
70
demos/preview/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;
|
||||
}
|
||||
141
demos/preview/src/pages/search.astro
Normal file
141
demos/preview/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/preview/src/pages/tag/[slug].astro
Normal file
120
demos/preview/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>
|
||||
110
demos/preview/src/styles/theme.css
Normal file
110
demos/preview/src/styles/theme.css
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
theme.css — override any :root variable here to retheme the blog.
|
||||
|
||||
This is the only file you need to edit to customize the site's visual
|
||||
appearance. All defaults are listed below as comments. Uncomment and
|
||||
change any value to override it.
|
||||
|
||||
Note: this template defines explicit dark mode colors in Base.astro.
|
||||
Overriding light-mode --color-* variables here won't affect dark mode.
|
||||
To customize dark mode, also override --color-* variables inside a
|
||||
@media (prefers-color-scheme: dark) block and/or in the :root.dark rule.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Colors ---
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-subtle: #fafafa;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-secondary: #525252;
|
||||
--color-muted: #8b8b8b;
|
||||
--color-border: #e5e5e5;
|
||||
--color-border-subtle: #f0f0f0;
|
||||
--color-surface: #f7f7f7;
|
||||
--color-accent: #0066cc;
|
||||
--color-accent-hover: #0052a3;
|
||||
--color-on-accent: white;
|
||||
--color-accent-ring: color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
*/
|
||||
|
||||
/* --- Fonts ---
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
*/
|
||||
|
||||
/* --- Type scale ---
|
||||
--font-size-xs: 0.8125rem;
|
||||
--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;
|
||||
*/
|
||||
|
||||
/* --- Line heights ---
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.7;
|
||||
*/
|
||||
|
||||
/* --- Letter spacing ---
|
||||
--tracking-tight: -0.03em; used on h1 and large titles
|
||||
--tracking-snug: -0.02em; used on h2–h6, site/card titles
|
||||
--tracking-wide: 0.06em; used on meta labels, TOC/widget titles
|
||||
--tracking-wider: 0.08em; used on footer headings, section labels
|
||||
*/
|
||||
|
||||
/* --- Spacing ---
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-3: 0.75rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-5: 1.25rem;
|
||||
--spacing-6: 1.5rem;
|
||||
--spacing-8: 2rem;
|
||||
--spacing-10: 2.5rem;
|
||||
--spacing-12: 3rem;
|
||||
--spacing-16: 4rem;
|
||||
--spacing-20: 5rem;
|
||||
--spacing-24: 6rem;
|
||||
*/
|
||||
|
||||
/* --- Layout ---
|
||||
--content-width: 680px; article/page body column width
|
||||
--wide-width: 1200px; max container width (home, archives)
|
||||
--gutter-width: 200px; right sidebar column (TOC) on article pages
|
||||
--meta-col-width: 180px; left meta column on article pages
|
||||
--nav-height: 64px; sticky header height
|
||||
--search-input-width: 180px; nav search box width
|
||||
*/
|
||||
|
||||
/* --- Borders & radius ---
|
||||
--radius: 4px;
|
||||
--radius-lg: 8px;
|
||||
*/
|
||||
|
||||
/* --- Transitions ---
|
||||
--transition-fast: 120ms ease;
|
||||
--transition-base: 180ms ease;
|
||||
*/
|
||||
|
||||
/* --- Avatars ---
|
||||
--avatar-size-xs: 18px; card byline avatars
|
||||
--avatar-size-sm: 20px; post list byline avatars
|
||||
--avatar-size-md: 24px; featured post byline avatars
|
||||
--avatar-size-lg: 32px; single post byline avatars
|
||||
*/
|
||||
|
||||
/* --- Shadows ---
|
||||
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
*/
|
||||
|
||||
/* --- Misc ---
|
||||
--tag-padding-y: 2px; vertical padding on tag pills
|
||||
*/
|
||||
}
|
||||
44
demos/preview/src/utils/reading-time.ts
Normal file
44
demos/preview/src/utils/reading-time.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { PortableTextBlock } from "emdash";
|
||||
|
||||
const WORDS_PER_MINUTE = 200;
|
||||
const WHITESPACE_REGEX = /\s+/;
|
||||
|
||||
/**
|
||||
* Extract plain text from Portable Text blocks
|
||||
*/
|
||||
export function extractText(blocks: PortableTextBlock[] | undefined): string {
|
||||
if (!blocks || !Array.isArray(blocks)) return "";
|
||||
|
||||
return blocks
|
||||
.filter(
|
||||
(
|
||||
block,
|
||||
): block is PortableTextBlock & {
|
||||
children: Array<{ _type: string; text?: string }>;
|
||||
} => block._type === "block" && Array.isArray(block.children),
|
||||
)
|
||||
.map((block) =>
|
||||
block.children
|
||||
.filter((child) => child._type === "span" && typeof child.text === "string")
|
||||
.map((span) => span.text)
|
||||
.join(""),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate reading time in minutes from Portable Text content
|
||||
*/
|
||||
export function getReadingTime(content: PortableTextBlock[] | undefined): number {
|
||||
const text = extractText(content);
|
||||
const wordCount = text.split(WHITESPACE_REGEX).filter(Boolean).length;
|
||||
const minutes = Math.ceil(wordCount / WORDS_PER_MINUTE);
|
||||
return Math.max(1, minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reading time for display
|
||||
*/
|
||||
export function formatReadingTime(minutes: number): string {
|
||||
return `${minutes} min read`;
|
||||
}
|
||||
14
demos/preview/src/worker.ts
Normal file
14
demos/preview/src/worker.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Preview Worker Entrypoint
|
||||
*
|
||||
* Exports:
|
||||
* - default: Astro handler
|
||||
* - EmDashPreviewDB: Durable Object class for preview databases
|
||||
*/
|
||||
|
||||
import handler from "@astrojs/cloudflare/entrypoints/server";
|
||||
|
||||
// Export the DO class so Cloudflare can instantiate it
|
||||
export { EmDashPreviewDB } from "@emdashcms/cloudflare/db/do";
|
||||
|
||||
export default handler;
|
||||
9
demos/preview/tsconfig.json
Normal file
9
demos/preview/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
"src",
|
||||
".astro/types.d.ts",
|
||||
"emdash-env.d.ts",
|
||||
"worker-configuration.d.ts"
|
||||
]
|
||||
}
|
||||
35
demos/preview/wrangler.jsonc
Normal file
35
demos/preview/wrangler.jsonc
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "emdash-preview",
|
||||
// Custom entrypoint that exports EmDashPreviewDB
|
||||
"main": "./src/worker.ts",
|
||||
"compatibility_date": "2026-01-14",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"disable_nodejs_process_v2"
|
||||
],
|
||||
"assets": {
|
||||
"directory": "./dist",
|
||||
},
|
||||
// Durable Object for preview databases
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "PREVIEW_DB",
|
||||
"class_name": "EmDashPreviewDB",
|
||||
},
|
||||
],
|
||||
},
|
||||
// DO migration — declares the SQLite-backed class
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": [
|
||||
"EmDashPreviewDB"
|
||||
],
|
||||
},
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user