@mintlify/previewing 4.0.1606 → 4.0.1608

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,274 @@
1
+ import fse from 'fs-extra';
2
+ import { mkdir, rm, writeFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { CMD_EXEC_PATH, NEXT_PROPS_PATH, NEXT_PUBLIC_PATH } from '../constants.js';
5
+ import listener, { initializeDocsConfigHashCache, initializeFrontmatterHashCache, } from '../local-preview/listener/index.js';
6
+ import { updateGeneratedNavFromPageMetadataCache } from '../local-preview/listener/update.js';
7
+ import * as logs from '../logging-state.js';
8
+ const watcher = vi.hoisted(() => ({
9
+ handlers: {},
10
+ }));
11
+ vi.mock('../constants.js', async (importOriginal) => {
12
+ const actual = await importOriginal();
13
+ const { mkdtempSync } = await import('fs');
14
+ const { tmpdir } = await import('os');
15
+ const { join } = await import('path');
16
+ const root = mkdtempSync(join(tmpdir(), 'listener-batching-'));
17
+ return {
18
+ ...actual,
19
+ CMD_EXEC_PATH: join(root, 'docs'),
20
+ CLIENT_PATH: join(root, 'client'),
21
+ CONTENT_PATH: join(root, 'client'),
22
+ NEXT_PROPS_PATH: join(root, 'client', 'src', '_props'),
23
+ NEXT_PUBLIC_PATH: join(root, 'client', 'public'),
24
+ };
25
+ });
26
+ vi.mock('chokidar', () => {
27
+ const watch = () => {
28
+ const instance = {
29
+ on: (event, handler) => {
30
+ watcher.handlers[event] = handler;
31
+ return instance;
32
+ },
33
+ close: async () => { },
34
+ };
35
+ return instance;
36
+ };
37
+ return { default: { watch }, watch };
38
+ });
39
+ vi.mock('@mintlify/prebuild', async (importOriginal) => {
40
+ const actual = await importOriginal();
41
+ return {
42
+ ...actual,
43
+ getMintIgnore: vi.fn().mockResolvedValue([]),
44
+ discoverOverlayFiles: vi.fn().mockResolvedValue([]),
45
+ };
46
+ });
47
+ vi.mock('../local-preview/listener/update.js', async (importOriginal) => {
48
+ const actual = await importOriginal();
49
+ return {
50
+ ...actual,
51
+ updateGeneratedNavFromPageMetadataCache: vi.fn(actual.updateGeneratedNavFromPageMetadataCache),
52
+ };
53
+ });
54
+ vi.mock('../local-preview/packages/index.js', () => ({
55
+ notifyPackageInputChange: vi.fn(),
56
+ writePackageBuildProps: vi.fn(),
57
+ }));
58
+ const addChangeLogSpy = vi.spyOn(logs, 'addChangeLog');
59
+ const triggerRefresh = vi.fn();
60
+ const startWatcher = async () => {
61
+ listener(triggerRefresh, {});
62
+ await vi.waitFor(() => expect(watcher.handlers.change).toBeDefined());
63
+ };
64
+ const emit = (event, filename) => watcher.handlers[event](filename);
65
+ const loggedFor = (filename) => addChangeLogSpy.mock.calls.filter(([log]) => log.props?.filename === filename);
66
+ const writeDocsFile = async (filename, content) => {
67
+ await mkdir(join(CMD_EXEC_PATH, filename, '..'), { recursive: true });
68
+ await writeFile(join(CMD_EXEC_PATH, filename), content);
69
+ };
70
+ const docsConfig = {
71
+ $schema: 'https://mintlify.com/docs.json',
72
+ theme: 'mint',
73
+ name: 'Docs',
74
+ colors: { primary: '#000000' },
75
+ navigation: { pages: ['guide', 'changed', 'restored', 'startup', 'nav/a', 'nav/b', 'nav/c'] },
76
+ };
77
+ beforeAll(async () => {
78
+ await mkdir(NEXT_PROPS_PATH, { recursive: true });
79
+ await mkdir(NEXT_PUBLIC_PATH, { recursive: true });
80
+ await writeFile(join(NEXT_PROPS_PATH, 'customLanguages.json'), '[]');
81
+ await writeDocsFile('docs.json', JSON.stringify(docsConfig));
82
+ await writeFile(join(NEXT_PROPS_PATH, 'docs.json'), JSON.stringify(docsConfig));
83
+ });
84
+ afterAll(async () => {
85
+ await rm(join(CMD_EXEC_PATH, '..'), { recursive: true, force: true });
86
+ });
87
+ beforeEach(async () => {
88
+ vi.clearAllMocks();
89
+ watcher.handlers = {};
90
+ await startWatcher();
91
+ });
92
+ describe('listener event batching', () => {
93
+ it('processes a burst of file events one at a time and refreshes once', async () => {
94
+ const files = ['images/a.png', 'images/b.png', 'images/c.png'];
95
+ await Promise.all(files.map((file) => writeDocsFile(file, file)));
96
+ let active = 0;
97
+ let maxActive = 0;
98
+ const copy = fse.copy.bind(fse);
99
+ const copySpy = vi.spyOn(fse, 'copy').mockImplementation((async (...args) => {
100
+ active += 1;
101
+ maxActive = Math.max(maxActive, active);
102
+ await new Promise((resolve) => setTimeout(resolve, 10));
103
+ try {
104
+ return await copy(...args);
105
+ }
106
+ finally {
107
+ active -= 1;
108
+ }
109
+ }));
110
+ await Promise.all(files.map((file) => emit('change', file)));
111
+ expect(maxActive).toBe(1);
112
+ expect(triggerRefresh).toHaveBeenCalledOnce();
113
+ for (const file of files) {
114
+ expect(await fse.pathExists(join(NEXT_PUBLIC_PATH, file))).toBe(true);
115
+ expect(loggedFor(file)).toHaveLength(1);
116
+ }
117
+ copySpy.mockRestore();
118
+ });
119
+ it('handles repeated events for one file once, keeping an add as an add', async () => {
120
+ await writeDocsFile('images/repeat.png', 'repeat');
121
+ await Promise.all([
122
+ emit('add', 'images/repeat.png'),
123
+ emit('change', 'images/repeat.png'),
124
+ emit('change', 'images/repeat.png'),
125
+ ]);
126
+ const logged = loggedFor('images/repeat.png');
127
+ expect(logged).toHaveLength(1);
128
+ expect(logged[0][0].type?.name).toBe('AddedLog');
129
+ expect(triggerRefresh).toHaveBeenCalledOnce();
130
+ });
131
+ it('regenerates navigation once for a batch of pages with new frontmatter', async () => {
132
+ const pages = ['nav/a.mdx', 'nav/b.mdx', 'nav/c.mdx'];
133
+ await Promise.all(pages.map((file) => writeDocsFile(file, `---\ntitle: ${file}\n---\n`)));
134
+ await Promise.all(pages.map((file) => emit('add', file)));
135
+ for (const file of pages)
136
+ expect(loggedFor(file)).toHaveLength(1);
137
+ expect(updateGeneratedNavFromPageMetadataCache).toHaveBeenCalledOnce();
138
+ expect(triggerRefresh).toHaveBeenCalledOnce();
139
+ });
140
+ it('processes events that arrive while a batch is running in the same batch', async () => {
141
+ await writeDocsFile('images/first.png', 'first');
142
+ await writeDocsFile('images/second.png', 'second');
143
+ const copy = fse.copy.bind(fse);
144
+ let secondEvent;
145
+ const copySpy = vi.spyOn(fse, 'copy').mockImplementation((async (...args) => {
146
+ secondEvent ??= emit('change', 'images/second.png');
147
+ return copy(...args);
148
+ }));
149
+ await emit('change', 'images/first.png');
150
+ await secondEvent;
151
+ expect(loggedFor('images/first.png')).toHaveLength(1);
152
+ expect(loggedFor('images/second.png')).toHaveLength(1);
153
+ expect(triggerRefresh).toHaveBeenCalledOnce();
154
+ copySpy.mockRestore();
155
+ });
156
+ });
157
+ describe('listener unchanged Markdown', () => {
158
+ const page = '---\ntitle: Guide\n---\n\nHello\n';
159
+ it('skips a Markdown file whose content has not changed since it was processed', async () => {
160
+ await writeDocsFile('guide.mdx', page);
161
+ await emit('change', 'guide.mdx');
162
+ expect(loggedFor('guide.mdx')).toHaveLength(1);
163
+ expect(await fse.pathExists(join(NEXT_PROPS_PATH, 'guide.mdx'))).toBe(true);
164
+ addChangeLogSpy.mockClear();
165
+ triggerRefresh.mockClear();
166
+ await writeDocsFile('guide.mdx', page);
167
+ await emit('change', 'guide.mdx');
168
+ expect(loggedFor('guide.mdx')).toHaveLength(0);
169
+ expect(triggerRefresh).not.toHaveBeenCalled();
170
+ });
171
+ it('processes a Markdown file again once its content changes', async () => {
172
+ await writeDocsFile('changed.mdx', page);
173
+ await emit('change', 'changed.mdx');
174
+ addChangeLogSpy.mockClear();
175
+ await writeDocsFile('changed.mdx', `${page}More\n`);
176
+ await emit('change', 'changed.mdx');
177
+ expect(loggedFor('changed.mdx')).toHaveLength(1);
178
+ });
179
+ it('processes a deleted Markdown file again when it comes back unchanged', async () => {
180
+ await writeDocsFile('restored.mdx', page);
181
+ await emit('change', 'restored.mdx');
182
+ await rm(join(CMD_EXEC_PATH, 'restored.mdx'));
183
+ await emit('unlink', 'restored.mdx');
184
+ addChangeLogSpy.mockClear();
185
+ await writeDocsFile('restored.mdx', page);
186
+ await emit('add', 'restored.mdx');
187
+ expect(loggedFor('restored.mdx')).toHaveLength(1);
188
+ });
189
+ it('processes a Markdown edit made while startup caches are seeded', async () => {
190
+ await writeDocsFile('startup.mdx', page);
191
+ let releaseGate = () => { };
192
+ const gate = new Promise((resolve) => {
193
+ releaseGate = resolve;
194
+ });
195
+ watcher.handlers = {};
196
+ listener(triggerRefresh, {}, gate);
197
+ await vi.waitFor(() => expect(watcher.handlers.change).toBeDefined());
198
+ await writeDocsFile('startup.mdx', `${page}Edited during startup\n`);
199
+ const event = emit('change', 'startup.mdx');
200
+ await initializeFrontmatterHashCache();
201
+ releaseGate();
202
+ await event;
203
+ expect(loggedFor('startup.mdx')).toHaveLength(1);
204
+ expect(triggerRefresh).toHaveBeenCalledOnce();
205
+ });
206
+ it('regenerates navigation for a frontmatter edit made while startup caches are seeded', async () => {
207
+ await writeDocsFile('startup.mdx', '---\ntitle: Before startup\n---\n');
208
+ let releaseGate = () => { };
209
+ const gate = new Promise((resolve) => {
210
+ releaseGate = resolve;
211
+ });
212
+ watcher.handlers = {};
213
+ listener(triggerRefresh, {}, gate);
214
+ await vi.waitFor(() => expect(watcher.handlers.change).toBeDefined());
215
+ await writeDocsFile('startup.mdx', '---\ntitle: Edited during startup\n---\n');
216
+ const event = emit('change', 'startup.mdx');
217
+ await initializeFrontmatterHashCache();
218
+ releaseGate();
219
+ await event;
220
+ expect(updateGeneratedNavFromPageMetadataCache).toHaveBeenCalledOnce();
221
+ expect(triggerRefresh).toHaveBeenCalledOnce();
222
+ });
223
+ it('applies pending page nav updates before handling docs.json in the same batch', async () => {
224
+ await initializeDocsConfigHashCache();
225
+ await writeDocsFile('nav/a.mdx', '---\ntitle: Renamed in batch\n---\n');
226
+ await writeDocsFile('docs.json', JSON.stringify({
227
+ ...docsConfig,
228
+ navigation: { pages: docsConfig.navigation.pages.filter((page) => page !== 'guide') },
229
+ }));
230
+ try {
231
+ await Promise.all([emit('change', 'nav/a.mdx'), emit('change', 'docs.json')]);
232
+ const generatedNav = (await fse.readJson(join(NEXT_PROPS_PATH, 'generatedDocsNav.json')));
233
+ expect(generatedNav.pages.map((page) => page.href)).not.toContain('/guide');
234
+ expect(generatedNav.pages).toContainEqual({ title: 'Renamed in batch', href: '/nav/a' });
235
+ expect(triggerRefresh).toHaveBeenCalledOnce();
236
+ }
237
+ finally {
238
+ await writeDocsFile('docs.json', JSON.stringify(docsConfig));
239
+ }
240
+ });
241
+ it('applies pending page nav updates before a referenced navigation file is handled', async () => {
242
+ await writeDocsFile('navigation.json', JSON.stringify(docsConfig.navigation));
243
+ await writeDocsFile('docs.json', JSON.stringify({ ...docsConfig, navigation: { $ref: './navigation.json' } }));
244
+ try {
245
+ await emit('change', 'docs.json');
246
+ await writeDocsFile('nav/b.mdx', '---\ntitle: Renamed via ref\n---\n');
247
+ await writeDocsFile('navigation.json', JSON.stringify({ pages: docsConfig.navigation.pages.filter((page) => page !== 'guide') }));
248
+ await Promise.all([emit('change', 'nav/b.mdx'), emit('change', 'navigation.json')]);
249
+ const generatedNav = (await fse.readJson(join(NEXT_PROPS_PATH, 'generatedDocsNav.json')));
250
+ expect(generatedNav.pages.map((page) => page.href)).not.toContain('/guide');
251
+ expect(generatedNav.pages).toContainEqual({ title: 'Renamed via ref', href: '/nav/b' });
252
+ }
253
+ finally {
254
+ await writeDocsFile('docs.json', JSON.stringify(docsConfig));
255
+ await rm(join(CMD_EXEC_PATH, 'navigation.json'), { force: true });
256
+ await emit('change', 'docs.json');
257
+ }
258
+ });
259
+ it('regenerates navigation once when page changes are interleaved with assets', async () => {
260
+ const pages = ['nav/a.mdx', 'nav/b.mdx', 'nav/c.mdx'];
261
+ await Promise.all(pages.map((file) => writeDocsFile(file, `---\ntitle: ${file} with assets\n---\n`)));
262
+ await Promise.all(pages.map((_, i) => writeDocsFile(`images/asset-${i}.png`, 'png')));
263
+ await Promise.all(pages.flatMap((file, i) => [emit('change', file), emit('add', `images/asset-${i}.png`)]));
264
+ expect(updateGeneratedNavFromPageMetadataCache).toHaveBeenCalledOnce();
265
+ expect(triggerRefresh).toHaveBeenCalledOnce();
266
+ });
267
+ it('always processes non-Markdown files, even when their content is unchanged', async () => {
268
+ await writeDocsFile('images/same.png', 'same');
269
+ await emit('change', 'images/same.png');
270
+ addChangeLogSpy.mockClear();
271
+ await emit('change', 'images/same.png');
272
+ expect(loggedFor('images/same.png')).toHaveLength(1);
273
+ });
274
+ });
@@ -4,6 +4,7 @@ import { createPage, discoverOverlayFiles, getFileListSync, getMintIgnore, MintC
4
4
  import { isOverlayDocument } from '@mintlify/validation';
5
5
  import Chalk from 'chalk';
6
6
  import chokidar from 'chokidar';
7
+ import { createHash } from 'crypto';
7
8
  import { promises as _promises } from 'fs';
8
9
  import fse from 'fs-extra';
9
10
  import fs from 'fs/promises';
@@ -111,7 +112,93 @@ const addUpdateTimingLog = (filename, category, durationMs) => {
111
112
  let watcherGate;
112
113
  let previewWatcher;
113
114
  let previewWatcherRestart = Promise.resolve();
114
- const startPreviewWatcher = (triggerRefresh, options) => {
115
+ const WATCH_BATCH_DELAY_MS = 100;
116
+ const pendingWatchEvents = new Map();
117
+ const markdownContentHashes = new Map();
118
+ let watchBatch;
119
+ let inFlightWatchFile;
120
+ let deferNavRegeneration = false;
121
+ let pendingNavRegeneration;
122
+ let refreshRequested = false;
123
+ let activeTriggerRefresh = () => { };
124
+ let activeWatchOptions = {};
125
+ const requestRefresh = () => {
126
+ refreshRequested = true;
127
+ };
128
+ const isMarkdownFile = (filename) => /\.mdx?$/i.test(filename);
129
+ const hashContent = (content) => createHash('sha1').update(content).digest('hex');
130
+ const readMarkdownContentHash = async (filename) => {
131
+ if (!isMarkdownFile(filename))
132
+ return undefined;
133
+ try {
134
+ return hashContent((await readFile(pathUtil.join(CMD_EXEC_PATH, filename))).toString());
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ };
140
+ const handleWatchEvent = (type, filename) => {
141
+ switch (type) {
142
+ case 'add':
143
+ return onAddEvent(filename, requestRefresh, activeWatchOptions);
144
+ case 'change':
145
+ return onChangeEvent(filename, requestRefresh, activeWatchOptions);
146
+ case 'unlink':
147
+ return onUnlinkEvent(filename, requestRefresh, activeWatchOptions);
148
+ }
149
+ };
150
+ const flushPendingNavRegeneration = async () => {
151
+ const navRegeneration = pendingNavRegeneration;
152
+ pendingNavRegeneration = undefined;
153
+ if (!navRegeneration)
154
+ return;
155
+ try {
156
+ await regenerateGeneratedNav(navRegeneration);
157
+ }
158
+ catch (error) {
159
+ console.error(error.message);
160
+ }
161
+ };
162
+ const drainWatchEvents = async () => {
163
+ deferNavRegeneration = true;
164
+ try {
165
+ while (pendingWatchEvents.size > 0 || pendingNavRegeneration) {
166
+ for (const [filename, type] of pendingWatchEvents) {
167
+ pendingWatchEvents.delete(filename);
168
+ inFlightWatchFile = filename;
169
+ try {
170
+ await handleWatchEvent(type, filename);
171
+ }
172
+ catch (error) {
173
+ console.error(error.message);
174
+ }
175
+ finally {
176
+ inFlightWatchFile = undefined;
177
+ }
178
+ }
179
+ await flushPendingNavRegeneration();
180
+ }
181
+ }
182
+ finally {
183
+ deferNavRegeneration = false;
184
+ watchBatch = undefined;
185
+ if (refreshRequested) {
186
+ refreshRequested = false;
187
+ activeTriggerRefresh();
188
+ }
189
+ }
190
+ };
191
+ const enqueueWatchEvent = (type, filename) => {
192
+ const previous = pendingWatchEvents.get(filename);
193
+ pendingWatchEvents.delete(filename);
194
+ pendingWatchEvents.set(filename, previous === 'add' && type === 'change' ? 'add' : type);
195
+ watchBatch ??= new Promise((resolve) => {
196
+ setTimeout(() => void drainWatchEvents().finally(resolve), WATCH_BATCH_DELAY_MS);
197
+ });
198
+ return watchBatch;
199
+ };
200
+ const startPreviewWatcher = (options) => {
201
+ activeWatchOptions = options;
115
202
  previewWatcherRestart = previewWatcherRestart
116
203
  .catch(() => undefined)
117
204
  .then(async () => {
@@ -127,55 +214,47 @@ const startPreviewWatcher = (triggerRefresh, options) => {
127
214
  ignored: isPreviewPathIgnored,
128
215
  cwd: CMD_EXEC_PATH,
129
216
  })
130
- .on('add', (filename) => onAddEvent(filename, triggerRefresh, options))
131
- .on('change', (filename) => onChangeEvent(filename, triggerRefresh, options))
132
- .on('unlink', (filename) => onUnlinkEvent(filename, triggerRefresh, options));
217
+ .on('add', (filename) => enqueueWatchEvent('add', filename))
218
+ .on('change', (filename) => enqueueWatchEvent('change', filename))
219
+ .on('unlink', (filename) => enqueueWatchEvent('unlink', filename));
133
220
  });
134
221
  return previewWatcherRestart;
135
222
  };
136
223
  const listener = (triggerRefresh, options = {}, gate) => {
137
224
  watcherGate = gate;
225
+ activeTriggerRefresh = triggerRefresh;
138
226
  const previewOptions = { ...options, allowSourceRefs: true };
139
- void startPreviewWatcher(triggerRefresh, previewOptions);
227
+ void startPreviewWatcher(previewOptions);
140
228
  };
141
- const onAddEvent = async (filename, triggerRefresh, options) => {
229
+ const processUpdateEvent = async (filename, triggerRefresh, options, log) => {
142
230
  if (watcherGate) {
143
231
  await watcherGate;
144
232
  }
145
233
  if (isMintIgnored(filename, getMintIgnoreGlobs())) {
146
234
  return;
147
235
  }
148
- try {
149
- const startTime = process.hrtime.bigint();
150
- const category = await onUpdateEvent(filename, triggerRefresh, options);
151
- addUpdateTimingLog(filename, category, getElapsedMs(startTime));
152
- if (category !== undefined) {
153
- addChangeLog(_jsx(AddedLog, { filename: filename }));
154
- }
155
- }
156
- catch (error) {
157
- console.error(error.message);
158
- }
159
- };
160
- const onChangeEvent = async (filename, triggerRefresh, options) => {
161
- if (watcherGate) {
162
- await watcherGate;
163
- }
164
- if (isMintIgnored(filename, getMintIgnoreGlobs())) {
236
+ const contentHash = await readMarkdownContentHash(filename);
237
+ if (contentHash !== undefined && markdownContentHashes.get(filename) === contentHash) {
165
238
  return;
166
239
  }
167
240
  try {
168
241
  const startTime = process.hrtime.bigint();
169
242
  const category = await onUpdateEvent(filename, triggerRefresh, options);
243
+ if (contentHash !== undefined) {
244
+ markdownContentHashes.set(filename, contentHash);
245
+ }
170
246
  addUpdateTimingLog(filename, category, getElapsedMs(startTime));
171
247
  if (category !== undefined) {
172
- addChangeLog(_jsx(EditedLog, { filename: filename }));
248
+ log(filename);
173
249
  }
174
250
  }
175
251
  catch (error) {
252
+ markdownContentHashes.delete(filename);
176
253
  console.error(error.message);
177
254
  }
178
255
  };
256
+ const onAddEvent = (filename, triggerRefresh, options) => processUpdateEvent(filename, triggerRefresh, options, (name) => addChangeLog(_jsx(AddedLog, { filename: name })));
257
+ const onChangeEvent = (filename, triggerRefresh, options) => processUpdateEvent(filename, triggerRefresh, options, (name) => addChangeLog(_jsx(EditedLog, { filename: name })));
179
258
  const onUnlinkEvent = async (filename, triggerRefresh, options) => {
180
259
  if (watcherGate) {
181
260
  await watcherGate;
@@ -188,6 +267,7 @@ const onUnlinkEvent = async (filename, triggerRefresh, options) => {
188
267
  const potentialCategory = getFileCategory(filename, { importedFiles });
189
268
  await notifyPackageInputChange(filename, potentialCategory);
190
269
  frontmatterHashes.delete(filename);
270
+ markdownContentHashes.delete(filename);
191
271
  removePageMetadataCacheEntryForFile(filename);
192
272
  if (potentialCategory === 'page') {
193
273
  reportPageCompileSuccess(filename);
@@ -280,7 +360,7 @@ const onUnlinkEvent = async (filename, triggerRefresh, options) => {
280
360
  await initializeImportCache(CMD_EXEC_PATH, prebuildResult?.fileImportsMap);
281
361
  await initializeFrontmatterHashCache();
282
362
  await refreshTrackedReferencedFiles();
283
- await startPreviewWatcher(triggerRefresh, options);
363
+ await startPreviewWatcher(options);
284
364
  }
285
365
  catch (err) {
286
366
  console.error('Error rebuilding after .mintignore deletion:', err);
@@ -339,6 +419,7 @@ const validateConfigFiles = async () => {
339
419
  };
340
420
  const initializeFrontmatterHashCache = async () => {
341
421
  frontmatterHashes.clear();
422
+ markdownContentHashes.clear();
342
423
  clearPageMetadataCache();
343
424
  const importedFiles = getImportedFilesFromCache();
344
425
  const pageFilenames = getFileListSync(CMD_EXEC_PATH, CMD_EXEC_PATH, getMintIgnoreGlobs()).filter((file) => {
@@ -349,10 +430,18 @@ const initializeFrontmatterHashCache = async () => {
349
430
  const filePath = pathUtil.join(CMD_EXEC_PATH, filename);
350
431
  const contentStr = (await readFile(filePath)).toString();
351
432
  seedFrontmatterHashForPage(filename, contentStr, frontmatterHashes, suppressParseError);
433
+ markdownContentHashes.set(filename, hashContent(contentStr));
352
434
  upsertPageMetadataCacheEntryForFile(filename, contentStr);
353
435
  }
354
436
  catch { }
355
437
  }));
438
+ // Files with queued events may have been hashed after their edit; let those events rebuild them.
439
+ for (const filename of [...pendingWatchEvents.keys(), inFlightWatchFile]) {
440
+ if (!filename)
441
+ continue;
442
+ markdownContentHashes.delete(filename);
443
+ frontmatterHashes.delete(filename);
444
+ }
356
445
  };
357
446
  const initializeDocsConfigHashCache = async () => {
358
447
  clearDocsConfigCaches();
@@ -523,6 +612,8 @@ const onUpdateEvent = async (filename, triggerRefresh, options = {}) => {
523
612
  // spec via frontmatter, this falls back to the full rebuild and
524
613
  // returns the API-expanded config, which we then persist.
525
614
  await updateCustomLanguages(docsConfig, suppressParseError);
615
+ // Reused nav metadata must include page changes deferred earlier in this batch.
616
+ await flushPendingNavRegeneration();
526
617
  const generatedDocsConfig = await updateGeneratedNavFromPageMetadataCache(docsConfig, suppressParseError, { reuseGeneratedMetadata: true });
527
618
  await DocsConfigUpdater.writeConfigFile(generatedDocsConfig, CONTENT_PATH);
528
619
  }
@@ -597,7 +688,7 @@ const onUpdateEvent = async (filename, triggerRefresh, options = {}) => {
597
688
  await initializeImportCache(CMD_EXEC_PATH, prebuildResult?.fileImportsMap);
598
689
  await initializeFrontmatterHashCache();
599
690
  await refreshTrackedReferencedFiles();
600
- await startPreviewWatcher(triggerRefresh, options);
691
+ await startPreviewWatcher(options);
601
692
  }
602
693
  catch (err) {
603
694
  console.error(err.message);
@@ -685,7 +776,22 @@ const onUpdateEvent = async (filename, triggerRefresh, options = {}) => {
685
776
  break;
686
777
  }
687
778
  }
688
- if (regenerateNavFromPageMetadataCache) {
779
+ const navRegeneration = regenerateNavFromPageMetadataCache
780
+ ? 'pageMetadataCache'
781
+ : regenerateNav
782
+ ? 'full'
783
+ : undefined;
784
+ if (navRegeneration && deferNavRegeneration) {
785
+ pendingNavRegeneration = pendingNavRegeneration === 'full' ? 'full' : navRegeneration;
786
+ }
787
+ else if (navRegeneration) {
788
+ await regenerateGeneratedNav(navRegeneration);
789
+ }
790
+ triggerRefresh();
791
+ return category;
792
+ };
793
+ const regenerateGeneratedNav = async (navRegeneration) => {
794
+ if (navRegeneration === 'pageMetadataCache') {
689
795
  const rawDocsConfig = await getCurrentRawDocsConfig();
690
796
  const docsConfig = rawDocsConfig ?? (await getCurrentDocsConfig());
691
797
  if (hasDocsConfigApiRoutes(docsConfig)) {
@@ -696,13 +802,11 @@ const onUpdateEvent = async (filename, triggerRefresh, options = {}) => {
696
802
  await updateGeneratedNavFromPageMetadataCache(docsConfig, suppressParseError);
697
803
  }
698
804
  }
699
- else if (regenerateNav) {
805
+ else {
700
806
  // TODO: Instead of re-generating the entire nav, optimize by just updating the specific page that changed.
701
807
  const docsConfig = await updateGeneratedNav(suppressParseError);
702
808
  await DocsConfigUpdater.writeConfigFile(docsConfig, CONTENT_PATH);
703
809
  }
704
- triggerRefresh();
705
- return category;
706
810
  };
707
811
  export { initializeDocsConfigHashCache, initializeFrontmatterHashCache, initializeImportCache };
708
812
  export default listener;