diff --git a/demos/playground/astro.config.mjs b/demos/playground/astro.config.mjs index 3b8d2e9123..a983708587 100644 --- a/demos/playground/astro.config.mjs +++ b/demos/playground/astro.config.mjs @@ -32,7 +32,7 @@ export default defineConfig({ { provider: fontProviders.google(), name: "Inter", - cssVariable: "--font-sans", + cssVariable: "--font-body", weights: [400, 500, 600, 700], fallbacks: ["sans-serif"], }, diff --git a/demos/playground/src/components/Byline.astro b/demos/playground/src/components/Byline.astro new file mode 100644 index 0000000000..a76857c0d2 --- /dev/null +++ b/demos/playground/src/components/Byline.astro @@ -0,0 +1,230 @@ +--- +import type { ContentBylineCredit } from "emdash"; + +interface Props { + bylines: ContentBylineCredit[]; + /** inline: stacked avatars + names on one line. list: one row per author with role. */ + layout?: "inline" | "list"; + /** How many names the inline layout spells out before collapsing the rest into "+N" */ + max?: number; + size?: "xs" | "sm" | "md" | "lg"; + class?: string; +} + +const { + bylines, + layout = "inline", + max = 1, + size = layout === "list" ? "lg" : "xs", + class: className, +} = Astro.props; + +const WHITESPACE = /\s+/; + +const people = bylines.map(({ byline, roleLabel }) => ({ + name: byline.displayName, + role: roleLabel, + src: byline.avatarStorageKey + ? (Astro.locals.emdash?.getPublicMediaUrl?.(byline.avatarStorageKey) ?? + `/_emdash/api/media/file/${byline.avatarStorageKey.split("/").map(encodeURIComponent).join("/")}`) + : null, + initials: byline.displayName + .split(WHITESPACE) + .filter(Boolean) + .slice(0, layout === "list" ? 2 : 1) + .map((word) => [...word][0]?.toUpperCase() ?? "") + .join(""), +})); +const shown = people.slice(0, max).map((person) => person.name); +const hidden = people.slice(max).map((person) => person.name); +--- + +{ + people.length > 0 && layout === "list" && ( + + ) +} +{ + people.length > 0 && layout === "inline" && ( + + + + {shown.map((name, index) => ( + <> + {index > 0 && + (index === shown.length - 1 && hidden.length === 0 + ? " and " + : ", ")} + {name} + + ))} + {hidden.length > 0 && ( + <> + + and {hidden.join(", ")} + + )} + + + ) +} + + diff --git a/demos/playground/src/components/Pagination.astro b/demos/playground/src/components/Pagination.astro new file mode 100644 index 0000000000..6035c371d2 --- /dev/null +++ b/demos/playground/src/components/Pagination.astro @@ -0,0 +1,71 @@ +--- +interface Props { + /** Path of the first page, e.g. "/posts" */ + basePath: string; + cursor?: string; + nextCursor?: string; +} + +const { basePath, cursor, nextCursor } = Astro.props; +--- + +{ + (cursor || nextCursor) && ( + + ) +} + + diff --git a/demos/playground/src/components/PostCard.astro b/demos/playground/src/components/PostCard.astro index 542fe0385b..762c89031b 100644 --- a/demos/playground/src/components/PostCard.astro +++ b/demos/playground/src/components/PostCard.astro @@ -1,281 +1,449 @@ --- -import type { MediaValue, ContentBylineCredit, EditProxy } from "emdash"; +import type { ContentEntry, InferCollectionData } from "emdash"; import { Image } from "emdash/ui"; +import Byline from "./Byline.astro"; +import { formatDate } from "../utils/locale"; +import { formatReadingTime, getReadingTime } from "../utils/reading-time"; interface Props { - title: string; - excerpt?: string; - featuredImage?: MediaValue | string; - href: string; - date?: Date; - readingTime?: number; + post: ContentEntry>; tags?: Array<{ slug: string; label: string }>; - bylines?: ContentBylineCredit[]; - edit: EditProxy; + /** card: grid tile. feature: large split card. row: archive list item. */ + variant?: "card" | "feature" | "row"; + headingLevel?: 2 | 3; + /** Load the image eagerly; use for the largest image above the fold. */ + priority?: boolean; } const { - title, - excerpt, - featuredImage, - href, - date, - readingTime, - tags, - bylines, - edit, + post, + tags = [], + variant = "card", + headingLevel = 2, + priority = false, } = Astro.props; -const formattedDate = date - ? date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }) - : null; +const Heading = `h${headingLevel}` as const; +const href = `/posts/${post.id}`; +const titleId = `post-${variant}-${post.id}`; +const title = post.data.title || "Untitled"; +const publishedAt = post.data.publishedAt ?? null; +const readingTime = formatReadingTime(getReadingTime(post.data.content)); +const bylines = post.data.bylines ?? []; +const visibleTags = tags.slice(0, variant === "card" ? 2 : 3); + +const sizes = { + card: "(min-width: 1080px) 360px, (min-width: 700px) 45vw, 100vw", + feature: "(min-width: 900px) 640px, 100vw", + row: "(min-width: 720px) 240px, 120px", +}[variant]; --- -
- +
+
diff --git a/demos/playground/src/components/TagList.astro b/demos/playground/src/components/TagList.astro deleted file mode 100644 index d93122d266..0000000000 --- a/demos/playground/src/components/TagList.astro +++ /dev/null @@ -1,45 +0,0 @@ ---- -interface Props { - tags: Array<{ slug: string; label: string }>; - class?: string; -} - -const { tags, class: className } = Astro.props; ---- - -{tags.length > 0 && ( - -)} - - diff --git a/demos/playground/src/layouts/Base.astro b/demos/playground/src/layouts/Base.astro index df078d8c05..fe17cfb2e9 100644 --- a/demos/playground/src/layouts/Base.astro +++ b/demos/playground/src/layouts/Base.astro @@ -1,5 +1,5 @@ --- -import { getMenu, getEmDashCollection, getSiteSettings } from "emdash"; +import { getMenuWithCacheHint, getSiteSettingsWithCacheHint } from "emdash"; import { WidgetArea, EmDashHead, @@ -9,7 +9,9 @@ import { import { createPublicPageContext } from "emdash/page"; import LiveSearch from "emdash/ui/search"; import { Font } from "astro:assets"; +import { getSiteLocale } from "../utils/locale"; import { resolveBlogSiteIdentity } from "../utils/site-identity"; +import "../styles/tokens.css"; import "../styles/theme.css"; interface Props { @@ -40,20 +42,26 @@ const { author, content, } = Astro.props; -const { siteTitle, siteTagline, siteLogo } = resolveBlogSiteIdentity(await getSiteSettings()); -// 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"); +const [settingsResult, menuResult] = await Promise.all([ + getSiteSettingsWithCacheHint(), + getMenuWithCacheHint("primary"), +]); +if (Astro.cache?.enabled) { + Astro.cache.set(settingsResult.cacheHint); + Astro.cache.set(menuResult.cacheHint); +} -// Optional "social" menu. If a site defines a `social` menu in its seed, -// it'll render in the footer's "Connect" column. Otherwise that column -// just shows RSS, avoiding duplication with the "Navigate" column above. -const socialMenu = await getMenu("social"); +const { siteTitle, siteTagline, siteLogo } = resolveBlogSiteIdentity( + settingsResult.data, +); +const titleIncludesSiteName = + title === siteTitle || + title.endsWith(` | ${siteTitle}`) || + title.endsWith(` — ${siteTitle}`); +const fullTitle = titleIncludesSiteName ? title : `${title} — ${siteTitle}`; -// Fetch pages for footer -const { entries: pages } = await getEmDashCollection("pages"); +const menu = menuResult.data; +const menuItems = menu?.items ?? []; // Build public page context for plugin contributions // SEO data is passed here and rendered securely by EmDashHead @@ -74,107 +82,214 @@ const pageCtx = createPublicPageContext({ // Check if user is logged in (for showing admin link) const isLoggedIn = !!Astro.locals.user; + +const TRAILING_SLASH = /\/$/; +const currentPath = Astro.url.pathname.replace(TRAILING_SLASH, "") || "/"; + +/** "page" for the current page, "true" for its parent section (e.g. Posts on a post). */ +function navCurrent(url: string): "page" | "true" | undefined { + let target: URL; + try { + target = new URL(url, Astro.url); + } catch { + return undefined; + } + if (target.origin !== Astro.url.origin || target.hash) return undefined; + if (target.search && target.search !== Astro.url.search) return undefined; + const path = target.pathname.replace(TRAILING_SLASH, "") || "/"; + if (path === currentPath) return "page"; + if (path !== "/" && currentPath.startsWith(`${path}/`)) return "true"; + return undefined; +} + +const lang = getSiteLocale() ?? "en"; +const monogram = [...siteTitle.trim()][0]?.toUpperCase() ?? ""; +const year = new Date().getFullYear(); --- - + - + {fullTitle} + + + -
+
-