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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build:
name: Lint & Format
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: ${{ runner.os }}-bun-

- name: Install dependencies
run: bun install

- name: Lint
run: bun run lint
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ bkio — Monorepo template for Bun + Hono + Vite + React + Cloudflare Workers.
- `bun run build` — Type-check API + build frontend
- `bun run deploy` — Build and deploy to Cloudflare Workers
- `bun run test` — Run all tests
- `bun run lint` — Lint with Biome
- `bun run format` — Format with Biome
- `bun run lint` — Lint with ESLint
- `bun run format` — Format with ESLint autofix

## Project Development

Expand Down
41 changes: 39 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'

type AssetBinding = {
interface AssetBinding {
fetch: (request: Request | string, init?: RequestInit) => Promise<Response>
}

export type Env = {
export interface Env {
Bindings: {
// Add your bindings here, e.g.:
// DB: D1Database
Expand All @@ -20,6 +20,36 @@ const app = new Hono<Env>()
app.use('*', logger())
app.use('*', cors())

function buildRedirectPath(pathname: string): string | null {
const locale = pathname === '/zh' || pathname.startsWith('/zh/') ? 'zh' : 'en'
let path = pathname

if (path === '/en' || path.startsWith('/en/')) {
path = path.slice(3) || '/'
} else if (path === '/zh' || path.startsWith('/zh/')) {
path = path.slice(3) || '/'
}

const prefix = locale === 'zh' ? '/zh' : ''

if (path === '/privacy')
return `${prefix}/sys/privacy`
if (path === '/terms')
return `${prefix}/sys/terms`
if (path === '/submit')
return `${prefix}/sys/submit`
if (path === '/tag')
return `${prefix}/tags`
if (path.startsWith('/tag/'))
return `${prefix}/tags/${path.slice('/tag/'.length)}`

if (pathname === '/en' || pathname.startsWith('/en/')) {
return path
}

return null
}

app.get('/api/health', (c) => {
return c.json({ status: 'ok', timestamp: Date.now() })
})
Expand All @@ -29,6 +59,13 @@ app.notFound((c) => {
return c.json({ message: 'Not Found' }, 404)
}

const redirectPath = buildRedirectPath(c.req.path)
if (redirectPath) {
const url = new URL(c.req.url)
url.pathname = redirectPath
return c.redirect(url.toString(), 308)
}

if (c.env.ASSETS) {
return c.env.ASSETS.fetch(c.req.raw)
}
Expand Down
4 changes: 2 additions & 2 deletions apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"compilerOptions": {
"baseUrl": ".",
"rootDir": "src",
"outDir": "dist",
"paths": {
"@/*": ["src/*"],
"@bkio/shared": ["../../packages/shared/src/index.ts"]
}
},
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
Expand Down
12 changes: 7 additions & 5 deletions apps/frontend/src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { localizePath } from '@/lib/routes'

const translations = {
en: {
privacy: 'Privacy Policy',
terms: 'Terms of Service',
submit: 'Submit',
copyright: '\u00a9 2026 BK.io',
copyright: '\u00A9 2026 BK.io',
},
zh: {
privacy: '隐私政策',
terms: '服务条款',
submit: '投稿',
copyright: '\u00a9 2026 BK.io',
copyright: '\u00A9 2026 BK.io',
},
} as const

Expand All @@ -21,9 +23,9 @@ export function Footer({ locale }: { locale: 'en' | 'zh' }) {
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 lg:px-8">
<div className="flex flex-col items-center gap-3 pt-6 text-center sm:flex-row sm:justify-between sm:text-left">
<div className="flex flex-wrap items-center justify-center gap-5 sm:justify-start">
<a href={`/${locale}/privacy`} className="transition-colors hover:text-foreground">{t.privacy}</a>
<a href={`/${locale}/terms`} className="transition-colors hover:text-foreground">{t.terms}</a>
<a href={`/${locale}/submit`} className="transition-colors hover:text-foreground">{t.submit}</a>
<a href={localizePath(locale, '/sys/privacy')} className="transition-colors hover:text-foreground">{t.privacy}</a>
<a href={localizePath(locale, '/sys/terms')} className="transition-colors hover:text-foreground">{t.terms}</a>
<a href={localizePath(locale, '/sys/submit')} className="transition-colors hover:text-foreground">{t.submit}</a>
</div>
<p className="whitespace-nowrap">{t.copyright}</p>
</div>
Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Github } from 'lucide-react'
import { LocaleSwitcher } from '@/components/LocaleSwitcher'
import { ThemeToggle } from '@/components/ThemeToggle'
import { localizePath } from '@/lib/routes'

const translations = {
en: { submit: 'Submit' },
Expand All @@ -13,7 +14,7 @@ export function Header({ locale }: { locale: 'en' | 'zh' }) {
return (
<header className="relative z-10">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-5 sm:px-6 lg:px-8">
<a href={`/${locale}/`}>
<a href={localizePath(locale, '/')}>
<img src="/logo.svg" alt="BK" className="h-10 w-10 rounded-lg" />
</a>

Expand All @@ -30,7 +31,7 @@ export function Header({ locale }: { locale: 'en' | 'zh' }) {
<LocaleSwitcher locale={locale} />
<ThemeToggle locale={locale} />
<a
href={`/${locale}/submit`}
href={localizePath(locale, '/sys/submit')}
className="inline-flex h-8 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-input bg-background px-3 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground"
>
{t.submit}
Expand Down
11 changes: 5 additions & 6 deletions apps/frontend/src/components/LocaleSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { switchLocalePath } from '@/lib/routes'

type Locale = 'en' | 'zh'

const STORAGE_KEY = 'bkio-locale'

const localeOptions: { value: Locale; label: string }[] = [
const localeOptions: { value: Locale, label: string }[] = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
]
Expand All @@ -22,10 +23,8 @@ export function LocaleSwitcher({ locale: initial }: { locale: Locale }) {
const setLocale = useCallback((next: Locale) => {
setLocaleState(next)
localStorage.setItem(STORAGE_KEY, next)
// Navigate to the same path under the new locale prefix
const path = window.location.pathname
const newPath = path.replace(/^\/(en|zh)/, `/${next}`)
window.location.href = newPath === path ? `/${next}/` : newPath
const newPath = switchLocalePath(window.location.pathname, next)
window.location.href = newPath
}, [])

return (
Expand All @@ -37,7 +36,7 @@ export function LocaleSwitcher({ locale: initial }: { locale: Locale }) {
<Globe className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{localeOptions.map((opt) => (
{localeOptions.map(opt => (
<DropdownMenuItem
key={opt.value}
onClick={() => setLocale(opt.value)}
Expand Down
39 changes: 39 additions & 0 deletions apps/frontend/src/components/MarkdownArticlePage.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
import type { Locale } from '@/i18n'
import Base from '@/layouts/Base.astro'
import { renderMarkdown } from '@/lib/content'

interface Props {
locale: Locale
title: string
description?: string
content: string
}

const { locale, title, description, content } = Astro.props
const htmlContent = await renderMarkdown(content, locale)
---

<Base title={title} description={description} locale={locale}>
<section class="relative z-10">
<div class="mx-auto w-full max-w-5xl px-4 pb-14 pt-8 sm:px-6 sm:pt-10 lg:px-8">
<div class="pb-8 text-center">
<h1 class="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
{title}
</h1>
{description && (
<p class="mx-auto mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{description}
</p>
)}
<div class="mt-5">
<slot name="actions" />
</div>
</div>

<div class="flex items-start justify-center">
<article class="prose-article rounded-xl bg-white p-6 shadow-sm dark:bg-white/[0.03] sm:p-8" set:html={htmlContent} />
</div>
</div>
</section>
</Base>
24 changes: 14 additions & 10 deletions apps/frontend/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ type Theme = 'light' | 'dark'
const STORAGE_KEY = 'bkio-theme'

function getSystemTheme(): Theme {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ?
'dark' :
'light'
}

const themeOptions: {
Expand All @@ -29,9 +29,11 @@ const themeOptions: {

export function ThemeToggle({ locale }: { locale: 'en' | 'zh' }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light'
if (typeof window === 'undefined')
return 'light'
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'light' || saved === 'dark') return saved
if (saved === 'light' || saved === 'dark')
return saved
return getSystemTheme()
})

Expand All @@ -50,11 +52,13 @@ export function ThemeToggle({ locale }: { locale: 'en' | 'zh' }) {
className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg outline-none hover:bg-muted hover:text-foreground size-7"
aria-label="Toggle color theme"
>
{theme === 'dark' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
{theme === 'dark' ?
(
<Moon className="h-4 w-4" />
) :
(
<Sun className="h-4 w-4" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{themeOptions.map((opt) => {
Expand Down
Loading