@riebeckite/plugin-diff 0.0.14 → 0.0.16

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,9 @@ 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) return;
103
+ let revisions = revisionsMap(payload.revisions ?? []);
104
+ let revisionsRequest = null;
17
105
  const fromSelect = root.querySelector(
18
106
  "[data-rr-diff-from]"
19
107
  );
@@ -25,25 +113,86 @@ function initDiffHistoryRoot(root) {
25
113
  )) {
26
114
  button.addEventListener("click", () => {
27
115
  const toHash = button.dataset.rrDiffSelect;
28
- const diff = payload.diffs.find((entry) => entry.to.hash === toHash);
29
- if (!diff) return;
30
- fromSelect.value = diff.from?.hash ?? "";
31
- toSelect.value = diff.to.hash;
32
- updateSelectedCommit(root, diff.to.hash);
33
- renderPanel(panel, diff);
116
+ if (!toHash) return;
117
+ void updateDiff({
118
+ root,
119
+ panel,
120
+ getRevisions: () => revisions,
121
+ setRevisions: (loaded) => {
122
+ revisions = loaded;
123
+ },
124
+ load: () => {
125
+ revisionsRequest ??= loadRevisions(payload.path);
126
+ return revisionsRequest;
127
+ },
128
+ fromHash: fromSelect.value,
129
+ toHash,
130
+ fromSelect,
131
+ toSelect
132
+ });
34
133
  });
35
134
  }
36
135
  const updateFromSelects = () => {
37
- const diff = payload.diffs.find(
38
- (entry) => (entry.from?.hash ?? "") === fromSelect.value && entry.to.hash === toSelect.value
39
- );
40
- if (!diff) return;
41
- updateSelectedCommit(root, diff.to.hash);
42
- renderPanel(panel, diff);
136
+ void updateDiff({
137
+ root,
138
+ panel,
139
+ getRevisions: () => revisions,
140
+ setRevisions: (loaded) => {
141
+ revisions = loaded;
142
+ },
143
+ load: () => {
144
+ revisionsRequest ??= loadRevisions(payload.path);
145
+ return revisionsRequest;
146
+ },
147
+ fromHash: fromSelect.value,
148
+ toHash: toSelect.value,
149
+ fromSelect,
150
+ toSelect
151
+ });
43
152
  };
44
153
  fromSelect.addEventListener("change", updateFromSelects);
45
154
  toSelect.addEventListener("change", updateFromSelects);
46
155
  }
