Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .replworks/PRODUCT_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions .replworks/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@
와이파이 노트
</a>
</li>
<li>
<a
href="https://if.repl.net"
target="_blank"
rel="noreferrer"
class="transition hover:text-cyan-400"
>
이미지 포지
</a>
</li>
</ul>
</div>
</div>
Expand Down
61 changes: 61 additions & 0 deletions src/content/showcase/imageforge.mdx
Original file line number Diff line number Diff line change
@@ -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하는 운영 도구를 함께 구성한 이미지 서비스입니다.

<MissionBox text="공개 서비스와 내부 처리 경계를 나누고, 이미지 변환부터 캐시 갱신까지 하나의 운영 흐름으로 연결합니다." />

서비스는 두 프로젝트 저장소, 운영 인프라, 이미지 처리 엔진으로 이루어집니다. 공개 저장소 [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 문서와 태스크 단위의 작업 흐름이 여러 구성요소를 함께 발전시키는 기반이 되었습니다.

<DocLink href="https://if.repl.net" text="이미지 포지 서비스 방문" />
<DocLink
href="https://github.com/replworks/imageforge"
text="이미지 포지 공개 GitHub 저장소"
/>
18 changes: 16 additions & 2 deletions src/data/showcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
},
];
20 changes: 18 additions & 2 deletions src/site-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -80,20 +81,35 @@ 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\//);
expect(project.tags.length).toBeGreaterThan(0);
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'");
Expand Down
68 changes: 46 additions & 22 deletions tests/index.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/');
Expand All @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -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);
Expand All @@ -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/);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -174,23 +193,28 @@ 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 }) => {
await page.setViewportSize({ width: 393, height: 852 });

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(
Expand All @@ -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<string, string>();

Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
});

Expand Down
Loading