diff --git a/docs/deploy.md b/docs/deploy.md index 9ef62f9..da65f07 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,7 +1,7 @@ # 배포 가이드 ## 현재 버전 -- `v0.1.30` +- `v0.1.31` ## Git 기본 설정 - 저장소 작성자 정보는 아래 값으로 통일한다. @@ -45,6 +45,16 @@ npm install npm run dev ``` +## 로컬 실시간 확인 +```bash +npm run dev:ghost:start +npm run dev:watch +``` + +- `npm run dev:ghost:start`는 로컬 Ghost 컨테이너를 시작한다. +- `npm run dev:watch`는 초기 빌드와 sync를 한 번 수행한 뒤, Tailwind watch와 파일 변경 감지 기반 theme sync를 계속 유지한다. +- 템플릿이나 자산을 저장한 뒤 브라우저 새로고침만으로 바로 반영 상태를 확인할 수 있다. + ## 로컬 스타일 빌드 ```bash npm run build:alpine diff --git a/docs/history.md b/docs/history.md index 14fee09..1dfe3b2 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,8 @@ # 의사결정 이력 +## 2026-04-14 v0.1.31 +zip 업로드 없이 로컬 Ghost에서 바로 확인하려면 자동 sync가 필요했지만, `fs.watch` 기반 재귀 watcher는 현재 환경에서 `EMFILE` 오류로 안정적으로 유지되지 않았다. 그래서 별도 의존성을 추가하지 않고, 제외 디렉터리를 뺀 파일 목록의 수정 시간을 주기적으로 비교하는 polling 방식으로 `dev:watch`를 구현했다. 이 방식은 다소 단순하지만 현재 저장소 크기에서는 충분히 가볍고, 사용자가 템플릿을 저장한 뒤 바로 브라우저 새로고침으로 확인할 수 있다는 점이 더 중요하다고 판단했다. + ## 2026-04-14 v0.1.30 `prose`는 Tailwind 기본 유틸리티가 아니라 `@tailwindcss/typography` 플러그인 영역인데, 현재 저장소에는 해당 플러그인이 들어있지 않았다. 이번 단계에서는 사용자가 이미 수정한 Tailwind 마크업을 최대한 유지하는 것이 우선이었기 때문에, 의존성을 새로 늘리기보다 `assets/styles/tailwind.css`의 `@layer components`에 원본 소스 기준 타이포그래피 규칙을 직접 옮겨 적용했다. 동시에 `bg-accent/10`처럼 현재 빌드에서 누락되던 유틸리티도 같은 방식으로 보강해 카드 태그 배경과 리스트 구분선이 다시 보이도록 정리했다. diff --git a/docs/map.md b/docs/map.md index 217c021..13939e6 100644 --- a/docs/map.md +++ b/docs/map.md @@ -1,7 +1,7 @@ # 파일-화면 매핑 가이드 ## 현재 버전 -- `v0.1.30` +- `v0.1.31` ## 공통 레이아웃 - [default.hbs](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/default.hbs): 전체 3열 셸과 공통 자산 로드 @@ -34,5 +34,6 @@ - [assets/built/theme.js](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/assets/built/theme.js): 인터랙션 스크립트 - [assets/styles/tailwind.css](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/assets/styles/tailwind.css): Tailwind 입력 파일, `prose` 타이포그래피 규칙, accent/구분선 보조 유틸리티 - [tailwind.config.js](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/tailwind.config.js): Tailwind 스캔 경로, 테마 설정, preflight 초기화 설정 +- [scripts/dev-watch.js](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/scripts/dev-watch.js): 로컬 파일 변경 감지 후 `dev:sync`와 Tailwind watch를 함께 실행하는 개발용 watcher - [routes.yaml.example](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/routes.yaml.example): Ghost 커스텀 라우트 예시 - [package.json](/Users/bicute/Desktop/UGREEN/GHOST%20THEME/package.json): Ghost 테마 메타데이터 diff --git a/docs/spec.md b/docs/spec.md index 99cbfc3..536af0b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1,7 +1,7 @@ # 기술 명세 ## 현재 버전 -- `v0.1.30` +- `v0.1.31` ## 테마 개요 - Ghost `v5` 대응 커스텀 테마 @@ -16,6 +16,7 @@ - Tailwind CSS 빌드 결과물(`assets/built/tailwind.css`)을 기존 `screen.css`와 함께 로드 - Tailwind 기본 초기화(`preflight`)를 활성화해 브라우저 기본 마진과 폼 스타일을 리셋 - Alpine.js 로컬 자산(`assets/built/alpine.js`)을 전역 로드 +- `npm run dev:watch`는 초기 `dev:prepare` 실행 후 Tailwind `--watch`와 파일 변경 감지 기반 `dev:sync`를 함께 실행함 - 좌측 카테고리 영역은 Alpine.js로 제어되며 `1024px` 이상에서 기본 열림, 미만에서 기본 닫힘 - 좌측 네비게이션 마커와 카테고리 마커는 동일한 세로 바 → 원형 hover 패턴 사용 - 전역 `ol`, `ul`, `menu` 기본 패딩과 리스트 스타일 리셋 적용 diff --git a/docs/update.md b/docs/update.md index b6cf94c..e34157f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## v0.1.31 - 2026-04-14 +- `npm run dev:watch` 스크립트 추가. +- 로컬 파일 변경 감지 후 자동 `dev:sync` 반영 흐름 추가. +- Tailwind `--watch`와 테마 sync를 함께 사용하는 로컬 확인 루프 정리. + ## v0.1.30 - 2026-04-14 - `prose` 본문 타이포그래피 스타일 추가. - `ul`, `ol`, `blockquote`, `code`, `table` 본문 표현 복구. diff --git a/package.json b/package.json index c221410..ec69f91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghost-theme-thred-clone", - "version": "0.1.30", + "version": "0.1.31", "private": true, "description": "A Ghost theme inspired by the Thred reference layout.", "keywords": [ @@ -79,6 +79,7 @@ "build:tailwind": "tailwindcss -c ./tailwind.config.js -i ./assets/styles/tailwind.css -o ./assets/built/tailwind.css --minify", "dev": "npm run dev:ghost:start", "dev:prepare": "npm run build:alpine && npm run build:tailwind && npm run dev:sync", + "dev:watch": "node ./scripts/dev-watch.js", "dev:sync": "sh ./scripts/sync-theme.sh", "dev:seed": "node ./scripts/build-sample-content.js", "dev:seed:zip": "npm run dev:seed && cd seed && zip -q -r thred-inspired-sample-content.ghost.zip thred-inspired-sample-content.ghost.json", diff --git a/scripts/dev-watch.js b/scripts/dev-watch.js new file mode 100644 index 0000000..7a0f15c --- /dev/null +++ b/scripts/dev-watch.js @@ -0,0 +1,211 @@ +const fs = require('fs') +const path = require('path') +const { spawn } = require('child_process') + +const rootDir = path.resolve(__dirname, '..') +const ignoredDirectories = new Set([ + '.git', + '.cursor', + '.vscode', + '.docker', + 'docs', + 'local-ghost', + 'node_modules', + 'seed', + 'theme-export' +]) +const ignoredFiles = new Set([ + '.DS_Store' +]) +const syncExtensions = new Set([ + '.css', + '.hbs', + '.js', + '.json', + '.svg', + '.yaml', + '.yml' +]) +const watchedRoots = [rootDir] + +let syncTimer = null +let syncProcess = null +let scanTimer = null +let previousSnapshot = new Map() + +/** + * @param {string} relativePath + */ +function shouldIgnorePath(relativePath) { + if (!relativePath || relativePath === '.') { + return false + } + + const segments = relativePath.split(path.sep) + return segments.some((segment) => ignoredDirectories.has(segment) || ignoredFiles.has(segment)) +} + +/** + * @param {string} relativePath + */ +function shouldSyncFile(relativePath) { + if (shouldIgnorePath(relativePath)) { + return false + } + + if (relativePath === path.join('assets', 'styles', 'tailwind.css')) { + return false + } + + const extension = path.extname(relativePath) + return syncExtensions.has(extension) +} + +/** + * @param {string} command + * @param {string[]} args + * @param {(code: number | null) => void} [onExit] + */ +function runCommand(command, args, onExit) { + const child = spawn(command, args, { + cwd: rootDir, + stdio: 'inherit', + shell: false + }) + + child.on('exit', (code) => { + if (onExit) { + onExit(code) + } + }) + + return child +} + +function queueSync() { + if (syncTimer) { + clearTimeout(syncTimer) + } + + syncTimer = setTimeout(() => { + if (syncProcess) { + queueSync() + return + } + + console.log('[dev:watch] theme sync') + syncProcess = runCommand('npm', ['run', 'dev:sync'], (code) => { + syncProcess = null + + if (code !== 0) { + console.error(`[dev:watch] sync failed with code ${code}`) + } + }) + }, 180) +} + +/** + * @param {string} directoryPath + * @param {Map} snapshot + */ +function collectSnapshot(directoryPath, snapshot) { + if (!fs.existsSync(directoryPath)) { + return + } + + for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) { + const entryPath = path.join(directoryPath, entry.name) + const relativePath = path.relative(rootDir, entryPath) + + if (shouldIgnorePath(relativePath)) { + continue + } + + if (entry.isDirectory()) { + collectSnapshot(entryPath, snapshot) + continue + } + + if (shouldSyncFile(relativePath)) { + snapshot.set(relativePath, fs.statSync(entryPath).mtimeMs) + } + } +} + +function buildSnapshot() { + const snapshot = new Map() + + for (const watchedRoot of watchedRoots) { + collectSnapshot(watchedRoot, snapshot) + } + + return snapshot +} + +function scanForChanges() { + const nextSnapshot = buildSnapshot() + + for (const [relativePath, modifiedTime] of nextSnapshot.entries()) { + if (previousSnapshot.get(relativePath) !== modifiedTime) { + console.log(`[dev:watch] change detected: ${relativePath}`) + queueSync() + previousSnapshot = nextSnapshot + return + } + } + + for (const relativePath of previousSnapshot.keys()) { + if (!nextSnapshot.has(relativePath)) { + console.log(`[dev:watch] change detected: ${relativePath}`) + queueSync() + previousSnapshot = nextSnapshot + return + } + } + + previousSnapshot = nextSnapshot +} + +function startPolling() { + previousSnapshot = buildSnapshot() + scanTimer = setInterval(scanForChanges, 800) +} + +function shutdown() { + if (syncTimer) { + clearTimeout(syncTimer) + } + + if (tailwindProcess && !tailwindProcess.killed) { + tailwindProcess.kill('SIGINT') + } + + if (syncProcess && !syncProcess.killed) { + syncProcess.kill('SIGINT') + } + + if (scanTimer) { + clearInterval(scanTimer) + } + + process.exit(0) +} + +console.log('[dev:watch] initial prepare') + +runCommand('npm', ['run', 'dev:prepare'], (prepareCode) => { + if (prepareCode !== 0) { + process.exit(prepareCode || 1) + } + + console.log('[dev:watch] tailwind watch start') + tailwindProcess = runCommand('npm', ['run', 'build:tailwind', '--', '--watch']) + + console.log('[dev:watch] filesystem watch start') + startPolling() +}) + +let tailwindProcess = null + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown)