@davmash/bygone 0.9.8 → 0.10.1

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 (68) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +74 -1
  3. package/cli/commandSpec.js +2 -0
  4. package/cli/completions.js +3 -3
  5. package/cli/present.js +41 -14
  6. package/cli/tour.js +22 -1
  7. package/cli/tourExport.js +147 -0
  8. package/cli/tourFile.js +109 -4
  9. package/cli/tourHistory.js +31 -1
  10. package/completions/_bygone +1 -1
  11. package/completions/bygone +1 -1
  12. package/completions/bygone.fish +1 -1
  13. package/docs/change-tour-format.md +85 -68
  14. package/docs/explore.md +14 -3
  15. package/docs/generating-change-tours.md +84 -46
  16. package/docs/present.md +103 -14
  17. package/docs/product-surface.md +19 -8
  18. package/docs/sharing-tours.md +95 -0
  19. package/docs/visual-walkthrough/README.md +89 -0
  20. package/docs/visual-walkthrough/build-tour.mjs +54 -0
  21. package/docs/visual-walkthrough/capture-desktop.cjs +42 -0
  22. package/docs/visual-walkthrough/capture-desktop.mjs +21 -0
  23. package/docs/visual-walkthrough/captures.md +49 -0
  24. package/docs/visual-walkthrough/create-demo.mjs +57 -0
  25. package/docs/visual-walkthrough/deconstructed-demo.bygone +100 -0
  26. package/docs/visual-walkthrough/images/01-two-files.png +0 -0
  27. package/docs/visual-walkthrough/images/02-many-panels.png +0 -0
  28. package/docs/visual-walkthrough/images/03-directories.png +0 -0
  29. package/docs/visual-walkthrough/images/04-search.png +0 -0
  30. package/docs/visual-walkthrough/images/05-repository-history.png +0 -0
  31. package/docs/visual-walkthrough/images/06-file-history.png +0 -0
  32. package/docs/visual-walkthrough/images/07-branch-review.png +0 -0
  33. package/docs/visual-walkthrough/images/08-tour-reader.png +0 -0
  34. package/docs/visual-walkthrough/images/09-focused-step.png +0 -0
  35. package/docs/visual-walkthrough/images/10-deconstructed.png +0 -0
  36. package/docs/visual-walkthrough/images/11-exploration.png +0 -0
  37. package/docs/visual-walkthrough/images/12-authoring.png +0 -0
  38. package/docs/visual-walkthrough/images/13-export.png +0 -0
  39. package/docs/visual-walkthrough/images/14-vscode.png +0 -0
  40. package/docs/visual-walkthrough/images/15-cli.png +0 -0
  41. package/docs/visual-walkthrough/narrative.md +134 -0
  42. package/docs/visual-walkthrough/outline.md +52 -0
  43. package/docs/visual-walkthrough/walkthrough.bygone +340 -0
  44. package/docs/walkthrough.md +6 -101
  45. package/examples/bygone-history.bygone +11 -44
  46. package/examples/marker-focus.bygone +116 -0
  47. package/examples/navigation-lab.bygone.yaml +401 -336
  48. package/examples/tour-orientation.bygone +349 -0
  49. package/media/diff.worker.js +13 -3
  50. package/media/webview.css +1 -1
  51. package/media/webview.js +394 -319
  52. package/out/changeTour.js +865 -397
  53. package/out/deepLink.js +167 -0
  54. package/out/historyDirectory.js +66 -0
  55. package/out/standalone-main.js +20532 -16832
  56. package/out/tourCoverage.js +850 -386
  57. package/out/tourDocument.js +97 -0
  58. package/package.json +1 -1
  59. package/schemas/change-tour-source.schema.json +91 -64
  60. package/skills/pr-tour-guide/SKILL.md +144 -128
  61. package/skills/pr-tour-guide/references/expectations-and-feedback.md +57 -0
  62. package/standalone/index.html +10 -25
  63. package/web/export-bootstrap.js +62 -0
  64. package/web/export-licenses.txt +78 -0
  65. package/web/export-runtime.json +12 -0
  66. package/web/index.html +54 -47
  67. package/web/presenter.css +332 -178
  68. package/web/web-host.js +239 -137
package/CHANGELOG.md CHANGED
@@ -1,6 +1,31 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.10.1
4
+
5
+ - Fixed desktop tours showing unstyled HTML after temporary asset cleanup during long-running app sessions
6
+ - Made presentation asset read failures return HTTP 500 and added HTTP and Electron ASAR regression coverage
7
+
8
+ ## 0.10.0
9
+
10
+ - Restored detailed highlighting for long prose and expanded instructions; unavailable word detail now has a uniform blue fill and an explanatory `≠` marker
11
+ - Made line-ending and final-newline differences visible instead of reporting identical files
12
+ - Unified overlapping multi-panel highlights without stacked fills, with optional focus on the selected comparison pair
13
+ - Added comparison-panel reordering, reliable revision selection, separate windows for new comparisons, and preserved initial change reveal
14
+ - Added file trees and changed-file filters across views, plus single-file and blank-panel comparisons
15
+ - Added portable HTML tour exports, pinned screenshot evidence, clearer tour introductions and navigation, and explanation expectations and reader feedback
16
+ - Added 2x narration playback and stabilized expanded playback controls
17
+ - Improved long-line and rewrapped YAML matching, with independent field-prefix scoring and ambiguity checks
18
+ - Merged touching blue change regions while preserving scroll anchors, and clipped gutter connectors to editor bounds
19
+ - Added desktop section links for tours saved outside their repository
20
+ - Replaced repeated Scene context with clickable sticky scene headings and expandable overviews
21
+ - Made History file navigation skip unchanged files; unified workspace controls, Files/Commits navigation, and disabled Tour tabs outside tours
22
+ - Added saved 2/3/4/Fit panel density, uncapped Fit, stable visible groups, broader Find, and preserved edits and scroll positions
23
+ - Unified window typography, surfaces, and control colors across tours, History, and Compare
24
+ - Added automatic `+dev.N` versions for uncommitted local rebuilds without consuming release patch numbers
25
+ - Bumped the tour authoring and compiled format to v4 and removed Review Notes; legacy sources offer conversion with review notes preserved in the tour flow
26
+
27
+ macOS desktop downloads are unsigned and not notarized. If Gatekeeper reports
28
+ the app as damaged, run `xattr -cr /Applications/Bygone.app`, then reopen it.
4
29
 
