From 8a3834c8f450563faee6eab7352e5e6d6a976d2a Mon Sep 17 00:00:00 2001 From: walishcs Date: Sun, 13 Sep 2026 13:56:46 +0800 Subject: [PATCH 1/3] Upgrade Astryx packages to 0.6.0 --- AGENTS.md | 6 ++-- package-lock.json | 92 ++++++++++++++++++++++++++++++++--------------- package.json | 12 +++---- 3 files changed, 73 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 74db186..a7772b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ -Astryx v0.5.2 · 163 components +Astryx v0.6.0 · 163 components CLI: run every command as `npx astryx ` (shown below as `astryx ...`). SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled: @@ -66,8 +66,8 @@ MORE CLI: search "" find any component / hook / doc / template / block component --list 163 components by category template --list page + block recipes -docs browser-support, cli-integrations, color, elevation, getting-started, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling-libraries, styling, theme, tokens, typography, working-with-ai +docs browser-support, cli-integrations, color, elevation, getting-started, icons, illustrations, internationalization, layout, migration, motion, principles, shadcn-compatibility, shape, spacing, styling-libraries, styling, theme, tokens, typography, working-with-ai swizzle eject component source for deep customization -upgrade --apply run after any @astryxdesign/core bump +upgrade --apply run after any Astryx or integration dependency bump diff --git a/package-lock.json b/package-lock.json index 33ccd2f..9337a91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "@astrojs/markdoc": "2.0.6", "@astrojs/react": "6.0.2", "@astrojs/sitemap": "3.7.3", - "@astryxdesign/core": "0.5.2", - "@astryxdesign/theme-chocolate": "0.5.2", - "@astryxdesign/theme-matcha": "0.5.2", - "@astryxdesign/theme-neutral": "0.5.2", - "@astryxdesign/theme-stone": "0.5.2", + "@astryxdesign/core": "0.6.0", + "@astryxdesign/theme-chocolate": "0.6.0", + "@astryxdesign/theme-matcha": "0.6.0", + "@astryxdesign/theme-neutral": "0.6.0", + "@astryxdesign/theme-stone": "0.6.0", "@keystar/ui": "0.9.2", "@keystatic/astro": "5.2.0", "@keystatic/core": "0.6.4", @@ -26,7 +26,7 @@ }, "devDependencies": { "@astrojs/check": "0.9.6", - "@astryxdesign/cli": "0.5.2", + "@astryxdesign/cli": "0.6.0", "@types/node": "24.10.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", @@ -431,9 +431,9 @@ } }, "node_modules/@astryxdesign/cli": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.5.2.tgz", - "integrity": "sha512-JKEV7JJZjapMB8fPe+HR/ZRRYD3Qce0c0tt/YjNsHDCUSAYxg0ZSWJgb1zKpqVaJzRws/IN9rKws2GsIDYkrMA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.6.0.tgz", + "integrity": "sha512-jOhCmFmyhla66+ho6s2R2MALnGhTx1D2Fh7M8tV1xzyEjzfPA7pJ8XKuUzqMEUCGF/QFdY8KF7IfGn9mxLx9Zg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -441,6 +441,8 @@ "commander": "^12.1.0", "jiti": "^2.7.0", "jscodeshift": "^17.3.0", + "postcss": "^8.5.23", + "postcss-selector-parser": "^7.1.5", "zod": "^4.4.3" }, "bin": { @@ -483,9 +485,9 @@ } }, "node_modules/@astryxdesign/core": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.5.2.tgz", - "integrity": "sha512-wJXjKIDpvqHG6t6BFzqRSuaJWIC2vMH3+9b4kVjwQE8LTYI5uUY6m8e5RQq06OC4BVTOHmnrhWfAzhsTaXFelw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.6.0.tgz", + "integrity": "sha512-6zGMQZgHXofBYYIdQlx/pyoALYUUsBw8Z9Xc+8YSsXRNddHyu9T6NVMsrz7mc2m5/XRaU3XvIBEBnk4O3f5vVw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -498,54 +500,54 @@ } }, "node_modules/@astryxdesign/theme-chocolate": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@astryxdesign/theme-chocolate/-/theme-chocolate-0.5.2.tgz", - "integrity": "sha512-pnXddJBcNDN8rIxwNs9/Dnhk/hj7NNlRi6c25rWVhP6YwTg3hvcl/WPgyD724s9ApM6VPA7elpBurbVGgHRcEQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/theme-chocolate/-/theme-chocolate-0.6.0.tgz", + "integrity": "sha512-k0gsjVX4iZ4suLd5s/g/vvhYVDPIpgUQpGgp+2Txy8phQtlXpFbKhTMWPFl5mR1toqQb/O4aE3QaeH7HJ7rIgQ==", "license": "MIT", "dependencies": { "lucide-react": "^1.18.0" }, "peerDependencies": { - "@astryxdesign/core": "0.5.2", + "@astryxdesign/core": "0.6.0", "react": ">=19" } }, "node_modules/@astryxdesign/theme-matcha": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@astryxdesign/theme-matcha/-/theme-matcha-0.5.2.tgz", - "integrity": "sha512-pWZoAeUt4gmSTjd8v4+pMQQaxWXjavudIQ9LyP7BqFJRklObAloeil4sG6ZAvlV9hCfy8TcjUSPgefCcYsrc1g==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/theme-matcha/-/theme-matcha-0.6.0.tgz", + "integrity": "sha512-xI0M1dHCmRb5/NCAfeUGz8VG/N2yLwUYsXDaoLQH1wtfjPxQZX9Y1+xO74CFAv9VQ0YlebMRTLSVDtfJpBLGlw==", "license": "MIT", "dependencies": { "lucide-react": "^1.18.0" }, "peerDependencies": { - "@astryxdesign/core": "0.5.2", + "@astryxdesign/core": "0.6.0", "react": ">=19" } }, "node_modules/@astryxdesign/theme-neutral": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@astryxdesign/theme-neutral/-/theme-neutral-0.5.2.tgz", - "integrity": "sha512-eVCN+x6OdnQk5Zw3ksAgrIKhDiTzu+0oFi689jyf0TPzFrz50f5VT3OB4SHeGgShp4dzFDWPgz6GUFQda6kTHw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/theme-neutral/-/theme-neutral-0.6.0.tgz", + "integrity": "sha512-p9hYJEcwX+Yx0+9JJyqDgZO6ul3kE72G2zHHqcBiCkLKtNg0nmn5agaAzwPqRSorSVowkF41XrcSpJIwWd4tnA==", "license": "MIT", "dependencies": { "lucide-react": "^1.18.0" }, "peerDependencies": { - "@astryxdesign/core": "0.5.2", + "@astryxdesign/core": "0.6.0", "react": ">=19" } }, "node_modules/@astryxdesign/theme-stone": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@astryxdesign/theme-stone/-/theme-stone-0.5.2.tgz", - "integrity": "sha512-qijUFvSmRNpXK0RVWpspCuRqYz/1vJyI8WPP6miHoVoShKXzi7B+hGr92DuUCFDcWxwzxAsY03IzLFEyIcpHyQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/theme-stone/-/theme-stone-0.6.0.tgz", + "integrity": "sha512-mb/LKwUr6/afqWZcvcRBmS58D1vAZ5GcdWBplF074eP+KevVlzjqI1Z1TR2nJADLiVWbARSdVnzGHgCOPuMp1g==", "license": "MIT", "dependencies": { "lucide-react": "^1.18.0" }, "peerDependencies": { - "@astryxdesign/core": "0.5.2", + "@astryxdesign/core": "0.6.0", "react": ">=19" } }, @@ -5119,6 +5121,19 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csso": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", @@ -8065,6 +8080,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss/node_modules/nanoid": { "version": "3.3.17", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", @@ -9505,6 +9534,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index be940ed..baa5ea0 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,11 @@ "@astrojs/markdoc": "2.0.6", "@astrojs/react": "6.0.2", "@astrojs/sitemap": "3.7.3", - "@astryxdesign/core": "0.5.2", - "@astryxdesign/theme-chocolate": "0.5.2", - "@astryxdesign/theme-matcha": "0.5.2", - "@astryxdesign/theme-neutral": "0.5.2", - "@astryxdesign/theme-stone": "0.5.2", + "@astryxdesign/core": "0.6.0", + "@astryxdesign/theme-chocolate": "0.6.0", + "@astryxdesign/theme-matcha": "0.6.0", + "@astryxdesign/theme-neutral": "0.6.0", + "@astryxdesign/theme-stone": "0.6.0", "@keystar/ui": "0.9.2", "@keystatic/astro": "5.2.0", "@keystatic/core": "0.6.4", @@ -39,7 +39,7 @@ }, "devDependencies": { "@astrojs/check": "0.9.6", - "@astryxdesign/cli": "0.5.2", + "@astryxdesign/cli": "0.6.0", "@types/node": "24.10.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", From d133112d79b624896a17875240a2c6faa6e406d8 Mon Sep 17 00:00:00 2001 From: walishcs Date: Sun, 13 Sep 2026 14:03:51 +0800 Subject: [PATCH 2/3] Replace blog grouping with month filters and searchable tag pages --- src/components/BlogDirectory.test.tsx | 45 ++++ src/components/BlogDirectory.tsx | 287 ++++++++++++++++++++------ src/lib/blog-directory.test.ts | 208 ++++++++++++++----- src/lib/blog-directory.ts | 131 ++++++++---- src/pages/blog/[id].astro | 13 +- src/pages/blog/index.astro | 22 +- src/pages/tags/[value].astro | 47 +++++ src/styles/global.css | 71 ++----- 8 files changed, 599 insertions(+), 225 deletions(-) create mode 100644 src/components/BlogDirectory.test.tsx create mode 100644 src/pages/tags/[value].astro diff --git a/src/components/BlogDirectory.test.tsx b/src/components/BlogDirectory.test.tsx new file mode 100644 index 0000000..d54abc7 --- /dev/null +++ b/src/components/BlogDirectory.test.tsx @@ -0,0 +1,45 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { BlogDirectory } from './BlogDirectory'; +import { getBlogTags, tagHref } from '../lib/blog-directory'; + +const posts = [ + { + href: '/blog/test/', + title: 'Test article', + summary: '', + publishedAt: '2026-06-01', + formattedDate: 'June 1, 2026', + tags: ['First', ' Second ', 'Second'], + }, +]; + +describe('blog directory rendering', () => { + it('server-renders posts with dates and every tag link, escaping content', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('href="/blog/test/"'); + expect(html).toContain('dateTime="2026-06-01"'); + expect(html).toContain('<unsafe>'); + expect(html).toContain(`href="${tagHref('Second')}"`); + expect(html).toContain('aria-pressed="true"'); + expect(html).not.toContain('First tag'); + }); + it('provides empty-state copy without requiring content', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Writing will appear here when published.'); + expect(html).toContain('No tags yet.'); + }); + it('marks the active tag and offers a way back to Blog', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('aria-current="page"'); + expect(html).toContain('Back to all blog posts'); + }); +}); diff --git a/src/components/BlogDirectory.tsx b/src/components/BlogDirectory.tsx index cf3fa91..d53407f 100644 --- a/src/components/BlogDirectory.tsx +++ b/src/components/BlogDirectory.tsx @@ -1,73 +1,236 @@ +import { Button } from '@astryxdesign/core/Button'; +import { Collapsible, CollapsibleGroup } from '@astryxdesign/core/Collapsible'; +import { Heading } from '@astryxdesign/core/Heading'; +import { Grid } from '@astryxdesign/core/Grid'; +import { HStack, VStack } from '@astryxdesign/core/Layout'; import { Link } from '@astryxdesign/core/Link'; +import { List, ListItem } from '@astryxdesign/core/List'; +import { Text } from '@astryxdesign/core/Text'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { useId, useMemo, useState } from 'react'; import { - SegmentedControl, - SegmentedControlItem, -} from '@astryxdesign/core/SegmentedControl'; -import { useMemo, useState } from 'react'; -import { ContentCardGrid } from '@/components/ContentCardGrid'; -import { - DEFAULT_BLOG_DIRECTORY_MODE, - groupBlogPosts, - type BlogDirectoryMode, + getBlogArchive, + normalizeTags, + searchBlogTags, + selectBlogPosts, + tagHref, + tagSize, type BlogDirectoryPost, -} from '@/lib/blog-directory'; + type BlogTag, +} from '../lib/blog-directory'; -export function BlogDirectory({ posts }: { posts: BlogDirectoryPost[] }) { - const [mode, setMode] = useState( - DEFAULT_BLOG_DIRECTORY_MODE, +export function BlogDirectory({ + posts, + tags, + activeTag, +}: { + posts: BlogDirectoryPost[]; + tags: BlogTag[]; + activeTag?: string; +}) { + const [month, setMonth] = useState(''); + const [query, setQuery] = useState(''); + const id = useId(); + const archive = useMemo(() => getBlogArchive(posts), [posts]); + const visiblePosts = useMemo( + () => selectBlogPosts(posts, { month }), + [posts, month], ); - const groups = useMemo(() => groupBlogPosts(posts, mode), [mode, posts]); + const visibleTags = useMemo(() => searchBlogTags(tags, query), [tags, query]); + const maximum = Math.max(1, ...tags.map((tag) => tag.count)); + const monthLabel = month + ? new Intl.DateTimeFormat('en', { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }).format(new Date(`${month}-01T00:00:00Z`)) + : 'All posts'; - return ( -
-
-

Browse by

- setMode(value as BlogDirectoryMode)} - label="Group blog posts by" - size="sm" + const navigation = (location: string) => ( + + + + Archive + +
- -
- {groups.map((group) => ( -
-

{group.label}

- ({ - href: post.href, - title: post.title, - summary: post.summary, - meta: post.formattedDate, - }))} + + + + + Tags + + {tags.length ? ( + <> + -
- ))} -
-
+ + {visibleTags.map((tag) => ( + + {tag.label} ({tag.count}) + + ))} + + {!visibleTags.length && ( + + No matching tags. + + )} + + ) : ( + + No tags yet. + + )} + + + ); + + return ( + + + + {activeTag && ( + + Back to all blog posts + + )} + + {monthLabel} + + + {visiblePosts.length} {visiblePosts.length === 1 ? 'post' : 'posts'} + + {visiblePosts.length ? ( + + {visiblePosts.map((post) => ( + + + {post.title} + + + } + description={ + + + + + {post.summary && ( + + {post.summary} + + )} + {normalizeTags(post.tags).length > 0 && ( + + {normalizeTags(post.tags).map((tag) => ( + + {tag} + + ))} + + )} + + } + /> + ))} + + ) : ( + + {month + ? 'No posts for this month.' + : 'Writing will appear here when published.'} + + )} + + + ); } diff --git a/src/lib/blog-directory.test.ts b/src/lib/blog-directory.test.ts index 4456207..61c086c 100644 --- a/src/lib/blog-directory.test.ts +++ b/src/lib/blog-directory.test.ts @@ -1,72 +1,170 @@ import { describe, expect, it } from 'vitest'; +import { filterPublished } from './collection-utils'; import { - DEFAULT_BLOG_DIRECTORY_MODE, - groupBlogPosts, + getBlogArchive, + getBlogTags, + normalizeTags, + searchBlogTags, + selectBlogPosts, + tagHref, + tagSize, + tagValue, type BlogDirectoryPost, } from './blog-directory'; -const posts: BlogDirectoryPost[] = [ - { - href: '/blog/newest/', - title: 'Newest', - summary: '', - publishedAt: '2026-06-01', - formattedDate: 'June 1, 2026', - tags: ['Research', 'Methods'], - }, - { - href: '/blog/older/', - title: 'Older', - summary: '', - publishedAt: '2025-02-01', - formattedDate: 'February 1, 2025', - tags: ['Writing'], - }, - { - href: '/blog/untagged/', - title: 'Untagged', - summary: '', - publishedAt: '2026-01-01', - formattedDate: 'January 1, 2026', - tags: [], - }, - { - href: '/blog/research-older/', - title: 'Research older', - summary: '', - publishedAt: '2025-01-01', - formattedDate: 'January 1, 2025', - tags: ['Research'], - }, +const post = ( + title: string, + publishedAt: string, + tags: string[] = [], +): BlogDirectoryPost => ({ + title, + href: `/blog/${title}/`, + summary: '', + formattedDate: publishedAt, + publishedAt, + tags, +}); +const posts = [ + post('older', '2025-02-01', ['Writing']), + post('newest', '2026-06-15', ['Research', 'Methods', ' Methods ', '']), + post('untagged', '2026-01-01'), + post('same-month', '2026-06-01', ['Research']), + post('oldest', '2025-01-01', ['Research']), ]; -describe('blog directory grouping', () => { - it('defaults to grouping posts by year', () => { - expect(DEFAULT_BLOG_DIRECTORY_MODE).toBe('year'); +describe('blog archives', () => { + it('lists only occupied months, newest first, with counts', () => { + expect(getBlogArchive(posts)).toEqual([ + { + year: '2026', + count: 3, + months: [ + { value: '2026-06', label: 'June', count: 2 }, + { value: '2026-01', label: 'January', count: 1 }, + ], + }, + { + year: '2025', + count: 2, + months: [ + { value: '2025-02', label: 'February', count: 1 }, + { value: '2025-01', label: 'January', count: 1 }, + ], + }, + ]); }); - - it('uses only the first tag, sorts groups, and leaves untagged last', () => { - const groups = groupBlogPosts(posts, 'tag'); - - expect(groups.map((group) => group.label)).toEqual([ + it('filters a month and restores every post when cleared without mutating input', () => { + const original = [...posts]; + expect( + selectBlogPosts(posts, { month: '2026-06' }).map((p) => p.title), + ).toEqual(['newest', 'same-month']); + expect(selectBlogPosts(posts, { month: '' }).map((p) => p.title)).toEqual([ + 'newest', + 'same-month', + 'untagged', + 'older', + 'oldest', + ]); + expect(selectBlogPosts(posts, { month: '2026-05' })).toEqual([]); + expect(posts).toEqual(original); + }); + it('uses only the selected tag for its archive and supports the second tag', () => { + const selected = selectBlogPosts(posts, { tag: 'Methods' }); + expect(selected.map((p) => p.title)).toEqual(['newest']); + expect(getBlogArchive(selected)[0]?.count).toBe(1); + expect( + selectBlogPosts(posts, { tag: 'Research', month: '2026-06' }), + ).toHaveLength(2); + }); + it('keeps drafts and future dates out of all public indexes', () => { + const candidates = [ + ...posts.map((p) => ({ ...p, draft: false })), + { ...post('draft', '2024-12-01', ['Draft only']), draft: true }, + { ...post('future', '2099-12-01', ['Future only']), draft: false }, + ]; + const published = filterPublished(candidates, { + isDraft: (p) => p.draft, + publishedAt: (p) => p.publishedAt, + now: new Date('2026-09-13'), + }); + expect(published).toHaveLength(posts.length); + expect(getBlogTags(published).map((t) => t.label)).toEqual([ 'Research', + 'Methods', 'Writing', - 'Untagged', ]); - expect(groups[0]?.posts.map((post) => post.title)).toEqual([ - 'Newest', - 'Research older', + expect(getBlogArchive(published).map((y) => y.year)).toEqual([ + '2026', + '2025', ]); - expect(groups.flatMap((group) => group.posts)).toHaveLength(posts.length); }); + it('handles empty collections and untagged posts', () => { + expect(getBlogArchive([])).toEqual([]); + expect(getBlogTags([])).toEqual([]); + expect(selectBlogPosts([])).toEqual([]); + expect(getBlogTags([post('untagged', '2026-01-01')])).toEqual([]); + }); +}); - it('groups by year newest first and keeps posts newest first', () => { - const groups = groupBlogPosts(posts, 'year'); - - expect(groups.map((group) => group.label)).toEqual(['2026', '2025']); - expect(groups[0]?.posts.map((post) => post.title)).toEqual([ - 'Newest', - 'Untagged', +describe('blog tags', () => { + it('normalizes whitespace, ignores empty tags, deduplicates per post, preserves case', () => { + expect(normalizeTags([' ', ' Research ', 'Research', 'research'])).toEqual([ + 'Research', + 'research', + ]); + expect( + getBlogTags(posts).map(({ label, count }) => ({ label, count })), + ).toEqual([ + { label: 'Research', count: 3 }, + { label: 'Methods', count: 1 }, + { label: 'Writing', count: 1 }, + ]); + }); + it('searches names case-insensitively and clears the search', () => { + const tags = getBlogTags(posts); + expect(searchBlogTags(tags, ' METH ').map((t) => t.label)).toEqual([ + 'Methods', ]); + expect(searchBlogTags(tags, 'missing')).toEqual([]); + expect(searchBlogTags(tags, '')).toEqual(tags); + }); + it('uses three bounded font sizes, with equal single-use tags at body size', () => { + expect([1, 2, 3].map((count) => tagSize(count, 3))).toEqual([ + 'sm', + 'base', + 'lg', + ]); + expect(tagSize(1, 1)).toBe('base'); + }); + it('creates reversible path segments without case-insensitive collisions', () => { + const labels = [ + 'Research', + 'research', + 'RESEARCH', + 'a/b', + 'a b', + 'a-b', + 'a~2fb', + '%2F', + '中文', + 'C++', + '..', + '#', + '?', + 'é', + 'e\u0301', + '👩‍💻', + ]; + const values = labels.map(tagValue); + expect(new Set(values.map((v) => v.toLowerCase())).size).toBe( + labels.length, + ); + for (const [index, value] of values.entries()) { + expect(value).toMatch(/^[a-z0-9_~-]+$/); + expect(decodeURIComponent(value.replace(/~/g, '%'))).toBe(labels[index]); + expect( + new URL(tagHref(labels[index]!), 'https://example.com').pathname, + ).toBe(`/tags/${value}/`); + } }); }); diff --git a/src/lib/blog-directory.ts b/src/lib/blog-directory.ts index dda94ca..cac2107 100644 --- a/src/lib/blog-directory.ts +++ b/src/lib/blog-directory.ts @@ -1,7 +1,3 @@ -export type BlogDirectoryMode = 'tag' | 'year'; - -export const DEFAULT_BLOG_DIRECTORY_MODE: BlogDirectoryMode = 'year'; - export interface BlogDirectoryPost { href: string; title: string; @@ -11,54 +7,103 @@ export interface BlogDirectoryPost { tags: string[]; } -export interface BlogDirectoryGroup { - id: string; +export interface BlogTag { + value: string; label: string; - posts: BlogDirectoryPost[]; + href: string; + count: number; } -const UNTAGGED_LABEL = 'Untagged'; +export interface BlogArchiveYear { + year: string; + count: number; + months: { value: string; label: string; count: number }[]; +} -function groupId(mode: BlogDirectoryMode, label: string, index: number) { - const slug = label - .normalize('NFKD') - .toLowerCase() - .replace(/[^\p{Letter}\p{Number}]+/gu, '-') - .replace(/^-|-$/g, ''); +export function normalizeTags(tags: string[]): string[] { + return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; +} - return `${mode}-${slug || 'group'}-${index + 1}`; +// Escape uppercase letters too: case-distinct tags must produce distinct files +// even on case-insensitive filesystems. Escape '~' before using it for bytes. +export function tagValue(tag: string): string { + return Array.from(tag.trim(), (character) => { + if (/^[a-z0-9_-]$/.test(character)) return character; + return Array.from( + new TextEncoder().encode(character), + (byte) => `~${byte.toString(16).padStart(2, '0')}`, + ).join(''); + }).join(''); } -export function groupBlogPosts( - posts: BlogDirectoryPost[], - mode: BlogDirectoryMode, -): BlogDirectoryGroup[] { - const sortedPosts = [...posts].sort((left, right) => - right.publishedAt.localeCompare(left.publishedAt), - ); - const groups = new Map(); +export function tagHref(tag: string): string { + return `/tags/${tagValue(tag)}/`; +} - for (const post of sortedPosts) { - const firstTag = post.tags[0]?.trim(); - const label = - mode === 'tag' - ? firstTag || UNTAGGED_LABEL - : post.publishedAt.slice(0, 4); - const group = groups.get(label) ?? []; - group.push(post); - groups.set(label, group); +export function getBlogTags(posts: BlogDirectoryPost[]): BlogTag[] { + const counts = new Map(); + for (const post of posts) { + for (const tag of normalizeTags(post.tags)) { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + } } + return [...counts] + .map(([label, count]) => ({ + value: tagValue(label), + label, + href: tagHref(label), + count, + })) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'en')); +} - const labels = [...groups.keys()].sort((left, right) => { - if (mode === 'year') return right.localeCompare(left); - if (left === UNTAGGED_LABEL) return 1; - if (right === UNTAGGED_LABEL) return -1; - return left.localeCompare(right, 'en', { sensitivity: 'base' }); - }); +export function searchBlogTags(tags: BlogTag[], query: string): BlogTag[] { + const search = query.trim().toLowerCase(); + return tags.filter((tag) => tag.label.toLowerCase().includes(search)); +} + +export function tagSize(count: number, maximum: number): 'sm' | 'base' | 'lg' { + if (maximum <= 1) return 'base'; + if (count / maximum > 2 / 3) return 'lg'; + return count / maximum > 1 / 3 ? 'base' : 'sm'; +} - return labels.map((label, index) => ({ - id: groupId(mode, label, index), - label, - posts: groups.get(label) ?? [], - })); +export function selectBlogPosts( + posts: BlogDirectoryPost[], + { tag, month }: { tag?: string; month?: string } = {}, +): BlogDirectoryPost[] { + return posts + .filter( + (post) => + (tag === undefined || normalizeTags(post.tags).includes(tag)) && + (!month || post.publishedAt.slice(0, 7) === month), + ) + .sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); +} + +export function getBlogArchive(posts: BlogDirectoryPost[]): BlogArchiveYear[] { + const years = new Map>(); + for (const post of posts) { + const year = post.publishedAt.slice(0, 4); + const month = post.publishedAt.slice(0, 7); + const months = years.get(year) ?? new Map(); + months.set(month, (months.get(month) ?? 0) + 1); + years.set(year, months); + } + return [...years] + .sort(([a], [b]) => b.localeCompare(a)) + .map(([year, months]) => ({ + year, + count: [...months.values()].reduce((sum, count) => sum + count, 0), + months: [...months] + .sort(([a], [b]) => b.localeCompare(a)) + .map(([value, count]) => ({ + value, + label: new Intl.DateTimeFormat('en', { + month: 'long', + timeZone: 'UTC', + }).format(new Date(`${value}-01T00:00:00Z`)), + count, + })), + })); } diff --git a/src/pages/blog/[id].astro b/src/pages/blog/[id].astro index af3945f..06d8906 100644 --- a/src/pages/blog/[id].astro +++ b/src/pages/blog/[id].astro @@ -7,6 +7,8 @@ import BaseLayout from '@/layouts/BaseLayout.astro'; import { prepareBlogMarkdown } from '@/lib/blog-markdown'; import { getPublishedPosts } from '@/lib/collections'; import { formatDate } from '@/lib/format'; +import { Link } from '@astryxdesign/core/Link'; +import { normalizeTags, tagHref } from '@/lib/blog-directory'; export async function getStaticPaths() { const posts = await getPublishedPosts(); @@ -18,6 +20,7 @@ interface Props { } const { post } = Astro.props; +const tags = normalizeTags(post.data.tags); const body = prepareBlogMarkdown(post.body, post.data.title); const contentId = 'blog-article-content'; const hasOutline = parseOutlineFromMarkdown(body).length > 0; @@ -49,10 +52,14 @@ const jsonLd = { {post.data.excerpt &&

{post.data.excerpt}

} { - post.data.tags.length > 0 && ( + tags.length > 0 && (
    - {post.data.tags.map((tag) => ( -
  • {tag}
  • + {tags.map((tag) => ( +
  • + + {tag} + +
  • ))}
) diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index af1d1d5..a308e7b 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -4,9 +4,17 @@ import { EmptyState } from '@/components/EmptyState'; import PageHeader from '@/components/PageHeader.astro'; import BaseLayout from '@/layouts/BaseLayout.astro'; import { getPublishedPosts } from '@/lib/collections'; +import { getBlogTags } from '@/lib/blog-directory'; import { formatDate } from '@/lib/format'; -const posts = await getPublishedPosts(); +const posts = (await getPublishedPosts()).map((entry) => ({ + href: `/blog/${entry.id}/`, + title: entry.data.title, + summary: entry.data.excerpt, + publishedAt: entry.data.publishedAt, + formattedDate: formatDate(entry.data.publishedAt), + tags: entry.data.tags, +})); --- { posts.length ? ( - ({ - href: `/blog/${entry.id}/`, - title: entry.data.title, - summary: entry.data.excerpt, - publishedAt: entry.data.publishedAt, - formattedDate: formatDate(entry.data.publishedAt), - tags: entry.data.tags, - }))} - client:load - /> + ) : ( ({ + href: `/blog/${entry.id}/`, + title: entry.data.title, + summary: entry.data.excerpt, + publishedAt: entry.data.publishedAt, + formattedDate: formatDate(entry.data.publishedAt), + tags: entry.data.tags, + })); + const tags = getBlogTags(posts); + return tags.map((tag) => ({ + params: { value: tag.value }, + props: { tag, tags, posts: selectBlogPosts(posts, { tag: tag.label }) }, + })); +} + +interface Props { + tag: BlogTag; + tags: BlogTag[]; + posts: BlogDirectoryPost[]; +} + +const { tag, tags, posts } = Astro.props; +const description = `Blog posts tagged “${tag.label}”.`; +--- + + + + + diff --git a/src/styles/global.css b/src/styles/global.css index 68b6a51..95f3811 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -773,65 +773,41 @@ button:focus-visible { font-size: 0.875rem; } -.blog-directory, -.blog-directory-groups, -.blog-directory-group, -.cv-content { - display: grid; -} - .blog-directory { - gap: var(--spacing-6); -} - -.blog-directory-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: var(--spacing-3); + grid-template-columns: minmax(0, 1fr) minmax(0, 16rem); } -.blog-directory-controls p, -.blog-directory-group h2 { - margin: 0; -} - -.blog-directory-controls p { - color: var(--color-text-secondary); - font-weight: var(--font-weight-semibold); +.blog-directory-results, +.blog-directory-sidebar { + min-width: 0; + overflow-wrap: anywhere; } -.blog-directory-index ul { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-2) var(--spacing-4); - margin: 0; - padding: 0; - list-style: none; +.blog-directory-mobile { + display: none; } -.blog-directory-index a { - color: var(--color-accent); - font-weight: var(--font-weight-semibold); +.blog-tag-link { + overflow-wrap: anywhere; } -.blog-directory-groups { - gap: var(--spacing-8); -} +@media (max-width: 48rem) { + .blog-directory { + grid-template-columns: minmax(0, 1fr); + } -.blog-directory-group { - scroll-margin-block-start: calc(var(--size-element-lg) + var(--spacing-8)); - gap: var(--spacing-4); -} + .blog-directory-sidebar { + display: none; + } -.blog-directory-group h2 { - padding-block-end: var(--spacing-2); - border-block-end: calc(var(--border-width) * 2) solid var(--color-accent); - font-family: var(--font-family-heading); + .blog-directory-mobile { + display: block; + min-width: 0; + } } .cv-content { + display: grid; gap: var(--spacing-5); } @@ -1002,11 +978,6 @@ button:focus-visible { justify-content: flex-start; } - .blog-directory-controls { - align-items: stretch; - flex-direction: column; - } - .cv-actions > a { width: 100%; } From a9871cbc681140fa486ece053d699df1aa0b985c Mon Sep 17 00:00:00 2001 From: walishcs Date: Sun, 13 Sep 2026 14:12:27 +0800 Subject: [PATCH 3/3] Refine blog typography and left-aligned archive navigation --- src/components/BlogDirectory.test.tsx | 2 +- src/components/BlogDirectory.tsx | 117 +++++++++++++++++--------- src/styles/global.css | 17 +++- 3 files changed, 92 insertions(+), 44 deletions(-) diff --git a/src/components/BlogDirectory.test.tsx b/src/components/BlogDirectory.test.tsx index d54abc7..57c10a7 100644 --- a/src/components/BlogDirectory.test.tsx +++ b/src/components/BlogDirectory.test.tsx @@ -23,7 +23,7 @@ describe('blog directory rendering', () => { expect(html).toContain('dateTime="2026-06-01"'); expect(html).toContain('<unsafe>'); expect(html).toContain(`href="${tagHref('Second')}"`); - expect(html).toContain('aria-pressed="true"'); + expect(html).toContain('aria-current="true"'); expect(html).not.toContain('First tag'); }); it('provides empty-state copy without requiring content', () => { diff --git a/src/components/BlogDirectory.tsx b/src/components/BlogDirectory.tsx index d53407f..da7f3ed 100644 --- a/src/components/BlogDirectory.tsx +++ b/src/components/BlogDirectory.tsx @@ -1,4 +1,3 @@ -import { Button } from '@astryxdesign/core/Button'; import { Collapsible, CollapsibleGroup } from '@astryxdesign/core/Collapsible'; import { Heading } from '@astryxdesign/core/Heading'; import { Grid } from '@astryxdesign/core/Grid'; @@ -53,58 +52,69 @@ export function BlogDirectory({ gap={3} aria-labelledby={`${id}-${location}-archive`} > - + Archive -