14 Commits

Author SHA1 Message Date
dependabot[bot]
9fa1bc04f4 build(deps-dev): Bump tmp from 0.2.5 to 0.2.7
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.5 to 0.2.7.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.5...v0.2.7)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 03:24:48 +00:00
aa1b61cfb3 chore: 2.0.3 버전 범프
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:14:57 +09:00
7209486a71 fix(watcher): 한글 NFD 파일 압축 해제 시 동일 파일 반복 변환 race condition 수정
- inFlight Set으로 handlePath 진입 즉시 경로를 잠가 burst add 이벤트가
  몰려올 때 첫 번째 처리가 끝나기 전 두 번째 호출을 즉시 차단
- recentlyRenamed 키를 NFC 정규화로 통일하고 oldPath/newPath 양쪽 등록
  (chokidar가 NFD·NFC 변형 중 어느 쪽으로 이벤트를 보내도 TTL 가드 적용)
- chokidar awaitWriteFinish(stabilityThreshold 300ms) 추가로 압축 해제
  중 부분 쓰기 단계의 add 폭주를 상류에서 차단
- race condition 회귀 방지를 위한 watcher 단위 테스트 3개 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:06:46 +09:00
7889f8eb96 chore: 2.0.2 버전 범프
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 14:30:30 +09:00
8db9e64302 chore(deps): vite 5→6, electron-vite 2→3, vitest 1→4 업그레이드 — 나머지 보안 취약점 해결
Vite path traversal(CVE) 및 esbuild dev-server SSRF 취약점을 Vite 6.4.2,
esbuild 0.25 업그레이드로 해결한다. @vitejs/plugin-react 5.x, vitest 4.x 동반 업그레이드.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 11:41:00 +09:00
2d7d586be7 chore(deps): electron 35 → 39 업그레이드 — Dependabot 보안 취약점 17개 해결
use-after-free, AppleScript injection, IPC spoofing 등 high/medium/low
Electron 취약점을 모두 39.8.x 패치 버전으로 해결한다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 11:22:40 +09:00
17a7a617fd fix(web): custom domain 대응 — base '/' 변경 및 CNAME 추가
Vite base를 '/NFD2NFC/'에서 '/'로 변경하고 web/public/CNAME을 추가해
nfd2nfc.pieroot.xyz에서 자산 404 및 배포 후 custom domain 초기화 문제를 수정한다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 18:49:48 +09:00
6f44af3e4f fix(web): 드롭존 label에 display:block 추가 — 테두리 잘림 수정
label 요소는 기본 display:inline이라 content 너비만큼만 테두리가 그려짐.
display:block + width:100% 로 full-width 드롭존으로 수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:38:24 +09:00
51f99856cf fix(ci): configure-pages enablement: true 추가 — Pages 미활성화 시 자동 활성화
첫 배포 시 'Get Pages site failed. Not Found' 오류 수정.
actions/configure-pages@v5 의 enablement: true 옵션으로 repo Pages 설정이
없을 때 자동으로 GitHub Actions source 로 활성화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:03 +09:00
e967a0fbdb feat(web): GitHub Pages 기반 NFD→NFC 파일명 변환 웹앱 추가
설치 없이 브라우저에서 한글 파일명을 NFC 로 정규화할 수 있는 웹 도구.
파일/폴더 드롭 → 모든 path segment 를 독립적으로 NFC 변환 → fflate 스트리밍
ZIP 다운로드. UTF-8 flag(bit 11) 자동 설정으로 Windows Explorer 에서 정상 표시.

기존 src/core/filter.ts 의 shouldNormalize 를 그대로 재사용. 빌드는 web/
디렉토리의 별도 Vite 설정으로 격리되어 Electron 빌드에 영향 없음.

main 브랜치 push 시 .github/workflows/pages.yml 이
https://jung-geun.github.io/NFD2NFC/ 로 자동 배포.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:11:41 +09:00
aa772f7f97 Refactor code structure for improved readability and maintainability 2026-05-11 14:18:14 +09:00
8f2af73ed4 fix(ci): release 워크플로에 contents: write 권한 추가
DMG asset 업로드 시 softprops/action-gh-release가
"Resource not accessible by integration" 오류로 실패. 기본 GITHUB_TOKEN에
release 수정 권한이 없어 발생.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:04:47 +09:00
a469c666fe fix(build): electron-builder publish 비활성화 및 explicit 플래그 적용
- package.json: "publish": "never" → null (이전 값은 "never" 이름의
  publisher provider를 찾으려 해 v2.0.0/v2.0.1 release 빌드가 실패함)
- release.yml: electron-builder에 --publish never 플래그를 명시적으로
  전달해 git tag 기반 implicit publishing(v27에서 제거 예정) 경고 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:02:02 +09:00
