From 847d5f6a7b435a632ab055f7faf14aa319f83bda Mon Sep 17 00:00:00 2001 From: Yuma Ishikawa Date: Wed, 5 Aug 2026 17:15:56 +0900 Subject: [PATCH 1/5] feat(mdp): markdown preview with google-docs-style PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mdp renders GitHub-flavored markdown through the GitHub API and serves it locally. For a pull request it previews every changed markdown file and turns the page into a Google Docs-style review surface: - left sidebar with the changed files (common path prefixes trimmed) and the active file's outline nested beneath it, with scroll-spy - review comments in a right margin column, aligned to the text they reference; PR conversation comments and unanchorable threads group at the top under 行に紐づかないコメント - select text to comment on a range: the quote plus a hidden mdp:range marker go into the comment body, so mdp can re-highlight the exact range later while GitHub still shows a normal line comment - reply by clicking a card, resolve with the blue check, and react with GitHub's eight emoji - mermaid blocks rendered as diagrams, light/dark themes, font-size cycle Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-07-mdp-pr-comments.md | 858 ++++++++++++ .../plans/2026-07-16-mdp-post-comments.md | 1185 +++++++++++++++++ .../plans/2026-08-04-mdp-gdocs-layout.md | 58 + .../2026-06-23-mdp-markdown-preview-design.md | 117 ++ .../2026-07-07-mdp-pr-comments-design.md | 89 ++ .../2026-07-16-mdp-post-comments-design.md | 75 ++ .../2026-07-17-mdp-ja-typography-design.md | 21 + .../2026-08-04-mdp-gdocs-layout-design.md | 29 + tools/mdp/.gitignore | 22 + tools/mdp/README.md | 76 ++ tools/mdp/bun.lock | 31 + tools/mdp/mdp.ts | 336 +++++ tools/mdp/package.json | 18 + tools/mdp/src/args.test.ts | 35 + tools/mdp/src/args.ts | 27 + tools/mdp/src/comments.test.ts | 468 +++++++ tools/mdp/src/comments.ts | 325 +++++ tools/mdp/src/input.test.ts | 71 + tools/mdp/src/input.ts | 79 ++ tools/mdp/src/links.test.ts | 45 + tools/mdp/src/links.ts | 26 + tools/mdp/src/mermaid.test.ts | 64 + tools/mdp/src/mermaid.ts | 41 + tools/mdp/src/post.test.ts | 381 ++++++ tools/mdp/src/post.ts | 313 +++++ tools/mdp/src/pr.test.ts | 64 + tools/mdp/src/pr.ts | 78 ++ tools/mdp/src/render.test.ts | 50 + tools/mdp/src/render.ts | 43 + tools/mdp/src/resolve.test.ts | 57 + tools/mdp/src/server.test.ts | 85 ++ tools/mdp/src/server.ts | 95 ++ tools/mdp/src/tabs.test.ts | 34 + tools/mdp/src/tabs.ts | 17 + tools/mdp/src/template.test.ts | 189 +++ tools/mdp/src/template.ts | 852 ++++++++++++ tools/mdp/src/toc.test.ts | 44 + tools/mdp/src/toc.ts | 35 + tools/mdp/themes/designdoc.css | 156 +++ tools/mdp/tsconfig.json | 14 + 40 files changed, 6603 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-mdp-pr-comments.md create mode 100644 docs/superpowers/plans/2026-07-16-mdp-post-comments.md create mode 100644 docs/superpowers/plans/2026-08-04-mdp-gdocs-layout.md create mode 100644 docs/superpowers/specs/2026-06-23-mdp-markdown-preview-design.md create mode 100644 docs/superpowers/specs/2026-07-07-mdp-pr-comments-design.md create mode 100644 docs/superpowers/specs/2026-07-16-mdp-post-comments-design.md create mode 100644 docs/superpowers/specs/2026-07-17-mdp-ja-typography-design.md create mode 100644 docs/superpowers/specs/2026-08-04-mdp-gdocs-layout-design.md create mode 100644 tools/mdp/.gitignore create mode 100644 tools/mdp/README.md create mode 100644 tools/mdp/bun.lock create mode 100755 tools/mdp/mdp.ts create mode 100644 tools/mdp/package.json create mode 100644 tools/mdp/src/args.test.ts create mode 100644 tools/mdp/src/args.ts create mode 100644 tools/mdp/src/comments.test.ts create mode 100644 tools/mdp/src/comments.ts create mode 100644 tools/mdp/src/input.test.ts create mode 100644 tools/mdp/src/input.ts create mode 100644 tools/mdp/src/links.test.ts create mode 100644 tools/mdp/src/links.ts create mode 100644 tools/mdp/src/mermaid.test.ts create mode 100644 tools/mdp/src/mermaid.ts create mode 100644 tools/mdp/src/post.test.ts create mode 100644 tools/mdp/src/post.ts create mode 100644 tools/mdp/src/pr.test.ts create mode 100644 tools/mdp/src/pr.ts create mode 100644 tools/mdp/src/render.test.ts create mode 100644 tools/mdp/src/render.ts create mode 100644 tools/mdp/src/resolve.test.ts create mode 100644 tools/mdp/src/server.test.ts create mode 100644 tools/mdp/src/server.ts create mode 100644 tools/mdp/src/tabs.test.ts create mode 100644 tools/mdp/src/tabs.ts create mode 100644 tools/mdp/src/template.test.ts create mode 100644 tools/mdp/src/template.ts create mode 100644 tools/mdp/src/toc.test.ts create mode 100644 tools/mdp/src/toc.ts create mode 100644 tools/mdp/themes/designdoc.css create mode 100644 tools/mdp/tsconfig.json diff --git a/docs/superpowers/plans/2026-07-07-mdp-pr-comments.md b/docs/superpowers/plans/2026-07-07-mdp-pr-comments.md new file mode 100644 index 0000000..7f0ea46 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-mdp-pr-comments.md @@ -0,0 +1,858 @@ +# mdp PR インラインコメント表示 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `mdp ` のプレビューに、PR の既存インラインレビューコメントを本文中の該当位置へ GitHub 風に表示する。 + +**Architecture:** 起動時に `gh api graphql` で reviewThreads を取得し、各スレッドのアンカーテキスト(対象ソース行を markdown 記法除去+空白正規化したもの)とレンダリング済みコメント本文を JSON としてページに埋め込む。クライアント JS がレンダリング済み HTML のブロック要素をテキストマッチして直後にスレッドボックスを挿入し、マッチ失敗分は末尾の Comments セクションへ。 + +**Tech Stack:** Bun + TypeScript(既存 mdp と同じ)。`gh` CLI(GraphQL)、GitHub `/markdown` API(コメント本文レンダリング)。 + +Spec: `docs/superpowers/specs/2026-07-07-mdp-pr-comments-design.md` + +## Global Constraints + +- 依存追加なし。`gh` 呼び出しは既存の `RunGh` 注入スタイル(`src/pr.ts` 参照) +- reviewThreads / comments とも first 100、超過時は stderr に warning +- `gh` 失敗・レンダリング失敗時は warning を出してコメントなしでプレビュー続行 +- テストは `bun test`、既存の `src/*.test.ts` スタイル +- クライアント JS は既存 `CONTROLS_JS` と同じく template.ts 内の文字列定数 + +--- + +### Task 1: comments.ts の純粋関数(URL パース・アンカーテキスト抽出) + +**Files:** +- Create: `src/comments.ts` +- Test: `src/comments.test.ts` + +**Interfaces:** +- Produces: + - `parsePrUrl(url: string): { owner: string; repo: string; number: number } | null` + - `stripMarkdownLine(s: string): string` + - `normalizeText(s: string): string` + - `anchorTextFor(thread: { line: number | null; diffHunk: string }, markdown: string): string | null` + +- [ ] **Step 1: Write the failing tests** + +`src/comments.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { + anchorTextFor, + normalizeText, + parsePrUrl, + stripMarkdownLine, +} from "./comments.ts"; + +describe("parsePrUrl", () => { + test("parses owner/repo/number from a PR URL", () => { + expect(parsePrUrl("https://github.com/o/r/pull/123")).toEqual({ + owner: "o", + repo: "r", + number: 123, + }); + }); + + test("rejects non-PR URLs", () => { + expect(parsePrUrl("https://github.com/o/r/issues/1")).toBeNull(); + expect(parsePrUrl("not a url")).toBeNull(); + }); +}); + +describe("stripMarkdownLine", () => { + test("strips heading markers", () => { + expect(stripMarkdownLine("## 設計方針")).toBe("設計方針"); + }); + + test("strips list markers and task boxes", () => { + expect(stripMarkdownLine("- [x] done item")).toBe("done item"); + expect(stripMarkdownLine(" 1. numbered")).toBe("numbered"); + }); + + test("strips emphasis, code and links, keeping text", () => { + expect(stripMarkdownLine("use **Redis** for `cache` [docs](http://x)")).toBe( + "use Redis for cache docs", + ); + }); + + test("turns table pipes into spaces", () => { + expect(normalizeText(stripMarkdownLine("| a | b |"))).toBe("a b"); + }); +}); + +describe("normalizeText", () => { + test("collapses whitespace and trims", () => { + expect(normalizeText(" a \t b\n c ")).toBe("a b c"); + }); +}); + +describe("anchorTextFor", () => { + const md = "# Title\n\nキャッシュは **Redis** を使用する。\n"; + + test("uses the source line when line is set", () => { + expect(anchorTextFor({ line: 3, diffHunk: "" }, md)).toBe( + "キャッシュは Redis を使用する。", + ); + }); + + test("falls back to the diff hunk's last line when line is null", () => { + const hunk = "@@ -1,2 +1,2 @@\n unchanged\n+**added** line"; + expect(anchorTextFor({ line: null, diffHunk: hunk }, md)).toBe("added line"); + }); + + test("falls back to the hunk when the source line is blank", () => { + const hunk = "@@ -1 +1 @@\n+real text"; + expect(anchorTextFor({ line: 2, diffHunk: hunk }, md)).toBe("real text"); + }); + + test("returns null when nothing yields text", () => { + expect(anchorTextFor({ line: null, diffHunk: "" }, md)).toBeNull(); + expect(anchorTextFor({ line: null, diffHunk: "@@\n+ " }, md)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/comments.test.ts` +Expected: FAIL(`comments.ts` が存在しない) + +- [ ] **Step 3: Write the implementation** + +`src/comments.ts`: + +```ts +/** Parse a github.com pull request URL into owner / repo / number. */ +export function parsePrUrl( + url: string, +): { owner: string; repo: string; number: number } | null { + const m = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + return m ? { owner: m[1], repo: m[2], number: Number(m[3]) } : null; +} + +/** Strip leading block markers and inline markdown syntax from a source line. */ +export function stripMarkdownLine(s: string): string { + return s + .replace(/^\s*#{1,6}\s+/, "") + .replace(/^\s*>\s*/, "") + .replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/, "") + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/(\*\*|__|~~|\*|_|`)/g, "") + .replace(/\|/g, " "); +} + +/** Collapse runs of whitespace to single spaces (same rule as the client matcher). */ +export function normalizeText(s: string): string { + return s.replace(/\s+/g, " ").trim(); +} + +/** + * The text a thread anchors to in the rendered document: the commented source + * line, or the diff hunk's last line when the source line is gone or blank. + * Null when neither yields visible text (the thread goes to the trailing section). + */ +export function anchorTextFor( + thread: { line: number | null; diffHunk: string }, + markdown: string, +): string | null { + let raw: string | undefined; + if (thread.line !== null) raw = markdown.split("\n")[thread.line - 1]; + if (raw === undefined || raw.trim() === "") { + const hunk = thread.diffHunk.split("\n"); + raw = hunk[hunk.length - 1]?.replace(/^[+\- ]/, ""); + } + if (raw === undefined) return null; + const text = normalizeText(stripMarkdownLine(raw)); + return text === "" ? null : text; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/comments.test.ts` +Expected: PASS(全テスト) + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/src/comments.ts tools/mdp/src/comments.test.ts +git commit -m "feat(mdp): add PR comment anchor-text helpers" +``` + +--- + +### Task 2: reviewThreads の取得と ThreadView 構築 + +**Files:** +- Modify: `src/comments.ts`(追記) +- Test: `src/comments.test.ts`(追記) + +**Interfaces:** +- Consumes: Task 1 の `parsePrUrl` / `anchorTextFor`。`src/pr.ts` の `RunGh` 型 +- Produces: + - `type ReviewThread = { path: string; line: number | null; isResolved: boolean; isOutdated: boolean; diffHunk: string; comments: { author: string; body: string; createdAt: string; url: string }[] }` + - `type ThreadView = { anchorText: string | null; isResolved: boolean; isOutdated: boolean; comments: { author: string; createdAt: string; url: string; bodyHtml: string }[] }` + - `loadPrThreads(prUrl: string, runGh: RunGh): Promise` + - `threadViewsFor(threads: ReviewThread[], doc: { name: string; markdown: string }, renderBody: (md: string) => Promise): Promise` + - `toEmbeddedJson(views: ThreadView[]): string` + +- [ ] **Step 1: Write the failing tests** + +`src/comments.test.ts` に追記: + +```ts +import { + loadPrThreads, + threadViewsFor, + toEmbeddedJson, + type ReviewThread, +} from "./comments.ts"; + +const graphqlResponse = JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { + totalCount: 2, + nodes: [ + { + path: "docs/a.md", + line: 3, + isResolved: false, + isOutdated: false, + comments: { + nodes: [ + { + author: { login: "alice" }, + body: "TTL は?", + createdAt: "2026-07-01T00:00:00Z", + url: "https://github.com/o/r/pull/1#discussion_r1", + diffHunk: "@@ -1 +3 @@\n+line three", + }, + { + author: null, + body: "1h で", + createdAt: "2026-07-02T00:00:00Z", + url: "https://github.com/o/r/pull/1#discussion_r2", + diffHunk: "@@ -1 +3 @@\n+line three", + }, + ], + }, + }, + { + path: "other.md", + line: null, + isResolved: true, + isOutdated: true, + comments: { + nodes: [ + { + author: { login: "bob" }, + body: "old", + createdAt: "2026-06-01T00:00:00Z", + url: "https://github.com/o/r/pull/1#discussion_r3", + diffHunk: "@@ -9 +9 @@\n+gone line", + }, + ], + }, + }, + ], + }, + }, + }, + }, +}); + +describe("loadPrThreads", () => { + test("queries graphql with owner/name/number and maps threads", async () => { + let seen: string[] = []; + const threads = await loadPrThreads( + "https://github.com/o/r/pull/1", + async (args) => { + seen = args; + return graphqlResponse; + }, + ); + expect(seen[0]).toBe("api"); + expect(seen[1]).toBe("graphql"); + expect(seen).toContain("owner=o"); + expect(seen).toContain("name=r"); + expect(seen).toContain("number=1"); + expect(threads).toHaveLength(2); + expect(threads[0]).toEqual({ + path: "docs/a.md", + line: 3, + isResolved: false, + isOutdated: false, + diffHunk: "@@ -1 +3 @@\n+line three", + comments: [ + { + author: "alice", + body: "TTL は?", + createdAt: "2026-07-01T00:00:00Z", + url: "https://github.com/o/r/pull/1#discussion_r1", + }, + { + author: "ghost", + body: "1h で", + createdAt: "2026-07-02T00:00:00Z", + url: "https://github.com/o/r/pull/1#discussion_r2", + }, + ], + }); + }); + + test("throws on a non-PR url", async () => { + await expect(loadPrThreads("nope", async () => "")).rejects.toThrow( + "not a PR URL", + ); + }); + + test("throws when the response has no thread data", async () => { + await expect( + loadPrThreads( + "https://github.com/o/r/pull/1", + async () => JSON.stringify({ data: { repository: { pullRequest: null } } }), + ), + ).rejects.toThrow("no review thread data"); + }); +}); + +describe("threadViewsFor", () => { + const mkThread = (over: Partial): ReviewThread => ({ + path: "docs/a.md", + line: 1, + isResolved: false, + isOutdated: false, + diffHunk: "", + comments: [ + { + author: "alice", + body: "*hi*", + createdAt: "2026-07-01T00:00:00Z", + url: "u", + }, + ], + ...over, + }); + const doc = { name: "docs/a.md", markdown: "line one\nline two\n" }; + const renderBody = async (md: string) => `

${md}

`; + + test("keeps only this doc's threads, sorted by line", async () => { + const views = await threadViewsFor( + [ + mkThread({ line: 2 }), + mkThread({ path: "other.md" }), + mkThread({ line: 1 }), + ], + doc, + renderBody, + ); + expect(views).toHaveLength(2); + expect(views[0].anchorText).toBe("line one"); + expect(views[1].anchorText).toBe("line two"); + }); + + test("renders comment bodies", async () => { + const views = await threadViewsFor([mkThread({})], doc, renderBody); + expect(views[0].comments[0]).toEqual({ + author: "alice", + createdAt: "2026-07-01T00:00:00Z", + url: "u", + bodyHtml: "

*hi*

", + }); + }); +}); + +describe("toEmbeddedJson", () => { + test("escapes < so cannot break out", () => { + const views = [ + { + anchorText: null, + isResolved: false, + isOutdated: false, + comments: [ + { + author: "a", + createdAt: "c", + url: "u", + bodyHtml: "", + }, + ], + }, + ]; + const json = toEmbeddedJson(views); + expect(json).not.toContain(""); + expect(JSON.parse(json)).toEqual(views); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/comments.test.ts` +Expected: FAIL(`loadPrThreads` などが未定義) + +- [ ] **Step 3: Write the implementation** + +`src/comments.ts` に追記: + +```ts +import type { RunGh } from "./pr.ts"; + +export type ThreadComment = { + author: string; + body: string; + createdAt: string; + url: string; +}; + +export type ReviewThread = { + path: string; + line: number | null; + isResolved: boolean; + isOutdated: boolean; + diffHunk: string; + comments: ThreadComment[]; +}; + +export type ThreadViewComment = { + author: string; + createdAt: string; + url: string; + bodyHtml: string; +}; + +export type ThreadView = { + anchorText: string | null; + isResolved: boolean; + isOutdated: boolean; + comments: ThreadViewComment[]; +}; + +const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!){ +repository(owner:$owner,name:$name){pullRequest(number:$number){ +reviewThreads(first:100){totalCount nodes{path line isResolved isOutdated +comments(first:100){nodes{author{login} body createdAt url diffHunk}}}}}}}`; + +/** Load a PR's inline review threads via the gh GraphQL API (first 100). */ +export async function loadPrThreads( + prUrl: string, + runGh: RunGh, +): Promise { + const ref = parsePrUrl(prUrl); + if (!ref) throw new Error(`not a PR URL: ${prUrl}`); + const out = await runGh([ + "api", + "graphql", + "-f", + `query=${THREADS_QUERY}`, + "-f", + `owner=${ref.owner}`, + "-f", + `name=${ref.repo}`, + "-F", + `number=${ref.number}`, + ]); + const threads = + JSON.parse(out)?.data?.repository?.pullRequest?.reviewThreads; + if (!threads) throw new Error(`no review thread data for ${prUrl}`); + if (threads.totalCount > threads.nodes.length) { + console.error( + `mdp: warning: showing ${threads.nodes.length} of ${threads.totalCount} review threads`, + ); + } + type CommentNode = { + author: { login: string } | null; + body: string; + createdAt: string; + url: string; + diffHunk: string; + }; + type ThreadNode = { + path: string; + line: number | null; + isResolved: boolean; + isOutdated: boolean; + comments: { nodes: CommentNode[] }; + }; + return (threads.nodes as ThreadNode[]).map((n) => ({ + path: n.path, + line: n.line, + isResolved: n.isResolved, + isOutdated: n.isOutdated, + diffHunk: n.comments.nodes[0]?.diffHunk ?? "", + comments: n.comments.nodes.map((c) => ({ + author: c.author?.login ?? "ghost", + body: c.body, + createdAt: c.createdAt, + url: c.url, + })), + })); +} + +/** Build the page-embeddable views for one document's threads (bodies rendered). */ +export async function threadViewsFor( + threads: ReviewThread[], + doc: { name: string; markdown: string }, + renderBody: (md: string) => Promise, +): Promise { + const mine = threads + .filter((t) => t.path === doc.name) + .sort((a, b) => (a.line ?? Infinity) - (b.line ?? Infinity)); + return Promise.all( + mine.map(async (t) => ({ + anchorText: anchorTextFor(t, doc.markdown), + isResolved: t.isResolved, + isOutdated: t.isOutdated, + comments: await Promise.all( + t.comments.map(async (c) => ({ + author: c.author, + createdAt: c.createdAt, + url: c.url, + bodyHtml: await renderBody(c.body), + })), + ), + })), + ); +} + +/** JSON safe to embed in a " can't break out). */ +export function toEmbeddedJson(views: ThreadView[]): string { + return JSON.stringify(views).replaceAll("<", "\\u003c"); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/comments.test.ts` +Expected: PASS(全テスト) + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/src/comments.ts tools/mdp/src/comments.test.ts +git commit -m "feat(mdp): load PR review threads and build embeddable views" +``` + +--- + +### Task 3: template.ts — コメント JSON の埋め込みとクライアント表示 + +**Files:** +- Modify: `src/template.ts` +- Test: `src/template.test.ts`(追記) + +**Interfaces:** +- Consumes: `PageOptions`(既存) +- Produces: `PageOptions.comments?: string`(`toEmbeddedJson` の出力を渡す)。値があるとページに `', + ); + expect(page).toContain("mdp-thread"); + }); + + test("omits the comments block when not given", () => { + const page = buildPage("

hi

", [], ""); + expect(page).not.toContain('id="mdp-comments"'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/template.test.ts` +Expected: FAIL(`comments` オプション未実装) + +- [ ] **Step 3: Write the implementation** + +`src/template.ts` を変更する。 + +`PageOptions` に追加: + +```ts +export type PageOptions = { + mermaid?: boolean; + darkCss?: string; + toc?: string; + files?: string; + title?: string; + /** Embed-ready JSON of the document's review threads (see toEmbeddedJson). */ + comments?: string; +}; +``` + +`LAYOUT_CSS` の末尾(`@media` の前)に追記: + +```css +.mdp-thread { + margin: 8px 0 16px; border: 1px solid #d0d7de; border-radius: 6px; + overflow: hidden; background: #f6f8fa; + font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +[data-theme="dark"] .mdp-thread { border-color: #30363d; background: #161b22; } +.mdp-thread-head { + display: flex; align-items: center; gap: 8px; width: 100%; + padding: 6px 12px; border: 0; background: transparent; color: #656d76; + font: inherit; cursor: pointer; text-align: left; +} +[data-theme="dark"] .mdp-thread-head { color: #9198a1; } +.mdp-badge { + display: inline-block; padding: 0 7px; border: 1px solid #d0d7de; + border-radius: 999px; font-size: 11px; line-height: 18px; color: #656d76; +} +.mdp-badge-resolved { border-color: #8250df; color: #8250df; } +[data-theme="dark"] .mdp-badge { border-color: #30363d; color: #9198a1; } +[data-theme="dark"] .mdp-badge-resolved { border-color: #ab7df8; color: #ab7df8; } +.mdp-comment { padding: 8px 12px; background: #fff; } +.mdp-comment + .mdp-comment, +.mdp-thread-head + .mdp-thread-comments .mdp-comment { border-top: 1px solid #d0d7de; } +[data-theme="dark"] .mdp-comment { background: #0d1117; } +[data-theme="dark"] .mdp-comment + .mdp-comment, +[data-theme="dark"] .mdp-thread-head + .mdp-thread-comments .mdp-comment { border-color: #30363d; } +.mdp-comment-head { margin-bottom: 4px; color: #656d76; } +[data-theme="dark"] .mdp-comment-head { color: #9198a1; } +.mdp-comment-head a { color: inherit; text-decoration: none; } +.mdp-comment-head a:hover { text-decoration: underline; } +.mdp-comment-body { font-size: 13px; } +.mdp-comment-body > :first-child { margin-top: 0; } +.mdp-comment-body > :last-child { margin-bottom: 0; } +.mdp-comments-rest { margin-top: 48px; border-top: 1px solid #d0d7de; padding-top: 16px; } +[data-theme="dark"] .mdp-comments-rest { border-color: #30363d; } +``` + +`CONTROLS_JS` の後に定数を追加: + +```ts +/** Parse the embedded thread JSON, text-match anchors, and insert thread boxes. */ +const COMMENTS_JS = `(function(){ +var el=document.getElementById('mdp-comments');if(!el)return; +var threads;try{threads=JSON.parse(el.textContent)||[];}catch(e){return;} +if(!threads.length)return; +var body=document.querySelector('.markdown-body');if(!body)return; +function norm(s){return s.replace(/\\s+/g,' ').trim();} +function esc(s){return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} +function rel(iso){var t=Date.parse(iso);if(isNaN(t))return iso; +var s=(Date.now()-t)/1000; +if(s<3600)return Math.max(1,Math.round(s/60))+'m ago'; +if(s<86400)return Math.round(s/3600)+'h ago'; +if(s<2592000)return Math.round(s/86400)+'d ago'; +return iso.slice(0,10);} +function build(t){ +var div=document.createElement('div'); +div.className='mdp-thread'+(t.isResolved?' mdp-thread-resolved':''); +var html=''; +if(t.isResolved){ +var a0=t.comments.length?t.comments[0].author:''; +html+='';} +html+='
'; +t.comments.forEach(function(c,i){ +html+='
'+esc(c.author)+' ' ++''+esc(rel(c.createdAt))+'' ++(!t.isResolved&&t.isOutdated&&i===0?' Outdated':'') ++'
'+c.bodyHtml+'
';}); +html+='
'; +div.innerHTML=html; +var head=div.querySelector('.mdp-thread-head'); +if(head)head.addEventListener('click',function(){ +var c=div.querySelector('.mdp-thread-comments');c.hidden=!c.hidden;}); +return div;} +var blocks=[].slice.call(body.querySelectorAll('p,li,h1,h2,h3,h4,h5,h6,pre,blockquote,tr')); +var texts=blocks.map(function(b){return norm(b.textContent);}); +function anchorFor(t){ +if(!t.anchorText)return null; +for(var i=0;i${opts.comments} +` + : ""; +``` + +戻り値 HTML の `` の直前に `${commentsBlock}` を挿入: + +``` +${mermaidScript} +${commentsBlock} + +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/template.test.ts` +Expected: PASS(既存テスト含む) + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/src/template.ts tools/mdp/src/template.test.ts +git commit -m "feat(mdp): render review threads in the page" +``` + +--- + +### Task 4: mdp.ts の配線・README・E2E 確認 + +**Files:** +- Modify: `mdp.ts` +- Modify: `README.md` + +**Interfaces:** +- Consumes: Task 2 の `loadPrThreads` / `threadViewsFor` / `toEmbeddedJson` / `ReviewThread`、Task 3 の `PageOptions.comments` + +- [ ] **Step 1: Wire comments into main** + +`mdp.ts` の import に追加: + +```ts +import { + loadPrThreads, + threadViewsFor, + toEmbeddedJson, + type ReviewThread, +} from "./src/comments.ts"; +``` + +`loadDocs` を PR URL も返すように変更(PR 分岐と戻り値のみ): + +```ts +async function loadDocs( + input: string | undefined, +): Promise<{ docs: Doc[]; base: Base; prUrl?: string }> { + const prRef = parsePrRef(input); + if (prRef) { + const pr = await loadPrDocs(prRef, runGh); + return { docs: pr.docs, base: { kind: "none" }, prUrl: pr.url }; + } + // ... 以下は変更なし +``` + +`main` の変更。`loadDocs` の呼び出しと routes ループ: + +```ts + const { docs, base, prUrl } = await loadDocs(args.input); +``` + +`const routes = new Map();` の直前に: + +```ts + let threads: ReviewThread[] = []; + if (prUrl) { + try { + threads = await loadPrThreads(prUrl, runGh); + } catch (e) { + console.error( + `mdp: warning: could not load PR comments: ${(e as Error)?.message ?? e}`, + ); + } + } +``` + +ループ内、`buildPage` 呼び出しの前に: + +```ts + let comments: string | undefined; + if (threads.length > 0) { + try { + const views = await threadViewsFor(threads, docs[i], (md) => + renderMarkdown(md, { token }), + ); + if (views.length > 0) comments = toEmbeddedJson(views); + } catch (e) { + console.error( + `mdp: warning: could not render comments for ${docs[i].name}: ${(e as Error)?.message ?? e}`, + ); + } + } +``` + +`buildPage` のオプションに `comments` を追加: + +```ts + buildPage(fragment, cssBlocks, githubCss, { + mermaid: blocks.length > 0, + darkCss, + toc, + files: filesNav(docs, i), + title: docs[i].name, + comments, + }), +``` + +- [ ] **Step 2: Run the full test suite and typecheck** + +Run: `bun test && bunx tsc --noEmit` +Expected: 全テスト PASS、型エラーなし + +- [ ] **Step 3: Update README** + +`README.md` の PR の usage 行の下の説明(`The page has a left **Files** panel ...` の段落の前)に追記: + +```markdown +When previewing a PR, inline review comments already on the PR are shown +inside the document at the commented text (resolved threads collapse to a +gray header; anything that can't be located falls back to a Comments +section at the end). +``` + +- [ ] **Step 4: E2E 確認** + +インラインレビューコメントのある実在の PR で確認する: + +```sh +cd tools/mdp && ./mdp.ts <レビューコメントつき PR の URL> +``` + +確認項目: +- コメントが本文中の該当テキスト直後に表示される +- resolved スレッドが折りたたまれ、クリックで展開できる +- dark テーマでも破綻しない +- コメントのない PR / 非 PR 入力(`mdp README.md`)が従来どおり動く + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/mdp.ts tools/mdp/README.md +git commit -m "feat(mdp): show existing PR review comments in previews" +``` diff --git a/docs/superpowers/plans/2026-07-16-mdp-post-comments.md b/docs/superpowers/plans/2026-07-16-mdp-post-comments.md new file mode 100644 index 0000000..123ea1f --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-mdp-post-comments.md @@ -0,0 +1,1185 @@ +# mdp コメント投稿機能 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `mdp ` のプレビュー上でテキストを選択し、その場で PR にレビューコメント(行内範囲情報付き)を投稿できるようにする。 + +**Architecture:** ページ JS は選択情報(doc パス・選択テキスト・ブロックテキスト、いずれも空白正規化済み)を mdp ローカルサーバーの `POST /api/comment` に送る。サーバーが markdown ソースを使って選択→ソース行のマッピングを行い、diff 内ならインラインレビューコメント、diff 外なら issue コメントとして `gh api` で投稿する。範囲情報は「引用 + `` 隠しマーカー」として本文に埋め込み、表示側はマーカーをパースして CSS Custom Highlight API で該当範囲をハイライトする。 + +**Tech Stack:** Bun + TypeScript(既存 mdp と同じ)。`gh` CLI(REST)。クライアントは素の JS 文字列定数。 + +Spec: `docs/superpowers/specs/2026-07-16-mdp-post-comments-design.md` + +## Global Constraints + +- 依存追加なし。`gh` 呼び出しは既存の `RunGh` 注入スタイル(`src/pr.ts` 参照) +- `gh` 失敗・情報取得失敗時は stderr に warning を出して投稿機能なしでプレビュー続行 +- テストは `bun test`、既存の `src/*.test.ts` スタイル(依存注入・純粋関数中心) +- クライアント JS は既存 `CONTROLS_JS` / `COMMENTS_JS` と同じく template.ts 内の文字列定数 +- 投稿は即時単発(`POST /pulls/{n}/comments`)。pending review・返信は扱わない +- spec からの意図的な調整: フォームの外側クリッククローズは textarea が空のときのみ(入力消失防止) + +--- + +### Task 1: post.ts の純粋関数(diff パース・行マッピング・本文組み立て・リクエスト検証) + +**Files:** +- Create: `src/post.ts` +- Test: `src/post.test.ts` + +**Interfaces:** +- Consumes: `src/comments.ts` の `normalizeText` / `stripMarkdownLine` +- Produces: + - `parseDiffLines(patch: string): Set` — unified diff パッチ → RIGHT 側コメント可能行(追加+文脈)の 1-based 行番号集合 + - `findSourceLine(markdown: string, selectedText: string, blockText: string): number | null` + - `buildCommentBody(selectedText: string, body: string): string` + - `type PostRequest = { doc: string; selectedText: string; blockText: string; body: string }` + - `parsePostRequest(body: unknown): PostRequest | null` + +- [ ] **Step 1: Write the failing tests** + +`src/post.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { + buildCommentBody, + findSourceLine, + parseDiffLines, + parsePostRequest, +} from "./post.ts"; + +describe("parseDiffLines", () => { + test("collects added and context right-side lines across hunks", () => { + const patch = [ + "@@ -1,3 +1,4 @@", + " context line", + "-removed", + "+added one", + "+added two", + " tail", + "@@ -10,2 +11,2 @@", + " ctx", + "+new", + ].join("\n"); + expect(parseDiffLines(patch)).toEqual(new Set([1, 2, 3, 4, 11, 12])); + }); + + test("ignores 'no newline' markers and empty patches", () => { + expect(parseDiffLines("@@ -1 +1 @@\n+only\n\\ No newline at end of file")).toEqual( + new Set([1]), + ); + expect(parseDiffLines("")).toEqual(new Set()); + }); +}); + +describe("findSourceLine", () => { + const md = [ + "# Title", + "", + "First paragraph line one", + "continues on line two.", + "", + "- item **bold** text", + ].join("\n"); + + test("finds the line containing the selection", () => { + expect( + findSourceLine( + md, + "paragraph line", + "First paragraph line one continues on line two.", + ), + ).toBe(3); + expect( + findSourceLine( + md, + "on line two", + "First paragraph line one continues on line two.", + ), + ).toBe(4); + }); + + test("resolves a selection spanning two source lines to the first", () => { + expect( + findSourceLine( + md, + "line one continues", + "First paragraph line one continues on line two.", + ), + ).toBe(3); + }); + + test("matches through stripped markdown syntax", () => { + expect(findSourceLine(md, "bold text", "item bold text")).toBe(6); + expect(findSourceLine(md, "Title", "Title")).toBe(1); + }); + + test("returns null when nothing matches", () => { + expect(findSourceLine(md, "nonexistent", "zzz")).toBeNull(); + expect(findSourceLine(md, "", "First paragraph line one")).toBeNull(); + }); +}); + +describe("buildCommentBody", () => { + test("composes quote, body and machine-readable marker", () => { + expect(buildCommentBody("selected text", "my comment")).toBe( + '> selected text\n\nmy comment\n\n', + ); + }); + + test("escapes -- inside the marker so the html comment cannot terminate early", () => { + const body = buildCommentBody("a -- b", "c"); + expect(body).toBe( + '> a -- b\n\nc\n\n', + ); + }); +}); + +describe("parsePostRequest", () => { + const valid = { + doc: "docs/a.md", + selectedText: "sel", + blockText: "block sel text", + body: "comment", + }; + + test("accepts a well-formed request", () => { + expect(parsePostRequest(valid)).toEqual(valid); + }); + + test("rejects missing fields, wrong types and empty bodies", () => { + expect(parsePostRequest(null)).toBeNull(); + expect(parsePostRequest("x")).toBeNull(); + expect(parsePostRequest({ ...valid, doc: 1 })).toBeNull(); + expect(parsePostRequest({ ...valid, body: " " })).toBeNull(); + const { blockText: _omitted, ...missing } = valid; + expect(parsePostRequest(missing)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd tools/mdp && bun test src/post.test.ts` +Expected: FAIL(`post.ts` が存在しない) + +- [ ] **Step 3: Write the implementation** + +`src/post.ts`: + +```ts +import { normalizeText, stripMarkdownLine } from "./comments.ts"; + +/** RIGHT 側(head)のコメント可能行 — unified diff の追加行と文脈行。 */ +export function parseDiffLines(patch: string): Set { + const lines = new Set(); + let right = 0; + for (const l of patch.split("\n")) { + const h = l.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (h) { + right = Number(h[1]); + continue; + } + if (right === 0) continue; + if (l.startsWith("+") || l.startsWith(" ")) { + lines.add(right); + right++; + } + } + return lines; +} + +/** + * レンダリング済みテキストの選択をソース行(1-based)へ写像する。 + * ブロックテキストに含まれる行を候補とし、選択を含む最初の行、 + * なければ選択と重なる最初の行を返す。どれも重ならなければ null。 + */ +export function findSourceLine( + markdown: string, + selectedText: string, + blockText: string, +): number | null { + const sel = normalizeText(selectedText); + const block = normalizeText(blockText); + if (sel === "" || block === "") return null; + const lines = markdown.split("\n"); + const candidates: { line: number; text: string }[] = []; + for (let i = 0; i < lines.length; i++) { + const text = normalizeText(stripMarkdownLine(lines[i])); + if (text !== "" && block.includes(text)) candidates.push({ line: i + 1, text }); + } + const containing = candidates.find((c) => c.text.includes(sel)); + if (containing) return containing.line; + const overlapping = candidates.find( + (c) => sel.includes(c.text) || suffixOverlaps(c.text, sel), + ); + return overlapping?.line ?? null; +} + +/** `a` の末尾(4文字以上)が `b` の先頭と一致 — 行の途中から選択が続いている形。 */ +function suffixOverlaps(a: string, b: string): boolean { + for (let k = Math.min(a.length, b.length); k >= 4; k--) { + if (a.slice(-k) === b.slice(0, k)) return true; + } + return false; +} + +/** + * 投稿本文: 選択テキストの引用 + ユーザー本文 + 機械可読マーカー。 + * マーカーは HTML コメントなので JSON 中の "--" は - でエスケープする。 + */ +export function buildCommentBody(selectedText: string, body: string): string { + const json = JSON.stringify({ text: selectedText }).replaceAll("--", "-\\u002d"); + return `> ${selectedText}\n\n${body.trim()}\n\n`; +} + +export type PostRequest = { + doc: string; + selectedText: string; + blockText: string; + body: string; +}; + +/** /api/comment の JSON ボディを検証つきで PostRequest にする。 */ +export function parsePostRequest(body: unknown): PostRequest | null { + if (typeof body !== "object" || body === null) return null; + const b = body as Record; + if ( + typeof b.doc !== "string" || + typeof b.selectedText !== "string" || + typeof b.blockText !== "string" || + typeof b.body !== "string" || + b.body.trim() === "" + ) { + return null; + } + return { + doc: b.doc, + selectedText: b.selectedText, + blockText: b.blockText, + body: b.body, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd tools/mdp && bun test src/post.test.ts` +Expected: PASS(全テスト) + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/src/post.ts tools/mdp/src/post.test.ts +git commit -m "feat(mdp): add comment posting pure helpers" +``` + +--- + +### Task 2: comments.ts — 範囲マーカーのパースと ThreadView.rangeText + +**Files:** +- Modify: `src/comments.ts` +- Test: `src/comments.test.ts`(追記+既存2箇所修正) + +**Interfaces:** +- Consumes: Task 1 の `buildCommentBody`(round-trip テストのみ) +- Produces: + - `parseRangeText(body: string): string | null` + - `stripRangeMarker(body: string): string` + - `ThreadView.rangeText: string | null`(スレッド先頭コメントのマーカー由来) + +- [ ] **Step 1: Write the failing tests** + +`src/comments.test.ts` に追記: + +```ts +import { buildCommentBody } from "./post.ts"; +import { parseRangeText, stripRangeMarker } from "./comments.ts"; + +describe("parseRangeText", () => { + test("extracts the marker's selected text", () => { + expect(parseRangeText('hi\n\n')).toBe("sel"); + }); + + test("round-trips buildCommentBody's escaped marker", () => { + expect(parseRangeText(buildCommentBody("a -- b", "c"))).toBe("a -- b"); + }); + + test("returns null without a marker or with bad json", () => { + expect(parseRangeText("plain")).toBeNull(); + expect(parseRangeText("")).toBeNull(); + }); +}); + +describe("stripRangeMarker", () => { + test("removes the marker, keeping the rest", () => { + expect(stripRangeMarker('body\n\n')).toBe( + "body", + ); + }); + + test("keeps bodies without a marker", () => { + expect(stripRangeMarker("body")).toBe("body"); + }); +}); +``` + +`threadViewsFor` の describe 内に追記: + +```ts + test("extracts rangeText from the first comment and strips markers", async () => { + const views = await threadViewsFor( + [ + mkThread({ + comments: [ + { + author: "alice", + avatarUrl: "", + body: 'hi\n\n', + createdAt: "2026-07-01T00:00:00Z", + url: "u", + }, + ], + }), + ], + doc, + renderBody, + ); + expect(views[0].rangeText).toBe("line one"); + expect(views[0].comments[0].bodyHtml).toBe("

