@tanishqkancharla/diffmap 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -170,9 +170,16 @@ Malformed references produce a parse error.
170
170
 
171
171
  On a GitHub-hosted spec, commit diffs come from the GitHub API. It returns patch
172
172
  hunks only, so only lines near changes are reachable, and the Diff button opens
173
- the pull request's full diff. `diffmap share` saves every referenced commit to a
174
- `diffmap-changes.json` file in the gist. Uncommitted references only work in a
175
- local viewer.
173
+ the pull request's full diff.
174
+
175
+ A shared gist is a snapshot taken when you run `diffmap share`. It saves every
176
+ referenced commit and every `changes` card, including uncommitted ones, to
177
+ `diffmap-changes.json`. It saves the current contents of every file that a file
178
+ excerpt or a reference without a commit reads to `diffmap-files.json`. In the
179
+ gist, those references open the saved file; symbols resolve to their first
180
+ matching line, and Cmd-click definitions only work in a local viewer. Commit
181
+ your work and point references at the commit before sharing, so the gist shows
182
+ the diff instead of a bare file.
176
183
 
177
184
  Cmd-click (or Ctrl-click) a TypeScript or JavaScript symbol in the source panel
178
185
  to open its definition. Use Back to retrace navigation and return to the reference.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanishqkancharla/diffmap",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Serve a spec or code walkthrough as a local page",
5
5
  "license": "MIT",
6
6
  "author": "Tanishq Kancharla",
package/src/changes.ts CHANGED
@@ -159,6 +159,12 @@ export type Changes = {
159
159
  /** Gist file holding the changes a shared spec references, keyed by rev. */
160
160
  export const GIST_CHANGES_FILE = "diffmap-changes.json";
161
161
 
162
+ /**
163
+ * Gist file holding the current contents of files a shared spec reads live,
164
+ * keyed by path: file excerpts and references without a commit.
165
+ */
166
+ export const GIST_FILES_FILE = "diffmap-files.json";
167
+
162
168
  export type ChangesResponse = { changes?: Changes; error?: string };
163
169
 
164
170
  /** jsdiff's default lines of context around each change. */
@@ -13,6 +13,7 @@ import { ViewerApp } from "../viewer/ViewerApp.tsx";
13
13
  import { loadGistMarkdown, type GistDocument } from "./fetchGist.ts";
14
14
  import { GistPinContext } from "./pin.ts";
15
15
  import { HostHome } from "../host/HostHome.tsx";
16
+ import { HostFilesContext } from "../host/files.ts";
16
17
  import { GistLoading, GistStatus } from "./GistStatus.tsx";
17
18
  import {
18
19
  gistPath,
@@ -166,26 +167,28 @@ export function GistApp() {
166
167
  htmlUrl: view.gist.htmlUrl,
167
168
  }}
168
169
  >
169
- <ChangesLoaderContext.Provider value={view.loadChanges}>
170
- <ViewerApp
171
- key={`${view.gist.id}:${view.file}`}
172
- document={view.document}
173
- mode="gist"
174
- headerActions={
175
- <GistHeader
176
- gistId={view.gist.id}
177
- htmlUrl={view.gist.htmlUrl}
178
- files={view.gist.files}
179
- current={view.file}
180
- onSelect={(file) => {
181
- const next = gistPath(view.gist.id, file);
182
- window.history.pushState({}, "", next);
183
- setRoute(parseGistPath(next));
184
- }}
185
- />
186
- }
187
- />
188
- </ChangesLoaderContext.Provider>
170
+ <HostFilesContext.Provider value={view.gist.sourceFiles}>
171
+ <ChangesLoaderContext.Provider value={view.loadChanges}>
172
+ <ViewerApp
173
+ key={`${view.gist.id}:${view.file}`}
174
+ document={view.document}
175
+ mode="gist"
176
+ headerActions={
177
+ <GistHeader
178
+ gistId={view.gist.id}
179
+ htmlUrl={view.gist.htmlUrl}
180
+ files={view.gist.files}
181
+ current={view.file}
182
+ onSelect={(file) => {
183
+ const next = gistPath(view.gist.id, file);
184
+ window.history.pushState({}, "", next);
185
+ setRoute(parseGistPath(next));
186
+ }}
187
+ />
188
+ }
189
+ />
190
+ </ChangesLoaderContext.Provider>
191
+ </HostFilesContext.Provider>
189
192
  </GistPinContext.Provider>
190
193
  );