4cbc044d15 perf(watcher): chokidar awaitWriteFinish 제거로 idle CPU 절감
100ms 주기 stat 폴링이 누적 CPU를 점유했음. 파일 이름만 다루는
정규화 도구이므로 안정화 대기가 불필요하며, recentlyRenamed TTL
맵이 이미 중복 이벤트를 막아준다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:48:28 +09:00
25 changed files with 2020 additions and 1128 deletions

49
.github/workflows/pages.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Deploy web to Pages
on:
push:
branches: [main]
paths:
- 'web/**'
- 'src/core/filter.ts'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/pages.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- run: npm ci
- run: npm run web:build
- uses: actions/configure-pages@v5
with:
enablement: true
- uses: actions/upload-pages-artifact@v3
with:
path: dist-web
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4

View File

@@ -4,6 +4,9 @@ on:
release: release:
types: [published] types: [published]
permissions:
contents: write
jobs: jobs:
build-and-release: build-and-release:
runs-on: macos-latest runs-on: macos-latest
@@ -21,7 +24,7 @@ jobs:
run: npm ci run: npm ci
- name: Build - name: Build
run: npm run build run: npm run build:cli && npm run build:lib && npm run build:app -- --publish never
env: env:
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"

2
.gitignore vendored
View File

@@ -142,6 +142,7 @@ out
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist dist
dist-web
# Gatsby files # Gatsby files
.cache/ .cache/
@@ -209,3 +210,4 @@ dist
.ionide .ionide
# End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node # End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node
.gstack/

View File

@@ -2,6 +2,8 @@
macOS에서 한글 파일명이 NFD(자모 분해)로 저장되는 문제를 실시간으로 해결하는 트레이 앱 + CLI + Node.js 라이브러리 통합 패키지입니다. macOS에서 한글 파일명이 NFD(자모 분해)로 저장되는 문제를 실시간으로 해결하는 트레이 앱 + CLI + Node.js 라이브러리 통합 패키지입니다.
> 🌐 **웹 버전**: Windows/Linux 사용자는 설치 없이 [https://jung-geun.github.io/NFD2NFC/](https://jung-geun.github.io/NFD2NFC/) 에 파일/폴더를 끌어다 놓으면 NFC 이름의 ZIP 으로 받을 수 있습니다.
> **Breaking Change (2.0.0)**: 단일 패키지 구조로 통합됐습니다. `MACOS-APP/`, `nfd2nfc/` 서브 패키지 구조는 제거됐습니다. CLI 명령이 `nfd2nfc file <path>` / `nfd2nfc dir <path>` 형태로 변경됐습니다. > **Breaking Change (2.0.0)**: 단일 패키지 구조로 통합됐습니다. `MACOS-APP/`, `nfd2nfc/` 서브 패키지 구조는 제거됐습니다. CLI 명령이 `nfd2nfc file <path>` / `nfd2nfc dir <path>` 형태로 변경됐습니다.
## 주요 기능 ## 주요 기능
@@ -37,14 +39,21 @@ npm install @pieroot/nfd2nfc
### macOS 애플리케이션 ### macOS 애플리케이션
<!-- TODO: 새 트레이 UI 스크린샷 교체 --> 앱 실행 후 메뉴바 아이콘을 좌클릭하면 팝오버가 표시됩니다. 감시 중인 디렉토리 목록과 최근 활동, Undo 버튼을 한눈에 확인할 수 있습니다.
![애플리케이션 화면](./assets/start-app.png)
앱 실행 후 메뉴바 아이콘을 클릭하면 팝오버가 표시됩니다. 팝오버에서 디렉토리를 추가하거나 설정창을 열 수 있습니다. ![트레이 팝오버](./assets/manubar.png)
![디렉토리 선택](./assets/select-directory.png) 메뉴바 아이콘을 우클릭하면 감시 일시 정지, 설정창 열기, 종료 메뉴가 나옵니다.
감시할 디렉토리를 선택합니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다. ![메뉴바 컨텍스트 메뉴](./assets/menubar-setting.png)
설정창의 **디렉토리** 탭에서 감시할 디렉토리를 추가하고, 디렉토리별로 감시 활성화 여부 / Auto·Manual 모드 / 하위 폴더 포함 / 추가 필터 범위를 개별 설정할 수 있습니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
![디렉토리 설정](./assets/setting-directory.png)
**일반** 탭에서는 기본 변환 모드, macOS 알림 활성화, 알림 인터벌(초)을 설정합니다.
![일반 설정](./assets/setting-general.png)
### CLI ### CLI

BIN
assets/manubar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/menubar-setting.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
assets/setting-general.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

1942
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@pieroot/nfd2nfc", "name": "@pieroot/nfd2nfc",
"version": "2.0.0", "version": "2.0.3",
"description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리", "description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리",
"main": "out/lib/index.js", "main": "out/lib/index.js",
"types": "out/lib/index.d.ts", "types": "out/lib/index.d.ts",
@@ -29,6 +29,9 @@
"build": "npm run build:cli && npm run build:lib && npm run build:app", "build": "npm run build:cli && npm run build:lib && npm run build:app",
"prepublishOnly": "npm run build:cli && npm run build:lib && chmod +x out/cli/index.js", "prepublishOnly": "npm run build:cli && npm run build:lib && chmod +x out/cli/index.js",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"web:dev": "vite --config web/vite.config.ts",
"web:build": "vite build --config web/vite.config.ts",
"web:preview": "vite preview --config web/vite.config.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "eslint src --ext .ts,.tsx", "lint": "eslint src --ext .ts,.tsx",
@@ -36,6 +39,7 @@
}, },
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"fflate": "^0.8.2",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
@@ -45,17 +49,17 @@
"@types/yargs": "^17.0.32", "@types/yargs": "^17.0.32",
"@typescript-eslint/eslint-plugin": "^7.7.0", "@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0", "@typescript-eslint/parser": "^7.7.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.1.4",
"electron": "^35.1.5", "electron": "^39.8.5",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^5.0.0", "electron-vite": "^3.1.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"typescript": "^5.4.0", "typescript": "^5.4.0",
"vite": "^8.0.11", "vite": "^6.4.2",
"vitest": "^4.1.5" "vitest": "^4.1.6"
}, },
"author": "jung-geun <pieroot.02@gmail.com>", "author": "jung-geun <pieroot.02@gmail.com>",
"license": "MIT", "license": "MIT",
@@ -96,7 +100,10 @@
"target": [ "target": [
{ {
"target": "dmg", "target": "dmg",
"arch": ["arm64", "x64"] "arch": [
"arm64",
"x64"
]
} }
] ]
}, },
@@ -104,6 +111,6 @@
"buildResources": "resources", "buildResources": "resources",
"output": "dist" "output": "dist"
}, },
"publish": "never" "publish": null
} }
} }

