Skip to content

feat(preview): 문서 속 이미지를 프리뷰에 렌더한다 (M10) - #23

Merged
Bori-github merged 14 commits into
mainfrom
feat/preview-local-images
Aug 12, 2026
Merged

feat(preview): 문서 속 이미지를 프리뷰에 렌더한다 (M10)#23
Bori-github merged 14 commits into
mainfrom
feat/preview-local-images

Conversation

@Bori-github

@Bori-github Bori-github commented Aug 12, 2026

Copy link
Copy Markdown
Owner

요약 / 의도

문서에 넣은 이미지를 프리뷰에 렌더한다. 파일 하나만 연 경우에도 문서와 같은 폴더의 이미지를 읽는다. VS Code도 작업 폴더에 속하지 않은 마크다운을 미리 볼 때 같은 폴더를 읽는다(getMarkdownLocalResourceRoots).

범위

이번에 한 것

  • 로컬 이미지는 Tauri asset 프로토콜로 읽는다. 파일을 허용하는 경우 그 파일이 있는 폴더를 asset 스코프에 등록한다(부모가 파일시스템 루트인 경우 그 파일만).
  • 상대 경로는 문서가 있는 폴더, /는 연 폴더 기준으로 해석한다. 기준 폴더가 없는 경우 그대로 둔다.
  • img[srcset]·source[srcset]을 후보마다 해석하고, 후보가 asset:으로 시작하는 경우 버린다.
  • 못 찾은 이미지는 그 자리에 alt 텍스트를 세우고, 이미지가 도착하면 스크롤 동기화가 블록을 다시 잰다.
  • 눈으로 판정하는 examples/이미지-예시.md를 만들고, 실앱 E2E에 파일만 연 경우를 더했다.
  • 브라우저 테스트의 간헐 실패를 없앴다. 원인은 이미지와 무관하다 — Vite가 실행 중간에 의존성을 다시 묶으며 페이지를 리로드했다.

의도적으로 안 한 것 / 후속

  • 연 폴더에도 문서 폴더에도 없는 이미지 — 다이얼로그로 이미지 폴더를 추가 허용할지가 결정 대상이다(열린 결정에 기록).
  • 원격 이미지를 끄는 설정 — CSP가 정적이라 렌더 단계에서 따로 제거해야 한다(기존 열린 결정).
  • 파일 커맨드의 범위는 넓히지 않았다. 넓어진 것은 이미지 읽기뿐이다.

주요 변경점

  • 프론트(FSD): 연 폴더를 workspace 상태에서 읽어 프리뷰로 넘긴다.
  • 마크다운(packages/markdown): 기준 폴더를 둘로 나누고, sanitize 뒤에 src·srcset을 바꾼다.
  • Rust(src-tauri): FileScope의 허용·거부를 asset 스코프에도 등록한다. assetProtocol 설정을 객체 형태로 바꿨다.
  • 스크립트/툴링: NORII_E2E_SCOPE_FILE로 파일 하나만 연 상태를 만든다(webdriver 빌드 전용). optimizeDeps.include로 테스트 중 페이지 리로드를 막는다.
  • 문서(.claude): 읽는 폴더·경로 해석·해석하는 속성·권한·위험을 갱신했다.

설계 · 결정

  • 파일을 허용하는 경우에만 두 스코프가 갈라진다. 파일 커맨드는 그 파일만, asset 스코프는 그 파일이 있는 폴더를 읽는다. 이 차이의 위험은 security.md#이미지가 소유한다.
  • /는 연 폴더 기준이다. 디스크 루트 기준으로 해석하는 경우 같은 문서가 GitHub·VS Code와 다른 파일을 가리킨다.
  • assetProtocol.scope는 객체로 적고 requireLiteralLeadingDot: false를 넣는다. unix 기본값이 true라 생략하면 점으로 시작하는 폴더의 파일을 파일 커맨드는 통과시키고 이미지만 거부한다.
  • 문서와 달라진 점: 없다. 문서를 먼저 갱신하고 코드를 맞췄다.
