From 2ace1facc552abde9a8ffe47047806feadc5c6c7 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:01:03 +0900 Subject: [PATCH 01/14] fix: add missing CLI output directory to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7202ae7..52e1ea3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ dist/ .idea/ .vscode/ +# CLI output +reader-output/ + # Logs *.log npm-debug.log* From cd97c2386eecc80a9bc918baa8e311273b4cf370 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:01:09 +0900 Subject: [PATCH 02/14] feat: add CLI entry point and update build scripts --- package.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d646bd..c630564 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ }, "license": "MIT", "author": "3rdflrhtl@gmail.com", + "bin": { + "github-mobile-reader": "./dist/cli.js" + }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -39,7 +42,8 @@ "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts --clean", "build:action": "tsup src/action.ts --format cjs --outDir dist --no-splitting", - "build:all": "npm run build && npm run build:action", + "build:cli": "tsup src/cli.ts --format cjs --outDir dist --no-splitting", + "build:all": "npm run build && npm run build:action && npm run build:cli", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "prepublishOnly": "npm run build:all" }, From 666c6a35428a9eabae079dd256d091a9e22284e2 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:01:17 +0900 Subject: [PATCH 03/14] feat: implement CLI for github-mobile-reader with argument parsing and GitHub API integration --- src/cli.ts | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/cli.ts diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..b8cafd1 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * github-mobile-reader CLI + * + * Usage: + * npx github-mobile-reader --repo owner/repo --pr 123 + * npx github-mobile-reader --repo owner/repo --all + * npx github-mobile-reader --repo owner/repo --pr 123 --token ghp_xxxx + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { generateReaderMarkdown } from './parser'; + +// ── CLI argument parser ──────────────────────────────────────────────────────── + +function parseArgs(): { + repo: string; + pr?: number; + all: boolean; + token?: string; + out: string; + limit: number; +} { + const args = process.argv.slice(2); + const get = (flag: string) => { + const idx = args.indexOf(flag); + return idx !== -1 ? args[idx + 1] : undefined; + }; + + const repo = get('--repo'); + if (!repo) { + console.error('Error: --repo is required'); + console.error(''); + console.error('Examples:'); + console.error(' npx github-mobile-reader --repo 3rdflr/-FE- --pr 5'); + console.error(' npx github-mobile-reader --repo 3rdflr/-FE- --all'); + process.exit(1); + } + + // Validate repo format (must be owner/repo with no path traversal) + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-]+$/.test(repo)) { + console.error('Error: --repo must be in "owner/repo" format (e.g. "3rdflr/my-app")'); + process.exit(1); + } + + const rawOut = get('--out') ?? './reader-output'; + // Prevent absolute paths and path traversal in --out + if (path.isAbsolute(rawOut) || rawOut.includes('..')) { + console.error('Error: --out must be a relative path without ".." (e.g. "./reader-output")'); + process.exit(1); + } + + if (args.includes('--token')) { + console.error('Error: --token flag is not supported for security reasons.'); + console.error(' Set the GITHUB_TOKEN environment variable instead:'); + console.error(' export GITHUB_TOKEN=ghp_xxxx'); + process.exit(1); + } + + return { + repo, + pr: get('--pr') ? Number(get('--pr')) : undefined, + all: args.includes('--all'), + token: process.env.GITHUB_TOKEN, + out: rawOut, + limit: Number(get('--limit') ?? '10'), + }; +} + +// ── GitHub API helpers ───────────────────────────────────────────────────────── + +async function githubFetch(url: string, token?: string, accept = 'application/vnd.github+json') { + const headers: Record = { Accept: accept }; + if (token) headers['Authorization'] = `token ${token}`; + + const resp = await fetch(url, { headers }); + if (!resp.ok) { + if (resp.status === 404) throw new Error(`Not found: ${url}`); + if (resp.status === 401) throw new Error('Authentication failed. Set the GITHUB_TOKEN environment variable.'); + if (resp.status === 403) throw new Error('Rate limit or permission error. Set GITHUB_TOKEN for higher rate limits.'); + // Avoid echoing raw API response body — it may contain sensitive request metadata + throw new Error(`GitHub API error (status ${resp.status})`); + } + return resp; +} + +async function getPRList(repo: string, token?: string, limit = 10): Promise<{ number: number; title: string }[]> { + const url = `https://api.github.com/repos/${repo}/pulls?state=all&per_page=${limit}&sort=updated&direction=desc`; + const resp = await githubFetch(url, token); + const data = await resp.json() as Array<{ number: number; title: string }>; + return data.map(pr => ({ number: pr.number, title: pr.title })); +} + +async function getPRMeta(repo: string, prNumber: number, token?: string): Promise<{ title: string; head: string }> { + const url = `https://api.github.com/repos/${repo}/pulls/${prNumber}`; + const resp = await githubFetch(url, token); + const data = await resp.json() as { title: string; head: { sha: string } }; + return { title: data.title, head: data.head.sha.slice(0, 7) }; +} + +// ── Diff splitting ───────────────────────────────────────────────────────────── + +const JS_TS_EXT = /\.(js|jsx|ts|tsx|mjs|cjs)$/; + +interface FileDiff { + filename: string; + diff: string; +} + +async function getPRFileDiffs(repo: string, prNumber: number, token?: string): Promise { + const url = `https://api.github.com/repos/${repo}/pulls/${prNumber}`; + const resp = await githubFetch(url, token, 'application/vnd.github.v3.diff'); + const rawDiff = await resp.text(); + + // diff를 파일별로 분리 + const chunks = rawDiff.split(/(?=^diff --git )/m).filter(Boolean); + + return chunks + .map(chunk => { + const match = chunk.match(/^diff --git a\/(.+?) b\//m); + return match ? { filename: match[1], diff: chunk } : null; + }) + .filter((item): item is FileDiff => item !== null && JS_TS_EXT.test(item.filename)); +} + +// ── Core: process one PR ─────────────────────────────────────────────────────── + +async function processPR(repo: string, prNumber: number, outDir: string, token?: string): Promise { + process.stdout.write(` Fetching PR #${prNumber}...`); + + const [fileDiffs, meta] = await Promise.all([ + getPRFileDiffs(repo, prNumber, token), + getPRMeta(repo, prNumber, token), + ]); + + if (fileDiffs.length === 0) { + console.log(` — JS/TS 변경 없음 (스킵)`); + return ''; + } + + // 파일별로 섹션 생성 + const sections: string[] = []; + + sections.push(`# 📖 PR #${prNumber} — ${meta.title}\n`); + sections.push(`> Repository: ${repo} `); + sections.push(`> Commit: \`${meta.head}\` `); + sections.push(`> 변경된 JS/TS 파일: ${fileDiffs.length}개\n`); + sections.push('---\n'); + + for (const { filename, diff } of fileDiffs) { + const section = generateReaderMarkdown(diff, { + pr: String(prNumber), + commit: meta.head, + file: filename, + repo, + }); + + // generateReaderMarkdown의 헤더(# 📖 ...) 대신 파일명 헤더로 교체 + const withoutHeader = section + .replace(/^# 📖.*\n/, '') + .replace(/^> Generated by.*\n/m, '') + .replace(/^> Repository:.*\n/m, '') + .replace(/^> Pull Request:.*\n/m, '') + .replace(/^> Commit:.*\n/m, '') + .replace(/^> File:.*\n/m, '') + .replace(/^\n+/, ''); + + sections.push(`## 📄 \`${filename}\`\n`); + sections.push(withoutHeader); + sections.push('\n---\n'); + } + + sections.push('🛠 Auto-generated by [github-mobile-reader](https://github.com/3rdflr/github-mobile-reader). Do not edit manually.'); + + const markdown = sections.join('\n'); + fs.mkdirSync(outDir, { recursive: true }); + const outPath = path.join(outDir, `pr-${prNumber}.md`); + fs.writeFileSync(outPath, markdown, 'utf8'); + + console.log(` ✓ "${meta.title}" (${fileDiffs.length}개 파일)`); + return outPath; +} + +// ── Main ─────────────────────────────────────────────────────────────────────── + +async function main() { + const opts = parseArgs(); + + console.log(`\n📖 github-mobile-reader CLI`); + console.log(` repo : ${opts.repo}`); + console.log(` out : ${opts.out}`); + if (!opts.token) { + console.log(` auth : none (60 req/hr limit — use --token or GITHUB_TOKEN for more)\n`); + } else { + console.log(` auth : token provided\n`); + } + + if (opts.pr) { + const outPath = await processPR(opts.repo, opts.pr, opts.out, opts.token); + if (outPath) console.log(`\n✅ Done → ${outPath}\n`); + return; + } + + if (opts.all) { + console.log(` Fetching PR list (limit: ${opts.limit})...`); + const prs = await getPRList(opts.repo, opts.token, opts.limit); + + if (prs.length === 0) { + console.log(' No PRs found.'); + return; + } + + console.log(` Found ${prs.length} PR(s)\n`); + + const results: string[] = []; + for (const pr of prs) { + try { + const outPath = await processPR(opts.repo, pr.number, opts.out, opts.token); + if (outPath) results.push(outPath); + } catch (err) { + console.log(` ✗ PR #${pr.number} skipped: ${(err as Error).message}`); + } + } + + console.log(`\n✅ Done — ${results.length} file(s) written to ${opts.out}/\n`); + results.forEach(p => console.log(` ${p}`)); + console.log(''); + return; + } + + console.error('Error: specify --pr or --all'); + process.exit(1); +} + +main().catch(err => { + console.error(`\n❌ ${err.message}\n`); + process.exit(1); +}); From e4a213e5be0146f89f6359c9290f8a4a644104a0 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:01:21 +0900 Subject: [PATCH 04/14] feat: add JSX/Tailwind exports and related types to the public API --- src/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/index.ts b/src/index.ts index bea1c68..25b647c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,10 +11,21 @@ export { renderFlowTree, parseToFlowTree, Priority, + // JSX/Tailwind + isJSXFile, + hasJSXContent, + isClassNameOnlyLine, + extractClassName, + parseClassNameChanges, + renderStyleChanges, + isJSXElement, + extractJSXComponentName, + parseJSXToFlowTree, } from './parser'; export type { FlowNode, ParseResult, ReaderMarkdownMeta, + ClassNameChange, } from './parser'; From a67e23d0cee78cf9ad22901e10d9a8bb70e38129 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:01:25 +0900 Subject: [PATCH 05/14] feat: enhance JSX handling with className change detection and rendering support --- src/parser.ts | 247 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 237 insertions(+), 10 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index f782b19..5e1f45d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -37,6 +37,197 @@ export interface ReaderMarkdownMeta { repo?: string; } +export interface ClassNameChange { + component: string; + added: string[]; + removed: string[]; +} + +// ── JSX / Tailwind helpers ───────────────────────────────────────────────────── + +export function isJSXFile(filename: string): boolean { + return /\.(jsx|tsx)$/.test(filename); +} + +export function hasJSXContent(lines: string[]): boolean { + return lines.some(l => /<[A-Z][A-Za-z]*[\s/>]/.test(l) || /return\s*\(/.test(l)); +} + +export function isClassNameOnlyLine(line: string): boolean { + return /^className=/.test(line.trim()); +} + +export function extractClassName(line: string): string | null { + // Static: className="flex items-center gap-2" + const staticMatch = line.match(/className="([^"]*)"/); + if (staticMatch) return staticMatch[1]; + + // Ternary: className={isDark ? "bg-gray-900" : "bg-white"} + const ternaryMatch = line.match(/className=\{[^?]+\?\s*"([^"]*)"\s*:\s*"([^"]*)"\}/); + if (ternaryMatch) return `${ternaryMatch[1]} ${ternaryMatch[2]}`; + + // Template literal: className={`base ${condition ? "a" : "b"}`} + const templateMatch = line.match(/className=\{`([^`]*)`\}/); + if (templateMatch) { + const raw = templateMatch[1]; + const literals = raw.replace(/\$\{[^}]*\}/g, ' ').trim(); + const exprStrings = [...raw.matchAll(/"([^"]*)"/g)].map(m => m[1]); + return [literals, ...exprStrings].filter(Boolean).join(' '); + } + + return null; +} + +export function extractComponentFromLine(line: string): string { + const tagMatch = line.match(/<([A-Za-z][A-Za-z0-9.]*)/); + if (tagMatch) return tagMatch[1]; + return 'unknown'; +} + +export function parseClassNameChanges( + addedLines: string[], + removedLines: string[] +): ClassNameChange[] { + const componentMap = new Map; removed: Set }>(); + + for (const line of addedLines.filter(l => /className=/.test(l))) { + const cls = extractClassName(line); + const comp = extractComponentFromLine(line); + if (!cls) continue; + if (!componentMap.has(comp)) componentMap.set(comp, { added: new Set(), removed: new Set() }); + cls.split(/\s+/).filter(Boolean).forEach(c => componentMap.get(comp)!.added.add(c)); + } + + for (const line of removedLines.filter(l => /className=/.test(l))) { + const cls = extractClassName(line); + const comp = extractComponentFromLine(line); + if (!cls) continue; + if (!componentMap.has(comp)) componentMap.set(comp, { added: new Set(), removed: new Set() }); + cls.split(/\s+/).filter(Boolean).forEach(c => componentMap.get(comp)!.removed.add(c)); + } + + const changes: ClassNameChange[] = []; + for (const [comp, { added, removed }] of componentMap) { + const pureAdded = [...added].filter(c => !removed.has(c)); + const pureRemoved = [...removed].filter(c => !added.has(c)); + if (pureAdded.length === 0 && pureRemoved.length === 0) continue; + changes.push({ component: comp, added: pureAdded, removed: pureRemoved }); + } + + return changes; +} + +export function renderStyleChanges(changes: ClassNameChange[]): string[] { + const lines: string[] = []; + for (const change of changes) { + lines.push(`**${change.component}**`); + if (change.added.length > 0) lines.push(` + ${change.added.join(' ')}`); + if (change.removed.length > 0) lines.push(` - ${change.removed.join(' ')}`); + } + return lines; +} + +// ── JSX Structure helpers ────────────────────────────────────────────────────── + +export function isJSXElement(line: string): boolean { + const t = line.trim(); + return /^<[A-Za-z]/.test(t) || /^<\/[A-Za-z]/.test(t); +} + +export function isJSXClosing(line: string): boolean { + return /^<\/[A-Za-z]/.test(line.trim()); +} + +export function isJSXSelfClosing(line: string): boolean { + return /\/>[\s]*$/.test(line.trim()); +} + +export function extractJSXComponentName(line: string): string { + const trimmed = line.trim(); + + const closingMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9.]*)/); + if (closingMatch) return `/${closingMatch[1]}`; + + const nameMatch = trimmed.match(/^<([A-Za-z][A-Za-z0-9.]*)/); + if (!nameMatch) return trimmed; + const name = nameMatch[1]; + + // Collect event handler props (onClick, onChange, etc.) + const eventProps: string[] = []; + for (const m of trimmed.matchAll(/\b(on[A-Z]\w+)=/g)) { + eventProps.push(m[1]); + } + + return eventProps.length > 0 ? `${name}(${eventProps.join(', ')})` : name; +} + +export function shouldIgnoreJSX(line: string): boolean { + const t = line.trim(); + return ( + isClassNameOnlyLine(t) || + /^style=/.test(t) || + /^aria-/.test(t) || + /^data-/.test(t) || + /^strokeLinecap=/.test(t) || + /^strokeLinejoin=/.test(t) || + /^strokeWidth=/.test(t) || + /^viewBox=/.test(t) || + /^fill=/.test(t) || + /^stroke=/.test(t) || + /^d="/.test(t) || + t === '{' || t === '}' || + t === '(' || t === ')' || + t === '<>' || t === '' || + /^\{\/\*/.test(t) + ); +} + +export function parseJSXToFlowTree(lines: string[]): FlowNode[] { + const roots: FlowNode[] = []; + const stack: Array<{ node: FlowNode; depth: number }> = []; + + for (const line of lines) { + if (!isJSXElement(line)) continue; + if (shouldIgnoreJSX(line)) continue; + + const depth = getIndentDepth(line); + + if (isJSXClosing(line)) { + while (stack.length > 0 && stack[stack.length - 1].depth >= depth) { + stack.pop(); + } + continue; + } + + const name = extractJSXComponentName(line); + const selfClosing = isJSXSelfClosing(line); + + const node: FlowNode = { + type: 'call', + name, + children: [], + depth, + priority: Priority.OTHER, + }; + + while (stack.length > 0 && stack[stack.length - 1].depth >= depth) { + stack.pop(); + } + + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].node.children.push(node); + } + + if (!selfClosing) { + stack.push({ node, depth }); + } + } + + return roots; +} + /** * Step 1: Filter diff lines — added (+) and removed (-) separately */ @@ -328,8 +519,29 @@ export function generateReaderMarkdown( diffText: string, meta: ReaderMarkdownMeta = {} ): string { - const result = parseDiffToLogicalFlow(diffText); + const { added, removed } = filterDiffLines(diffText); + + // ── Detect JSX mode ────────────────────────────────────── + const isJSX = Boolean( + (meta.file && isJSXFile(meta.file)) || hasJSXContent(added) + ); + + // ── Parse logical flow (strip className lines in JSX mode) ── + const addedForFlow = isJSX ? added.filter(l => !isClassNameOnlyLine(l)) : added; + const normalizedAdded = normalizeCode(addedForFlow); + const flowTree = parseToFlowTree(normalizedAdded); + + // ── Raw code (className stripped in JSX mode) ──────────── + const rawCode = addedForFlow.join('\n'); + const removedForCode = isJSX ? removed.filter(l => !isClassNameOnlyLine(l)) : removed; + const removedCode = removedForCode.join('\n'); + + // ── JSX-specific analysis ──────────────────────────────── + const classNameChanges = isJSX ? parseClassNameChanges(added, removed) : []; + const jsxTree = isJSX ? parseJSXToFlowTree(added) : []; + const sections: string[] = []; + const lang = isJSX ? 'tsx' : 'typescript'; // ── Header ────────────────────────────────────────────── sections.push('# 📖 GitHub Reader View\n'); @@ -340,27 +552,42 @@ export function generateReaderMarkdown( if (meta.file) sections.push(`> File: \`${meta.file}\``); sections.push('\n'); - // ── Logical Flow (added) ───────────────────────────────── - if (result.root.length > 0) { + // ── Logical Flow ───────────────────────────────────────── + if (flowTree.length > 0) { sections.push('## 🧠 Logical Flow\n'); sections.push('```'); - sections.push(...renderFlowTree(result.root)); + sections.push(...renderFlowTree(flowTree)); + sections.push('```\n'); + } + + // ── JSX Structure (JSX only) ───────────────────────────── + if (isJSX && jsxTree.length > 0) { + sections.push('## 🎨 JSX Structure\n'); + sections.push('```'); + sections.push(...renderFlowTree(jsxTree)); sections.push('```\n'); } + // ── Style Changes (JSX only) ───────────────────────────── + if (isJSX && classNameChanges.length > 0) { + sections.push('## 💅 Style Changes\n'); + sections.push(...renderStyleChanges(classNameChanges)); + sections.push(''); + } + // ── Added Code ─────────────────────────────────────────── - if (result.rawCode.trim()) { + if (rawCode.trim()) { sections.push('## ✅ Added Code\n'); - sections.push('```typescript'); - sections.push(result.rawCode); + sections.push(`\`\`\`${lang}`); + sections.push(rawCode); sections.push('```\n'); } // ── Removed Code ───────────────────────────────────────── - if (result.removedCode.trim()) { + if (removedCode.trim()) { sections.push('## ❌ Removed Code\n'); - sections.push('```typescript'); - sections.push(result.removedCode); + sections.push(`\`\`\`${lang}`); + sections.push(removedCode); sections.push('```\n'); } From 794874c5578a5c16d6237e566961c1dbdb42e201 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:01:30 +0900 Subject: [PATCH 06/14] feat: update README to include CLI usage, authentication details, and output format --- README.ko.md | 82 +++++++++++++++++++++++++++++++++++++++++++++----- README.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/README.ko.md b/README.ko.md index 56f9b42..06f682b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -40,9 +40,13 @@ data - **의존성 제로 코어** — 파서는 Node.js ≥ 18이 있는 어디서나 동작합니다 - **이중 출력 포맷** — CJS (`require`)와 ESM (`import`) 모두 지원, TypeScript 타입 포함 +- **CLI** — `npx github-mobile-reader --repo owner/repo --pr 42` 로 어떤 PR이든 즉시 변환 - **GitHub Action** — 레포에 YAML 파일 하나만 추가하면 PR마다 Reader 문서가 자동 생성됩니다 +- **파일별 분리 출력** — 변경된 JS/TS 파일마다 독립적인 섹션으로 출력 +- **JSX/Tailwind 인식** — `.jsx`/`.tsx` 파일은 컴포넌트 트리(`🎨 JSX Structure`)와 Tailwind 클래스 diff(`💅 Style Changes`)를 별도 섹션으로 분리 출력 - **양방향 diff 추적** — 추가된 코드와 삭제된 코드를 각각 별도 섹션으로 표시 - **보수적 설계** — 패턴이 애매할 때는 잘못된 정보를 보여주는 대신 덜 보여줍니다 +- **보안 기본값** — 토큰은 `$GITHUB_TOKEN` 환경변수로만 읽음 — 셸 히스토리나 `ps` 목록에 노출되는 `--token` 플래그 없음 --- @@ -50,13 +54,75 @@ data 1. [빠른 시작](#빠른-시작) 2. [언어 지원](#언어-지원) -3. [GitHub Action (권장)](#github-action-권장) -4. [npm 라이브러리 사용법](#npm-라이브러리-사용법) -5. [출력 형식](#출력-형식) -6. [API 레퍼런스](#api-레퍼런스) -7. [파서 동작 원리](#파서-동작-원리) -8. [기여하기](#기여하기) -9. [라이선스](#라이선스) +3. [CLI 사용법](#cli-사용법) +4. [GitHub Action (권장)](#github-action-권장) +5. [npm 라이브러리 사용법](#npm-라이브러리-사용법) +6. [출력 형식](#출력-형식) +7. [API 레퍼런스](#api-레퍼런스) +8. [파서 동작 원리](#파서-동작-원리) +9. [기여하기](#기여하기) +10. [라이선스](#라이선스) + +--- + +## CLI 사용법 + +터미널에서 `github-mobile-reader`를 바로 실행할 수 있습니다 — 별도 설정 파일 불필요. GitHub에서 PR diff를 받아 모바일 친화적인 Markdown으로 변환하고 `./reader-output/`에 PR별로 파일을 저장합니다. + +### 인증 (토큰 설정) + +CLI 실행 **전에** 환경변수로 GitHub 토큰을 설정하세요: + +```bash +export GITHUB_TOKEN=ghp_xxxx +npx github-mobile-reader --repo owner/repo --pr 42 +``` + +> **보안 안내:** CLI는 `--token` 플래그를 지원하지 않습니다. 커맨드라인 인자로 시크릿을 전달하면 셸 히스토리와 `ps` 출력에 토큰이 노출됩니다. 반드시 환경변수를 사용하세요. + +### 단일 PR + +```bash +npx github-mobile-reader --repo owner/repo --pr 42 +``` + +### 최근 PR 전체 + +```bash +npx github-mobile-reader --repo owner/repo --all +``` + +### 옵션 + +| 플래그 | 기본값 | 설명 | +| ----------- | ------------------ | ------------------------------------------------------- | +| `--repo` | *(필수)* | `owner/repo` 형식의 레포지토리 | +| `--pr` | — | 특정 PR 번호 하나 처리 | +| `--all` | — | 최근 PR 전체 처리 (`--limit`와 함께 사용) | +| `--out` | `./reader-output` | 생성된 `.md` 파일 저장 경로 — 상대 경로만 허용, `..` 불가 | +| `--limit` | `10` | `--all` 사용 시 가져올 PR 최대 개수 | + +토큰: `$GITHUB_TOKEN` 환경변수에서 읽음 (미인증 시 60 req/hr, 인증 시 5 000 req/hr). + +### 출력 결과 + +PR마다 `reader-output/pr-<번호>.md` 파일 하나가 생성됩니다. + +JSX/TSX 파일은 추가 섹션이 생성됩니다: + +``` +# 📖 PR #42 — My Feature + +## 📄 `src/App.tsx` + +### 🧠 Logical Flow ← JS 로직 트리 +### 🎨 JSX Structure ← 컴포넌트 계층 구조 (JSX/TSX 전용) +### 💅 Style Changes ← 추가/제거된 Tailwind 클래스 (JSX/TSX 전용) +### ✅ Added Code +### ❌ Removed Code +``` + +> **참고:** `reader-output/`는 기본적으로 `.gitignore`에 포함되어 있습니다 — 생성된 파일은 로컬에만 저장되며 레포지토리에 커밋되지 않습니다. --- @@ -487,8 +553,10 @@ github-mobile-reader/ │ ├── parser.ts ← 핵심 diff → logical flow 파서 │ ├── index.ts ← npm 공개 API │ ├── action.ts ← GitHub Action 진입점 +│ ├── cli.ts ← CLI 진입점 (npx github-mobile-reader) │ └── test.ts ← 스모크 테스트 (33개) ├── dist/ ← 컴파일 결과물 (자동 생성, 수정 금지) +├── reader-output/ ← CLI 출력 디렉토리 (gitignore됨) ├── .github/ │ └── workflows/ │ └── mobile-reader.yml ← 사용자용 예시 워크플로우 diff --git a/README.md b/README.md index f9a3297..6724726 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,13 @@ data - **Zero-dependency core** — the parser runs anywhere Node.js ≥ 18 is available - **Dual output format** — CJS (`require`) and ESM (`import`) with full TypeScript types +- **CLI** — `npx github-mobile-reader --repo owner/repo --pr 42` fetches and converts any PR instantly - **GitHub Action** — drop one YAML block into any repo and get auto-generated Reader docs on every PR +- **File-by-file output** — each changed JS/TS file gets its own independent section in the output +- **JSX/Tailwind aware** — `.jsx`/`.tsx` files get a component tree (`🎨 JSX Structure`) and a Tailwind class diff (`💅 Style Changes`) instead of one unreadable blob - **Tracks both sides of a diff** — shows added _and_ removed code in separate sections - **Conservative by design** — when a pattern is ambiguous, the library shows less rather than showing something wrong +- **Secure by default** — token is read from `$GITHUB_TOKEN` only; no flag that leaks to shell history or `ps` --- @@ -48,13 +52,14 @@ data 1. [Quick Start](#quick-start) 2. [Language Support](#language-support) -3. [GitHub Action (recommended)](#github-action-recommended) -4. [npm Library Usage](#npm-library-usage) -5. [Output Format](#output-format) -6. [API Reference](#api-reference) -7. [How the Parser Works](#how-the-parser-works) -8. [Contributing](#contributing) -9. [License](#license) +3. [CLI Usage](#cli-usage) +4. [GitHub Action (recommended)](#github-action-recommended) +5. [npm Library Usage](#npm-library-usage) +6. [Output Format](#output-format) +7. [API Reference](#api-reference) +8. [How the Parser Works](#how-the-parser-works) +9. [Contributing](#contributing) +10. [License](#license) --- @@ -129,6 +134,67 @@ If you'd like to contribute an adapter for your language, see [Contributing](#co --- +## CLI Usage + +Run `github-mobile-reader` directly from your terminal — no setup, no config file. It fetches a PR diff from GitHub, converts it to mobile-friendly Markdown, and saves one file per PR to `./reader-output/`. + +### Authentication + +Set your GitHub token as an environment variable **before** running the CLI: + +```bash +export GITHUB_TOKEN=ghp_xxxx +npx github-mobile-reader --repo owner/repo --pr 42 +``` + +> **Security note:** The CLI does not accept a `--token` flag. Passing secrets as command-line arguments exposes them in shell history and `ps` output. Always use the environment variable. + +### Single PR + +```bash +npx github-mobile-reader --repo owner/repo --pr 42 +``` + +### All recent PRs + +```bash +npx github-mobile-reader --repo owner/repo --all +``` + +### Options + +| Flag | Default | Description | +| --------- | ----------------- | ------------------------------------------------- | +| `--repo` | *(required)* | Repository in `owner/repo` format | +| `--pr` | — | Process a single PR by number | +| `--all` | — | Process all recent PRs (use with `--limit`) | +| `--out` | `./reader-output` | Output directory — relative paths only, no `..` | +| `--limit` | `10` | Max number of PRs to fetch when using `--all` | + +Token: read from `$GITHUB_TOKEN` environment variable (60 req/hr unauthenticated, 5 000 req/hr authenticated). + +### Output + +Each PR produces one file: `reader-output/pr-.md`. + +Inside that file, every changed JS/TS file gets its own section. JSX/TSX files get two extra sections: + +``` +# 📖 PR #42 — My Feature + +## 📄 `src/App.tsx` + +### 🧠 Logical Flow ← JS logic tree +### 🎨 JSX Structure ← component hierarchy (JSX/TSX only) +### 💅 Style Changes ← added/removed Tailwind classes (JSX/TSX only) +### ✅ Added Code +### ❌ Removed Code +``` + +> **Note:** `reader-output/` is gitignored by default — the generated files are local only and not committed to your repository. + +--- + ## Quick Start ```bash @@ -482,8 +548,10 @@ github-mobile-reader/ ├── src/ │ ├── parser.ts ← core diff → logical flow parser │ ├── index.ts ← public npm API surface -│ └── action.ts ← GitHub Action entry point +│ ├── action.ts ← GitHub Action entry point +│ └── cli.ts ← CLI entry point (npx github-mobile-reader) ├── dist/ ← compiled output (auto-generated, do not edit) +├── reader-output/ ← CLI output directory (gitignored) ├── .github/ │ └── workflows/ │ └── mobile-reader.yml ← example workflow for consumers From 11639bf65556013af69e91fa7a8b93f8377df1a4 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:39:10 +0900 Subject: [PATCH 07/14] fix: update mobile reader workflow for improved node setup and markdown generation --- .github/workflows/mobile-reader.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/mobile-reader.yml b/.github/workflows/mobile-reader.yml index c9b42c2..f4a966b 100644 --- a/.github/workflows/mobile-reader.yml +++ b/.github/workflows/mobile-reader.yml @@ -5,8 +5,8 @@ on: types: [opened, synchronize, reopened] permissions: - contents: write # commit the generated .md file - pull-requests: write # post PR comment + contents: write # commit the generated .md file + pull-requests: write # post PR comment jobs: generate-reader: @@ -20,15 +20,16 @@ jobs: with: fetch-depth: 0 - # 2. Run the reader action - - name: Generate Reader Markdown - uses: your-org/github-mobile-reader@v1 # ← update after publishing + # 2. Install and run the reader + - name: Setup Node + uses: actions/setup-node@v4 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - base_branch: ${{ github.base_ref }} - output_dir: docs/reader + node-version: '20' + + - name: Generate Reader Markdown + run: npx github-mobile-reader@latest --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }} --out docs/reader env: - PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 3. Commit the generated file back to the PR branch - name: Commit Reader Markdown From 86341fe0bdd62360e211b53df168edb22f2166d4 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:43:49 +0900 Subject: [PATCH 08/14] refactor: clarify step description for Node setup in mobile reader workflow --- .github/workflows/mobile-reader.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mobile-reader.yml b/.github/workflows/mobile-reader.yml index f4a966b..1a3424c 100644 --- a/.github/workflows/mobile-reader.yml +++ b/.github/workflows/mobile-reader.yml @@ -20,7 +20,7 @@ jobs: with: fetch-depth: 0 - # 2. Install and run the reader + # 2. Run the CLI via npx - name: Setup Node uses: actions/setup-node@v4 with: From 34daf4b54d8cc4353bb50ed56f543b38717c4d41 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:44:59 +0900 Subject: [PATCH 09/14] 0.1.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f9d81e2..0aaa995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-mobile-reader", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-mobile-reader", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", diff --git a/package.json b/package.json index c630564..59b2d0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-mobile-reader", - "version": "0.1.0", + "version": "0.1.1", "description": "Transform git diffs into mobile-friendly Markdown — no more horizontal scrolling when reviewing code on your phone.", "keywords": [ "github", From 6dbba5d55b5178db8a4e8ea77136f6d59556ad4f Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:46:17 +0900 Subject: [PATCH 10/14] chore: update version to 0.1.2 and fix URL format in package.json --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 59b2d0a..fbde73e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-mobile-reader", - "version": "0.1.1", + "version": "0.1.2", "description": "Transform git diffs into mobile-friendly Markdown — no more horizontal scrolling when reviewing code on your phone.", "keywords": [ "github", @@ -17,12 +17,12 @@ }, "repository": { "type": "git", - "url": "https://github.com/3rdflr/github-mobile-reader.git" + "url": "git+https://github.com/3rdflr/github-mobile-reader.git" }, "license": "MIT", "author": "3rdflrhtl@gmail.com", "bin": { - "github-mobile-reader": "./dist/cli.js" + "github-mobile-reader": "dist/cli.js" }, "main": "./dist/index.js", "module": "./dist/index.mjs", From 18aa0828e03355991c59c4b203d84a909318dfa9 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 14:48:03 +0900 Subject: [PATCH 11/14] fix: specify push target for generated markdown in mobile reader workflow --- .github/workflows/mobile-reader.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mobile-reader.yml b/.github/workflows/mobile-reader.yml index 1a3424c..37c4d99 100644 --- a/.github/workflows/mobile-reader.yml +++ b/.github/workflows/mobile-reader.yml @@ -41,5 +41,5 @@ jobs: echo "No changes to commit" else git commit -m "docs(reader): update mobile reader view for PR #${{ github.event.pull_request.number }} [skip ci]" - git push + git push origin HEAD:${{ github.head_ref }} fi From 51ad702bf0a377eeaf353ef03569f8e47b81b1c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 05:49:16 +0000 Subject: [PATCH 12/14] docs(reader): update mobile reader view for PR #2 [skip ci] --- docs/reader/pr-2.md | 636 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 docs/reader/pr-2.md diff --git a/docs/reader/pr-2.md b/docs/reader/pr-2.md new file mode 100644 index 0000000..985e837 --- /dev/null +++ b/docs/reader/pr-2.md @@ -0,0 +1,636 @@ +# 📖 PR #2 — feat: CLI 추가 + JSX/Tailwind 파서 확장 + 보안 강화 + +> Repository: 3rdflr/github-mobile-reader +> Commit: `18aa082` +> 변경된 JS/TS 파일: 3개 + +--- + +## 📄 `src/cli.ts` + +## 🧠 Logical Flow + +``` +parseArgs() +process +get() +args +get +if (!repo) +process +if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-]+$/.test(repo) +process +get +if (path.isAbsolute(rawOut) +process +if (args.includes('--token') +process +githubFetch() +if (token) +await +if (!resp.ok) +if (resp.status === 404) +if (resp.status === 401) +if (resp.status === 403) +getPRList() +await +await +getPRMeta() +await +await +getPRFileDiffs() +await +await +rawDiff +chunk + └─ filter() +processPR() +process +getPRFileDiffs() +getPRMeta() +if (fileDiffs.length === 0) +sections +sections +sections +sections +sections +loop +generateReaderMarkdown +section + └─ replace() + └─ replace() + └─ replace() + └─ replace() + └─ replace() + └─ replace() +sections +sections +sections +sections +sections +fs +path +fs +main() +parseArgs +if (!opts.token) +if (opts.pr) +await +if (outPath) +if (opts.all) +await +if (prs.length === 0) +loop +await +if (outPath) +results +process +main() +process +``` + +## ✅ Added Code + +```typescript +#!/usr/bin/env node +/** + * github-mobile-reader CLI + * + * Usage: + * npx github-mobile-reader --repo owner/repo --pr 123 + * npx github-mobile-reader --repo owner/repo --all + * npx github-mobile-reader --repo owner/repo --pr 123 --token ghp_xxxx + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { generateReaderMarkdown } from './parser'; +// ── CLI argument parser ──────────────────────────────────────────────────────── +function parseArgs(): { + repo: string; + pr?: number; + all: boolean; + token?: string; + out: string; + limit: number; +} { + const args = process.argv.slice(2); + const get = (flag: string) => { + const idx = args.indexOf(flag); + return idx !== -1 ? args[idx + 1] : undefined; + }; + const repo = get('--repo'); + if (!repo) { + console.error('Error: --repo is required'); + console.error(''); + console.error('Examples:'); + console.error(' npx github-mobile-reader --repo 3rdflr/-FE- --pr 5'); + console.error(' npx github-mobile-reader --repo 3rdflr/-FE- --all'); + process.exit(1); + } + // Validate repo format (must be owner/repo with no path traversal) + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-]+$/.test(repo)) { + console.error('Error: --repo must be in "owner/repo" format (e.g. "3rdflr/my-app")'); + process.exit(1); + } + const rawOut = get('--out') ?? './reader-output'; + // Prevent absolute paths and path traversal in --out + if (path.isAbsolute(rawOut) || rawOut.includes('..')) { + console.error('Error: --out must be a relative path without ".." (e.g. "./reader-output")'); + process.exit(1); + } + if (args.includes('--token')) { + console.error('Error: --token flag is not supported for security reasons.'); + console.error(' Set the GITHUB_TOKEN environment variable instead:'); + console.error(' export GITHUB_TOKEN=ghp_xxxx'); + process.exit(1); + } + return { + repo, + pr: get('--pr') ? Number(get('--pr')) : undefined, + all: args.includes('--all'), + token: process.env.GITHUB_TOKEN, + out: rawOut, + limit: Number(get('--limit') ?? '10'), + }; +} +// ── GitHub API helpers ───────────────────────────────────────────────────────── +async function githubFetch(url: string, token?: string, accept = 'application/vnd.github+json') { + const headers: Record = { Accept: accept }; + if (token) headers['Authorization'] = `token ${token}`; + const resp = await fetch(url, { headers }); + if (!resp.ok) { + if (resp.status === 404) throw new Error(`Not found: ${url}`); + if (resp.status === 401) throw new Error('Authentication failed. Set the GITHUB_TOKEN environment variable.'); + if (resp.status === 403) throw new Error('Rate limit or permission error. Set GITHUB_TOKEN for higher rate limits.'); + // Avoid echoing raw API response body — it may contain sensitive request metadata + throw new Error(`GitHub API error (status ${resp.status})`); + } + return resp; +} +async function getPRList(repo: string, token?: string, limit = 10): Promise<{ number: number; title: string }[]> { + const url = `https://api.github.com/repos/${repo}/pulls?state=all&per_page=${limit}&sort=updated&direction=desc`; + const resp = await githubFetch(url, token); + const data = await resp.json() as Array<{ number: number; title: string }>; + return data.map(pr => ({ number: pr.number, title: pr.title })); +} +async function getPRMeta(repo: string, prNumber: number, token?: string): Promise<{ title: string; head: string }> { + const url = `https://api.github.com/repos/${repo}/pulls/${prNumber}`; + const resp = await githubFetch(url, token); + const data = await resp.json() as { title: string; head: { sha: string } }; + return { title: data.title, head: data.head.sha.slice(0, 7) }; +} +// ── Diff splitting ───────────────────────────────────────────────────────────── +const JS_TS_EXT = /\.(js|jsx|ts|tsx|mjs|cjs)$/; +interface FileDiff { + filename: string; + diff: string; +} +async function getPRFileDiffs(repo: string, prNumber: number, token?: string): Promise { + const url = `https://api.github.com/repos/${repo}/pulls/${prNumber}`; + const resp = await githubFetch(url, token, 'application/vnd.github.v3.diff'); + const rawDiff = await resp.text(); + // diff를 파일별로 분리 + const chunks = rawDiff.split(/(?=^diff --git )/m).filter(Boolean); + return chunks + .map(chunk => { + const match = chunk.match(/^diff --git a\/(.+?) b\//m); + return match ? { filename: match[1], diff: chunk } : null; + }) + .filter((item): item is FileDiff => item !== null && JS_TS_EXT.test(item.filename)); +} +// ── Core: process one PR ─────────────────────────────────────────────────────── +async function processPR(repo: string, prNumber: number, outDir: string, token?: string): Promise { + process.stdout.write(` Fetching PR #${prNumber}...`); + const [fileDiffs, meta] = await Promise.all([ + getPRFileDiffs(repo, prNumber, token), + getPRMeta(repo, prNumber, token), + ]); + if (fileDiffs.length === 0) { + console.log(` — JS/TS 변경 없음 (스킵)`); + return ''; + } + // 파일별로 섹션 생성 + const sections: string[] = []; + sections.push(`# 📖 PR #${prNumber} — ${meta.title}\n`); + sections.push(`> Repository: ${repo} `); + sections.push(`> Commit: \`${meta.head}\` `); + sections.push(`> 변경된 JS/TS 파일: ${fileDiffs.length}개\n`); + sections.push('---\n'); + for (const { filename, diff } of fileDiffs) { + const section = generateReaderMarkdown(diff, { + pr: String(prNumber), + commit: meta.head, + file: filename, + repo, + }); + // generateReaderMarkdown의 헤더(# 📖 ...) 대신 파일명 헤더로 교체 + const withoutHeader = section + .replace(/^# 📖.*\n/, '') + .replace(/^> Generated by.*\n/m, '') + .replace(/^> Repository:.*\n/m, '') + .replace(/^> Pull Request:.*\n/m, '') + .replace(/^> Commit:.*\n/m, '') + .replace(/^> File:.*\n/m, '') + .replace(/^\n+/, ''); + sections.push(`## 📄 \`${filename}\`\n`); + sections.push(withoutHeader); + sections.push('\n---\n'); + } + sections.push('🛠 Auto-generated by [github-mobile-reader](https://github.com/3rdflr/github-mobile-reader). Do not edit manually.'); + const markdown = sections.join('\n'); + fs.mkdirSync(outDir, { recursive: true }); + const outPath = path.join(outDir, `pr-${prNumber}.md`); + fs.writeFileSync(outPath, markdown, 'utf8'); + console.log(` ✓ "${meta.title}" (${fileDiffs.length}개 파일)`); + return outPath; +} +// ── Main ─────────────────────────────────────────────────────────────────────── +async function main() { + const opts = parseArgs(); + console.log(`\n📖 github-mobile-reader CLI`); + console.log(` repo : ${opts.repo}`); + console.log(` out : ${opts.out}`); + if (!opts.token) { + console.log(` auth : none (60 req/hr limit — use --token or GITHUB_TOKEN for more)\n`); + } else { + console.log(` auth : token provided\n`); + } + if (opts.pr) { + const outPath = await processPR(opts.repo, opts.pr, opts.out, opts.token); + if (outPath) console.log(`\n✅ Done → ${outPath}\n`); + return; + } + if (opts.all) { + console.log(` Fetching PR list (limit: ${opts.limit})...`); + const prs = await getPRList(opts.repo, opts.token, opts.limit); + if (prs.length === 0) { + console.log(' No PRs found.'); + return; + } + console.log(` Found ${prs.length} PR(s)\n`); + const results: string[] = []; + for (const pr of prs) { + try { + const outPath = await processPR(opts.repo, pr.number, opts.out, opts.token); + if (outPath) results.push(outPath); + } catch (err) { + console.log(` ✗ PR #${pr.number} skipped: ${(err as Error).message}`); + } + } + console.log(`\n✅ Done — ${results.length} file(s) written to ${opts.out}/\n`); + results.forEach(p => console.log(` ${p}`)); + console.log(''); + return; + } + console.error('Error: specify --pr or --all'); + process.exit(1); +} +main().catch(err => { + console.error(`\n❌ ${err.message}\n`); + process.exit(1); +}); +``` + +--- +🛠 Auto-generated by [github-mobile-reader](https://github.com/your-org/github-mobile-reader). Do not edit manually. + +--- + +## 📄 `src/index.ts` + +## ✅ Added Code + +```typescript + // JSX/Tailwind + isJSXFile, + hasJSXContent, + isClassNameOnlyLine, + extractClassName, + parseClassNameChanges, + renderStyleChanges, + isJSXElement, + extractJSXComponentName, + parseJSXToFlowTree, + ClassNameChange, +``` + +--- +🛠 Auto-generated by [github-mobile-reader](https://github.com/your-org/github-mobile-reader). Do not edit manually. + +--- + +## 📄 `src/parser.ts` + +## 🧠 Logical Flow + +``` +line +if (staticMatch) +line +if (ternaryMatch) +line +if (templateMatch) +templateMatch +raw +line +if (tagMatch) +new +loop +extractClassName +extractComponentFromLine +if (!cls) +if (!componentMap.has(comp) +cls +loop +extractClassName +extractComponentFromLine +if (!cls) +if (!componentMap.has(comp) +cls +loop +if (pureAdded.length === 0 && pureRemoved.length === 0) +changes +loop +lines +if (change.added.length > 0) +if (change.removed.length > 0) +line +line +trimmed +if (closingMatch) +trimmed +if (!nameMatch) +nameMatch +loop +eventProps +line +isClassNameOnlyLine() +loop +if (!isJSXElement(line) +if (shouldIgnoreJSX(line) +getIndentDepth +if (isJSXClosing(line) +loop +stack +extractJSXComponentName +isJSXSelfClosing +loop +stack +if (stack.length === 0) +roots +if (!selfClosing) +stack +Boolean +isJSX +normalizeCode +parseToFlowTree +addedForFlow +isJSX +removedForCode +isJSX +isJSX +isJSX +if (flowTree.length > 0) +sections +sections +if (isJSX && jsxTree.length > 0) +sections +sections +sections +if (isJSX && classNameChanges.length > 0) +sections +sections +sections +if (rawCode.trim() +sections +sections +if (removedCode.trim() +sections +sections +``` + +## 💅 Style Changes + +**unknown** + + flex items-center gap-2 ([^ bg-gray-900 bg-white a b + +## ✅ Added Code + +```tsx +export interface ClassNameChange { + component: string; + added: string[]; + removed: string[]; +} +// ── JSX / Tailwind helpers ───────────────────────────────────────────────────── +export function isJSXFile(filename: string): boolean { + return /\.(jsx|tsx)$/.test(filename); +} +export function hasJSXContent(lines: string[]): boolean { + return lines.some(l => /<[A-Z][A-Za-z]*[\s/>]/.test(l) || /return\s*\(/.test(l)); +} +export function isClassNameOnlyLine(line: string): boolean { + return /^className=/.test(line.trim()); +} +export function extractClassName(line: string): string | null { + // Static: className="flex items-center gap-2" + const staticMatch = line.match(/className="([^"]*)"/); + if (staticMatch) return staticMatch[1]; + // Ternary: className={isDark ? "bg-gray-900" : "bg-white"} + const ternaryMatch = line.match(/className=\{[^?]+\?\s*"([^"]*)"\s*:\s*"([^"]*)"\}/); + if (ternaryMatch) return `${ternaryMatch[1]} ${ternaryMatch[2]}`; + // Template literal: className={`base ${condition ? "a" : "b"}`} + const templateMatch = line.match(/className=\{`([^`]*)`\}/); + if (templateMatch) { + const raw = templateMatch[1]; + const literals = raw.replace(/\$\{[^}]*\}/g, ' ').trim(); + const exprStrings = [...raw.matchAll(/"([^"]*)"/g)].map(m => m[1]); + return [literals, ...exprStrings].filter(Boolean).join(' '); + } + return null; +} +export function extractComponentFromLine(line: string): string { + const tagMatch = line.match(/<([A-Za-z][A-Za-z0-9.]*)/); + if (tagMatch) return tagMatch[1]; + return 'unknown'; +} +export function parseClassNameChanges( + addedLines: string[], + removedLines: string[] +): ClassNameChange[] { + const componentMap = new Map; removed: Set }>(); + for (const line of addedLines.filter(l => /className=/.test(l))) { + const cls = extractClassName(line); + const comp = extractComponentFromLine(line); + if (!cls) continue; + if (!componentMap.has(comp)) componentMap.set(comp, { added: new Set(), removed: new Set() }); + cls.split(/\s+/).filter(Boolean).forEach(c => componentMap.get(comp)!.added.add(c)); + } + for (const line of removedLines.filter(l => /className=/.test(l))) { + const cls = extractClassName(line); + const comp = extractComponentFromLine(line); + if (!cls) continue; + if (!componentMap.has(comp)) componentMap.set(comp, { added: new Set(), removed: new Set() }); + cls.split(/\s+/).filter(Boolean).forEach(c => componentMap.get(comp)!.removed.add(c)); + } + const changes: ClassNameChange[] = []; + for (const [comp, { added, removed }] of componentMap) { + const pureAdded = [...added].filter(c => !removed.has(c)); + const pureRemoved = [...removed].filter(c => !added.has(c)); + if (pureAdded.length === 0 && pureRemoved.length === 0) continue; + changes.push({ component: comp, added: pureAdded, removed: pureRemoved }); + } + return changes; +} +export function renderStyleChanges(changes: ClassNameChange[]): string[] { + const lines: string[] = []; + for (const change of changes) { + lines.push(`**${change.component}**`); + if (change.added.length > 0) lines.push(` + ${change.added.join(' ')}`); + if (change.removed.length > 0) lines.push(` - ${change.removed.join(' ')}`); + } + return lines; +} +// ── JSX Structure helpers ────────────────────────────────────────────────────── +export function isJSXElement(line: string): boolean { + const t = line.trim(); + return /^<[A-Za-z]/.test(t) || /^<\/[A-Za-z]/.test(t); +} +export function isJSXClosing(line: string): boolean { + return /^<\/[A-Za-z]/.test(line.trim()); +} +export function isJSXSelfClosing(line: string): boolean { + return /\/>[\s]*$/.test(line.trim()); +} +export function extractJSXComponentName(line: string): string { + const trimmed = line.trim(); + const closingMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9.]*)/); + if (closingMatch) return `/${closingMatch[1]}`; + const nameMatch = trimmed.match(/^<([A-Za-z][A-Za-z0-9.]*)/); + if (!nameMatch) return trimmed; + const name = nameMatch[1]; + // Collect event handler props (onClick, onChange, etc.) + const eventProps: string[] = []; + for (const m of trimmed.matchAll(/\b(on[A-Z]\w+)=/g)) { + eventProps.push(m[1]); + } + return eventProps.length > 0 ? `${name}(${eventProps.join(', ')})` : name; +} +export function shouldIgnoreJSX(line: string): boolean { + const t = line.trim(); + return ( + isClassNameOnlyLine(t) || + /^style=/.test(t) || + /^aria-/.test(t) || + /^data-/.test(t) || + /^strokeLinecap=/.test(t) || + /^strokeLinejoin=/.test(t) || + /^strokeWidth=/.test(t) || + /^viewBox=/.test(t) || + /^fill=/.test(t) || + /^stroke=/.test(t) || + /^d="/.test(t) || + t === '{' || t === '}' || + t === '(' || t === ')' || + t === '<>' || t === '' || + /^\{\/\*/.test(t) + ); +} +export function parseJSXToFlowTree(lines: string[]): FlowNode[] { + const roots: FlowNode[] = []; + const stack: Array<{ node: FlowNode; depth: number }> = []; + for (const line of lines) { + if (!isJSXElement(line)) continue; + if (shouldIgnoreJSX(line)) continue; + const depth = getIndentDepth(line); + if (isJSXClosing(line)) { + while (stack.length > 0 && stack[stack.length - 1].depth >= depth) { + stack.pop(); + } + continue; + } + const name = extractJSXComponentName(line); + const selfClosing = isJSXSelfClosing(line); + const node: FlowNode = { + type: 'call', + name, + children: [], + depth, + priority: Priority.OTHER, + }; + while (stack.length > 0 && stack[stack.length - 1].depth >= depth) { + stack.pop(); + } + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].node.children.push(node); + } + if (!selfClosing) { + stack.push({ node, depth }); + } + } + return roots; +} + const { added, removed } = filterDiffLines(diffText); + // ── Detect JSX mode ────────────────────────────────────── + const isJSX = Boolean( + (meta.file && isJSXFile(meta.file)) || hasJSXContent(added) + ); + // ── Parse logical flow (strip className lines in JSX mode) ── + const addedForFlow = isJSX ? added.filter(l => !isClassNameOnlyLine(l)) : added; + const normalizedAdded = normalizeCode(addedForFlow); + const flowTree = parseToFlowTree(normalizedAdded); + // ── Raw code (className stripped in JSX mode) ──────────── + const rawCode = addedForFlow.join('\n'); + const removedForCode = isJSX ? removed.filter(l => !isClassNameOnlyLine(l)) : removed; + const removedCode = removedForCode.join('\n'); + // ── JSX-specific analysis ──────────────────────────────── + const classNameChanges = isJSX ? parseClassNameChanges(added, removed) : []; + const jsxTree = isJSX ? parseJSXToFlowTree(added) : []; + const lang = isJSX ? 'tsx' : 'typescript'; + // ── Logical Flow ───────────────────────────────────────── + if (flowTree.length > 0) { + sections.push(...renderFlowTree(flowTree)); + sections.push('```\n'); + } + // ── JSX Structure (JSX only) ───────────────────────────── + if (isJSX && jsxTree.length > 0) { + sections.push('## 🎨 JSX Structure\n'); + sections.push('```'); + sections.push(...renderFlowTree(jsxTree)); + // ── Style Changes (JSX only) ───────────────────────────── + if (isJSX && classNameChanges.length > 0) { + sections.push('## 💅 Style Changes\n'); + sections.push(...renderStyleChanges(classNameChanges)); + sections.push(''); + } + if (rawCode.trim()) { + sections.push(`\`\`\`${lang}`); + sections.push(rawCode); + if (removedCode.trim()) { + sections.push(`\`\`\`${lang}`); + sections.push(removedCode); +``` + +## ❌ Removed Code + +```tsx + const result = parseDiffToLogicalFlow(diffText); + // ── Logical Flow (added) ───────────────────────────────── + if (result.root.length > 0) { + sections.push(...renderFlowTree(result.root)); + if (result.rawCode.trim()) { + sections.push('```typescript'); + sections.push(result.rawCode); + if (result.removedCode.trim()) { + sections.push('```typescript'); + sections.push(result.removedCode); +``` + +--- +🛠 Auto-generated by [github-mobile-reader](https://github.com/your-org/github-mobile-reader). Do not edit manually. + +--- + +🛠 Auto-generated by [github-mobile-reader](https://github.com/3rdflr/github-mobile-reader). Do not edit manually. \ No newline at end of file From 3493bb5c3e275ef6b580197fec6cd141b16e4e28 Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 15:00:12 +0900 Subject: [PATCH 13/14] fix: update mobile reader workflow to generate markdown in /tmp and post as PR comment --- .github/workflows/mobile-reader.yml | 54 ++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/.github/workflows/mobile-reader.yml b/.github/workflows/mobile-reader.yml index 37c4d99..f1cc452 100644 --- a/.github/workflows/mobile-reader.yml +++ b/.github/workflows/mobile-reader.yml @@ -5,7 +5,6 @@ on: types: [opened, synchronize, reopened] permissions: - contents: write # commit the generated .md file pull-requests: write # post PR comment jobs: @@ -14,32 +13,55 @@ jobs: runs-on: ubuntu-latest steps: - # 1. Full history so git diff origin/...HEAD works + # 1. Checkout PR head - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - # 2. Run the CLI via npx + # 2. Generate reader markdown - name: Setup Node uses: actions/setup-node@v4 with: node-version: '20' - name: Generate Reader Markdown - run: npx github-mobile-reader@latest --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }} --out docs/reader + run: npx github-mobile-reader@latest --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }} --out /tmp/reader env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # 3. Commit the generated file back to the PR branch - - name: Commit Reader Markdown - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add docs/reader/ - if git diff --cached --quiet; then - echo "No changes to commit" - else - git commit -m "docs(reader): update mobile reader view for PR #${{ github.event.pull_request.number }} [skip ci]" - git push origin HEAD:${{ github.head_ref }} - fi + # 3. Post the generated markdown as a PR comment + - name: Post PR Comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = '/tmp/reader/pr-${{ github.event.pull_request.number }}.md'; + if (!fs.existsSync(path)) { + console.log('No reader file generated, skipping comment.'); + return; + } + const body = fs.readFileSync(path, 'utf8'); + // Delete previous bot comment if exists + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + }); + const prev = comments.data.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('# 📖 PR #') + ); + if (prev) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: prev.id, + }); + } + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + body, + }); From e2f2b64e9b06133448520cd27e7a985038999f2e Mon Sep 17 00:00:00 2001 From: 3rdflr <3rdflrhtl@gmail.com> Date: Sun, 22 Feb 2026 15:03:01 +0900 Subject: [PATCH 14/14] fix: use relative path for reader output in workflow --- .github/workflows/mobile-reader.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mobile-reader.yml b/.github/workflows/mobile-reader.yml index f1cc452..4273d6f 100644 --- a/.github/workflows/mobile-reader.yml +++ b/.github/workflows/mobile-reader.yml @@ -26,7 +26,7 @@ jobs: node-version: '20' - name: Generate Reader Markdown - run: npx github-mobile-reader@latest --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }} --out /tmp/reader + run: npx github-mobile-reader@latest --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }} --out ./reader-output env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -36,7 +36,7 @@ jobs: with: script: | const fs = require('fs'); - const path = '/tmp/reader/pr-${{ github.event.pull_request.number }}.md'; + const path = './reader-output/pr-${{ github.event.pull_request.number }}.md'; if (!fs.existsSync(path)) { console.log('No reader file generated, skipping comment.'); return;