hi

"); + }); + + test("has null rangeText without a marker", async () => { + const views = await threadViewsFor([mkThread({})], doc, renderBody); + expect(views[0].rangeText).toBeNull(); + }); +``` + +既存テストの修正(型エラー防止): `toEmbeddedJson` テストの views オブジェクトに `rangeText: null,` を追加(`anchorText: null,` の直後)。 + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd tools/mdp && bun test src/comments.test.ts` +Expected: FAIL(`parseRangeText` 未定義) + +- [ ] **Step 3: Write the implementation** + +`src/comments.ts` を変更。`ThreadView` に追加: + +```ts +export type ThreadView = { + anchorText: string | null; + /** 行内選択範囲のレンダリング済みテキスト(mdp:range マーカー由来)。 */ + rangeText: string | null; + isResolved: boolean; + isOutdated: boolean; + comments: ThreadViewComment[]; +}; +``` + +`normalizeText` の後に追加: + +```ts +const RANGE_MARKER = //; + +/** mdp:range マーカーが持つ選択テキスト。なければ null。 */ +export function parseRangeText(body: string): string | null { + const m = body.match(RANGE_MARKER); + if (!m) return null; + try { + const text = JSON.parse(m[1]).text; + return typeof text === "string" ? text : null; + } catch { + return null; + } +} + +/** 表示用に mdp:range マーカーを取り除いた本文。 */ +export function stripRangeMarker(body: string): string { + return body.replace(RANGE_MARKER, "").trimEnd(); +} +``` + +`threadViewsFor` の map 内を変更: + +```ts + mine.map(async (t) => ({ + anchorText: anchorTextFor(t, doc.markdown), + rangeText: parseRangeText(t.comments[0]?.body ?? ""), + isResolved: t.isResolved, + isOutdated: t.isOutdated, + comments: await Promise.all( + t.comments.map(async (c) => ({ + author: c.author, + avatarUrl: c.avatarUrl, + createdAt: c.createdAt, + url: c.url, + bodyHtml: await renderBody(stripRangeMarker(c.body)), + })), + ), + })), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd tools/mdp && bun test src/comments.test.ts` +Expected: PASS(既存テスト含む) + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/src/comments.ts tools/mdp/src/comments.test.ts +git commit -m "feat(mdp): parse range markers into thread views" +``` + +--- + +### Task 3: post.ts — PostContext の取得と投稿(インライン / issue 振り分け) + +**Files:** +- Modify: `src/post.ts`(追記) +- Test: `src/post.test.ts`(追記) + +**Interfaces:** +- Consumes: Task 1 の各関数、`src/comments.ts` の `parsePrUrl`、`src/pr.ts` の `RunGh` +- Produces: + - `type PostContext = { owner: string; repo: string; number: number; headSha: string; diffLines: Map> }` + - `loadPostContext(prUrl: string, runGh: RunGh): Promise` + - `type PostResult = { kind: "inline" | "issue"; url: string }` + - `postComment(req: PostRequest, ctx: PostContext, markdown: string, runGh: RunGh): Promise` + +- [ ] **Step 1: Write the failing tests** + +`src/post.test.ts` に追記: + +```ts +import { + loadPostContext, + postComment, + type PostContext, +} from "./post.ts"; + +describe("loadPostContext", () => { + test("loads head sha and per-file commentable lines", async () => { + const ctx = await loadPostContext( + "https://github.com/o/r/pull/5", + async (args) => { + if (args[1] === "repos/o/r/pulls/5") { + return JSON.stringify({ head: { sha: "abc" } }); + } + expect(args[1]).toBe("repos/o/r/pulls/5/files?per_page=100"); + return JSON.stringify([ + { filename: "docs/a.md", patch: "@@ -1 +1,2 @@\n line1\n+line2" }, + { filename: "bin.png" }, + ]); + }, + ); + expect(ctx).toEqual({ + owner: "o", + repo: "r", + number: 5, + headSha: "abc", + diffLines: new Map([["docs/a.md", new Set([1, 2])]]), + }); + }); + + test("throws on a non-PR url", async () => { + await expect(loadPostContext("nope", async () => "")).rejects.toThrow( + "not a PR URL", + ); + }); +}); + +describe("postComment", () => { + const ctx: PostContext = { + owner: "o", + repo: "r", + number: 5, + headSha: "abc", + diffLines: new Map([["docs/a.md", new Set([3])]]), + }; + const md = "# T\n\nhello world here\n"; + const req = { + doc: "docs/a.md", + selectedText: "hello world", + blockText: "hello world here", + body: "nit", + }; + + test("posts an inline review comment when the line is in the diff", async () => { + let seen: string[] = []; + const res = await postComment(req, ctx, md, async (args) => { + seen = args; + return JSON.stringify({ html_url: "http://c" }); + }); + expect(seen[1]).toBe("repos/o/r/pulls/5/comments"); + expect(seen).toContain("path=docs/a.md"); + expect(seen).toContain("line=3"); + expect(seen).toContain("side=RIGHT"); + expect(seen).toContain("commit_id=abc"); + expect(seen.join("\n")).toContain("> hello world"); + expect(res).toEqual({ kind: "inline", url: "http://c" }); + }); + + test("falls back to an issue comment for out-of-diff lines", async () => { + let seen: string[] = []; + const res = await postComment( + req, + { ...ctx, diffLines: new Map() }, + md, + async (args) => { + seen = args; + return JSON.stringify({ html_url: "http://i" }); + }, + ); + expect(seen[1]).toBe("repos/o/r/issues/5/comments"); + expect(seen.join("\n")).toContain("**docs/a.md:3**"); + expect(res).toEqual({ kind: "issue", url: "http://i" }); + }); + + test("falls back without a line number when mapping fails", async () => { + let seen: string[] = []; + await postComment( + { ...req, selectedText: "no such text", blockText: "zzz" }, + ctx, + md, + async (args) => { + seen = args; + return JSON.stringify({ html_url: "http://i" }); + }, + ); + expect(seen[1]).toBe("repos/o/r/issues/5/comments"); + expect(seen.join("\n")).toContain("**docs/a.md**\n\n> no such text"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd tools/mdp && bun test src/post.test.ts` +Expected: FAIL(`loadPostContext` / `postComment` 未定義) + +- [ ] **Step 3: Write the implementation** + +`src/post.ts` の import を変更し、末尾に追記: + +```ts +import { normalizeText, parsePrUrl, stripMarkdownLine } from "./comments.ts"; +import type { RunGh } from "./pr.ts"; +``` + +```ts +export type PostContext = { + owner: string; + repo: string; + number: number; + headSha: string; + /** path → RIGHT 側コメント可能行。 */ + diffLines: Map>; +}; + +/** 投稿に必要な PR 情報(head SHA と各ファイルの diff 行)を取得する。 */ +export async function loadPostContext( + prUrl: string, + runGh: RunGh, +): Promise { + const ref = parsePrUrl(prUrl); + if (!ref) throw new Error(`not a PR URL: ${prUrl}`); + const base = `repos/${ref.owner}/${ref.repo}/pulls/${ref.number}`; + const pr = JSON.parse(await runGh(["api", base])); + const files = JSON.parse(await runGh(["api", `${base}/files?per_page=100`])); + const diffLines = new Map>(); + for (const f of files as { filename: string; patch?: string }[]) { + if (f.patch) diffLines.set(f.filename, parseDiffLines(f.patch)); + } + return { ...ref, headSha: pr.head.sha, diffLines }; +} + +export type PostResult = { kind: "inline" | "issue"; url: string }; + +/** + * コメントを投稿する。ソース行が特定でき diff に含まれればインライン + * レビューコメント、そうでなければファイル名(と行番号)付きの issue コメント。 + */ +export async function postComment( + req: PostRequest, + ctx: PostContext, + markdown: string, + runGh: RunGh, +): Promise { + const body = buildCommentBody(req.selectedText, req.body); + const line = findSourceLine(markdown, req.selectedText, req.blockText); + const repo = `repos/${ctx.owner}/${ctx.repo}`; + if (line !== null && (ctx.diffLines.get(req.doc)?.has(line) ?? false)) { + const out = await runGh([ + "api", + `${repo}/pulls/${ctx.number}/comments`, + "-f", + `body=${body}`, + "-f", + `commit_id=${ctx.headSha}`, + "-f", + `path=${req.doc}`, + "-F", + `line=${line}`, + "-f", + "side=RIGHT", + ]); + return { kind: "inline", url: JSON.parse(out).html_url ?? "" }; + } + const loc = line !== null ? `**${req.doc}:${line}**` : `**${req.doc}**`; + const out = await runGh([ + "api", + `${repo}/issues/${ctx.number}/comments`, + "-f", + `body=${loc}\n\n${body}`, + ]); + return { kind: "issue", url: JSON.parse(out).html_url ?? "" }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd tools/mdp && bun test src/post.test.ts` +Expected: PASS(全テスト) + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/src/post.ts tools/mdp/src/post.test.ts +git commit -m "feat(mdp): post inline or issue comments via gh" +``` + +--- + +### Task 4: server.ts — POST /api ルーティング + +**Files:** +- Modify: `src/server.ts` +- Test: `src/server.test.ts`(追記) + +**Interfaces:** +- Consumes: 既存 `Base` / `resolveAsset` +- Produces: + - `type ApiHandler = (pathname: string, body: unknown) => Promise`(null = 未処理) + - `makeFetchHandler(routes: Map, base: Base, api?: ApiHandler): (req: Request) => Promise` + - `serve(routes, base, api?)` — 既存シグネチャに第3引数を追加 + +- [ ] **Step 1: Write the failing tests** + +`src/server.test.ts` に追記: + +```ts +import { makeFetchHandler } from "./server.ts"; + +describe("makeFetchHandler", () => { + const routes = new Map([["/", "home"]]); + + test("serves routes for GET", async () => { + const handle = makeFetchHandler(routes, { kind: "none" }); + const res = await handle(new Request("http://x/")); + expect(res.status).toBe(200); + expect(await res.text()).toBe("home"); + }); + + test("dispatches POST json to the api handler", async () => { + let got: unknown; + const handle = makeFetchHandler(routes, { kind: "none" }, async (path, body) => { + got = { path, body }; + return Response.json({ ok: true }); + }); + const res = await handle( + new Request("http://x/api/comment", { + method: "POST", + body: JSON.stringify({ a: 1 }), + headers: { "Content-Type": "application/json" }, + }), + ); + expect(res.status).toBe(200); + expect(got).toEqual({ path: "/api/comment", body: { a: 1 } }); + }); + + test("400 on unparsable POST bodies", async () => { + const handle = makeFetchHandler(routes, { kind: "none" }, async () => + Response.json({ ok: true }), + ); + const res = await handle( + new Request("http://x/api/comment", { method: "POST", body: "not json" }), + ); + expect(res.status).toBe(400); + }); + + test("404 for POSTs the handler declines or without a handler", async () => { + const declined = makeFetchHandler(routes, { kind: "none" }, async () => null); + const res1 = await declined( + new Request("http://x/other", { method: "POST", body: "{}" }), + ); + expect(res1.status).toBe(404); + const none = makeFetchHandler(routes, { kind: "none" }); + const res2 = await none( + new Request("http://x/api/comment", { method: "POST", body: "{}" }), + ); + expect(res2.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd tools/mdp && bun test src/server.test.ts` +Expected: FAIL(`makeFetchHandler` 未定義) + +- [ ] **Step 3: Write the implementation** + +`src/server.ts` — `resolveAsset` の後に追加し、`serve` を書き換え: + +```ts +/** POST /api/* を処理するハンドラ。null を返すと未処理(404)。 */ +export type ApiHandler = ( + pathname: string, + body: unknown, +) => Promise; + +/** ルート・アセット・API を捌く fetch ハンドラ(Bun.serve から独立してテスト可能)。 */ +export function makeFetchHandler( + routes: Map, + base: Base, + api?: ApiHandler, +): (req: Request) => Promise { + return async (req) => { + const { pathname } = new URL(req.url); + if (req.method === "POST") { + if (api) { + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "invalid JSON body" }, { status: 400 }); + } + const res = await api(pathname, body); + if (res) return res; + } + return new Response("Not found", { status: 404 }); + } + const page = routes.get(pathname); + if (page !== undefined) { + return new Response(page, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + const asset = resolveAsset(base, decodeURIComponent(pathname)); + if (asset.type === "redirect") { + return Response.redirect(asset.url, 302); + } + if (asset.type === "file") { + const file = Bun.file(asset.path); + if (await file.exists()) return new Response(file); + } + return new Response("Not found", { status: 404 }); + }; +} + +/** Serve the route map on an ephemeral port, opening it in the browser. Resolves on SIGINT. */ +export async function serve( + routes: Map, + base: Base, + api?: ApiHandler, +): Promise { + const server = Bun.serve({ port: 0, fetch: makeFetchHandler(routes, base, api) }); + + const url = `http://localhost:${server.port}`; + console.error(`mdp serving ${url} (Ctrl-C to quit)`); + Bun.spawn(["open", url]); + + await new Promise((resolve) => { + process.on("SIGINT", () => { + server.stop(); + resolve(); + }); + }); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd tools/mdp && bun test src/server.test.ts` +Expected: PASS(既存テスト含む) + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/src/server.ts tools/mdp/src/server.test.ts +git commit -m "feat(mdp): route POST /api requests through the server" +``` + +--- + +### Task 5: template.ts — 選択ポップアップ・投稿フォーム・範囲ハイライト + +**Files:** +- Modify: `src/template.ts` +- Test: `src/template.test.ts`(追記) + +**Interfaces:** +- Consumes: `PageOptions`(既存)、`POST /api/comment`(Task 4/6 の配線先; レスポンス `{ kind, url }` or `{ error }`) +- Produces: `PageOptions.postDoc?: string` — 設定するとページに選択→コメント UI が載り、この doc パスで `/api/comment` に POST する + +- [ ] **Step 1: Write the failing tests** + +`src/template.test.ts` に追記: + +```ts +describe("post ui", () => { + test("embeds the selection script with the doc path when postDoc is set", () => { + const page = buildPage("

