diff --git a/.replworks/PRODUCT_SPEC.md b/.replworks/PRODUCT_SPEC.md index 1f8ca0e..88150e6 100644 --- a/.replworks/PRODUCT_SPEC.md +++ b/.replworks/PRODUCT_SPEC.md @@ -537,9 +537,11 @@ The homepage displays these featured projects: ```text REPL Works Website -ai-issue - Wifi Note + +ImageForge + +Cool Restore ``` The Showcase page displays all projects. It shows the Lessons Learned diff --git a/.replworks/TASKS.md b/.replworks/TASKS.md index b98241b..b28b28b 100644 --- a/.replworks/TASKS.md +++ b/.replworks/TASKS.md @@ -294,6 +294,22 @@ Propose TASK update - [x] T1501 Manage document sidebar order and display properties from one data source +## PHASE 16 + +### IMAGEFORGE SHOWCASE + +- [x] T1601 Publish ImageForge in the Showcase + - Add ImageForge to the shared Showcase data and publish its detail page. + - Describe its two repositories, Coolify deployment, imgproxy processing, and Cloudflare CDN/Purge roles without linking the private repository. + - Explain its REPL Works workflow, documents, tools, and owner-provided lessons. + - Verify the public image endpoint and CDN cache against the live service. + - Pass applicable unit, E2E, and build checks. + +- [x] T1602 Feature ImageForge after Wifi Note on the homepage + - Add ImageForge to the documented homepage featured projects. + - Keep ImageForge immediately after Wifi Note in the shared featured order. + - Verify the homepage card and all project checks. + --- ## COMPLETION diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 5957085..4846e22 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -133,6 +133,16 @@ 와이파이 노트 +
  • + + 이미지 포지 + +
  • diff --git a/src/content/showcase/imageforge.mdx b/src/content/showcase/imageforge.mdx new file mode 100644 index 0000000..6db64da --- /dev/null +++ b/src/content/showcase/imageforge.mdx @@ -0,0 +1,61 @@ +--- +title: '이미지 포지(Image Forge)' +version: 'v1' +description: '이미지 포지는 img.repl.net 이미지 변환 서비스와 Cloudflare CDN 캐시 Purge 운영 도구를 함께 운영합니다.' +publishedAt: '2026-10-09T00:00:00Z' +--- + +import MissionBox from '../../components/MissionBox.astro'; +import DocLink from '../../components/DocLink.astro'; + +## 1. 개요 (Overview) + +![이미지 포지 홈페이지](https://if.repl.net/og.png) + +이미지 포지는 여러 서비스의 이미지를 변환해 제공하는 `img.repl.net`과, 이미지가 갱신되었을 때 Cloudflare CDN 캐시를 Purge하는 운영 도구를 함께 구성한 이미지 서비스입니다. + + + +서비스는 두 프로젝트 저장소, 운영 인프라, 이미지 처리 엔진으로 이루어집니다. 공개 저장소 [replworks/imageforge](https://github.com/replworks/imageforge)는 서비스 소개 페이지와 운영자 전용 Purge 도구를 제공합니다. 비공개 `img-proxy`는 서비스별 원본을 연결하고 요청을 검증한 뒤 이미지 변환 서버로 전달합니다. 이미지 변환은 공개 도메인에 직접 노출하지 않은 imgproxy가 맡습니다. Coolify가 애플리케이션을 배포하고, Cloudflare가 `img.repl.net`의 CDN 캐시와 접근 제어를 담당합니다. 이미지가 바뀌면 운영자는 이미지 포지의 Purge 도구로 해당 캐시를 정리합니다. + +## 2. 사용된 표준 문서 (Documents Used) + +이미지 포지는 공개 저장소와 비공개 `img-proxy` 저장소에서 각각 REPL Works 표준 문서를 관리합니다. `AGENTS.md`는 AI 에이전트가 따를 공통 규칙을 정하고, `PRODUCT_SPEC.md`는 서비스가 무엇을 해야 하는지 정의합니다. `TECH_STACK.md`에는 구현에 사용할 기술과 제약을 기록하고, `ARCHITECTURE.md`에는 구성요소의 역할과 경계를 정리합니다. `TASKS.md`는 확정된 작업과 수락 기준을 관리합니다. 비공개 저장소의 문서는 공개하지 않습니다. + +## 3. 적용 워크플로 (Workflow Usage) + +대화형 AI와 서비스 요구사항 및 구성요소 경계를 정리하고, REPL Works 문서에 제품 정의와 구현 계획을 남깁니다. 그 문서를 바탕으로 코딩형 AI가 서비스와 운영 도구를 태스크 단위로 구현합니다. 외부 서비스를 연동하는 테스트는 로컬에서 같은 환경을 만들어 재현하고, 실제 운영 환경에서의 최종 E2E 테스트는 사람이 수행합니다. 사람은 결과를 검토해 AI에 전달하고, 이를 다음 개선에 반영합니다. + +## 4. 사용 기술 (Tools Used) + +이미지 포지 공개 저장소는 **TypeScript**, **Hono**, **Vite**, **Tailwind CSS**로 랜딩 페이지와 Purge 콘솔을 제공합니다. **Vitest**로 단위 테스트를 수행하고 **GitHub Actions**로 검증과 배포를 자동화합니다. 배포 이미지는 Docker로 만들고 GHCR에 올립니다. + +비공개 `img-proxy`는 **Node.js 24**, **Hono**, **imgproxy**를 사용합니다. `img-proxy`가 서비스별 원본을 선택하고 요청을 검증하면 imgproxy가 이미지 리사이즈와 포맷 변환을 처리합니다. **Cloudflare**는 `img.repl.net`의 CDN 캐시와 운영자 접근 제어를 맡고, **Coolify**는 애플리케이션을 배포하고 운영합니다. + +## 5. 안전 설계와 배포 (Safety & Release) + +### 안전 설계 + +Purge 콘솔은 공개 랜딩 페이지와 분리해 운영자만 사용할 수 있도록 **Cloudflare Access**로 보호합니다. 보호된 요청은 Cloudflare Access JWT를 검증한 뒤 처리하고, Cloudflare API 호출은 서버에서만 수행합니다. API 자격 증명은 저장소에 넣지 않고 배포 환경 변수로 관리합니다. + +`img-proxy`는 클라이언트가 보낸 임의의 URL을 원본으로 사용하지 않습니다. 설정된 서비스명과 원본 주소를 기준으로 요청을 검증하며, imgproxy가 가져올 수 있는 원본도 허용 목록으로 제한합니다. imgproxy는 도메인을 연결하거나 호스트 포트를 공개하지 않아 외부에서 직접 접근할 수 없습니다. + +### 배포 + +이미지 포지 공개 저장소는 GitHub Release 또는 수동 실행으로 배포를 시작합니다. GitHub Actions가 `linux/arm64` Docker 이미지를 빌드해 GHCR에 올리고, Coolify에 배포할 이미지 정보를 전달합니다. CI는 Pull Request와 `main` 브랜치 변경에서 타입 검사, 테스트, 빌드를 확인합니다. + +이미지 변환 서비스는 Coolify에서 별도의 애플리케이션 두 개로 운영합니다. `img-proxy`는 Nixpacks로 배포하고, imgproxy는 버전을 고정한 Docker 이미지로 실행합니다. 두 리소스는 같은 네트워크에서 통신하지만, 공개 도메인은 `img-proxy`에만 연결합니다. Cloudflare는 `img.repl.net`의 CDN 캐시 규칙과 Edge TTL을 적용하며, 이미지가 바뀌면 운영자가 이미지 포지 Purge 콘솔에서 해당 캐시를 정리합니다. + +## 6. 학습된 레슨 (Lessons Learned) + +여러 구성요소를 나눠도 공개 진입점과 내부 처리 경계를 분명히 하면 하나의 서비스로 운영할 수 있습니다. 각 구성요소가 맡은 역할을 문서로 정해 두면 두 저장소와 외부 서비스를 연결하면서도 전체 동작을 이해하고 개선할 수 있습니다. + +외부 서비스를 연동하는 E2E 테스트는 로컬에 같은 환경을 만들지 않으면 오래 걸립니다. Docker로 테스트 환경을 재현하면 동작을 확인하고 디버깅하기 쉽지만, 최종적으로는 사람이 실제 환경에서 E2E 테스트를 수행해야 합니다. 그 결과를 AI에게 전달해야 실제 운영에서 확인한 사실을 다음 개선에 반영할 수 있습니다. + +두 프로젝트와 외부 서비스를 연결한 서비스와 운영 도구를 이틀 만에 만들고, 계속 개선할 수 있는 환경까지 구성할 수 있다는 점은 놀라웠습니다. REPL Works 문서와 태스크 단위의 작업 흐름이 여러 구성요소를 함께 발전시키는 기반이 되었습니다. + + + diff --git a/src/data/showcase.ts b/src/data/showcase.ts index 776c6b0..788f1ad 100644 --- a/src/data/showcase.ts +++ b/src/data/showcase.ts @@ -49,8 +49,8 @@ export const showcaseProjects: ShowcaseProject[] = [ '모호한 지시는 에이전트의 환각을 유발합니다. 수락 기준(Acceptance Criteria)이 명시된 이슈일수록 구현이 정밀해집니다.', detailUrl: '/showcase/ai-issue/', github: 'https://github.com/replworks/ai-issue', - featured: true, - order: 3, + featured: false, + order: 4, }, { slug: 'coolrestore', @@ -102,4 +102,18 @@ export const showcaseProjects: ShowcaseProject[] = [ featured: false, order: 8, }, + { + slug: 'imageforge', + name: '이미지 포지(Image Forge)', + description: + 'img.repl.net 이미지 변환 서비스와 Cloudflare CDN 캐시 Purge 운영 도구를 함께 운영하는 이미지 서비스입니다.', + tags: ['Image Service', 'CDN', 'Operations'], + lesson: + '여러 구성요소를 나눠도 공개 진입점과 내부 처리 경계를 명확히 하면 하나의 서비스로 운영할 수 있습니다.', + detailUrl: '/showcase/imageforge/', + website: 'https://if.repl.net', + github: 'https://github.com/replworks/imageforge', + featured: true, + order: 3, + }, ]; diff --git a/src/site-invariants.test.ts b/src/site-invariants.test.ts index c25b8bd..7e16ec4 100644 --- a/src/site-invariants.test.ts +++ b/src/site-invariants.test.ts @@ -70,6 +70,7 @@ describe('REPL Works content invariants', () => { 'coolrestore.mdx', 'etern-labs.mdx', 'eternops.mdx', + 'imageforge.mdx', 'mma.mdx', 'repl-works-website.mdx', 'wifinote.mdx', @@ -80,13 +81,19 @@ describe('REPL Works content invariants', () => { }); it('keeps the shared showcase data complete for home and Showcase', () => { - expect(showcaseProjects).toHaveLength(7); + expect(showcaseProjects).toHaveLength(8); expect(new Set(showcaseProjects.map((project) => project.slug)).size).toBe( - 7, + 8, ); expect(showcaseProjects.filter((project) => project.featured)).toHaveLength( 4, ); + expect( + showcaseProjects + .filter((project) => project.featured) + .sort((a, b) => a.order - b.order) + .map((project) => project.slug), + ).toEqual(['repl-works-website', 'wifinote', 'imageforge', 'coolrestore']); for (const project of showcaseProjects) { expect(project.detailUrl).toMatch(/^\/showcase\//); @@ -94,6 +101,15 @@ describe('REPL Works content invariants', () => { expect(project.lesson.length).toBeGreaterThan(0); } + const imageForge = showcaseProjects.find( + (project) => project.slug === 'imageforge', + ); + expect(imageForge?.website).toBe('https://if.repl.net'); + expect(imageForge?.github).toBe('https://github.com/replworks/imageforge'); + expect(read('src/content/showcase/imageforge.mdx')).not.toContain( + 'github.com/replworks/img-proxy', + ); + const home = read('src/pages/index.astro'); const card = read('src/components/ShowcaseCard.astro'); expect(home).toContain("from '../data/showcase'"); diff --git a/tests/index.e2e.ts b/tests/index.e2e.ts index 6ce62f2..bc395fe 100644 --- a/tests/index.e2e.ts +++ b/tests/index.e2e.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { documentMenu as documentMenuConfig } from '../src/data/documents'; import { promptDefinitions } from '../src/data/prompts'; +import { showcaseProjects } from '../src/data/showcase'; test('has title', async ({ page }) => { await page.goto('/'); @@ -14,7 +15,7 @@ test('has title', async ({ page }) => { test('links to document details and provides raw document downloads', async ({ page, }) => { - await page.goto('/documents'); + await page.goto('/documents/'); const documentLink = page .locator( @@ -33,7 +34,7 @@ test('links to document details and provides raw document downloads', async ({ }); test('renders document sidebar from its configured order', async ({ page }) => { - await page.goto('/documents/agents'); + await page.goto('/documents/agents/'); const documentMenu = page .locator('aside nav[aria-label="문서 목록"]') .first() @@ -64,11 +65,11 @@ test('renders document explanations without code controls', async ({ ]; for (const slug of documentSlugs) { - await page.goto(`/documents/${slug}`); + await page.goto(`/documents/${slug}/`); await expect(page.locator('.expressive-code')).toHaveCount(0); } - await page.goto('/documents/agents'); + await page.goto('/documents/agents/'); const agentsCode = page.locator('article .expressive-code'); await expect(agentsCode).toHaveCount(1); await expect(agentsCode.locator('.title')).toHaveCount(1); @@ -89,7 +90,7 @@ test('renders document explanations without code controls', async ({ test('applies the light theme to detail pages', async ({ page }) => { await page.addInitScript(() => localStorage.setItem('theme', 'light')); - await page.goto('/prompts/idea-generation'); + await page.goto('/prompts/idea-generation/'); await expect(page.locator('html')).toHaveClass(/light/); await expect(page.locator('html')).not.toHaveClass(/dark/); @@ -107,7 +108,7 @@ test('renders expressive code blocks with a localized copy button', async ({ page, }) => { await page.addInitScript(() => localStorage.setItem('theme', 'light')); - await page.goto('/prompts/idea-generation'); + await page.goto('/prompts/idea-generation/'); const copyButton = page.locator('.expressive-code .copy button').first(); await expect(copyButton).toBeVisible(); @@ -164,8 +165,26 @@ test('applies the light theme to the homepage', async ({ page }) => { ); }); +test('shows 이미지 포지 after Wifi Note among homepage Showcase cards', async ({ + page, +}) => { + await page.goto('/'); + + const featuredLinks = page.locator('main article a[href^="/showcase/"]'); + await expect(featuredLinks).toHaveCount(4); + const projectLinks = await featuredLinks.evaluateAll((links) => + links.map((link) => link.getAttribute('href')), + ); + expect(projectLinks).toEqual([ + '/showcase/repl-works-website/', + '/showcase/wifinote/', + '/showcase/imageforge/', + '/showcase/coolrestore/', + ]); +}); + test('loads the search interface', async ({ page }) => { - await page.goto('/search'); + await page.goto('/search/'); const searchInput = page.locator('pagefind-searchbox').locator('input'); await expect(searchInput).toBeVisible(); @@ -174,9 +193,14 @@ test('loads the search interface', async ({ page }) => { }); test('shows all Showcase projects as compatible cards', async ({ page }) => { - await page.goto('/showcase'); + await page.goto('/showcase/'); - await expect(page.locator('article a[href^="/showcase/"]')).toHaveCount(8); + await expect(page.locator('article a[href^="/showcase/"]')).toHaveCount( + showcaseProjects.length, + ); + await expect( + page.locator('article a[href="/showcase/imageforge/"]'), + ).toBeVisible(); }); test('keeps primary pages within a mobile viewport', async ({ page }) => { @@ -184,13 +208,13 @@ test('keeps primary pages within a mobile viewport', async ({ page }) => { for (const path of [ '/', - '/workflow', - '/prompts', - '/documents', - '/tools', - '/showcase', - '/faq', - '/search', + '/workflow/', + '/prompts/', + '/documents/', + '/tools/', + '/showcase/', + '/faq/', + '/search/', ]) { await page.goto(path); const width = await page.evaluate( @@ -216,7 +240,7 @@ test('embeds the exact prompt text rendered on every prompt detail page and has page, }) => { const slugs = promptDefinitions.map((prompt) => prompt.slug); - await page.goto('/prompts'); + await page.goto('/prompts/'); await expect(page.locator('button[data-prompt-copy]').first()).toBeVisible(); const embeddedPrompts = new Map(); @@ -229,7 +253,7 @@ test('embeds the exact prompt text rendered on every prompt detail page and has } for (const slug of slugs) { - await page.goto(`/prompts/${slug}`); + await page.goto(`/prompts/${slug}/`); await expect( page.locator('article .expressive-code .copy button').first(), ).toBeVisible(); @@ -255,7 +279,7 @@ test('embeds the exact prompt text rendered on every prompt detail page and has test('copies prompt text to the clipboard', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); - await page.goto('/prompts'); + await page.goto('/prompts/'); const button = page.locator('button[data-prompt-copy]').first(); const source = page.locator('script[data-prompt-source]').first(); @@ -270,18 +294,18 @@ test('copies prompt text to the clipboard', async ({ page, context }) => { }); test('renders sidebar navigation on detail pages', async ({ page }) => { - await page.goto('/prompts/idea-generation'); + await page.goto('/prompts/idea-generation/'); const promptSidebar = page.locator('aside nav:visible').first(); await expect(promptSidebar).toBeVisible(); await expect( promptSidebar.locator('a[href="/prompts/pitch-creation/"]'), ).toBeVisible(); - await page.goto('/showcase/repl-works-website'); + await page.goto('/showcase/imageforge/'); const showcaseSidebar = page.locator('aside nav:visible').first(); await expect(showcaseSidebar).toBeVisible(); await expect( - showcaseSidebar.locator('a[href="/showcase/claytube/"]'), + showcaseSidebar.locator('a[href="/showcase/imageforge/"]'), ).toBeVisible(); });