flowchart LR
  A["FileScope.allow(경로)"] --> B{폴더인가}
  B -- 예 --> C["파일 커맨드: 하위 트리<br/>asset: 하위 트리"]
  B -- 아니오 --> D{부모가 루트인가}
  D -- 아니오 --> E["파일 커맨드: 그 파일만<br/>asset: 부모 폴더의 하위 트리"]
  D -- 예 --> F["둘 다 그 파일만"]
Loading

검증 (체크가 아니라 값으로)

  • mise run check: 통과 — desktop 62파일 435개 · markdown 113개 · editor 28개 · rust 88개. 차가운 node_modules/.vite로도 통과.
  • TDD(신규 기능·커맨드·상태 전이·파서는 실패 테스트 먼저): 예 — 스코프 3개, 경로 해석 3개, srcset 5개, 앱 배선 2개를 먼저 실패시키고 구현했다.
  • 실앱 E2E (mise run dev-webdrivermise run e2e): 통과 23/23. 이미지 시나리오 둘이 문서 폴더 기준·연 폴더 기준·srcset 후보·점으로 시작하는 폴더·없는 파일·읽을 수 없는 경로·파일만 연 경우를 덮는다.
  • 번들 크기: N/A — 릴리스 빌드를 만들지 않았다.
  • 자동 검증 불가 항목: 서명된 배포 빌드에서 http: 이미지가 막히는지. 개발 서버는 CSP를 붙이지 않아 개발 빌드에서 확인되지 않는다.

관련 문서 (단일 출처)

  • .claude/docs/preview-strategy.md — 어느 폴더의 이미지를 읽는가 · 경로 해석 · 해석하는 속성
  • .claude/docs/rust-commands.md — 권한(asset 프로토콜)
  • .claude/docs/security.md — 이미지가 읽을 수 있는 경로와 남는 위험
  • examples/이미지-예시.md — 눈으로 판정하는 예시

리뷰 포인트 / 위험 지점

  • scope.rsallow() 파일 분기 — 두 스코프가 갈라지는 유일한 자리다.
  • render.tsparseSrcset — 후보 경계를 URL 뒤의 쉼표로만 판정한다.
  • lib.rsNORII_E2E_SCOPE_FILE — webdriver 피처에서만 컴파일된다.

체크리스트 (프로젝트 규칙)

  • 동작을 바꾸는 변경은 계약 문서를 먼저/같은 커밋에서 갱신
  • docs-drift 통과 (tech-stack 버전 ↔ 핀, Rust 커맨드 등재)
  • 비목표 경계(.claude/rules/non-goals.md)를 넘지 않음 — 인덱싱·DB가 생기지 않는다
  • 성능 규칙(뷰포트 한정 데코레이션·프리뷰 디바운스·번들 목표) 준수
  • 커밋·PR 제목이 Conventional Commits(한국어)

스크린샷 / 데모

norii-.mov

E2E 시나리오 둘을 그대로 녹화했다. 왼쪽 소스, 오른쪽 프리뷰 — 문서 폴더 기준·연 폴더 기준·srcset 2배 후보가 뜨고, 이어서 폴더 없이 파일만 연 문서의 옆 이미지가 뜬다.

마일스톤 / 비고

M10(문서 속 이미지). 브라우저 테스트 간헐 실패 수정은 이 마일스톤과 무관하지만 mise run check를 무작위로 실패시켜 함께 담았다.

Bori-github and others added 14 commits August 10, 2026 17:05
앱에서 지우거나 이름을 바꾼 파일도 최근 파일 목록에서 변경되지 않는다. 코드는 그렇게
동작하는데 문서가 이 경우를 규정하지 않았다 — 그 항목을 누르면 열지 못했다고 알리고,
없는 경로는 다음 실행의 load_session이 걸러낸다.

사이드바를 접으면 새 파일을 만드는 길이 화면에서 사라진다. 탭바에 버튼을 둘지와 이름을
어디서 받을지가 정해지지 않아 열린 결정으로 둔다.

저장 다이얼로그는 시작 디렉터리를 줄 때 파일명 제안을 뺀다 — rfd 제약을 우회한 것이라
업스트림이 고치면 되돌린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
프리뷰가 문서 속 이미지를 하나도 띄우지 못한다 — 로컬 상대 경로는 웹뷰 기준으로 해석되고
원격 이미지는 CSP가 막는다. 구현에 앞서 계약을 확정한다.

로컬 파일은 asset 프로토콜로 싣고, 읽을 수 있는 경로는 파일 커맨드와 같은 허용 루트다 —
FileScope가 두 스코프의 단일 출처라 갈라지지 않는다. src는 sanitize 뒤에 바꾼다.
DOMPurify 허용목록에 asset:을 더하면 문서가 적은 asset URL도 함께 통과하는데, 순서를
뒤집으면 그 길이 닫힌 채로 상대 경로만 해석된다.

원격 이미지는 https만 싣는다. 문서를 여는 것만으로 그 URL에 요청이 나가는 것이 대가이며,
끄는 설정은 열린 결정으로 남긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
이미지 한 장마다 IPC를 왕복하지 않도록 허용 루트 목록이 두 스코프의 단일 출처가 된다 —
FileScope의 허용·거부가 asset 스코프에도 같은 경로를 등록하므로 다이얼로그·폴더 열기·
load_session 세 입구가 손대지 않아도 따라온다. 두 스코프가 갈라지면 파일 커맨드가 막는
경로를 이미지로 읽을 수 있어, mock_app의 진짜 Tauri 스코프로 일치를 검증한다.

assetProtocol의 scope는 비워 둔다 — 허용 경로는 위 세 입구에서 런타임에 정해진다.
protocol-asset 피처는 tauri CLI가 빌드할 때만 붙여 주므로, cargo test·cargo build에도
켜지도록 Cargo.toml에 명시한다.

CSP img-src에 https를 더해 원격 이미지를 싣는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
renderMarkdown이 resolveImageSrc 옵션을 받아 sanitize를 마친 HTML의 img[src]만 바꾼다.
DOMPurify 허용목록에 asset:을 더해 파서 단계에서 심으면 문서가 적은 asset URL도 함께
통과하므로, 순서를 뒤집어 그 길을 닫은 채로 상대 경로만 해석한다. 원시 HTML의 img도 같은
자리에서 잡힌다 — 크기를 주려고 흔히 쓰는 표기다.

resolveImagePath는 스킴이 붙은 src와 프로토콜 생략 주소를 그대로 두고, 나머지만 퍼센트
디코딩·문서 폴더 결합·경로 정규화를 거쳐 절대 경로로 만든다. asset 프로토콜이 상위 참조가
든 경로를 거부하고, markdown-it이 src를 인코딩해 넘기기 때문이다.

이미지가 없는 문서는 다시 파싱하지 않는다. 다시 파싱하는 경로는 src 말고 아무것도 바꾸지
않아야 하므로, 표·체크박스·details가 든 문서로 그 불변을 고정한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
활성 탭의 폴더가 상대 경로의 기준이다 — 앱이 그 폴더를 resolveImagePath에 넘기고, 결과 경로를
asset URL로 바꿔 프리뷰에 넣는다. 경로 없는 문서(Untitled)와 원격 주소는 그대로 둔다.

로드에 실패한 이미지는 그 자리에 흐린 alt 텍스트로 남는다. 브라우저는 물음표 상자를 그릴 뿐
alt를 글자로 표시하지 않고 img는 대체 요소라 ::after도 화면에 나오지 않아, 실패한 img에
display:none을 걸고 뒤에 span을 넣는다. 설명이 없는 이미지는 못 찾았다는 한 줄을 대신 쓴다.

