diff --git a/website/community/how-to-contribute/contribute-blog-posts.md b/website/community/how-to-contribute/contribute-blog-posts.md index 17e82791295..60b379432c7 100644 --- a/website/community/how-to-contribute/contribute-blog-posts.md +++ b/website/community/how-to-contribute/contribute-blog-posts.md @@ -71,15 +71,17 @@ slug: my-post-slug title: "My Blog Post Title" date: YYYY-MM-DD authors: [your_key] -tags: [apache-fluss] +tags: [engineering] +description: "A short summary explaining what readers will learn from this post." image: ./assets/my_post/banner.png --- ``` - `slug` — URL path for the post (for example, `/blog/my-post-slug`). - `authors` — List of author keys defined in `blog/authors.yml`. -- `tags` — List of tag keys defined in `blog/tags.yml`. -- `image` — Optional cover image used for social sharing (Open Graph previews). +- `tags` — One or two category keys from the four categories below. +- `description` — One concise sentence (roughly 120–200 characters) for the homepage card and social sharing. +- `image` — Cover image for the homepage and social sharing. Use an explicit banner path; posts in `blog/releases/` should use `./../assets//banner.png` so Docusaurus bundles the image. ### 3. Add images @@ -89,7 +91,9 @@ Place post-specific images in `blog/assets//` and reference them with ![My Diagram](assets/my_post/diagram.png) ``` -Keep images reasonably sized (compressed PNG or SVG) so the site stays fast to load. +Use a **1200 × 510 px** canvas (40:17, approximately 2.35:1) for blog banners, or **2400 × 1020 px** for high-density screens. Keep titles and logos at least 60 px from the edges of the smaller canvas, and use a short headline that remains legible at a card width of about 400 px. Aim for a compressed image below 300 KB where possible. + +The homepage fits the complete image into a fixed banner frame. Older images with different ratios have padding. For a full-bleed cover, compose a separate banner for this canvas and reference it in `image`; preserve the original illustration in the article body. Avoid stretching images or cropping titles and logos to force a different ratio. ### 4. Add yourself as an author @@ -105,16 +109,18 @@ your_key: Place your avatar image in `blog/static/avatars/`. -### 5. Register new tags +### 5. Choose categories -If your post uses a tag that does not yet exist, define it in `blog/tags.yml`: +Choose one primary category and, when useful, one secondary category from `blog/tags.yml`: -```yaml -my-new-tag: - label: 'My New Tag' -``` +| Key | Label | Use for | +| --- | --- | --- | +| `announcement` | Announcement | Releases, project milestones, and official announcements | +| `case-study` | Case Study | Enterprise case studies and production practices | +| `engineering` | Engineering | Architecture, system internals, and technical deep dives | +| `guides` | Guides | Getting started, how-to guides, application patterns, and best practices | -Reuse existing tags when you can; only add new ones when none of them fit. +Keep the taxonomy limited to these four categories. Put technology names such as Flink, Iceberg, Rust, or Arrow in the title, summary, or article text. For example, a graduation announcement uses `[announcement]`; a production tuning deep dive can use `[engineering, guides]`. ## Preview and Build Locally diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 61dd0cfa7ce..ee8538e9826 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -21,6 +21,7 @@ import lightTheme from './src/utils/prismLight'; import darkTheme from './src/utils/prismDark'; import versionReplace from './src/plugins/remark-version-replace/index'; import { loadVersionData } from './src/utils/versionData'; +import {prepareBlogPosts} from './src/utils/blogPosts'; const { versionsMap, latestVersion } = loadVersionData(); const config: Config = { @@ -170,6 +171,9 @@ const config: Config = { }, blog: { showReadingTime: false, + // The card index filters the full archive, including older posts. + postsPerPage: 'ALL', + processBlogPosts: prepareBlogPosts, feedOptions: { type: ['rss', 'atom'], xslt: true, @@ -393,4 +397,4 @@ const config: Config = { } satisfies Preset.ThemeConfig, }; -export default config; \ No newline at end of file +export default config; diff --git a/website/src/theme/BlogListPage/index.tsx b/website/src/theme/BlogListPage/index.tsx new file mode 100644 index 00000000000..b0fce63ea99 --- /dev/null +++ b/website/src/theme/BlogListPage/index.tsx @@ -0,0 +1,204 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useMemo, useState} from 'react'; +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import useIsBrowser from '@docusaurus/useIsBrowser'; +import {useHistory, useLocation} from '@docusaurus/router'; +import Layout from '@theme/Layout'; +import SearchMetadata from '@theme/SearchMetadata'; +import BlogListPageStructuredData from '@theme/BlogListPage/StructuredData'; +import type {Props} from '@theme/BlogListPage'; + +import styles from './styles.module.css'; + +type Post = Props['items'][number]['content']; +type Tag = Post['metadata']['tags'][number]; +const PAGE_SIZE = 9; +const TAG_ORDER = ['Announcement', 'Case Study', 'Engineering', 'Guides']; +const dateFormat = new Intl.DateTimeFormat('en', { + month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC', +}); + +function PostImage({post, featured = false}: {post: Post; featured?: boolean}): React.JSX.Element { + const [failed, setFailed] = useState(false); + const cover = post.assets.image ?? post.metadata.frontMatter.image; + const imageUrl = useBaseUrl(cover ?? '/img/logo/svg/white_color_logo.svg'); + const logoUrl = useBaseUrl('/img/logo/svg/white_color_logo.svg'); + const hasCover = Boolean(cover) && !failed; + + return ( + + setFailed(true) : undefined} /> + {!hasCover && THE FLUSS BLOG} + + ); +} + +function PostMeta({post}: {post: Post}): React.JSX.Element { + const {authors, date} = post.metadata; + return ( +
+ + {authors.map((author, index) => ( + + {index > 0 && ', '} + {author.url + ? {author.name} + : author.name} + + ))} + + {authors.length > 0 && } + +
+ ); +} + +function PostTags({tags, onSelect}: {tags: readonly Tag[]; onSelect: (label: string) => void}): React.JSX.Element { + return ( +
+ {tags.map((tag) => ( + + ))} + {tags.length === 0 && Article} +
+ ); +} + +function PostCard({post, featured = false, onSelect}: { + post: Post; featured?: boolean; onSelect: (label: string) => void; +}): React.JSX.Element { + const {title, permalink, description, tags} = post.metadata; + const Heading = featured ? 'h2' : 'h3'; + return ( +
+ +
+ {title} + +

{description}

+ +
+
+ ); +} + +function PostGrid({posts, selectedTag, onSelect}: { + posts: Post[]; selectedTag: string; onSelect: (label: string) => void; +}): React.JSX.Element { + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + return ( +
+
+

{posts.length} {posts.length === 1 ? 'article' : 'articles'}

+
+
+ {posts.slice(0, visibleCount).map((post) => ( + + ))} +
+ {posts.length === 0 &&

No articles with this tag.

} + {visibleCount < posts.length && ( +
+ + Showing {Math.min(visibleCount, posts.length)} of {posts.length} +
+ )} +
+ ); +} + +export default function BlogListPage(props: Props): React.JSX.Element { + const history = useHistory(); + const location = useLocation(); + const isBrowser = useIsBrowser(); + const selectedTag = isBrowser ? new URLSearchParams(location.search).get('tag') ?? '' : ''; + const posts = useMemo(() => props.items.map(({content}) => content) + .sort((a, b) => Date.parse(b.metadata.date) - Date.parse(a.metadata.date)), [props.items]); + const tags = useMemo(() => { + const byLabel = new Map(); + posts.forEach((post) => post.metadata.tags.forEach(({label}) => { + const tag = byLabel.get(label) ?? {label, count: 0}; + tag.count += 1; + byLabel.set(label, tag); + })); + return [...byLabel.values()].sort((a, b) => { + const aIndex = TAG_ORDER.indexOf(a.label); + const bIndex = TAG_ORDER.indexOf(b.label); + return (aIndex < 0 ? TAG_ORDER.length : aIndex) + - (bIndex < 0 ? TAG_ORDER.length : bIndex) + || a.label.localeCompare(b.label); + }); + }, [posts]); + + function selectTag(label: string): void { + const params = new URLSearchParams(location.search); + if (label) { + params.set('tag', label); + } else { + params.delete('tag'); + } + history.push({...location, search: params.toString() ? `?${params}` : ''}); + } + + const filteredPosts = selectedTag + ? posts.filter((post) => post.metadata.tags.some(({label}) => label === selectedTag)) + : posts.slice(1); + + return ( + + + +
+
+
+

The Fluss Blog.

+

Engineering, ideas, and stories from the streaming frontier.

+
+
+ + {posts[0] && } + +
+ + {tags.map(({label, count}) => ( + + ))} +
+ + +
+
+ ); +} diff --git a/website/src/theme/BlogListPage/styles.module.css b/website/src/theme/BlogListPage/styles.module.css new file mode 100644 index 00000000000..362d70f1439 --- /dev/null +++ b/website/src/theme/BlogListPage/styles.module.css @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.page { + --blog-text: #102856; + --blog-muted: #53667d; + --blog-border: #dce5ed; + --blog-surface: #fff; + --blog-soft: #f1f5f8; + --blog-accent: #266d95; + --blog-banner-ratio: 1200 / 510; + width: min(1280px, calc(100% - 64px)); + margin: 0 auto; + padding: 32px 0 80px; + color: var(--blog-text); + font-family: 'Geist Variable', sans-serif; +} + +.page h1, .page h2, .page h3 { + color: var(--blog-text); + font-family: inherit; +} + +.pageHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.pageHeader h1 { + margin: 0 0 8px; + font-size: clamp(2rem, 3.4vw, 3rem); + font-weight: 650; + letter-spacing: -0.045em; + line-height: 1.1; +} + +.pageHeader h1 span { color: var(--blog-accent); } +.pageHeader p { margin: 0; color: var(--blog-muted); font-size: 15px; } + +.card { + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--blog-border); + border-radius: 12px; + background: var(--blog-surface); + transition: border-color 180ms ease, box-shadow 180ms ease; +} + +.card:hover { + border-color: var(--blog-accent); + box-shadow: 0 8px 24px rgb(16 40 86 / 7%); +} + +.imageLink { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: var(--blog-banner-ratio); + overflow: hidden; + background: var(--blog-soft); +} + +.imageLink img { display: block; width: 100%; height: 100%; object-fit: contain; } +.cardBody { display: flex; flex: 1; flex-direction: column; align-items: flex-start; padding: 22px; } +.cardTitle { margin: 0 0 12px; font-size: 21px; font-weight: 600; line-height: 1.3; letter-spacing: -0.025em; } +.cardTitle a { color: inherit; text-decoration: none; } +.cardTitle a:hover { color: var(--blog-accent); } + +.meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + margin-bottom: 13px; + color: var(--blog-muted); + font-size: 12px; + line-height: 1.6; +} + +.meta a { color: inherit; } +.meta time { white-space: nowrap; } +.description { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + margin: 0 0 18px; + color: var(--blog-muted); + font-size: 14px; + line-height: 1.6; +} + +.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; } +.tag { + border: 0; + border-radius: 6px; + padding: 6px 10px; + background: var(--blog-soft); + color: var(--blog-muted); + font: inherit; + font-size: 13px; + line-height: 1.4; +} + +button.tag { cursor: pointer; } +button.tag:hover { color: var(--blog-text); background: var(--blog-border); } + +.featured { display: grid; grid-template-columns: 1.05fr 1fr; margin-bottom: 32px; } +.featured .imageLink { align-self: center; } +.featured .cardBody { justify-content: center; padding: 24px 32px; } +.featured .cardTitle { font-size: clamp(1.6rem, 2.1vw, 1.875rem); line-height: 1.2; letter-spacing: -0.035em; } +.featured .description { -webkit-line-clamp: 2; font-size: 15px; } +.featured .tags { margin-top: 0; } + +.fallback { + position: relative; + flex-direction: column; + gap: 22px; + background-color: #102856; + background-image: radial-gradient(circle at 80% 20%, #266d9566, transparent 65%), + repeating-linear-gradient(125deg, transparent 0 50px, #ffffff0a 50px 51px); +} +.fallback img { width: 44%; height: auto; max-height: 110px; object-fit: contain; } +.fallbackLabel { color: #b3cee0; font-size: 10px; letter-spacing: 0.24em; } + +.filters { display: flex; flex-wrap: wrap; gap: 8px; padding-bottom: 26px; border-bottom: 1px solid var(--blog-border); } +.filter { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 10px 14px; + border: 1px solid var(--blog-border); + border-radius: 6px; + background: var(--blog-surface); + color: var(--blog-muted); + cursor: pointer; + font: inherit; + font-size: 14px; + line-height: 1.4; + transition: background 150ms ease, color 150ms ease; +} +.filter span { font-size: 12px; opacity: 0.75; } +.filter:hover { color: var(--blog-text); border-color: var(--blog-accent); } +.activeFilter, .activeFilter:hover { color: #fff; border-color: #102856; background: #102856; } +.archive { padding-top: 26px; } +.archiveHeader { display: flex; justify-content: flex-end; margin-bottom: 16px; } +.archiveHeader p { margin: 0; color: var(--blog-muted); font-size: 12px; white-space: nowrap; } +.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; } +.loadMore { display: flex; flex-direction: column; align-items: center; gap: 12px; margin-top: 36px; } +.loadMore button, .empty button { border: 1px solid var(--blog-border); border-radius: 6px; background: var(--blog-surface); color: var(--blog-text); padding: 11px 20px; font: inherit; font-size: 13px; cursor: pointer; } +.loadMore button:hover, .empty button:hover { background: var(--blog-soft); border-color: var(--blog-accent); } +.loadMore button span { margin-left: 18px; } +.loadMore > span { color: var(--blog-muted); font-size: 12px; } +.empty { padding: 32px 0; color: var(--blog-muted); } +.page :is(a, button):focus-visible { outline: 2px solid var(--blog-accent); outline-offset: 4px; } + +:global([data-theme='dark']) .page { + --blog-text: #e0eaf4; + --blog-muted: #a7b8cd; + --blog-border: #2b3c54; + --blog-surface: #102139; + --blog-soft: #1b3049; + --blog-accent: #7aafcb; +} +:global([data-theme='dark']) .activeFilter { background: #d6e4ed; color: #102856; border-color: #d6e4ed; } + +@media (max-width: 996px) { + .page { width: calc(100% - 40px); padding-top: 32px; } + .featured .cardBody { padding: 24px; } + .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; } +} + +@media (max-width: 800px) { + .featured { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .page { width: calc(100% - 32px); padding-top: 26px; } + .pageHeader { align-items: flex-start; gap: 16px; margin-bottom: 24px; } + .pageHeader h1 { font-size: 30px; } + .pageHeader p { font-size: 13px; } + .featured { margin-bottom: 24px; } + .featured .cardBody { padding: 22px; } + .featured .cardTitle { font-size: 25px; } + .filters { flex-wrap: nowrap; overflow-x: auto; padding: 2px 2px 18px; } + .filter { flex-shrink: 0; } + .archive { padding-top: 20px; } + .grid { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + .card, .filter { transition: none; } +} diff --git a/website/src/utils/blogPosts.ts b/website/src/utils/blogPosts.ts new file mode 100644 index 00000000000..3056db528a3 --- /dev/null +++ b/website/src/utils/blogPosts.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type {ProcessBlogPostsFn} from '@docusaurus/plugin-content-blog'; +import {createExcerpt} from '@docusaurus/utils'; + +// Older posts often put a banner at the start of the body without setting +// front matter. Let Docusaurus bundle that image just like an explicit cover. +// Only use a leading image, so diagrams inside an article do not become covers. +export const prepareBlogPosts: ProcessBlogPostsFn = async ({blogPosts}) => + blogPosts.map((post) => { + const body = post.content.trimStart(); + const introduction = body.replace(/^(?:#{1,6}[ \t]+[^\n]*\r?\n\s*)+/, ''); + const banner = introduction.match(/^!\[[^\]]*\]\(([^\s)]+)\)/)?.[1]; + const cover = post.metadata.frontMatter.image ?? banner; + // Docusaurus only bundles image assets beginning with "./". This also + // makes parent-relative covers ("../assets/...") work for release posts. + const image = cover && !/^(?:[a-z][a-z\d+.-]*:|\/|\.\/)/i.test(cover) + ? `./${cover}` + : cover; + + // Docusaurus can use an image's alt text (often just "Banner") as the + // default excerpt. Skip standalone images and headings for card summaries. + const prose = body + .replace(//g, '') + .replace(/^\s*!\[[^\n]*\]\([^\n]*\)\s*$/gm, '') + .replace(/^#{1,6}\s+.*$/gm, '') + .replace(/^\*\*[^*\r\n]+:\*\*[ \t]*$/gm, '') + .replace(/^[ \t]*>[ \t]?/gm, ''); + const paragraph = prose.split(/\r?\n\s*\r?\n/).find((text) => createExcerpt(text)); + const description = post.metadata.frontMatter.description + ?? createExcerpt((paragraph ?? '').replace(/\r?\n/g, ' ')) + ?? post.metadata.description; + return { + ...post, + metadata: { + ...post.metadata, + description, + frontMatter: {...post.metadata.frontMatter, image}, + }, + }; + });