diff --git a/.cursor/memory/mistakes.md b/.cursor/memory/mistakes.md index 72b52201..f5e5fa71 100644 --- a/.cursor/memory/mistakes.md +++ b/.cursor/memory/mistakes.md @@ -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.` 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 diff --git a/website/scripts/check-seo-meta.mjs b/website/scripts/check-seo-meta.mjs index b4ceba8a..7825b0a3 100644 --- a/website/scripts/check-seo-meta.mjs +++ b/website/scripts/check-seo-meta.mjs @@ -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)) { @@ -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); @@ -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 = []; diff --git a/website/src/components/features/blog/BlogList.test.ts b/website/src/components/features/blog/BlogList.test.ts new file mode 100644 index 00000000..6b37cbce --- /dev/null +++ b/website/src/components/features/blog/BlogList.test.ts @@ -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", + ); + }); +}); diff --git a/website/src/components/features/blog/BlogList.tsx b/website/src/components/features/blog/BlogList.tsx index 0ca0e4b3..5507ea44 100644 --- a/website/src/components/features/blog/BlogList.tsx +++ b/website/src/components/features/blog/BlogList.tsx @@ -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; @@ -14,6 +26,7 @@ interface Post { title: string; description: string; pubDate: string; + category: BlogCategory; tags: string[]; heroImage?: string; heroAlt?: string; @@ -31,6 +44,8 @@ interface Props { featuredLabel: string; readLabel: string; tagsMap: Record; + categoriesMap: Record; + categoriesLabel: string; } function useCardEntrance(deps: unknown[]) { @@ -90,27 +105,59 @@ export const BlogList: React.FC = ({ featuredLabel, readLabel, tagsMap, + categoriesMap, + categoriesLabel, }) => { const [query, setQuery] = useState(""); const [debouncedQuery] = useDebounceValue(query, INPUT_DEBOUNCE_INTERVAL); + const [selectedCategory, setSelectedCategory] = + useState(ALL_BLOG_CATEGORY); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT); const observerTarget = useRef(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; @@ -122,7 +169,7 @@ export const BlogList: React.FC = ({ useEffect(() => { setVisibleCount(INITIAL_VISIBLE_COUNT); - }, [debouncedQuery]); + }, [debouncedQuery, selectedCategory]); useEffect(() => { const observer = new IntersectionObserver( @@ -155,16 +202,41 @@ export const BlogList: React.FC = ({ return (
- {/* Search */} -
- - 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" - /> +
+
+ {CATEGORY_FILTERS.map((category) => { + const isActive = selectedCategory === category; + return ( + + ); + })} +
+
+ + 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" + /> +
{/* Featured post */} @@ -200,6 +272,10 @@ export const BlogList: React.FC = ({ > {formatDate(featuredPost.data.pubDate)} + + {categoriesMap[featuredPost.data.category] || + featuredPost.data.category} + {featuredLabel} @@ -259,12 +335,17 @@ export const BlogList: React.FC = ({
)}
- +
+ + + {categoriesMap[post.data.category] || post.data.category} + +
{post.data.title} diff --git a/website/src/components/features/blog/BlogPost.astro b/website/src/components/features/blog/BlogPost.astro index cc32e89b..6bf75de1 100644 --- a/website/src/components/features/blog/BlogPost.astro +++ b/website/src/components/features/blog/BlogPost.astro @@ -15,6 +15,7 @@ import { getPersonJsonLd } from "@/constants/person-jsonld"; import { organizationJsonLd } from "@/constants/default-jsonld"; import { resolveTwitterCreator } from "@/utils/resolve-blog-author"; import { blogMetaTitle } from "@/utils/blog-titles"; +import { blogCategoryListHref } from "@/utils/blog-categories"; import env from "@/config"; type Props = CollectionEntry<"blog">["data"] & { @@ -30,6 +31,7 @@ const { updatedDate, heroImage, heroAlt, + category, tags = [], author: authorId, relatedPosts = [], @@ -51,7 +53,7 @@ const metadata = createMetadata({ imageUrl: heroImage, imageAlt: heroAlt, twitter: { - creator: resolveTwitterCreator(author?.social.username, tags), + creator: resolveTwitterCreator(author?.social.username, category), }, }); @@ -82,31 +84,21 @@ const jsonLd = JsonLdGraph({
{t("blog.post.back_to_blog")} - { - tags.length > 0 && ( -
- {tags.map((tag: string) => ( - - ))} -
- ) - } + + {t(`blog.categories.${category}`)} + { isUnpublishedPreview && ( @@ -160,12 +152,29 @@ const jsonLd = JsonLdGraph({

) } + + { + tags.length > 0 && ( +
+ {tags.map((tag: string) => ( + + ))} +
+ ) + }
{ heroImage && ( - <> +
) }
@@ -207,6 +216,7 @@ const jsonLd = JsonLdGraph({ `/blog/${relatedPost.id.split("/").slice(1).join("/")}`, )} tags={relatedPost.data.tags} + category={relatedPost.data.category} heroImage={relatedPost.data.heroImage} heroAlt={relatedPost.data.heroAlt} featured={relatedPost.data.featured} diff --git a/website/src/components/features/blog/Card.astro b/website/src/components/features/blog/Card.astro index 7b4bc789..64c05d32 100644 --- a/website/src/components/features/blog/Card.astro +++ b/website/src/components/features/blog/Card.astro @@ -1,9 +1,6 @@ --- -import { - getLocaleFromUrl, - getLocalizedPath, - createTranslator, -} from "@/utils/i18n"; +import { getLocaleFromUrl, createTranslator } from "@/utils/i18n"; +import type { BlogCategory } from "@/utils/blog-categories"; interface Props { title: string; @@ -11,6 +8,7 @@ interface Props { pubDate: Date; href: string; tags?: string[]; + category?: BlogCategory; heroImage?: string; heroAlt?: string; featured?: boolean; @@ -23,6 +21,7 @@ const { pubDate, href, tags = [], + category, heroImage, heroAlt, featured, @@ -80,12 +79,21 @@ const formattedDate = pubDate horizontal ? "px-5 py-5" : "px-5 pt-6 pb-7", ]} > - +
+ + { + category && ( + + {t(`blog.categories.${category}`)} + + ) + } +
+