프런트엔드 아키텍처
Oleafly가 데스크톱 앱을 프런트엔드 워크스페이스, Rust 서비스, 고정된 사이드카로 나누는 방식을 설명합니다.
Oleafly의 프런트엔드는 pnpm 워크스페이스입니다. 앱 셸은 저장소 루트(src/)에
있고, 재사용 가능한 프런트엔드 코드는 packages/*에 열두 개의 역할이 뚜렷한
패키지로 들어 있습니다. Tauri 백엔드는 src-tauri/에 있고, 재사용 가능한 AI
제공자 및 에이전트 코드는 crates/oleafly-agent/에 있습니다. 프런트엔드/Rust/사이드카
구성은 README의 아키텍처 다이어그램 (새 탭에서 열림)을,
일상적인 명령은 개발을 참고하세요.
이 문서는 각 패키지가 무엇인지, 패키지를 순수하게 유지하는 의존성 역전 규칙, 기능을 셸에 연결하는 기여 레지스트리, 모듈 해석 방식, 패키지를 추가하거나 추출하는 방법을 다룹니다.
워크스페이스를 쓰는 이유
섹션 제목: “워크스페이스를 쓰는 이유”예전에는 모든 기능이 앱의 Zustand 스토어, Tauri IPC 클라이언트, shadcn UI 키트에 직접 손을 뻗었습니다. 그래서 기능을 셸에서 떼어 낼 수 없었고, 아무것도 따로 테스트하거나 재사용하거나 독립적으로 파악할 수 없었습니다. 워크스페이스는 이를 뒤집습니다. 패키지가 기능 로직을 갖고, 필요한 모든 것에 대해 포트라는 좁은 인터페이스를 선언합니다. 앱은 스토어와 Tauri 클라이언트 위에서 그 포트를 구현해 주입합니다. 앱이 패키지에 의존하고, 그 반대는 결코 없습니다.
패키지는 TypeScript 소스로 소비됩니다(main이 src/index.ts를 가리킵니다).
빌드 단계도, 배포도, 버전 불일치도 없습니다. Vite, Vitest, tsc가 모두 별칭을 통해
@oleafly/*를 패키지 소스로 곧바로 해석합니다(모듈 해석 참고).
패키지
섹션 제목: “패키지”| 패키지 | 설명 | 주입되는 포트 | 주요 의존성 |
|---|---|---|---|
@oleafly/latex | 순수 LaTeX/그림 로직: 다이어그램 모델, TikZ 직렬화기(modelToTikz, 내장 모델 왕복 변환), 독립 문서 빌더(buildStandaloneDoc), 그림 이름/바이트 도우미 | 없음(순수) | 없음 |
@oleafly/ai-core | 제공자 표시 메타데이터와 프런트엔드 모델 선택. Rust 카탈로그와 동일하게 유지됩니다 | 없음(순수) | 없음 |
@oleafly/latex-intelligence | 편집기 인텔리전스가 쓰는 LaTeX 코퍼스의 검증된 타입과 데이터 접근 | 없음(순수) | 없음 |
@oleafly/registry | 기여 레지스트리: 레일 탭, 팔레트/옴니바 명령, AI 툴셋(아래 자세히) | 없음(기여가 자체 동작을 가짐) | react(타입) |
@oleafly/preflight | 프리플라이트 엔진: 문서 유형 판별, 소스/PDF/참조 규칙, ATS 파싱, 접근성 내보내기 준비, 점수 계산. pdf.js가 node 테스트 그래프에 들어가지 않도록 pdf-extract는 @oleafly/preflight/pdf-extract 하위 경로로 제공됩니다 | 없음(순수) | pdfjs-dist(하위 경로만) |
@oleafly/pdf-to-latex | 로컬 PDF-to-LaTeX 재구성 파이프라인: 줄 묶기, 제목과 단 감지, 스타일 추론, 기호 매핑, 그림 추출, 이스케이프, 문서 조립. 같은 node 테스트 그래프 사유로 pdf.js 어댑터는 @oleafly/pdf-to-latex/pdf-adapter 하위 경로로 제공됩니다 | 없음(순수) | pdfjs-dist(하위 경로만) |
@oleafly/wysiwyg | 제한된 비주얼 편집기 코어: 공유 스키마, LaTeX와 Markdown 파싱/직렬화, 지원하지 않는 소스를 버리지 않고 보이게 유지하는 raw 인라인/블록 노드 | 없음(순수) | 없음 |
@oleafly/editor | LaTeX, Typst, Markdown용 CodeMirror 코어: CodeMirrorEditor, 편집기 뷰 컨트롤러(insertAtCursor, gotoLine, …), 언어 + 자동 완성, 테마, 접기, 고정 제목, 린터, latex-mask, 수식 미리보기(LaTeX는 KaTeX, Typst는 Typst), Typst 편집 보조와 LaTeX-to-Typst 수식 변환, LaTeX와 Typst의 제자리 비주얼 모드(@oleafly/editor/visual), 검색 패널, 맞춤법/문법 린터 | EditorHost(문서 모델 + 설정, 훅 형태), SpellHost(맞춤법 검사기/Harper/사전), setBibKeysProvider, extraExtensions/extraKeymap | @codemirror/*, @replit/codemirror-vim, @replit/codemirror-emacs, katex |
@oleafly/preview | 가상화된 pdf.js 뷰어(PdfViewer), SyncTeX 페이지 컨트롤러(gotoRect, pageClickToBp), pdf.js 워커, WebView 폴리필(@oleafly/preview/polyfills, main.tsx에서 가장 먼저 임포트) | onOpenLink prop(시스템 브라우저 링크), setPdfLogger | pdfjs-dist |
@oleafly/diagram | TikZ, Typst(fletcher), Mermaid용 비주얼 다이어그램 작성기: React Flow 캔버스, 도형 인스펙터, 그리기/코드 탭, 컴파일-미리보기-삽입 흐름. src/languages/의 각 언어에는 작성기, 판독기, 삽입 및 파일 형식이 있으며, 캔버스와 코드가 양방향으로 맞춰집니다 | DiagramHost(LaTeX 컴파일, Typst와 Mermaid 렌더링, 파일 IO, 편집기 삽입, AI 수정), DiagramKit 컨텍스트(Button/Tooltip/Select/toast/theme) | @xyflow/react, @codemirror/*, @oleafly/latex, @oleafly/editor |
@oleafly/ai-tools | 프로젝트, 연구, 메모리, 그림 도구 정의. 변경 작업은 승인을 인식합니다 | AiToolsHost(파일, 컴파일, 심볼 인덱스, 그림 파이프라인, 편집기) | @oleafly/latex |
@oleafly/templates | 새 프로젝트 템플릿 갤러리(2단계 마법사, 카테고리, 미리보기, 진행률이 있는 일회성 에셋 다운로드) | TemplatesHost(미리보기, 에셋 다운로드, 로깅), TemplatesKit(Button/Tooltip), 색 옵션은 props로 | UI 유틸리티 외에는 없음 |
엄격하게 강제되는 의존성 방향:
패키지는 src/(@/…), Zustand 스토어, @tauri-apps/*, 앱의 UI 컴포넌트에서
절대 임포트하면 안 됩니다. 순수성 검사는 추출 위생의 일부입니다.
grep -rn 'from "@/\|@tauri\|zustand' packages/*/src # must return nothing포트 패턴
섹션 제목: “포트 패턴”패키지는 인터페이스를 선언합니다. 앱은 그것을 한 번 구현하며, 보통 스토어를 감싸는 모듈 수준 어댑터 객체로 구현합니다. 반복해서 나타나는 형태는 네 가지입니다.
호스트 포트: 필요한 서비스를 담는 패키지당 interface XHost 하나입니다.
앱은 명령형 스토어 읽기로 어댑터를 만듭니다.
// src/lib/ai-tools.ts (app adapter, abridged)const HOST: AiToolsHost = { getProjectId: () => useFilesStore.getState().projectId, readFileContent, // Tauri client passthrough recompile: () => useCompileStore.getState().recompile(), insertAtCursor, // editor controller // ...};export const createOleaflyTools = (opts?) => createOleaflyToolsCore(HOST, opts);앱에만 해당하는 관심사를 어댑터 안에 두면 의존성도 패키지 밖에 머뭅니다. 제공자 자격 증명과 요청은 Rust에 남으므로, 프런트엔드 패키지는 인터페이스가 요구하는 상태와 결과만 받습니다.
UI 키트: 구조적으로 타입이 지정된 컴포넌트를 담은 React 컨텍스트(또는 prop)입니다.
Button: ComponentType<{ variant?: …; onClick?: () => void }> 같은 형태입니다. 더 작은
구조적 타입이 호환되므로 앱은 shadcn 컴포넌트를 그대로 넘깁니다. 키트에는 toast와
useThemeMode() 훅도 들어 있습니다.
훅 형태의 포트: 앱 상태를 구독해야 하는 패키지용입니다. 편집기의 EditorHost에는
훅 멤버(useActivePath(), useSettings())와 이벤트 리스너를 위한 명령형 짝(getActivePath())이
있습니다. 규칙은 이렇습니다. 호스트 객체는 모듈 수준이어야 하고(안정적인 정체성),
use* 멤버는 훅을 무조건 호출해야 합니다.
모듈 싱글턴: 횡단 관심사를 잇는 접합부로, setPdfLogger(fn), setSpellHost(host),
setBibKeysProvider(fn)에는 아무 일도 하지 않는 기본값이 있고 앱 심이 임포트 부수
효과로 설치합니다.
모든 추출은 예전 @/… 경로에 패키지를 다시 내보내는(또는 감싸는) 얇은 파일을
남겼으므로, 소비하는 쪽과 기존 vi.mock(...) 경로가 흔들린 적이 없습니다. 예를 들면
src/components/editor/cm/controller.ts(다시 내보내기), src/components/pdf/PdfViewer.tsx(셸 플러그인
링크 열기 기능을 주입하는 래퍼), src/lib/ai-tools.ts(어댑터 + 원래 팩토리 시그니처)입니다.
새 코드는 @oleafly/*를 직접 임포트해도 됩니다. 심은 호환성을 위해 있으며 권장
경로가 아닙니다.
기여 레지스트리
섹션 제목: “기여 레지스트리”@oleafly/registry는 기능이 셸이 알지 못하는 채로 앱 셸에 끼워지는 방식입니다. 기여
종류는 세 가지입니다.
- 레일 탭(
registerRailTab): 아이콘, 섹션(explore/review/assist, 비어 있지 않은 섹션 사이에 구분선이 그려집니다), 선택적인when(ctx)표시 조건, 선택적인useBadge()훅(예: Git 변경 개수), 사이드바panel컴포넌트. - 명령(
registerCommand):surfaces에 따라 명령 팔레트(Cmd+K,group별로 묶임) 그리고/또는 옴니바(Cmd+P)에 제공됩니다. 레이블과 아이콘은 컨텍스트의 함수일 수 있습니다(예: “Switch to light theme”). - AI 툴셋(
registerAiToolset): 채팅 모드("chat","figure")를 툴셋 팩토리에 매핑합니다. 채팅 패널은 하나를 고정해 두지 않고 활성 모드의 툴셋을 찾아봅니다.
AppContext({ projectId, projectKind, theme })는 각 렌더링 화면이 자기 구독으로 만들므로
when/label 조건이 반응성을 유지합니다.
등록은 정적입니다. src/contributions/가 모든 내장 항목(탭, 명령, 툴셋)을
등록하고, main.tsx가 셸이 마운트되기 전에 registerContributions()를 한 번 호출합니다.
Rail, Sidebar, CommandPalette, SearchOmnibar, ChatPanel은 등록된 것만 렌더링합니다.
기능의 탭이나 명령을 추가하려면 등록하면 됩니다. 셸을 편집할 필요가 없습니다.
// src/contributions/my-feature.tsx (called from src/contributions/index.ts)registerRailTab({ id: "review", label: "Review", icon: MessageSquare, section: "review", order: 55, when: (ctx) => ctx.projectKind !== "image", panel: ReviewPanel,});registerCommand({ id: "omnibar.review", surfaces: ["omnibar"], order: 45, label: "Open review", keywords: "review comments", run: () => useSettingsStore.getState().setRailTab("review"),});이것은 플러그인 SDK가 아니라 내부 API입니다. 형태는 자유롭게 바뀔 수 있고, 동적으로 로드되는 것은 없습니다. 실제 소비자가 생길 때까지 미뤄 둔 것은 내보내기 메뉴 형식, 설정 스키마, 프로젝트 종류별 동작을 기여로 만드는 일입니다.
모듈 해석
섹션 제목: “모듈 해석”패키지를 소스로 소비하므로 같은 별칭이 세 곳에 선언되어 있으며, 패키지를 추가할 때 세 곳 모두를 일치시켜야 합니다.
tsconfig.json→compilerOptions.paths.@oleafly/preflight/pdf-extract같은 하위 경로에는 명시적인 항목이 따로 필요합니다.vite.config.ts→resolve.alias. 문자열 별칭은 접두사로 일치하므로 하위 경로는 항목을 더 두지 않아도 해석됩니다.vitest.config.ts→ 같은resolve.alias, 그리고test.include가packages/**/*.test.ts를 포함해야 합니다. 그렇지 않으면 옮긴 테스트가 조용히 실행에서 빠집니다.
또한 루트 package.json은 각 패키지에 "workspace:*"로 의존하고,
src/styles/globals.css에는 Tailwind v4가 클래스 이름을 찾도록 패키지 소스를 스캔하는
@source "../../packages"가 있습니다.
소스를 가리키는 별칭 덕분에 Vite 전용 임포트(?worker&url 워커, CSS 임포트)는 패키지
소스에서도 그대로 동작합니다. node 환경 테스트가 임포트하는 배럴에는 pdf.js를 끌고
오는 것이 들어가지 않게 하세요. pdf-extract와 preview 패키지의 뷰어가 DOM이 있는
곳에서만 접근 가능한 이유입니다.
패키지 작업하기
섹션 제목: “패키지 작업하기”- 테스트는 소스 옆(
packages/*/src/*.test.ts)에 있고 앱 테스트와 같은pnpm test로 실행됩니다. 파일을 옮길 때는 테스트 개수를 지켜보세요. - 의존성은 느슨하게(
"react": "*") 선언합니다. pnpm이 루트와 중복을 제거하며, 실제 버전 하한은 루트에 있습니다. 패키지가 임포트하지 않는 의존성은 선언하지 마세요. - 패키지를 바꿀 때의 관문:
pnpm exec tsc --noEmit,pnpm build,pnpm test(개수 변화 없음), 위의 순수성 grep. - 새로 추출하기:
git mv로 파일을 옮기고(이력이 따라옵니다), 포트를 정의하고, 예전 경로에 심을 남기고, 세 별칭을 연결한 다음 관문을 실행하세요. 한창 다듬는 중인 기능은 추출하지 마세요. - 라이선스: 모든 패키지는 앱과 같은
AGPL-3.0-or-later입니다.
앱에 남는 것
섹션 제목: “앱에 남는 것”src/에는 진짜 프런트엔드 앱인 것이 모두 남습니다. Zustand 스토어, Tauri IPC
클라이언트(src/lib/tauri.ts), shadcn UI 키트, 레이아웃/셸 컴포넌트, 포트 어댑터와 심,
src/contributions/, 그리고 본질적으로 스토어에 묶여 있는 기능 접합부(프리플라이트 패널 UI,
인라인 AI 편집 플러그인, extraExtensions로 편집기에 주입되는 코드 및 호버 인텔리전스)입니다.
Tauri 명령과 운영 체제 통합은 src-tauri/에 남습니다. 제공자 클라이언트, 스트리밍,
에이전트 오케스트레이션은 crates/oleafly-agent/에 속하므로, 시크릿과 제공자 요청은 웹뷰 밖에 있습니다.