View File

@@ -0,0 +1,103 @@
import { EventEmitter } from 'events';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
vi.mock('chokidar');
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }));
const mockAppendUndoEntries = vi.fn().mockResolvedValue(undefined);
vi.mock('../store', () => ({ appendUndoEntries: mockAppendUndoEntries }));
const mockQueueRenamedNotification = vi.fn().mockResolvedValue(undefined);
vi.mock('../notifier', () => ({
queueRenamedNotification: mockQueueRenamedNotification,
notifyManualQueue: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../nanoid', () => ({ nanoid: () => 'test-id' }));
const mockNormalizeEntry = vi.fn();
vi.mock('../../core/normalizer', () => ({ normalizeEntry: mockNormalizeEntry }));
import chokidar from 'chokidar';
import type { WatchedDir } from '../../core/types';
// 테스트마다 고유 경로를 사용해 모듈 레벨 recentlyRenamed 상태 충돌 방지
function makePaths(slug: string) {
const nfc = `/test/watch/${slug}.txt`;
return { nfc, nfd: nfc.normalize('NFD') };
}
function makeDir(id: string): WatchedDir {
return { id, path: '/test/watch', recursive: false, mode: 'auto', enabled: true, customRanges: [] };
}
describe('watcher — in-flight race condition', () => {
let mockWatcher: EventEmitter & { close: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockWatcher = Object.assign(new EventEmitter(), { close: vi.fn().mockResolvedValue(undefined) });
vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as unknown as ReturnType<typeof chokidar.watch>);
});
afterEach(async () => {
const { stopDir } = await import('../watcher');
// 각 테스트에서 사용한 id 정리
for (const id of ['dir-burst', 'dir-nfc-reentry', 'dir-nfd-reentry']) {
await stopDir(id);
}
});
it('동일 NFD 경로에 burst add 이벤트가 와도 normalizeEntry가 1번만 호출된다', async () => {
const { nfc, nfd } = makePaths('강의자료-burst');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-burst'));
// 같은 경로를 동기적으로 두 번 emit — race 시나리오 재현
mockWatcher.emit('add', nfd);
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
expect(mockAppendUndoEntries).toHaveBeenCalledTimes(1);
});
it('rename 완료 후 NFC 경로로 다시 add 이벤트가 와도 recentlyRenamed 가드에 막힌다', async () => {
const { nfc, nfd } = makePaths('강의자료-nfc-reentry');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-nfc-reentry'));
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// RENAME_TTL(2000ms) 이내에 NFC 경로로 재진입 — 가드에 막혀야 함
mockWatcher.emit('add', nfc);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).not.toHaveBeenCalled();
});
it('rename 완료 후 NFD 경로로 재진입해도 recentlyRenamed 가드에 막힌다', async () => {
const { nfc, nfd } = makePaths('강의자료-nfd-reentry');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-nfd-reentry'));
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).not.toHaveBeenCalled();
});
});