156
+ async function updateDiff(input) {
157
+ const { root, panel, fromHash, toHash, fromSelect, toSelect } = input;
158
+ const token = `${fromHash}
159
+ ${toHash}`;
160
+ root.dataset.rrDiffPending = token;
161
+ const loaded = await input.load();
162
+ if (loaded) input.setRevisions(loaded);
163
+ if (root.dataset.rrDiffPending !== token) return;
164
+ const diff = createDiff(input.getRevisions(), fromHash, toHash);
165
+ if (!diff) return;
166
+ fromSelect.value = diff.from?.hash ?? "";
167
+ toSelect.value = diff.to.hash;
168
+ updateSelectedCommit(root, diff.to.hash);
169
+ renderPanel(panel, diff);
170
+ }
171
+ async function loadRevisions(path) {
172
+ if (!path) return null;
173
+ try {
174
+ const response = await fetch(path);
175
+ if (!response.ok) return null;
176
+ const payload = await response.json();
177
+ return revisionsMap(payload.revisions ?? []);
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+ function revisionsMap(revisions) {
183
+ return new Map(revisions.map((revision) => [revision.hash, revision]));
184
+ }
185
+ function createDiff(revisions, fromHash, toHash) {
186
+ const to = revisions.get(toHash);
187
+ if (!to) return null;
188
+ const from = fromHash ? revisions.get(fromHash) : null;
189
+ if (fromHash && !from) return null;
190
+ return {
191
+ from: from ?? null,
192
+ to,
193
+ lines: createLineDiff(from?.markdown ?? "", to.markdown)
194
+ };
195
+ }
47
196
  function readPayload(root) {
48
197
  const script = root.querySelector(
49
198
  "[data-rr-diff-history-data]"
@@ -66,9 +215,7 @@ function updateSelectedCommit(root, hash) {
66
215
  }
67
216
  }
68
217
  function renderPanel(panel, diff) {
69
- const fromLabel = diff.from?.shortHash ?? "Beginning";
70
218
  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>
72
219
  <div class="rr-diff-history__table-wrap">
73
220
  <table class="rr-diff-history__table">
74
221
  <tbody>${diff.lines.map(renderLine).join("")}</tbody>
package/dist/index.js CHANGED
@@ -53,9 +53,7 @@ function renderDiffViewer({
53
53
  </div>`;
54
54
  }
55
55
  function renderDiffPanel(diff2) {
56
- const fromLabel = diff2.from?.shortHash ?? "Beginning";
57
56
  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>
59
57
  <div class="rr-diff-history__table-wrap">
60
58
  <table class="rr-diff-history__table">
61
59
  <tbody>${diff2.lines.map((line) => renderDiffLine({ line })).join("")}</tbody>
@@ -72,23 +70,26 @@ function renderRevisionSelect(kind, history, value) {
72
70
 
73
71
  // src/components/diff-history.tsx
74
72
  function renderDiffHistory(input) {
75
- const selected = input.diffs[0] ?? null;
76
- const payload = JSON.stringify(input).replace(/</g, "\\u003c");
73
+ const history = input.revisions;
74
+ const selected = input.selected;
75
+ const payloadPath = input.payloadPath;
77
76
  return `<section class="rr-diff-history" data-rr-diff-history>
78
- <div class="rr-diff-history__header">
77
+ <details class="rr-diff-history__panel">
78
+ <summary class="rr-diff-history__header">
79
79
  <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)}
83
- <script type="application/json" data-rr-diff-history-data>${payload}</script>
80
+ <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>
81
+ </summary>
82
+ ${history.length === 0 || !selected ? renderEmptyState() : renderContent(history, selected)}
83
+ </details>
84
+ ${payloadPath ? `<script type="application/json" data-rr-diff-history-data>${JSON.stringify({ path: payloadPath }).replace(/</g, "\\u003c")}</script>` : ""}
84
85
  </section>`;
85
86
  }
86
- function renderContent(input, selected) {
87
+ function renderContent(history, selected) {
87
88
  return `<div class="rr-diff-history__layout">
88
89
  <ol class="rr-diff-history__list" aria-label="Commit history">
89
- ${input.history.map((revision, index) => renderRevisionButton(revision, index === 0)).join("")}
90
+ ${history.map((revision, index) => renderRevisionButton(revision, index === 0)).join("")}
90
91
  </ol>
91
- ${renderDiffViewer({ history: input.history, selected })}
92
+ ${renderDiffViewer({ history, selected })}
92
93
  </div>`;
93
94
  }
94
95
  function renderEmptyState() {
@@ -191,30 +192,131 @@ function buildLongestCommonSubsequenceTable(fromLines, toLines) {
191
192
  }
192
193
 
193
194
  // src/git/history_reader.ts
195
+ import path2 from "node:path";
196
+
197
+ // src/git/repo_paths.ts
198
+ import path from "node:path";
199
+
200
+ // src/git/run_git.ts
194
201
  import { execFile } from "node:child_process";
195
202
  import { promisify } from "node:util";
196
203
  var execFileAsync = promisify(execFile);
204
+ function isGitCommandFailure(result) {
205
+ return result.ok === false;
206
+ }
207
+ async function runGit(args, cwd) {
208
+ try {
209
+ const { stdout } = await execFileAsync(
210
+ "git",
211
+ ["-c", "core.quotePath=false", ...args],
212
+ {
213
+ ...cwd === void 0 ? {} : { cwd },
214
+ maxBuffer: 1024 * 1024 * 10
215
+ }
216
+ );
217
+ return { ok: true, stdout };
218
+ } catch (error) {
219
+ return {
220
+ ok: false,
221
+ spawnError: isSpawnFailure(readErrorCode(error)),
222
+ detail: describeGitFailure(error)
223
+ };
224
+ }
225
+ }
226
+ function readErrorCode(error) {
227
+ return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
228
+ }
229
+ function isSpawnFailure(code) {
230
+ return typeof code === "string" && !code.startsWith("ERR_");
231
+ }
232
+ function describeGitFailure(error) {
233
+ if (typeof error !== "object" || error === null) {
234
+ return error instanceof Error ? error.message : String(error);
235
+ }
236
+ const stderr = "stderr" in error ? error.stderr : void 0;
237
+ if (typeof stderr === "string" && stderr.trim()) return stderr.trim();
238
+ if (error instanceof Error) return error.message;
239
+ return String(error);
240
+ }
241
+
242
+ // src/git/repo_paths.ts
243
+ async function resolveRepositoryPaths(contentRoot) {
244
+ const resolvedContentRoot = path.resolve(contentRoot);
245
+ const result = await runGit([
246
+ "-C",
247
+ resolvedContentRoot,
248
+ "rev-parse",
249
+ "--show-toplevel",
250
+ "--show-prefix"
251
+ ]);
252
+ if (isGitCommandFailure(result)) {
253
+ return result.spawnError ? { kind: "git-unavailable", detail: result.detail } : { kind: "content-outside-repository", detail: result.detail };
254
+ }
255
+ const [toplevel, prefix] = parseRevParseOutput(result.stdout);
256
+ if (!toplevel) {
257
+ return {
258
+ kind: "content-outside-repository",
259
+ detail: "git rev-parse reported no work tree root"
260
+ };
261
+ }
262
+ return {
263
+ kind: "resolved",
264
+ paths: {
265
+ repositoryRoot: toPosixPath(path.normalize(toplevel)),
266
+ contentRoot: toPosixPath(resolvedContentRoot),
267
+ contentPrefix: toPosixPath(prefix).replace(/\/+$/, "")
268
+ }
269
+ };
270
+ }
271
+ function toRepositoryPath(paths, contentRelativePath) {
272
+ const normalized = normalizeContentPath(contentRelativePath);
273
+ if (!paths.contentPrefix) return normalized;
274
+ if (!normalized) return paths.contentPrefix;
275
+ return `${paths.contentPrefix}/${normalized}`;
276
+ }
277
+ function normalizeContentPath(value) {
278
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
279
+ }
280
+ function isResolvedRepository(resolution) {
281
+ return resolution.kind === "resolved";
282
+ }
283
+ function parseRevParseOutput(stdout) {
284
+ const [toplevel = "", prefix = ""] = stdout.split(/\r?\n/);
285
+ return [toplevel.trim(), prefix.trim()];
286
+ }
287
+ function toPosixPath(value) {
288
+ return value.replace(/\\/g, "/");
289
+ }
290
+
291
+ // src/git/history_reader.ts
197
292
  var fieldSeparator = "";
198
293
  var recordSeparator = "";
199
294
  var GitMarkdownHistoryReader = class {
200
- #cwd;
295
+ #contentRoot;
201
296
  #historyCache = /* @__PURE__ */ new Map();
202
297
  #markdownCache = /* @__PURE__ */ new Map();
298
+ /** Maps a content-relative path to `<revision hash>` → work tree path. */
203
299
  #revisionPathCache = /* @__PURE__ */ new Map();
204
- #repositoryAvailable;
300
+ #repository;
205
301
  constructor(options = {}) {
206
- this.#cwd = options.cwd ?? process.cwd();
302
+ this.#contentRoot = path2.resolve(options.cwd ?? process.cwd());
207
303
  }
304
+ /**
305
+ * Revision history for one file, newest first, following renames.
306
+ *
307
+ * @param filePath Content-relative path, e.g. `notes/hello.md`.
308
+ */
208
309
  getHistory(filePath) {
209
- const normalizedPath = normalizeGitPath(filePath);
310
+ const normalizedPath = normalizeContentPath(filePath);
210
311
  const cached = this.#historyCache.get(normalizedPath);
211
312
  if (cached) return cached;
212
313
  const history = this.#readHistory(normalizedPath);
213
314
  this.#historyCache.set(normalizedPath, history);
214
315
  return history;
215
316
  }
317
+ /** Content of one revision of a file, or `null` when Git cannot read it. */
216
318
  getRevisionMarkdown(filePath, hash) {
217
- const normalizedPath = normalizeGitPath(filePath);
319
+ const normalizedPath = normalizeContentPath(filePath);
218
320
  const cacheKey = `${hash}:${normalizedPath}`;
219
321
  const cached = this.#markdownCache.get(cacheKey);
220
322
  if (cached) return cached;
@@ -223,35 +325,44 @@ var GitMarkdownHistoryReader = class {
223
325
  return markdown;
224
326
  }
225
327
  async #readHistory(filePath) {
226
- if (!await this.#isRepositoryAvailable()) return [];
328
+ const repository = await this.#resolvePaths();
329
+ if (!repository) return [];
227
330
  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
- ]);
331
+ const result = await runGit(
332
+ [
333
+ "log",
334
+ "--follow",
335
+ "--name-status",
336
+ `--format=${recordSeparator}${format}`,
337
+ "--",
338
+ toRepositoryPath(repository, filePath)
339
+ ],
340
+ repository.repositoryRoot
341
+ );
236
342
  if (!result.ok || !result.stdout.trim()) return [];
237
343
  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);
344
+ const fallbackPath = toRepositoryPath(repository, filePath);
345
+ const revisions = result.stdout.split(recordSeparator).map((record) => record.trim()).filter(Boolean).map((record) => parseRevisionRecord(record, revisionPaths, fallbackPath)).filter((revision) => revision !== null);
239
346
  this.#revisionPathCache.set(filePath, revisionPaths);
240
347
  return revisions;
241
348
  }
242
349
  async #readRevisionMarkdown(filePath, hash) {
243
- if (!await this.#isRepositoryAvailable()) return null;
350
+ const repository = await this.#resolvePaths();
351
+ if (!repository) return null;
244
352
  await this.getHistory(filePath);
245
- const revisionPath = this.#revisionPathCache.get(filePath)?.get(hash) ?? filePath;
246
- const result = await runGit(this.#cwd, ["show", `${hash}:${revisionPath}`]);
353
+ const revisionPath = this.#revisionPathCache.get(filePath)?.get(hash) ?? toRepositoryPath(repository, filePath);
354
+ const result = await runGit(
355
+ ["show", `${hash}:${revisionPath}`],
356
+ repository.repositoryRoot
357
+ );
247
358
  return result.ok ? result.stdout : null;
248
359
  }
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;
360
+ /** `null` when the content directory has no readable work tree. */
361
+ async #resolvePaths() {
362
+ this.#repository ??= resolveRepositoryPaths(this.#contentRoot);
363
+ const resolution = await this.#repository;
364
+ if (!isResolvedRepository(resolution)) return null;
365
+ return resolution.paths;
255
366
  }
256
367
  };
257
368
  function parseRevisionRecord(record, revisionPaths, fallbackPath) {
@@ -271,48 +382,54 @@ function getRevisionPath(nameStatusLines, fallbackPath) {
271
382
  const line = nameStatusLines.find((entry) => entry.trim().length > 0);
272
383
  if (!line) return fallbackPath;
273
384
  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
- }
385
+ return normalizeContentPath(paths.at(-1) ?? fallbackPath);
290
386
  }
291
387
 
292
388
  // index.ts
293
389
  function diff(options = {}) {
294
- const api = createPostDiffApi(options);
295
390
  const ui = { enabled: true, maxRevisions: 20, ...options.ui };
391
+ let api;
296
392
  return definePlugin({
297
393
  name: "diff",
394
+ processedContentCache: {
395
+ version: "diff-v2",
396
+ dependencyMode: "unsafe"
397
+ },
298
398
  options,
299
399
  assets: ui.enabled === false ? [] : [createStyleAsset("diff")],
300
400
  clientEntries: ui.enabled === false ? [] : [createClientEntry("diff", "initDiffHistory")],
301
401
  onPostProcessed: async (context) => {
302
402
  if (ui.enabled === false) return;
403
+ api ??= createPostDiffApi({
404
+ ...options,
405
+ cwd: options.cwd ?? context.config?.content.directory
406
+ });
303
407
  const filePath = resolvePostFilePath(context);
304
408
  const history = (await api.getHistory(filePath)).slice(
305
409
  0,
306
410
  ui.maxRevisions
307
411
  );
308
- const diffs = await buildRevisionDiffs(api, filePath, history);
412
+ const revisions = await getMarkdownRevisions(api, filePath, history);
413
+ const selected = await api.getCurrentDiff(filePath);
414
+ const payloadPath = revisions.length > 0 ? diffPayloadPath(context.slug) : void 0;
415
+ if (payloadPath) {
416
+ context.output.emit({
417
+ path: payloadPath,
418
+ content: JSON.stringify({ revisions }).replace(/</g, "\\u003c"),
419
+ dependencies: [{ type: "content", slug: context.slug }]
420
+ });
421
+ }
309
422
  context.content.html += renderDiffHistory({
310
- history,
311
- diffs
423
+ revisions,
424
+ selected,
425
+ payloadPath: payloadPath ? `/${payloadPath}` : void 0
312
426
  });
313
427
  }
314
428
  });
315
429
  }
430
+ function diffPayloadPath(slug) {
431
+ return `_riebeckite/diff/${Buffer.from(slug).toString("base64url")}.json`;
432
+ }
316
433
  function createPostDiffApi(options = {}) {
317
434
  const reader = new GitMarkdownHistoryReader(options);
318
435
  return {
@@ -354,32 +471,16 @@ function createPostDiffApi(options = {}) {
354
471
  }
355
472
  };
356
473
  }
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))
474
+ async function getMarkdownRevisions(api, filePath, history) {
475
+ const revisions = await Promise.all(
476
+ history.map(async (revision) => ({
477
+ revision,
478
+ markdown: await api.getRevisionMarkdown(filePath, revision.hash)
479
+ }))
480
+ );
481
+ return revisions.flatMap(
482
+ ({ revision, markdown }) => markdown === null ? [] : [{ ...revision, markdown }]
381
483
  );
382
- return diffs.filter((entry) => entry !== null);
383
484
  }
384
485
  function resolvePostFilePath(context) {
385
486
  const indexedPath = context.contentIndex.get(context.slug.toLowerCase());
@@ -1,6 +1,7 @@
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
+ payloadPath?: string;
5
6
  };
6
7
  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.16",
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.16"
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.16"
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 {
@@ -136,27 +221,16 @@
136
221
  var(--rb-color-border, color-mix(in srgb, currentColor 18%, transparent));
137
222
  }
138
223
 
139
- .rr-diff-history__diff-head {
140
- border-block-end: 1px solid
141
- var(--rb-color-border, color-mix(in srgb, currentColor 18%, transparent));
142
- padding: 0.625rem 0.75rem;
143
- font-family: var(
144
- --rb-font-mono,
145
- ui-monospace,
146
- SFMono-Regular,
147
- Consolas,
148
- monospace
149
- );
150
- font-size: 0.8125rem;
151
- }
152
-
153
224
  .rr-diff-history__table-wrap {
154
225
  overflow-x: auto;
155
226
  }
156
227
 
157
228
  .rr-diff-history__table {
229
+ display: table;
158
230
  width: 100%;
159
231
  min-width: 42rem;
232
+ margin-block: 0;
233
+ overflow-x: visible;
160
234
  border-collapse: collapse;
161
235
  font-family: var(
162
236
  --rb-font-mono,