hi

", [], "", { postDoc: "docs/a.md" }); + expect(page).toContain('data-mdp-doc="docs/a.md"'); + expect(page).toContain("mdp-post-btn"); + expect(page).toContain("/api/comment"); + }); + + test("escapes the doc path attribute", () => { + const page = buildPage("

hi

", [], "", { postDoc: 'a"b.md' }); + expect(page).toContain('data-mdp-doc="a"b.md"'); + }); + + test("omits the post ui when postDoc is not set", () => { + const page = buildPage("

hi

", [], ""); + expect(page).not.toContain("data-mdp-doc"); + expect(page).not.toContain("/api/comment"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd tools/mdp && bun test src/template.test.ts` +Expected: FAIL(`postDoc` 未実装) + +- [ ] **Step 3: Write the implementation** + +`src/template.ts` を変更する。 + +**(a)** `PageOptions` に追加: + +```ts + /** Doc path to post selection comments for; enables the posting UI. */ + postDoc?: string; +``` + +**(b)** `LAYOUT_CSS` の `.mdp-comments-rest` ブロックの後(`@media` の前)に追記: + +```css +#mdp-post-btn { + position: absolute; z-index: 20; padding: 4px 10px; + border: 1px solid #d0d7de; border-radius: 6px; background: #fff; color: #1f2328; + font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + cursor: pointer; box-shadow: 0 2px 8px rgba(31,35,40,0.18); +} +[data-theme="dark"] #mdp-post-btn { background: #161b22; color: #e6edf3; border-color: #30363d; } +.mdp-post-quote { + padding: 2px 8px; margin-bottom: 8px; border-left: 3px solid #d0d7de; + color: #656d76; overflow-wrap: anywhere; +} +[data-theme="dark"] .mdp-post-quote { border-color: #30363d; color: #9198a1; } +.mdp-post-text { + width: 100%; resize: vertical; padding: 6px 8px; + border: 1px solid #d0d7de; border-radius: 6px; + background: #fff; color: inherit; font: inherit; +} +[data-theme="dark"] .mdp-post-text { background: #0d1117; border-color: #30363d; } +.mdp-post-err { margin-top: 6px; color: #d1242f; } +[data-theme="dark"] .mdp-post-err { color: #f85149; } +.mdp-post-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; } +.mdp-post-actions button { + padding: 4px 12px; border-radius: 6px; border: 1px solid #d0d7de; + cursor: pointer; font: inherit; +} +.mdp-post-submit { background: #1f883d; border-color: #1f883d; color: #fff; } +.mdp-post-cancel { background: transparent; color: inherit; } +[data-theme="dark"] .mdp-post-actions button { border-color: #30363d; } +[data-theme="dark"] .mdp-post-submit { background: #238636; border-color: #238636; } +::highlight(mdp-range) { background: rgba(255, 212, 0, 0.35); } +[data-theme="dark"] ::highlight(mdp-range) { background: rgba(187, 128, 9, 0.4); } +``` + +**(c)** `COMMENTS_JS` に範囲ハイライトを足す。`function anchorFor(t){...}` の直前に追加: + +```js +function findRange(block,needle){ +if(!needle)return null; +var walker=document.createTreeWalker(block,NodeFilter.SHOW_TEXT); +var map=[],normed='',lastSpace=true,n; +while(n=walker.nextNode()){var s=n.nodeValue; +for(var i=0;i/g,'>').replace(/"/g,'"');} +var BLOCK='p,li,h1,h2,h3,h4,h5,h6,pre,blockquote,tr'; +function blockOf(node){ +var el=node.nodeType===1?node:node.parentElement; +if(!el)return null; +if(el.closest('.mdp-thread,.mdp-post-form'))return null; +var b=el.closest(BLOCK); +return b&&body.contains(b)?b:null;} +function insertAfter(ref,node){ +var t=ref; +if(t.tagName==='TR')t=t.closest('table')||t; +if(t.tagName==='LI'){t.appendChild(node);return;} +if(t.parentNode)t.parentNode.insertBefore(node,t.nextSibling);} +function highlight(range){ +if(!window.Highlight||!CSS.highlights||!range)return; +var h=CSS.highlights.get('mdp-range')||new Highlight(); +h.add(range);CSS.highlights.set('mdp-range',h);} +var btn=document.createElement('button'); +btn.type='button';btn.id='mdp-post-btn';btn.textContent='\\ud83d\\udcac Comment';btn.hidden=true; +document.body.appendChild(btn); +var pending=null; +document.addEventListener('mouseup',function(e){ +if(e.target===btn||btn.contains(e.target))return; +if(e.target.closest&&e.target.closest('.mdp-post-form'))return; +setTimeout(function(){ +var sel=window.getSelection(); +if(!sel||sel.isCollapsed||!sel.rangeCount){btn.hidden=true;return;} +var r=sel.getRangeAt(0); +var b1=blockOf(r.startContainer),b2=blockOf(r.endContainer); +var text=norm(sel.toString()); +if(!b1||b1!==b2||!text){btn.hidden=true;return;} +pending={block:b1,text:text,blockText:norm(b1.textContent),range:r.cloneRange()}; +var rect=r.getBoundingClientRect(); +btn.style.left=(rect.left+window.scrollX)+'px'; +btn.style.top=(rect.bottom+window.scrollY+6)+'px'; +btn.hidden=false;},0);}); +document.addEventListener('mousedown',function(e){ +if(e.target!==btn&&!btn.contains(e.target))btn.hidden=true; +var form=document.querySelector('.mdp-post-form'); +if(form&&!form.contains(e.target)&&!form.querySelector('.mdp-post-text').value)form.remove(); +},true); +btn.addEventListener('click',function(){ +if(!pending)return; +btn.hidden=true; +var p=pending;pending=null; +var old=document.querySelector('.mdp-post-form');if(old)old.remove(); +var form=document.createElement('div'); +form.className='mdp-thread mdp-post-form'; +form.innerHTML='
' ++'
'+esc(p.text)+'
' ++'' ++'' ++'
' ++'' ++'' ++'
'; +insertAfter(p.block,form); +var ta=form.querySelector('.mdp-post-text');ta.focus(); +function submit(){ +var text=ta.value.trim();if(!text)return; +var sb=form.querySelector('.mdp-post-submit');sb.disabled=true;sb.textContent='\\u2026'; +var err=form.querySelector('.mdp-post-err');err.hidden=true; +fetch('/api/comment',{method:'POST',headers:{'Content-Type':'application/json'}, +body:JSON.stringify({doc:DOC,selectedText:p.text,blockText:p.blockText,body:text})}) +.then(function(res){return res.json().then(function(j){return{ok:res.ok,j:j};});}) +.then(function(r){ +if(!r.ok)throw new Error(r.j&&r.j.error||'post failed'); +highlight(p.range); +var box=document.createElement('div'); +box.className='mdp-thread'; +box.innerHTML='
you ' ++'just now' ++(r.j.kind==='issue'?' PR comment':'') ++'
'+esc(text)+'
'; +form.replaceWith(box);}) +.catch(function(e){ +err.textContent=String(e&&e.message||e);err.hidden=false; +sb.disabled=false;sb.textContent='Comment';});} +form.querySelector('.mdp-post-cancel').addEventListener('click',function(){form.remove();}); +form.querySelector('.mdp-post-submit').addEventListener('click',submit); +ta.addEventListener('keydown',function(e){ +if(e.key==='Escape')form.remove(); +if(e.key==='Enter'&&(e.metaKey||e.ctrlKey))submit();}); +}); +})();`; +``` + +**(e)** `buildPage` に配線。ファイル末尾(`buildPage` の前)に属性エスケープを追加: + +```ts +function escapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +``` + +`commentsBlock` の宣言の後に: + +```ts + const postBlock = opts.postDoc + ? `` + : ""; +``` + +戻り値 HTML の `${commentsBlock}` の直後の行に `${postBlock}` を挿入: + +``` +${mermaidScript} +${commentsBlock} +${postBlock} + +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd tools/mdp && bun test src/template.test.ts` +Expected: PASS(既存テスト含む) + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/src/template.ts tools/mdp/src/template.test.ts +git commit -m "feat(mdp): selection comment form and range highlights" +``` + +--- + +### Task 6: mdp.ts の配線・README・E2E 確認 + +**Files:** +- Modify: `mdp.ts` +- Modify: `README.md` + +**Interfaces:** +- Consumes: Task 3 の `loadPostContext` / `postComment` / `parsePostRequest` / `PostContext`、Task 4 の `serve(routes, base, api)`、Task 5 の `PageOptions.postDoc` + +- [ ] **Step 1: Wire posting into main** + +`mdp.ts` の import に追加: + +```ts +import { + loadPostContext, + parsePostRequest, + postComment, + type PostContext, +} from "./src/post.ts"; +``` + +`main` 内、threads 取得ブロックの直後に: + +```ts + let postCtx: PostContext | undefined; + if (prUrl) { + try { + postCtx = await loadPostContext(prUrl, runGh); + } catch (e) { + console.error( + `mdp: warning: posting disabled: ${(e as Error)?.message ?? e}`, + ); + } + } +``` + +routes ループ内、`buildPage` のオプションに追加: + +```ts + postDoc: postCtx ? docs[i].name : undefined, +``` + +`serve` の呼び出しを変更(`await serve(routes, base);` を置き換え): + +```ts + const ctx = postCtx; + const api = ctx + ? async (pathname: string, body: unknown): Promise => { + if (pathname !== "/api/comment") return null; + const req = parsePostRequest(body); + if (!req) { + return Response.json({ error: "invalid request" }, { status: 400 }); + } + const doc = docs.find((d) => d.name === req.doc); + if (!doc) { + return Response.json( + { error: `unknown doc: ${req.doc}` }, + { status: 400 }, + ); + } + try { + return Response.json(await postComment(req, ctx, doc.markdown, runGh)); + } catch (e) { + return Response.json( + { error: (e as Error)?.message ?? String(e) }, + { status: 500 }, + ); + } + } + : undefined; + + await serve(routes, base, api); +``` + +- [ ] **Step 2: Run the full test suite and typecheck** + +Run: `cd tools/mdp && bun test && bunx tsc --noEmit` +Expected: 全テスト PASS、型エラーなし + +- [ ] **Step 3: Update README** + +`README.md` の「When previewing a PR, inline review comments ...」段落の後に追記: + +```markdown +You can also post comments from the preview: select text in the document and +click the floating **💬 Comment** button. The comment lands on the PR as an +inline review comment on the selected line (or as a regular PR comment when +the line isn't part of the diff), quoting the selected range; mdp highlights +that exact range when the preview is opened again. +``` + +- [ ] **Step 4: E2E 確認** + +自分の PR(またはテスト用 PR)で確認する: + +```sh +cd tools/mdp && ./mdp.ts +``` + +確認項目: +- テキスト選択でボタンが出る。複数ブロックまたぎ・スレッドボックス内では出ない +- diff 内の行 → インラインレビューコメントが付く(GitHub 上で引用+本文が見える、マーカーは不可視) +- diff 外の行 → `**path:line**` 付きの通常 PR コメントになる +- 投稿後、その場でボックス化+選択範囲がハイライトされる +- mdp を再起動すると、投稿したコメントがスレッド表示され、範囲がハイライトされる +- Cmd+Enter 送信、Esc/Cancel クローズ、空フォームの外側クリッククローズ +- 非 PR 入力(`mdp README.md`)で UI が出ない・従来どおり動く + +- [ ] **Step 5: Commit** + +```bash +git add tools/mdp/mdp.ts tools/mdp/README.md +git commit -m "feat(mdp): post range comments from PR previews" +``` diff --git a/docs/superpowers/plans/2026-08-04-mdp-gdocs-layout.md b/docs/superpowers/plans/2026-08-04-mdp-gdocs-layout.md new file mode 100644 index 0000000..a8042ed --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-mdp-gdocs-layout.md @@ -0,0 +1,58 @@ +# mdp Google Docs 風レイアウト Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** コメントを右マージンカラムへ、Outline を左へ、ファイルを上部タブへ移すレイアウト刷新。 + +**Architecture:** `buildPage` の構造を「header → タブバー → [toc | 本文 | gutter]」に変更。gutter 内のカード配置は共有クライアント JS(GUTTER_JS)が `window.mdpGutter = { add, remove, layout }` として提供し、COMMENTS_JS(既存スレッド)と POST_JS(投稿フォーム)がそれを使う。サーバー・API・マーカー形式は変更なし。 + +**Tech Stack:** 既存どおり(Bun + TS、クライアント JS は文字列定数)。 + +Spec: `docs/superpowers/specs/2026-08-04-mdp-gdocs-layout-design.md` + +注: クライアント JS はユニットテスト対象外(既存方針どおり)のため、本プランは構造・テスト変更を規定し、JS 本文は実装時に書く。 + +## Global Constraints + +- 依存追加なし。`bun test` + `tsc --noEmit` green を維持 +- 挙動の詳細(配置・再配置トリガ・狭い画面)は spec に従う + +--- + +### Task 1: buildPage 構造と CSS(タブバー / toc 左 / gutter) + +**Files:** +- Modify: `src/template.ts`, `mdp.ts` +- Test: `src/template.test.ts` + +**Steps:** +- [ ] template.test.ts を更新: files→`mdp-tabs`(タブバー、layout より前)、toc は article より前、gutter は comments/postDoc 指定時のみ `id="mdp-gutter"`。実行して FAIL 確認 +- [ ] template.ts: `.mdp-files*` CSS を `.mdp-tabs` に置換、`.mdp-toc` を border-right の左カラムに、`.mdp-gutter`/`.mdp-card`/`.mdp-anchor-hover`/`.mdp-gutter-divider` CSS 追加、本文 max-width 900px、media query を 1280px(toc)/1000px(gutter+ボタン)に変更。buildPage の HTML を「header → tabs → layout(toc, article, gutter)」に変更 +- [ ] mdp.ts: `filesNav` を `` タブ列に変更し、`docs.length > 1` のときだけ渡す +- [ ] `bun test src/template.test.ts` PASS → commit "feat(mdp): tabs-top / outline-left / comments-gutter layout" + +### Task 2: GUTTER_JS と COMMENTS_JS の gutter 対応 + +**Files:** +- Modify: `src/template.ts` + +**Steps:** +- [ ] GUTTER_JS 追加(comments || postDoc のとき COMMENTS_JS/POST_JS より前に挿入): カード管理・アンカー整列(重なりは押し下げ)・loose カードの「Other comments」区切り・ホバーで `.mdp-anchor-hover`・クリックでアンカーへスクロール・resize/load/テーマ切替で再レイアウト +- [ ] COMMENTS_JS: インライン挿入(insertAfter・rest セクション)を廃止し `mdpGutter.add(build(t), anchorFor(t))` に。resolved 開閉時に `mdpGutter.layout()` +- [ ] `bun test` PASS → commit "feat(mdp): place review threads in the margin gutter" + +### Task 3: POST_JS の gutter 対応 + +**Files:** +- Modify: `src/template.ts` + +**Steps:** +- [ ] フォームを `mdpGutter.add(form, block)` で右欄に出す。Cancel/Esc/空で外側クリック → `mdpGutter.remove`。textarea input で layout()。成功時はフォーム要素をカード内容に書き換えて layout()。`blockOf` は `.mdp-gutter` 内を除外 +- [ ] `bun test` + `tsc --noEmit` PASS → commit "feat(mdp): post form lives in the margin gutter" + +### Task 4: E2E・README + +**Steps:** +- [ ] 実 PR でタブ・左 Outline・右コメント欄・投稿フォームの配置を確認(投稿はしない)。非 PR 入力で gutter/タブが出ないこと +- [ ] README のレイアウト説明を更新(top tabs / left Outline / right margin comments) +- [ ] commit "docs(mdp): describe the gdocs-style layout" diff --git a/docs/superpowers/specs/2026-06-23-mdp-markdown-preview-design.md b/docs/superpowers/specs/2026-06-23-mdp-markdown-preview-design.md new file mode 100644 index 0000000..cfcd31f --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-mdp-markdown-preview-design.md @@ -0,0 +1,117 @@ +# mdp — GitHub Markdown Preview (design) + +Date: 2026-06-23 + +## 目的 + +GitHub からダウンロードした (あるいはローカルの) Markdown を、GitHub と同じ見た目でブラウザにプレビューする CLI ツール。主用途はパイプ入力: + +```sh +curl -sL https://raw.githubusercontent.com/owner/repo/main/README.md | mdp - +``` + +カスタム CSS で見た目を上書きできる: + +```sh +mdp README.md --css 'h1 { color: red }' --css '.markdown-body { max-width: 1000px }' +``` + +## 配置 + +既存ツールと同じく `tools/mdp/` に bun CLI として置く。`package.json` の `bin` で `mdp` を公開。 + +``` +tools/mdp/ + mdp.ts # エントリ: argパース → 入力 → render → serve → open + src/ + input.ts # stdin / file / URL から { markdown, base } を解決 + render.ts # GitHub API /markdown に POST して HTML を得る + template.ts # github-markdown-css + 注入CSS でHTMLページ組み立て + server.ts # Bun.serve でページ + 相対アセット配信、openで起動 + package.json + tsconfig.json +``` + +## コンポーネント + +### input.ts — 入力解決 + +`resolveInput(arg?: string): Promise<{ markdown: string; base: Base }>` + +分岐ルール: + +1. `arg === "-"`、または `arg` が無く stdin が非 TTY → **stdin** を全部読む。base なし +2. `arg` が `http://` / `https://` で始まる → **URL**。`fetch` で取得。 + - GitHub の `blob` URL (`github.com/o/r/blob/ref/path`) は raw (`raw.githubusercontent.com/o/r/ref/path`) に正規化してから取得 + - base = 取得元の raw ディレクトリ URL (相対画像解決用) +3. それ以外 → **ローカルファイル**。読み込み、base = ファイルの親ディレクトリ (絶対パス) + +`Base` は `{ kind: "none" } | { kind: "url"; dir: string } | { kind: "file"; dir: string }`。 + +### render.ts — GitHub API でレンダリング + +`renderMarkdown(markdown: string): Promise` (HTML 断片を返す) + +- `POST https://api.github.com/markdown`、body `{ text, mode: "gfm" }`、`Accept: application/vnd.github+json` +- `GITHUB_TOKEN` または `GH_TOKEN` 環境変数があれば `Authorization: Bearer ` を付与 (未認証 60 req/h → 認証 5000 req/h) +- 失敗時 (401/403/429/5xx/ネットワーク) は status と短い理由を添えて throw。レート制限超過は分かりやすいメッセージにする + +### template.ts — HTML ページ組み立て + +`buildPage(htmlFragment: string, customCss: string[]): string` + +- `github-markdown-css` の CSS を `` にインライン (依存として bundle、オフラインでも動くように) +- 本文を `
` でラップ +- `github-markdown-css` の **後ろ** に `--css` の生 CSS 群を順に ``) + .join("\n"); + const dark = opts.darkCss ? `` : ""; + const title = opts.title ?? "mdp"; + const sidebar = opts.sidebar + ? `` + : ""; + const gutter = + opts.comments || opts.postDoc || opts.prComments + ? `` + : ""; + const mermaidScript = opts.mermaid + ? `` + : ""; + const prCommentsJson = opts.prComments + ? `` + : ""; + const commentsBlock = + opts.comments || opts.prComments + ? `${ + opts.comments + ? `` + : "" + }${prCommentsJson} +` + : ""; + const postBlock = opts.postDoc + ? `` + : ""; + const gutterScript = gutter ? `` : ""; + const prLink = opts.prUrl + ? `
` + + `` + : ""; + + return ` + + + + + +${title} + + +${dark} + +${custom} + + +
+${title} +
+${prLink} + + +
+
+
+${sidebar} +
+${fragment} +
+${gutter} +
+${mermaidScript} +${gutterScript} +${postBlock} +${commentsBlock} + + + +`; +} diff --git a/tools/mdp/src/toc.test.ts b/tools/mdp/src/toc.test.ts new file mode 100644 index 0000000..6bb8f06 --- /dev/null +++ b/tools/mdp/src/toc.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { buildToc } from "./toc.ts"; + +describe("buildToc", () => { + test("returns empty toc and unchanged fragment when there are no headings", () => { + const { fragment, toc } = buildToc("

hi

"); + expect(toc).toBe(""); + expect(fragment).toBe("

hi

"); + }); + + test("adds sequential ids to headings", () => { + const { fragment } = buildToc("

One

Two

"); + expect(fragment).toContain('

One

'); + expect(fragment).toContain('

Two

'); + }); + + test("builds a list of links pointing at the heading ids", () => { + const { toc } = buildToc("

One

Two

"); + expect(toc).toContain('One'); + expect(toc).toContain('Two'); + }); + + test("records the heading level for indentation", () => { + const { toc } = buildToc("

One

Deep

"); + expect(toc).toContain("mdp-toc-lvl1"); + expect(toc).toContain("mdp-toc-lvl3"); + }); + + test("uses the text content of headings that contain markup", () => { + const { toc } = buildToc("

Use bm sync now

"); + expect(toc).toContain(">Use bm sync now"); + }); + + test("escapes special characters in the toc text", () => { + const { toc } = buildToc("

A & B < C

"); + expect(toc).toContain("A & B < C"); + }); + + test("preserves existing heading attributes when adding an id", () => { + const { fragment } = buildToc('

One

'); + expect(fragment).toContain('class="x"'); + expect(fragment).toContain('id="mdp-h0"'); + }); +}); diff --git a/tools/mdp/src/toc.ts b/tools/mdp/src/toc.ts new file mode 100644 index 0000000..8bc0811 --- /dev/null +++ b/tools/mdp/src/toc.ts @@ -0,0 +1,35 @@ +type TocItem = { level: number; id: string; text: string }; + +/** + * Add stable ids to every heading in the rendered fragment and build a flat, + * level-tagged table of contents linking to them. Heading text is taken from + * the inner HTML with tags stripped; entities are already escaped by the API, + * so the text is reused as-is. + */ +export function buildToc(fragment: string): { fragment: string; toc: string } { + const items: TocItem[] = []; + let i = 0; + + const out = fragment.replace( + /]*)>([\s\S]*?)<\/h\1>/g, + (_m, level: string, attrs: string, inner: string) => { + const id = `mdp-h${i++}`; + items.push({ + level: Number(level), + id, + text: inner.replace(/<[^>]+>/g, "").trim(), + }); + return `${inner}`; + }, + ); + + if (items.length === 0) return { fragment: out, toc: "" }; + + const lis = items + .map( + (it) => + `
  • ${it.text}
  • `, + ) + .join("\n"); + return { fragment: out, toc: `
      \n${lis}\n
    ` }; +} diff --git a/tools/mdp/themes/designdoc.css b/tools/mdp/themes/designdoc.css new file mode 100644 index 0000000..2dfe777 --- /dev/null +++ b/tools/mdp/themes/designdoc.css @@ -0,0 +1,156 @@ +/* DesignDoc theme — mimics the Google Docs "DesignDoc" look. */ + +.markdown-body { + font-family: "Helvetica Neue", Arial, "Hiragino Sans", + "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif; + color: #222; + background: #fff; + line-height: 1.3; + letter-spacing: 0.02em; + font-weight: 400; +} + +/* コードは字間を広げない */ +.markdown-body pre, +.markdown-body code { + letter-spacing: normal; +} + +/* Go easy on bold — lots of **strong** gets tiring at full weight. */ +.markdown-body strong { + font-weight: 500; +} + +/* Green headings, no underline, lighter weight — like Google Docs headings. */ +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + color: #38761d; + font-weight: 400; + border-bottom: none; + padding-bottom: 0; + margin-top: 1.4em; +} + +/* 見出しの緑は h1 だけ。h2 以下はグレーで控えめに。 */ +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + color: #5f6368; +} + +.markdown-body h1 { + font-size: 1.9em; +} + +/* The very first H1 is the document title — make it large like the Doc title. */ +.markdown-body h1:first-of-type { + font-size: 2.2em; + margin-top: 0.2em; +} + +.markdown-body h2 { + font-size: 1.5em; +} + +/* Helper / template text is wrapped in _italics_; make it small and faint. */ +.markdown-body em { + color: #8a8a8a; + font-size: 13px; /* smaller than the 16px body */ + line-height: 1.35; /* tighten the helper-text paragraphs */ +} + +/* Bold "※" notes that trail the italic helper text get the same subdued look. */ +.markdown-body p em ~ strong { + color: #8a8a8a; + font-size: 13px; + font-weight: 400; +} + +/* Inline code in the same green as the headings. */ +.markdown-body code { + color: #38761d; +} + +/* Code blocks: dark background with GitHub-dark syntax highlighting. + Language-tagged blocks come wrapped in .highlight, which the base stylesheet + targets more specifically, so override that selector too. */ +.markdown-body pre, +.markdown-body .highlight pre { + background: #22272e; + border-radius: 10px; + padding: 16px 20px; + color: #adbac7; +} +.markdown-body pre code { + color: #adbac7; +} +.markdown-body pre .pl-c { + color: #9198a1; +} +.markdown-body pre .pl-c1, +.markdown-body pre .pl-s .pl-v { + color: #79c0ff; +} +.markdown-body pre .pl-e, +.markdown-body pre .pl-en { + color: #d2a8ff; +} +.markdown-body pre .pl-smi, +.markdown-body pre .pl-s .pl-s1 { + color: #f0f6fc; +} +.markdown-body pre .pl-ent { + color: #7ee787; +} +.markdown-body pre .pl-k { + color: #ff7b72; +} +.markdown-body pre .pl-s, +.markdown-body pre .pl-pds, +.markdown-body pre .pl-sr { + color: #a5d6ff; +} +.markdown-body pre .pl-v, +.markdown-body pre .pl-smw { + color: #ffa657; +} + +/* Dark mode: keep the green accent (brighter), dark canvas, light body text. + Code blocks are already dark, so they carry over unchanged. */ +[data-theme="dark"] .markdown-body { + background: #0d1117; + color: #c9d1d9; +} +[data-theme="dark"] .markdown-body h1, +[data-theme="dark"] .markdown-body h2, +[data-theme="dark"] .markdown-body h3, +[data-theme="dark"] .markdown-body h4, +[data-theme="dark"] .markdown-body h5, +[data-theme="dark"] .markdown-body h6, +[data-theme="dark"] .markdown-body code { + color: #7ee787; +} +[data-theme="dark"] .markdown-body h2, +[data-theme="dark"] .markdown-body h3, +[data-theme="dark"] .markdown-body h4, +[data-theme="dark"] .markdown-body h5, +[data-theme="dark"] .markdown-body h6 { + color: #9198a1; +} +[data-theme="dark"] .markdown-body h1, +[data-theme="dark"] .markdown-body h2 { + border-bottom: none; +} +[data-theme="dark"] .markdown-body em, +[data-theme="dark"] .markdown-body p em ~ strong { + color: #768390; +} +[data-theme="dark"] .markdown-body a { + color: #4493f8; +} diff --git a/tools/mdp/tsconfig.json b/tools/mdp/tsconfig.json new file mode 100644 index 0000000..5a88b41 --- /dev/null +++ b/tools/mdp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun-types"] + } +} From 4dd6986417974187f9af2df91979379883e01533 Mon Sep 17 00:00:00 2001 From: Yuma Ishikawa Date: Wed, 5 Aug 2026 17:21:03 +0900 Subject: [PATCH 2/5] fix(mdp): fall back to the gh cli token for rendering Co-Authored-By: Claude Fable 5 --- tools/mdp/mdp.ts | 6 ++++-- tools/mdp/src/render.ts | 6 +++++- tools/mdp/src/token.test.ts | 40 +++++++++++++++++++++++++++++++++++++ tools/mdp/src/token.ts | 21 +++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 tools/mdp/src/token.test.ts create mode 100644 tools/mdp/src/token.ts diff --git a/tools/mdp/mdp.ts b/tools/mdp/mdp.ts index 8013ee3..b4c2037 100755 --- a/tools/mdp/mdp.ts +++ b/tools/mdp/mdp.ts @@ -30,6 +30,7 @@ import type { Base } from "./src/server.ts"; import { serve } from "./src/server.ts"; import { tabLabels } from "./src/tabs.ts"; import { buildPage } from "./src/template.ts"; +import { resolveToken } from "./src/token.ts"; import { buildToc } from "./src/toc.ts"; const DEFAULT_THEME = "designdoc"; @@ -51,7 +52,8 @@ Options: -h, --help show this help Environment: - GITHUB_TOKEN / GH_TOKEN raise the GitHub API rate limit + GITHUB_TOKEN / GH_TOKEN token for the rendering API + (falls back to \`gh auth token\`) `; async function loadGitHubCss(): Promise { @@ -177,7 +179,7 @@ async function main(): Promise { const cssBlocks: string[] = [await loadTheme(args.theme ?? DEFAULT_THEME)]; const githubCss = await loadGitHubCss(); const darkCss = await loadDarkCss(); - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const token = await resolveToken(process.env, runGh); let threads: ReviewThread[] = []; if (prUrl) { diff --git a/tools/mdp/src/render.ts b/tools/mdp/src/render.ts index af2af80..e77ed43 100644 --- a/tools/mdp/src/render.ts +++ b/tools/mdp/src/render.ts @@ -34,7 +34,11 @@ export async function renderMarkdown( if (!res.ok) { if (res.status === 403 || res.status === 429) { throw new Error( - `GitHub API rate limit hit (HTTP ${res.status}). Set GITHUB_TOKEN to raise the limit.`, + `GitHub API rate limit hit (HTTP ${res.status}). ${ + opts.token + ? "Wait for the limit to reset." + : "Run `gh auth login`, or set GITHUB_TOKEN, to raise the limit." + }`, ); } throw new Error(`GitHub markdown API failed: HTTP ${res.status}`); diff --git a/tools/mdp/src/token.test.ts b/tools/mdp/src/token.test.ts new file mode 100644 index 0000000..07b9b6c --- /dev/null +++ b/tools/mdp/src/token.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { resolveToken } from "./token.ts"; + +describe("resolveToken", () => { + const never = async () => { + throw new Error("gh should not be called"); + }; + + test("prefers GITHUB_TOKEN, then GH_TOKEN", async () => { + expect( + await resolveToken({ GITHUB_TOKEN: "a", GH_TOKEN: "b" }, never), + ).toBe("a"); + expect(await resolveToken({ GH_TOKEN: "b" }, never)).toBe("b"); + }); + + test("falls back to the gh cli token", async () => { + let seen: string[] = []; + const token = await resolveToken({}, async (args) => { + seen = args; + return "gho_fromgh\n"; + }); + expect(seen).toEqual(["auth", "token"]); + expect(token).toBe("gho_fromgh"); + }); + + test("ignores blank env values", async () => { + expect( + await resolveToken({ GITHUB_TOKEN: "", GH_TOKEN: " " }, async () => "gh_t"), + ).toBe("gh_t"); + }); + + test("returns undefined when gh has no token", async () => { + expect(await resolveToken({}, async () => " ")).toBeUndefined(); + expect( + await resolveToken({}, async () => { + throw new Error("not logged in"); + }), + ).toBeUndefined(); + }); +}); diff --git a/tools/mdp/src/token.ts b/tools/mdp/src/token.ts new file mode 100644 index 0000000..c64c27b --- /dev/null +++ b/tools/mdp/src/token.ts @@ -0,0 +1,21 @@ +import type { RunGh } from "./pr.ts"; + +/** + * レンダリング API 用のトークン。環境変数が無ければ `gh auth token` に頼る + * (gh でログイン済みなら 60 req/h の未認証枠を踏まずに済む)。 + */ +export async function resolveToken( + env: Record, + runGh: RunGh, +): Promise { + const fromEnv = [env.GITHUB_TOKEN, env.GH_TOKEN] + .map((v) => v?.trim()) + .find((v) => v); + if (fromEnv) return fromEnv; + try { + const out = (await runGh(["auth", "token"])).trim(); + return out === "" ? undefined : out; + } catch { + return undefined; + } +} From f2d2820e02e9556ae7a6dd855287abfe6453954c Mon Sep 17 00:00:00 2001 From: Yuma Ishikawa Date: Thu, 13 Aug 2026 17:47:22 +0900 Subject: [PATCH 3/5] fix(mdp): accept longer and tilde mermaid fences Co-Authored-By: Claude Fable 5 --- tools/mdp/src/mermaid.test.ts | 13 +++++++++++++ tools/mdp/src/mermaid.ts | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tools/mdp/src/mermaid.test.ts b/tools/mdp/src/mermaid.test.ts index aad4dce..745c0e7 100644 --- a/tools/mdp/src/mermaid.test.ts +++ b/tools/mdp/src/mermaid.test.ts @@ -36,6 +36,19 @@ describe("extractMermaid", () => { expect(markdown).toBe(md); }); + test("extracts fences longer than three backticks", () => { + const md = "````mermaid\nflowchart TD\n a --> b\n````\n"; + const { markdown, blocks } = extractMermaid(md); + expect(blocks).toEqual(["flowchart TD\n a --> b"]); + expect(markdown).not.toContain("mermaid\n"); + }); + + test("extracts tilde fences", () => { + const md = "~~~mermaid\nstateDiagram-v2\n [*] --> A\n~~~\n"; + const { blocks } = extractMermaid(md); + expect(blocks).toEqual(["stateDiagram-v2\n [*] --> A"]); + }); + test("extracts an indented fence but requires line-start fences", () => { const md = "- item\n\n ```mermaid\n graph LR\n x-->y\n ```\n"; const { blocks } = extractMermaid(md); diff --git a/tools/mdp/src/mermaid.ts b/tools/mdp/src/mermaid.ts index 7f60c80..0a0c048 100644 --- a/tools/mdp/src/mermaid.ts +++ b/tools/mdp/src/mermaid.ts @@ -1,5 +1,7 @@ // 行頭(+インデント)のフェンスのみ。インラインコード中の ```mermaid 言及は対象外。 -const FENCE = /^[ \t]*```[ \t]*mermaid[^\n]*\n([\s\S]*?)^[ \t]*```[ \t]*$/gm; +// CommonMark どおり ``` / ~~~ どちらも、3 文字以上の長さを受ける。 +const FENCE = + /^[ \t]*(?:`{3,}|~{3,})[ \t]*mermaid[^\n]*\n([\s\S]*?)^[ \t]*(?:`{3,}|~{3,})[ \t]*$/gm; function escapeHtml(s: string): string { return s From 12de3c9435f2b3bbf077b3dc2e5fb193d46c1c86 Mon Sep 17 00:00:00 2001 From: Yuma Ishikawa Date: Thu, 13 Aug 2026 18:14:49 +0900 Subject: [PATCH 4/5] fix(mdp): keep hidden pickers and buttons hidden Co-Authored-By: Claude Fable 5 --- tools/mdp/src/template.test.ts | 7 +++++++ tools/mdp/src/template.ts | 2 ++ 2 files changed, 9 insertions(+) diff --git a/tools/mdp/src/template.test.ts b/tools/mdp/src/template.test.ts index 9513b1f..20c55d3 100644 --- a/tools/mdp/src/template.test.ts +++ b/tools/mdp/src/template.test.ts @@ -77,6 +77,13 @@ describe("buildPage", () => { expect(html).not.toContain("cdn.jsdelivr.net/npm/mermaid"); }); + test("keeps [hidden] elements hidden despite author display rules", () => { + const html = buildPage("x", [], "", { postDoc: "a.md" }); + // .mdp-react-picker / #mdp-post-btn / .mdp-post-actions all set display, + // which would otherwise beat the UA stylesheet's [hidden] rule. + expect(html).toContain("[hidden] { display: none !important; }"); + }); + test("includes the mermaid script when requested", () => { const html = buildPage("x", [], "", { mermaid: true }); expect(html).toContain("cdn.jsdelivr.net/npm/mermaid"); diff --git a/tools/mdp/src/template.ts b/tools/mdp/src/template.ts index e40509b..0062314 100644 --- a/tools/mdp/src/template.ts +++ b/tools/mdp/src/template.ts @@ -19,6 +19,8 @@ export type PageOptions = { /** Layout, header, toc and dark/light chrome — independent of the markdown theme. */ const LAYOUT_CSS = ` * { box-sizing: border-box; } +/* Our display:flex rules would otherwise beat the UA sheet's [hidden]. */ +[hidden] { display: none !important; } html { scroll-behavior: smooth; } body { margin: 0; background: #fff; color: #1f2328; } [data-theme="light"] { color-scheme: light; } From 9eb370e37da382c2425f432be21ba0953606a9ef Mon Sep 17 00:00:00 2001 From: Yuma Ishikawa Date: Thu, 13 Aug 2026 19:43:22 +0900 Subject: [PATCH 5/5] fix(mdp): stop mangling snake_case when matching source lines Co-Authored-By: Claude Fable 5 --- tools/mdp/src/comments.test.ts | 12 ++++++++++++ tools/mdp/src/comments.ts | 16 ++++++++++++++-- tools/mdp/src/post.test.ts | 7 +++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tools/mdp/src/comments.test.ts b/tools/mdp/src/comments.test.ts index 0de8e5c..2856322 100644 --- a/tools/mdp/src/comments.test.ts +++ b/tools/mdp/src/comments.test.ts @@ -73,6 +73,18 @@ describe("stripMarkdownLine", () => { ); }); + test("keeps underscores inside words (snake_case is not emphasis)", () => { + expect(stripMarkdownLine(" pull_request:")).toBe(" pull_request:"); + expect(stripMarkdownLine("auto_dismiss と snake_case_name")).toBe( + "auto_dismiss と snake_case_name", + ); + }); + + test("still strips underscore emphasis at word boundaries", () => { + expect(stripMarkdownLine("use _emphasis_ here")).toBe("use emphasis here"); + expect(stripMarkdownLine("__bold__ text")).toBe("bold text"); + }); + test("turns table pipes into spaces", () => { expect(normalizeText(stripMarkdownLine("| a | b |"))).toBe("a b"); }); diff --git a/tools/mdp/src/comments.ts b/tools/mdp/src/comments.ts index 864f9fc..ec8685e 100644 --- a/tools/mdp/src/comments.ts +++ b/tools/mdp/src/comments.ts @@ -62,14 +62,26 @@ export function parsePrUrl( return m ? { owner: m[1], repo: m[2], number: Number(m[3]) } : null; } -/** Strip leading block markers and inline markdown syntax from a source line. */ +const WORD = /[A-Za-z0-9]/; + +/** + * Strip leading block markers and inline markdown syntax from a source line. + * Underscores between word characters are kept: `pull_request` is an + * identifier, not emphasis (CommonMark forbids intraword `_` emphasis), and + * mangling it would break matching against the rendered text. + */ export function stripMarkdownLine(s: string): string { return s .replace(/^\s*#{1,6}\s+/, "") .replace(/^\s*>\s*/, "") .replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/, "") .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/(\*\*|__|~~|\*|_|`)/g, "") + .replace(/(\*\*|~~|\*|`)/g, "") + .replace(/__?/g, (m, offset: number, whole: string) => + WORD.test(whole[offset - 1] ?? "") && WORD.test(whole[offset + m.length] ?? "") + ? m + : "", + ) .replace(/\|/g, " "); } diff --git a/tools/mdp/src/post.test.ts b/tools/mdp/src/post.test.ts index f8a4916..187776d 100644 --- a/tools/mdp/src/post.test.ts +++ b/tools/mdp/src/post.test.ts @@ -81,6 +81,13 @@ describe("findSourceLine", () => { expect(findSourceLine(md, "Title", "Title")).toBe(1); }); + test("maps a selection inside snake_case code", () => { + const yaml = "# T\n\n```yaml\non:\n pull_request:\n types: [opened]\n```\n"; + expect( + findSourceLine(yaml, "pull_request", "on: pull_request: types: [opened]"), + ).toBe(5); + }); + test("returns null when nothing matches", () => { expect(findSourceLine(md, "nonexistent", "zzz")).toBeNull(); expect(findSourceLine(md, "", "First paragraph line one")).toBeNull();