@suzumiyaaoba/mdxr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/dist/cli.d.mts +1 -0
  4. package/dist/cli.mjs +2902 -0
  5. package/dist/components.d.mts +791 -0
  6. package/dist/components.mjs +2 -0
  7. package/dist/config-SI9IyFiC.mjs +184 -0
  8. package/dist/doc-context-CEqzMYKv.d.mts +568 -0
  9. package/dist/index.d.mts +20 -0
  10. package/dist/index.mjs +4 -0
  11. package/dist/ui-GKFD0mx5.mjs +15986 -0
  12. package/package.json +131 -0
  13. package/skill/SKILL.md +49 -0
  14. package/skill/references/components/charts.md +172 -0
  15. package/skill/references/components/document.md +98 -0
  16. package/skill/references/components/forms.md +35 -0
  17. package/skill/references/components/investigation.md +198 -0
  18. package/skill/references/components/layout.md +68 -0
  19. package/skill/references/components/output.md +158 -0
  20. package/skill/references/components/planning.md +155 -0
  21. package/skill/references/components/reports.md +252 -0
  22. package/skill/references/components/shadcn.md +19 -0
  23. package/skill/references/components.md +186 -0
  24. package/skill/references/extending.md +48 -0
  25. package/src/ask-sheet.ts +40 -0
  26. package/src/assets/css.ts +392 -0
  27. package/src/assets/scripts.ts +78 -0
  28. package/src/catalog.ts +287 -0
  29. package/src/cli.ts +178 -0
  30. package/src/client/doc-events.ts +1108 -0
  31. package/src/client/entry.ts +27 -0
  32. package/src/client-js.ts +47 -0
  33. package/src/component-map.ts +47 -0
  34. package/src/components/ui/accordion.tsx +77 -0
  35. package/src/components/ui/alert-dialog.tsx +185 -0
  36. package/src/components/ui/alert.tsx +76 -0
  37. package/src/components/ui/aspect-ratio.tsx +22 -0
  38. package/src/components/ui/attachment.tsx +208 -0
  39. package/src/components/ui/avatar.tsx +106 -0
  40. package/src/components/ui/badge.tsx +52 -0
  41. package/src/components/ui/breadcrumb.tsx +121 -0
  42. package/src/components/ui/bubble.tsx +128 -0
  43. package/src/components/ui/button-group.tsx +88 -0
  44. package/src/components/ui/button.tsx +58 -0
  45. package/src/components/ui/calendar.tsx +226 -0
  46. package/src/components/ui/card.tsx +102 -0
  47. package/src/components/ui/carousel.tsx +246 -0
  48. package/src/components/ui/chart.tsx +379 -0
  49. package/src/components/ui/checkbox.tsx +27 -0
  50. package/src/components/ui/collapsible.tsx +19 -0
  51. package/src/components/ui/combobox.tsx +298 -0
  52. package/src/components/ui/command.tsx +193 -0
  53. package/src/components/ui/context-menu.tsx +271 -0
  54. package/src/components/ui/dialog.tsx +159 -0
  55. package/src/components/ui/direction.tsx +4 -0
  56. package/src/components/ui/drawer.tsx +227 -0
  57. package/src/components/ui/dropdown-menu.tsx +269 -0
  58. package/src/components/ui/empty.tsx +104 -0
  59. package/src/components/ui/field.tsx +237 -0
  60. package/src/components/ui/fieldset.tsx +32 -0
  61. package/src/components/ui/frame.tsx +87 -0
  62. package/src/components/ui/hover-card.tsx +50 -0
  63. package/src/components/ui/input-group.tsx +159 -0
  64. package/src/components/ui/input-otp.tsx +83 -0
  65. package/src/components/ui/input.tsx +19 -0
  66. package/src/components/ui/item.tsx +202 -0
  67. package/src/components/ui/kbd.tsx +26 -0
  68. package/src/components/ui/label.tsx +19 -0
  69. package/src/components/ui/marker.tsx +71 -0
  70. package/src/components/ui/menubar.tsx +284 -0
  71. package/src/components/ui/message-scroller.tsx +128 -0
  72. package/src/components/ui/message.tsx +91 -0
  73. package/src/components/ui/meter.tsx +80 -0
  74. package/src/components/ui/native-select.tsx +64 -0
  75. package/src/components/ui/navigation-menu.tsx +170 -0
  76. package/src/components/ui/pagination.tsx +133 -0
  77. package/src/components/ui/popover.tsx +87 -0
  78. package/src/components/ui/progress.tsx +82 -0
  79. package/src/components/ui/questionnaire.tsx +328 -0
  80. package/src/components/ui/radio-group.tsx +35 -0
  81. package/src/components/ui/resizable.tsx +49 -0
  82. package/src/components/ui/scroll-area.tsx +50 -0
  83. package/src/components/ui/select.tsx +201 -0
  84. package/src/components/ui/separator.tsx +22 -0
  85. package/src/components/ui/sheet.tsx +135 -0
  86. package/src/components/ui/sidebar.tsx +730 -0
  87. package/src/components/ui/skeleton.tsx +13 -0
  88. package/src/components/ui/slider.tsx +51 -0
  89. package/src/components/ui/spinner.tsx +16 -0
  90. package/src/components/ui/switch.tsx +31 -0
  91. package/src/components/ui/table.tsx +113 -0
  92. package/src/components/ui/tabs.tsx +82 -0
  93. package/src/components/ui/textarea.tsx +17 -0
  94. package/src/components/ui/toast.tsx +229 -0
  95. package/src/components/ui/toggle-group.tsx +87 -0
  96. package/src/components/ui/toggle.tsx +43 -0
  97. package/src/components/ui/tooltip.tsx +65 -0
  98. package/src/components.ts +78 -0
  99. package/src/config.ts +56 -0
  100. package/src/define.ts +122 -0
  101. package/src/doc-context.ts +20 -0
  102. package/src/editor.ts +94 -0
  103. package/src/format-error.ts +78 -0
  104. package/src/guards.ts +84 -0
  105. package/src/hooks/use-mobile.ts +21 -0
  106. package/src/html.ts +91 -0
  107. package/src/hydrate/export-index.ts +232 -0
  108. package/src/hydrate/import-scan.ts +169 -0
  109. package/src/hydrate/plugins.ts +118 -0
  110. package/src/hydrate/runtime-module.ts +145 -0
  111. package/src/hydrate-runtime.ts +67 -0
  112. package/src/hydrate.ts +147 -0
  113. package/src/index.ts +16 -0
  114. package/src/init.ts +57 -0
  115. package/src/langs.ts +148 -0
  116. package/src/lines.ts +53 -0
  117. package/src/load-user-module.ts +191 -0
  118. package/src/mdx.ts +248 -0
  119. package/src/paths.ts +36 -0
  120. package/src/rehype/shiki.ts +533 -0
  121. package/src/remark/alerts.ts +53 -0
  122. package/src/remark/ast.ts +96 -0
  123. package/src/remark/callouts.ts +25 -0
  124. package/src/remark/code-file.ts +85 -0
  125. package/src/remark/code-meta.ts +21 -0
  126. package/src/remark/directives.ts +170 -0
  127. package/src/remark/file-paths.ts +64 -0
  128. package/src/remark/headings.ts +131 -0
  129. package/src/remark/no-js.ts +40 -0
  130. package/src/render.ts +337 -0
  131. package/src/serve.ts +322 -0
  132. package/src/styles/globals.css +134 -0
  133. package/src/styles/shadcn.css +641 -0
  134. package/src/tailwind.ts +119 -0
  135. package/src/ui/approvals.tsx +76 -0
  136. package/src/ui/ask-question.tsx +386 -0
  137. package/src/ui/ask.tsx +206 -0
  138. package/src/ui/attrs.ts +51 -0
  139. package/src/ui/audit.tsx +139 -0
  140. package/src/ui/bar-chart.tsx +334 -0
  141. package/src/ui/benchmarks.tsx +143 -0
  142. package/src/ui/bits.tsx +537 -0
  143. package/src/ui/board.tsx +173 -0
  144. package/src/ui/bridge.tsx +207 -0
  145. package/src/ui/bumps.tsx +178 -0
  146. package/src/ui/callout.tsx +106 -0
  147. package/src/ui/changes.tsx +89 -0
  148. package/src/ui/chart-bits.tsx +52 -0
  149. package/src/ui/chart.ts +577 -0
  150. package/src/ui/checks.tsx +203 -0
  151. package/src/ui/child-index.tsx +44 -0
  152. package/src/ui/children.ts +43 -0
  153. package/src/ui/chips.tsx +49 -0
  154. package/src/ui/cmd.tsx +27 -0
  155. package/src/ui/columns.tsx +31 -0
  156. package/src/ui/comments.tsx +544 -0
  157. package/src/ui/compare.tsx +67 -0
  158. package/src/ui/decision.tsx +79 -0
  159. package/src/ui/deps.tsx +78 -0
  160. package/src/ui/details.tsx +43 -0
  161. package/src/ui/diff-parse.ts +307 -0
  162. package/src/ui/diff.tsx +458 -0
  163. package/src/ui/diffstat.tsx +58 -0
  164. package/src/ui/due.tsx +65 -0
  165. package/src/ui/effort.tsx +29 -0
  166. package/src/ui/endpoints.tsx +118 -0
  167. package/src/ui/envvars.tsx +96 -0
  168. package/src/ui/figure.tsx +37 -0
  169. package/src/ui/file-icon.ts +1020 -0
  170. package/src/ui/file-link.ts +32 -0
  171. package/src/ui/file-ref.tsx +39 -0
  172. package/src/ui/files.tsx +112 -0
  173. package/src/ui/findings.tsx +85 -0
  174. package/src/ui/flow.tsx +73 -0
  175. package/src/ui/funnel.tsx +121 -0
  176. package/src/ui/gantt.tsx +443 -0
  177. package/src/ui/gauges.tsx +135 -0
  178. package/src/ui/glossary.tsx +29 -0
  179. package/src/ui/graph-layout.ts +149 -0
  180. package/src/ui/graph-specs.tsx +102 -0
  181. package/src/ui/graph.tsx +278 -0
  182. package/src/ui/grid.tsx +119 -0
  183. package/src/ui/hypothesis.tsx +94 -0
  184. package/src/ui/icon.tsx +80 -0
  185. package/src/ui/incident.tsx +130 -0
  186. package/src/ui/index.ts +342 -0
  187. package/src/ui/ins-del.tsx +41 -0
  188. package/src/ui/json.tsx +190 -0
  189. package/src/ui/layout.ts +9 -0
  190. package/src/ui/line-chart.tsx +251 -0
  191. package/src/ui/matrix.tsx +208 -0
  192. package/src/ui/meta.tsx +73 -0
  193. package/src/ui/option.tsx +64 -0
  194. package/src/ui/owner.tsx +40 -0
  195. package/src/ui/packages.tsx +104 -0
  196. package/src/ui/pathway.tsx +96 -0
  197. package/src/ui/phase.tsx +41 -0
  198. package/src/ui/pie-chart.tsx +170 -0
  199. package/src/ui/plan.tsx +77 -0
  200. package/src/ui/pre.tsx +175 -0
  201. package/src/ui/priority.tsx +51 -0
  202. package/src/ui/props.tsx +77 -0
  203. package/src/ui/quadrant.tsx +172 -0
  204. package/src/ui/radar.tsx +200 -0
  205. package/src/ui/ref.tsx +160 -0
  206. package/src/ui/release.tsx +178 -0
  207. package/src/ui/req.tsx +42 -0
  208. package/src/ui/review.tsx +133 -0
  209. package/src/ui/risk.tsx +67 -0
  210. package/src/ui/sankey.tsx +287 -0
  211. package/src/ui/scatter.tsx +237 -0
  212. package/src/ui/schema.tsx +113 -0
  213. package/src/ui/score.tsx +105 -0
  214. package/src/ui/search.tsx +120 -0
  215. package/src/ui/series.tsx +38 -0
  216. package/src/ui/severity.tsx +69 -0
  217. package/src/ui/shadcn.tsx +216 -0
  218. package/src/ui/spark.tsx +86 -0
  219. package/src/ui/stack.tsx +57 -0
  220. package/src/ui/stats.tsx +63 -0
  221. package/src/ui/status-badge.tsx +66 -0
  222. package/src/ui/statuspage.tsx +238 -0
  223. package/src/ui/steps.tsx +74 -0
  224. package/src/ui/summary.tsx +41 -0
  225. package/src/ui/symbol-ref.tsx +73 -0
  226. package/src/ui/terminal.tsx +117 -0
  227. package/src/ui/tests.tsx +218 -0
  228. package/src/ui/timeline.tsx +63 -0
  229. package/src/ui/toc.tsx +56 -0
  230. package/src/ui/tones.ts +187 -0
  231. package/src/ui/trace.tsx +69 -0
  232. package/src/ui/tree.tsx +281 -0
  233. package/src/ui/treemap.tsx +128 -0
  234. package/src/ui/venn.tsx +258 -0
  235. package/src/ui/verdict.tsx +78 -0
  236. package/src/ui/waterfall.tsx +142 -0