5
30
  ## 0.9.8
6
31
 
package/README.md CHANGED
@@ -22,7 +22,7 @@ Bygone is a VS Code extension and standalone desktop app for visual diff and fil
22
22
 
23
23
  - Editable two-way side-by-side diff view
24
24
  - Dynamic adjacent multi-panel diff view
25
- - Flowing connectors, block contours, and inline change highlighting
25
+ - Flowing connectors, block contours, and [inline change highlighting](#change-blocks-and-inline-highlighting)
26
26
  - Git file history viewer with commit-by-commit navigation
27
27
  - Local branch-change exploration using merge-base-to-tip semantics and changed-file progress
28
28
  - Binary-aware comparisons with side-by-side image previews and byte-equality reporting
@@ -31,6 +31,66 @@ Bygone is a VS Code extension and standalone desktop app for visual diff and fil
31
31
 
32
32
  This project is usable as a VS Code extension and standalone desktop app. The two-way diff and git history flows are the most complete. The multi-panel view is diff-focused, not a merge tool.
33
33
 
34
+ ## Change blocks and inline highlighting
35
+
36
+ Hover a changed block on either side or its gutter connector to show copy arrows.
37
+ Moving the text cursor into a changed block or jumping to a change also shows them.
38
+ Each arrow stays beside its source block's first line: the left arrow points right,
39
+ and the right arrow points left. Clicking copies that block to the opposite panel;
40
+ arrows targeting a read-only panel are disabled.
41
+
42
+ A touching run of deletions and insertions forms one blue replacement block,
43
+ even when the text has no matching words. One-sided changes stay green. Green
44
+ and blue blocks can still touch; exactly matching lines separate change regions.
45
+ When an unpaired gap contains exactly one line on each side, those lines are
46
+ compared for inline highlighting. Larger ambiguous gaps keep conservative line
47
+ alignment within their blue block. A short statement expanded from one line to
48
+ several, or collapsed back to one, is compared as a whole for inline highlights
49
+ when the combined text is similar enough. This preserves the line alignment and
50
+ the inline highlighting length limit.
51
+
52
+ Multi-panel comparisons show changes from all adjacent pairs. A shared panel
53
+ shows the union of changed lines and words, with overlaps painted once. A line
54
+ that is one-sided in one pair and replaced in another uses replacement blue;
55
+ connectors in each gutter still describe that pair. **Focus selected pair** is off
56
+ by default. Turn it on to show only the selected pair's highlights and connectors,
57
+ then select another gutter to inspect that comparison with its own block colors.
58
+ Toggling focus preserves your scroll position. Previous/next change always follows the selected pair.
59
+ Directory and file views share the **Visible panels** setting (2, 3, 4, or Fit),
60
+ so opening a file or returning to its directory keeps the chosen density.
61
+ New three-way and four-way comparisons start with 3 and 4 panels selected.
62
+ Those counts stay visible even in tight windows; choose **Fit** for automatic
63
+ width-based adjustment. You can still select a smaller count manually.
64
+
65
+ Bygone matches words first, then uses **edge trimming** inside each replacement:
66
+ leave the longest identical beginning and ending unhighlighted, and highlight
67
+ the remaining middle. The beginning and ending never overlap. If one complete
68
+ text is a prefix or suffix of the other, keep it unhighlighted: `Name` → `NewName`
69
+ shows `Name` → `[New]Name`. If either edge works, or otherwise when matching edges
70
+ overlap, prefer the beginning. Adjacent removed
71
+ and inserted tokens form one replacement until an unchanged token separates them.
72
+
73
+ Brackets below represent the stronger inline highlight:
74
+
75
+ | Change | Highlighted result |
76
+ | --- | --- |
77
+ | `oldName` → `newName` | `[old]Name` → `[new]Name` |
78
+ | `nameOld` → `nameNew` | `name[Old]` → `name[New]` |
79
+ | `userCount` → `usersCount` | `userCount` → `user[s]Count` |
80
+ | `userStatisticalCount` → `userMetricsCount` | `user[Statistical]Count` → `user[Metrics]Count` |
81
+ | `oldNameOld` → `newNameNew` | `[oldNameOld]` → `[newNameNew]` |
82
+
83
+ This handles a single contiguous edit anywhere in a replacement. It does not
84
+ search for unchanged text inside the remaining middle: multiple separated edits
85
+ within one word highlight everything from the first difference through the last.
86
+ It also makes no semantic judgment: `timeoutMs` → `timeoutSeconds` preserves the
87
+ shared final `s`, showing `timeout[M]s` → `timeout[Second]s`.
88
+
89
+ Highlight boundaries preserve whole displayed Unicode characters, including
90
+ combining accents and emoji. Edge trimming changes inline emphasis, not line
91
+ pairing or block colors. Existing whitespace-only highlighting and limits on
92
+ inline highlighting for long lines still apply.
93
+
34
94
  ## Install For Development
35
95
 
36
96
  Install dependencies and compile:
@@ -81,6 +141,19 @@ For a one-command local sync that builds the repo, installs the global CLI, pack
81
141
  npm run dev:sync
82
142
  ```
83
143
 
144
+ A clean checkout installs the committed version. Rebuilding with uncommitted
145
+ changes automatically increments a local iteration: `0.9.9+dev.1`,
146
+ `0.9.9+dev.2`, and so on. The CLI, Desktop About dialog, and artifact names
147
+ include that label. This is SemVer build metadata, not a new release patch;
148
+ it does not change version precedence. `npm run reinstall` reuses the current
149
+ artifacts without incrementing the iteration.
150
+
151
+ For a real release, set the intended plain version with
152
+ `npm version 0.9.10 --no-git-tag-version`, consolidate the changelog, and commit
153
+ before `npm run dev:sync`. Local dev versions must not be published;
154
+ `release:build` and `release:publish` reject them. Keep dev iterations out of
155
+ committed release history.
156
+
84
157
  CLI defaults:
85
158
  - In a Git repo, `bygone` opens directory history for the current directory.
86
159
  - Outside a Git repo, `bygone` opens a blank editable diff.
@@ -18,6 +18,8 @@ const CLI_SPEC = Object.freeze({
18
18
  'bygone tour compile <file.bygone> [--output <tour.json>]',
19
19
  'bygone tour context [<head>] [--base <base>] [--output <context.json>]',
20
20
  'bygone tour coverage <file.bygone> [--json] [--minimum-coverage <0-100>]',
21
+ 'bygone tour export <file.bygone> --output <tour.html> [--profile minimal|full] [--runtime embedded|cdn]',
22
+ 'bygone <bygone://open/v1?...>',
21
23
  'bygone tour schema',
22
24
  'bygone --branch-diff [-b BRANCH] [-m MAIN]',
23
25
  'bygone completion <zsh|bash|fish>',
@@ -75,7 +75,7 @@ ${legacyOptions}
75
75
  ${historyOptions}
76
76
  )
77
77
  shells=('zsh:Z shell' 'bash:Bash' 'fish:Fish shell')
78
- tour_actions=('context:Build structured change evidence' 'coverage:Measure authored tour coverage' 'validate:Validate source and anchors' 'compile:Compile a repository-bound manifest' 'schema:Print the source schema')
78
+ tour_actions=('context:Build structured change evidence' 'coverage:Measure authored tour coverage' 'validate:Validate source and anchors' 'compile:Compile a repository-bound manifest' 'export:Export a portable HTML tour' 'schema:Print the source schema')
79
79
 
80
80
  if (( CURRENT == 2 )); then
81
81
  _describe 'bygone command' root_items
@@ -183,7 +183,7 @@ _bygone() {
183
183
  ;;
184
184
  tour)
185
185
  if (( COMP_CWORD == 2 )); then
186
- COMPREPLY=( $(compgen -W 'context coverage validate compile schema' -- "$cur") )
186
+ COMPREPLY=( $(compgen -W 'context coverage validate compile export schema' -- "$cur") )
187
187
  elif [[ "\${COMP_WORDS[2]}" == "context" ]]; then
188
188
  refs="$(_bygone_git_refs)"
189
189
  if [[ "$prev" =~ ^(${baseTokens})$ ]]; then
@@ -262,7 +262,7 @@ function generateFishCompletion() {
262
262
 
263
263
  lines.push(
264
264
  "complete -c bygone -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish' -d 'Shell'",
265
- "complete -c bygone -n '__fish_seen_subcommand_from tour' -a 'context coverage validate compile schema' -d 'Tour action'",
265
+ "complete -c bygone -n '__fish_seen_subcommand_from tour' -a 'context coverage validate compile export schema' -d 'Tour action'",
266
266
  "complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l base -r -a '(__bygone_git_refs)' -d 'Set the change-context base ref'",
267
267
  "complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l output -s o -r -d 'Write the change context'",
268
268
  "complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l max-patch-bytes -r -d 'Maximum included patch size per file'",
package/cli/present.js CHANGED
@@ -1,4 +1,4 @@
1
- const { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs');
1
+ const { existsSync, mkdirSync, readFile, readFileSync, realpathSync, writeFileSync } = require('fs');
2
2
  const { createServer } = require('http');
3
3
  const path = require('path');
4
4
  const { spawn } = require('child_process');
@@ -6,8 +6,10 @@ const { buildChangeTourManifest, parseChangeTourStory } = require('../out/change
6
6
  const { buildTourAuthoringCoverage, buildTourCoverageReport } = require('../out/tourCoverage.js');
7
7
  const { buildTourWindowTitle } = require('../out/windowTitle.js');
8
8
  const { tokenMatches } = require('./commandSpec.js');
9
- const { loadTourSource, parseTourSourceText, buildManifestForTourSource } = require('./tourFile.js');
9
+ const { loadTourSource, parseTourSourceText, inspectTourConversion, convertTourSourceText, buildManifestForTourSource } = require('./tourFile.js');
10
10
  const { createTourHistory } = require('./tourHistory.js');
11
+ const { pathToFileURL } = require('url');
12
+ const { resolveDocumentFocus, serializeDocumentFragment } = require('../out/deepLink.js');
11
13
 
12
14
  const MIME_TYPES = new Map([
13
15
  ['.css', 'text/css; charset=utf-8'],
@@ -24,7 +26,9 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
24
26
  const story = !options.ignoreEnvironment && process.env.BYGONE_TOUR_STORY
25
27
  ? parseChangeTourStory(JSON.parse(readFileSync(path.resolve(cwd, process.env.BYGONE_TOUR_STORY), 'utf8')))
26
28
  : undefined;
27
- const source = tourPath ? loadTourSource(cwd, tourPath).source : undefined;
29
+ const loadedSource = tourPath ? await (options.loadTourSource || loadTourSource)(cwd, tourPath) : undefined;
30
+ if (tourPath && !loadedSource) return null;
31
+ const source = loadedSource?.source;
28
32
  const builtManifest = buildChangeTourManifest(cwd, {
29
33
  headRef: explicitHeadRef || source?.range?.head || headRef,
30
34
  baseRef: baseRef || source?.range?.base,
@@ -36,6 +40,14 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
36
40
  const manifest = source
37
41
  ? { ...builtManifest, authoringCoverage: buildTourAuthoringCoverage(buildTourCoverageReport(cwd, source)) }
38
42
  : builtManifest;
43
+ if (options.location) resolveDocumentFocus(manifest, options.location.mode, options.location.focus);
44
+ if (tourPath) {
45
+ const root = realpathSync(builtManifest.repository?.root || cwd);
46
+ const documentPath = realpathSync(loadedSource.resolvedPath);
47
+ const relative = path.relative(root, documentPath).split(path.sep).join('/');
48
+ const contained = relative && relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative);
49
+ manifest.localSource = { repo: pathToFileURL(root).href, tour: contained ? relative : pathToFileURL(documentPath).href };
50
+ }
39
51
  const history = createTourHistory(manifest);
40
52
  const serializedManifest = `${JSON.stringify(manifest, null, 2)}\n`;
41
53
  const outputPath = options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_OUTPUT;
@@ -68,16 +80,20 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
68
80
  request.on('end', () => {
69
81
  if (response.writableEnded) return;
70
82
  try {
71
- const uploadedSource = parseTourSourceText(JSON.parse(body).source);
83
+ const input = JSON.parse(body);
84
+ const conversion = inspectTourConversion(input.source);
85
+ if (conversion && input.convertToV4 !== true) return respondJson(response, 409, { conversion });
86
+ const converted = conversion ? convertTourSourceText(input.source) : null;
87
+ const uploadedSource = converted?.source || parseTourSourceText(input.source);
72
88
  const loaded = buildManifestForTourSource(cwd, uploadedSource);
73
89
  const id = String(uploadedTours.size + 1);
74
90
  uploadedTours.set(id, { manifest: loaded, history: createTourHistory(loaded) });
75
- respondJson(response, 200, { manifestUrl: `/loaded/${id}/tour.json`, repository: loaded.repository?.root || cwd, range: loaded.range });
91
+ respondJson(response, 200, { manifestUrl: `/loaded/${id}/tour.json`, repository: loaded.repository?.root || cwd, range: loaded.range, convertedSource: converted?.text });
76
92
  } catch (error) { respondJson(response, 400, { error: `Invalid tour for this repository: ${error.message}` }); }
77
93
  });
78
94
  return;
79
95
  }
80
- const loadedMatch = requestUrl.pathname.match(/^\/loaded\/(\d+)\/(tour\.json|history\/(?:list|diff|compare|compare-many|revisions))$/);
96
+ const loadedMatch = requestUrl.pathname.match(/^\/loaded\/(\d+)\/(tour\.json|history\/(?:list|diff|compare|compare-many|changed-files|revisions))$/);
81
97
  const loadedTour = loadedMatch ? uploadedTours.get(loadedMatch[1]) : null;
82
98
  if (loadedMatch && !loadedTour) return respondJson(response, 404, { error: 'Loaded document not found' });
83
99
  if (loadedTour && loadedMatch[2] === 'tour.json') return respondJson(response, 200, loadedTour.manifest);
@@ -85,7 +101,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
85
101
  if (loadedMatch) requestUrl.pathname = `/${loadedMatch[2]}`;
86
102
  if (requestUrl.pathname === '/history/list' || requestUrl.pathname === '/history/diff'
87
103
  || requestUrl.pathname === '/history/compare' || requestUrl.pathname === '/history/compare-many'
88
- || requestUrl.pathname === '/history/revisions') {
104
+ || requestUrl.pathname === '/history/changed-files' || requestUrl.pathname === '/history/revisions') {
89
105
  if (request.method !== 'POST') return respondJson(response, 405, { error: 'Method not allowed' });
90
106
  if (!isSameOriginLoopbackRequest(request)) return respondJson(response, 403, { error: 'Forbidden' });
91
107
  if (!requestHistory) return respondJson(response, 404, { error: 'History requires a version 2 tour.' });
@@ -110,7 +126,9 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
110
126
  ? requestHistory.compare(input)
111
127
  : requestUrl.pathname === '/history/compare-many'
112
128
  ? requestHistory.compareMany(input)
113
- : requestHistory.revisions(input);
129
+ : requestUrl.pathname === '/history/changed-files'
130
+ ? requestHistory.changedFiles(input)
131
+ : requestHistory.revisions(input);
114
132
  respondJson(response, 200, result);
115
133
  } catch {
116
134
  respondJson(response, 400, { error: 'Could not load history for this file and revision.' });
@@ -160,11 +178,20 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
160
178
  respond(response, 404, 'Not found');
161
179
  return;
162
180
  }
163
- response.writeHead(200, {
164
- 'Content-Type': MIME_TYPES.get(path.extname(targetPath)) || 'application/octet-stream',
165
- 'Cache-Control': 'no-store'
181
+ // Electron streams extract ASAR entries to cached temporary files. Those
182
+ // can disappear during a long-running session; readFile reads the archive
183
+ // directly and lets us report failures before committing success headers.
184
+ readFile(targetPath, (error, content) => {
185
+ if (error) {
186
+ respond(response, 500, 'Read failed');
187
+ return;
188
+ }
189
+ response.writeHead(200, {
190
+ 'Content-Type': MIME_TYPES.get(path.extname(targetPath)) || 'application/octet-stream',
191
+ 'Cache-Control': 'no-store'
192
+ });
193
+ response.end(content);
166
194
  });
167
- createReadStream(targetPath).on('error', () => respond(response, 500, 'Read failed')).pipe(response);
168
195
  });
169
196
  const requestedPort = readPort(options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_PORT);
170
197
  await new Promise((resolve, reject) => {
@@ -176,7 +203,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
176
203
  server.close();
177
204
  throw new Error('Could not determine the presentation server address.');
178
205
  }
179
- const url = `http://127.0.0.1:${address.port}/?manifest=/tour.json`;
206
+ const url = `http://127.0.0.1:${address.port}/?manifest=/tour.json${options.location ? serializeDocumentFragment(options.location.mode, options.location.focus) : ''}`;
180
207
  if (options.announce !== false) {
181
208
  process.stdout.write(`Bygone change tour running at ${url}\n`);
182
209
  process.stdout.write('Press Ctrl+C to stop.\n');
@@ -190,7 +217,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
190
217
  throw error;
191
218
  }
192
219
  }
