diff --git a/assets/blog/cover-1.svg b/assets/blog/cover-1.svg new file mode 100644 index 00000000..40f37a18 --- /dev/null +++ b/assets/blog/cover-1.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/blog/cover-2.svg b/assets/blog/cover-2.svg new file mode 100644 index 00000000..6f4ae860 --- /dev/null +++ b/assets/blog/cover-2.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/blog/cover-3.svg b/assets/blog/cover-3.svg new file mode 100644 index 00000000..6aa96998 --- /dev/null +++ b/assets/blog/cover-3.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/blog/cover-4.svg b/assets/blog/cover-4.svg new file mode 100644 index 00000000..dded0fd1 --- /dev/null +++ b/assets/blog/cover-4.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/components/Blog/Byline/index.jsx b/components/Blog/Byline/index.jsx new file mode 100644 index 00000000..d4ace2b0 --- /dev/null +++ b/components/Blog/Byline/index.jsx @@ -0,0 +1,83 @@ +import classNames from 'classnames'; +import AvatarGroup from '@node-core/ui-components/Common/AvatarGroup'; +import Avatar from '@node-core/ui-components/Common/AvatarGroup/Avatar'; + +import styles from './index.module.css'; + +const fullDate = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', + timeZone: 'UTC', + year: 'numeric', +}); + +const shortDate = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', + timeZone: 'UTC', +}); + +/** + * ui-components avatar descriptor for a GitHub username. `image` pulls the + * profile picture straight from GitHub; `url` is omitted on purpose so the + * Avatar renders as a `
` rather than an `` (a post card already wraps + * the byline in a link, and nested anchors are invalid). + */ +const toAvatar = username => ({ + nickname: username, + name: username, + image: `https://github.com/${username}.png`, + fallback: username.slice(0, 2).toUpperCase(), +}); + +/** Readable author label: one name, "A & B", or "A & N others". */ +const authorLabel = authors => { + if (authors.length <= 1) return authors[0] ?? 'webpack'; + if (authors.length === 2) return `${authors[0]} & ${authors[1]}`; + return `${authors[0]} & ${authors.length - 1} others`; +}; + +/** + * GitHub avatar(s) of the post author(s) beside the author name(s) and the + * publish date. A single author renders one {@link Avatar}; several render an + * {@link AvatarGroup}. `size="md"` stacks the name above the date (featured + * card); `size="sm"` is a compact inline row (grid card). + * + * @param {{ + * authors: string[], + * date: string, + * size?: 'sm'|'md', + * className?: string, + * }} props + */ +export default function Byline({ authors, date, size = 'sm', className }) { + const format = size === 'md' ? fullDate : shortDate; + const avatarSize = size === 'md' ? 'medium' : 'small'; + + return ( +
+ {CLIENT && authors.length > 0 && ( + + {authors.length === 1 ? ( + + ) : ( + + )} + + )} + + {authorLabel(authors)} + + + +
+ ); +} diff --git a/components/Blog/Byline/index.module.css b/components/Blog/Byline/index.module.css new file mode 100644 index 00000000..b9501492 --- /dev/null +++ b/components/Blog/Byline/index.module.css @@ -0,0 +1,66 @@ +@reference "../../../styles/index.css"; + +.byline { + @apply flex + items-center + gap-3; +} + +.avatars { + @apply inline-flex + shrink-0 + items-center; +} + +.meta { + @apply flex + min-w-0 + items-center + gap-1.5; +} + +.who { + @apply truncate + font-semibold + text-neutral-900 + dark:text-white; +} + +.date { + @apply shrink-0 + font-mono + text-neutral-500 + dark:text-neutral-400; +} + +.sep { + @apply text-neutral-300 + dark:text-neutral-600; +} + +.sm { + @apply gap-2.5; +} + +.sm .who, +.sm .date { + @apply text-[13px]; +} + +.md .meta { + @apply flex-col + items-start + gap-0.5; +} + +.md .sep { + @apply hidden; +} + +.md .who { + @apply text-sm; +} + +.md .date { + @apply text-[13px]; +} diff --git a/components/Blog/CategoryFilter/index.jsx b/components/Blog/CategoryFilter/index.jsx new file mode 100644 index 00000000..b2286cbc --- /dev/null +++ b/components/Blog/CategoryFilter/index.jsx @@ -0,0 +1,37 @@ +import classNames from 'classnames'; + +import styles from './index.module.css'; + +const ALL = 'all'; + +/** + * Category chips rendered as links. Selecting one navigates to `?category=…` + * (and back to `/blog` for "All posts"), which the layout reads from the URL. + * + * @param {{ + * categories: string[], + * active: string, + * hrefFor: (category: string) => string, + * }} props + */ +export default function CategoryFilter({ categories, active, hrefFor }) { + const chips = [ + { key: ALL, label: 'All posts' }, + ...categories.map(category => ({ key: category, label: category })), + ]; + + return ( +
+ {chips.map(({ key, label }) => ( + + {label} + + ))} +
+ ); +} diff --git a/components/Blog/CategoryFilter/index.module.css b/components/Blog/CategoryFilter/index.module.css new file mode 100644 index 00000000..e3279570 --- /dev/null +++ b/components/Blog/CategoryFilter/index.module.css @@ -0,0 +1,43 @@ +@reference "../../../styles/index.css"; + +.filters { + @apply flex + flex-wrap + gap-2 + pt-7; +} + +.chip { + @apply cursor-pointer + rounded-full + border + border-transparent + bg-neutral-100 + px-3.5 + py-1.5 + text-[13px] + font-semibold + text-neutral-600 + no-underline + transition-colors + duration-150 + hover:bg-neutral-200 + hover:text-neutral-900 + dark:bg-neutral-900 + dark:text-neutral-300 + dark:hover:bg-neutral-800 + dark:hover:text-white; +} + +.active { + @apply border-blue-200 + bg-blue-100 + text-blue-700 + hover:bg-blue-100 + hover:text-blue-700 + dark:border-blue-900 + dark:bg-blue-950 + dark:text-blue-300 + dark:hover:bg-blue-950 + dark:hover:text-blue-300; +} diff --git a/components/Blog/Cover/index.jsx b/components/Blog/Cover/index.jsx new file mode 100644 index 00000000..4abe8e99 --- /dev/null +++ b/components/Blog/Cover/index.jsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; + +import Cube from '../../Icons/Webpack.jsx'; + +import styles from './index.module.css'; + +/** + * Geometric brand cover for a post. Draws the hairline pattern and the cube + * placeholder, layering the hero `image` over it when present. `tag` renders + * the category chip. + * + * @param {{ + * image?: string|null, + * tag?: string|null, + * alt?: string, + * className?: string, + * }} props + */ +export default function Cover({ image, tag, alt = '', className }) { + return ( + + {tag && {tag}} + + {image && ( + {alt} + )} + + ); +} diff --git a/components/Blog/Cover/index.module.css b/components/Blog/Cover/index.module.css new file mode 100644 index 00000000..f3f5f1d8 --- /dev/null +++ b/components/Blog/Cover/index.module.css @@ -0,0 +1,68 @@ +@reference "../../../styles/index.css"; + +.cover { + @apply relative + flex + items-end + overflow-hidden + rounded-lg + border + border-neutral-200 + bg-neutral-100 + dark:border-neutral-800 + dark:bg-neutral-900; +} + +.cover::before { + background-image: repeating-linear-gradient( + 135deg, + rgb(28 120 192 / 7%) 0 1px, + transparent 1px 14px + ); + content: ''; + inset: 0; + position: absolute; +} + +.tag { + @apply absolute + left-3.5 + top-3.5 + z-20 + inline-flex + items-center + rounded-full + bg-white + px-2.5 + py-1 + text-xs + font-bold + text-blue-700 + shadow-sm + dark:bg-neutral-900 + dark:text-blue-300; +} + +.cube { + @apply pointer-events-none + absolute + -bottom-7 + -right-7 + z-0 + block + w-36 + opacity-[0.14]; +} + +.cube svg { + height: auto; + width: 100%; +} + +.image { + @apply absolute + inset-0 + z-10 + size-full + object-cover; +} diff --git a/components/Blog/PostCard/index.jsx b/components/Blog/PostCard/index.jsx new file mode 100644 index 00000000..efee9ffb --- /dev/null +++ b/components/Blog/PostCard/index.jsx @@ -0,0 +1,34 @@ +import Byline from '../Byline/index.jsx'; +import Cover from '../Cover/index.jsx'; + +import styles from './index.module.css'; + +/** + * A post tile for the recent-posts grid: cover, category, title, optional + * excerpt, and a compact byline. + * + * @param {{ post: object }} props + */ +export default function PostCard({ post }) { + return ( + + + {post.category && ( + {post.category} + )} +

{post.title}

+ {post.description &&

{post.description}

} + +
+ ); +} diff --git a/components/Blog/PostCard/index.module.css b/components/Blog/PostCard/index.module.css new file mode 100644 index 00000000..b75748c5 --- /dev/null +++ b/components/Blog/PostCard/index.module.css @@ -0,0 +1,59 @@ +@reference "../../../styles/index.css"; + +.card { + @apply flex + h-full + flex-col + no-underline; +} + +.cover { + @apply mb-4 + aspect-[16/10] + transition-shadow + duration-150; +} + +.card:hover .cover { + @apply shadow-md; +} + +.category { + @apply mb-2 + text-xs + font-bold + tracking-wide + uppercase + text-blue-600 + dark:text-blue-400; +} + +.title { + @apply m-0 + mb-2 + text-lg + font-semibold + leading-snug + tracking-tight + text-neutral-900 + dark:text-white; +} + +.card:hover .title { + @apply text-blue-700 + dark:text-blue-300; +} + +.excerpt { + @apply m-0 + mb-4 + text-sm + leading-relaxed + text-neutral-600 + dark:text-neutral-300; +} + +.foot { + @apply mt-auto + pt-1; +} diff --git a/components/Layout.jsx b/components/Layout.jsx index 66c1b1c7..e06d5d6e 100644 --- a/components/Layout.jsx +++ b/components/Layout.jsx @@ -1,11 +1,15 @@ import DefaultLayout from '@node-core/doc-kit/src/generators/web/ui/components/Layout/index.jsx'; import HomeLayout from '../layouts/Home/index.jsx'; import SponsorsLayout from '../layouts/Sponsors/index.jsx'; +import BlogLayout from '../layouts/Blog/index.jsx'; +import PostLayout from '../layouts/Post/index.jsx'; import '../styles/index.css'; const LAYOUTS = { home: HomeLayout, sponsors: SponsorsLayout, + blog: BlogLayout, + post: PostLayout, }; export default function Layout(props) { diff --git a/layouts/Blog/index.jsx b/layouts/Blog/index.jsx new file mode 100644 index 00000000..ee2e1e98 --- /dev/null +++ b/layouts/Blog/index.jsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; + +import BasePagination from '@node-core/ui-components/Common/BasePagination'; + +import NavBar from '../../components/NavBar.jsx'; +import Footer from '../../components/Footer/index.jsx'; +import CategoryFilter from '../../components/Blog/CategoryFilter/index.jsx'; +import PostCard from '../../components/Blog/PostCard/index.jsx'; +import data from '#theme/blog' with { type: 'json' }; + +import styles from './index.module.css'; + +const ALL = 'all'; +const PAGE_SIZE = 6; + +// Unique categories in first-appearance order (data is sorted newest-first). +const CATEGORIES = [ + ...new Set(data.map(post => post.category).filter(Boolean)), +]; + +const PAGINATION_LABELS = { + aria: 'Blog pages', + next: 'Next', + nextAriaLabel: 'Next page', + previous: 'Previous', + previousAriaLabel: 'Previous page', +}; + +/** Build a `/blog` URL carrying the active category and page (defaults omitted). */ +const hrefFor = ({ category, page }) => { + const params = new URLSearchParams(); + if (category && category !== ALL) params.set('category', category); + if (page && page > 1) params.set('page', String(page)); + const query = params.toString(); + return query ? `/blog?${query}` : '/blog'; +}; + +/** + * @param {{ metadata: object }} props + */ +export default function BlogLayout({ metadata }) { + const [{ category, page }, setView] = useState({ category: ALL, page: 1 }); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const requested = Number.parseInt(params.get('page'), 10); + setView({ + category: params.get('category') ?? ALL, + page: Number.isInteger(requested) && requested > 1 ? requested : 1, + }); + }, []); + + const pool = + category === ALL ? data : data.filter(post => post.category === category); + + const pageCount = Math.max(1, Math.ceil(pool.length / PAGE_SIZE)); + const current = Math.min(Math.max(page, 1), pageCount); + const start = (current - 1) * PAGE_SIZE; + const visible = pool.slice(start, start + PAGE_SIZE); + + const pages = Array.from({ length: pageCount }, (_, index) => ({ + url: hrefFor({ category, page: index + 1 }), + })); + + return ( + <> + + +
+
+
+

+ + The webpack blog +

+

Notes from the bundler.

+

+ Release notes, deep dives into the chunk graph, and engineering + write-ups from the webpack core team and the wider community. +

+ hrefFor({ category: key, page: 1 })} + /> +
+ +
+

Recent posts

+ + {visible.length > 0 ? ( +
+ {visible.map(post => ( + + ))} +
+ ) : ( +

No posts in this category yet.

+ )} + + {pageCount > 1 && ( +
+ `Page ${number}`} + labels={PAGINATION_LABELS} + /> +
+ )} +
+
+
+ +