Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions website/community/how-to-contribute/contribute-blog-posts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<my_post>/banner.png` so Docusaurus bundles the image.

### 3. Add images

Expand All @@ -89,7 +91,9 @@ Place post-specific images in `blog/assets/<post_name>/` 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

Expand All @@ -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

Expand Down
6 changes: 5 additions & 1 deletion website/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -393,4 +397,4 @@ const config: Config = {
} satisfies Preset.ThemeConfig,
};

export default config;
export default config;
204 changes: 204 additions & 0 deletions website/src/theme/BlogListPage/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link to={post.metadata.permalink} className={clsx(styles.imageLink, !hasCover && styles.fallback)}
tabIndex={-1} aria-hidden="true">
<img src={hasCover ? imageUrl : logoUrl} alt="" loading={featured ? 'eager' : 'lazy'}
decoding="async" onError={hasCover ? () => setFailed(true) : undefined} />
{!hasCover && <span className={styles.fallbackLabel}>THE FLUSS BLOG</span>}
</Link>
);
}

function PostMeta({post}: {post: Post}): React.JSX.Element {
const {authors, date} = post.metadata;
return (
<div className={styles.meta}>
<span>
{authors.map((author, index) => (
<React.Fragment key={index}>
{index > 0 && ', '}
{author.url
? <Link href={author.url}>{author.name}</Link>
: author.name}
</React.Fragment>
))}
</span>
{authors.length > 0 && <span aria-hidden="true">·</span>}
<time dateTime={date}>{dateFormat.format(new Date(date))}</time>
</div>
);
}

function PostTags({tags, onSelect}: {tags: readonly Tag[]; onSelect: (label: string) => void}): React.JSX.Element {
return (
<div className={styles.tags}>
{tags.map((tag) => (
<button type="button" key={tag.permalink} onClick={() => onSelect(tag.label)}
aria-label={`Filter by ${tag.label}`} className={styles.tag}>
{tag.label}
</button>
))}
{tags.length === 0 && <span className={styles.tag}>Article</span>}
</div>
);
}

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 (
<article className={clsx(styles.card, featured && styles.featured)}>
<PostImage post={post} featured={featured} />
<div className={styles.cardBody}>
<Heading className={styles.cardTitle}><Link to={permalink}>{title}</Link></Heading>
<PostMeta post={post} />
<p className={styles.description}>{description}</p>
<PostTags tags={tags} onSelect={onSelect} />
</div>
</article>
);
}

function PostGrid({posts, selectedTag, onSelect}: {
posts: Post[]; selectedTag: string; onSelect: (label: string) => void;
}): React.JSX.Element {
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
return (
<section aria-label={selectedTag ? `${selectedTag} articles` : 'All articles'} className={styles.archive}>
<div className={styles.archiveHeader}>
<p role="status">{posts.length} {posts.length === 1 ? 'article' : 'articles'}</p>
</div>
<div className={styles.grid}>
{posts.slice(0, visibleCount).map((post) => (
<PostCard key={post.metadata.permalink} post={post} onSelect={onSelect} />
))}
</div>
{posts.length === 0 && <p className={styles.empty}>No articles with this tag. <button type="button" onClick={() => onSelect('')}>View all articles</button></p>}
{visibleCount < posts.length && (
<div className={styles.loadMore}>
<button type="button" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>
Load more articles <span aria-hidden="true">↓</span>
</button>
<span>Showing {Math.min(visibleCount, posts.length)} of {posts.length}</span>
</div>
)}
</section>
);
}

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<string, {label: string; count: number}>();
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 (
<Layout title="Blog" description={props.metadata.blogDescription}>
<SearchMetadata tag="blog_posts_list" />
<BlogListPageStructuredData {...props} />
<main className={styles.page}>
<header className={styles.pageHeader}>
<div>
<h1>The Fluss Blog<span>.</span></h1>
<p>Engineering, ideas, and stories from the streaming frontier.</p>
</div>
</header>

{posts[0] && <PostCard post={posts[0]} featured onSelect={selectTag} />}

<div className={styles.filters} role="group" aria-label="Filter articles by tag">
<button type="button" className={clsx(styles.filter, !selectedTag && styles.activeFilter)}
aria-pressed={!selectedTag} onClick={() => selectTag('')}>
All posts <span>{posts.length}</span>
</button>
{tags.map(({label, count}) => (
<button type="button" key={label}
className={clsx(styles.filter, selectedTag === label && styles.activeFilter)}
aria-pressed={selectedTag === label} onClick={() => selectTag(label)}>
{label} <span>{count}</span>
</button>
))}
</div>

<PostGrid key={selectedTag} posts={filteredPosts} selectedTag={selectedTag} onSelect={selectTag} />
</main>
</Layout>
);
}
Loading
Loading