diff --git a/.gitignore b/.gitignore index 757db3a33..80d8817a4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ yarn-error.log* .env*.local .vercel next-env.d.ts +/AGENTS.md +/CLAUDE.md __pycache__/ .vscode/ @@ -31,4 +33,4 @@ __pycache__/ api-reference/nodejs-api/index.d.ts public/api-reference/nodejs/ -public/api-reference/python/ \ No newline at end of file +public/api-reference/python/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..4bad9b1da --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Blog contribution guide + +Every post must provide a repository-hosted cover image. The blog index uses one predictable presentation rule for every cover, so article-specific fit overrides are not supported. + +## Cover requirements + +- Store cover assets under `public/img/blog//` and reference them with `/img/blog//`. +- Use a 16:9 landscape canvas. Recommended export size: `1600x900` or `1920x1080`; minimum accepted size: `800x450`. +- Keep titles, logos, diagrams, and other essential content inside a 7% safe margin. Covers are displayed with `object-fit: cover` and may lose a very small edge area because of responsive rounding. +- Prefer providing both `image` and `imageDark`, designed for light and dark page backgrounds respectively. Both covers must use the same dimensions, composition, and safe-area rules. +- Do not use remote image URLs or add per-post CSS/object-fit exceptions. + +## Frontmatter + +```yaml +--- +title: "Post title" +date: "YYYY-MM-DD" +image: /img/blog/YYYY-MM-DD-post-slug/header.webp +imageDark: /img/blog/YYYY-MM-DD-post-slug/header-dark.webp # optional +description: "Short summary" +--- +``` + +## Before opening a PR + +Run: + +```bash +npm run blog:covers:check +npm run types:check +npm run build +``` + +`npm run build` also runs the cover check. A blog PR with a missing, remote, undersized, or incorrectly proportioned cover will fail CI before deployment. diff --git a/app/[lang]/(home)/blog/page.tsx b/app/[lang]/(home)/blog/page.tsx index 645342b23..32b07dc30 100644 --- a/app/[lang]/(home)/blog/page.tsx +++ b/app/[lang]/(home)/blog/page.tsx @@ -100,6 +100,7 @@ function BlogCover({ src={image} alt={title} loading={loading} + decoding="async" /> {imageDark && ( )} diff --git a/app/global.css b/app/global.css index 03b5432e2..0ae54271c 100644 --- a/app/global.css +++ b/app/global.css @@ -6218,6 +6218,7 @@ body:has(.zvec-docs-overview) #nd-sidebar > div:nth-child(2) { .zvec-blog-featured-media { + position: relative; min-height: 330px; overflow: hidden; border-right: 1px solid var(--zvec-border); @@ -6226,9 +6227,12 @@ body:has(.zvec-docs-overview) #nd-sidebar > div:nth-child(2) { .zvec-blog-featured-media img { + position: absolute; + inset: 0; width: 100%; height: 100%; - object-fit: contain; + object-fit: cover; + object-position: center; transition: transform 240ms ease; } @@ -6331,9 +6335,12 @@ body:has(.zvec-docs-overview) #nd-sidebar > div:nth-child(2) { .zvec-blog-card-media img { + position: absolute; + inset: 0; width: 100%; height: 100%; - object-fit: contain; + object-fit: cover; + object-position: center; transition: transform 240ms ease; } diff --git a/content/blog/en/2026-09-08-zvec-rabitq.mdx b/content/blog/en/2026-09-08-zvec-rabitq.mdx index 47d319b65..1cc8d1b24 100644 --- a/content/blog/en/2026-09-08-zvec-rabitq.mdx +++ b/content/blog/en/2026-09-08-zvec-rabitq.mdx @@ -2,6 +2,7 @@ title: "RaBitQ in Zvec: Quantization Principles, Index Design, and Benchmarks" date: "2026-09-08" image: /img/blog/2026-09-08-zvec-rabitq/header.png +imageDark: /img/blog/2026-09-08-zvec-rabitq/header-dark.jpg description: "Explore native HNSW-RaBitQ and IVF-RaBitQ in Zvec through benchmarks, usage examples, randomized vector quantization, error bounds, and index design." --- diff --git a/content/blog/en/2026-09-17-zvec-multi-platform.mdx b/content/blog/en/2026-09-17-zvec-multi-platform.mdx index 0e218f445..7e1ff4a2b 100644 --- a/content/blog/en/2026-09-17-zvec-multi-platform.mdx +++ b/content/blog/en/2026-09-17-zvec-multi-platform.mdx @@ -2,6 +2,7 @@ title: "Making Vector Search Available Everywhere: A Complete View of Zvec Multi-Platform Support" date: "2026-09-17" image: /img/blog/2026-09-17-zvec-multi-platform/header.webp +imageDark: /img/blog/2026-09-17-zvec-multi-platform/header-dark.jpg description: "Zvec runs wherever your application runs: Linux, macOS, Windows, Android, and iOS, with official SDKs for Python, Node.js, Go, Rust, Java, and Dart/Flutter plus ready-to-use prebuilt binaries. A complete inventory of CI-verified platforms, per-platform index availability, and the cross-platform engineering foundation behind them." --- diff --git a/content/blog/zh/2026-09-08-zvec-rabitq.mdx b/content/blog/zh/2026-09-08-zvec-rabitq.mdx index 7434bdbef..511af829a 100644 --- a/content/blog/zh/2026-09-08-zvec-rabitq.mdx +++ b/content/blog/zh/2026-09-08-zvec-rabitq.mdx @@ -2,6 +2,7 @@ title: "RaBitQ 落地 Zvec:量化原理、索引设计与性能实测" date: "2026-09-08" image: /img/blog/2026-09-08-zvec-rabitq/header.png +imageDark: /img/blog/2026-09-08-zvec-rabitq/header-dark.jpg description: "Zvec 原生集成 HNSW-RaBitQ 与 IVF-RaBitQ:从性能实测和使用示例,理解随机化向量量化、误差界与索引实现。" --- diff --git a/content/blog/zh/2026-09-17-zvec-multi-platform.mdx b/content/blog/zh/2026-09-17-zvec-multi-platform.mdx index 6e06e046b..9389e1705 100644 --- a/content/blog/zh/2026-09-17-zvec-multi-platform.mdx +++ b/content/blog/zh/2026-09-17-zvec-multi-platform.mdx @@ -2,6 +2,7 @@ title: "让向量检索随处可用:Zvec 多平台支持全景" date: "2026-09-17" image: /img/blog/2026-09-17-zvec-multi-platform/header.webp +imageDark: /img/blog/2026-09-17-zvec-multi-platform/header-dark.jpg description: "Zvec 已支持 Linux、macOS、Windows、Android 与 iOS,提供 Python、Node.js、Go、Rust、Java、Dart/Flutter 六种官方 SDK 与开箱即用的预编译产物。本文完整盘点 CI 验证的平台、各平台索引可用性、不同内存预算下的选型思路,以及背后的跨平台工程底座。" --- diff --git a/package.json b/package.json index c241a804d..e21a03fdb 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "next build", + "build": "npm run blog:covers:check && next build", + "blog:covers:check": "node scripts/validate-blog-covers.mjs", "dev": "next dev", "start": "next start", "types:check": "fumadocs-mdx && tsc --noEmit", diff --git a/public/img/blog/2026-01-20-introduction/header.png b/public/img/blog/2026-01-20-introduction/header.png index cddd49bd5..9abcc2cd0 100644 Binary files a/public/img/blog/2026-01-20-introduction/header.png and b/public/img/blog/2026-01-20-introduction/header.png differ diff --git a/public/img/blog/2026-06-22-zvec-mobile/cover.jpeg b/public/img/blog/2026-06-22-zvec-mobile/cover.jpeg index 7551b73fa..8756bcfba 100644 Binary files a/public/img/blog/2026-06-22-zvec-mobile/cover.jpeg and b/public/img/blog/2026-06-22-zvec-mobile/cover.jpeg differ diff --git a/public/img/blog/2026-09-08-zvec-rabitq/header-dark.jpg b/public/img/blog/2026-09-08-zvec-rabitq/header-dark.jpg new file mode 100644 index 000000000..c0f618c65 Binary files /dev/null and b/public/img/blog/2026-09-08-zvec-rabitq/header-dark.jpg differ diff --git a/public/img/blog/2026-09-08-zvec-rabitq/header.png b/public/img/blog/2026-09-08-zvec-rabitq/header.png index 1d3074fe2..b842a220d 100644 Binary files a/public/img/blog/2026-09-08-zvec-rabitq/header.png and b/public/img/blog/2026-09-08-zvec-rabitq/header.png differ diff --git a/public/img/blog/2026-09-17-zvec-multi-platform/header-dark.jpg b/public/img/blog/2026-09-17-zvec-multi-platform/header-dark.jpg new file mode 100644 index 000000000..3aad20f39 Binary files /dev/null and b/public/img/blog/2026-09-17-zvec-multi-platform/header-dark.jpg differ diff --git a/scripts/validate-blog-covers.mjs b/scripts/validate-blog-covers.mjs new file mode 100644 index 000000000..e6bfc27a8 --- /dev/null +++ b/scripts/validate-blog-covers.mjs @@ -0,0 +1,166 @@ +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const ROOT = process.cwd(); +const BLOG_ROOT = path.join(ROOT, 'content', 'blog'); +const PUBLIC_ROOT = path.join(ROOT, 'public'); +const TARGET_RATIO = 16 / 9; +const MAX_RATIO_ERROR = 0.02; +const MIN_WIDTH = 800; +const MIN_HEIGHT = 450; + +async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all(entries.map(async (entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? walk(target) : [target]; + })); + return files.flat(); +} + +function readFrontmatter(source) { + if (!source.startsWith('---')) return {}; + const end = source.indexOf('\n---', 3); + if (end === -1) return {}; + + const fields = {}; + for (const line of source.slice(3, end).split('\n')) { + const match = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.*?)\s*$/); + if (!match) continue; + fields[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2'); + } + return fields; +} + +function pngSize(buffer) { + if (buffer.subarray(1, 4).toString() !== 'PNG') return null; + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +function jpegSize(buffer) { + if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return null; + let offset = 2; + + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + + const marker = buffer[offset + 1]; + offset += 2; + if (marker === 0xd8 || marker === 0xd9) continue; + const length = buffer.readUInt16BE(offset); + if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) { + return { width: buffer.readUInt16BE(offset + 5), height: buffer.readUInt16BE(offset + 3) }; + } + offset += length; + } + return null; +} + +function webpSize(buffer) { + if (buffer.subarray(0, 4).toString() !== 'RIFF' || buffer.subarray(8, 12).toString() !== 'WEBP') return null; + let offset = 12; + + while (offset + 8 <= buffer.length) { + const type = buffer.subarray(offset, offset + 4).toString(); + const length = buffer.readUInt32LE(offset + 4); + const data = offset + 8; + + if (type === 'VP8X' && data + 10 <= buffer.length) { + return { + width: 1 + buffer.readUIntLE(data + 4, 3), + height: 1 + buffer.readUIntLE(data + 7, 3), + }; + } + if (type === 'VP8 ' && data + 10 <= buffer.length) { + return { + width: buffer.readUInt16LE(data + 6) & 0x3fff, + height: buffer.readUInt16LE(data + 8) & 0x3fff, + }; + } + if (type === 'VP8L' && data + 5 <= buffer.length && buffer[data] === 0x2f) { + const bits = buffer.readUInt32LE(data + 1); + return { + width: 1 + (bits & 0x3fff), + height: 1 + ((bits >> 14) & 0x3fff), + }; + } + + offset = data + length + (length % 2); + } + return null; +} + +function svgSize(buffer) { + const source = buffer.toString('utf8', 0, Math.min(buffer.length, 4096)); + if (!/ file.endsWith('.mdx')); +const references = new Map(); +const errors = []; + +for (const contentFile of contentFiles) { + const fields = readFrontmatter(await readFile(contentFile, 'utf8')); + for (const key of ['image', 'imageDark']) { + const image = fields[key]; + if (!image) continue; + if (!image.startsWith('/img/blog/')) { + errors.push(`${path.relative(ROOT, contentFile)}: ${key} must be a local /img/blog/... path`); + continue; + } + const owners = references.get(image) || []; + owners.push(`${path.relative(ROOT, contentFile)} (${key})`); + references.set(image, owners); + } +} + +for (const [image, owners] of references) { + const asset = path.resolve(PUBLIC_ROOT, `.${image}`); + if (!asset.startsWith(`${PUBLIC_ROOT}${path.sep}`)) { + errors.push(`${image}: path escapes public/`); + continue; + } + + let buffer; + try { + buffer = await readFile(asset); + } catch { + errors.push(`${image}: file does not exist (used by ${owners.join(', ')})`); + continue; + } + + const size = imageSize(buffer); + if (!size || !Number.isFinite(size.width) || !Number.isFinite(size.height)) { + errors.push(`${image}: unsupported image or unreadable dimensions`); + continue; + } + + const ratioError = Math.abs(size.width / size.height - TARGET_RATIO) / TARGET_RATIO; + if (ratioError > MAX_RATIO_ERROR) { + errors.push(`${image}: ${size.width}x${size.height} is not 16:9 (maximum deviation: 2%)`); + } + if (size.width < MIN_WIDTH || size.height < MIN_HEIGHT) { + errors.push(`${image}: ${size.width}x${size.height} is smaller than ${MIN_WIDTH}x${MIN_HEIGHT}`); + } +} + +if (errors.length > 0) { + console.error('Blog cover validation failed:\n'); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log(`Validated ${references.size} blog cover assets (16:9, local, minimum ${MIN_WIDTH}x${MIN_HEIGHT}).`);