193
- return { manifest, server, url };
220
+ return { manifest, server, url, sourcePath: loadedSource?.resolvedPath };
194
221
  }
195
222
 
196
223
  function parsePresentArgs(args) {
package/cli/tour.js CHANGED
@@ -4,9 +4,30 @@ const { buildChangeTourContext } = require('../out/changeTour.js');
4
4
  const { buildTourCoverageReport } = require('../out/tourCoverage.js');
5
5
  const { buildManifestForTourSource, loadTourSource } = require('./tourFile.js');
6
6
 
7
- const TOUR_ACTIONS = Object.freeze(['context', 'coverage', 'validate', 'compile', 'schema']);
7
+ const TOUR_ACTIONS = Object.freeze(['context', 'coverage', 'validate', 'compile', 'export', 'schema']);
8
8
 
9
9
  function runTourCommand(args, cwd, packageRoot, output = process.stdout) {
10
+ if (args[0] === 'export') {
11
+ const { exportTour } = require('./tourExport');
12
+ let sourcePath, outputPath;
13
+ const options = {};
14
+ for (let i = 1; i < args.length; i++) {
15
+ const arg = args[i];
16
+ if (arg === '--overwrite') { options.overwrite = true; continue; }
17
+ if (['--output', '-o', '--profile', '--runtime', '--format', '--runtime-base'].includes(arg)) {
18
+ const value = args[++i];
19
+ if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
20
+ if (arg === '--output' || arg === '-o') outputPath = value;
21
+ else if (arg === '--format') { if (value !== 'html') throw new Error('Only HTML export is supported.'); }
22
+ else options[arg === '--runtime-base' ? 'runtimeBase' : arg.slice(2)] = value;
23
+ } else if (arg.startsWith('-') || sourcePath) throw new Error(`Unexpected export argument: ${arg}`);
24
+ else sourcePath = arg;
25
+ }
26
+ if (!sourcePath || !outputPath) throw new Error('Usage: bygone tour export file.bygone --output tour.html [--profile minimal|full] [--runtime embedded|cdn]');
27
+ const result = exportTour(path.resolve(cwd, sourcePath), path.resolve(cwd, outputPath), packageRoot, options);
28
+ output.write(`Wrote ${result.profile} HTML tour to ${result.outputPath} (${result.bytes} bytes). ${result.summary}\n`);
29
+ return result;
30
+ }
10
31
  const options = parseTourArgs(args);
11
32
  if (options.action === 'schema') {
12
33
  output.write(readFileSync(path.join(packageRoot, 'schemas', 'change-tour-source.schema.json'), 'utf8'));
@@ -0,0 +1,147 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { createHash } = require('crypto');
4
+ const { execFileSync } = require('child_process');
5
+ const { TextDecoder } = require('util');
6
+ const { buildManifestForTourSource, loadTourSource } = require('./tourFile');
7
+ const { discoverAuthoredTourDocument } = require('../out/tourDocument');
8
+
9
+ const ASSETS = ['web/presenter.css', 'media/webview.css', 'web/web-host.js', 'media/webview.js', 'media/editor.worker.js', 'media/diff.worker.js'];
10
+ const MAX_BYTES = 128 * 1024 * 1024;
11
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
12
+ function json(value) { return JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); }
13
+ function html(value) { return String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])); }
14
+ function git(root, args, binary = false) {
15
+ return execFileSync('git', args, { cwd: root, encoding: binary ? undefined : 'utf8', maxBuffer: MAX_BYTES, stdio: ['ignore', 'pipe', 'pipe'] });
16
+ }
17
+ function buildHistory(root, manifest, texts) {
18
+ const base = manifest.range.mergeBaseOid;
19
+ const head = manifest.range.headOid;
20
+ const refs = new Set([head, ...git(root, ['rev-list', '--topo-order', `${base}..${head}`, '--']).trim().split('\n').filter(Boolean), base]);
21
+ function collect(value) {
22
+ if (!value || typeof value !== 'object') return;
23
+ if (value.kind === 'stacked-diff') value.stack.forEach(panel => refs.add(panel.oid));
24
+ if (value.kind === 'deconstructed-diff') value.panels.forEach(panel => { if (panel.oid) refs.add(panel.oid); });
25
+ Object.values(value).forEach(child => { if (Array.isArray(child)) child.forEach(collect); else if (child && typeof child === 'object') collect(child); });
26
+ }
27
+ collect(manifest);
28
+ if (refs.size > 256) throw new Error('Full export exceeds the initial 256-revision safety limit. Narrow the tour range.');
29
+ const paths = new Set(manifest.files.flatMap(file => [file.path, file.previousPath].filter(Boolean)));
30
+ function collectPaths(value) {
31
+ if (!value || typeof value !== 'object') return;
32
+ if (typeof value.path === 'string' && value.kind !== 'directory-diff') paths.add(value.path);
33
+ Object.values(value).forEach(child => { if (Array.isArray(child)) child.forEach(collectPaths); else if (child && typeof child === 'object') collectPaths(child); });
34
+ }
35
+ collectPaths(manifest.scenes); collectPaths(manifest.tours); collectPaths(manifest.zoom);
36
+ const commits = [...refs].map(oid => {
37
+ const [parents, summary, timestamp, author, authorEmail] = git(root, ['show', '-s', '--format=%P%x00%s%x00%cI%x00%an%x00%ae', oid, '--']).trimEnd().split('\0');
38
+ const parentIds = parents.split(' ').filter(Boolean);
39
+ // Include changes that disappear in the final endpoint diff, and rename predecessors.
40
+ if (oid !== base) for (const name of git(root, ['diff-tree', '--root', '-m', '--no-commit-id', '-r', '--name-only', '-z', oid, '--']).split('\0').filter(Boolean)) paths.add(name);
41
+ return { oid, commit: oid, parents: parentIds, parentCommit: parentIds[0] || null, shortCommit: oid.slice(0, 7), summary, timestamp, author, authorEmail, message: summary };
42
+ });
43
+ if (paths.size > 4096) throw new Error('Full export exceeds the initial 4096-path safety limit. Narrow the tour.');
44
+ const snapshots = {};
45
+ for (const oid of refs) {
46
+ const tree = new Map(git(root, ['ls-tree', '-r', '-z', oid]).split('\0').filter(Boolean).map(record => {
47
+ const split = record.indexOf('\t');
48
+ const [mode, type, object] = record.slice(0, split).split(' ');
49
+ return [record.slice(split + 1), { mode, type, object }];
50
+ }));
51
+ snapshots[oid] = Object.create(null);
52
+ for (const name of paths) {
53
+ const record = tree.get(name);
54
+ if (!record) { snapshots[oid][name] = null; continue; }
55
+ if (record.type !== 'blob') { snapshots[oid][name] = { omitted: 'Submodule content is not packaged.' }; continue; }
56
+ if (Number(git(root, ['cat-file', '-s', record.object]).trim()) > 2 * 1024 * 1024) { snapshots[oid][name] = { omitted: 'File exceeds 2 MiB.' }; continue; }
57
+ const bytes = git(root, ['cat-file', 'blob', record.object], true);
58
+ let text;
59
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); if (bytes.includes(0)) throw new Error('binary'); }
60
+ catch { snapshots[oid][name] = { omitted: 'Binary or non-UTF-8 content.' }; continue; }
61
+ if (text.startsWith('version https://git-lfs.github.com/spec/v1\n')) { snapshots[oid][name] = { omitted: 'Git LFS object is not packaged.' }; continue; }
62
+ if (text.split('\n').some(line => Buffer.byteLength(line) > 64 * 1024)) { snapshots[oid][name] = { omitted: 'A line exceeds 64 KiB.' }; continue; }
63
+ const blob = digest(text); texts[blob] = text;
64
+ snapshots[oid][name] = { blob, mode: record.mode, gitBlob: record.object };
65
+ }
66
+ if (Buffer.byteLength(JSON.stringify(texts)) > MAX_BYTES) throw new Error('Export evidence exceeds 128 MiB. Narrow the tour.');
67
+ }
68
+ return { commits, paths: [...paths].sort(), snapshots, comparison: 'Exact paths; renames appear as deletion and addition. Parents outside the included set are unavailable.' };
69
+ }
70
+ function materializeTour(sourcePath, profile) {
71
+ const document = discoverAuthoredTourDocument(sourcePath);
72
+ const originalBytes = fs.readFileSync(document.documentPath);
73
+ const { source } = loadTourSource(document.repoRoot, document.documentPath);
74
+ const manifest = buildManifestForTourSource(document.repoRoot, source);
75
+ const texts = {};
76
+ const history = profile === 'full' ? buildHistory(document.repoRoot, manifest, texts) : undefined;
77
+ delete manifest.repository;
78
+ delete manifest.localSource;
79
+ // A source URL is optional provenance and may carry credentials or local bindings.
80
+ delete manifest.sourceUrl;
81
+ const contentKeys = new Set(['leftContent', 'rightContent', 'content']);
82
+ function pack(value) {
83
+ if (Array.isArray(value)) return value.map(pack);
84
+ if (!value || typeof value !== 'object') return value;
85
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
86
+ if (contentKeys.has(key) && typeof child === 'string') {
87
+ const id = digest(child); texts[id] = child; return [key, { $text: id }];
88
+ }
89
+ return [key, pack(child)];
90
+ }));
91
+ }
92
+ if (!fs.readFileSync(document.documentPath).equals(originalBytes)) throw new Error('The tour source changed during export. Retry from the saved document.');
93
+ const packed = pack(manifest);
94
+ const identity = { profile, manifest: { ...packed, generatedAt: undefined }, texts, history };
95
+ return { version: 1, profile, contentId: digest(JSON.stringify(identity)), sourceFingerprint: digest(originalBytes), manifest: packed, texts, ...(history ? { history } : {}) };
96
+ }
97
+ function exportTour(sourcePath, outputPath, packageRoot, options = {}) {
98
+ const profile = options.profile || 'minimal';
99
+ const runtime = options.runtime || 'embedded';
100
+ if (!['minimal', 'full'].includes(profile)) throw new Error('Export profile must be minimal or full.');
101
+ if (!['embedded', 'cdn'].includes(runtime)) throw new Error('Export runtime must be embedded or cdn.');
102
+ const data = materializeTour(sourcePath, profile);
103
+ const version = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')).version;
104
+ const runtimeManifestPath = path.join(packageRoot, 'web', 'export-runtime.json');
105
+ if (!fs.existsSync(runtimeManifestPath)) throw new Error('Export runtime assets are missing. Rebuild Bygone first.');
106
+ const runtimeManifest = JSON.parse(fs.readFileSync(runtimeManifestPath, 'utf8'));
107
+ if (runtimeManifest.version !== version || runtimeManifest.schema !== 1) throw new Error('Export runtime does not match this Bygone build.');
108
+ if (runtime === 'cdn' && !options.runtimeBase) throw new Error('CDN export requires a published, verified export runtime. Use --runtime embedded until the export-enabled release is published.');
109
+ if (options.runtimeBase && !/^https:\/\/cdn\.jsdelivr\.net\/npm\/@davmash\/bygone@\d+\.\d+\.\d+(?:-[\w.-]+)?\/$/.test(options.runtimeBase)) throw new Error('Runtime base must pin an exact Bygone release on jsDelivr.');
110
+ if (runtime === 'cdn' && options.runtimeBase !== `https://cdn.jsdelivr.net/npm/@davmash/bygone@${version}/`) throw new Error('CDN runtime version must match this exact Bygone build.');
111
+ const assets = ASSETS.map(name => {
112
+ const bytes = fs.readFileSync(path.join(packageRoot, name));
113
+ const integrity = `sha256-${createHash('sha256').update(bytes).digest('base64')}`;
114
+ if (runtimeManifest.assets[name] !== integrity) throw new Error(`Runtime integrity mismatch: ${name}. Rebuild Bygone.`);
115
+ return { name, integrity, ...(runtime === 'embedded' ? { base64: bytes.toString('base64') } : { url: options.runtimeBase + name }) };
116
+ });
117
+ const payload = { ...data, runtimeVersion: version, assets };
118
+ const summary = `${profile === 'full' ? 'Full' : 'Minimal'} snapshot · ${Object.keys(data.texts).length} unique texts · ${Buffer.byteLength(JSON.stringify(data.texts))} evidence bytes${data.history ? ` · ${data.history.commits.length} revisions · ${data.history.paths.length} paths` : ' · no browsable Git history'}. Full file contents, including surrounding/deleted code, are included.`;
119
+ let shell = fs.readFileSync(path.join(packageRoot, 'web', 'index.html'), 'utf8')
120
+ .replace(/<link href="\/(?:media|web)\/[^"]+" rel="stylesheet">/g, '')
121
+ .replace(/<script src="\/(?:web|media)\/[^"]+"><\/script>/g, '')
122
+ .replace('<title>Bygone Tour</title>', `<title>${html(data.manifest.title)} — Bygone export</title>`);
123
+ const network = runtime === 'cdn' ? ' https://cdn.jsdelivr.net' : '';
124
+ const policy = `default-src 'none'; script-src 'unsafe-inline' blob:${network}; style-src 'unsafe-inline' blob:${network}; font-src data:; img-src data: blob:; worker-src blob:; connect-src${network || " 'none'"}; frame-src 'none'; base-uri 'none'; form-action 'none'`;
125
+ shell = shell.replace('<meta charset="UTF-8">', `<meta charset="UTF-8"><meta http-equiv="Content-Security-Policy" content="${policy}">`)
126
+ .replace('<body class="web-host">', `<body class="web-host"><div id="export-loading" role="status">Loading ${html(data.manifest.title)}… ${runtime === 'cdn' ? 'Internet required to load the Bygone viewer.' : 'Embedded viewer; no internet required.'} If loading fails, reopen the file or obtain a new export.</div><noscript>This interactive tour requires JavaScript.</noscript>`)
127
+ .replace('<summary>Tour details</summary>', `<summary>Tour details</summary><p class="export-notice">${html(summary)} ${runtime === 'cdn' ? 'Internet required to load the Bygone viewer.' : 'Embedded viewer; no internet required.'} ${html(data.history?.comparison || '')}</p>`);
128
+ const bootstrap = fs.readFileSync(path.join(packageRoot, 'web', 'export-bootstrap.js'), 'utf8');
129
+ const licenses = fs.readFileSync(path.join(packageRoot, 'web', 'export-licenses.txt'), 'utf8');
130
+ shell = shell.replace('</body>', `<script id="bygone-export-data" type="application/json">${json(payload)}</script><script>${bootstrap}</script><details class="export-notice"><summary>Runtime licenses</summary><pre>${html(licenses)}</pre></details></body>`);
131
+ if (Buffer.byteLength(shell) > MAX_BYTES) throw new Error('HTML export exceeds the 128 MiB safety limit. Narrow the tour.');
132
+ if (fs.existsSync(outputPath) && !options.overwrite) throw new Error('Output exists. Choose another path or pass --overwrite.');
133
+ if (path.resolve(sourcePath) === path.resolve(outputPath)
134
+ || (fs.existsSync(outputPath) && fs.realpathSync(sourcePath) === fs.realpathSync(outputPath))) throw new Error('Cannot overwrite the authored source.');
135
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
136
+ const temporary = `${outputPath}.${process.pid}.tmp`;
137
+ let created = false;
138
+ try {
139
+ fs.writeFileSync(temporary, shell, { flag: 'wx', mode: 0o600 });
140
+ created = true;
141
+ if (options.overwrite) fs.renameSync(temporary, outputPath);
142
+ else fs.linkSync(temporary, outputPath); // Fail atomically if another writer creates the destination.
143
+ }
144
+ finally { if (created) fs.rmSync(temporary, { force: true }); }
145
+ return { outputPath, contentId: data.contentId, profile, runtime, bytes: Buffer.byteLength(shell), summary };
146
+ }
147
+ module.exports = { exportTour, materializeTour, ASSETS };
package/cli/tourFile.js CHANGED
@@ -1,7 +1,8 @@
1
1
  const { readFileSync, statSync } = require('fs');
