feat(blog): grouped list, pinned posts, author/date/copy-link metadata (#20)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
title: "Introducing PenguinHarness: agents that build agents"
|
||||
date: 2026-07-17
|
||||
category: news
|
||||
pinned: true
|
||||
excerpt: We proved agents can self-evolve in our GDPevo Benchmark — now we are bringing that capability to everyone. The first open-source harness with recursive self-improvement covers everything from one-sentence agent construction to continuous self-evolution.
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: PenguinHarness 正式发布:让 Agent 为你构建 Agent
|
||||
date: 2026-07-17
|
||||
category: news
|
||||
pinned: true
|
||||
excerpt: 我们在 GDPevo Benchmark 中验证了 Agent 自我进化的能力,现在把它带给所有人——首个支持递归自我进化的开源 Harness 正式发布,从一句话构建 Agent 到持续自我进化,一套基础设施全部覆盖。
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "A closer look at PenguinHarness — and running a self-improving agent locally on an AMD GPU"
|
||||
date: 2026-07-20
|
||||
category: news
|
||||
category: practice
|
||||
author: Ning Zhang (AMD), Yuyang Gao (AMD), Yaowei Zheng (PrismShadow)
|
||||
excerpt: What PenguinHarness actually is, the ideas behind its architecture, and a real end-to-end run — a local open-weight model on an AMD GPU that fails a scored task, then improves itself from 0 to 4 out of 5 by editing its own files, entirely on-device.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: 深入了解 PenguinHarness——并在 AMD GPU 上本地跑通一个自我进化的 Agent
|
||||
date: 2026-07-20
|
||||
category: news
|
||||
category: practice
|
||||
author: 张宁(AMD)、高钰洋(AMD)、郑耀威(PrismShadow)
|
||||
excerpt: PenguinHarness 到底是什么、它的架构背后有哪些理念,以及一次真实的端到端运行——一个完全本地、运行在 AMD GPU 上的开源权重模型,先在一个打分任务上失败,再通过编辑自己的文件把分数从 0 提升到 4(满分 5),全程数据不出本机。
|
||||
---
|
||||
|
||||
|
||||
+2
-3
@@ -1,15 +1,14 @@
|
||||
---
|
||||
title: "Implementing Agent Self-Improvement with PenguinHarness on an AMD GPU"
|
||||
date: 2026-07-22
|
||||
category: "news"
|
||||
category: practice
|
||||
author: Yuyang Gao (AMD), Ning Zhang (AMD), Yaowei Zheng (PrismShadow)
|
||||
excerpt: "A complete PenguinHarness self-improvement loop—from baseline evaluation and Trace analysis to Agent optimization and rollback—using a local Qwen3:8B model alongside the Fireworks API."
|
||||
description: "Learn how PenguinHarness combines Benchmarks, Traces, editable Agent State, Snapshots, and rollback in a dual-model experiment with local Qwen3:8B and the Fireworks API."
|
||||
---
|
||||
|
||||
|
||||
|
||||
# Implementing Agent Self-Improvement with PenguinHarness on an AMD GPU
|
||||
|
||||
*AMD × PrismShadow — Yuyang Gao and Ning Zhang (AMD), Yaowei Zheng (PrismShadow).*
|
||||
|
||||
[PenguinHarness](https://github.com/Prism-Shadow/penguin-harness) is an open-source Agent Harness that brings model integrations, Agent configuration, workspace tools, Sessions, Traces, Skills, and Benchmarks into one runtime, with both a CLI and a Web UI. It can use hosted models as well as local models exposed through an OpenAI-compatible endpoint.
|
||||
+2
-3
@@ -1,12 +1,11 @@
|
||||
---
|
||||
title: "在 AMD GPU 上用 PenguinHarness 实现 Agent 自我进化"
|
||||
date: 2026-07-22
|
||||
category: "news"
|
||||
category: practice
|
||||
author: 高钰洋(AMD)、张宁(AMD)、郑耀威(PrismShadow)
|
||||
excerpt: "通过本地 Qwen3:8B 与 Fireworks API 的双模型分工,完整演示 PenguinHarness 从基线评测、Trace 分析到 Agent 优化与回滚的自我进化闭环。"
|
||||
description: "介绍 PenguinHarness 如何通过 Benchmark、Trace、可编辑的 Agent State 与 Snapshot 回滚构建自我进化闭环,并用本地 Qwen3:8B 与 Fireworks API 完成一次双模型实验。"
|
||||
---
|
||||
# 在 AMD GPU 上用 PenguinHarness 实现 Agent 自我进化
|
||||
|
||||
*AMD × PrismShadow——高钰洋、张宁(AMD),郑耀威(PrismShadow)。*
|
||||
|
||||
[PenguinHarness](https://github.com/Prism-Shadow/penguin-harness) 是一个开源的 Agent Harness。它把模型接入、Agent 配置、工作区工具、Session、Trace、Skill 和 Benchmark 放在同一套运行环境中,并同时提供 CLI 与 Web UI。在线模型和通过 OpenAI 兼容接口暴露的本地模型,都可以作为其中的推理后端。
|
||||
@@ -1,18 +1,32 @@
|
||||
/** Blog category badge: product news (brand tint) vs release notes (neutral tint). */
|
||||
/**
|
||||
* Blog badges: category (news = brand fill, practice = brand outline, changelog =
|
||||
* neutral) + the pinned marker, all sharing the same pill shape.
|
||||
*/
|
||||
import { S } from "../lib/strings";
|
||||
import type { BlogCategory } from "../lib/blog";
|
||||
|
||||
const CATEGORY_STYLES: Record<BlogCategory, string> = {
|
||||
news: "border-brand-200 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-300",
|
||||
practice:
|
||||
"border-brand-200 bg-transparent text-brand-700 dark:border-brand-800 dark:text-brand-300",
|
||||
changelog:
|
||||
"border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
};
|
||||
|
||||
export function CategoryBadge({ category }: { category: BlogCategory }) {
|
||||
const isNews = category === "news";
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${
|
||||
isNews
|
||||
? "border-brand-200 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-300"
|
||||
: "border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
}`}
|
||||
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${CATEGORY_STYLES[category]}`}
|
||||
>
|
||||
{isNews ? S.blog.news : S.blog.changelog}
|
||||
{S.blog[category]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PinnedBadge() {
|
||||
return (
|
||||
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-[11px] font-medium text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-300">
|
||||
{S.blog.pinned}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -339,6 +339,14 @@ export function ExternalLinkIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
import { parseFrontmatter } from "./frontmatter";
|
||||
import type { Locale } from "../state/locale";
|
||||
|
||||
export type BlogCategory = "news" | "changelog";
|
||||
export type BlogCategory = "news" | "practice" | "changelog";
|
||||
|
||||
/** Byline used when a post has no `author` frontmatter. */
|
||||
export const DEFAULT_AUTHOR = "Yaowei Zheng (PrismShadow AI)";
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string;
|
||||
@@ -17,9 +20,27 @@ export interface BlogPost {
|
||||
date: string;
|
||||
category: BlogCategory;
|
||||
excerpt: string;
|
||||
/** Author list from comma-separated `author` frontmatter (default byline when absent). */
|
||||
authors: string[];
|
||||
/** `pinned: true` in frontmatter: sorts before everything else, badge on the card. */
|
||||
pinned: boolean;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Split `author` frontmatter on comma-class separators (`,` `,` `、`); default when empty. */
|
||||
export function parseAuthors(raw: string | undefined): string[] {
|
||||
const authors = (raw ?? "")
|
||||
.split(/[,,、]/)
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
return authors.length > 0 ? authors : [DEFAULT_AUTHOR];
|
||||
}
|
||||
|
||||
/** Join authors for display: 顿号 in Chinese, comma otherwise. */
|
||||
export function formatAuthors(authors: string[], locale: Locale): string {
|
||||
return authors.join(locale === "zh" ? "、" : ", ");
|
||||
}
|
||||
|
||||
const files = import.meta.glob("../../content/blog/*.md", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
@@ -31,7 +52,8 @@ function toPost(path: string, raw: string): BlogPost | null {
|
||||
const match = /^(.+)\.(zh|en)\.md$/.exec(file);
|
||||
if (!match) return null;
|
||||
const { meta, body } = parseFrontmatter(raw);
|
||||
const category: BlogCategory = meta.category === "changelog" ? "changelog" : "news";
|
||||
const category: BlogCategory =
|
||||
meta.category === "changelog" || meta.category === "practice" ? meta.category : "news";
|
||||
return {
|
||||
slug: match[1]!,
|
||||
lang: match[2] as Locale,
|
||||
@@ -39,15 +61,50 @@ function toPost(path: string, raw: string): BlogPost | null {
|
||||
date: meta.date ?? "",
|
||||
category,
|
||||
excerpt: meta.excerpt ?? "",
|
||||
authors: parseAuthors(meta.author),
|
||||
pinned: meta.pinned === "true",
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sort order for post lists: pinned first, then newest date, then slug as tie-break. */
|
||||
export function comparePosts(
|
||||
a: Pick<BlogPost, "pinned" | "date" | "slug">,
|
||||
b: Pick<BlogPost, "pinned" | "date" | "slug">,
|
||||
): number {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
if (a.date !== b.date) return a.date < b.date ? 1 : -1;
|
||||
return a.slug.localeCompare(b.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD post date for display ("July 20, 2026" / "2026年7月20日").
|
||||
* Parsed as UTC and formatted in UTC so the calendar day never shifts with the
|
||||
* viewer's timezone; unexpected input falls back to the raw string.
|
||||
*/
|
||||
export function formatPostDate(date: string, locale: Locale): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||
if (!match) return date;
|
||||
const [y, m, d] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
const utc = new Date(Date.UTC(y, m - 1, d));
|
||||
// Date.UTC silently normalizes impossible dates (2026-02-31 -> March 3): fall back
|
||||
// to the raw string when the components do not survive the round-trip.
|
||||
if (utc.getUTCFullYear() !== y || utc.getUTCMonth() !== m - 1 || utc.getUTCDate() !== d) {
|
||||
return date;
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale === "zh" ? "zh-CN" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(utc);
|
||||
}
|
||||
|
||||
const ALL: BlogPost[] = Object.entries(files)
|
||||
.map(([path, raw]) => toPost(path, raw))
|
||||
.filter((p): p is BlogPost => p !== null);
|
||||
|
||||
/** Pick the locale's version of each slug (fallback to the other language), newest first. */
|
||||
/** Pick the locale's version of each slug (fallback to the other language), pinned/newest first. */
|
||||
export function postsFor(locale: Locale, category?: BlogCategory): BlogPost[] {
|
||||
const bySlug = new Map<string, BlogPost>();
|
||||
for (const post of ALL) {
|
||||
@@ -58,7 +115,7 @@ export function postsFor(locale: Locale, category?: BlogCategory): BlogPost[] {
|
||||
}
|
||||
return [...bySlug.values()]
|
||||
.filter((p) => (category ? p.category === category : true))
|
||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug)));
|
||||
.sort(comparePosts);
|
||||
}
|
||||
|
||||
export function getPost(slug: string, locale: Locale): BlogPost | undefined {
|
||||
|
||||
@@ -411,10 +411,14 @@ export const en: Strings = {
|
||||
|
||||
blog: {
|
||||
title: "Blog",
|
||||
subtitle: "Product news and release notes",
|
||||
subtitle: "Product news, tech practices and release notes",
|
||||
all: "All",
|
||||
news: "Product news",
|
||||
practice: "Tech practice",
|
||||
changelog: "Release notes",
|
||||
pinned: "Pinned",
|
||||
copyLink: "Copy page link",
|
||||
linkCopied: "Copied",
|
||||
back: "Back to blog",
|
||||
empty: "No posts in this category yet",
|
||||
notFound: "Post not found",
|
||||
|
||||
@@ -405,10 +405,14 @@ export const zh = {
|
||||
|
||||
blog: {
|
||||
title: "博客",
|
||||
subtitle: "产品动态与更新日志",
|
||||
subtitle: "产品动态、技术实践与更新日志",
|
||||
all: "全部",
|
||||
news: "产品动态",
|
||||
practice: "技术实践",
|
||||
changelog: "更新日志",
|
||||
pinned: "置顶",
|
||||
copyLink: "复制页面链接",
|
||||
linkCopied: "已复制",
|
||||
back: "返回博客",
|
||||
empty: "该分类下暂无文章",
|
||||
notFound: "文章不存在",
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
/** Blog list: category chips (all / product news / release notes) + post cards. */
|
||||
/**
|
||||
* Blog list: category chips (all / product news / tech practice / release notes) +
|
||||
* post cards. A flat list in every view — each card carries its category badge,
|
||||
* chips filter by category, and pinned posts sort first.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { useLocale } from "../state/locale";
|
||||
import { postsFor } from "../lib/blog";
|
||||
import { formatPostDate, postsFor } from "../lib/blog";
|
||||
import type { BlogCategory } from "../lib/blog";
|
||||
import { CategoryBadge } from "../components/category-badge";
|
||||
import { CategoryBadge, PinnedBadge } from "../components/category-badge";
|
||||
|
||||
type Filter = "all" | BlogCategory;
|
||||
|
||||
const FILTERS: Filter[] = ["all", "news", "practice", "changelog"];
|
||||
|
||||
export function BlogListPage() {
|
||||
const { locale } = useLocale();
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
@@ -27,23 +33,16 @@ export function BlogListPage() {
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{S.blog.title}</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">{S.blog.subtitle}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<button type="button" className={chip(filter === "all")} onClick={() => setFilter("all")}>
|
||||
{S.blog.all}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={chip(filter === "news")}
|
||||
onClick={() => setFilter("news")}
|
||||
>
|
||||
{S.blog.news}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={chip(filter === "changelog")}
|
||||
onClick={() => setFilter("changelog")}
|
||||
>
|
||||
{S.blog.changelog}
|
||||
</button>
|
||||
{FILTERS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={chip(filter === value)}
|
||||
onClick={() => setFilter(value)}
|
||||
>
|
||||
{value === "all" ? S.blog.all : S.blog[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -61,9 +60,10 @@ export function BlogListPage() {
|
||||
>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={post.date} className="tabular-nums">
|
||||
{post.date}
|
||||
{formatPostDate(post.date, locale)}
|
||||
</time>
|
||||
<CategoryBadge category={post.category} />
|
||||
{post.pinned && <PinnedBadge />}
|
||||
</div>
|
||||
<h2 className="mt-2 text-lg font-semibold tracking-tight group-hover:text-brand-700 dark:group-hover:text-brand-300">
|
||||
{post.title}
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
* (same slugifier as the TOC) and the active section is tracked with an
|
||||
* IntersectionObserver so the TOC highlights while scrolling.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { useLocale } from "../state/locale";
|
||||
import { getPost } from "../lib/blog";
|
||||
import { formatAuthors, formatPostDate, getPost } from "../lib/blog";
|
||||
import { extractToc, slugifyHeading } from "../lib/toc";
|
||||
import { CategoryBadge } from "../components/category-badge";
|
||||
import { ArrowRightIcon } from "../components/icons";
|
||||
import { ArrowRightIcon, CheckIcon, LinkIcon } from "../components/icons";
|
||||
|
||||
/** Flatten react-markdown heading children to plain text for slugging. */
|
||||
function nodeText(node: ReactNode): string {
|
||||
@@ -27,6 +27,68 @@ function nodeText(node: ReactNode): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Copy text to the clipboard; reports whether a copy actually happened. */
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Clipboard API unavailable (e.g. non-secure context): fall back to a textarea
|
||||
// kept out of layout (fixed + invisible) so select() cannot scroll the page.
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.top = "0";
|
||||
ta.style.left = "0";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
try {
|
||||
ta.select();
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
ta.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the page URL to the clipboard; the label flips to "copied" for ~2s. */
|
||||
function CopyLinkButton() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!(await copyToClipboard(window.location.href))) return;
|
||||
setCopied(true);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onCopy()}
|
||||
className="inline-flex items-center gap-1.5 transition-colors hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copied ? S.blog.linkCopied : S.blog.copyLink}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Toc({ entries, activeId }: { entries: ReturnType<typeof extractToc>; activeId: string }) {
|
||||
return (
|
||||
<aside className="hidden xl:block">
|
||||
@@ -130,13 +192,16 @@ export function BlogPostPage() {
|
||||
{S.blog.back}
|
||||
</Link>
|
||||
<header className="mt-6">
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-balance">{post.title}</h1>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={post.date} className="tabular-nums">
|
||||
{post.date}
|
||||
{formatPostDate(post.date, locale)}
|
||||
</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatAuthors(post.authors, locale)}</span>
|
||||
<CategoryBadge category={post.category} />
|
||||
<CopyLinkButton />
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-balance">{post.title}</h1>
|
||||
</header>
|
||||
<div className="md-body mt-8 text-[15px] text-gray-800 dark:text-gray-200">
|
||||
<Markdown
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_AUTHOR,
|
||||
comparePosts,
|
||||
formatAuthors,
|
||||
formatPostDate,
|
||||
getPost,
|
||||
parseAuthors,
|
||||
postsFor,
|
||||
} from "../src/lib/blog";
|
||||
|
||||
const entry = (pinned: boolean, date: string, slug: string) => ({ pinned, date, slug });
|
||||
|
||||
describe("comparePosts", () => {
|
||||
it("sorts pinned first, then date desc, then slug", () => {
|
||||
const sorted = [
|
||||
entry(false, "2026-07-20", "beta"),
|
||||
entry(false, "2026-07-21", "newest"),
|
||||
entry(true, "2026-07-01", "pinned-old"),
|
||||
entry(false, "2026-07-20", "alpha"),
|
||||
].sort(comparePosts);
|
||||
expect(sorted.map((p) => p.slug)).toEqual(["pinned-old", "newest", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("orders two pinned posts by date desc", () => {
|
||||
const sorted = [entry(true, "2026-07-01", "old"), entry(true, "2026-07-20", "new")].sort(
|
||||
comparePosts,
|
||||
);
|
||||
expect(sorted.map((p) => p.slug)).toEqual(["new", "old"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPostDate", () => {
|
||||
it("formats en dates as long US dates", () => {
|
||||
expect(formatPostDate("2026-06-18", "en")).toBe("June 18, 2026");
|
||||
expect(formatPostDate("2026-07-20", "en")).toBe("July 20, 2026");
|
||||
});
|
||||
|
||||
it("formats zh dates as 年月日", () => {
|
||||
expect(formatPostDate("2026-06-18", "zh")).toBe("2026年6月18日");
|
||||
expect(formatPostDate("2026-07-20", "zh")).toBe("2026年7月20日");
|
||||
});
|
||||
|
||||
it("interprets the date in UTC so the calendar day never shifts", () => {
|
||||
// 2026-01-01 rendered in a western timezone would show Dec 31 without UTC pinning.
|
||||
expect(formatPostDate("2026-01-01", "en")).toBe("January 1, 2026");
|
||||
});
|
||||
|
||||
it("falls back to the raw string for unexpected input", () => {
|
||||
expect(formatPostDate("", "en")).toBe("");
|
||||
expect(formatPostDate("not-a-date", "zh")).toBe("not-a-date");
|
||||
});
|
||||
|
||||
it("falls back to the raw string for impossible calendar dates", () => {
|
||||
expect(formatPostDate("2026-02-31", "en")).toBe("2026-02-31");
|
||||
expect(formatPostDate("2026-13-01", "en")).toBe("2026-13-01");
|
||||
expect(formatPostDate("2026-04-00", "zh")).toBe("2026-04-00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAuthors / formatAuthors", () => {
|
||||
it("splits comma-class separators and trims", () => {
|
||||
expect(parseAuthors("A (AMD), B(X)、C ,D")).toEqual(["A (AMD)", "B(X)", "C", "D"]);
|
||||
});
|
||||
|
||||
it("falls back to the default byline when absent or blank", () => {
|
||||
expect(parseAuthors(undefined)).toEqual([DEFAULT_AUTHOR]);
|
||||
expect(parseAuthors(" ")).toEqual([DEFAULT_AUTHOR]);
|
||||
});
|
||||
|
||||
it("joins with a comma in English and 顿号 in Chinese", () => {
|
||||
expect(formatAuthors(["A", "B"], "en")).toBe("A, B");
|
||||
expect(formatAuthors(["甲", "乙"], "zh")).toBe("甲、乙");
|
||||
expect(formatAuthors(["A"], "en")).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("frontmatter mapping (author / pinned / category)", () => {
|
||||
it("defaults authors and pinned when the frontmatter omits them", () => {
|
||||
const post = getPost("july-2026-updates", "en");
|
||||
expect(post?.authors).toEqual([DEFAULT_AUTHOR]);
|
||||
expect(post?.pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the explicit author list and the practice category", () => {
|
||||
const post = getPost("local-agents-on-amd-gpus", "en");
|
||||
expect(post?.authors).toEqual([
|
||||
"Ning Zhang (AMD)",
|
||||
"Yuyang Gao (AMD)",
|
||||
"Yaowei Zheng (PrismShadow)",
|
||||
]);
|
||||
expect(post?.category).toBe("practice");
|
||||
expect(post?.pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the pinned flag and sorts the pinned post first", () => {
|
||||
for (const locale of ["en", "zh"] as const) {
|
||||
const posts = postsFor(locale);
|
||||
expect(posts.length).toBe(5);
|
||||
expect(posts[0]?.slug).toBe("introducing-penguinharness");
|
||||
expect(posts[0]?.pinned).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("filters by the practice category, newest first", () => {
|
||||
expect(postsFor("en", "practice").map((p) => p.slug)).toEqual([
|
||||
"penguin-harness-self-improvement-with-amd-gpu",
|
||||
"local-agents-on-amd-gpus",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user