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
13 changes: 6 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

<!-- ASTRYX:START -->

Astryx v0.3.0 · 155 components
Astryx v0.4.3 · 156 components
CLI: run every command as `npx astryx <cmd>` (shown below as `astryx ...`).

SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled:
Expand All @@ -55,17 +55,16 @@ WORKFLOW — discover, don't guess. Before writing UI:

RULES:

- No <div> — components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav.
- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (`astryx docs layout`).
- Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only.
- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration.
- No <div> — components do all layout/spacing, page frame included.
- Frame first: read `astryx docs layout` before writing any page or screen — page frame, region widths, breakpoint behavior.
- Dense data = rows (Table, List/Item), never Card-wrapped list items; Card is for standalone widgets. Status = StatusDot/Token; Badge = counts only.
- Custom styling: component props first; else style/className with tokens — var(--color-_|--spacing-_|--radius-\*). No raw hex/px. (No StyleX/Tailwind compiler here — don't use xstyle/utility classes.)
- Tokens for every value (`astryx docs tokens`). Brand/accent via `astryx theme` — never override --color-\* in :root.
- Tokens for every value (`astryx docs tokens`). Brand/accent belongs in the theme (`astryx theme list` / `theme add <slug>`, or `astryx theme template` for a custom one) — never override --color-\* in :root.
- SELF-CHECK before you finish: re-read the file and replace any raw <div>/<span> layout, imported .css/@apply, or hardcoded value (#hex, 16px) with the component or a token (var(--color-_|--spacing-_|…)). If unsure a component/prop exists, run `astryx component <Name>` / `astryx search "<thing>"`; don't hand-roll CSS.

MORE CLI:
search "<query>" find any component / hook / doc / template / block
component --list 155 components by category
component --list 156 components by category
template --list page + block recipes
docs <topic> color, elevation, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling, theme, tokens, typography
swizzle <Name> eject component source for deep customization
Expand Down
6 changes: 5 additions & 1 deletion keystatic.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,11 @@ export default config({
itemLabel: (props) => props.value || 'Tag',
}),
cover: imageField('Cover image', 'blog'),
body: fields.markdoc({ label: 'Body' }),
body: fields.markdoc({
label: 'Body',
description:
'The title is displayed automatically. Start article sections with Heading 2.',
}),
},
}),
projects: collection({
Expand Down
56 changes: 28 additions & 28 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@
"@astrojs/markdoc": "2.0.6",
"@astrojs/react": "6.0.2",
"@astrojs/sitemap": "3.7.3",
"@astryxdesign/core": "0.3.0",
"@astryxdesign/theme-chocolate": "0.3.0",
"@astryxdesign/theme-matcha": "0.3.0",
"@astryxdesign/theme-neutral": "0.3.0",
"@astryxdesign/theme-stone": "0.3.0",
"@astryxdesign/core": "0.4.3",
"@astryxdesign/theme-chocolate": "0.4.3",
"@astryxdesign/theme-matcha": "0.4.3",
"@astryxdesign/theme-neutral": "0.4.3",
"@astryxdesign/theme-stone": "0.4.3",
"@keystar/ui": "0.9.2",
"@keystatic/astro": "5.2.0",
"@keystatic/core": "0.6.4",
Expand All @@ -39,7 +39,7 @@
},
"devDependencies": {
"@astrojs/check": "0.9.6",
"@astryxdesign/cli": "0.3.0",
"@astryxdesign/cli": "0.4.3",
"@types/node": "24.10.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
Expand Down
5 changes: 4 additions & 1 deletion src/components/BlogDirectory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import {
import { useMemo, useState } from 'react';
import { ContentCardGrid } from '@/components/ContentCardGrid';
import {
DEFAULT_BLOG_DIRECTORY_MODE,
groupBlogPosts,
type BlogDirectoryMode,
type BlogDirectoryPost,
} from '@/lib/blog-directory';

export function BlogDirectory({ posts }: { posts: BlogDirectoryPost[] }) {
const [mode, setMode] = useState<BlogDirectoryMode>('tag');
const [mode, setMode] = useState<BlogDirectoryMode>(
DEFAULT_BLOG_DIRECTORY_MODE,
);
const groups = useMemo(() => groupBlogPosts(posts, mode), [mode, posts]);

return (
Expand Down
14 changes: 14 additions & 0 deletions src/components/BlogMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Markdown } from '@astryxdesign/core/Markdown';

export function BlogMarkdown({ body }: { body: string }) {
return (
<Markdown
className="blog-markdown"
contentWidth="var(--reading-width)"
headingLevelStart={1}
autolink="gfm"
>
{body}
</Markdown>
);
}
2 changes: 1 addition & 1 deletion src/content/blog/bkuw-app.mdoc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: bkuw — 語言田野調查工具箱 APP
excerpt: |
bkuw 是賽考利克泰雅語的「排列」之意,是一套為單一研究者設計的離線詞彙資料庫,希望讓詞彙從採集、整理到匯出,都能在同一個清楚而可靠的工作環境中完成。
publishedAt: 2026-08-17
draft: true
draft: false
tags:
- bkuw
- 田野調查
Expand Down
10 changes: 9 additions & 1 deletion src/lib/blog-directory.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { groupBlogPosts, type BlogDirectoryPost } from './blog-directory';
import {
DEFAULT_BLOG_DIRECTORY_MODE,
groupBlogPosts,
type BlogDirectoryPost,
} from './blog-directory';

const posts: BlogDirectoryPost[] = [
{
Expand Down Expand Up @@ -37,6 +41,10 @@ const posts: BlogDirectoryPost[] = [
];

describe('blog directory grouping', () => {
it('defaults to grouping posts by year', () => {
expect(DEFAULT_BLOG_DIRECTORY_MODE).toBe('year');
});

it('uses only the first tag, sorts groups, and leaves untagged last', () => {
const groups = groupBlogPosts(posts, 'tag');

Expand Down
2 changes: 2 additions & 0 deletions src/lib/blog-directory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type BlogDirectoryMode = 'tag' | 'year';

export const DEFAULT_BLOG_DIRECTORY_MODE: BlogDirectoryMode = 'year';

export interface BlogDirectoryPost {
href: string;
title: string;
Expand Down
38 changes: 38 additions & 0 deletions src/lib/blog-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { BlogMarkdown } from '../components/BlogMarkdown';
import { prepareBlogMarkdown } from './blog-markdown';

describe('prepareBlogMarkdown', () => {
it('removes a leading heading that duplicates the page title', () => {
expect(
prepareBlogMarkdown(
'# An article title\n\n## First section\n\nBody text.',
'An article title',
),
).toBe('## First section\n\nBody text.');
});

it('keeps a leading heading with different content', () => {
const body = '# A distinct heading\n\nBody text.';

expect(prepareBlogMarkdown(body, 'The page title')).toBe(body);
});

it('handles empty content', () => {
expect(prepareBlogMarkdown(undefined, 'The page title')).toBe('');
});

it('renders article structure with Astryx Markdown', () => {
const html = renderToStaticMarkup(
createElement(BlogMarkdown, {
body: '## Section\n\n- Bullet\n\n1. Numbered',
}),
);

expect(html).toContain('<h2');
expect(html).toContain('data-list-style="disc"');
expect(html).toContain('data-list-style="decimal"');
});
});
19 changes: 19 additions & 0 deletions src/lib/blog-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const LEADING_HEADING = /^#\s+(.+?)\s*#?\s*(?:\r?\n|$)/;

function normalizeHeading(value: string) {
return value.trim().replace(/\s+/g, ' ');
}

export function prepareBlogMarkdown(
body: string | undefined,
title: string,
): string {
const source = body?.replace(/^\uFEFF/, '').trimStart() ?? '';
const match = source.match(LEADING_HEADING);

if (match?.[1] && normalizeHeading(match[1]) === normalizeHeading(title)) {
return source.slice(match[0].length).trimStart();
}

return source;
}
8 changes: 5 additions & 3 deletions src/pages/blog/[id].astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
---
import { render, type CollectionEntry } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import { BlogMarkdown } from '@/components/BlogMarkdown';
import BaseLayout from '@/layouts/BaseLayout.astro';
import { prepareBlogMarkdown } from '@/lib/blog-markdown';
import { getPublishedPosts } from '@/lib/collections';
import { formatDate } from '@/lib/format';

Expand All @@ -14,7 +16,7 @@ interface Props {
}

const { post } = Astro.props;
const { Content } = await render(post);
const body = prepareBlogMarkdown(post.body, post.data.title);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
Expand Down Expand Up @@ -56,6 +58,6 @@ const jsonLd = {
<img class="detail-cover" src={post.data.cover} alt="" />
)
}
<div class="prose"><Content /></div>
<BlogMarkdown body={body} />
</article>
</BaseLayout>