diff --git a/.github/workflows/mobile-reader.yml b/.github/workflows/mobile-reader.yml index c9b42c2..4273d6f 100644 --- a/.github/workflows/mobile-reader.yml +++ b/.github/workflows/mobile-reader.yml @@ -5,8 +5,7 @@ on: types: [opened, synchronize, reopened] permissions: - contents: write # commit the generated .md file - pull-requests: write # post PR comment + pull-requests: write # post PR comment jobs: generate-reader: @@ -14,31 +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 reader action - - name: Generate Reader Markdown - uses: your-org/github-mobile-reader@v1 # ← update after publishing + # 2. Generate reader markdown + - 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 ./reader-output 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 - 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 - 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 = './reader-output/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, + }); 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* diff --git a/README.ko.md b/README.ko.md index dabe347..34bab94 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 089f844..f38432c 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 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 diff --git a/package.json b/package.json index fd4be29..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,10 +17,13 @@ }, "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" + }, "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" }, 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); +}); 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'; 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'); }