View File

@@ -12,6 +12,7 @@ type ActivityListener = (event: ActivityEvent) => void;
const watchers = new Map<string, FSWatcher>(); const watchers = new Map<string, FSWatcher>();
const recentlyRenamed = new Map<string, number>(); const recentlyRenamed = new Map<string, number>();
const inFlight = new Set<string>();
const RENAME_TTL = 2000; const RENAME_TTL = 2000;
const listeners = new Set<ActivityListener>(); const listeners = new Set<ActivityListener>();
const manualQueue = new Map<string, string[]>(); const manualQueue = new Map<string, string[]>();
@@ -50,41 +51,52 @@ export async function startDir(dir: WatchedDir): Promise<void> {
ignoreInitial: false, ignoreInitial: false,
persistent: true, persistent: true,
depth: dir.recursive ? undefined : 0, depth: dir.recursive ? undefined : 0,
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
ignored: /(^|[/\\])\../, ignored: /(^|[/\\])\../,
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
}); });
async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> { async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> {
const now = Date.now(); const now = Date.now();
const lastRenamed = recentlyRenamed.get(filePath); const key = filePath.normalize('NFC');
// 동일 경로가 이미 처리 중이면 즉시 return (burst add 이벤트로 인한 race 차단)
if (inFlight.has(key)) return;
const lastRenamed = recentlyRenamed.get(key);
if (lastRenamed && now - lastRenamed < RENAME_TTL) return; if (lastRenamed && now - lastRenamed < RENAME_TTL) return;
const basename = filePath.split('/').pop() ?? ''; const basename = filePath.split('/').pop() ?? '';
if (!shouldNormalize(basename, filterOpts)) return; if (!shouldNormalize(basename, filterOpts)) return;
if (dir.mode === 'auto') { inFlight.add(key);
const result = await normalizeEntry(filePath, type, filterOpts); try {
if (result.status === 'renamed' || result.status === 'noop-same-inode') { if (dir.mode === 'auto') {
recentlyRenamed.set(result.newPath, Date.now()); const result = await normalizeEntry(filePath, type, filterOpts);
if (result.status === 'renamed') { if (result.status === 'renamed' || result.status === 'noop-same-inode') {
await store.appendUndoEntries([ // oldPath와 newPath 모두 등록 — chokidar가 NFD/NFC 변형 중 어느 쪽으로 이벤트를 보내도 막힘
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false }, recentlyRenamed.set(result.oldPath.normalize('NFC'), Date.now());
]); recentlyRenamed.set(result.newPath.normalize('NFC'), Date.now());
if (result.status === 'renamed') {
await store.appendUndoEntries([
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
]);
}
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath}${result.newPath}`, result });
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송
await notifier.queueRenamedNotification(dir.path);
} else if (result.status === 'collision') {
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result });
} }
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath}${result.newPath}`, result }); } else {
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송 const queue = manualQueue.get(dir.id) ?? [];
await notifier.queueRenamedNotification(dir.path); if (!queue.includes(filePath)) {
} else if (result.status === 'collision') { queue.push(filePath);
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result }); manualQueue.set(dir.id, queue);
emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` });
}
await notifier.notifyManualQueue(queue.length, dir.path);
} }
} else { } finally {
const queue = manualQueue.get(dir.id) ?? []; inFlight.delete(key);
if (!queue.includes(filePath)) {
queue.push(filePath);
manualQueue.set(dir.id, queue);
emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` });
}
await notifier.notifyManualQueue(queue.length, dir.path);
} }
} }
@@ -148,7 +160,8 @@ export async function applyManualQueue(dirId: string, dir: WatchedDir): Promise<
const result = await normalizeEntry(filePath, type, filterOpts); const result = await normalizeEntry(filePath, type, filterOpts);
results.push(result); results.push(result);
if (result.status === 'renamed') { if (result.status === 'renamed') {
recentlyRenamed.set(result.newPath, Date.now()); recentlyRenamed.set(result.oldPath.normalize('NFC'), Date.now());
recentlyRenamed.set(result.newPath.normalize('NFC'), Date.now());
undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false }); undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false });
} }
} catch (err) { } catch (err) {

View File

@@ -11,5 +11,5 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"baseUrl": "." "baseUrl": "."
}, },
"exclude": ["node_modules", "out", "dist", "src/renderer/**"] "exclude": ["node_modules", "out", "dist", "dist-web", "src/renderer/**", "web/**"]
} }

16
web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="macOS에서 NFD로 깨진 한글 파일명을 NFC로 변환하는 웹 도구. 설치 없이 브라우저에서 바로 사용."
/>
<title>NFD → NFC 파일명 변환기</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1
web/public/CNAME Normal file
View File