2
2
  const path = require('path');
3
3
  const { TextDecoder } = require('util');
4
- const { loadAll: loadYamlDocuments } = require('js-yaml');
4
+ const { loadAll: loadYamlDocuments, dump: dumpYaml } = require('js-yaml');
5
+ const { serializeDocumentFragment } = require('../out/deepLink.js');
5
6
  const { buildChangeTourManifest, parseChangeTourSource } = require('../out/changeTour.js');
6
7
 
7
8
  const DEFAULT_MAX_TOUR_SOURCE_BYTES = 1024 * 1024;
@@ -13,6 +14,10 @@ function loadTourSource(cwd, sourcePath) {
13
14
  }
14
15
 
15
16
  function readTourSourceDocument(sourcePath, options = {}) {
17
+ return parseTourSourceText(readTourSourceText(sourcePath, options), path.resolve(sourcePath), options);
18
+ }
19
+
20
+ function readTourSourceText(sourcePath, options = {}) {
16
21
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_TOUR_SOURCE_BYTES;
17
22
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
18
23
  throw new Error('The Bygone source size limit must be a positive safe integer.');
@@ -48,10 +53,10 @@ function readTourSourceDocument(sourcePath, options = {}) {
48
53
  throw new Error(`Could not decode Bygone source ${sourceLabel} as UTF-8: ${errorMessage(error)}`, { cause: error });
49
54
  }
50
55
 
51
- return parseTourSourceText(text, sourceLabel, { maxBytes });
56
+ return text;
52
57
  }