191
194
  }
@@ -1,4 +1,10 @@
1
- import { GIST_CHANGES_FILE, type Changes, type Rev } from "../changes.ts";
1
+ import {
2
+ GIST_CHANGES_FILE,
3
+ GIST_FILES_FILE,
4
+ type Changes,
5
+ type Rev,
6
+ } from "../changes.ts";
7
+ import type { HostFiles } from "../host/files.ts";
2
8
  import { githubGistUrl, markdownFileNames, pickMarkdownFile } from "./route.ts";
3
9
 
4
10
  export type GistMarkdownFile = {
@@ -11,8 +17,10 @@ export type GistDocument = {
11
17
  htmlUrl: string;
12
18
  files: GistMarkdownFile[];
13
19
  chosen: GistMarkdownFile;
14
- /** Snapshot `diffmap share` saved for each commit the spec references. */
20
+ /** Snapshot `diffmap share` saved for each commit and changes card the spec references. */
15
21
  changes: Record<Rev, Changes>;
22
+ /** Files excerpts and references without a commit read, as they were when shared. */
23
+ sourceFiles: HostFiles;
16
24
  };
17
25
 
18
26
  export type GistLoadResult =
@@ -98,23 +106,21 @@ export async function loadGistMarkdown(
98
106
  return { status: "missing-file", htmlUrl, files };
99
107
  }
100
108
 
101
- const snapshotFile = apiFiles[GIST_CHANGES_FILE];
102
- const snapshot =
103
- snapshotFile === undefined
104
- ? "{}"
105
- : await readGistFile(snapshotFile, signal);
106
- if (snapshot instanceof Error) {
107
- return { status: "error", message: snapshot.message };
109
+ // SAFETY: diffmap share writes this file from readDocumentChanges.
110
+ const changes = (await readSnapshot(apiFiles, GIST_CHANGES_FILE, signal)) as
111
+ | Record<Rev, Changes>
112
+ | Error;
113
+ if (changes instanceof Error) {
114
+ return { status: "error", message: changes.message };
108
115
  }
109
- let changes: Record<Rev, Changes>;
110
- try {
111
- // SAFETY: diffmap share writes this file from readDocumentChanges.
112
- changes = JSON.parse(snapshot) as Record<Rev, Changes>;
113
- } catch {
114
- return {
115
- status: "error",
116
- message: `${GIST_CHANGES_FILE} is not valid JSON.`,
117
- };
116
+ // SAFETY: diffmap share writes this file as path to contents.
117
+ const sourceFiles = (await readSnapshot(
118
+ apiFiles,
119
+ GIST_FILES_FILE,
120
+ signal,
121
+ )) as HostFiles | Error;
122
+ if (sourceFiles instanceof Error) {
123
+ return { status: "error", message: sourceFiles.message };
118
124
  }
119
125
 
120
126
  return {
@@ -125,10 +131,28 @@ export async function loadGistMarkdown(
125
131
  files,
126
132
  chosen,
127
133
  changes,
134
+ sourceFiles,
128
135
  },
129
136
  };
130
137
  }
131
138
 
139
+ /** A JSON file `diffmap share` wrote, or `{}` when the gist predates it. */
140
+ async function readSnapshot(
141
+ apiFiles: Record<string, GistApiFile>,
142
+ name: string,
143
+ signal?: AbortSignal,
144
+ ): Promise<unknown> {
145
+ const file = apiFiles[name];
146
+ if (file === undefined) return {};
147
+ const text = await readGistFile(file, signal);
148
+ if (text instanceof Error) return text;
149
+ try {
150
+ return JSON.parse(text) as unknown;
151
+ } catch {
152
+ return new Error(`${name} is not valid JSON.`);
153
+ }
154
+ }
155
+
132
156
  async function readGistFile(file: GistApiFile, signal?: AbortSignal) {
133
157
  const name = file.filename ?? "file";
134
158
  if (file.truncated !== true && typeof file.content === "string") {
package/src/host/files.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { createContext, useContext } from "react";
2
2
 
3
- /** Bundled files for the hosted homepage. `[[path]]` reads these, not GitHub. */
3
+ /**
4
+ * Files a hosted viewer has without a server: the homepage's bundled sources,
5
+ * or the files `diffmap share` saved in a gist. `[[path]]` and file excerpts
6
+ * read these, not GitHub.
7
+ */
4
8
  export type HostFiles = Record<string, string>;
5
9
 
6
10
  export const HostFilesContext = createContext<HostFiles | undefined>(undefined);
@@ -234,6 +234,59 @@ test("shareMarkdownFile does not call gh when mermaid is invalid", async () => {
234
234
  });
235
235
  });
236
236
 
237
+ test("shareMarkdownFile uploads the files excerpts and rev-less references read", async () => {
238
+ const spec = [
239
+ "# Shared",
240
+ "",
241
+ "See [[src/share.ts#shareMarkdownFile]].",
242
+ "",
243
+ "```1:3:src/changes.ts",
244
+ "```",
245
+ "",
246
+ ].join("\n");
247
+ await withTempSpec(spec, async (filePath) => {
248
+ const dir = path.dirname(filePath);
249
+ const uploaded = path.join(dir, "uploaded");
250
+ await fs.mkdir(uploaded);
251
+ const gh = path.join(dir, "gh");
252
+ await fs.writeFile(
253
+ gh,
254
+ [
255
+ "#!/bin/sh",
256
+ 'if [ "$1" = auth ]; then exit 0; fi',
257
+ `for arg; do case "$arg" in *.json) cp "$arg" ${JSON.stringify(uploaded)};; esac; done`,
258
+ "echo https://gist.github.com/0123456789abcdef0123456789abcdef",
259
+ ].join("\n"),
260
+ { mode: 0o755 },
261
+ );
262
+ const originalPath = process.env.PATH;
263
+ process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
264
+ try {
265
+ const shared = await shareMarkdownFile({
266
+ workspaceRoot: packageRoot,
267
+ filePath,
268
+ isPublic: false,
269
+ });
270
+ assert.equal(shared instanceof Error, false, String(shared));
271
+ } finally {
272
+ process.env.PATH = originalPath;
273
+ }
274
+ const files: unknown = JSON.parse(
275
+ await fs.readFile(path.join(uploaded, "diffmap-files.json"), "utf8"),
276
+ );
277
+ assert.deepEqual(files, {
278
+ "src/share.ts": await fs.readFile(
279
+ path.join(packageRoot, "src/share.ts"),
280
+ "utf8",
281
+ ),
282
+ "src/changes.ts": await fs.readFile(
283
+ path.join(packageRoot, "src/changes.ts"),
284
+ "utf8",
285
+ ),
286
+ });
287
+ });
288
+ });
289
+
237
290
  function mermaidCliMessage(filePath: string) {
238
291
  return `diffmap could not parse mermaid in ${filePath} (diagram 1, line 3): Invalid mermaid header: "flwochart TD". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`;
239
292
  }
@@ -168,7 +168,7 @@ export function parseViewerDocument(source: string, path = "spec") {
168
168
  };
169
169
  }
170
170
 
171
- function collectFences(nodes: ViewerNode[]): Fence[] {
171
+ export function collectFences(nodes: ViewerNode[]): Fence[] {
172
172
  return nodes.flatMap((node) => {
173
173
  if (node.type === "view") return [node.fence];
174
174
  if (node.type === "element") return collectFences(node.children);
package/src/share.ts CHANGED
@@ -6,9 +6,14 @@ import { promisify } from "node:util";
6
6
  import { DiffmapFileError, DiffmapShareError } from "./errors.js";
7
7
  import { extractTitle } from "./extractDocument.js";
8
8
  import { extractGistId, gistViewerUrl } from "./gist/route.ts";
9
- import { GIST_CHANGES_FILE } from "./changes.js";
10
- import { readDocumentChanges } from "./git.js";
11
- import { parseViewerDocument } from "./parseViewer.js";
9
+ import { GIST_CHANGES_FILE, GIST_FILES_FILE } from "./changes.js";
10
+ import { readSourceReference } from "./definitions.js";
11
+ import { readChanges, readDocumentChanges } from "./git.js";
12
+ import {
13
+ collectFences,
14
+ parseViewerDocument,
15
+ type ViewerDocument,
16
+ } from "./parseViewer.js";
12
17
 
13
18
  const execFileAsync = promisify(execFile);
14
19
 
@@ -39,6 +44,16 @@ export async function shareMarkdownFile(input: {
39
44
  // The hosted viewer has no git, so the gist carries a snapshot of each commit.
40
45
  const changes = await readDocumentChanges(input.workspaceRoot, parsed);
41
46
  if (changes instanceof Error) return changes;
47
+ // An uncommitted changes card shows the tree as it is when you share.
48
+ for (const rev of parsed.changeRevs) {
49
+ if (changes.has(rev)) continue;
50
+ const loaded = await readChanges(input.workspaceRoot, rev);
51
+ if (loaded instanceof Error) return loaded;
52
+ changes.set(rev, loaded);
53
+ }
54
+ // It has no files either, so excerpts and references without a commit read these.
55
+ const files = readDocumentFiles(input.workspaceRoot, parsed);
56
+ if (files instanceof Error) return files;
42
57
 
43
58
  const auth = await runGh(["auth", "status"]);
44
59
  if (auth instanceof DiffmapShareError) return auth;
@@ -46,14 +61,20 @@ export async function shareMarkdownFile(input: {
46
61
  const args = ["gist", "create"];
47
62
  if (input.isPublic) args.push("--public");
48
63
  args.push("--desc", extractTitle(source), filePath);
64
+ const snapshots = [
65
+ { name: GIST_CHANGES_FILE, value: Object.fromEntries(changes) },
66
+ { name: GIST_FILES_FILE, value: files },
67
+ ].filter((snapshot) => Object.keys(snapshot.value).length > 0);
49
68
  const snapshotDir =
50
- changes.size === 0
69
+ snapshots.length === 0
51
70
  ? undefined
52
71
  : await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-share-"));
53
72
  if (snapshotDir !== undefined) {
54
- const snapshot = path.join(snapshotDir, GIST_CHANGES_FILE);
55
- await fs.writeFile(snapshot, JSON.stringify(Object.fromEntries(changes)));
56
- args.push(snapshot);
73
+ for (const snapshot of snapshots) {
74
+ const snapshotPath = path.join(snapshotDir, snapshot.name);
75
+ await fs.writeFile(snapshotPath, JSON.stringify(snapshot.value));
76
+ args.push(snapshotPath);
77
+ }
57
78
  }
58
79
 
59
80
  const created = await runGh(args);
@@ -81,6 +102,28 @@ export async function shareMarkdownFile(input: {
81
102
  };
82
103
  }
83
104
 
105
+ /** Current contents of every file an excerpt or a reference without a commit reads, by path. */
106
+ function readDocumentFiles(root: string, document: ViewerDocument) {
107
+ const paths = new Set<string>();
108
+ for (const reference of document.references) {
109
+ if (reference.kind === "file" && reference.rev === undefined)
110
+ paths.add(reference.path);
111
+ }
112
+ for (const fence of collectFences(document.nodes)) {
113
+ if (fence.kind === "file") paths.add(fence.path);
114
+ }
115
+ const files: Record<string, string> = {};
116
+ for (const filePath of paths) {
117
+ const read = readSourceReference(
118
+ root,
119
+ new URLSearchParams({ path: filePath }),
120
+ );
121
+ if (read instanceof Error) return read;
122
+ files[filePath] = read.contents;
123
+ }
124
+ return files;
125
+ }
126
+
84
127
  async function runGh(args: string[]) {
85
128
  try {
86
129
  return await execFileAsync("gh", args, {
@@ -112,7 +112,8 @@ export function SourceDiffPanel(props: {
112
112
  ? navigation.status
113
113
  : source instanceof Error
114
114
  ? source.message
115
- : changes instanceof Error
115
+ : // A shown file replaces the changes, so their error is moot, as in a gist without uncommitted changes.
116
+ changes instanceof Error && file === undefined
116
117
  ? changes.message
117
118
  : changes === undefined
118
119
  ? "Loading changes…"