diff --git a/.github/workflows/slides.yml b/.github/workflows/slides.yml index 2a31186..ff4aca2 100644 --- a/.github/workflows/slides.yml +++ b/.github/workflows/slides.yml @@ -25,6 +25,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm test # 토큰이 테마에 반영됐는지 확인 (tokens.json만 고치고 sync를 잊은 PR 방지) - run: npm run tokens && git diff --exit-code tokens/tokens.css themes/causal-lab.css - run: npm run build diff --git a/SLIDES.md b/SLIDES.md index aba1166..eb7ecf9 100644 --- a/SLIDES.md +++ b/SLIDES.md @@ -23,6 +23,18 @@ GitHub Actions가 빌드해서 GitHub Pages에 올립니다. - `slides/<덱 폴더>/`에는 **`index.md`만** 둡니다. 이미지는 `img/`에 둡니다. - 챕터 노트와 과제 같은 일반 `.md`는 `chapters/`와 `assignments/`에 둡니다. 이 폴더들은 빌드 입력이 아니며 HTML로 자동 변환되지 않습니다. GitHub 문서 화면에서 읽습니다. - Pages의 덱 목록 페이지는 `slides/<덱 폴더>/index.md`가 있는 폴더를 찾아 만듭니다. 이 파일 이름이 다르면 목록에 나타나지 않습니다. + +## 파일 배치 자동 검사 + +PR의 CI와 HTML·PDF·OT·보고서 빌드, 로컬 미리보기 시작 전에 같은 파일 배치 검사를 실행합니다. + +- 발표 폴더에는 파일 이름이 정확히 `index.md`인 파일이 있어야 합니다. +- `slides/<발표 폴더>/index.md` 외의 마크다운은 허용하지 않습니다. `README.md`, `INDEX.MD`, `img/notes.md`나 `.markdown`·`.mdown`·`.markdn` 파일도 차단합니다. +- `chapters/`와 `assignments/`의 `README.md`, 이미지·CSS·JavaScript 같은 발표 보조 파일은 이 규칙에 걸리지 않습니다. + +오류에는 잘못된 파일 경로와 수정 방법이 나옵니다. 발표 파일은 `index.md`로 바꾸고, 일반 노트·과제는 해당 폴더로 옮기는 변경을 제안하세요. AI는 파일을 임의로 삭제하지 않고 사용자가 의도한 내용을 확인합니다. 같은 PR 브랜치에서 수정·commit하면 CI가 다시 검사합니다. + +Node가 있는 로컬 환경에서는 `npm run check:slides`로 검사만 실행할 수 있습니다. 검증 스모크 테스트는 `npm test`입니다. GitHub 웹만 쓰는 참여자는 설치할 필요 없이 PR의 CI 결과를 확인하면 됩니다. - Pages에는 PR이 main에 머지된 뒤에 올라갑니다. PR을 열었다고 Pages에 바로 보이지 않습니다. ## 발표 자료 올리기 diff --git a/TUTOR.md b/TUTOR.md index c4b4b73..09cb792 100644 --- a/TUTOR.md +++ b/TUTOR.md @@ -36,6 +36,7 @@ GitHub에서 파일을 내려받고, 내 폴더에 작성하고, Pull Request(PR - 직접 해볼지 작성·제출을 도와줄지는 처음에 한 번 정하고, 내가 바꾸자고 하면 그때 바꿔. 선택을 묻기 전에 네 도구로 어디까지 할 수 있는지 설명해. 도구가 없으면 초안·파일 내용·PR 제목과 설명을 만들고 클릭은 내가 해야 한다고 알려줘. GitHub 쓰기나 브라우저 조작 도구가 실제로 있으면 내가 맡긴 범위에서 업로드·commit·PR 생성을 할 수 있지만 로그인·인증은 내가 한다고 알려줘. - 내가 이미 알려준 역할·주차·경험·선호는 다시 묻지 마. 직접 학습을 선택했다면 클릭을 대신하지 말고, 작성 지원을 선택했더라도 내 풀이와 근거를 받기 전에 확인하지 않은 결과를 만들어내지 마. - 파일 추가 메뉴는 화면에 따라 파일 목록 주변의 + 버튼 안에 Add file이 있거나, Add file이 바로 보일 수 있어. 메뉴를 열어 Create new file 또는 Upload files를 찾도록 안내하고, 표시가 다르면 실제 보이는 문구부터 물어봐. +- 제출 전에 아래 경로 표에 맞는 파일 이름인지 확인해. 슬라이드는 slides/<발표 폴더>/index.md 하나만 두고 다른 마크다운을 함께 넣지 마. 일반 노트·러너 기록은 chapters/ 또는 assignments/의 README.md로 안내해. CI가 파일 배치 오류를 내면 경로와 오류를 읽고 같은 PR에서 수정하게 도와줘. 파일을 임의로 삭제하거나 검사 우회를 안내하지 마. [공통 경로와 제출 완료 조건] - 러너 과제: templates/runner-record.md 내용을 assignments/<주차>//README.md 에 작성 (예: assignments/w03//README.md) diff --git a/package.json b/package.json index 82cebc4..2c7c865 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,15 @@ "private": true, "type": "module", "scripts": { + "test": "node --test scripts/check-slides.test.mjs", + "check:slides": "node scripts/check-slides.mjs", "tokens": "node scripts/sync-tokens.mjs", - "dev": "marp --no-stdin --server ./slides", - "build:html": "marp --no-stdin", - "build:pdf": "marp --no-stdin --pdf", - "build:reports": "node scripts/build-index.mjs", - "build": "npm run tokens && npm run build:html && npm run build:pdf && node scripts/copy-assets.mjs && npm run build:ot-live && node scripts/build-index.mjs", - "build:ot-live": "node slides/ot-live/build-live.mjs" + "dev": "npm run check:slides && marp --no-stdin --server ./slides", + "build:html": "npm run check:slides && marp --no-stdin", + "build:pdf": "npm run check:slides && marp --no-stdin --pdf", + "build:reports": "npm run check:slides && node scripts/build-index.mjs", + "build": "npm run check:slides && npm run tokens && npm run build:html && npm run build:pdf && node scripts/copy-assets.mjs && npm run build:ot-live && node scripts/build-index.mjs", + "build:ot-live": "npm run check:slides && node slides/ot-live/build-live.mjs" }, "devDependencies": { "@marp-team/marp-cli": "^4.5.1" diff --git a/scripts/check-slides.mjs b/scripts/check-slides.mjs new file mode 100644 index 0000000..8d9e345 --- /dev/null +++ b/scripts/check-slides.mjs @@ -0,0 +1,32 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join, sep } from 'node:path'; + +const errors = []; +for (const entry of readdirSync('slides', { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const index = `slides/${entry.name}/index.md`; + if (!existsSync(index) || !statSync(index).isFile()) { + errors.push(`${index}: 발표 폴더에는 index.md가 필요합니다.`); + } +} + +function check(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) check(path); + else if (/\.(md|mdown|markdown|markdn)$/i.test(entry.name)) { + const relative = path.split(sep).join('/'); + if (!/^slides\/[^/]+\/index\.md$/.test(relative)) { + errors.push(`${relative}: 마크다운은 slides/<발표 폴더>/index.md만 허용합니다.`); + } + } + } +} +check('slides'); + +if (errors.length) { + console.error('슬라이드 파일 배치를 확인하세요:\n' + errors.map(error => `- ${error}`).join('\n')); + console.error('발표 파일은 index.md로 저장하고, 일반 노트·과제는 chapters/ 또는 assignments/에 두세요.'); + process.exit(1); +} +console.log('슬라이드 파일 배치 검사 통과'); diff --git a/scripts/check-slides.test.mjs b/scripts/check-slides.test.mjs new file mode 100644 index 0000000..73699df --- /dev/null +++ b/scripts/check-slides.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { cpSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const deck = '---\nmarp: true\n---\n# Smoke test\n'; + +function build(files, command = 'build:html') { + const cwd = mkdtempSync(join(tmpdir(), 'slide-layout-')); + try { + cpSync(join(root, 'package.json'), join(cwd, 'package.json')); + cpSync(join(root, 'scripts'), join(cwd, 'scripts'), { recursive: true }); + writeFileSync(join(cwd, '.marprc.yml'), 'inputDir: ./slides\noutput: ./dist\n'); + for (const [name, content] of Object.entries(files)) { + mkdirSync(dirname(join(cwd, name)), { recursive: true }); + writeFileSync(join(cwd, name), content); + } + const result = spawnSync('npm', ['run', command], { cwd, encoding: 'utf8', timeout: 20000 }); + const html = join(cwd, 'dist/demo/index.html'); + return { ...result, html: existsSync(html) ? readFileSync(html, 'utf8') : null }; + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +} + +test('HTML build rejects an extra README before Marp can convert it', () => { + const result = build({ 'slides/demo/index.md': deck, 'slides/demo/README.md': '# Notes\n' }); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stderr, /slides\/demo\/README\.md/); + assert.equal(result.html, null); +}); + +test('valid deck, image assets, and notes outside slides still build', () => { + const result = build({ + 'slides/demo/index.md': deck, + 'slides/demo/img/chart.svg': '', + 'chapters/w03/README.md': '# Chapter notes\n', + 'assignments/w03/learner/README.md': '# Learner record\n', + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.html, /Smoke test/); +}); + +test('a deck folder with only assets reports its missing index.md', () => { + const result = build({ 'slides/demo/img/chart.svg': '' }); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stderr, /slides\/demo\/index\.md/); +}); + +for (const path of [ + 'slides/notes.md', + 'slides/demo/img/notes.md', + 'slides/demo/INDEX.MD', + 'slides/demo/notes.markdown', + 'slides/demo/notes.mdown', + 'slides/demo/notes.markdn', +]) { + test(`rejects Markdown outside the deck entry: ${path}`, () => { + const files = { [path]: '# Notes\n' }; + // macOS can treat index.md and INDEX.MD as the same file. + if (path !== 'slides/demo/INDEX.MD') files['slides/demo/index.md'] = deck; + const result = build(files); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.ok(result.stderr.includes(path), result.stderr); + assert.equal(result.html, null); + }); +} + +for (const command of ['build', 'build:pdf', 'dev', 'build:ot-live', 'build:reports']) { + test(`${command} also blocks invalid Markdown before its build step`, () => { + const result = build({ 'slides/demo/index.md': deck, 'slides/demo/README.md': '# Notes\n' }, command); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stderr, /slides\/demo\/README\.md/); + assert.equal(result.html, null); + }); +}