@@ -0,0 +1 @@
nfd2nfc.pieroot.xyz

349
web/src/App.tsx Normal file
View File

@@ -0,0 +1,349 @@
import { useCallback, useReducer, type DragEvent } from 'react';
import {
collectFromDataTransfer,
collectFromFileList,
supportsDirectoryDrop,
type CollectResult,
} from './lib/collectFiles';
import { buildZip, downloadBlob, type BuildProgress } from './lib/buildZip';
type State =
| { kind: 'idle'; dragActive: boolean; error?: string }
| { kind: 'reading' }
| {
kind: 'processing';
collected: CollectResult;
progress: BuildProgress;
}
| {
kind: 'ready';
collected: CollectResult;
blob?: Blob;
filename: string;
};
type Action =
| { type: 'drag'; active: boolean }
| { type: 'reading' }
| { type: 'error'; message: string }
| { type: 'processing-start'; collected: CollectResult }
| { type: 'progress'; progress: BuildProgress }
| { type: 'ready'; collected: CollectResult; blob: Blob; filename: string }
| { type: 'ready-empty'; collected: CollectResult; filename: string }
| { type: 'reset' };
const initial: State = { kind: 'idle', dragActive: false };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'drag':
if (state.kind !== 'idle') return state;
return { ...state, dragActive: action.active };
case 'reading':
return { kind: 'reading' };
case 'error':
return { kind: 'idle', dragActive: false, error: action.message };
case 'processing-start':
return {
kind: 'processing',
collected: action.collected,
progress: { done: 0, total: action.collected.files.length },
};
case 'progress':
if (state.kind !== 'processing') return state;
return { ...state, progress: action.progress };
case 'ready':
return {
kind: 'ready',
collected: action.collected,
blob: action.blob,
filename: action.filename,
};
case 'ready-empty':
return {
kind: 'ready',
collected: action.collected,
filename: action.filename,
};
case 'reset':
return initial;
}
}
function timestamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(
d.getMinutes(),
)}${pad(d.getSeconds())}`;
}
export function App() {
const [state, dispatch] = useReducer(reducer, initial);
const process = useCallback(async (collected: CollectResult) => {
const filename = `nfc-${timestamp()}.zip`;
if (collected.files.length === 0) {
dispatch({ type: 'ready-empty', collected, filename });
return;
}
dispatch({ type: 'processing-start', collected });
try {
const blob = await buildZip(collected.files, (progress) =>
dispatch({ type: 'progress', progress }),
);
dispatch({ type: 'ready', collected, blob, filename });
} catch (e) {
dispatch({
type: 'error',
message: e instanceof Error ? e.message : 'ZIP 생성 중 알 수 없는 오류가 발생했습니다.',
});
}
}, []);
const handleDrop = useCallback(
async (ev: DragEvent<HTMLLabelElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (state.kind !== 'idle') return;
if (!supportsDirectoryDrop(ev.dataTransfer.items)) {
dispatch({
type: 'error',
message:
'이 브라우저는 폴더 드롭을 완전히 지원하지 않습니다. 최신 Chrome / Edge / Safari 16.4+ 를 사용해주세요.',
});
return;
}
dispatch({ type: 'reading' });
try {
const collected = await collectFromDataTransfer(ev.dataTransfer);
await process(collected);
} catch (e) {
dispatch({
type: 'error',
message: e instanceof Error ? e.message : '파일을 읽는 중 오류가 발생했습니다.',
});
}
},
[process, state.kind],
);
const handleFileInput = useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const fl = ev.target.files;
if (!fl || fl.length === 0) return;
dispatch({ type: 'reading' });
try {
const collected = collectFromFileList(fl);
await process(collected);
} catch (e) {
dispatch({
type: 'error',
message: e instanceof Error ? e.message : '파일을 읽는 중 오류가 발생했습니다.',
});
} finally {
// 같은 파일을 다시 선택할 수 있게 input 초기화.
ev.target.value = '';
}
},
[process],
);
const onDragOver = (ev: DragEvent<HTMLLabelElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (state.kind === 'idle') dispatch({ type: 'drag', active: true });
};
const onDragLeave = (ev: DragEvent<HTMLLabelElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (state.kind === 'idle') dispatch({ type: 'drag', active: false });
};
return (
<div className="app">
<header>
<h1>NFD NFC </h1>
<p>
macOS ZIP Windows / Linux
. NFC ZIP
. .
</p>
</header>
<main>
{state.kind === 'idle' && (
<IdleView
dragActive={state.dragActive}
error={state.error}
onDrop={handleDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onFileInput={handleFileInput}
/>
)}
{state.kind === 'reading' && <ReadingView />}
{state.kind === 'processing' && (
<ProcessingView collected={state.collected} progress={state.progress} />
)}
{state.kind === 'ready' && (
<ReadyView
collected={state.collected}
blob={state.blob}
filename={state.filename}
onReset={() => dispatch({ type: 'reset' })}
/>
)}
</main>
<footer>
<p>
. ( 2GB )
.
</p>
</footer>
</div>
);
}
function IdleView(props: {
dragActive: boolean;
error?: string;
onDrop: (ev: DragEvent<HTMLLabelElement>) => void;
onDragOver: (ev: DragEvent<HTMLLabelElement>) => void;
onDragLeave: (ev: DragEvent<HTMLLabelElement>) => void;
onFileInput: (ev: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<>
<label
className={'dropzone' + (props.dragActive ? ' active' : '')}
onDrop={props.onDrop}
onDragOver={props.onDragOver}
onDragLeave={props.onDragLeave}
>
<h2> </h2>
<p>
.
<br />
NFC ZIP .
</p>
<p className="hint"> .</p>
<input
type="file"
/* @ts-expect-error — webkitdirectory 는 React 타입 정의가 누락된 비표준 속성. */
webkitdirectory=""
directory=""
multiple
onChange={props.onFileInput}
/>
</label>
{props.error && <div className="error">{props.error}</div>}
</>
);
}
function ReadingView() {
return (
<div className="status">
<h2> </h2>
<p className="progress-label"> .</p>
</div>
);
}
function ProcessingView(props: {
collected: CollectResult;
progress: BuildProgress;
}) {
const pct =
props.progress.total === 0 ? 0 : Math.round((props.progress.done / props.progress.total) * 100);
return (
<div className="status">
<h2>ZIP </h2>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div className="progress-label">
{props.progress.done.toLocaleString()} / {props.progress.total.toLocaleString()}
{' · '}
{pct}%
</div>
<div className="stats">
<span>
<strong>{props.collected.normalizedCount.toLocaleString()}</strong>
</span>
<span>
<strong>{props.collected.passthroughCount.toLocaleString()}</strong>
</span>
</div>
</div>
);
}
function ReadyView(props: {
collected: CollectResult;
blob?: Blob;
filename: string;
onReset: () => void;
}) {
const { collected, blob, filename, onReset } = props;
const hasFiles = collected.files.length > 0;
const hasNormalizable = collected.normalizedCount > 0;
return (
<div className="status">
<h2>
{hasNormalizable ? '완료되었습니다.' : '변환할 파일이 없습니다.'}
</h2>
<div className="stats">
<span>
<strong>{collected.normalizedCount.toLocaleString()}</strong>
</span>
<span>
<strong>{collected.passthroughCount.toLocaleString()}</strong>
</span>
</div>
{!hasFiles && (
<div className="warn">
.
.
</div>
)}
{hasFiles && !hasNormalizable && (
<div className="success">
NFC . .
</div>
)}
{collected.collisions.length > 0 && (
<div className="warn">
{collected.collisions.length} <code>-2</code>,
<code>-3</code> ... .
</div>
)}
<div className="actions">
{blob && hasFiles && (
<button className="primary" onClick={() => downloadBlob(blob, filename)}>
ZIP ({formatSize(blob.size)})
</button>
)}
<button className="link" onClick={onReset}>
</button>
</div>
</div>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}

94
web/src/lib/buildZip.ts Normal file
View File

@@ -0,0 +1,94 @@
import { Zip, ZipPassThrough } from 'fflate';
import type { CollectedFile } from './collectFiles';
export interface BuildProgress {
/** 완료된 파일 수. */
done: number;
/** 전체 파일 수. */
total: number;
}
/**
* NFC 경로로 정규화된 파일 목록을 스트리밍 방식으로 ZIP 으로 묶는다.
*
* - fflate 의 Zip + ZipPassThrough 사용. ZipPassThrough 는 deflate 없이 STORE 모드로 묶기 때문에
* 사진/영상처럼 이미 압축된 파일이 많은 일반 케이스에서 CPU 를 낭비하지 않는다.
* - 파일명은 JS 문자열로 그대로 넘긴다. fflate 는 non-ASCII 가 포함되면 general purpose bit 11
* (UTF-8 flag)을 자동으로 켜기 때문에 Windows Explorer 가 CP949 로 오디코딩하지 않는다.
* - 한 번에 하나씩 파이프해서 다중 entry 가 동시에 push 되지 않도록 한다 (Zip 은 순차 처리 필요).
*/
export async function buildZip(
files: CollectedFile[],
onProgress?: (p: BuildProgress) => void,
): Promise<Blob> {
const chunks: Uint8Array[] = [];
const zipDone = new Promise<void>((resolve, reject) => {
const zip = new Zip((err, data, final) => {
if (err) {
reject(err);
return;
}
if (data && data.length) chunks.push(data);
if (final) resolve();
});
void (async () => {
try {
let done = 0;
for (const f of files) {
const entry = new ZipPassThrough(f.zipPath);
zip.add(entry);
await pipeFileToEntry(f.file, entry);
done++;
onProgress?.({ done, total: files.length });
}
zip.end();
} catch (e) {
reject(e);
}
})();
});
await zipDone;
// Uint8Array<ArrayBufferLike> 의 TypeScript 5.7+ 변경 때문에 BlobPart 직접 매칭이 실패할 수 있어 캐스팅.
return new Blob(chunks as BlobPart[], { type: 'application/zip' });
}
async function pipeFileToEntry(file: File, entry: ZipPassThrough): Promise<void> {
// 일부 브라우저(특히 구버전) 에서 Blob.stream() 이 없을 수 있어 fallback 으로 arrayBuffer 사용.
if (typeof file.stream !== 'function') {
const buf = new Uint8Array(await file.arrayBuffer());
entry.push(buf, true);
return;
}
const reader = file.stream().getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
entry.push(new Uint8Array(0), true);
return;
}
if (value && value.length) entry.push(value, false);
}
} finally {
reader.releaseLock();
}
}
/**
* 다운로드 트리거. URL.revokeObjectURL 로 메모리 해제까지 처리.
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// 같은 microtask 에서 revoke 하면 일부 브라우저에서 다운로드가 취소될 수 있어 약간 지연.
setTimeout(() => URL.revokeObjectURL(url), 1_000);
}

164
web/src/lib/collectFiles.ts Normal file
View File

@@ -0,0 +1,164 @@
import { normalizeToNFC, shouldNormalize } from './normalize';
export interface CollectedFile {
/** ZIP 내부에서 사용할 NFC 정규화된 상대 경로 (POSIX, 디렉토리 segment 포함). */
zipPath: string;
/** 원본 상대 경로 (디버그/통계용). */
originalPath: string;
/** 실제 파일 핸들. */
file: File;
/** 경로 segment 중 하나 이상이 정규화되었는지. */
normalized: boolean;
}
export interface CollectResult {
files: CollectedFile[];
normalizedCount: number;
passthroughCount: number;
collisions: string[];
}
/**
* webkitGetAsEntry 가 동작하는지 확인. Safari < 16.4 등에서 folder drop 시 null 반환.
*/
export function supportsDirectoryDrop(items: DataTransferItemList): boolean {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== 'file') continue;
if (typeof item.webkitGetAsEntry !== 'function') return false;
}
return true;
}
/**
* DataTransfer 에서 파일과 폴더를 모두 수집한다.
* - 폴더는 webkitGetAsEntry 로 재귀 traversal.
* - DirectoryReader.readEntries 는 Chromium 에서 호출당 최대 100 개만 반환 → 빈 배열 나올 때까지 루프.
* - 각 path segment 를 독립적으로 NFC 정규화.
* - 정규화 후 경로 충돌 시 -2, -3 ... suffix 로 회피.
*/
export async function collectFromDataTransfer(dt: DataTransfer): Promise<CollectResult> {
const items = dt.items;
const entries: FileSystemEntry[] = [];
// file objects collected when item.webkitGetAsEntry() returns null (드물게 발생).
const looseFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== 'file') continue;
const entry =
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (entry) {
entries.push(entry);
} else {
const f = item.getAsFile();
if (f) looseFiles.push(f);
}
}
const flat: { path: string; file: File }[] = [];
for (const e of entries) {
await walkEntry(e, '', flat);
}
for (const f of looseFiles) {
flat.push({ path: f.name, file: f });
}
return finalize(flat);
}
/**
* `<input type="file" webkitdirectory>` 또는 일반 input 에서 수집.
*/
export function collectFromFileList(files: FileList): CollectResult {
const flat: { path: string; file: File }[] = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
// webkitRelativePath 가 비어있으면 단일 파일 선택.
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
flat.push({ path: rel, file: f });
}
return finalize(flat);
}
async function walkEntry(
entry: FileSystemEntry,
prefix: string,
out: { path: string; file: File }[],
): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject),
);
out.push({ path: prefix + entry.name, file });
return;
}
if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries 페이지네이션: 빈 배열 반환할 때까지 반복.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject),
);
if (batch.length === 0) break;
for (const child of batch) {
await walkEntry(child, prefix + entry.name + '/', out);
}
}
}
}
function finalize(flat: { path: string; file: File }[]): CollectResult {
const seen = new Set<string>();
const collisions: string[] = [];
const collected: CollectedFile[] = [];
let normalizedCount = 0;
let passthroughCount = 0;
for (const { path, file } of flat) {
const segments = path.split('/');
let normalized = false;
const nfcSegments = segments.map((seg) => {
if (shouldNormalize(seg)) {
normalized = true;
return normalizeToNFC(seg);
}
return seg;
});
if (normalized) normalizedCount++;
else passthroughCount++;
let nfcPath = nfcSegments.join('/');
if (seen.has(nfcPath)) {
collisions.push(nfcPath);
nfcPath = disambiguate(nfcPath, seen);
}
seen.add(nfcPath);
collected.push({
zipPath: nfcPath,
originalPath: path,
file,
normalized,
});
}
return {
files: collected,
normalizedCount,
passthroughCount,
collisions,
};
}
function disambiguate(path: string, seen: Set<string>): string {
const dot = path.lastIndexOf('.');
const slash = path.lastIndexOf('/');
const hasExt = dot > slash; // 디렉토리 안의 dot 만 확장자로 인식
const stem = hasExt ? path.slice(0, dot) : path;
const ext = hasExt ? path.slice(dot) : '';
let n = 2;
while (seen.has(`${stem}-${n}${ext}`)) n++;
return `${stem}-${n}${ext}`;
}

6
web/src/lib/normalize.ts Normal file
View File

@@ -0,0 +1,6 @@
// 브라우저 환경 전용 정규화 헬퍼.
// src/core/filter.ts 는 fs 의존성이 없는 순수 모듈이므로 그대로 import.
// src/lib/index.ts 는 normalizer/scanner(fs 사용)를 re-export 하므로 import 하지 않는다.
export { shouldNormalize } from '@core/core/filter';
export const normalizeToNFC = (s: string): string => s.normalize('NFC');

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

259
web/src/styles.css Normal file
View File

@@ -0,0 +1,259 @@
:root {
color-scheme: light dark;
--bg: #fafafa;
--fg: #1a1a1a;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #2563eb;
--accent-fg: #ffffff;
--warn-bg: #fef3c7;
--warn-fg: #92400e;
--success-bg: #d1fae5;
--success-fg: #065f46;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--fg: #f3f4f6;
--muted: #9ca3af;
--border: #2a2f3a;
--accent: #3b82f6;
--accent-fg: #ffffff;
--warn-bg: #422006;
--warn-fg: #fcd34d;
--success-bg: #064e3b;
--success-fg: #a7f3d0;
}
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', Roboto, 'Helvetica Neue',
'Malgun Gothic', sans-serif;
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
}
.app {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px 32px;
}
header {
width: 100%;
max-width: 720px;
margin-bottom: 32px;
}
header h1 {
margin: 0 0 8px;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
header p {
margin: 0;
color: var(--muted);
font-size: 15px;
line-height: 1.6;
}
main {
width: 100%;
max-width: 720px;
flex: 1;
}
.dropzone {
display: block;
width: 100%;
border: 2px dashed var(--border);
border-radius: 16px;
padding: 64px 24px;
text-align: center;
background: transparent;
transition:
border-color 120ms,
background 120ms;
cursor: pointer;
}
.dropzone:hover,
.dropzone.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.dropzone.busy {
cursor: progress;
opacity: 0.7;
pointer-events: none;
}
.dropzone h2 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
}
.dropzone p {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.dropzone .hint {
margin-top: 16px;
font-size: 12px;
color: var(--muted);
}
.dropzone input[type='file'] {
display: none;
}
.status {
margin-top: 24px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--fg) 3%, transparent);
}
.status h2 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
}
.status .progress-track {
height: 8px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
margin: 12px 0 8px;
}
.status .progress-fill {
height: 100%;
background: var(--accent);
transition: width 120ms ease-out;
}
.status .progress-label {
font-size: 13px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-top: 16px;
}
button.primary {
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: 10px;
padding: 12px 20px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
button.primary:hover {
filter: brightness(1.05);
}
button.link {
background: none;
border: none;
color: var(--accent);
font-size: 14px;
cursor: pointer;
padding: 8px 4px;
}
button.link:hover {
text-decoration: underline;
}
.warn,
.success,
.error {
margin-top: 12px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
}
.warn {
background: var(--warn-bg);
color: var(--warn-fg);
}
.success {
background: var(--success-bg);
color: var(--success-fg);
}
.error {
background: #fee2e2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.error {
background: #450a0a;
color: #fca5a5;
}
}
footer {
margin-top: 48px;
font-size: 12px;
color: var(--muted);
text-align: center;
line-height: 1.6;
}
footer a {
color: var(--muted);
}
.stats {
display: flex;
gap: 24px;
margin-top: 8px;
font-size: 14px;
color: var(--muted);
}
.stats strong {
color: var(--fg);
font-weight: 600;
margin-right: 4px;
}

22
web/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@core/*": ["../src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*", "../src/core/filter.ts"]
}

19
web/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
root: __dirname,
base: '/',
plugins: [react()],
build: {
outDir: resolve(__dirname, '../dist-web'),
emptyOutDir: true,
target: 'es2022',
},
resolve: {
alias: {
'@core': resolve(__dirname, '../src'),
},
},
});