From 16e9e2392188a666e102b0f742ef65770781ec10 Mon Sep 17 00:00:00 2001 From: seungwonme Date: Wed, 30 Sep 2026 15:56:15 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=9E=91=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EA=B0=80=20GitHub=20API=20=EC=9D=BC=EC=8B=9C?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=EC=97=90=20=EB=91=90=20=EB=B2=88=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A4=EC=8B=9C=20=EC=9A=94=EC=B2=AD=ED=95=98?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/postAuthors.ts | 73 ++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/src/utils/postAuthors.ts b/src/utils/postAuthors.ts index 606b20d..f0efd4e 100644 --- a/src/utils/postAuthors.ts +++ b/src/utils/postAuthors.ts @@ -54,26 +54,71 @@ function repository(): string | null { } } +// 배포 빌드(CI)에서 일시 오류가 나면 이 간격으로 두 번까지 다시 요청한다. +// 로컬 빌드는 실패해도 줄만 빠지므로 기다리지 않는다(getPostAuthors 참고) +const RETRY_DELAYS = [2_000, 5_000]; +// 요청 한도에 걸려 GitHub이 retry-after로 기다리라고 할 때, 이보다 길면 기다리지 않고 멈춘다 +const MAX_RETRY_AFTER = 60_000; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +// 다시 요청할 만한 응답이면 기다릴 시간(ms), 아니면 null +function retryDelay(res: Response, backoff: number): number | null { + // GitHub 쪽 일시 오류 + if (res.status >= 500) return backoff; + // 짧은 요청 한도(secondary rate limit)는 403이나 429에 retry-after(초)를 붙여 보낸다. + // 시간당 한도를 다 쓴 403에는 retry-after가 없어서 다시 요청해도 소용없다 + if (res.status === 403 || res.status === 429) { + const seconds = Number(res.headers.get("retry-after")); + if (seconds > 0 && seconds * 1000 <= MAX_RETRY_AFTER) return seconds * 1000; + } + return null; +} + async function get(url: string): Promise<{ data: T; last?: string }> { const token = process.env.GITHUB_TOKEN; - const res = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "astro-post-authors", - ...(token && { Authorization: `Bearer ${token}` }), - }, - signal: AbortSignal.timeout(10_000), - }); - if (!res.ok) { + const delays = process.env.CI ? RETRY_DELAYS : []; + + for (let attempt = 0; ; attempt++) { + // 남은 재시도가 없으면 undefined + const backoff: number | undefined = delays[attempt]; + let res: Response; + try { + res = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "astro-post-authors", + ...(token && { Authorization: `Bearer ${token}` }), + }, + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + // 연결 실패나 10초 시간 초과 + if (backoff === undefined) throw error; + await sleep(backoff); + continue; + } + + if (res.ok) { + const last = res.headers + .get("link") + ?.match(/<([^>]+)>;\s*rel="last"/)?.[1]; + return { data: (await res.json()) as T, last }; + } + + const wait = backoff === undefined ? null : retryDelay(res, backoff); + if (wait !== null) { + await res.body?.cancel(); + await sleep(wait); + continue; + } const limited = res.status === 403 || res.status === 429; throw new Error( `GitHub API 요청이 실패했습니다 (${res.status}): ${url}` + (limited ? ". 토큰 없이 요청하면 시간당 60회로 제한됩니다" : "") ); } - const last = res.headers.get("link")?.match(/<([^>]+)>;\s*rel="last"/)?.[1]; - return { data: (await res.json()) as T, last }; } // 경로 하나의 커밋 기록에서 가장 최근 커밋과 가장 오래된 커밋. 최신 커밋이 먼저 오고, @@ -164,8 +209,8 @@ async function load(filePath: string): Promise { const cache = new Map>(); /** - * 배포 빌드(CI)에서는 GitHub 요청이 실패하면 빌드를 멈춘다. 줄을 조용히 빼고 배포하면 - * 모든 글에서 작성자가 사라진 걸 한참 뒤에 알게 된다. 로컬에서는 토큰 없이 돌려 + * 배포 빌드(CI)에서는 GitHub 요청이 실패하면 빌드를 멈춘다. 일시 오류는 두 번까지 다시 요청한 뒤다. + * 줄을 조용히 빼고 배포하면 모든 글에서 작성자가 사라진 걸 한참 뒤에 알게 된다. 로컬에서는 토큰 없이 돌려 * 요청 한도에 걸리기 쉬우므로 줄만 빼고 넘어간다. */ export function getPostAuthors( From 1ad027a6f7319073071724afa07c3dd097b91732 Mon Sep 17 00:00:00 2001 From: seungwonme Date: Wed, 30 Sep 2026 15:56:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=B9=8C=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=EC=97=90=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EA=B9=A8=EC=A7=80?= =?UTF-8?q?=EB=8A=94=20=EA=B8=80=EC=9D=84=20=EA=B2=80=EC=82=AC=ED=95=B4=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=EB=A5=BC=20=EB=A9=88=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astro.config.ts | 7 + src/utils/checkPosts.ts | 281 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/utils/checkPosts.ts diff --git a/astro.config.ts b/astro.config.ts index 8989101..519da3e 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -20,6 +20,7 @@ import { import { transformerFileName } from "./src/utils/transformers/fileName"; import { transformerWrap } from "./src/utils/transformers/wrap"; import { postLastmod } from "./src/utils/postLastmod"; +import checkPosts from "./src/utils/checkPosts"; import config from "./astro-paper.config"; const lastmod = postLastmod(config.site.url); @@ -47,6 +48,12 @@ export default defineConfig({ return date ? { ...item, lastmod: date } : item; }, }), + // 빌드는 통과하는데 발행된 글이 조용히 깨지는 경우(목차에 지워지는 문장, 미래 발행 시각 등)를 + // 빌드 시작 전에 잡아 멈춘다. 규칙은 src/utils/checkPosts.ts + checkPosts({ + // src/config.ts와 같은 기본값 + scheduledPostMargin: config.posts?.scheduledPostMargin ?? 15 * 60 * 1000, + }), ], i18n: { locales: ["ko"], diff --git a/src/utils/checkPosts.ts b/src/utils/checkPosts.ts new file mode 100644 index 0000000..ef6d4fe --- /dev/null +++ b/src/utils/checkPosts.ts @@ -0,0 +1,281 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import type { AstroIntegration } from "astro"; +import { isTocHeading, TOC_MAX_DEPTH, TOC_MIN_DEPTH } from "./toc"; + +// 빌드는 통과하는데 발행된 글이 조용히 깨지는 경우를 빌드 시작 전에 잡아 빌드를 멈춘다. +// 로컬 pnpm build, PR 검사(check.yml), 배포(deploy.yml)가 모두 astro build를 거치므로 셋 다 걸린다. +// 웹 편집기처럼 PR 없이 main에 바로 올린 글은 배포 빌드에서 걸리고, 사이트는 이전 배포에 머문다. +// 규칙의 이유는 AGENTS.md의 "발행", "본문 관례", "이미지" 절에 있다. 초안(draft: true)은 발행되지 않아 보지 않는다. + +export type Problem = { file: string; line?: number; message: string }; + +export type CheckOptions = { + /** postFilter.ts가 쓰는 예약 여유 시간. 발행 시각이 지금보다 이만큼 넘게 미래인 글은 빌드에서 빠진다 */ + scheduledPostMargin: number; + /** 기준 시각. 기본값은 지금 */ + now?: number; + /** 글 폴더. content.config.ts의 BLOG_PATH와 같다 */ + postsDir?: string; + /** GIF 파일을 찾을 폴더 */ + assetDirs?: string[]; +}; + +// 시간대까지 적은 ISO 시각. 시간대를 빼면 YAML이 UTC로 읽어 한국 시각으로 9시간 늦게 발행된다 +const DATETIME = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/; + +// 프런트매터 한 줄의 값과 그 줄 번호. 따옴표와 줄 끝 주석은 걷는다 +function field(front: string[], key: string) { + const index = front.findIndex(line => line.startsWith(`${key}:`)); + if (index < 0) return undefined; + const value = front[index] + .slice(key.length + 1) + .replace(/\s+#.*$/, "") + .trim() + .replace(/^(["'])(.*)\1$/, "$2"); + // 파일 첫 줄이 ---라서 프런트매터의 0번 줄이 파일의 2번째 줄이다 + return { value, line: index + 2 }; +} + +function checkDates( + file: string, + front: string[], + now: number, + margin: number +): Problem[] { + const problems: Problem[] = []; + for (const key of ["pubDatetime", "modDatetime"]) { + const date = field(front, key); + if (!date || ["", "null", "~"].includes(date.value)) continue; + if (!DATETIME.test(date.value)) { + problems.push({ + file, + line: date.line, + message: + `${key}(${date.value})에 시각과 시간대까지 적는다. 예: 2026-09-23T18:00:00+09:00. ` + + "시간대를 빼면 UTC로 읽혀 한국 시각으로 9시간 늦게 발행된다", + }); + } + } + + // postFilter.ts와 같은 조건이다. 여기에 걸리는 글은 목록과 페이지에서 빠진다 + const pub = field(front, "pubDatetime"); + if ( + pub && + DATETIME.test(pub.value) && + Date.parse(pub.value) - margin >= now + ) { + problems.push({ + file, + line: pub.line, + message: + `pubDatetime(${pub.value})이 빌드 시각보다 미래다. 이대로면 이 글이 빠진 채 배포되고, ` + + "정해진 시각에 다시 빌드하는 장치가 없어서 그 시각이 지나도 다음 배포 전까지 안 보인다. 발행하는 시각으로 고친다", + }); + } + return problems; +} + +type Heading = { index: number; depth: number; text: string }; + +// mdast-util-to-string처럼 제목에서 서식을 걷고 글자만 남긴다. 이미지 대체 텍스트는 뺀다 +const plain = (text: string) => + text + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*_~`]/g, "") + .trim(); + +// 인라인 코드 안의
나 이미지 문법은 예시일 뿐이라 걷고 본다 +const stripInlineCode = (line: string) => line.replace(/(`+).*?\1/g, ""); + +// 본문의 이미지 주소. 마크다운 ![](주소)와 HTML +const imageSources = (text: string) => [ + ...[...text.matchAll(/!\[[^\]]*\]\(\s*]+)/g)].map(m => m[1]), + ...[...text.matchAll(/]*?\bsrc\s*=\s*["']([^"']+)["']/gi)].map( + m => m[1] + ), +]; + +// remark-toc(mdast-util-toc)는 최상위 목차 제목부터 같은 깊이 이하의 다음 제목 전까지를 목차 목록으로 +// 바꾼다. 그 뒤에 목차에 넣을 깊이의 제목이 없으면 아무것도 바꾸지 않는다. 같은 조건으로 지워질 줄을 찾는다 +function tocProblem( + file: string, + lines: string[], + headings: Heading[] +): Problem | undefined { + const at = headings.findIndex(h => isTocHeading(h.text)); + if (at < 0) return undefined; + const toc = headings[at]; + const endAt = headings.findIndex((h, i) => i > at && h.depth <= toc.depth); + if (endAt < 0) return undefined; + const listed = headings + .slice(endAt) + .some(h => h.depth >= TOC_MIN_DEPTH && h.depth <= TOC_MAX_DEPTH); + if (!listed) return undefined; + + for (let i = toc.index + 1; i < headings[endAt].index; i++) { + const line = lines[i].trim(); + if (line && !/^$/.test(line)) { + return { + file, + line: i + 1, + message: + `"${lines[toc.index].trim()}"부터 다음 제목 전까지는 목차 목록으로 바뀌며 지워진다. ` + + "이 줄부터의 내용을 목차 뒤 절로 옮긴다", + }; + } + } + return undefined; +} + +function checkBody(file: string, lines: string[], start: number): Problem[] { + const problems: Problem[] = []; + const headings: Heading[] = []; + // 열려 있는 코드 블록의 울타리(``` 또는 ~~~). 코드 블록 안은 예시라 보지 않는다 + let fence: string | undefined; + + for (let i = start; i < lines.length; i++) { + // 콜아웃(> [!faq]-) 안의 코드 블록과 이미지도 보도록 인용 표시를 걷는다 + const unquoted = lines[i].replace(/^( {0,3}>[ \t]?)+/, ""); + const marker = unquoted.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]; + if (fence) { + const closes = + marker?.[0] === fence[0] && + marker.length >= fence.length && + unquoted.trim() === marker; + if (closes) fence = undefined; + continue; + } + if (marker) { + fence = marker; + continue; + } + + // 목차는 최상위 제목만 본다. 인용 안의 제목은 remark-toc도 보지 않는다 + const heading = lines[i].match( + /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/ + ); + if (heading) { + headings.push({ + index: i, + depth: heading[1].length, + text: plain(heading[2] ?? ""), + }); + } + + const text = stripInlineCode(unquoted); + const at = { file, line: i + 1 }; + if (/는 테마 CSS(typography.css)가 안의 문단을 숨긴다. " + + '접는 내용은 "> [!faq]- 질문" 콜아웃으로 쓴다', + }); + } + for (const src of imageSources(text)) { + if (/^\/(?!\/)/.test(src)) { + problems.push({ + ...at, + message: + `이미지 ${src}가 public/을 가리킨다. Astro가 최적화하지 않아 원본이 그대로 나간다. ` + + "src/assets/images/에 두고 @/assets/images/로 참조한다", + }); + } + if (!/^https?:/i.test(src) && /\.gif$/i.test(src.split(/[?#]/)[0])) { + problems.push({ + ...at, + message: `GIF(${src})를 쓰지 않는다. mp4로 만들어 public/videos/에 둔다`, + }); + } + } + } + + const toc = tocProblem(file, lines, headings); + if (toc) problems.push(toc); + return problems; +} + +const listFiles = (dir: string) => + readdirSync(dir, { recursive: true, encoding: "utf8" }).map(f => + join(dir, f) + ); + +export function findProblems(options: CheckOptions) { + const { + scheduledPostMargin, + now = Date.now(), + postsDir = "src/content/posts", + assetDirs = ["src/assets", "public"], + } = options; + const problems: Problem[] = []; + let checked = 0; + + // content.config.ts의 glob("**/[^_]*.{md,mdx}")과 같은 글을 본다 + const posts = listFiles(postsDir) + .filter(f => /\.mdx?$/.test(f) && !basename(f).startsWith("_")) + .sort(); + for (const file of posts) { + const lines = readFileSync(file, "utf-8").split(/\r?\n/); + const end = lines[0] === "---" ? lines.indexOf("---", 1) : -1; + const front = end > 0 ? lines.slice(1, end) : []; + if (field(front, "draft")?.value === "true") continue; + + checked++; + problems.push( + ...checkDates(file, front, now, scheduledPostMargin), + ...checkBody(file, lines, end + 1) + ); + } + + for (const file of assetDirs + .filter(dir => existsSync(dir)) + .flatMap(listFiles)) { + if (/\.gif$/i.test(file)) { + problems.push({ + file, + message: + "GIF 파일을 커밋하지 않는다. mp4로 만들어 public/videos/에 둔다", + }); + } + } + return { problems, checked }; +} + +/** 빌드 시작 전에 모든 글을 검사한다. 하나라도 걸리면 빌드를 멈춘다 */ +export default function checkPosts(options: CheckOptions): AstroIntegration { + return { + name: "check-posts", + hooks: { + "astro:build:start": ({ logger }) => { + const { problems, checked } = findProblems(options); + if (problems.length > 0) { + problems.sort( + (a, b) => + a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) + ); + const error = new Error( + [ + `글 검사에서 ${problems.length}건이 걸렸습니다. 이유는 AGENTS.md의 "발행", "본문 관례", "이미지" 절에 있습니다.`, + ...problems.map( + p => `- ${p.file}${p.line ? `:${p.line}` : ""} ${p.message}` + ), + ].join("\n") + ); + // 위치를 주지 않으면 Astro가 메시지 속 괄호(예: 발행 시각)를 파일 위치로 잘못 읽는다. + // 첫 문제의 줄을 주면 그 줄 주변 코드를 함께 보여 준다 + const first = problems.find(p => p.line); + throw Object.assign( + error, + first && { + loc: { file: resolve(first.file), line: first.line, column: 1 }, + } + ); + } + logger.info(`글 ${checked}편 검사 통과`); + }, + }, + }; +} From 1ee158b5f3a39635e000e6ec04fb2f7d9878d423 Mon Sep 17 00:00:00 2001 From: seungwonme Date: Wed, 30 Sep 2026 15:56:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?ci:=20PR=EB=A7=88=EB=8B=A4=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=EC=99=80=20=EA=B0=99=EC=9D=80=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=8F=8C=EB=A0=A4=20=ED=95=A9=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=EC=97=90=20=EC=98=A4=EB=A5=98=20=EC=9E=A1=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..8d8021a --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,34 @@ +name: Check + +# PR마다 배포와 같은 빌드를 돌려 합치기 전에 오류를 잡는다. 배포는 하지 않는다. +# 빌드에는 타입 검사(astro check), 글 검사(src/utils/checkPosts.ts), +# 작성자 조회(src/utils/postAuthors.ts)가 들어 있다. +on: + pull_request: + branches: [main] + +permissions: + contents: read + +# 같은 PR에 새로 push하면 앞선 검사는 취소한다. +concurrency: + group: check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + # 글의 작성자를 찾을 때 커밋이 들어온 PR을 읽는다(src/utils/postAuthors.ts) + pull-requests: read + steps: + - uses: actions/checkout@v7 + # deploy.yml과 같은 액션으로 빌드해서 여기서 통과하면 배포 빌드도 통과하게 한다. + # 이 액션은 결과물을 Pages용 아티팩트로 올리기까지 하지만, 배포 잡이 없어 쓰이지 않는다. + - uses: withastro/action@v6 + with: + package-manager: pnpm@latest + # 소유확인 토큰과 분석 ID(vars.*)는 넘기지 않는다. 비어 있으면 태그만 빠지고 빌드는 같다. + env: + GITHUB_TOKEN: ${{ github.token }} From 1460b3d88eb604adb55f1387cfe79d87c5258715 Mon Sep 17 00:00:00 2001 From: seungwonme Date: Wed, 30 Sep 2026 15:56:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20PR=20=EB=B0=9C=ED=96=89=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84,=20=EB=B0=9C=ED=96=89=20=EC=8B=9C=EA=B0=81=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99,=20=EB=B9=8C=EB=93=9C=EA=B0=80=20=EB=A7=89?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=20=EC=A0=81=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 53 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8decb1d..6ef447f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,12 +16,18 @@ velog `@nomadamas`와 dev.to는 재게시 채널이다. 정본이 아니다. ## 발행 ```bash -# 글 하나 추가하고 push하면 끝이다. 별도 업로드 단계가 없다. -git add src/content/posts/<슬러그>.md src/assets/images/ +# 글을 브랜치에 올리고 PR을 연다. PR마다 배포와 같은 빌드가 돌고(check.yml), 합치면 배포된다(deploy.yml). +git switch -c post/<슬러그> +git add src/content/posts/<슬러그>.md src/assets/images/<슬러그>/ git commit -m "post: <제목>" -git push +git push -u origin post/<슬러그> +gh pr create ``` +합치기 전에 PR의 검사(Check)가 통과했는지 본다. 빌드는 프런트매터 형식 오류, 없는 이미지 경로, +아래 "빌드가 막는 것", 작성자 조회 실패에서 멈춘다. 합친 뒤 배포 빌드가 멈추면 사이트는 이전 배포에 +머물고 새 글만 올라가지 않는다. + frontmatter는 AstroPaper 스키마를 따른다. `pubDate`가 아니라 `pubDatetime`이다. ```yaml @@ -37,6 +43,14 @@ draft: false # true면 빌드에서 빠진다 `modDatetime`을 발행 뒤 다른 날짜로 적으면 날짜 옆에 수정일이 함께 나온다. 같은 날 고친 것은 표시하지 않는다. +`pubDatetime`은 발행하는 시각을 시간대(`+09:00`)까지 적고, 합치기 직전에 합치는 시각으로 고친다. + +- **시간대를 빼면 UTC로 읽힌다.** 한국 시각으로 9시간 늦게 발행된다 +- **예약 발행은 없다.** 빌드 시각보다 15분 넘게 미래인 글은 빌드에서 빠지는데(`src/utils/postFilter.ts`), + 정해진 시각에 다시 빌드하는 장치가 없어서 그 시각이 지나도 다음 배포 전까지 보이지 않는다 + +둘 다 빌드가 막는다(아래 "빌드가 막는 것"). + ### 본문 관례 글마다 켤 필요 없이 붙는 것은 하나다. 절(h2)과 소절(h3)을 합쳐 3개 이상이면 마우스가 있는 1024px 이상 @@ -47,16 +61,31 @@ draft: false # true면 빌드에서 빠진다 - **목차 상자**: 요약 뒤, 첫 절 앞에 `## 목차` 한 줄을 두고 바로 다음에 `##` 절을 쓴다. remark-toc가 그 뒤의 절과 소절로 목록을 채우고 `src/utils/remarkTocBox.ts`가 늘 펼친 상자로 감싼다. 떠 있는 목차가 보이는 화면에서는 상자가 숨는다 - `## 목차`부터 다음 `##` 제목 전까지는 전부 목록으로 바뀌며 지워진다. 그 사이에 쓴 문장은 물론 - `###` 소절과 그 본문도 빌드 경고 없이 사라진다 + `###` 소절과 그 본문도 사라지므로 빌드가 막는다 - `## 목차`보다 앞에 있는 제목은 상자에 들어가지 않는다. 떠 있는 목차에는 들어간다 - 테마 기본의 접는 목차(remark-collapse)는 뺐다. `## Table of contents`, `## Contents`도 같은 상자가 된다 - **접는 내용**(자주 묻는 질문, 개발자용 예시): rehype-callouts 문법으로 `> [!faq]- 질문`처럼 종류 뒤에 `-`를 붙인다. `+`를 붙이면 펼친 채로 시작한다. 접는 방식은 이것 하나로 통일한다. raw `
`를 쓰면 테마 - `typography.css`의 `details:not(.callout)` 규칙이 안의 문단을 숨긴다(remark-collapse 목차용으로 남은 업스트림 규칙이다) + `typography.css`의 `details:not(.callout)` 규칙이 안의 문단을 숨긴다(remark-collapse 목차용으로 남은 업스트림 규칙이다). + 그래서 빌드가 막는다 - **강조 상자**: `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]` - **줄바꿈되는 코드 블록**: 독자가 복사해 쓰는 긴 문장은 ```` ```text wrap ````처럼 코드 블록 메타에 `wrap`을 적는다. 나머지 코드 블록은 가로 스크롤이다. 줄바꿈이 뜻을 바꿀 수 있어서다 +### 빌드가 막는 것 + +아래는 빌드가 통과하는데 발행된 글이 조용히 깨지는 경우다. `src/utils/checkPosts.ts`가 빌드 시작 전에 +초안을 뺀 모든 글을 보고, 하나라도 걸리면 파일과 줄을 알려 주며 빌드를 멈춘다. +로컬 `pnpm build`, PR 검사, 배포 빌드가 모두 거친다. + +- `## 목차` 뒤에 지워질 내용(목차 판정은 remark-toc와 같은 `src/utils/toc.ts` 규칙을 쓴다) +- raw `
` +- `/`로 시작하는 이미지 주소(`public/` 이미지). 이유는 "이미지" 절 +- GIF 참조와 `src/assets/`, `public/` 아래의 GIF 파일 +- 시간대가 없는 `pubDatetime`, `modDatetime`과 빌드 시각보다 미래인 `pubDatetime` + +코드 블록과 인라인 코드 안은 예시로 보고 검사하지 않는다. + ## 웹에서 글쓰기 `app.pagescms.org`에 GitHub으로 로그인하면 브라우저에서 글을 쓰고 이미지를 드래그해 올릴 수 있다. @@ -74,6 +103,9 @@ draft: false # true면 빌드에서 빠진다 에이전트가 push하는 경로와 충돌하지 않는다. 둘 다 같은 저장소 파일을 고칠 뿐이다. 다만 같은 글을 동시에 건드리지 않는다. +웹 편집기는 PR 없이 `main`에 바로 커밋하므로 PR 검사를 거치지 않는다. "빌드가 막는 것"에 걸리면 +배포 빌드가 멈추고 사이트는 이전 배포에 머문다. 저장한 뒤 Actions 탭에서 배포가 성공했는지 본다. + ## 작성자 표시 글 제목 아래 "작성"은 그 글 파일을 처음 추가한 커밋, "최근 수정"은 가장 최근 커밋을 한 GitHub 계정이다. @@ -89,7 +121,11 @@ draft: false # true면 빌드에서 빠진다 - **대신 올리는 글은 커밋의 author를 실제로 쓴 사람으로 적는다.** git은 쓴 사람(author)과 올린 사람(committer)을 따로 기록한다. `git commit --author="이름 <이메일>"`로 올리면 그 사람이 작성자로 고정된다. 이메일은 그 사람의 GitHub 계정에 등록된 주소여야 계정이 잡힌다. 확실한 것은 `+<아이디>@users.noreply.github.com`이고, - id는 `gh api users/<아이디> --jq .id`로 본다 + id는 `gh api users/<아이디> --jq .id`로 본다. + 자동 발행(Actions, 클라우드 세션)도 같다. 봇 계정이 커밋하고 PR까지 열면 작성자가 봇으로 나오므로 + 커밋 author를 사람으로 적고, 첫 글에서 작성자 줄을 확인한다 +- **"최근 수정"은 그 파일을 건드린 마지막 커밋이다.** 여러 글을 한꺼번에 고치는 커밋(경로 일괄 변경, + 서식 정리)은 그 글들의 "최근 수정"을 모두 그 커밋을 한 사람으로 바꾼다. 글 내용과 무관한 일괄 수정은 꼭 필요할 때만 한다 - **글쓴이 표기는 전부 이 작성자 하나에서 나온다.** 구조화 데이터의 author, ``, 자동 생성 OG 이미지의 "by" 줄이다. 봇이 처음 올린 글과 GitHub 기록을 못 받은 로컬 빌드만 프런트매터 `author`(기본값 조직)로 돌아간다 @@ -97,8 +133,9 @@ draft: false # true면 빌드에서 빠진다 커밋을 계정에 잇는다. 로컬 git 이메일이 계정에 없으면 커밋의 author가 비어 오므로 그 커밋이 들어온 PR을 본다. PR 없이 `main`에 바로 올린 커밋이면 커밋에 적힌 이름만 링크 없이 나온다. 봇(Pages CMS, Actions) 커밋도 PR을 먼저 보고, PR을 연 쪽도 봇이면 봇 계정이 나온다 -- **배포 빌드는 GitHub 요청이 실패하면 멈춘다.** `deploy.yml`이 빌드에 `GITHUB_TOKEN`을 넘기고 - 빌드 잡에 `pull-requests: read`를 준다. 로컬 빌드는 실패해도 이 줄만 빼고 통과한다. +- **배포 빌드는 GitHub 요청이 실패하면 멈춘다.** `deploy.yml`과 `check.yml`이 빌드에 `GITHUB_TOKEN`을 넘기고 + 빌드 잡에 `pull-requests: read`를 준다. 일시 오류(5xx, 10초 시간 초과, 1분 이하의 `retry-after`)는 + 2초, 5초 뒤에 두 번까지 다시 요청하고, 그래도 실패하면 멈춘다. 로컬 빌드는 다시 요청하지 않고 이 줄만 빼고 통과한다. 토큰 없는 요청은 시간당 60회라 글이 많으면 로컬에서 줄이 빠진다. 제대로 보려면 `GITHUB_TOKEN=$(gh auth token) pnpm build` - **GitHub에 올라간 기록만 반영된다.** push하지 않은 글과 커밋은 빠진다. 로컬 빌드는 기본 브랜치의 기록을 쓴다 - **Pages CMS는 로그인한 사람 명의로 커밋한다.** `.pages.yml`의 `settings.commit.identity: user`.