코드에 빈칸을 뚫어 인쇄용 코딩 학습지를 만드는 웹 앱입니다. Monaco 에디터로 코드를 쓰고, 가릴 부분을 드래그로 지정하고, A4에 맞춰 학생용과 정답지를 뽑습니다.
작성한 내용은 브라우저 밖으로 나가지 않습니다. 저장은 JSON 파일 내려받기, 불러오기는 그 파일을 다시 읽는 방식이고, 서버로 아무것도 전송하지 않습니다.
npm ci
npm run dev명령은 셋뿐입니다.
| 명령 | 하는 일 |
|---|---|
npm run dev |
개발 서버 (HMR) |
npm run build |
dist/ 에 배포용 정적 파일 생성 |
npm run ci |
린트 → 테스트 → 빌드. CI가 돌리는 것과 같은 명령 |
npm run ci 는 eslint . && vitest run && vite build 입니다. 로컬에서 이게 통과하면 CI에서도 통과합니다.
테스트는 tests/ 에 Vitest로 두었습니다. 상태 리듀서, 마스크 오프셋 계산, 인쇄용 렌더링 — 틀렸을 때 화면만 봐서는 알기 어려운 세 곳에 붙어 있습니다. Monaco 인스턴스 생성이나 DOM 이벤트 배선은 테스트 대신 브라우저에서 확인합니다.
master 에 push해도 배포되지 않습니다. GitHub Actions 탭에서 Deploy to GitHub Pages 를 직접 실행할 때에만 사이트가 갱신되고, 같은 실행에서 오프라인 번들 code-sheet-builder.zip 이 latest 릴리스에 올라갑니다.
push와 PR에는 CI 워크플로만 돌아서 검사만 합니다.
latest 릴리스의 zip을 받아 압축을 풀고 index.html 을 더블클릭하면 그대로 실행됩니다. 네트워크가 없어도 됩니다.
그래서 실행 시점에 CDN을 부르는 코드를 넣으면 안 됩니다. 폰트도 라이브러리도 전부 npm으로 받아 번들에 넣습니다.
file:// 로 열리는 환경을 위해 빌드 설정에 몇 가지 제약이 걸려 있는데, 이유는 vite.config.js 주석에 적어 두었습니다.
예외가 있습니다. 상단의 '예제코드'와 '오프라인 버전' 두 버튼은 latest 릴리스를 가리키므로 네트워크가 있어야 동작합니다. 예제코드 zip은 바이너리라 저장소에 두지 않고 릴리스 자산으로만 배포합니다. 둘 다 눌러야 발생하는 일이라 화면이 뜨는 것 자체는 네트워크 없이 됩니다.
index.html 단일 페이지 앱의 마크업. Vite의 진입 HTML이다.
src/
main.js 진입점. 폰트·스타일을 불러오고 DOM 이벤트를 연결한다.
languages.js 지원 언어 레지스트리
store/state.js 단일 상태 저장소 + 리듀서
services/ 마스크 오프셋 계산과 렌더링
components/ 사이드바, 문제 편집기, 인쇄
monaco/setup.js Monaco 로딩과 테마 매핑
ui/, utils/, data/, styles/
tests/ Vitest 테스트
| 파일 | 역할 |
|---|---|
src/store/state.js |
상태와 리듀서. 문제·코드블록·마스크의 모든 변경이 여기를 지난다 |
src/services/mask.service.js |
마스크 오프셋 계산, 가리기 모드 HTML 렌더링, Monaco 데코레이션 |
src/components/problem-editor.js |
문제 카드 렌더링, Monaco 인스턴스 관리, 드래그 선택 |
src/components/print.js |
인쇄용 A4 레이아웃 생성 |
src/components/sidebar.js |
문제 목록과 순서 변경(SortableJS) |
src/utils/html.js |
HTML 이스케이프. 문자열로 HTML을 만드는 자리는 전부 여기를 지납니다 |
가려진 영역은 코드 문자열의 문자 오프셋(start, end)으로 저장합니다. 이 저장소에서 가장 조심스러운 부분입니다 — 오프셋이 한 칸만 밀려도 엉뚱한 글자가 가려지고, 학생용 인쇄물에 답이 그대로 나오는데도 화면만 봐서는 알아채기 어렵습니다.
세 가지로 지킵니다.
줄바꿈 정규화. \r\n 이 섞이면 코드가 줄마다 한 글자씩 짧아져 오프셋이 밀립니다. 코드가 상태에 들어가는 길목(UPDATE_BLOCK_CODE, LOAD_STATE)에서 \n 으로 정규화하고, .gitattributes 로 저장소의 줄바꿈도 LF로 고정합니다. 불러온 파일이 CRLF였다면 코드만 줄이지 않고 마스크 오프셋도 함께 당깁니다.
편집을 따라가는 오프셋. 마스크보다 앞쪽 코드를 고치면 뒤쪽 마스크가 그만큼 밀려야 합니다. 편집 모드에서는 Monaco 가 마스크 데코레이션의 위치를 편집에 맞춰 옮겨 주므로, 코드를 상태에 넣을 때 그 위치를 함께 넘겨 씁니다. 추적 정보가 없는 경로에서는 이전 코드와 새 코드의 공통 접두·접미로 바뀐 구간을 추정합니다. 어느 쪽이든 가린 글자 자체가 바뀐 마스크는 무엇을 의도했는지 알 수 없으므로 버리고 다시 지정하게 합니다.
불러온 파일 정규화. 저장 파일은 손으로 고칠 수 있으니 상태에 넣기 전에 정리합니다. 빠진 필드를 채우고, 코드 밖을 가리키거나 서로 겹치는 마스크를 걸러 내고, 모르는 언어·겹친 id·범위 밖 설정값을 바로잡습니다. 이 정규화를 지나면 그 뒤의 코드는 상태가 앱이 만든 것과 같은 모양이라고 믿습니다.
가리기 모드는 마스크를 ??? 같은 자리표시자로 그리므로 화면에서 잰 오프셋과 원본 오프셋이 다릅니다. mask.service.js 의 render() 와 mapHtmlToRaw() 가 그 변환의 양쪽인데, 각자 자리표시자 길이를 계산하므로 한쪽만 고치면 조용히 어긋납니다. 둘을 대조하는 테스트를 잠금장치로 두었습니다.
외부 라이브러리는 모두 npm으로 관리하며 빌드 시 번들에 포함됩니다.
Monaco Editor, SortableJS, Plus Jakarta Sans, DM Mono.
LICENSE.md 참고.