first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
---
/**
* PostCard Component
*
* Displays a post preview with optional featured image.
*
* IMPORTANT: Image fields are objects with { src, alt }, not strings!
*/
interface Props {
title: string;
href: string;
date?: string;
excerpt?: string;
// Image fields from EmDash are always { src?: string, alt?: string }
featuredImage?: {
src?: string;
alt?: string;
};
}
const { title, href, date, excerpt, featuredImage } = Astro.props;
const formattedDate = date
? new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
---
<article class="post-card">
{/* Check featuredImage.src, not just featuredImage */}
{
featuredImage?.src && (
<a href={href} class="post-card-image">
<img src={featuredImage.src} alt={featuredImage.alt || title} loading="lazy" />
</a>
)
}
<div class="post-card-content">
<h2 class="post-card-title">
<a href={href}>{title}</a>
</h2>
{formattedDate && <p class="post-card-meta">{formattedDate}</p>}
{excerpt && <p class="post-card-excerpt">{excerpt}</p>}
</div>
</article>

View File

@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View File

@@ -0,0 +1,72 @@
---
/**
* Base Layout
*
* Main layout with header, footer, and navigation from EmDash menus.
*/
import { getMenu, getSiteSettings } from "emdash";
import "../styles/global.css";
interface Props {
title?: string;
description?: string;
}
const { title, description } = Astro.props;
// These APIs automatically get the database from the Astro context
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
const footerMenu = await getMenu("footer");
const siteTitle = settings.title || "My Site";
const pageTitle = title ? `${title} | ${siteTitle}` : siteTitle;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{pageTitle}</title>
{description && <meta name="description" content={description} />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<header class="site-header">
<div class="container">
<a href="/" class="site-title">{siteTitle}</a>
{
primaryMenu && primaryMenu.items.length > 0 && (
<nav class="site-nav">
{primaryMenu.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
)
}
</div>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="container">
{
footerMenu && footerMenu.items.length > 0 && (
<nav class="footer-nav">
{footerMenu.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
)
}
<p class="copyright">&copy; {new Date().getFullYear()} {siteTitle}</p>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,19 @@
/**
* EmDash Live Config
*
* This file defines your content collections using EmDash's loader.
* It replaces Astro's content collections for CMS-managed content.
*/
import { defineCollection } from "astro:content";
import { emdashLoader } from "emdash";
// Posts collection - loaded from EmDash CMS
export const collections = {
posts: defineCollection({
loader: emdashLoader({ collection: "posts" }),
}),
pages: defineCollection({
loader: emdashLoader({ collection: "pages" }),
}),
};

View File

@@ -0,0 +1,17 @@
---
/**
* 404 Page
*/
import Base from "../layouts/Base.astro";
---
<Base title="Page Not Found">
<div class="container">
<div class="content-width" style="text-align: center; padding: 4rem 0;">
<h1>404</h1>
<p class="text-muted">The page you're looking for doesn't exist.</p>
<p><a href="/">Go home</a></p>
</div>
</div>
</Base>

View File

@@ -0,0 +1,57 @@
---
/**
* Category Archive
*
* Demonstrates:
* - getTerm for fetching taxonomy term details
* - getEntriesByTerm for entries with a specific term
*/
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
const { slug } = Astro.params;
const category = await getTerm("categories", slug!);
const posts = await getEntriesByTerm("posts", "categories", slug!);
if (!category) {
return Astro.redirect("/404");
}
---
<Base title={category.label}>
<div class="container">
<div class="content-width">
<header class="archive-header">
<h1>{category.label}</h1>
</header>
{
posts.length > 0 ? (
<div class="posts-list">
{posts.map((post) => (
<PostCard
title={post.data.title}
href={`/posts/${post.data.slug || post.id}`}
date={post.data.published_at}
excerpt={post.data.excerpt}
featuredImage={
post.data.featured_image?.src
? {
src: post.data.featured_image.src,
alt: post.data.featured_image.alt || post.data.title,
}
: undefined
}
/>
))}
</div>
) : (
<p class="text-muted">No posts in this category.</p>
)
}
</div>
</div>
</Base>

View File

@@ -0,0 +1,54 @@
---
/**
* Homepage / Blog Index
*
* Demonstrates:
* - getEmDashCollection for listing entries
* - Passing image fields correctly to components
*/
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
limit: 10,
});
---
<Base>
<div class="container">
<div class="content-width">
{
posts.length > 0 ? (
<div class="posts-list">
{posts.map((post) => (
<PostCard
title={post.data.title}
href={`/posts/${post.data.slug || post.id}`}
date={post.data.published_at}
excerpt={post.data.excerpt}
{
/*
IMPORTANT: featured_image is { src, alt }, not a string!
Pass the whole object, or extract src/alt explicitly.
*/ }
featuredImage={
post.data.featured_image?.src
? {
src: post.data.featured_image.src,
alt: post.data.featured_image.alt || post.data.title,
}
: undefined
}
/>
))}
</div>
) : (
<p class="text-muted">No posts yet.</p>
)
}
</div>
</div>
</Base>

View File

@@ -0,0 +1,35 @@
---
/**
* Single Page
*/
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: page, error } = await getEmDashEntry("pages", slug!);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!page) {
return Astro.redirect("/404");
}
---
<Base title={page.data.title}>
<article class="container">
<div class="content-width">
<header class="page-header">
<h1>{page.data.title}</h1>
</header>
<div class="prose">
{page.data.content && <PortableText value={page.data.content} />}
</div>
</div>
</article>
</Base>

View File

@@ -0,0 +1,99 @@
---
/**
* Single Post
*
* Demonstrates:
* - getEmDashEntry for fetching a single entry
* - getEntryTerms for taxonomy terms (NO db parameter!)
* - PortableText component for rich content
* - Proper image field access
*/
import { getEmDashEntry, getEntryTerms } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug!);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!post) {
return Astro.redirect("/404");
}
// Get taxonomy terms - NOTE: no db parameter!
const categories = await getEntryTerms("posts", post.id, "categories");
const tags = await getEntryTerms("posts", post.id, "tags");
const formattedDate = post.data.published_at
? new Date(post.data.published_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
---
<Base title={post.data.title} description={post.data.excerpt}>
<article class="container">
<div class="content-width">
<header class="post-header">
<h1>{post.data.title}</h1>
<div class="post-meta">
{
formattedDate && (
<time datetime={post.data.published_at}>{formattedDate}</time>
)
}
{
categories.length > 0 && (
<span class="post-categories">
in{" "}
{categories.map((cat, i) => (
<>
{i > 0 && ", "}
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
</>
))}
</span>
)
}
</div>
</header>
{/* IMPORTANT: Check .src, not just the field */}
{
post.data.featured_image?.src && (
<div class="post-featured-image">
<img
src={post.data.featured_image.src}
alt={post.data.featured_image.alt || post.data.title}
/>
</div>
)
}
<div class="prose">
{post.data.content && <PortableText value={post.data.content} />}
</div>
{
tags.length > 0 && (
<div class="post-tags">
{tags.map((tag) => (
<a href={`/tags/${tag.slug}`} class="tag">
{tag.label}
</a>
))}
</div>
)
}
</div>
</article>
</Base>

View File

@@ -0,0 +1,48 @@
---
/**
* Posts Archive
*/
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
---
<Base title="Blog">
<div class="container">
<div class="content-width">
<header class="archive-header">
<h1>Blog</h1>
</header>
{
posts.length > 0 ? (
<div class="posts-list">
{posts.map((post) => (
<PostCard
title={post.data.title}
href={`/posts/${post.data.slug || post.id}`}
date={post.data.published_at}
excerpt={post.data.excerpt}
featuredImage={
post.data.featured_image?.src
? {
src: post.data.featured_image.src,
alt: post.data.featured_image.alt || post.data.title,
}
: undefined
}
/>
))}
</div>
) : (
<p class="text-muted">No posts found.</p>
)
}
</div>
</div>
</Base>

View File

@@ -0,0 +1,54 @@
---
/**
* Tag Archive
*/
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
const { slug } = Astro.params;
const tag = await getTerm("tags", slug!);
const posts = await getEntriesByTerm("posts", "tags", slug!);
if (!tag) {
return Astro.redirect("/404");
}
---
<Base title={`Tagged: ${tag.label}`}>
<div class="container">
<div class="content-width">
<header class="archive-header">
<p class="text-muted">Tagged</p>
<h1>{tag.label}</h1>
</header>
{
posts.length > 0 ? (
<div class="posts-list">
{posts.map((post) => (
<PostCard
title={post.data.title}
href={`/posts/${post.data.slug || post.id}`}
date={post.data.published_at}
excerpt={post.data.excerpt}
featuredImage={
post.data.featured_image?.src
? {
src: post.data.featured_image.src,
alt: post.data.featured_image.alt || post.data.title,
}
: undefined
}
/>
))}
</div>
) : (
<p class="text-muted">No posts with this tag.</p>
)
}
</div>
</div>
</Base>

View File

@@ -0,0 +1,274 @@
/**
* Global Styles - Scaffold Theme
*
* Minimal styles for demonstration. Replace with your theme's design tokens.
*/
/* Reset */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Variables - replace with your theme's tokens */
:root {
--color-text: #1a1a1a;
--color-text-muted: #666;
--color-bg: #fff;
--color-border: #e5e5e5;
--color-accent: #0066cc;
--font-body: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 2rem;
--space-xl: 4rem;
--content-width: 40rem;
--container-width: 60rem;
}
/* Base */
html {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background: var(--color-bg);
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
padding: var(--space-xl) var(--space-md);
}
/* Layout */
.container {
max-width: var(--container-width);
margin: 0 auto;
padding: 0 var(--space-md);
}
.content-width {
max-width: var(--content-width);
}
/* Header */
.site-header {
padding: var(--space-md) 0;
border-bottom: 1px solid var(--color-border);
}
.site-header .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.site-title {
font-size: 1.25rem;
font-weight: 600;
text-decoration: none;
color: inherit;
}
.site-nav {
display: flex;
gap: var(--space-md);
}
.site-nav a {
color: var(--color-text-muted);
text-decoration: none;
}
.site-nav a:hover {
color: var(--color-text);
}
/* Footer */
.site-footer {
padding: var(--space-lg) 0;
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.875rem;
}
.footer-nav {
display: flex;
gap: var(--space-md);
margin-bottom: var(--space-sm);
}
.footer-nav a {
color: inherit;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.3;
margin-bottom: var(--space-sm);
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
p {
margin-bottom: var(--space-md);
}
a {
color: var(--color-accent);
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-border);
padding: 0.1em 0.3em;
border-radius: 3px;
}
/* Images */
img {
max-width: 100%;
height: auto;
display: block;
}
/* Post Card */
.post-card {
margin-bottom: var(--space-lg);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.post-card:last-child {
border-bottom: none;
}
.post-card-image {
margin-bottom: var(--space-md);
}
.post-card-image img {
border-radius: 4px;
}
.post-card-title {
font-size: 1.25rem;
margin-bottom: var(--space-sm);
}
.post-card-title a {
color: inherit;
text-decoration: none;
}
.post-card-title a:hover {
color: var(--color-accent);
}
.post-card-meta {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: var(--space-sm);
}
.post-card-excerpt {
color: var(--color-text-muted);
}
/* Single Post/Page */
.post-header,
.page-header {
margin-bottom: var(--space-lg);
}
.post-meta {
font-size: 0.875rem;
color: var(--color-text-muted);
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
}
.post-featured-image {
margin-bottom: var(--space-lg);
}
.post-featured-image img {
border-radius: 4px;
}
/* Taxonomy terms */
.post-categories a,
.post-tags a {
color: var(--color-text-muted);
}
.post-tags {
margin-top: var(--space-lg);
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.tag {
font-size: 0.875rem;
padding: var(--space-sm) var(--space-md);
background: var(--color-border);
border-radius: 4px;
text-decoration: none;
color: var(--color-text-muted);
}
.tag:hover {
background: var(--color-text);
color: var(--color-bg);
}
/* Prose (PortableText content) */
.prose > * + * {
margin-top: var(--space-md);
}
.prose h2,
.prose h3,
.prose h4 {
margin-top: var(--space-lg);
}
/* Archive header */
.archive-header {
margin-bottom: var(--space-lg);
}
/* Utility */
.text-muted {
color: var(--color-text-muted);
}