diff --git a/apps/frontend/src/components/ToolDetailPage.astro b/apps/frontend/src/components/ToolDetailPage.astro index 8eeb978..c358b99 100644 --- a/apps/frontend/src/components/ToolDetailPage.astro +++ b/apps/frontend/src/components/ToolDetailPage.astro @@ -3,7 +3,6 @@ import type { Locale } from '@/i18n' import { getTranslations } from '@/i18n' import Base from '@/layouts/Base.astro' import { - getToolBadge, getToolDoc, getToolDocs, getToolMeta, @@ -24,7 +23,6 @@ type RelatedToolCard = { href: string title: string description: string - badge: string logo: ReturnType } @@ -36,13 +34,12 @@ const docSlug = parts[1] || 'index' const t = getTranslations(locale) const toolsData = getToolsJson() const toolEntry = toolsData.list.find(item => item.slug === toolSlug) -const categoryLabel = toolEntry ? getToolBadge(toolsData, toolEntry.category, locale) : '' const toolTags = toolEntry ? getToolTags(toolsData, toolEntry.tags ?? [], locale) : [] -const meta = getToolMeta(toolSlug) -const { data: frontmatter, html: htmlContent } = await getToolDoc(toolSlug, docSlug, locale) +const meta = getToolMeta(toolSlug, toolsData) +const { data: frontmatter, html: htmlContent } = await getToolDoc(toolSlug, docSlug, locale, toolsData) -const allDocs = await getToolDocs(toolSlug, locale) +const allDocs = await getToolDocs(toolSlug, locale, toolsData) const sidebarDocs = allDocs .map(doc => ({ slug: doc.slug, @@ -80,14 +77,13 @@ const linkedTools: RelatedToolCard[] = (meta.link ?? []) if (!relatedEntry) return null - const relatedMeta = getToolMeta(relatedSlug) + const relatedMeta = getToolMeta(relatedSlug, toolsData) return { slug: relatedSlug, href: localizePath(locale, `/${relatedSlug}`), title: txt(relatedMeta.title, locale), description: txt(relatedMeta.description, locale), - badge: getToolBadge(toolsData, relatedEntry.category, locale), - logo: resolveToolLogo(relatedSlug, relatedEntry.logo), + logo: resolveToolLogo(relatedSlug, relatedEntry.logo, toolsData), } }) .filter((item): item is RelatedToolCard => item !== null) @@ -101,9 +97,6 @@ return null {txt(meta.title, locale)}
- {categoryLabel && toolEntry && ( - {categoryLabel} - )} {toolTags.map(tag => ( {tag.label} ))} @@ -156,34 +149,6 @@ return null )}
- {linkedTools.length > 0 && ( -
-
{t.toolDetail.link}
- -
- )} -
@@ -227,6 +192,33 @@ return null )}
diff --git a/apps/frontend/src/components/ToolDirectoryPage.astro b/apps/frontend/src/components/ToolDirectoryPage.astro index 5f5944d..1145e62 100644 --- a/apps/frontend/src/components/ToolDirectoryPage.astro +++ b/apps/frontend/src/components/ToolDirectoryPage.astro @@ -4,7 +4,6 @@ import type { Locale } from '@/i18n' import { getTranslations } from '@/i18n' import Base from '@/layouts/Base.astro' import { - getToolBadge, getToolMeta, getToolTags, getToolsJson, @@ -16,7 +15,6 @@ interface Props { locale: Locale metaTitle?: string metaDescription?: string - initialCategory?: string initialTag?: string detectLocale?: boolean } @@ -25,7 +23,6 @@ const { locale, metaTitle, metaDescription, - initialCategory, initialTag, detectLocale = false, } = Astro.props @@ -36,14 +33,12 @@ const toolsData = getToolsJson() const toolItems = toolsData.list .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)) .map((item) => { - const meta = getToolMeta(item.slug) - const logo = resolveToolLogo(item.slug, item.logo) + const meta = getToolMeta(item.slug, toolsData) + const logo = resolveToolLogo(item.slug, item.logo, toolsData) const title = txt(meta.title, locale) return { slug: item.slug, - category: item.category, - badge: getToolBadge(toolsData, item.category, locale), logo: logo.type === 'icon' ? item.logo! : logo.type === 'svg' ? 'custom' : '', logoSvg: logo.type === 'svg' ? logo.content : undefined, order: item.order, @@ -53,11 +48,6 @@ const toolItems = toolsData.list } }) -const categories = toolsData.categories.map(cat => ({ - key: cat.key, - label: txt(cat, locale), -})) - const allTags = toolsData.tags.map(tag => ({ key: tag.key, label: txt(tag, locale), @@ -84,13 +74,11 @@ const allTags = toolsData.tags.map(tag => ({ client:load locale={locale} tools={toolItems} - categories={categories} allTags={allTags} - initialCategory={initialCategory} initialTag={initialTag} translations={{ searchPlaceholder: t.hero.searchPlaceholder, - all: t.categories.all, + all: t.filters.all, view: t.card.view, noResults: t.card.noResults, tags: t.card.tags, diff --git a/apps/frontend/src/components/ToolSearch.tsx b/apps/frontend/src/components/ToolSearch.tsx index 7239c03..5f89196 100644 --- a/apps/frontend/src/components/ToolSearch.tsx +++ b/apps/frontend/src/components/ToolSearch.tsx @@ -27,8 +27,6 @@ interface ToolTag { interface ToolItem { slug: string - category: string - badge: string logo: string logoSvg?: string order: number @@ -37,11 +35,6 @@ interface ToolItem { tags?: ToolTag[] } -interface Category { - key: string - label: string -} - const iconMap: Record = { 'image': ImageIcon, 'braces': Braces, @@ -56,9 +49,7 @@ const PAGE_SIZE = 12 interface Props { locale: Locale tools: ToolItem[] - categories: Category[] allTags: ToolTag[] - initialCategory?: string initialTag?: string translations: { searchPlaceholder: string @@ -75,28 +66,13 @@ interface Props { } } -export function ToolSearch({ locale, tools, categories, allTags, initialCategory, initialTag, translations }: Props) { +export function ToolSearch({ locale, tools, allTags, initialTag, translations }: Props) { const [query, setQuery] = useState('') const [page, setPage] = useState(1) - const [activeCategory, setActiveCategory] = useState(initialCategory ?? 'all') const [activeTag, setActiveTag] = useState(initialTag ?? '') const normalizedQuery = query.trim().toLowerCase() - const allCategories: Category[] = [ - { key: 'all', label: translations.all }, - ...categories, - ] - - const categoryCounts = useMemo(() => { - const counts = new Map() - counts.set('all', tools.length) - for (const cat of categories) { - counts.set(cat.key, tools.filter(t => t.category === cat.key).length) - } - return counts - }, [tools, categories]) - const tagCounts = useMemo(() => { const counts = new Map() for (const tag of allTags) { @@ -107,17 +83,15 @@ export function ToolSearch({ locale, tools, categories, allTags, initialCategory const filteredTools = useMemo(() => { return tools.filter((tool) => { - const matchesCategory = - activeCategory === 'all' || tool.category === activeCategory const matchesTag = activeTag === '' || tool.tags?.some(t => t.key === activeTag) const searchText = - `${tool.title} ${tool.description} ${tool.badge} ${(tool.tags ?? []).map(t => t.label).join(' ')}`.toLowerCase() + `${tool.title} ${tool.description} ${(tool.tags ?? []).map(t => t.label).join(' ')}`.toLowerCase() const matchesQuery = normalizedQuery.length === 0 || searchText.includes(normalizedQuery) - return matchesCategory && matchesTag && matchesQuery + return matchesTag && matchesQuery }) - }, [tools, activeCategory, activeTag, normalizedQuery]) + }, [tools, activeTag, normalizedQuery]) const totalPages = Math.max(1, Math.ceil(filteredTools.length / PAGE_SIZE)) const currentPage = Math.min(page, totalPages) @@ -131,14 +105,6 @@ export function ToolSearch({ locale, tools, categories, allTags, initialCategory setPage(1) }, [normalizedQuery]) - function handleCategoryChange(key: string) { - setActiveCategory(key) - setActiveTag('') - setPage(1) - const url = key === 'all' ? localizePath(locale, '/') : localizePath(locale, `/category/${key}`) - window.history.replaceState(null, '', url) - } - function handleTagChange(key: string) { const newTag = activeTag === key ? '' : key setActiveTag(newTag) @@ -146,10 +112,7 @@ export function ToolSearch({ locale, tools, categories, allTags, initialCategory if (newTag) { window.history.replaceState(null, '', localizePath(locale, `/tags/${newTag}`)) } else { - const url = activeCategory === 'all' ? - localizePath(locale, '/') : - localizePath(locale, `/category/${activeCategory}`) - window.history.replaceState(null, '', url) + window.history.replaceState(null, '', localizePath(locale, '/')) } } @@ -166,32 +129,26 @@ export function ToolSearch({ locale, tools, categories, allTags, initialCategory /> -
- {allCategories.map(cat => ( + {allTags.length > 0 && ( +
- ))} -
- - {allTags.length > 0 && ( -
{allTags.map((tag) => { const count = tagCounts.get(tag.key) ?? 0 @@ -264,10 +221,7 @@ export function ToolSearch({ locale, tools, categories, allTags, initialCategory
- - {tool.badge} - - {tool.tags?.slice(0, 3).map(tag => ( + {tool.tags?.slice(0, 4).map(tag => ( segment.length > 0 && segment !== '.') +} + +function getToolContentPath(slug: string, toolsData: ToolsJson = getToolsJson()): string { + const item = toolsData.list.find(entry => entry.slug === slug) + const contentPath = item?.path ?? slug + return isSafeContentPath(contentPath) ? contentPath : slug +} + function replaceLinkPlaceholders(content: string): string { const segments = content.split(/(```[\s\S]*?```)/g) return segments @@ -167,16 +182,17 @@ function rewriteInternalLinks(html: string, locale: Locale): string { }) } -export function resolveToolLogo(slug: string, logo?: string): { type: 'icon', name: string } | { type: 'svg', content: string } | { type: 'none' } { +export function resolveToolLogo(slug: string, logo?: string, toolsData?: ToolsJson): { type: 'icon', name: string } | { type: 'svg', content: string } | { type: 'none' } { if (!logo) return { type: 'none' } - if (!isValidSlug(slug) || (logo.includes('.') && !isValidSlug(logo.replace('.svg', '')))) { + const contentPath = getToolContentPath(slug, toolsData) + if (!isSafeContentPath(contentPath) || (logo.includes('.') && !isValidSlug(logo.replace('.svg', '')))) { return { type: 'none' } } if (logo.includes('.')) { try { - const logoPath = resolve(CONTENT_ROOT, slug, logo) + const logoPath = resolve(CONTENT_ROOT, contentPath, logo) if (!logoPath.startsWith(resolve(CONTENT_ROOT))) return { type: 'none' } const content = readFileSync(logoPath, 'utf-8') @@ -196,13 +212,6 @@ export function getToolTags(toolsData: ToolsJson, tagKeys: string[], locale: Loc }) } -export function getToolBadge(toolsData: ToolsJson, category: string, locale: Locale): string { - const categoryItem = toolsData.categories.find(item => item.key === category) - if (!categoryItem) - return category - return txt(categoryItem.badge, locale) -} - export async function renderMarkdown(content: string, locale?: Locale): Promise { const highlighter = await getHighlighter() const prepared = replaceLinkPlaceholders(content) @@ -240,12 +249,19 @@ export async function renderMarkdown(content: string, locale?: Locale): Promise< } export function getToolsJson(): ToolsJson { - return JSON.parse(readFileSync(join(CONTENT_ROOT, 'list.json'), 'utf-8')) + const listJson = JSON.parse(readFileSync(join(CONTENT_ROOT, 'list.json'), 'utf-8')) as ToolListJson + const tagsJson = JSON.parse(readFileSync(join(CONTENT_ROOT, 'tags.json'), 'utf-8')) as TagItem[] + + return { + tags: tagsJson, + featured: listJson.featured, + list: listJson.list, + } } -export function getToolMeta(slug: string): ToolMeta { +export function getToolMeta(slug: string, toolsData?: ToolsJson): ToolMeta { try { - const metaPath = join(CONTENT_ROOT, slug, 'meta.json') + const metaPath = join(CONTENT_ROOT, getToolContentPath(slug, toolsData), 'meta.json') return JSON.parse(readFileSync(metaPath, 'utf-8')) } catch { return { @@ -264,9 +280,9 @@ function parseDocFilename(filename: string): { locale: string | null, docSlug: s return { locale: null, docSlug: name } } -export function getToolDocSlugs(slug: string): string[] { +export function getToolDocSlugs(slug: string, toolsData?: ToolsJson): string[] { try { - const toolDir = join(CONTENT_ROOT, slug) + const toolDir = join(CONTENT_ROOT, getToolContentPath(slug, toolsData)) const files = readdirSync(toolDir).filter(filename => filename.endsWith('.md')) const slugs = new Set() for (const filename of files) { @@ -278,9 +294,9 @@ export function getToolDocSlugs(slug: string): string[] { } } -export async function getToolDocs(slug: string, locale?: Locale): Promise { +export async function getToolDocs(slug: string, locale?: Locale, toolsData?: ToolsJson): Promise { let files: string[] - const toolDir = join(CONTENT_ROOT, slug) + const toolDir = join(CONTENT_ROOT, getToolContentPath(slug, toolsData)) try { files = readdirSync(toolDir).filter(filename => filename.endsWith('.md')) @@ -312,12 +328,13 @@ export async function getToolDocs(slug: string, locale?: Locale): Promise ({ - params: { locale: 'zh', category: category.key }, - })) -} - -const locale = 'zh' as const -const categoryKey = Astro.params.category! -const t = getTranslations(locale) -const toolsData = getToolsJson() -const categoryObj = toolsData.categories.find(item => item.key === categoryKey) -const categoryLabel = categoryObj ? txt(categoryObj, locale) : categoryKey ---- - - diff --git a/apps/frontend/src/pages/[locale]/sys/submit.astro b/apps/frontend/src/pages/[locale]/sys/submit.astro index 326f6b4..08484bb 100644 --- a/apps/frontend/src/pages/[locale]/sys/submit.astro +++ b/apps/frontend/src/pages/[locale]/sys/submit.astro @@ -25,8 +25,9 @@ const content = String.raw` # 1. 在 GitHub 上 Fork 仓库 # 2. 克隆你的 Fork # 3. 添加 content// -# 4. 在 content/list.json 里注册 slug -# 5. 提交、推送并创建 Pull Request +# 4. 如果需要,先在 content/tags.json 里补标签文案 +# 5. 在 content/list.json 里注册 slug +# 6. 提交、推送并创建 Pull Request git clone https://github.com//website.git cd website @@ -37,6 +38,7 @@ mkdir -p content/my-tool \`\`\`text content/ +├── tags.json ├── list.json └── my-tool/ ├── meta.json @@ -55,6 +57,7 @@ content/ ## 说明 - slug 必须是 URL 安全格式,像 \`zzci/chrome\` 这样的展示名应该写进 \`meta.json\`,slug 则用 \`zzci-chrome\`。 +- 新标签的文案定义写在 \`content/tags.json\`,工具条目里只引用对应的 tag key。 - 文档里的站内链接用 \`[[agent-browser]]\` 或 \`[[bkd/data|数据说明]]\` 这种占位符写法,渲染时会自动按语言切换。 - 如果两个工具需要互相关联,可以在 \`meta.json\` 里添加 \`link\`。 ` diff --git a/apps/frontend/src/pages/[locale]/template.astro b/apps/frontend/src/pages/[locale]/template.astro index f14acfb..736363b 100644 --- a/apps/frontend/src/pages/[locale]/template.astro +++ b/apps/frontend/src/pages/[locale]/template.astro @@ -16,6 +16,7 @@ const content = String.raw` \`\`\`text content/ +├── tags.json ├── list.json └── my-tool/ ├── meta.json @@ -45,27 +46,34 @@ content/ } \`\`\` +## \`content/tags.json\` + +\`\`\`json +[ + { "key": "devTools", "en": "Dev Tools", "zh": "开发工具" }, + { "key": "open-source", "en": "Open Source", "zh": "开源" } +] +\`\`\` + ## \`content/list.json\` 注册示例 \`\`\`json { "slug": "my-tool", - "category": "devTools", - "tags": ["open-source", "free", "cli", "web"] + "path": "my-group/my-tool", + "tags": ["devTools", "open-source", "free", "cli", "web"] } \`\`\` -## 可用分类 +只有当内容目录和 URL slug 不一致时才需要写 \`path\`。 +例如 \`slug: "zzci-chrome"\` 可以对应到 \`path: "zzci/chrome"\`。 +标签文案定义放在 \`content/tags.json\`,工具条目里只引用 tag key。 + +## 常用标签 - \`images\` -- \`textCode\` -- \`colors\` -- \`converters\` - \`devTools\` - \`containers\` - -## 常用标签 - - \`open-source\` - \`free\` - \`desktop\` diff --git a/apps/frontend/src/pages/category/[category].astro b/apps/frontend/src/pages/category/[category].astro deleted file mode 100644 index 34eec84..0000000 --- a/apps/frontend/src/pages/category/[category].astro +++ /dev/null @@ -1,21 +0,0 @@ ---- -import ToolDirectoryPage from '@/components/ToolDirectoryPage.astro' -import { getTranslations } from '@/i18n' -import { getToolsJson, txt } from '@/lib/content' - -export function getStaticPaths() { - const toolsData = getToolsJson() - return toolsData.categories.map(category => ({ - params: { category: category.key }, - })) -} - -const locale = 'en' as const -const categoryKey = Astro.params.category! -const t = getTranslations(locale) -const toolsData = getToolsJson() -const categoryObj = toolsData.categories.find(item => item.key === categoryKey) -const categoryLabel = categoryObj ? txt(categoryObj, locale) : categoryKey ---- - - diff --git a/apps/frontend/src/pages/sys/submit.astro b/apps/frontend/src/pages/sys/submit.astro index cdefe0a..164ce60 100644 --- a/apps/frontend/src/pages/sys/submit.astro +++ b/apps/frontend/src/pages/sys/submit.astro @@ -21,8 +21,9 @@ If you want to add a tool manually, start from the [Template Guide →](/templat # 1. Fork the repo on GitHub # 2. Clone your fork # 3. Add content// -# 4. Register the slug in content/list.json -# 5. Commit, push, and open a Pull Request +# 4. Add tag labels in content/tags.json if needed +# 5. Register the slug in content/list.json +# 6. Commit, push, and open a Pull Request git clone https://github.com//website.git cd website @@ -33,6 +34,7 @@ mkdir -p content/my-tool \`\`\`text content/ +├── tags.json ├── list.json └── my-tool/ ├── meta.json @@ -52,6 +54,7 @@ content/ - Keep the slug URL-safe. Use \`zzci-chrome\`, not \`zzci/chrome\`. - Put display names such as \`zzci/chrome\` in \`meta.json\`. +- Add new tag label definitions to \`content/tags.json\`; use their keys in \`content/list.json\`. - Use placeholders like \`[[agent-browser]]\` or \`[[bkd/data|Data & Storage]]\` for internal links. They are localized automatically. - Add \`link\` in \`meta.json\` if this tool should link to another entry. ` diff --git a/apps/frontend/src/pages/template.astro b/apps/frontend/src/pages/template.astro index 96a260a..c51af6e 100644 --- a/apps/frontend/src/pages/template.astro +++ b/apps/frontend/src/pages/template.astro @@ -12,6 +12,7 @@ Use this page as the copyable starter for a custom tool entry. \`\`\`text content/ +├── tags.json ├── list.json └── my-tool/ ├── meta.json @@ -41,27 +42,34 @@ content/ } \`\`\` +## \`content/tags.json\` + +\`\`\`json +[ + { "key": "devTools", "en": "Dev Tools", "zh": "开发工具" }, + { "key": "open-source", "en": "Open Source", "zh": "开源" } +] +\`\`\` + ## \`content/list.json\` Entry \`\`\`json { "slug": "my-tool", - "category": "devTools", - "tags": ["open-source", "free", "cli", "web"] + "path": "my-group/my-tool", + "tags": ["devTools", "open-source", "free", "cli", "web"] } \`\`\` -## Available Categories +Use \`path\` only when the content directory should differ from the URL slug. +For example, \`slug: "zzci-chrome"\` can point to \`path: "zzci/chrome"\`. +Keep tag label definitions in \`content/tags.json\`, and reference their keys from \`content/list.json\`. + +## Common Tags - \`images\` -- \`textCode\` -- \`colors\` -- \`converters\` - \`devTools\` - \`containers\` - -## Common Tags - - \`open-source\` - \`free\` - \`desktop\` diff --git a/apps/frontend/src/styles/global.css b/apps/frontend/src/styles/global.css index 9d44c45..27f8e71 100644 --- a/apps/frontend/src/styles/global.css +++ b/apps/frontend/src/styles/global.css @@ -260,7 +260,7 @@ background: transparent; color: var(--muted-foreground); cursor: pointer; - opacity: 0; + opacity: 1; transition: opacity 0.15s, background-color 0.15s, @@ -268,10 +268,6 @@ z-index: 2; } -.prose-article .code-block:hover .code-copy-btn { - opacity: 1; -} - .prose-article .code-block .code-copy-btn:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); border-color: var(--border); diff --git a/content/demo/configuration.md b/content/demo/configuration.md deleted file mode 100644 index b688020..0000000 --- a/content/demo/configuration.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: - en: Configuration - zh: 配置 -order: 2 ---- - -Image Compressor ships with sensible defaults, but advanced users can fine-tune output quality, rename patterns, overwrite behavior, and destination handling from the preferences panel. Each option updates instantly and applies to batch exports. - -图片压缩器默认配置合理,高级用户可以在偏好设置面板中微调输出质量、重命名模式、覆盖行为和目标处理。每个选项都会即时更新并应用于批量导出。 diff --git a/content/demo/index.md b/content/demo/index.md deleted file mode 100644 index e78b6d5..0000000 --- a/content/demo/index.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: - en: Introduction - zh: 简介 -order: 0 ---- - -## Overview - -Image Compressor is a lightweight desktop application that reduces the file size of your PNG, JPG, and WebP images without losing visible quality. All processing happens locally on your machine — your files never leave your device. - -Built with performance in mind, Image Compressor leverages modern compression algorithms including MozJPEG, OptiPNG, and libwebp to achieve optimal file size reduction while preserving visual fidelity. Whether you're a web developer optimizing assets, a photographer managing large libraries, or anyone who needs smaller image files, this tool provides a simple drag-and-drop workflow. - -### Key Features - -- **Batch Processing** — Compress hundreds of images at once with parallel processing. Simply drag a folder onto the app and let it handle the rest. -- **Smart Quality Detection** — Automatically analyzes each image and selects the optimal compression level. No manual tweaking required for most use cases. -- **Format Conversion** — Convert between PNG, JPG, and WebP formats during compression. Easily migrate your entire image library to modern WebP format. -- **Lossless Mode** — When every pixel matters, enable lossless mode to reduce file size without any quality degradation. -- **Watch Mode** — Monitor folders for new images and compress them automatically as they appear. -- **EXIF Data Control** — Choose to preserve or strip metadata. Remove GPS coordinates for privacy while keeping camera settings. - -### How It Works - -Image Compressor uses a multi-stage pipeline to process your images: - -1. **Analysis** — The image is loaded and analyzed for color depth, transparency, and complexity. This determines which compression strategy will be most effective. -2. **Pre-processing** — Optional steps like resizing, color profile conversion (sRGB normalization), and alpha channel optimization are applied. -3. **Compression** — The appropriate encoder processes the image using the selected quality settings. For JPEG, MozJPEG provides superior compression compared to standard libraries. For PNG, OptiPNG finds the optimal filter and compression parameters. For WebP, Google's libwebp encoder handles both lossy and lossless modes. -4. **Verification** — The output is compared against the original to ensure quality thresholds are met. If the compressed file is somehow larger than the original, the original is kept. - -### Supported Formats - -| Format | Input | Output | Notes | -|--------|-------|--------|-------| -| JPEG/JPG | Yes | Yes | Progressive JPEG support | -| PNG | Yes | Yes | 8-bit and 24-bit with alpha | -| WebP | Yes | Yes | Lossy and lossless modes | -| AVIF | Yes | No | Read-only, convert to other formats | -| GIF | Yes | No | Static frames only | - -### System Requirements - -- **macOS** 12.0 or later (Universal Binary — Apple Silicon and Intel) -- **Windows** 10 version 1903 or later (64-bit) -- **Linux** — Ubuntu 20.04+, Fedora 36+, or equivalent (x86_64 and arm64) -- Minimum 4 GB RAM (8 GB recommended for batch processing) -- 100 MB disk space for installation - ---- - -## 概述 - -图片压缩器是一款轻量级桌面应用,可以在不损失可见质量的情况下减小 PNG、JPG 和 WebP 图片的文件大小。所有处理都在本地完成——你的文件永远不会离开你的设备。 - -图片压缩器以性能为核心设计,利用 MozJPEG、OptiPNG 和 libwebp 等现代压缩算法,在保持视觉保真度的同时实现最佳的文件大小缩减。无论你是优化资源的 Web 开发者、管理大量图片库的摄影师,还是任何需要更小图片文件的人,这款工具都提供了简单的拖放式工作流程。 - -### 主要功能 - -- **批量处理** — 通过并行处理一次压缩数百张图片。只需将文件夹拖到应用上,让它处理剩下的工作。 -- **智能质量检测** — 自动分析每张图片并选择最佳压缩级别。大多数情况下无需手动调整。 -- **格式转换** — 在压缩过程中转换 PNG、JPG 和 WebP 格式。轻松将整个图片库迁移到现代 WebP 格式。 -- **无损模式** — 当每个像素都很重要时,启用无损模式可在不降低任何质量的情况下减小文件大小。 -- **监视模式** — 监控文件夹中的新图片,并在出现时自动压缩。 -- **EXIF 数据控制** — 选择保留或删除元数据。删除 GPS 坐标以保护隐私,同时保留相机设置。 - -### 工作原理 - -图片压缩器使用多阶段管道处理你的图片: - -1. **分析** — 加载并分析图片的色深、透明度和复杂度,以确定最有效的压缩策略。 -2. **预处理** — 应用可选步骤,如调整大小、颜色配置文件转换(sRGB 标准化)和 Alpha 通道优化。 -3. **压缩** — 使用所选质量设置处理图片。JPEG 使用 MozJPEG,PNG 使用 OptiPNG,WebP 使用 Google 的 libwebp 编码器。 -4. **验证** — 将输出与原始文件进行比较,确保满足质量阈值。如果压缩文件比原始文件大,则保留原始文件。 - -### 支持的格式 - -| 格式 | 输入 | 输出 | 备注 | -|------|------|------|------| -| JPEG/JPG | 支持 | 支持 | 支持渐进式 JPEG | -| PNG | 支持 | 支持 | 8 位和 24 位(含 Alpha) | -| WebP | 支持 | 支持 | 有损和无损模式 | -| AVIF | 支持 | 不支持 | 仅读取,转换为其他格式 | -| GIF | 支持 | 不支持 | 仅静态帧 | - -### 系统要求 - -- **macOS** 12.0 或更高版本(通用二进制 — 支持 Apple Silicon 和 Intel) -- **Windows** 10 1903 版本或更高(64 位) -- **Linux** — Ubuntu 20.04+、Fedora 36+ 或同等版本(x86_64 和 arm64) -- 最低 4 GB 内存(批量处理建议 8 GB) -- 100 MB 磁盘空间用于安装 diff --git a/content/demo/logo.svg b/content/demo/logo.svg deleted file mode 100644 index 9b5f89c..0000000 --- a/content/demo/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/content/demo/meta.json b/content/demo/meta.json deleted file mode 100644 index bfdf24b..0000000 --- a/content/demo/meta.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "title": { "en": "Demo Tool", "zh": "示例工具" }, - "description": { - "en": "A fast, privacy-focused desktop app that reduces PNG, JPG, and WebP file sizes using MozJPEG, OptiPNG, and libwebp — with batch processing, smart quality detection, and format conversion. All processing stays on your device.", - "zh": "一款快速、注重隐私的桌面应用,使用 MozJPEG、OptiPNG 和 libwebp 压缩 PNG、JPG 和 WebP 图片——支持批量处理、智能质量检测和格式转换。所有处理均在本地完成。" - }, - "version": "2.4.1", - "website": "https://example.com/image-compressor", - "github": "https://github.com/example/image-compressor", - "addedAt": "2026-01-15", - "addedBy": { "name": "Alex", "url": "https://github.com/alex" }, - "links": [ - { - "label": { "en": "Changelog", "zh": "更新日志" }, - "url": "https://github.com/example/image-compressor/releases" - }, - { - "label": { "en": "Report Issue", "zh": "报告问题" }, - "url": "https://github.com/example/image-compressor/issues" - } - ] -} diff --git a/content/demo/troubleshooting.md b/content/demo/troubleshooting.md deleted file mode 100644 index 77a76a8..0000000 --- a/content/demo/troubleshooting.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: - en: Troubleshooting - zh: 故障排除 -order: 3 ---- - -If an export fails, check write permissions for your destination folder and confirm the image format is supported. For reproducible issues, visit the GitHub repository and include your operating system, app version, and a sample file when filing a report. - -如果导出失败,请检查目标文件夹的写入权限并确认图片格式是否受支持。对于可复现的问题,请访问 GitHub 仓库,在提交报告时附上操作系统、应用版本和示例文件。 diff --git a/content/demo/usage.md b/content/demo/usage.md deleted file mode 100644 index c1940d6..0000000 --- a/content/demo/usage.md +++ /dev/null @@ -1,233 +0,0 @@ ---- -title: - en: Usage Guide - zh: 使用指南 -order: 1 ---- - -## Quick Start - -Install Image Compressor via your preferred package manager: - -```bash -# macOS -brew install image-compressor - -# Windows (winget) -winget install ImageCompressor - -# Linux (snap) -sudo snap install image-compressor -``` - -Or download the installer directly from the [releases page](https://example.com). - -## CLI Usage - -Image Compressor includes a powerful command-line interface for scripting and automation: - -```bash -# Compress a single image -imgc compress photo.jpg - -# Compress with custom quality (0-100) -imgc compress --quality 80 photo.jpg - -# Batch compress an entire directory -imgc compress --recursive ./images/ - -# Convert to WebP format -imgc convert --format webp --quality 85 ./images/*.png - -# Watch a folder for new images -imgc watch --quality 75 --format webp ~/Downloads/ -``` - -### CLI Options Reference - -``` -Usage: imgc [options] - -Commands: - compress Compress images in place or to output directory - convert Convert images between formats - watch Monitor directory for new images - info Display image metadata and compression stats - -Options: - -q, --quality Quality level 0-100 (default: 80) - -f, --format Output format: jpg, png, webp (default: same as input) - -o, --output Output directory (default: overwrite in place) - -r, --recursive Process subdirectories - --lossless Use lossless compression - --strip-metadata Remove EXIF/IPTC data - --keep-gps Preserve GPS coordinates (stripped by default) - --max-width Resize if wider than specified pixels - --max-height Resize if taller than specified pixels - --threads Number of parallel workers (default: CPU cores) - -v, --verbose Show detailed processing info - --dry-run Preview changes without writing files -``` - -## Node.js API - -Use Image Compressor programmatically in your build pipeline: - -```typescript -import { compress, convert, ImageOptions } from 'image-compressor'; - -// Basic compression -const result = await compress('photo.jpg', { quality: 80 }); -console.log(`Saved ${result.savedBytes} bytes (${result.savedPercent}%)`); - -// Batch compression with options -const options: ImageOptions = { - quality: 75, - format: 'webp', - stripMetadata: true, - maxWidth: 1920, - progressive: true, -}; - -const files = await compress('./images/**/*.{jpg,png}', options); -for (const file of files) { - console.log(`${file.path}: ${file.originalSize} → ${file.compressedSize}`); -} -``` - -### Integration with Build Tools - -**Vite Plugin:** - -```typescript -// vite.config.ts -import { defineConfig } from 'vite'; -import imageCompressor from 'image-compressor/vite'; - -export default defineConfig({ - plugins: [ - imageCompressor({ - include: ['**/*.{jpg,png,webp}'], - exclude: ['node_modules/**'], - quality: 80, - format: 'webp', - // Only compress in production builds - enabled: process.env.NODE_ENV === 'production', - }), - ], -}); -``` - -**Webpack Loader:** - -```javascript -// webpack.config.js -module.exports = { - module: { - rules: [ - { - test: /\.(jpe?g|png|webp)$/i, - use: [ - { - loader: 'image-compressor/webpack', - options: { - quality: 80, - progressive: true, - stripMetadata: true, - }, - }, - ], - }, - ], - }, -}; -``` - -## GitHub Actions - -Automate image compression in your CI/CD pipeline: - -```yaml -# .github/workflows/compress-images.yml -name: Compress Images - -on: - pull_request: - paths: - - '**.jpg' - - '**.jpeg' - - '**.png' - - '**.webp' - -jobs: - compress: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Compress new/changed images - uses: example/image-compressor-action@v2 - with: - quality: 80 - format: preserve - strip-metadata: true - commit-changes: true - commit-message: 'chore: compress images [skip ci]' -``` - -## Configuration File - -Create an `.imgcrc.json` in your project root for persistent settings: - -```json -{ - "quality": 80, - "format": "preserve", - "stripMetadata": true, - "keepGps": false, - "progressive": true, - "lossless": false, - "maxWidth": 2560, - "threads": 4, - "ignore": [ - "node_modules/**", - ".git/**", - "dist/**", - "**/*.ico" - ], - "overrides": [ - { - "match": "**/thumbnails/**", - "quality": 60, - "maxWidth": 400 - }, - { - "match": "**/hero-*", - "quality": 90, - "lossless": false - } - ] -} -``` - -## Output Example - -When running in verbose mode, you'll see detailed compression stats: - -``` -$ imgc compress --verbose --recursive ./photos/ - -Processing 47 images with 8 threads... - - photos/landscape.jpg 4.2 MB → 1.1 MB (73.8% saved) ✓ - photos/portrait.png 8.7 MB → 2.3 MB (73.6% saved) ✓ - photos/icon.png 24 KB → 18 KB (25.0% saved) ✓ - photos/banner.webp 1.8 MB → 890 KB (51.7% saved) ✓ - photos/tiny.jpg 3.2 KB → 3.2 KB (skipped, already optimal) - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Total: 47 files processed in 2.3s - Saved: 89.4 MB (68.2% average reduction) - Skipped: 3 files (already optimal) -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` diff --git a/content/list.json b/content/list.json index 969e7a5..10c567f 100644 --- a/content/list.json +++ b/content/list.json @@ -1,56 +1,27 @@ { - "categories": [ - { "key": "images", "en": "Images", "zh": "图片", "badge": { "en": "Images", "zh": "图片" } }, - { "key": "textCode", "en": "Text & Code", "zh": "文本 & 代码", "badge": { "en": "Text & Code", "zh": "文本 & 代码" } }, - { "key": "colors", "en": "Colors", "zh": "颜色", "badge": { "en": "Colors", "zh": "颜色" } }, - { "key": "converters", "en": "Converters", "zh": "转换器", "badge": { "en": "Converters", "zh": "转换器" } }, - { "key": "devTools", "en": "Dev Tools", "zh": "开发工具", "badge": { "en": "Dev Tools", "zh": "开发工具" } }, - { "key": "containers", "en": "Containers", "zh": "容器", "badge": { "en": "Containers", "zh": "容器" } } - ], - "tags": [ - { "key": "open-source", "en": "Open Source", "zh": "开源" }, - { "key": "free", "en": "Free", "zh": "免费" }, - { "key": "desktop", "en": "Desktop", "zh": "桌面端" }, - { "key": "cli", "en": "CLI", "zh": "命令行" }, - { "key": "web", "en": "Web", "zh": "网页" }, - { "key": "privacy", "en": "Privacy", "zh": "隐私" }, - { "key": "docker", "en": "Docker", "zh": "Docker" }, - { "key": "self-hosted", "en": "Self-Hosted", "zh": "自托管" }, - { "key": "automation", "en": "Automation", "zh": "自动化" }, - { "key": "proxy", "en": "Proxy", "zh": "代理" } - ], - "featured": ["demo", "bkd"], + "featured": ["bkd"], "list": [ - { - "slug": "demo", - "category": "images", - "logo": "logo.svg", - "order": 1, - "tags": ["open-source", "free", "desktop", "privacy"] - }, { "slug": "bkd", - "category": "devTools", - "order": 2, - "tags": ["open-source", "web"] + "order": 1, + "tags": ["devTools", "open-source", "web"] }, { "slug": "zzci-traefik", - "category": "containers", - "order": 3, - "tags": ["open-source", "free", "cli", "web", "docker", "self-hosted", "proxy"] + "path": "zzci/traefik", + "order": 2, + "tags": ["containers", "open-source", "free", "cli", "web", "docker", "self-hosted", "proxy"] }, { "slug": "zzci-chrome", - "category": "containers", - "order": 4, - "tags": ["open-source", "free", "web", "docker", "self-hosted", "automation"] + "path": "zzci/chrome", + "order": 3, + "tags": ["containers", "open-source", "free", "web", "docker", "self-hosted", "automation"] }, { "slug": "agent-browser", - "category": "devTools", - "order": 5, - "tags": ["open-source", "free", "cli", "web", "automation"] + "order": 4, + "tags": ["devTools", "open-source", "free", "cli", "web", "automation"] } ] } diff --git a/content/tags.json b/content/tags.json new file mode 100644 index 0000000..16a7e9d --- /dev/null +++ b/content/tags.json @@ -0,0 +1,15 @@ +[ + { "key": "images", "en": "Images", "zh": "图片" }, + { "key": "devTools", "en": "Dev Tools", "zh": "开发工具" }, + { "key": "containers", "en": "Containers", "zh": "容器" }, + { "key": "open-source", "en": "Open Source", "zh": "开源" }, + { "key": "free", "en": "Free", "zh": "免费" }, + { "key": "desktop", "en": "Desktop", "zh": "桌面端" }, + { "key": "cli", "en": "CLI", "zh": "命令行" }, + { "key": "web", "en": "Web", "zh": "网页" }, + { "key": "privacy", "en": "Privacy", "zh": "隐私" }, + { "key": "docker", "en": "Docker", "zh": "Docker" }, + { "key": "self-hosted", "en": "Self-Hosted", "zh": "自托管" }, + { "key": "automation", "en": "Automation", "zh": "自动化" }, + { "key": "proxy", "en": "Proxy", "zh": "代理" } +] diff --git a/content/zzci-chrome/en.configuration.md b/content/zzci/chrome/en.configuration.md similarity index 100% rename from content/zzci-chrome/en.configuration.md rename to content/zzci/chrome/en.configuration.md diff --git a/content/zzci-chrome/en.data.md b/content/zzci/chrome/en.data.md similarity index 100% rename from content/zzci-chrome/en.data.md rename to content/zzci/chrome/en.data.md diff --git a/content/zzci-chrome/en.faq.md b/content/zzci/chrome/en.faq.md similarity index 100% rename from content/zzci-chrome/en.faq.md rename to content/zzci/chrome/en.faq.md diff --git a/content/zzci-chrome/en.index.md b/content/zzci/chrome/en.index.md similarity index 100% rename from content/zzci-chrome/en.index.md rename to content/zzci/chrome/en.index.md diff --git a/content/zzci-chrome/en.installation.md b/content/zzci/chrome/en.installation.md similarity index 100% rename from content/zzci-chrome/en.installation.md rename to content/zzci/chrome/en.installation.md diff --git a/content/zzci-chrome/meta.json b/content/zzci/chrome/meta.json similarity index 100% rename from content/zzci-chrome/meta.json rename to content/zzci/chrome/meta.json diff --git a/content/zzci-chrome/zh.configuration.md b/content/zzci/chrome/zh.configuration.md similarity index 100% rename from content/zzci-chrome/zh.configuration.md rename to content/zzci/chrome/zh.configuration.md diff --git a/content/zzci-chrome/zh.data.md b/content/zzci/chrome/zh.data.md similarity index 100% rename from content/zzci-chrome/zh.data.md rename to content/zzci/chrome/zh.data.md diff --git a/content/zzci-chrome/zh.faq.md b/content/zzci/chrome/zh.faq.md similarity index 100% rename from content/zzci-chrome/zh.faq.md rename to content/zzci/chrome/zh.faq.md diff --git a/content/zzci-chrome/zh.index.md b/content/zzci/chrome/zh.index.md similarity index 100% rename from content/zzci-chrome/zh.index.md rename to content/zzci/chrome/zh.index.md diff --git a/content/zzci-chrome/zh.installation.md b/content/zzci/chrome/zh.installation.md similarity index 100% rename from content/zzci-chrome/zh.installation.md rename to content/zzci/chrome/zh.installation.md diff --git a/content/zzci-traefik/en.configuration.md b/content/zzci/traefik/en.configuration.md similarity index 100% rename from content/zzci-traefik/en.configuration.md rename to content/zzci/traefik/en.configuration.md diff --git a/content/zzci-traefik/en.data.md b/content/zzci/traefik/en.data.md similarity index 100% rename from content/zzci-traefik/en.data.md rename to content/zzci/traefik/en.data.md diff --git a/content/zzci-traefik/en.faq.md b/content/zzci/traefik/en.faq.md similarity index 100% rename from content/zzci-traefik/en.faq.md rename to content/zzci/traefik/en.faq.md diff --git a/content/zzci-traefik/en.index.md b/content/zzci/traefik/en.index.md similarity index 100% rename from content/zzci-traefik/en.index.md rename to content/zzci/traefik/en.index.md diff --git a/content/zzci-traefik/en.installation.md b/content/zzci/traefik/en.installation.md similarity index 100% rename from content/zzci-traefik/en.installation.md rename to content/zzci/traefik/en.installation.md diff --git a/content/zzci-traefik/meta.json b/content/zzci/traefik/meta.json similarity index 100% rename from content/zzci-traefik/meta.json rename to content/zzci/traefik/meta.json diff --git a/content/zzci-traefik/zh.configuration.md b/content/zzci/traefik/zh.configuration.md similarity index 100% rename from content/zzci-traefik/zh.configuration.md rename to content/zzci/traefik/zh.configuration.md diff --git a/content/zzci-traefik/zh.data.md b/content/zzci/traefik/zh.data.md similarity index 100% rename from content/zzci-traefik/zh.data.md rename to content/zzci/traefik/zh.data.md diff --git a/content/zzci-traefik/zh.faq.md b/content/zzci/traefik/zh.faq.md similarity index 100% rename from content/zzci-traefik/zh.faq.md rename to content/zzci/traefik/zh.faq.md diff --git a/content/zzci-traefik/zh.index.md b/content/zzci/traefik/zh.index.md similarity index 100% rename from content/zzci-traefik/zh.index.md rename to content/zzci/traefik/zh.index.md diff --git a/content/zzci-traefik/zh.installation.md b/content/zzci/traefik/zh.installation.md similarity index 100% rename from content/zzci-traefik/zh.installation.md rename to content/zzci/traefik/zh.installation.md diff --git a/docs/changelog.md b/docs/changelog.md index e85cbd3..691b26a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -27,3 +27,23 @@ Kept the Worker entry on TypeScript source because local development and Wrangle Changed frontend routing to use English as the default no-prefix path and Chinese under `/zh/*`. Moved system pages to `/sys/*` and tag pages to `/tags/*`, while keeping `/api/*` reserved. Updated locale switching, internal link localization, canonical and alternate tags, and Worker redirects for legacy `/en/*` and `/tag/*` paths. + +## 2026-03-09 20:27 [decision] + +Removed the `category` concept from content and frontend routing. +Moved former category semantics into `tags`, deleted `/category/*` page generation, and updated directory/detail UI to use tags only. + +## 2026-03-09 20:30 [decision] + +Removed the `demo` tool from the content registry and deleted the `content/demo/` directory. +Static generation no longer produces `/demo/*` or `/zh/demo/*` pages. + +## 2026-03-09 20:50 [decision] + +Added optional `path` mapping for tool content directories while keeping URL `slug` unchanged. +Migrated `zzci-chrome` and `zzci-traefik` to nested `content/zzci/...` directories and updated content loading to resolve files by `path`. + +## 2026-03-09 21:06 [decision] + +Split the site-wide tag dictionary out of `content/list.json` into `content/tags.json`. +Kept the frontend data API stable by having `getToolsJson()` compose `tags.json` and `list.json` into the existing structure. diff --git a/docs/plan/PLAN-005.md b/docs/plan/PLAN-005.md new file mode 100644 index 0000000..1e299a3 --- /dev/null +++ b/docs/plan/PLAN-005.md @@ -0,0 +1,62 @@ +# PLAN-005 删除 category 并统一使用 tags 分类 + +- **status**: completed +- **createdAt**: 2026-03-09 20:23 +- **approvedAt**: 2026-03-09 20:23 +- **completedAt**: 2026-03-09 20:27 +- **relatedTask**: FE-003 + +## 现状 + +当前站点同时维护两套分类语义: + +- `content/list.json` 中存在 `categories[]` +- `content/list.json` 中每个工具条目存在 `category` +- 首页目录页同时渲染 category filters 与 tags filters +- 详情页与关联卡片使用 category badge +- 静态路由单独生成 `/category/*` 页面 + +这导致分类语义重复,且与“只保留 tags 做分类”的目标不一致。 + +## 调查上下文 + +本次变更会影响: + +- 数据与类型:`content/list.json`、`apps/frontend/src/lib/content.ts` +- 目录页:`apps/frontend/src/components/ToolDirectoryPage.astro`、`apps/frontend/src/components/ToolSearch.tsx` +- 详情页:`apps/frontend/src/components/ToolDetailPage.astro` +- 路由:`apps/frontend/src/pages/category/[category].astro`、`apps/frontend/src/pages/[locale]/category/[category].astro` +- 路由保留字与模板示例:`apps/frontend/src/lib/routes.ts`、`apps/frontend/src/pages/template.astro` + +这是一个跨模块改动,需要同步清理类型、页面和数据。 + +## 方案 + +采用“彻底删除 category,只保留 tags”的方案: + +1. 删除 `content/list.json` 中的 `categories[]` +2. 删除 `content/list.json` 中每个工具条目的 `category` +3. 将原分类 key 直接加入对应工具的 `tags[]` +4. 首页只保留 tag 过滤,不再渲染 category tabs +5. 详情页与关联卡片移除 category badge +6. 删除 `/category/*` 页面生成和路由保留字 + +## 风险 + +- 首页目录页的交互会从“双维度过滤”变成“单维度 tag 过滤” +- 若页面仍残留 `category` 类型引用,构建会直接失败 +- 旧 `/category/*` 链接会失效,但这次按要求不做兼容 + +## 范围 + +本次纳入: + +- 数据结构清理 +- category UI 清理 +- category 页面删除 +- 构建验证 + +本次不纳入: + +- 旧 `/category/*` 重定向兼容 +- tags 重新命名或重新分组 diff --git a/docs/plan/PLAN-006.md b/docs/plan/PLAN-006.md new file mode 100644 index 0000000..184c5f8 --- /dev/null +++ b/docs/plan/PLAN-006.md @@ -0,0 +1,52 @@ +# PLAN-006 删除 demo tool 内容与目录 + +- **status**: completed +- **createdAt**: 2026-03-09 20:29 +- **approvedAt**: 2026-03-09 20:29 +- **completedAt**: 2026-03-09 20:30 +- **relatedTask**: FE-004 + +## 现状 + +`demo` 当前仍然注册在 `content/list.json` 中,同时存在完整的 `content/demo/` 内容目录。 + +- `featured` 仍包含 `demo` +- `list[]` 仍包含 `demo` +- `[...slug].astro` 与 `[locale]/[...slug].astro` 的静态路由生成完全依赖 `toolsData.list` + +因此只要 `demo` 保留在 `list.json`,页面就会继续生成 `/demo/*` 与 `/zh/demo/*`。 + +## 调查上下文 + +本次变更影响面很小,主要集中在: + +- `content/list.json` +- `content/demo/` +- 文档记录与变更日志 + +页面生成逻辑本身不需要改,只要数据源删掉 `demo` 即可自然收敛。 + +## 方案 + +采用“彻底删除 demo tool”的方案: + +1. 从 `featured` 删除 `demo` +2. 从 `list[]` 删除 `demo` +3. 删除整个 `content/demo/` 目录 +4. 运行前端构建,确认 `/demo/*` 不再生成 + +## 风险 + +- 如果还有其它内容引用 `demo` slug,删除后会变成死链 +- 当前检索中没有发现其它业务代码依赖 `demo` + +## 范围 + +本次纳入: + +- `demo` 数据与内容目录删除 +- 构建验证 + +本次不纳入: + +- 为旧 `/demo/*` 做兼容重定向 diff --git a/docs/plan/PLAN-007.md b/docs/plan/PLAN-007.md new file mode 100644 index 0000000..7dc862c --- /dev/null +++ b/docs/plan/PLAN-007.md @@ -0,0 +1,57 @@ +# PLAN-007 为工具内容目录增加 path 映射并支持嵌套目录 + +- **status**: completed +- **createdAt**: 2026-03-09 20:31 +- **approvedAt**: 2026-03-09 20:31 +- **completedAt**: 2026-03-09 20:50 +- **relatedTask**: FE-005 + +## 现状 + +当前内容读取逻辑直接把 `slug` 当作 `content/` 下的目录名使用。 + +- `getToolMeta(slug)` 读取 `content//meta.json` +- `getToolDocSlugs(slug)`、`getToolDocs(slug)`、`getToolDoc(slug, doc)` 都读取 `content//` +- `resolveToolLogo(slug, logo)` 也从 `content//` 取文件 + +这意味着如果保留 URL slug 为 `zzci-chrome`,就无法把内容实际存成 `content/zzci/chrome/`。 + +## 调查上下文 + +当前路由层仍以 `slug` 作为页面主键,这正符合目标: + +- URL 继续使用 `slug` +- 站内 `link` 关系继续用 `slug` +- 只需要把内容读取层从“按 slug 找目录”改成“按 path 找目录,未配置时回退 slug” + +因此不需要改路由结构,只需要改数据与内容加载层。 + +## 方案 + +采用 `path` 字段方案: + +1. 在 `content/list.json` 的 `list[]` 中增加可选 `path` +2. 所有内容读取函数优先解析 `path`,未配置时回退 `slug` +3. 路径校验允许多级目录,但继续禁止 `..`、空段、绝对路径和越界访问 +4. 将 `zzci-chrome` / `zzci-traefik` 迁移到 `content/zzci/chrome` / `content/zzci/traefik` +5. 模板页增加 `path` 用法说明 + +## 风险 + +- 如果路径校验不严,可能引入目录穿越风险 +- 如果只有部分读取函数支持 `path`,会出现 logo / meta / docs 不一致 +- 迁移目录后若 `list.json` 漏填 `path`,页面会直接读不到内容 + +## 范围 + +本次纳入: + +- `path` 字段支持 +- 读取逻辑统一收口 +- `zzci-*` 目录迁移 +- 构建验证 + +本次不纳入: + +- slug 改成多级路径 +- 其它工具目录重组 diff --git a/docs/plan/PLAN-008.md b/docs/plan/PLAN-008.md new file mode 100644 index 0000000..b149c92 --- /dev/null +++ b/docs/plan/PLAN-008.md @@ -0,0 +1,51 @@ +# PLAN-008 拆分 tags 到独立 content/tags.json + +- **status**: completed +- **createdAt**: 2026-03-09 21:04 +- **approvedAt**: 2026-03-09 21:04 +- **completedAt**: 2026-03-09 21:06 +- **relatedTask**: FE-006 + +## 现状 + +当前 `content/list.json` 同时承担两种职责: + +- 工具注册表:`featured` 与 `list[]` +- 站点级 tag 字典:`tags[]` + +这会让文件在后续扩展 tag 元数据时不断膨胀,也不利于分别维护“工具索引”和“标签配置”。 + +## 调查上下文 + +前端绝大多数代码都通过 `getToolsJson()` 读取统一结构,再访问 `toolsData.tags`。 + +因此最小改法不是全站改成新接口,而是: + +1. 新增 `content/tags.json` +2. `getToolsJson()` 内部同时读取 `list.json` 与 `tags.json` +3. 对调用方继续返回相同的 `toolsData.tags` + +这样页面层几乎不用改,只需要同步更新模板与投稿说明文档。 + +## 方案 + +采用“物理拆分、逻辑兼容”的方案: + +1. 新建 `content/tags.json` +2. 从 `content/list.json` 移除 `tags[]` +3. 在 `content.ts` 中分别读取两个文件并组装成现有 `ToolsJson` +4. 更新模板页与投稿页文档,说明 `tags.json` 与 `list.json` 的职责划分 + +## 风险 + +- 如果 `tags.json` 路径或格式写错,标签页生成会直接失败 +- 如果只改数据文件、不改文档,后续投稿仍容易按旧结构操作 + +## 范围 + +本次纳入: + +- `tags.json` 拆分 +- 读取逻辑更新 +- 模板与投稿文档同步 +- 构建验证 diff --git a/docs/plan/index.md b/docs/plan/index.md index 5c860ab..9a75ce3 100644 --- a/docs/plan/index.md +++ b/docs/plan/index.md @@ -32,3 +32,7 @@ Each plan is a single line linking to its detail file. All detailed information - [x] [**PLAN-002 Keep Worker source entry and move API TypeScript output to dist**](PLAN-002.md) `2026-03-07` - [x] [**PLAN-003 迁移前端到 Astro + Content Collections**](PLAN-003.md) `2026-03-07` - [x] [**PLAN-004 调整默认语言无前缀路由并整理保留路径命名空间**](PLAN-004.md) `2026-03-09` +- [x] [**PLAN-005 删除 category 并统一使用 tags 分类**](PLAN-005.md) `2026-03-09` +- [x] [**PLAN-006 删除 demo tool 内容与目录**](PLAN-006.md) `2026-03-09` +- [x] [**PLAN-007 为工具内容目录增加 path 映射并支持嵌套目录**](PLAN-007.md) `2026-03-09` +- [x] [**PLAN-008 拆分 tags 到独立 content/tags.json**](PLAN-008.md) `2026-03-09` diff --git a/docs/task/FE-003.md b/docs/task/FE-003.md new file mode 100644 index 0000000..b42100b --- /dev/null +++ b/docs/task/FE-003.md @@ -0,0 +1,26 @@ +# FE-003 删除 category 数据与路由并统一使用 tags 分类 + +- **status**: completed +- **priority**: P1 +- **createdAt**: 2026-03-09 20:23 +- **owner**: codex +- **completedAt**: 2026-03-09 +- **relatedPlan**: PLAN-005 + +## 描述 + +彻底移除站点中的 `category` 概念,只保留 `tags` 作为内容分类方式: + +- 删除 `content/list.json` 中的 `categories` 字典 +- 删除每个工具列表项上的 `category` 字段 +- 将原分类语义并入 `tags` +- 删除前端 `/category/*` 页面与相关筛选逻辑 +- 详情页、列表页、关联项卡片不再依赖 category badge + +## 验收标准 + +- [x] `content/list.json` 不再包含 `categories` 与 `list[].category` +- [x] 首页筛选与列表展示仅基于 tags +- [x] 详情页不再渲染 category badge +- [x] `/category/*` 页面与相关生成逻辑被删除 +- [x] 构建通过 diff --git a/docs/task/FE-004.md b/docs/task/FE-004.md new file mode 100644 index 0000000..8c4b03a --- /dev/null +++ b/docs/task/FE-004.md @@ -0,0 +1,24 @@ +# FE-004 删除 demo tool 内容与目录 + +- **status**: completed +- **priority**: P1 +- **createdAt**: 2026-03-09 20:29 +- **owner**: codex +- **completedAt**: 2026-03-09 +- **relatedPlan**: PLAN-006 + +## 描述 + +彻底删除仓库中的 `demo` 工具示例内容: + +- 从 `content/list.json` 移除 `demo` +- 从 `featured` 移除 `demo` +- 删除 `content/demo/` 目录 +- 确认静态页面不再生成 `/demo/*` + +## 验收标准 + +- [x] `content/list.json` 中不再包含 `demo` +- [x] `content/demo/` 目录被删除 +- [x] 构建结果不再生成 `/demo/*` +- [x] 前端构建通过 diff --git a/docs/task/FE-005.md b/docs/task/FE-005.md new file mode 100644 index 0000000..77f82fc --- /dev/null +++ b/docs/task/FE-005.md @@ -0,0 +1,24 @@ +# FE-005 为工具内容目录增加 path 映射并支持嵌套目录 + +- **status**: completed +- **priority**: P1 +- **createdAt**: 2026-03-09 20:31 +- **owner**: codex +- **completedAt**: 2026-03-09 +- **relatedPlan**: PLAN-007 + +## 描述 + +在不改变 URL slug 的前提下,为工具内容目录增加 `path` 映射字段,使内容可以放在嵌套目录中: + +- `slug` 继续用于 URL 和站内引用 +- `path` 用于指向 `content/` 下的实际目录 +- 未配置 `path` 时默认回退到 `slug` +- 将 `zzci-chrome` / `zzci-traefik` 迁移到 `content/zzci/chrome` 与 `content/zzci/traefik` + +## 验收标准 + +- [x] `list.json` 支持 `path` +- [x] 内容读取逻辑优先按 `path` 读取目录 +- [x] `zzci-*` 内容迁移到嵌套目录后仍可正常构建 +- [x] URL slug 保持不变 diff --git a/docs/task/FE-006.md b/docs/task/FE-006.md new file mode 100644 index 0000000..5e3d459 --- /dev/null +++ b/docs/task/FE-006.md @@ -0,0 +1,23 @@ +# FE-006 拆分 tags 到独立 content/tags.json + +- **status**: completed +- **priority**: P1 +- **createdAt**: 2026-03-09 21:04 +- **owner**: codex +- **completedAt**: 2026-03-09 +- **relatedPlan**: PLAN-008 + +## 描述 + +将站点级 tag 字典从 `content/list.json` 中拆分到独立的 `content/tags.json`: + +- `content/list.json` 只保留 `featured` 与 `list` +- `content/tags.json` 维护 tag key 与中英文标签名 +- 前端继续通过统一数据接口读取 tags,不改变页面使用方式 + +## 验收标准 + +- [x] `content/tags.json` 创建完成 +- [x] `content/list.json` 不再内嵌 `tags` +- [x] 前端仍可正常读取 tag 列表并生成标签页 +- [x] 构建和 lint 通过 diff --git a/docs/task/index.md b/docs/task/index.md index 33329f5..a5b2cd0 100644 --- a/docs/task/index.md +++ b/docs/task/index.md @@ -34,3 +34,7 @@ Each task is a single line linking to its detail file. All detailed information - [x] [**REPO-002 Move API build output out of src and ignore artifacts**](REPO-002.md) `P1` - [x] [**FE-001 迁移前端到 Astro + Content Collections**](FE-001.md) `P1` - [x] [**FE-002 调整默认语言无前缀路由并保留保留路径命名空间**](FE-002.md) `P1` +- [x] [**FE-003 删除 category 数据与路由并统一使用 tags 分类**](FE-003.md) `P1` +- [x] [**FE-004 删除 demo tool 内容与目录**](FE-004.md) `P1` +- [x] [**FE-005 为工具内容目录增加 path 映射并支持嵌套目录**](FE-005.md) `P1` +- [x] [**FE-006 拆分 tags 到独立 content/tags.json**](FE-006.md) `P1`