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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 16 additions & 0 deletions .cursor/memory/mistakes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,19 @@ When appending, use the next `MEM-###` ID and this structure:
- **Do instead:** Use **Quantus** only. When shortening meta titles, rewrite to stay within SEO length targets (MEM-001) without reintroducing “Network”.
- **Context:** Especially `website/src/i18n/*.json` meta and UI strings; avoid the phrase in new copy
- **Source:** user

### MEM-003 — 2026-09-16

- **Category:** content, blog
- **Mistake:** Using `weekly-update` as a blog tag after it already exists as a category, so article pages still showed “Weekly Update” as a tag.
- **Do instead:** Keep `weekly-update` as a **category only**. Never put a category id in frontmatter `tags`. Weekly-update author/Twitter defaults must key off `category`, not tags.
- **Context:** Blog frontmatter, `BlogPost.astro` tag pills, `content.config.ts`, `resolve-blog-author.ts`
- **Source:** user

### MEM-004 — 2026-09-16

- **Category:** content, blog
- **Mistake:** Letting blog tags proliferate (PQC/quantum-safe/Dilithium/GPU mining/mobile wallet/security/audit, plus one-off names) instead of a small canonical set.
- **Do instead:** Use only these tag ids: `q-day`, `quantum-computing`, `post-quantum-cryptography`, `cryptography`, `bitcoin`, `mining`, `pow`, `privacy`, `zero-knowledge-proofs`, `wallet`, `mainnet`, `ml-dsa`, `wormhole`, `protocol-security`, `governance`, `tokenomics`. Map aliases: Post-Quantum / PQC / Quantum-Safe / Quantum-Resistant → `post-quantum-cryptography`; ZK-Proofs → `zero-knowledge-proofs`; GPU Mining / miner / miner-app / gui-miner → `mining`; Mobile Wallet / hardware-wallet / mobile → `wallet`; Dilithium → `ml-dsa`; Security / Audit / Immunefi / bug-bounty → `protocol-security`; quantum → `quantum-computing`. Drop any other tag. Add `blog.tags.<id>` in every i18n file when adding an id to `BLOG_TAGS`. Schema must reject unknown tags rather than coercing them.
- **Context:** `website/src/constants/blog-tags.ts`, blog frontmatter `tags`, `blog.tags` i18n, tag pages
- **Source:** user
19 changes: 15 additions & 4 deletions website/scripts/check-seo-meta.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ function checkLength(kind, locale, key, value, errors) {
}