이미지는 렌더를 마친 뒤 도착해 블록 높이를 바꾸므로, 도착할 때마다 리비전을 올려 스크롤
동기화가 낡은 측정을 버리게 한다. load·error는 버블하지 않아 콘텐츠 요소 한 곳에서 캡처로
받는다. 갱신마다 이미지가 다시 로드되지만 빈 프레임은 1.5%(최장 32ms)라 캐시를 두지 않는다.

asset 프로토콜은 실제 웹뷰에만 있어 실앱 E2E가 픽셀까지 확인한다. http와 https의 구분은
개발 서버가 CSP 없이 서빙해 개발 빌드에서 확인되지 않는다 — 수동 검증 대상으로 문서에 남긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
파일 하나만 연 세션에서는 asset 스코프에 그 파일만 들어가, 문서와 같은 폴더에 둔 이미지가
렌더되지 않는다. 파일을 허용할 때 그 파일이 있는 폴더의 하위 트리를 asset 스코프에 등록하는
것으로 계약을 바꾼다. 파일 커맨드는 그 파일만 다루므로 두 스코프는 여기서만 갈라지고,
넓어진 범위와 남는 위험은 보안 문서에 적는다. VS Code도 작업 폴더에 속하지 않은 마크다운을
미리 볼 때 그 문서가 있는 폴더를 리소스 루트로 삼는다.

`/`로 시작하는 경로는 연 폴더 기준으로 해석한다. 디스크 루트 기준으로 해석하면 같은 문서가
GitHub·VS Code와 다른 파일을 가리킨다. 폴더를 열지 않은 경우에는 기준이 없어 그대로 둔다.

srcset과 source[srcset]도 해석 대상에 넣는다. src만 바꾸면 srcset이 있는 이미지는 같은
폴더에 파일이 있어도 렌더되지 않는다. 후보에 섞인 asset:은 버린다.

assetProtocol의 scope는 객체로 적고 requireLiteralLeadingDot을 false로 둔다. unix 기본값이
true라 생략하면 점으로 시작하는 폴더 안의 파일을 이미지만 거부한다.

코드는 아직 옛 계약대로 동작한다 — 뒤따르는 커밋에서 정렬한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
폴더 없이 파일 하나만 여는 길(다이얼로그·최근 파일·세션 복원)에서는 asset 스코프에 그 파일만
들어가, 문서와 같은 폴더에 둔 이미지가 렌더되지 않았다. 파일을 허용할 때 그 파일이 있는 폴더의
하위 트리를 asset 스코프에 등록한다. 파일 커맨드의 범위는 그대로 그 파일 하나다.

부모가 파일시스템 루트인 경우 폴더째 등록하면 디스크 전체가 열리므로 그 파일만 등록한다.

assetProtocol의 scope는 객체로 적고 requireLiteralLeadingDot을 false로 둔다. unix 기본값이
true라 생략하면 점으로 시작하는 폴더 안의 파일을 파일 커맨드는 통과시키고 이미지만 거부한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
이미지 경로의 기준을 문서 폴더 하나에서 문서 폴더·연 폴더 둘로 나눈다. `/`로 시작하는 경로는
연 폴더 기준으로 해석하고, 폴더를 열지 않은 경우 그대로 둔다. GitHub·VS Code가 같은 규칙이라
같은 문서가 세 곳에서 같은 파일을 가리킨다. 디스크 루트 기준으로 해석하면 그 표기를 쓴 문서가
조용히 다른 파일을 가리킨다.

연 폴더를 아는 것은 앱이므로 workspace 상태에서 읽어 프리뷰로 넘긴다. 상대 경로의 기준은
그대로 활성 탭이 있는 폴더다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srcset이 있으면 브라우저는 src보다 그쪽을 먼저 쓴다. src만 바꾸면 srcset을 쓴 이미지는 문서와
같은 폴더에 파일이 있어도 렌더되지 않는다. img[srcset]과 picture 안의 source[srcset]을
후보마다 해석한다.

후보를 가르는 것은 URL 뒤에 오는 쉼표뿐이다. 쉼표로 통째로 쪼개면 이름에 쉼표가 든 파일이 두
후보로 갈려 둘 다 없는 파일이 된다.