@@ -0,0 +1,78 @@
1
+ import * as v from "valibot";
2
+
3
+ import { defineComponent } from "../define.js";
4
+ import { TITLE_PROP } from "./attrs.js";
5
+ import { ListPanel, ListRow, RowIcon, RowNote, Tag } from "./bits.js";
6
+ import { MONO_CLS, TONE_TEXT } from "./tones.js";
7
+
8
+ export const DEP_KINDS = [
9
+ "calls",
10
+ "extends",
11
+ "implements",
12
+ "imports",
13
+ "reads",
14
+ "writes",
15
+ ] as const;
16
+ export type DepKind = (typeof DEP_KINDS)[number];
17
+
18
+ const KINDS: Record<DepKind, { cls: string; icon: string }> = {
19
+ calls: {
20
+ cls: TONE_TEXT.sky,
21
+ icon: "lucide:square-function",
22
+ },
23
+ extends: {
24
+ cls: TONE_TEXT.violet,
25
+ icon: "lucide:git-branch",
26
+ },
27
+ implements: {
28
+ cls: TONE_TEXT.teal,
29
+ icon: "lucide:layers",
30
+ },
31
+ imports: {
32
+ cls: TONE_TEXT.neutral,
33
+ icon: "lucide:package",
34
+ },
35
+ reads: {
36
+ cls: TONE_TEXT.neutral,
37
+ icon: "lucide:eye",
38
+ },
39
+ writes: {
40
+ cls: TONE_TEXT.amber,
41
+ icon: "lucide:pencil",
42
+ },
43
+ };
44
+
45
+ export const Deps = defineComponent(
46
+ {
47
+ description:
48
+ "依存関係エッジ一覧のコンテナ。<Dep> を並べる。title でキャプションバー",
49
+ schema: v.looseObject(TITLE_PROP),
50
+ },
51
+ ({ title, children }) => <ListPanel title={title}>{children}</ListPanel>
52
+ );
53
+
54
+ export const Dep = defineComponent(
55
+ {
56
+ description:
57
+ "依存エッジ1行。from → to。kind は imports|calls|extends|implements|reads|writes。children は注記",
58
+ schema: v.looseObject({
59
+ from: v.string(),
60
+ kind: v.optional(v.picklist(DEP_KINDS), "imports"),
61
+ to: v.string(),
62
+ }),
63
+ },
64
+ ({ from, to, kind, children }) => {
65
+ const k = KINDS[kind];
66
+ return (
67
+ <ListRow gapX="2">
68
+ <code className={MONO_CLS}>{from}</code>
69
+ <RowIcon name="lucide:arrow-right" />
70
+ <code className={MONO_CLS}>{to}</code>
71
+ <Tag className={k.cls} icon={k.icon}>
72
+ {kind}
73
+ </Tag>
74
+ <RowNote>{children}</RowNote>
75
+ </ListRow>
76
+ );
77
+ }
78
+ );
@@ -0,0 +1,43 @@
1
+ import * as v from "valibot";
2
+
3
+ import { defineComponent } from "../define.js";
4
+ import { attrTrue, BOOLISH_PROP } from "./attrs.js";
5
+ import { Icon } from "./icon.js";
6
+ import { BORDER_CLS, TEXT, TRIM_CLS } from "./tones.js";
7
+
8
+ /**
9
+ * Collapsible section on native `<details>` — opens/closes without client
10
+ * JS, unlike the shadcn Collapsible (which renders its initial state only).
11
+ */
12
+ export const Details = defineComponent(
13
+ {
14
+ description:
15
+ '折りたたみセクション (ネイティブ <details> — JS なしで開閉)。summary="..." で要約、open で初期展開',
16
+ schema: v.looseObject({
17
+ open: BOOLISH_PROP,
18
+ summary: v.optional(v.string()),
19
+ }),
20
+ },
21
+ ({ summary, open, children }) => {
22
+ const isOpen = attrTrue(open);
23
+ return (
24
+ <details
25
+ className={`mdxr-details my-6 rounded-lg border ${BORDER_CLS}`}
26
+ open={isOpen}
27
+ >
28
+ <summary
29
+ className={`flex cursor-pointer items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium ${TEXT.chip} transition-colors select-none hover:bg-neutral-50 active:bg-neutral-100 dark:hover:bg-neutral-900 dark:active:bg-neutral-800`}
30
+ >
31
+ <Icon
32
+ className={`mdxr-chev h-4 w-4 shrink-0 ${TEXT.faint}`}
33
+ name="lucide:chevron-right"
34
+ />
35
+ {summary ?? "Details"}
36
+ </summary>
37
+ <div className={`border-t px-4 py-3 ${BORDER_CLS} ${TRIM_CLS}`}>
38
+ {children}
39
+ </div>
40
+ </details>
41
+ );
42
+ }
43
+ );
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Unified-diff parsing. `parseDiff` turns patch text into per-file
3
+ * structures; `DiffView` (diff.tsx) renders them. Bare `+`/`-` line
4
+ * streams (no `diff --git`/`@@` headers) parse as a single anonymous file.
5
+ */
6
+
7
+ export interface DiffRow {
8
+ kind: "add" | "ctx" | "del" | "note";
9
+ newLine?: number;
10
+ oldLine?: number;
11
+ text: string;
12
+ }
13
+
14
+ export interface DiffHunk {
15
+ header?: string;
16
+ rows: DiffRow[];
17
+ }
18
+
19
+ export interface FileDiff {
20
+ adds: number;
21
+ dels: number;
22
+ hunks: DiffHunk[];
23
+ meta: string[];
24
+ newPath?: string;
25
+ oldPath?: string;
26
+ raw: string[];
27
+ }
28
+
29
+ /** One highlighted token inside a rendered diff row. */
30
+ export interface DiffHlToken {
31
+ /** Shiki style string — `--shiki-light`/`--shiki-dark` vars. */
32
+ s?: string;
33
+ /** Token text. */
34
+ t: string;
35
+ }
36
+
37
+ /**
38
+ * Per-file highlight payload parked on `<code data-diffhl>` by rehypeShiki:
39
+ * `[file][hunk][row]` → token list. `null` means "render the row's plain
40
+ * text" — note rows, or files whose language could not be resolved.
41
+ */
42
+ export type DiffHl = (DiffHlToken[] | null)[][][];
43
+
44
+ /** JSON from the fence's `data-diffhl` attribute, or undefined when absent or
45
+ * malformed — malformed data degrades to unhighlighted rows, never a crash. */
46
+ export const parseDiffHl = (raw: string | undefined): DiffHl | undefined => {
47
+ if (raw === undefined) {
48
+ return undefined;
49
+ }
50
+ try {
51
+ const parsed: unknown = JSON.parse(raw);
52
+ return Array.isArray(parsed) ? (parsed as DiffHl) : undefined;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ };
57
+
58
+ const GIT_RE =
59
+ /^diff --git (?:"(?<aq>[^"]+)" "(?<bq>[^"]+)"|(?<a>\S+) (?<b>\S+))/u;
60
+ const OLD_RE = /^--- (?:"(?<q>[^"]+)"|(?<p>\S+))/u;
61
+ const NEW_RE = /^\+\+\+ (?:"(?<q>[^"]+)"|(?<p>\S+))/u;
62
+ const HUNK_RE =
63
+ /^@@ -(?<o>\d+)(?:,(?<oc>\d+))? \+(?<n>\d+)(?:,(?<nc>\d+))? @@/u;
64
+ const RENAME_FROM_RE = /^rename from (?<p>.+)/u;
65
+ const RENAME_TO_RE = /^rename to (?<p>.+)/u;
66
+
67
+ /** `a/src/x.ts` → `src/x.ts`; quoted names unquoted; `/dev/null` → undefined. */
68
+ const cleanPath = (p: string | undefined): string | undefined => {
69
+ if (p === undefined) {
70
+ return undefined;
71
+ }
72
+ const unq = p.replaceAll(/^"|"$/gu, "").replace(/^[ab]\//u, "");
73
+ return unq === "/dev/null" || unq === "" ? undefined : unq;
74
+ };
75
+
76
+ const newFile = (): FileDiff => ({
77
+ adds: 0,
78
+ dels: 0,
79
+ hunks: [],
80
+ meta: [],
81
+ raw: [],
82
+ });
83
+
84
+ interface ParseState {
85
+ cur?: FileDiff;
86
+ files: FileDiff[];
87
+ hunk?: DiffHunk;
88
+ /** Rows the declared hunk still expects on each side (Infinity for implicit
89
+ * hunks — bare +/- streams have no header counts to bound them). */
90
+ newLeft: number;
91
+ newNo: number;
92
+ oldLeft: number;
93
+ oldNo: number;
94
+ /** True once the current file consumed a `---`/`+++` header line — the
95
+ * paths a `diff --git` line sets don't count (a `---` after it belongs to
96
+ * the same file, not a new one). */
97
+ seenHeader: boolean;
98
+ }
99
+
100
+ /** The active hunk still has rows to consume per its `@@` counts. */
101
+ const hunkLive = (st: ParseState): boolean =>
102
+ st.hunk !== undefined &&
103
+ st.hunk.header !== undefined &&
104
+ (st.oldLeft > 0 || st.newLeft > 0);
105
+
106
+ /** Current file already carries content → a new `---` starts a new file. */
107
+ const hasContent = (st: ParseState): boolean =>
108
+ st.cur !== undefined && (st.seenHeader || st.cur.hunks.length > 0);
109
+
110
+ const ensureFile = (st: ParseState): FileDiff => {
111
+ if (st.cur === undefined) {
112
+ st.cur = newFile();
113
+ st.files.push(st.cur);
114
+ }
115
+ return st.cur;
116
+ };
117
+
118
+ const push = (st: ParseState, line: string): FileDiff => {
119
+ const f = ensureFile(st);
120
+ f.raw.push(line);
121
+ return f;
122
+ };
123
+
124
+ /** `---`/`+++` file headers. Returns true when the line was consumed. */
125
+ const fileHeader = (st: ParseState, line: string): boolean => {
126
+ const oldM = OLD_RE.exec(line);
127
+ if (oldM?.groups !== undefined) {
128
+ if (hasContent(st)) {
129
+ st.cur = newFile();
130
+ st.files.push(st.cur);
131
+ }
132
+ push(st, line).oldPath = cleanPath(oldM.groups.q ?? oldM.groups.p);
133
+ st.hunk = undefined;
134
+ st.seenHeader = true;
135
+ return true;
136
+ }
137
+ const newM = NEW_RE.exec(line);
138
+ if (newM?.groups !== undefined) {
139
+ push(st, line).newPath = cleanPath(newM.groups.q ?? newM.groups.p);
140
+ st.hunk = undefined;
141
+ st.seenHeader = true;
142
+ return true;
143
+ }
144
+ return false;
145
+ };
146
+
147
+ /** One row inside an active hunk; tracks both line counters. */
148
+ const hunkRow = (st: ParseState, line: string): void => {
149
+ const { hunk } = st;
150
+ if (hunk === undefined) {
151
+ return;
152
+ }
153
+ // Line numbers come from the `@@` tallies — an implicit hunk (bare +/-
154
+ // stream) has none, so its rows must not inherit stale counters from the
155
+ // previous counted hunk.
156
+ const numbered = hunk.header !== undefined;
157
+ const f = push(st, line);
158
+ if (line.startsWith("\\")) {
159
+ hunk.rows.push({ kind: "note", text: line });
160
+ } else if (line.startsWith("+")) {
161
+ f.adds += 1;
162
+ st.newLeft -= 1;
163
+ hunk.rows.push({
164
+ kind: "add",
165
+ newLine: numbered ? st.newNo : undefined,
166
+ text: line.slice(1),
167
+ });
168
+ st.newNo += 1;
169
+ } else if (line.startsWith("-")) {
170
+ f.dels += 1;
171
+ st.oldLeft -= 1;
172
+ hunk.rows.push({
173
+ kind: "del",
174
+ oldLine: numbered ? st.oldNo : undefined,
175
+ text: line.slice(1),
176
+ });
177
+ st.oldNo += 1;
178
+ } else {
179
+ st.oldLeft -= 1;
180
+ st.newLeft -= 1;
181
+ hunk.rows.push({
182
+ kind: "ctx",
183
+ newLine: numbered ? st.newNo : undefined,
184
+ oldLine: numbered ? st.oldNo : undefined,
185
+ text: line.startsWith(" ") ? line.slice(1) : line,
186
+ });
187
+ st.oldNo += 1;
188
+ st.newNo += 1;
189
+ }
190
+ // A counted hunk is done when its `@@` tallies run out — the next `---` /
191
+ // `diff --git` line is a file boundary again, not content.
192
+ if (hunk.header !== undefined && st.oldLeft <= 0 && st.newLeft <= 0) {
193
+ st.hunk = undefined;
194
+ }
195
+ };
196
+
197
+ /**
198
+ * A line with no hunk context: file meta (index/mode/rename/binary) or the
199
+ * first line of a bare +/- stream, which opens an implicit hunk.
200
+ */
201
+ const looseLine = (st: ParseState, line: string): void => {
202
+ const f = push(st, line);
203
+ const rf = RENAME_FROM_RE.exec(line);
204
+ if (rf?.groups !== undefined) {
205
+ f.oldPath ??= cleanPath(rf.groups.p.trim());
206
+ }
207
+ const rt = RENAME_TO_RE.exec(line);
208
+ if (rt?.groups !== undefined) {
209
+ f.newPath ??= cleanPath(rt.groups.p.trim());
210
+ }
211
+ if (line.startsWith("+") || line.startsWith("-")) {
212
+ const kind = line.startsWith("+") ? "add" : "del";
213
+ f[kind === "add" ? "adds" : "dels"] += 1;
214
+ st.oldLeft = Number.POSITIVE_INFINITY;
215
+ st.newLeft = Number.POSITIVE_INFINITY;
216
+ st.hunk = { rows: [{ kind, text: line.slice(1) }] };
217
+ f.hunks.push(st.hunk);
218
+ } else if (line !== "") {
219
+ f.meta.push(line);
220
+ }
221
+ };
222
+
223
+ /** One input line of `parseDiff`: git/file headers, `@@` hunk headers, and
224
+ * hunk/loose content each consume the line and update `st`. */
225
+ const parseLine = (st: ParseState, line: string, nextLine: string): void => {
226
+ const git = GIT_RE.exec(line);
227
+ if (git?.groups !== undefined) {
228
+ st.cur = newFile();
229
+ st.files.push(st.cur);
230
+ st.cur.raw.push(line);
231
+ st.cur.oldPath = cleanPath(git.groups.aq ?? git.groups.a);
232
+ st.cur.newPath = cleanPath(git.groups.bq ?? git.groups.b);
233
+ st.hunk = undefined;
234
+ st.seenHeader = false;
235
+ return;
236
+ }
237
+
238
+ // A `---`/`+++` pair is a file header — but only once the current counted
239
+ // hunk is exhausted. Inside a `@@` hunk body the pair is content: deleting
240
+ // a line like `-- a/x` and adding `++ b/x` emits exactly those rows (e.g.
241
+ // YAML frontmatter or nested diffs).
242
+ const pair = OLD_RE.test(line) && NEW_RE.test(nextLine);
243
+ if (
244
+ (st.hunk === undefined || (pair && !hunkLive(st))) &&
245
+ fileHeader(st, line)
246
+ ) {
247
+ return;
248
+ }
249
+
250
+ const hunkM = HUNK_RE.exec(line);
251
+ if (hunkM?.groups !== undefined) {
252
+ st.hunk = { header: line, rows: [] };
253
+ push(st, line).hunks.push(st.hunk);
254
+ st.oldNo = Number(hunkM.groups.o);
255
+ st.newNo = Number(hunkM.groups.n);
256
+ st.oldLeft = Number(hunkM.groups.oc ?? "1");
257
+ st.newLeft = Number(hunkM.groups.nc ?? "1");
258
+ return;
259
+ }
260
+
261
+ if (st.hunk === undefined) {
262
+ // `` follows the row it annotates — the hunk's
263
+ // counters may already have closed it, but the note still belongs inside.
264
+ const last = st.cur?.hunks.at(-1);
265
+ if (line.startsWith("\\") && last !== undefined) {
266
+ push(st, line);
267
+ last.rows.push({ kind: "note", text: line });
268
+ return;
269
+ }
270
+ looseLine(st, line);
271
+ return;
272
+ }
273
+ hunkRow(st, line);
274
+ };
275
+
276
+ /**
277
+ * Parse unified-diff text into per-file structures. `--- `/`+++ ` count as
278
+ * file headers only outside hunks (or when they form a `---`/`+++` pair —
279
+ * inside a hunk a lone `-`-prefixed line is content). Lines with no diff
280
+ * structure at all land in `meta`/`raw`; bare `+`/`-` streams without `@@`
281
+ * headers collect into an implicit hunk.
282
+ */
283
+ export const parseDiff = (text: string): FileDiff[] => {
284
+ // Normalize CRLF — a trailing \r would leak into row text and break the
285
+ // regexes that test the *next* line's structure.
286
+ const lines = text.replaceAll("\r\n", "\n").replace(/\n+$/u, "").split("\n");
287
+ const st: ParseState = {
288
+ files: [],
289
+ newLeft: 0,
290
+ newNo: 0,
291
+ oldLeft: 0,
292
+ oldNo: 0,
293
+ seenHeader: false,
294
+ };
295
+ for (let i = 0; i < lines.length; i += 1) {
296
+ parseLine(st, lines[i] ?? "", lines[i + 1] ?? "");
297
+ }
298
+ // Drop shells that never got content — an empty input would otherwise
299
+ // yield one blank file card.
300
+ return st.files.filter(
301
+ (f) =>
302
+ f.hunks.length > 0 ||
303
+ f.meta.length > 0 ||
304
+ f.oldPath !== undefined ||
305
+ f.newPath !== undefined
306
+ );
307
+ };