@riebeckite/plugin-diff 0.0.14 → 0.0.15

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.
package/README.md CHANGED
@@ -28,6 +28,9 @@ export default defineConfig({
28
28
  ```
29
29
 
30
30
  `diff(options?)` accepts the same `GitHistoryReaderOptions` as the API.
31
+ The revision panel renders the initial comparison during the build and computes
32
+ other selected comparisons in the browser. It embeds each available revision
33
+ once rather than every pairwise diff, keeping generated pages compact.
31
34
 
32
35
  ## Programmatic API
33
36
 
@@ -61,7 +64,7 @@ const compare = await api.compareRevisions({
61
64
 
62
65
  | Option | Type | Default | Description |
63
66
  | ------ | ---- | ------- | ----------- |
64
- | `cwd` | `string` | `process.cwd()` | Working directory for Git commands |
67
+ | `cwd` | `string` | `config.content.directory`, else `process.cwd()` | Content root used to locate the Git work tree |
65
68
 
66
69
  When `cwd` is not inside a Git repository, API calls resolve to empty results
67
70
  (`[]` / `null`) instead of throwing.
@@ -87,4 +90,4 @@ When `cwd` is not inside a Git repository, API calls resolve to empty results
87
90
 
88
91
  ## See also
89
92
 
90
- - [Plugin guide](../../../docs/en/reference/plugin-api.md)
93
+ - [Plugin guide](../../../docs/en/docs/reference/plugin-api.md)
package/README_ja.md CHANGED
@@ -28,7 +28,9 @@ const current = await api.getCurrentDiff("notes/hello.md");
28
28
  | `getCurrentDiff(filePath)` | 最新と一つ前のリビジョンの差分 |
29
29
  | `compareRevisions(input)` | 指定した二つのリビジョンの差分 |
30
30
 
31
- `cwd` は Git コマンドを実行するディレクトリで、既定値は `process.cwd()` です。Git リポジトリ外を指定しても例外は投げず、空の結果を返します。
31
+ `cwd` は Git リポジトリを探すためのコンテンツルートで、既定値はビルド内の `config.content.directory`、それ以外では `process.cwd()` です。相対パスは `process.cwd()` 基準で解決されます。Git リポジトリ外を指定しても例外は投げず、空の結果を返します。
32
+
33
+ リビジョンパネルは初期表示の差分だけをビルド時に描画し、利用者が選んだ比較はブラウザ上で計算します。各リビジョンは一度だけ埋め込むため、全組み合わせの差分を出力する場合より生成ページを小さく保てます。
32
34
 
33
35
  ## 公開 API
34
36
 
@@ -38,4 +40,4 @@ const current = await api.getCurrentDiff("notes/hello.md");
38
40
 
39
41
  ## 関連資料
40
42
 
41
- - [プラグインシステム](../../../docs/ja/reference/plugin-api.md)
43
+ - [プラグインシステム](../../../docs/ja/docs/reference/plugin-api.md)
package/dist/client.js CHANGED
@@ -3,6 +3,92 @@ function escapeHtml(value) {
3
3
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4
4
  }
5
5
 
6
+ // src/diff/line_diff.ts
7
+ function createLineDiff(fromMarkdown, toMarkdown) {
8
+ const fromLines = splitLines(fromMarkdown);
9
+ const toLines = splitLines(toMarkdown);
10
+ const table = buildLongestCommonSubsequenceTable(fromLines, toLines);
11
+ const lines = [];
12
+ let fromIndex = 0;
13
+ let toIndex = 0;
14
+ let oldLineNumber = 1;
15
+ let newLineNumber = 1;
16
+ while (fromIndex < fromLines.length && toIndex < toLines.length) {
17
+ if (fromLines[fromIndex] === toLines[toIndex]) {
18
+ lines.push({
19
+ type: "context",
20
+ content: fromLines[fromIndex],
21
+ oldLineNumber,
22
+ newLineNumber
23
+ });
24
+ fromIndex += 1;
25
+ toIndex += 1;
26
+ oldLineNumber += 1;
27
+ newLineNumber += 1;
28
+ continue;
29
+ }
30
+ if (table[fromIndex + 1][toIndex] >= table[fromIndex][toIndex + 1]) {
31
+ lines.push({
32
+ type: "removed",
33
+ content: fromLines[fromIndex],
34
+ oldLineNumber,
35
+ newLineNumber: null
36
+ });
37
+ fromIndex += 1;
38
+ oldLineNumber += 1;
39
+ } else {
40
+ lines.push({
41
+ type: "added",
42
+ content: toLines[toIndex],
43
+ oldLineNumber: null,
44
+ newLineNumber
45
+ });
46
+ toIndex += 1;
47
+ newLineNumber += 1;
48
+ }
49
+ }
50
+ while (fromIndex < fromLines.length) {
51
+ lines.push({
52
+ type: "removed",
53
+ content: fromLines[fromIndex],
54
+ oldLineNumber,
55
+ newLineNumber: null
56
+ });
57
+ fromIndex += 1;
58
+ oldLineNumber += 1;
59
+ }
60
+ while (toIndex < toLines.length) {
61
+ lines.push({
62
+ type: "added",
63
+ content: toLines[toIndex],
64
+ oldLineNumber: null,
65
+ newLineNumber
66
+ });
67
+ toIndex += 1;
68
+ newLineNumber += 1;
69
+ }
70
+ return lines;
71
+ }
72
+ function splitLines(markdown) {
73
+ if (!markdown) return [];
74
+ return markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
75
+ }
76
+ function buildLongestCommonSubsequenceTable(fromLines, toLines) {
77
+ const table = Array.from(
78
+ { length: fromLines.length + 1 },
79
+ () => Array.from({ length: toLines.length + 1 }, () => 0)
80
+ );
81
+ for (let fromIndex = fromLines.length - 1; fromIndex >= 0; fromIndex -= 1) {
82
+ for (let toIndex = toLines.length - 1; toIndex >= 0; toIndex -= 1) {
83
+ table[fromIndex][toIndex] = fromLines[fromIndex] === toLines[toIndex] ? table[fromIndex + 1][toIndex + 1] + 1 : Math.max(
84
+ table[fromIndex + 1][toIndex],
85
+ table[fromIndex][toIndex + 1]
86
+ );
87
+ }
88
+ }
89
+ return table;
90
+ }
91
+
6
92
  // client/diff-viewer.ts
7
93
  function initDiffHistory() {
8
94
  for (const root of document.querySelectorAll(
@@ -13,7 +99,10 @@ function initDiffHistory() {
13
99
  }
14
100
  function initDiffHistoryRoot(root) {
15
101
  const payload = readPayload(root);
16
- if (!payload || payload.diffs.length === 0) return;
102
+ if (!payload || payload.revisions.length === 0) return;
103
+ const revisions = new Map(
104
+ payload.revisions.map((revision) => [revision.hash, revision])
105
+ );
17
106
  const fromSelect = root.querySelector(
18
107
  "[data-rr-diff-from]"
19
108
  );
@@ -25,7 +114,8 @@ function initDiffHistoryRoot(root) {
25
114
  )) {
26
115
  button.addEventListener("click", () => {
27
116
  const toHash = button.dataset.rrDiffSelect;
28
- const diff = payload.diffs.find((entry) => entry.to.hash === toHash);
117
+ if (!toHash) return;
118
+ const diff = createDiff(revisions, fromSelect.value, toHash);
29
119
  if (!diff) return;
30
120
  fromSelect.value = diff.from?.hash ?? "";
31
121
  toSelect.value = diff.to.hash;
@@ -34,9 +124,7 @@ function initDiffHistoryRoot(root) {
34
124
  });
35
125
  }
36
126
  const updateFromSelects = () => {
37
- const diff = payload.diffs.find(
38
- (entry) => (entry.from?.hash ?? "") === fromSelect.value && entry.to.hash === toSelect.value
39
- );
127
+ const diff = createDiff(revisions, fromSelect.value, toSelect.value);
40
128
  if (!diff) return;
41
129
  updateSelectedCommit(root, diff.to.hash);
42
130
  renderPanel(panel, diff);
@@ -44,6 +132,17 @@ function initDiffHistoryRoot(root) {
44
132
  fromSelect.addEventListener("change", updateFromSelects);
45
133
  toSelect.addEventListener("change", updateFromSelects);
46
134
  }
135
+ function createDiff(revisions, fromHash, toHash) {
136
+ const to = revisions.get(toHash);
137
+ if (!to) return null;
138
+ const from = fromHash ? revisions.get(fromHash) : null;
139
+ if (fromHash && !from) return null;
140
+ return {
141
+ from: from ?? null,
142
+ to,
143
+ lines: createLineDiff(from?.markdown ?? "", to.markdown)
144
+ };
145
+ }
47
146
  function readPayload(root) {
48
147
  const script = root.querySelector(
49
148
  "[data-rr-diff-history-data]"
@@ -67,14 +166,14 @@ function updateSelectedCommit(root, hash) {
67
166
  }
68
167
  function renderPanel(panel, diff) {
69
168
  const fromLabel = diff.from?.shortHash ?? "Beginning";
70
- panel.innerHTML = `<div class="rr-diff-history__diff" data-rr-diff-rendered>
71
- <div class="rr-diff-history__diff-head">${escapeHtml(fromLabel)} → ${escapeHtml(diff.to.shortHash)}</div>
169
+ panel.innerHTML = `<details class="rr-diff-history__diff" data-rr-diff-rendered open>
170
+ <summary class="rr-diff-history__diff-head">${escapeHtml(fromLabel)} → ${escapeHtml(diff.to.shortHash)}</summary>
72
171
  <div class="rr-diff-history__table-wrap">
73
172
  <table class="rr-diff-history__table">
74
173
  <tbody>${diff.lines.map(renderLine).join("")}</tbody>
75
174
  </table>
76
175
  </div>
77
- </div>`;
176
+ </details>`;
78
177
  }
79
178
  function renderLine(line) {
80
179
  const prefix = line.type === "added" ? "+" : line.type === "removed" ? "-" : " ";
package/dist/index.js CHANGED
@@ -54,14 +54,14 @@ function renderDiffViewer({
54
54
  }
55
55
  function renderDiffPanel(diff2) {
56
56
  const fromLabel = diff2.from?.shortHash ?? "Beginning";
57
- return `<div class="rr-diff-history__diff" data-rr-diff-rendered>
58
- <div class="rr-diff-history__diff-head">${escapeHtml(fromLabel)} → ${escapeHtml(diff2.to.shortHash)}</div>
57
+ return `<details class="rr-diff-history__diff" data-rr-diff-rendered open>
58
+ <summary class="rr-diff-history__diff-head">${escapeHtml(fromLabel)} → ${escapeHtml(diff2.to.shortHash)}</summary>
59
59
  <div class="rr-diff-history__table-wrap">
60
60
  <table class="rr-diff-history__table">
61
61
  <tbody>${diff2.lines.map((line) => renderDiffLine({ line })).join("")}</tbody>
62
62
  </table>
63
63
  </div>
64
- </div>`;
64
+ </details>`;
65
65
  }
66
66
  function renderRevisionSelect(kind, history, value) {
67
67
  const emptyOption = kind === "from" ? `<option value="">Beginning</option>` : "";
@@ -72,23 +72,29 @@ function renderRevisionSelect(kind, history, value) {
72
72
 
73
73
  // src/components/diff-history.tsx
74
74
  function renderDiffHistory(input) {
75
- const selected = input.diffs[0] ?? null;
76
- const payload = JSON.stringify(input).replace(/</g, "\\u003c");
75
+ const history = input.revisions;
76
+ const selected = input.selected;
77
+ const payload = JSON.stringify({ revisions: input.revisions }).replace(
78
+ /</g,
79
+ "\\u003c"
80
+ );
77
81
  return `<section class="rr-diff-history" data-rr-diff-history>
78
- <div class="rr-diff-history__header">
82
+ <details class="rr-diff-history__panel" open>
83
+ <summary class="rr-diff-history__header">
79
84
  <h2 class="rr-diff-history__title">History</h2>
80
- <span class="rr-diff-history__count">${input.history.length} changes</span>
81
- </div>
82
- ${input.history.length === 0 || !selected ? renderEmptyState() : renderContent(input, selected)}
85
+ <span class="rr-diff-history__header-meta"><span class="rr-diff-history__count">${history.length} changes</span><span class="rr-diff-history__toggle-label rr-diff-history__toggle-label--open">Collapse</span><span class="rr-diff-history__toggle-label rr-diff-history__toggle-label--closed">Expand</span><span class="rr-diff-history__toggle" aria-hidden="true"><svg class="rr-diff-history__toggle-icon" viewBox="0 0 16 16" width="16" height="16" focusable="false"><path d="M5.2 3.4 10.8 8l-5.6 4.6" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"/></svg></span></span>
86
+ </summary>
87
+ ${history.length === 0 || !selected ? renderEmptyState() : renderContent(history, selected)}
88
+ </details>
83
89
  <script type="application/json" data-rr-diff-history-data>${payload}</script>
84
90
  </section>`;
85
91
  }
86
- function renderContent(input, selected) {
92
+ function renderContent(history, selected) {
87
93
  return `<div class="rr-diff-history__layout">
88
94
  <ol class="rr-diff-history__list" aria-label="Commit history">
89
- ${input.history.map((revision, index) => renderRevisionButton(revision, index === 0)).join("")}
95
+ ${history.map((revision, index) => renderRevisionButton(revision, index === 0)).join("")}
90
96
  </ol>
91
- ${renderDiffViewer({ history: input.history, selected })}
97
+ ${renderDiffViewer({ history, selected })}
92
98
  </div>`;
93
99
  }
94
100
  function renderEmptyState() {
@@ -191,30 +197,131 @@ function buildLongestCommonSubsequenceTable(fromLines, toLines) {
191
197
  }
192
198
 
193
199
  // src/git/history_reader.ts
200
+ import path2 from "node:path";
201
+
202
+ // src/git/repo_paths.ts
203
+ import path from "node:path";
204
+
205
+ // src/git/run_git.ts
194
206
  import { execFile } from "node:child_process";
195
207
  import { promisify } from "node:util";
196
208
  var execFileAsync = promisify(execFile);
209
+ function isGitCommandFailure(result) {
210
+ return result.ok === false;
211
+ }
212
+ async function runGit(args, cwd) {
213
+ try {
214
+ const { stdout } = await execFileAsync(
215
+ "git",
216
+ ["-c", "core.quotePath=false", ...args],
217
+ {
218
+ ...cwd === void 0 ? {} : { cwd },
219
+ maxBuffer: 1024 * 1024 * 10
220
+ }
221
+ );
222
+ return { ok: true, stdout };
223
+ } catch (error) {
224
+ return {
225
+ ok: false,
226
+ spawnError: isSpawnFailure(readErrorCode(error)),
227
+ detail: describeGitFailure(error)
228
+ };
229
+ }
230
+ }
231
+ function readErrorCode(error) {
232
+ return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
233
+ }
234
+ function isSpawnFailure(code) {
235
+ return typeof code === "string" && !code.startsWith("ERR_");
236
+ }
237
+ function describeGitFailure(error) {
238
+ if (typeof error !== "object" || error === null) {
239
+ return error instanceof Error ? error.message : String(error);
240
+ }
241
+ const stderr = "stderr" in error ? error.stderr : void 0;
242
+ if (typeof stderr === "string" && stderr.trim()) return stderr.trim();
243
+ if (error instanceof Error) return error.message;
244
+ return String(error);
245
+ }
246
+
247
+ // src/git/repo_paths.ts
248
+ async function resolveRepositoryPaths(contentRoot) {
249
+ const resolvedContentRoot = path.resolve(contentRoot);
250
+ const result = await runGit([
251
+ "-C",
252
+ resolvedContentRoot,
253
+ "rev-parse",
254
+ "--show-toplevel",
255
+ "--show-prefix"
256
+ ]);
257
+ if (isGitCommandFailure(result)) {
258
+ return result.spawnError ? { kind: "git-unavailable", detail: result.detail } : { kind: "content-outside-repository", detail: result.detail };
259
+ }
260
+ const [toplevel, prefix] = parseRevParseOutput(result.stdout);
261
+ if (!toplevel) {
262
+ return {
263
+ kind: "content-outside-repository",
264
+ detail: "git rev-parse reported no work tree root"
265
+ };
266
+ }
267
+ return {
268
+ kind: "resolved",
269
+ paths: {
270
+ repositoryRoot: toPosixPath(path.normalize(toplevel)),
271
+ contentRoot: toPosixPath(resolvedContentRoot),
272
+ contentPrefix: toPosixPath(prefix).replace(/\/+$/, "")
273
+ }
274
+ };
275
+ }
276
+ function toRepositoryPath(paths, contentRelativePath) {
277
+ const normalized = normalizeContentPath(contentRelativePath);
278
+ if (!paths.contentPrefix) return normalized;
279
+ if (!normalized) return paths.contentPrefix;
280
+ return `${paths.contentPrefix}/${normalized}`;
281
+ }
282
+ function normalizeContentPath(value) {
283
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
284
+ }
285
+ function isResolvedRepository(resolution) {
286
+ return resolution.kind === "resolved";
287
+ }
288
+ function parseRevParseOutput(stdout) {
289
+ const [toplevel = "", prefix = ""] = stdout.split(/\r?\n/);
290
+ return [toplevel.trim(), prefix.trim()];
291
+ }
292
+ function toPosixPath(value) {
293
+ return value.replace(/\\/g, "/");
294
+ }
295
+
296
+ // src/git/history_reader.ts
197
297
  var fieldSeparator = "";
198
298
  var recordSeparator = "";
199
299
  var GitMarkdownHistoryReader = class {
200
- #cwd;
300
+ #contentRoot;
201
301
  #historyCache = /* @__PURE__ */ new Map();
202
302
  #markdownCache = /* @__PURE__ */ new Map();
303
+ /** Maps a content-relative path to `<revision hash>` → work tree path. */
203
304
  #revisionPathCache = /* @__PURE__ */ new Map();
204
- #repositoryAvailable;
305
+ #repository;
205
306
  constructor(options = {}) {
206
- this.#cwd = options.cwd ?? process.cwd();
307
+ this.#contentRoot = path2.resolve(options.cwd ?? process.cwd());
207
308
  }
309
+ /**
310
+ * Revision history for one file, newest first, following renames.
311
+ *
312
+ * @param filePath Content-relative path, e.g. `notes/hello.md`.
313
+ */
208
314
  getHistory(filePath) {
209
- const normalizedPath = normalizeGitPath(filePath);
315
+ const normalizedPath = normalizeContentPath(filePath);
210
316
  const cached = this.#historyCache.get(normalizedPath);
211
317
  if (cached) return cached;
212
318
  const history = this.#readHistory(normalizedPath);
213
319
  this.#historyCache.set(normalizedPath, history);
214
320
  return history;
215
321
  }
322
+ /** Content of one revision of a file, or `null` when Git cannot read it. */
216
323
  getRevisionMarkdown(filePath, hash) {
217
- const normalizedPath = normalizeGitPath(filePath);
324
+ const normalizedPath = normalizeContentPath(filePath);
218
325
  const cacheKey = `${hash}:${normalizedPath}`;
219
326
  const cached = this.#markdownCache.get(cacheKey);
220
327
  if (cached) return cached;
@@ -223,35 +330,44 @@ var GitMarkdownHistoryReader = class {
223
330
  return markdown;
224
331
  }
225
332
  async #readHistory(filePath) {
226
- if (!await this.#isRepositoryAvailable()) return [];
333
+ const repository = await this.#resolvePaths();
334
+ if (!repository) return [];
227
335
  const format = ["%H", "%h", "%cI", "%s", "%an"].join(fieldSeparator);
228
- const result = await runGit(this.#cwd, [
229
- "log",
230
- "--follow",
231
- "--name-status",
232
- `--format=${recordSeparator}${format}`,
233
- "--",
234
- filePath
235
- ]);
336
+ const result = await runGit(
337
+ [
338
+ "log",
339
+ "--follow",
340
+ "--name-status",
341
+ `--format=${recordSeparator}${format}`,
342
+ "--",
343
+ toRepositoryPath(repository, filePath)
344
+ ],
345
+ repository.repositoryRoot
346
+ );
236
347
  if (!result.ok || !result.stdout.trim()) return [];
237
348
  const revisionPaths = /* @__PURE__ */ new Map();
238
- const revisions = result.stdout.split(recordSeparator).map((record) => record.trim()).filter(Boolean).map((record) => parseRevisionRecord(record, revisionPaths, filePath)).filter((revision) => revision !== null);
349
+ const fallbackPath = toRepositoryPath(repository, filePath);
350
+ const revisions = result.stdout.split(recordSeparator).map((record) => record.trim()).filter(Boolean).map((record) => parseRevisionRecord(record, revisionPaths, fallbackPath)).filter((revision) => revision !== null);
239
351
  this.#revisionPathCache.set(filePath, revisionPaths);
240
352
  return revisions;
241
353
  }
242
354
  async #readRevisionMarkdown(filePath, hash) {
243
- if (!await this.#isRepositoryAvailable()) return null;
355
+ const repository = await this.#resolvePaths();
356
+ if (!repository) return null;
244
357
  await this.getHistory(filePath);
245
- const revisionPath = this.#revisionPathCache.get(filePath)?.get(hash) ?? filePath;
246
- const result = await runGit(this.#cwd, ["show", `${hash}:${revisionPath}`]);
358
+ const revisionPath = this.#revisionPathCache.get(filePath)?.get(hash) ?? toRepositoryPath(repository, filePath);
359
+ const result = await runGit(
360
+ ["show", `${hash}:${revisionPath}`],
361
+ repository.repositoryRoot
362
+ );
247
363
  return result.ok ? result.stdout : null;
248
364
  }
249
- #isRepositoryAvailable() {
250
- this.#repositoryAvailable ??= runGit(this.#cwd, [
251
- "rev-parse",
252
- "--is-inside-work-tree"
253
- ]).then((result) => result.ok && result.stdout.trim() === "true");
254
- return this.#repositoryAvailable;
365
+ /** `null` when the content directory has no readable work tree. */
366
+ async #resolvePaths() {
367
+ this.#repository ??= resolveRepositoryPaths(this.#contentRoot);
368
+ const resolution = await this.#repository;
369
+ if (!isResolvedRepository(resolution)) return null;
370
+ return resolution.paths;
255
371
  }
256
372
  };
257
373
  function parseRevisionRecord(record, revisionPaths, fallbackPath) {
@@ -271,44 +387,38 @@ function getRevisionPath(nameStatusLines, fallbackPath) {
271
387
  const line = nameStatusLines.find((entry) => entry.trim().length > 0);
272
388
  if (!line) return fallbackPath;
273
389
  const [, ...paths] = line.split(" ");
274
- return paths.at(-1) ?? fallbackPath;
275
- }
276
- function normalizeGitPath(filePath) {
277
- return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
278
- }
279
- async function runGit(cwd, args) {
280
- try {
281
- const { stdout } = await execFileAsync("git", args, {
282
- cwd,
283
- maxBuffer: 1024 * 1024 * 10
284
- });
285
- return { ok: true, stdout };
286
- } catch (error) {
287
- const stdout = typeof error === "object" && error !== null && "stdout" in error && typeof error.stdout === "string" ? error.stdout : "";
288
- return { ok: false, stdout };
289
- }
390
+ return normalizeContentPath(paths.at(-1) ?? fallbackPath);
290
391
  }
291
392
 
292
393
  // index.ts
293
394
  function diff(options = {}) {
294
- const api = createPostDiffApi(options);
295
395
  const ui = { enabled: true, maxRevisions: 20, ...options.ui };
396
+ let api;
296
397
  return definePlugin({
297
398
  name: "diff",
399
+ processedContentCache: {
400
+ version: "diff-v2",
401
+ dependencyMode: "unsafe"
402
+ },
298
403
  options,
299
404
  assets: ui.enabled === false ? [] : [createStyleAsset("diff")],
300
405
  clientEntries: ui.enabled === false ? [] : [createClientEntry("diff", "initDiffHistory")],
301
406
  onPostProcessed: async (context) => {
302
407
  if (ui.enabled === false) return;
408
+ api ??= createPostDiffApi({
409
+ ...options,
410
+ cwd: options.cwd ?? context.config?.content.directory
411
+ });
303
412
  const filePath = resolvePostFilePath(context);
304
413
  const history = (await api.getHistory(filePath)).slice(
305
414
  0,
306
415
  ui.maxRevisions
307
416
  );
308
- const diffs = await buildRevisionDiffs(api, filePath, history);
417
+ const revisions = await getMarkdownRevisions(api, filePath, history);
418
+ const selected = await api.getCurrentDiff(filePath);
309
419
  context.content.html += renderDiffHistory({
310
- history,
311
- diffs
420
+ revisions,
421
+ selected
312
422
  });
313
423
  }
314
424
  });
@@ -354,32 +464,16 @@ function createPostDiffApi(options = {}) {
354
464
  }
355
465
  };
356
466
  }
357
- function getPreviousRevision(history, hash) {
358
- const index = history.findIndex((revision) => revision.hash === hash);
359
- return index >= 0 ? history[index + 1] ?? null : null;
360
- }
361
- async function buildRevisionDiffs(api, filePath, history) {
362
- const comparisons = [];
363
- for (let toIndex = 0; toIndex < history.length; toIndex += 1) {
364
- const to = history[toIndex];
365
- const previous = getPreviousRevision(history, to.hash);
366
- comparisons.push({
367
- filePath,
368
- fromHash: previous?.hash ?? null,
369
- toHash: to.hash
370
- });
371
- for (let fromIndex = toIndex + 2; fromIndex < history.length; fromIndex += 1) {
372
- comparisons.push({
373
- filePath,
374
- fromHash: history[fromIndex].hash,
375
- toHash: to.hash
376
- });
377
- }
378
- }
379
- const diffs = await Promise.all(
380
- comparisons.map((comparison) => api.compareRevisions(comparison))
467
+ async function getMarkdownRevisions(api, filePath, history) {
468
+ const revisions = await Promise.all(
469
+ history.map(async (revision) => ({
470
+ revision,
471
+ markdown: await api.getRevisionMarkdown(filePath, revision.hash)
472
+ }))
473
+ );
474
+ return revisions.flatMap(
475
+ ({ revision, markdown }) => markdown === null ? [] : [{ ...revision, markdown }]
381
476
  );
382
- return diffs.filter((entry) => entry !== null);
383
477
  }
384
478
  function resolvePostFilePath(context) {
385
479
  const indexedPath = context.contentIndex.get(context.slug.toLowerCase());
@@ -1,6 +1,6 @@
1
- import type { DiffRevision, PostDiff } from "../types.js";
1
+ import type { MarkdownRevision, PostDiff } from "../types.js";
2
2
  export type DiffHistoryProps = {
3
- history: DiffRevision[];
4
- diffs: PostDiff[];
3
+ revisions: MarkdownRevision[];
4
+ selected: PostDiff | null;
5
5
  };
6
6
  export declare function renderDiffHistory(input: DiffHistoryProps): string;
@@ -1,7 +1,20 @@
1
1
  import type { DiffRevision, GitHistoryReaderOptions } from "../types.js";
2
+ /**
3
+ * Reads Markdown revisions out of a local Git repository.
4
+ *
5
+ * Callers speak content-relative paths — the same paths the content index
6
+ * reports. The reader resolves the owning work tree once and translates every
7
+ * path itself, so the process working directory never influences a result.
8
+ */
2
9
  export declare class GitMarkdownHistoryReader {
3
10
  #private;
4
11
  constructor(options?: GitHistoryReaderOptions);
12
+ /**
13
+ * Revision history for one file, newest first, following renames.
14
+ *
15
+ * @param filePath Content-relative path, e.g. `notes/hello.md`.
16
+ */
5
17
  getHistory(filePath: string): Promise<DiffRevision[]>;
18
+ /** Content of one revision of a file, or `null` when Git cannot read it. */
6
19
  getRevisionMarkdown(filePath: string, hash: string): Promise<string | null>;
7
20
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The paths that locate a content directory inside a Git work tree.
3
+ *
4
+ * Every field is a POSIX-separated path (`git` prints and accepts those on every
5
+ * platform, including Windows).
6
+ */
7
+ export type GitRepositoryPaths = {
8
+ /** Work tree root reported by `git rev-parse --show-toplevel`. */
9
+ repositoryRoot: string;
10
+ /** Absolute path of the content directory. */
11
+ contentRoot: string;
12
+ /**
13
+ * Content directory relative to `repositoryRoot`, without a trailing slash.
14
+ * Empty when the content directory is the work tree root itself.
15
+ */
16
+ contentPrefix: string;
17
+ };
18
+ /**
19
+ * Why Git history cannot be read for a content directory.
20
+ *
21
+ * The reason is modelled explicitly so a plugin can report an actionable
22
+ * diagnostic instead of quietly rendering nothing.
23
+ */
24
+ export type RepositoryPathsResolution = {
25
+ kind: "resolved";
26
+ paths: GitRepositoryPaths;
27
+ }
28
+ /** Git is not installed, or could not be started. */
29
+ | {
30
+ kind: "git-unavailable";
31
+ detail: string;
32
+ }
33
+ /** Git ran, but the content directory does not live in a work tree. */
34
+ | {
35
+ kind: "content-outside-repository";
36
+ detail: string;
37
+ };
38
+ /**
39
+ * Locates the Git work tree that owns `contentRoot`.
40
+ *
41
+ * Both the work tree root and the content prefix come from Git itself, so their
42
+ * casing and symlink resolution always agree with the paths `git log` prints —
43
+ * the reason content-relative paths can be translated without guessing.
44
+ *
45
+ * The work tree is resolved through `git -C <contentRoot>` rather than by
46
+ * running Git in `contentRoot`, so a missing directory surfaces as a Git
47
+ * failure instead of an indistinguishable spawn failure.
48
+ */
49
+ export declare function resolveRepositoryPaths(contentRoot: string): Promise<RepositoryPathsResolution>;
50
+ /**
51
+ * Translates a content-relative path into the work tree-relative path that
52
+ * `git log` pathspecs and `git show <rev>:<path>` expect.
53
+ */
54
+ export declare function toRepositoryPath(paths: GitRepositoryPaths, contentRelativePath: string): string;
55
+ /**
56
+ * Translates a work tree-relative path printed by `git` back into a
57
+ * content-relative path, or returns `null` when the path lies outside the
58
+ * content directory.
59
+ */
60
+ export declare function toContentPath(paths: GitRepositoryPaths, repositoryRelativePath: string): string | null;
61
+ /** Normalizes a path to the POSIX form used for content and work tree paths. */
62
+ export declare function normalizeContentPath(value: string): string;
63
+ /**
64
+ * True when the content directory has a usable work tree. Narrows the
65
+ * resolution so callers can read `paths`.
66
+ */
67
+ export declare function isResolvedRepository(resolution: RepositoryPathsResolution): resolution is Extract<RepositoryPathsResolution, {
68
+ kind: "resolved";
69
+ }>;
70
+ /**
71
+ * True when history cannot be read and a reason is available. Narrows the
72
+ * resolution so callers can read `detail`.
73
+ */
74
+ export declare function isRepositoryUnavailable(resolution: RepositoryPathsResolution): resolution is Exclude<RepositoryPathsResolution, {
75
+ kind: "resolved";
76
+ }>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Result of a `git` invocation.
3
+ *
4
+ * Failures are returned instead of thrown: a site whose Git history cannot be
5
+ * read must still build, so every caller decides what an unavailable repository
6
+ * means for its own output.
7
+ */
8
+ export type GitCommandResult = {
9
+ ok: true;
10
+ stdout: string;
11
+ }
12
+ /**
13
+ * `spawnError` separates "Git could not be started" (missing or unexecutable
14
+ * binary) from "Git ran and rejected the command" (not a work tree, unknown
15
+ * revision, unreadable path). The distinction drives which diagnostic a
16
+ * plugin reports.
17
+ */
18
+ | {
19
+ ok: false;
20
+ spawnError: boolean;
21
+ detail: string;
22
+ };
23
+ /**
24
+ * Narrows a failed command so its failure fields can be read.
25
+ *
26
+ * Type predicates are used instead of `result.ok` checks throughout this
27
+ * package because the build type-checks without `strictNullChecks`, where
28
+ * discriminant narrowing of an inline condition does not apply.
29
+ */
30
+ export declare function isGitCommandFailure(result: GitCommandResult): result is Extract<GitCommandResult, {
31
+ ok: false;
32
+ }>;
33
+ /**
34
+ * Runs `git` with `args`.
35
+ *
36
+ * `core.quotePath=false` keeps non-ASCII paths verbatim so the printed paths
37
+ * match the ones the content index reports; Git otherwise escapes them into
38
+ * octal sequences that no path lookup can resolve.
39
+ *
40
+ * @param cwd Directory Git runs in. Omit when the command carries its own
41
+ * `-C <dir>` argument.
42
+ */
43
+ export declare function runGit(args: readonly string[], cwd?: string): Promise<GitCommandResult>;
@@ -26,6 +26,12 @@ export type RevisionComparisonInput = {
26
26
  toHash: string;
27
27
  };
28
28
  export type GitHistoryReaderOptions = {
29
+ /**
30
+ * Content root: the directory holding the notes, used to locate the owning
31
+ * Git work tree. Defaults to `config.content.directory` when the plugin runs
32
+ * inside a build, otherwise `process.cwd()`. A relative value is resolved
33
+ * against `process.cwd()`.
34
+ */
29
35
  cwd?: string;
30
36
  };
31
37
  export type DiffPluginUiOptions = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riebeckite/plugin-diff",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,12 +21,12 @@
21
21
  "./style.css": "./style.css"
22
22
  },
23
23
  "dependencies": {
24
- "@riebeckite/core": "0.0.14"
24
+ "@riebeckite/core": "0.0.15"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^24.5.2",
28
28
  "tsx": "^4.22.4",
29
- "@riebeckite/test": "0.0.14"
29
+ "@riebeckite/test": "0.0.15"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
package/style.css CHANGED
@@ -12,6 +12,25 @@
12
12
  justify-content: space-between;
13
13
  gap: 1rem;
14
14
  margin-block-end: 1rem;
15
+ cursor: pointer;
16
+ user-select: none;
17
+ }
18
+
19
+ .rr-diff-history__panel {
20
+ interpolate-size: allow-keywords;
21
+ }
22
+
23
+ .rr-diff-history__header::marker {
24
+ content: "";
25
+ }
26
+
27
+ .rr-diff-history__header::-webkit-details-marker {
28
+ display: none;
29
+ }
30
+
31
+ .rr-diff-history__header:focus-visible {
32
+ outline: 2px solid var(--rb-color-accent, #f66620);
33
+ outline-offset: 2px;
15
34
  }
16
35
 
17
36
  .rr-diff-history__title {
@@ -21,6 +40,72 @@
21
40
  text-transform: uppercase;
22
41
  }
23
42
 
43
+ .rr-diff-history__header-meta {
44
+ display: inline-flex;
45
+ align-items: center;
46
+ gap: 0.5rem;
47
+ }
48
+
49
+ .rr-diff-history__toggle-label {
50
+ color: var(
51
+ --rb-color-muted,
52
+ color-mix(in srgb, currentColor 62%, transparent)
53
+ );
54
+ font-size: 0.75rem;
55
+ letter-spacing: 0.04em;
56
+ text-transform: uppercase;
57
+ }
58
+
59
+ .rr-diff-history__toggle-label--open {
60
+ display: none;
61
+ }
62
+
63
+ .rr-diff-history__panel[open] .rr-diff-history__toggle-label--open {
64
+ display: inline;
65
+ }
66
+
67
+ .rr-diff-history__panel[open] .rr-diff-history__toggle-label--closed {
68
+ display: none;
69
+ }
70
+
71
+ .rr-diff-history__toggle {
72
+ display: inline-grid;
73
+ place-items: center;
74
+ width: 1.5rem;
75
+ height: 1.5rem;
76
+ color: var(
77
+ --rb-color-muted,
78
+ color-mix(in srgb, currentColor 62%, transparent)
79
+ );
80
+ }
81
+
82
+ .rr-diff-history__toggle-icon {
83
+ transform: rotate(180deg);
84
+ transition: transform 180ms ease;
85
+ }
86
+
87
+ .rr-diff-history__panel[open] .rr-diff-history__toggle-icon {
88
+ transform: rotate(90deg);
89
+ }
90
+
91
+ @supports selector(::details-content) {
92
+ .rr-diff-history__panel::details-content {
93
+ block-size: 0;
94
+ overflow: clip;
95
+ opacity: 0;
96
+ transition:
97
+ block-size 220ms ease,
98
+ content-visibility 220ms ease allow-discrete,
99
+ opacity 160ms ease;
100
+ transition-behavior: allow-discrete;
101
+ }
102
+
103
+ .rr-diff-history__panel[open]::details-content {
104
+ block-size: auto;
105
+ opacity: 1;
106
+ }
107
+ }
108
+
24
109
  .rr-diff-history__count,
25
110
  .rr-diff-history__date,
26
111
  .rr-diff-history__meta {
@@ -98,6 +183,7 @@
98
183
  }
99
184
 
100
185
  .rr-diff-history__commit:focus-visible,
186
+ .rr-diff-history__diff-head:focus-visible,
101
187
  .rr-diff-history__controls select:focus-visible {
102
188
  outline: 2px solid var(--rb-color-accent, #f66620);
103
189
  outline-offset: 2px;
@@ -137,6 +223,9 @@
137
223
  }
138
224
 
139
225
  .rr-diff-history__diff-head {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 0.5rem;
140
229
  border-block-end: 1px solid
141
230
  var(--rb-color-border, color-mix(in srgb, currentColor 18%, transparent));
142
231
  padding: 0.625rem 0.75rem;
@@ -148,6 +237,15 @@
148
237
  monospace
149
238
  );
150
239
  font-size: 0.8125rem;
240
+ cursor: pointer;
241
+ user-select: none;
242
+ }
243
+
244
+ .rr-diff-history__diff-head::marker {
245
+ color: var(
246
+ --rb-color-muted,
247
+ color-mix(in srgb, currentColor 62%, transparent)
248
+ );
151
249
  }
152
250
 
153
251
  .rr-diff-history__table-wrap {