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..df2220b49 100644 --- a/app/global.css +++ b/app/global.css @@ -6218,7 +6218,9 @@ body:has(.zvec-docs-overview) #nd-sidebar > div:nth-child(2) { .zvec-blog-featured-media { - min-height: 330px; + position: relative; + min-height: 0; + aspect-ratio: 16 / 9; overflow: hidden; border-right: 1px solid var(--zvec-border); background: var(--zvec-muted-surface); @@ -6226,9 +6228,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; } @@ -6243,14 +6248,14 @@ body:has(.zvec-docs-overview) #nd-sidebar > div:nth-child(2) { min-width: 0; flex-direction: column; justify-content: center; - padding: 34px; + padding: 28px; } .zvec-blog-featured-body h2 { margin-top: 18px; color: var(--zvec-ink); - font-size: clamp(1.75rem, 3vw, 2.65rem); + font-size: clamp(1.75rem, 2.7vw, 2.4rem); font-weight: 680; letter-spacing: -0.045em; line-height: 1.06; @@ -6258,10 +6263,14 @@ body:has(.zvec-docs-overview) #nd-sidebar > div:nth-child(2) { .zvec-blog-featured-body > p { + display: -webkit-box; + overflow: hidden; margin-top: 14px; color: var(--zvec-muted); font-size: 0.9rem; line-height: 1.6; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; } @@ -6331,9 +6340,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; } @@ -6807,7 +6819,7 @@ a.zvec-map-tile:is(:hover, :focus-visible), } -@media (max-width: 780px) { +@media (max-width: 1199px) { .zvec-blog-featured-card { grid-template-columns: 1fr; } @@ -6822,6 +6834,10 @@ a.zvec-map-tile:is(:hover, :focus-visible), .zvec-blog-featured-body { padding: 24px; } +} + + +@media (max-width: 780px) { .zvec-grep-diagram { gap: 18px; 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..a6ddcde2d 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 --test scripts/validate-blog-covers.test.mjs && 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..2fe47fbb1 --- /dev/null +++ b/scripts/validate-blog-covers.mjs @@ -0,0 +1,196 @@ +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { frontmatter } from 'fumadocs-core/content/md/frontmatter'; + +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(); +} + +export function readFrontmatter(source) { + const data = frontmatter(source).data; + return data && typeof data === 'object' && !Array.isArray(data) ? data : {}; +} + +export function validateCoverFields(fields, owner) { + const covers = []; + const errors = []; + const image = typeof fields.image === 'string' ? fields.image.trim() : ''; + + if (!image) { + errors.push(`${owner}: image is required and must be a local /img/blog/... path`); + } else { + covers.push({ key: 'image', image }); + } + + if (fields.imageDark !== undefined && fields.imageDark !== null) { + if (typeof fields.imageDark !== 'string') { + errors.push(`${owner}: imageDark must be a local /img/blog/... path when provided`); + } else if (fields.imageDark.trim()) { + covers.push({ key: 'imageDark', image: fields.imageDark.trim() }); + } + } + + return { covers, errors }; +} + +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 owner = path.relative(ROOT, contentFile); + let fields; + try { + fields = readFrontmatter(await readFile(contentFile, 'utf8')); + } catch (error) { + errors.push(`${owner}: invalid YAML frontmatter (${error instanceof Error ? error.message : String(error)})`); + continue; + } + + const result = validateCoverFields(fields, owner); + errors.push(...result.errors); + for (const { key, image } of result.covers) { + if (!image.startsWith('/img/blog/')) { + errors.push(`${owner}: ${key} must be a local /img/blog/... path`); + continue; + } + const owners = references.get(image) || []; + owners.push(`${owner} (${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}`); + } + } + + return { references, errors }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const { references, errors } = await validateBlogCovers(); + if (errors.length > 0) { + console.error('Blog cover validation failed:\n'); + for (const error of errors) console.error(`- ${error}`); + process.exitCode = 1; + } else { + console.log(`Validated ${references.size} blog cover assets (16:9, local, minimum ${MIN_WIDTH}x${MIN_HEIGHT}).`); + } +} diff --git a/scripts/validate-blog-covers.test.mjs b/scripts/validate-blog-covers.test.mjs new file mode 100644 index 000000000..87729bce7 --- /dev/null +++ b/scripts/validate-blog-covers.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFrontmatter, validateCoverFields } from './validate-blog-covers.mjs'; + +test('parses blog cover fields with standard YAML semantics', () => { + const cases = [ + ['image: /img/blog/post/header.webp', '/img/blog/post/header.webp'], + ['image: "/img/blog/post/header.webp"', '/img/blog/post/header.webp'], + ['image: /img/blog/post/header.webp # primary cover', '/img/blog/post/header.webp'], + ]; + + for (const [field, expected] of cases) { + const fields = readFrontmatter(`---\n${field}\n---\n`); + assert.equal(fields.image, expected); + } + + const fields = readFrontmatter('---\nimage: /img/blog/post/header.webp\n---\n'); + assert.equal(fields.imageDark, undefined); +}); + +test('requires a non-empty primary image', () => { + for (const fields of [ + {}, + { image: '' }, + { imageDark: '/img/blog/post/header-dark.webp' }, + ]) { + const result = validateCoverFields(fields, 'post.mdx'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /image is required/); + } +}); + +test('allows a primary image without a dark variant', () => { + const result = validateCoverFields({ image: '/img/blog/post/header.webp' }, 'post.mdx'); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.covers, [ + { key: 'image', image: '/img/blog/post/header.webp' }, + ]); +});