53
58
 
54
- function parseTourSourceText(text, sourceLabel = 'uploaded tour', options = {}) {
59
+ function readTourSourceValue(text, sourceLabel = 'uploaded tour', options = {}) {
55
60
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_TOUR_SOURCE_BYTES;
56
61
  if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > maxBytes || text.includes('\0')) {
57
62
  throw new Error('Invalid tour text or source size limit exceeded.');
@@ -66,13 +71,110 @@ function parseTourSourceText(text, sourceLabel = 'uploaded tour', options = {})
66
71
  throw new Error(`Could not parse Bygone source ${sourceLabel}: expected one YAML document, found ${documents.length}.`);
67
72
  }
68
73
 
74
+ return documents[0];
75
+ }
76
+
77
+ function parseTourSourceText(text, sourceLabel = 'uploaded tour', options = {}) {
69
78
  try {
70
- return parseChangeTourSource(documents[0]);
79
+ return parseChangeTourSource(readTourSourceValue(text, sourceLabel, options));
71
80
  } catch (error) {
72
81
  throw new Error(`Invalid Bygone source ${sourceLabel}: ${errorMessage(error)}`, { cause: error });
73
82
  }
74
83
  }
75
84
 
85
+ // Inspect without accepting retired formats in the normal source parser.
86
+ function inspectTourConversion(text, sourceLabel) {
87
+ const value = readTourSourceValue(text, sourceLabel);
88
+ if (![1, 2, 3].includes(value?.version)) return null;
89
+ return {
90
+ fromVersion: value.version,
91
+ preservesReview: Object.prototype.hasOwnProperty.call(value, 'review'),
92
+ message: `Convert this version ${value.version} tour to v4?`,
93
+ detail: 'Conversion preserves tour IDs and revision references. Review notes are preserved as a Legacy review notes appendix in the final authored step of each available tour. Their recorded revisions and evidence references are retained, without claiming they have been revalidated. The original file stays unchanged. YAML formatting and comments are not retained in the copy.'
94
+ };
95
+ }
96
+
97
+ function convertTourSourceText(text, sourceLabel) {
98
+ const conversion = inspectTourConversion(text, sourceLabel);
99
+ if (!conversion) throw new Error('Only authored tour versions 1, 2, and 3 can be converted to v4.');
100
+ const value = readTourSourceValue(text, sourceLabel);
101
+ const converted = { ...value, version: 4 };
102
+ delete converted.review;
103
+ try {
104
+ parseChangeTourSource(converted);
105
+ if (Object.prototype.hasOwnProperty.call(value, 'review')) appendLegacyReview(converted, value.review);
106
+ parseChangeTourSource(converted);
107
+ } catch (error) {
108
+ throw new Error(`This tour cannot be converted automatically to v4: ${errorMessage(error)} No evidence was changed; update the original tour manually.`, { cause: error });
109
+ }
110
+ const convertedText = dumpYaml(converted, { noRefs: true, lineWidth: -1 });
111
+ // Apply the same size and document checks to the serialized output.
112
+ const source = parseTourSourceText(convertedText, sourceLabel);
113
+ return { source, text: convertedText };
114
+ }
115
+
116
+ function appendLegacyReview(source, review) {
117
+ const modes = Object.entries(source.tours || {});
118
+ const evidenceLink = (evidence) => {
119
+ if (!evidence || typeof evidence.sceneId !== 'string' || typeof evidence.stepId !== 'string') return '';
120
+ const candidates = [...modes];
121
+ if (!source.tours?.historical) candidates.push(['historical', { chapters: source.chapters }]);
122
+ for (const [mode, tour] of candidates) {
123
+ const scene = tour.chapters.flatMap(chapter => chapter.scenes).find(scene => scene.id === evidence.sceneId);
124
+ if (scene && (!scene.kind || scene.kind === 'walkthrough') && scene.steps.some(step => step.id === evidence.stepId)) {
125
+ return ` [Open referenced step](${serializeDocumentFragment(mode, { part: 'step', scene: evidence.sceneId, step: evidence.stepId })})`;
126
+ }
127
+ }
128
+ return '';
129
+ };
130
+ let notes = '\n\n## Legacy review notes\n\nImported from the older tour. These notes have not been revalidated against the current revisions.';
131
+ // Render recognized review fields as ordinary prose. Keep unfamiliar or
132
+ // incomplete blocks verbatim so no legacy data is silently discarded.
133
+ if (review && typeof review === 'object' && Array.isArray(review.items)) {
134
+ notes += `\n\nRecorded revisions: ${review.baseOid ?? '(unspecified)'} → ${review.headOid ?? '(unspecified)'}.`;
135
+ for (const item of review.items) {
136
+ if (!item || typeof item !== 'object') continue;
137
+ notes += `\n\n### ${item.title ?? 'Review note'}\n\n${item.kind ?? 'Note'} (${item.id ?? 'no id'})\n\n${item.body ?? ''}`;
138
+ for (const evidence of Array.isArray(item.evidence) ? item.evidence : []) {
139
+ notes += `\n\nEvidence: ${evidence?.sceneId ?? '?'} / ${evidence?.stepId ?? '?'}.${evidenceLink(evidence)}`;
140
+ }
141
+ if (item.nextCheck !== undefined) notes += `\n\nNext check: ${item.nextCheck}`;
142
+ }
143
+ }
144
+ const onlyKeys = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
145
+ && Object.keys(value).every(key => keys.includes(key));
146
+ const recognized = onlyKeys(review, ['baseOid', 'headOid', 'items'])
147
+ && typeof review.baseOid === 'string' && typeof review.headOid === 'string'
148
+ && Array.isArray(review.items) && review.items.every(item =>
149
+ onlyKeys(item, ['id', 'kind', 'title', 'body', 'evidence', 'nextCheck'])
150
+ && ['id', 'kind', 'title', 'body'].every(key => typeof item[key] === 'string')
151
+ && (item.nextCheck === undefined || typeof item.nextCheck === 'string')
152
+ && Array.isArray(item.evidence) && item.evidence.every(evidence =>
153
+ onlyKeys(evidence, ['sceneId', 'stepId']) && typeof evidence.sceneId === 'string' && typeof evidence.stepId === 'string'));
154
+ if (!recognized) {
155
+ const raw = dumpYaml(review, { noRefs: true, lineWidth: -1 }).trimEnd();
156
+ const fence = '`'.repeat(Math.max(3, ...[...raw.matchAll(/`+/g)].map(match => match[0].length + 1)));
157
+ notes += `\n\n### Original review data\n\n${fence}yaml\n${raw}\n${fence}`;
158
+ }
159
+ const appended = new Set();
160
+ const append = (target, field) => {
161
+ if (!target || appended.has(target)) return;
162
+ target[field] += notes;
163
+ appended.add(target);
164
+ };
165
+ for (const tour of [{ chapters: source.chapters }, ...modes.map(([, tour]) => tour)]) {
166
+ const scene = tour.chapters.at(-1).scenes.at(-1);
167
+ if (scene.kind === 'deconstructed-diff') {
168
+ append(scene.stages.at(-1), 'narration');
169
+ // Legacy deconstruction also exposes its real-evidence steps in the
170
+ // Historical fallback. Preserve the notes there too.
171
+ append(scene.steps?.at(-1), 'body');
172
+ } else {
173
+ append(scene.steps.at(-1), 'body');
174
+ }
175
+ }
176
+ }
177
+
76
178
  function errorMessage(error) {
77
179
  return error instanceof Error ? error.message : String(error);
78
180
  }
@@ -92,6 +194,9 @@ module.exports = {
92
194
  DEFAULT_MAX_TOUR_SOURCE_BYTES,
93
195
  buildManifestForTourSource,
94
196
  loadTourSource,
197
+ readTourSourceText,
198
+ inspectTourConversion,
199
+ convertTourSourceText,
95
200
  parseTourSourceText,
96
201
  readTourSourceDocument
97
202
  };