diff --git a/.github/workflows/seo-articles.yml b/.github/workflows/seo-articles.yml new file mode 100644 index 0000000..f979710 --- /dev/null +++ b/.github/workflows/seo-articles.yml @@ -0,0 +1,100 @@ +name: SEO Articles + +on: + schedule: + # Every Monday at 08:00 UTC + - cron: '0 8 * * 1' + workflow_dispatch: + +jobs: + generate: + timeout-minutes: 30 + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + token: ${{ secrets.PAT }} + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '24' + cache: pnpm + + - name: Install dependencies + run: pnpm install --filter @semianalysisai/inferencex-app... + env: + CYPRESS_INSTALL_BINARY: '0' + + - name: Fetch benchmark data + run: pnpm admin:seo:data + + - name: Generate articles with Claude + uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # v1.0.82 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.PAT }} + direct_prompt: | + You are generating SEO blog articles for InferenceX from benchmark data. + + ## Instructions + 1. Read the article template at `packages/app/scripts/seo/article-template.md` + 2. Read the benchmark data at `packages/app/tmp/seo-data.json` + 3. For each model in the data, create or update an MDX file at + `packages/app/content/blog/best-gpu-for--inference.mdx` + 4. Create/update the rollup article at + `packages/app/content/blog/inference-benchmark-roundup.mdx` + + Follow the template structure EXACTLY — same sections, same order, same table format. + Write natural, varied prose for each model (not copy-paste between articles). + + ## Critical Rules + - ALL numbers MUST come from the data file. Never invent or estimate numbers. + - TTFT/TPOT/E2EL values in the data are in SECONDS — multiply by 1000 for display in ms. + - Use gpuDisplayName from the data (e.g. "NVIDIA B200", "AMD MI 355X"). + - Preserve the `date` frontmatter from existing files (check if file exists first). + - The runner-up in Key Findings must be a DIFFERENT GPU than the winner. + - Skip the 1k/8k sequence entirely — it is deprecated. + - Format large numbers with commas (e.g. 18,131.6). + allowed_tools: 'Read,Write,Edit,Glob,Grep,Bash' + claude_args: '--model claude-sonnet-4-5-20250929' + + - name: Check for changes + id: changes + run: | + if git diff --quiet -- 'packages/app/content/blog/'; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create PR + if: steps.changes.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.PAT }} + run: | + DATE=$(date +%Y-%m-%d) + BRANCH="seo/update-articles-${DATE}" + git checkout -b "$BRANCH" + git add 'packages/app/content/blog/' + git commit -m "update SEO benchmark articles (${DATE})" + git push origin "$BRANCH" + gh pr create \ + --title "update SEO benchmark articles (${DATE})" \ + --body "$(cat <<'EOF' + ## Summary + - Auto-generated SEO blog articles from latest benchmark data + - Articles written by Claude using real data from `seo-data.json` + + ## Review checklist + - [ ] Spot-check numbers against live dashboard + - [ ] Verify JSON-LD in page source + - [ ] Read prose for quality and accuracy + EOF + )" \ + --base master diff --git a/.gitignore b/.gitignore index b9f92a6..e86e3d4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ **/inferencex-backup-*.dump # local data +**/tmp **/gcs **/logs **/public/data/* diff --git a/docs/blog.md b/docs/blog.md index 8ff8f3a..51e2777 100644 --- a/docs/blog.md +++ b/docs/blog.md @@ -18,9 +18,14 @@ title: string subtitle: string date: YYYY-MM-DD modifiedDate?: YYYY-MM-DD # Used in sitemap and JSON-LD +publishDate?: YYYY-MM-DD # Scheduled publishing, hidden in production until this date tags?: string[] # Used for filtering on /blog and in RSS categories ``` +### Scheduled Publishing (`publishDate`) + +Posts without `publishDate` are hidden in production — this field is required for a post to be visible. If `publishDate` is set to a future date, the post is hidden until that date arrives. In development, all posts are visible regardless. This allows articles to be merged to `master` via PR and go live automatically when the date arrives. All downstream consumers (sitemap, RSS, llms.txt) automatically respect the filter since they call `getAllPosts()`. `getPostBySlug()` still returns the post regardless of `publishDate` (for direct URL preview). + Slug is derived from the filename (e.g., `my-post.mdx` -> `my-post`), not from frontmatter. Reading time is calculated at 265 WPM. ## MDX Components Available to Authors @@ -32,6 +37,7 @@ Slug is derived from the filename (e.g., `my-post.mdx` -> `my-post`), not from f | `![alt](src)` | Images | Rendered via `next/image` with lazy loading (first image is eager) | | `
` | Captioned figures | Uses `` (not `next/image`) for external URLs | | `...` | Paywall teaser blur overlay | Content is blurred, unselectable, and not clickable | +| `{...}` | Structured data (JSON-LD) | Renders `; + }, }; } diff --git a/packages/app/src/lib/blog.test.ts b/packages/app/src/lib/blog.test.ts index 6f28bf7..be0640b 100644 --- a/packages/app/src/lib/blog.test.ts +++ b/packages/app/src/lib/blog.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import { @@ -45,6 +45,41 @@ date: '2026-01-01' Some middle content. `; +const FAKE_MDX_FUTURE = `--- +title: 'Future Post' +subtitle: 'A future subtitle' +date: '2099-06-01' +publishDate: '2099-06-01' +--- + +# Future + +This post is scheduled for the far future. +`; + +const FAKE_MDX_PAST_PUBLISH = `--- +title: 'Past Publish Post' +subtitle: 'Already published' +date: '2025-06-01' +publishDate: '2025-01-01' +--- + +# Past Publish + +This post has a publishDate in the past. +`; + +const FAKE_MDX_NO_PUBLISH = `--- +title: 'No Publish Date Post' +subtitle: 'No publishDate set' +date: '2025-08-01' +--- + +# No Publish + +This post has no publishDate field at all. +`; + vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, default: { ...actual } }; @@ -136,6 +171,117 @@ describe('getAllPosts', () => { }); }); +describe('getAllPosts — publishDate filtering', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + function mockPostFiles(files: Record) { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readdirSync').mockReturnValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Object.keys(files).map((name) => name) as any, + ); + vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + const p = String(filePath); + for (const [name, content] of Object.entries(files)) { + if (p.includes(name.replace('.mdx', ''))) return content; + } + return ''; + }); + } + + it('filters out future-publishDate and missing-publishDate posts in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + mockPostFiles({ + 'past-publish.mdx': FAKE_MDX_PAST_PUBLISH, + 'future-post.mdx': FAKE_MDX_FUTURE, + 'no-publish.mdx': FAKE_MDX_NO_PUBLISH, + }); + + const posts = getAllPosts(); + const slugs = posts.map((p) => p.slug); + expect(slugs).toContain('past-publish'); + expect(slugs).not.toContain('no-publish'); + expect(slugs).not.toContain('future-post'); + }); + + it('filters out posts without publishDate in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + mockPostFiles({ + 'no-publish.mdx': FAKE_MDX_NO_PUBLISH, + }); + + const posts = getAllPosts(); + expect(posts).toHaveLength(0); + }); + + it('keeps posts with past publishDate in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + mockPostFiles({ + 'past-publish.mdx': FAKE_MDX_PAST_PUBLISH, + }); + + const posts = getAllPosts(); + expect(posts).toHaveLength(1); + expect(posts[0].slug).toBe('past-publish'); + }); + + it('shows all posts including future-dated in development', () => { + vi.stubEnv('NODE_ENV', 'development'); + mockPostFiles({ + 'past-publish.mdx': FAKE_MDX_PAST_PUBLISH, + 'future-post.mdx': FAKE_MDX_FUTURE, + 'no-publish.mdx': FAKE_MDX_NO_PUBLISH, + }); + + const posts = getAllPosts(); + const slugs = posts.map((p) => p.slug); + expect(slugs).toContain('past-publish'); + expect(slugs).toContain('future-post'); + expect(slugs).toContain('no-publish'); + expect(posts).toHaveLength(3); + }); + + it('shows all posts including future-dated in test env', () => { + vi.stubEnv('NODE_ENV', 'test'); + mockPostFiles({ + 'future-post.mdx': FAKE_MDX_FUTURE, + 'no-publish.mdx': FAKE_MDX_NO_PUBLISH, + }); + + const posts = getAllPosts(); + expect(posts).toHaveLength(2); + const slugs = posts.map((p) => p.slug); + expect(slugs).toContain('future-post'); + expect(slugs).toContain('no-publish'); + }); + + it('returns empty array when all posts are future-dated in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + mockPostFiles({ + 'future-post.mdx': FAKE_MDX_FUTURE, + }); + + const posts = getAllPosts(); + expect(posts).toHaveLength(0); + }); + + it('still sorts filtered results by date descending in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + mockPostFiles({ + 'past-publish.mdx': FAKE_MDX_PAST_PUBLISH, + 'no-publish.mdx': FAKE_MDX_NO_PUBLISH, + 'future-post.mdx': FAKE_MDX_FUTURE, + }); + + const posts = getAllPosts(); + // only past-publish has a valid publishDate <= now + expect(posts).toHaveLength(1); + expect(posts[0].slug).toBe('past-publish'); + }); +}); + describe('getPostBySlug', () => { it('returns null for non-existent slug', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); @@ -153,6 +299,20 @@ describe('getPostBySlug', () => { expect(result!.meta.slug).toBe('test-post'); expect(result!.raw).toContain('# Test Heading'); }); + + it('returns a post with future publishDate regardless of NODE_ENV', () => { + vi.stubEnv('NODE_ENV', 'production'); + + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readFileSync').mockReturnValue(FAKE_MDX_FUTURE); + + const result = getPostBySlug('future-post'); + expect(result).not.toBeNull(); + expect(result!.meta.title).toBe('Future Post'); + expect(result!.meta.publishDate).toBe('2099-06-01'); + + vi.unstubAllEnvs(); + }); }); describe('getAdjacentPosts', () => { diff --git a/packages/app/src/lib/blog.ts b/packages/app/src/lib/blog.ts index 8a846f6..7978543 100644 --- a/packages/app/src/lib/blog.ts +++ b/packages/app/src/lib/blog.ts @@ -8,6 +8,7 @@ export interface BlogFrontmatter { date: string; subtitle: string; modifiedDate?: string; + publishDate?: string; tags?: string[]; } @@ -50,7 +51,13 @@ export function getAllPosts(): BlogPostMeta[] { }; }); - return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + const now = new Date(); + const visible = + process.env.NODE_ENV === 'production' + ? posts.filter((p) => !!p.publishDate && new Date(p.publishDate + 'T00:00:00Z') <= now) + : posts; + + return visible.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); } export interface AdjacentPosts { diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 374c66e..ebdf0d3 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -4,7 +4,7 @@ import path from 'path'; export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], coverage: { provider: 'v8', include: ['src/lib/**/*.ts', 'src/scripts/**/*.ts', 'src/app/api/**/*.ts'],