후보가 asset:으로 시작하면 버린다. 무해한 후보 뒤에 붙인 asset URL은 sanitize를 통과한다
(실측) — src에서 막는 것과 같은 방어를 후보에도 건다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"읽는 범위"·"서술자"·"스코프 미러링"은 이 저장소에 없던 말이라 문서 세 곳과 코드·테스트가 같은
이름으로 엮여 있었다. 절 제목을 "어느 폴더의 이미지를 읽는가"로 바꾸고 본문은 "읽을 수 있는
경로"로 되돌린다. 대조로 쓴 "~가 아니라 ~한다"와 앞 문단을 가리키는 말도 없앤다.

보안 문서의 이미지 절에서 어느 폴더를 읽는지 적힌 문장이 빠져 뒤따르는 "범위 밖"·"같은 폴더"가
가리킬 곳을 잃었다. 그 문장을 되살리고 주어를 고친다.

scope.rs의 mirror_to_asset은 "같은 경로를 등록한다"고 적혀 있었으나 파일 허용에서는 부모 폴더를
등록한다. image-src.ts의 머리 주석도 기준이 문서 폴더 하나인 것처럼 남아 있었다.

"src는 sanitize 뒤에 바꾼다" 절이 srcset도 다루므로 제목에서 src를 빼고, 그 앵커를 가리키던
세 곳을 함께 고친다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
이미지는 뜨는 조건과 뜨지 않는 조건이 갈리는데, 그 판정은 화면을 봐야 한다. 뜨는 경우 일곱과
뜨지 않는 경우 다섯을 한 문서에 담아 눈으로 대조하게 한다.

프리뷰-기본.md의 이미지 절은 이 문서로 옮긴다 — 한 표면은 한 파일이 담당한다.

예시 이미지 셋을 더한다. srcset의 1배·2배 후보가 갈리는 것을 색과 무늬로 구별하고, 이름에 공백이
든 파일과 점으로 시작하는 폴더 안의 파일을 각각 확인한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
이미지 시나리오가 폴더를 연 상태만 봤다. 문서 폴더 기준·연 폴더 기준·srcset 후보·점으로 시작하는
폴더를 한 문서에 담아 확인하고, 없는 파일과 읽을 수 없는 경로에는 깨짐 표시가 붙는 것을 본다.
읽을 수 없는 경로는 파일이 실제로 있는 자리를 가리키므로, 스코프가 넓어지면 이 단언이 먼저 깨진다.

폴더 없이 파일 하나만 연 상태는 열기 다이얼로그·최근 파일·세션 복원으로 만들어지는데 WebDriver는
다이얼로그를 열 수 없다. webdriver 빌드에 한해 환경변수로 파일 하나를 허용 루트에 넣는다.

1×1 PNG 대신 예시 이미지를 복사해 쓴다 — 이 시나리오는 데모 영상으로 그대로 녹화된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
계약대로 동작하는 것을 게이트와 실앱 E2E로 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vite가 실행 중간에 @tauri-apps/plugin-store·react-dom/client를 발견해 의존성을 다시 묶고
페이지를 리로드한다. 그 순간 읽히던 테스트 파일이 러너 없는 페이지에서 실행돼
"Vitest failed to find the runner"로 죽었고, 리로드에 걸린 파일이 매번 달라 무작위로 보였다.

node_modules/.vite를 지우면 재현된다. 두 의존성을 미리 넣은 뒤 차가운 캐시로 두 번,
mise run check로 한 번 돌려 리로드가 나지 않는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Bori-github Bori-github self-assigned this Aug 12, 2026
@Bori-github Bori-github added the feat 기능 추가/변경 label Aug 12, 2026
@Bori-github
Bori-github merged commit b08e04f into main Aug 12, 2026
1 check passed
@Bori-github
Bori-github deleted the feat/preview-local-images branch August 12, 2026 07:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 기능 추가/변경

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant