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');
}