function checkI18n(errors) {
for (const file of fs.readdirSync(i18nDir).filter((f) => f.endsWith(".json"))) {
for (const file of fs
.readdirSync(i18nDir)
.filter((f) => f.endsWith(".json"))) {
const locale = path.basename(file, ".json");
const data = JSON.parse(fs.readFileSync(path.join(i18nDir, file), "utf8"));
for (const [key, value] of walkJson(data)) {
Expand Down Expand Up @@ -121,7 +123,9 @@ function checkBlogs(errors) {
const raw = fs.readFileSync(file, "utf8");
const meta = parseFrontmatter(raw);
if (!meta) {
errors.push(`[parse] ${path.relative(websiteRoot, file)}: missing title/description`);
errors.push(
`[parse] ${path.relative(websiteRoot, file)}: missing title/description`,
);
continue;
}
const rel = path.relative(websiteRoot, file);
Expand All @@ -135,9 +139,16 @@ function checkDefaultMetadata(errors) {
const raw = fs.readFileSync(file, "utf8");
const title = raw.match(/default:\s*"([^"]+)"/)?.[1];
const desc = raw.match(/const description =\s*\n?\s*"([^"]+)"/)?.[1];
if (title) checkLength("title", "en-US", "default-metadata.title", title, errors);
if (title)
checkLength("title", "en-US", "default-metadata.title", title, errors);
if (desc)
checkLength("description", "en-US", "default-metadata.description", desc, errors);
checkLength(
"description",
"en-US",
"default-metadata.description",
desc,
errors,
);
}

const errors = [];
Expand Down
31 changes: 31 additions & 0 deletions website/src/components/features/blog/BlogList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import {
ALL_BLOG_CATEGORY,
blogCategoryFilterFromSearch,
} from "@/utils/blog-categories";

const blogListSource = readFileSync(
join(import.meta.dir, "BlogList.tsx"),
"utf8",
);

describe("BlogList deep-link category query", () => {
test("applies untrusted location.search through blogCategoryFilterFromSearch", () => {
expect(blogListSource).toContain(
"blogCategoryFilterFromSearch(window.location.search)",
);
expect(blogListSource).not.toContain("parseBlogCategoryFilter(");
});

test("initial load and popstate keep the list usable for unknown query values", () => {
expect(blogCategoryFilterFromSearch("?category=")).toBe(ALL_BLOG_CATEGORY);
expect(blogCategoryFilterFromSearch("?category=podcast")).toBe(
ALL_BLOG_CATEGORY,
);
expect(blogCategoryFilterFromSearch("?category=education")).toBe(
"education",
);
});
});
127 changes: 104 additions & 23 deletions website/src/components/features/blog/BlogList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@ import Fuse from "fuse.js";
import { Search as SearchIcon } from "lucide-react";
import { useDebounceValue } from "usehooks-ts";
import { INPUT_DEBOUNCE_INTERVAL } from "@/constants/debounce-interval";
import {
ALL_BLOG_CATEGORY,
blogCategoryFilterFromSearch,
filterPostsByCategory,
type BlogCategory,
type BlogCategoryFilter,
} from "@/utils/blog-categories";
import { BLOG_CATEGORIES } from "@/constants/blog-categories";

const INITIAL_VISIBLE_COUNT = 6;
const LOAD_MORE_COUNT = 6;
const CATEGORY_FILTERS: BlogCategoryFilter[] = [
ALL_BLOG_CATEGORY,
...BLOG_CATEGORIES,
];

interface Post {
id: string;
Expand All @@ -14,6 +26,7 @@ interface Post {
title: string;
description: string;
pubDate: string;
category: BlogCategory;
tags: string[];
heroImage?: string;
heroAlt?: string;
Expand All @@ -31,6 +44,8 @@ interface Props {
featuredLabel: string;
readLabel: string;
tagsMap: Record<string, string>;
categoriesMap: Record<string, string>;
categoriesLabel: string;
}

function useCardEntrance(deps: unknown[]) {
Expand Down Expand Up @@ -90,27 +105,59 @@ export const BlogList: React.FC<Props> = ({
featuredLabel,
readLabel,
tagsMap,
categoriesMap,
categoriesLabel,
}) => {
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebounceValue(query, INPUT_DEBOUNCE_INTERVAL);
const [selectedCategory, setSelectedCategory] =
useState<BlogCategoryFilter>(ALL_BLOG_CATEGORY);
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT);
const observerTarget = useRef<HTMLDivElement>(null);

useEffect(() => {
const applyCategoryFromUrl = () => {
const category = blogCategoryFilterFromSearch(window.location.search);
setSelectedCategory(category);
};

applyCategoryFromUrl();
window.addEventListener("popstate", applyCategoryFromUrl);
return () => window.removeEventListener("popstate", applyCategoryFromUrl);
}, []);

const selectCategory = (category: BlogCategoryFilter) => {
setSelectedCategory(category);
const url = new URL(window.location.href);
if (category === ALL_BLOG_CATEGORY) {
url.searchParams.delete("category");
} else {
url.searchParams.set("category", category);
}
window.history.replaceState(null, "", url);
};

const categoryFilteredPosts = useMemo(
() => filterPostsByCategory(posts, selectedCategory),
[posts, selectedCategory],
);

const fuse = useMemo(() => {
return new Fuse(posts, {
keys: ["data.title", "data.description", "data.tags"],
return new Fuse(categoryFilteredPosts, {
keys: ["data.title", "data.description", "data.tags", "data.category"],
});
}, [posts]);
}, [categoryFilteredPosts]);

const results = useMemo(() => {
if (!debouncedQuery) return posts;
if (!debouncedQuery) return categoryFilteredPosts;
return fuse.search(debouncedQuery).map((result) => result.item);
}, [fuse, debouncedQuery, posts]);
}, [fuse, debouncedQuery, categoryFilteredPosts]);

const featuredPost = useMemo(() => {
if (debouncedQuery) return null;
if (selectedCategory !== ALL_BLOG_CATEGORY) return null;
return posts.find((post) => post.data.featured) ?? posts[0] ?? null;
}, [posts, debouncedQuery]);
}, [posts, debouncedQuery, selectedCategory]);

const displayedPosts = useMemo(() => {
let filteredResults = results;
Expand All @@ -122,7 +169,7 @@ export const BlogList: React.FC<Props> = ({

useEffect(() => {
setVisibleCount(INITIAL_VISIBLE_COUNT);
}, [debouncedQuery]);
}, [debouncedQuery, selectedCategory]);

useEffect(() => {
const observer = new IntersectionObserver(
Expand Down Expand Up @@ -155,16 +202,41 @@ export const BlogList: React.FC<Props> = ({

return (
<div className="flex flex-col gap-10">
{/* Search */}
<div className="relative max-w-md">
<SearchIcon className="text-content-35 absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<input
type="text"
placeholder={searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="border-border text-content placeholder:text-content-35 focus:border-content-25 h-11 w-full border bg-transparent pr-4 pl-10 font-mono text-sm focus:outline-none"
/>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div
role="group"
aria-label={categoriesLabel}
className="flex flex-wrap gap-2"
>
{CATEGORY_FILTERS.map((category) => {
const isActive = selectedCategory === category;
return (
<button
key={category}
type="button"
aria-pressed={isActive}
onClick={() => selectCategory(category)}
className={
isActive
? "border-flare text-flare h-11 border px-3.5 font-mono text-[11px] tracking-[0.14em] uppercase"
: "border-border text-content-40 hover:border-content-25 hover:text-content-70 h-11 border bg-transparent px-3.5 font-mono text-[11px] tracking-[0.14em] uppercase transition-colors"
}
>
{categoriesMap[category] || category}
</button>
);
})}
</div>
<div className="relative w-full max-w-md md:w-80">
<SearchIcon className="text-content-35 absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<input
type="text"
placeholder={searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="border-border text-content placeholder:text-content-35 focus:border-content-25 h-11 w-full border bg-transparent pr-4 pl-10 font-mono text-sm focus:outline-none"
/>
</div>
</div>

{/* Featured post */}
Expand Down Expand Up @@ -200,6 +272,10 @@ export const BlogList: React.FC<Props> = ({
>
{formatDate(featuredPost.data.pubDate)}
</time>
<span className="text-content-40 font-mono text-[10px] tracking-[0.14em] uppercase">
{categoriesMap[featuredPost.data.category] ||
featuredPost.data.category}
</span>
<span className="text-flare font-mono text-[10px] tracking-[0.14em] uppercase">
{featuredLabel}
</span>
Expand Down Expand Up @@ -259,12 +335,17 @@ export const BlogList: React.FC<Props> = ({
</div>
)}
<div className="flex flex-1 flex-col px-5 pt-6 pb-7">
<time
dateTime={post.data.pubDate}
className="text-content-35 mb-2.5 block font-mono text-[11px] tracking-[0.14em] uppercase"
>
{formatDate(post.data.pubDate)}
</time>
<div className="mb-2.5 flex items-center gap-3">
<time
dateTime={post.data.pubDate}
className="text-content-35 font-mono text-[11px] tracking-[0.14em] uppercase"
>
{formatDate(post.data.pubDate)}
</time>
<span className="text-flare font-mono text-[10px] tracking-[0.14em] uppercase">
{categoriesMap[post.data.category] || post.data.category}
</span>
</div>
<span className="text-content-90 block text-[17px] leading-[1.35] font-medium">
{post.data.title}
</span>
Expand Down
Loading