@mintlify/previewing 4.0.1606 → 4.0.1607
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
|
|
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) =>
|
|
131
|
-
.on('change', (filename) =>
|
|
132
|
-
.on('unlink', (filename) =>
|
|
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(
|
|
227
|
+
void startPreviewWatcher(previewOptions);
|
|
140
228
|
};
|
|
141
|
-
const
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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;
|