@mintlify/previewing 4.0.1195 → 4.0.1197

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.
@@ -13,6 +13,7 @@ vi.mock('fs-extra', () => {
13
13
  pathExists: vi.fn().mockResolvedValue(true),
14
14
  readFileSync: vi.fn().mockReturnValue('0.0.100'),
15
15
  emptyDirSync: vi.fn().mockResolvedValue(undefined),
16
+ outputFileSync: vi.fn(),
16
17
  };
17
18
  return {
18
19
  ...mocks,
@@ -1,13 +1,32 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { prebuild } from '@mintlify/prebuild';
2
+ import { getFileListSync, prebuild } from '@mintlify/prebuild';
3
3
  import fse, { pathExists } from 'fs-extra';
4
4
  import isOnline from 'is-online';
5
+ import pathUtil from 'path';
5
6
  import { CLIENT_PATH, DOT_MINTLIFY, CMD_EXEC_PATH, VERSION_PATH, NEXT_PUBLIC_PATH, NEXT_PROPS_PATH, } from '../constants.js';
6
7
  import { addLog, clearLogs } from '../logging-state.js';
7
8
  import { ErrorLog, SpinnerLog } from '../logs.js';
8
9
  import { timeDev, timeDevSync } from './dev-timing.js';
9
10
  import { run } from './run.js';
10
11
  import { silentUpdateClient } from './update.js';
12
+ const PRUNE_EXEMPT_PREFIXES = ['favicons/', '.mint-content-source'];
13
+ const prunePublicOrphans = () => {
14
+ let publicFiles;
15
+ try {
16
+ publicFiles = getFileListSync(NEXT_PUBLIC_PATH);
17
+ }
18
+ catch {
19
+ return;
20
+ }
21
+ for (const relativePath of publicFiles) {
22
+ if (PRUNE_EXEMPT_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) {
23
+ continue;
24
+ }
25
+ if (!fse.existsSync(pathUtil.join(CMD_EXEC_PATH, relativePath))) {
26
+ fse.removeSync(pathUtil.join(NEXT_PUBLIC_PATH, relativePath));
27
+ }
28
+ }
29
+ };
11
30
  const dev = async (argv) => {
12
31
  const hasInternet = await timeDev('check internet', () => isOnline());
13
32
  const localSchema = argv['local-schema'];
@@ -42,8 +61,23 @@ const dev = async (argv) => {
42
61
  }
43
62
  // clear preexisting prebuild files
44
63
  timeDevSync('clear prebuild output', () => {
45
- fse.emptyDirSync(NEXT_PUBLIC_PATH);
46
64
  fse.emptyDirSync(NEXT_PROPS_PATH);
65
+ // only clear public/ when previewing a different docs repo, so unchanged
66
+ // static files can be skipped on warm starts; on warm starts, prune files
67
+ // whose source no longer exists so deleted assets stop being served
68
+ const markerPath = pathUtil.join(NEXT_PUBLIC_PATH, '.mint-content-source');
69
+ let previousSource;
70
+ try {
71
+ previousSource = fse.readFileSync(markerPath, 'utf8');
72
+ }
73
+ catch { }
74
+ if (previousSource !== CMD_EXEC_PATH) {
75
+ fse.emptyDirSync(NEXT_PUBLIC_PATH);
76
+ fse.outputFileSync(markerPath, CMD_EXEC_PATH);
77
+ }
78
+ else {
79
+ prunePublicOrphans();
80
+ }
47
81
  });
48
82
  timeDevSync('switch to local client directory', () => {
49
83
  process.chdir(CLIENT_PATH);
@@ -55,6 +89,7 @@ const dev = async (argv) => {
55
89
  groups,
56
90
  disableOpenApi,
57
91
  allowSourceRefs: true,
92
+ lazyPages: true,
58
93
  }));
59
94
  fileImportsMap = result?.fileImportsMap;
60
95
  }
@@ -4,7 +4,7 @@ type LocalPreviewOptions = {
4
4
  groups?: string[];
5
5
  allowSourceRefs?: boolean;
6
6
  };
7
- declare const listener: (triggerRefresh: () => void, options?: LocalPreviewOptions) => void;
7
+ declare const listener: (triggerRefresh: () => void, options?: LocalPreviewOptions, gate?: Promise<void>) => void;
8
8
  declare const initializeFrontmatterHashCache: () => Promise<void>;
9
9
  declare const initializeDocsConfigHashCache: () => Promise<void>;
10
10
  export { initializeDocsConfigHashCache, initializeFrontmatterHashCache, initializeImportCache };
@@ -81,7 +81,9 @@ const addUpdateTimingLog = (filename, category, durationMs) => {
81
81
  const categoryLabel = category == null ? 'uncategorized' : category;
82
82
  addChangeLog(_jsx(InfoLog, { message: `processed ${filename} in ${formatElapsedSeconds(durationMs)} (${categoryLabel})` }));
83
83
  };
84
- const listener = (triggerRefresh, options = {}) => {
84
+ let watcherGate;
85
+ const listener = (triggerRefresh, options = {}, gate) => {
86
+ watcherGate = gate;
85
87
  const previewOptions = { ...options, allowSourceRefs: true };
86
88
  const mintIgnoreGlobs = getMintIgnoreGlobs();
87
89
  chokidar
@@ -103,6 +105,9 @@ const listener = (triggerRefresh, options = {}) => {
103
105
  .on('unlink', (filename) => onUnlinkEvent(filename, triggerRefresh, previewOptions));
104
106
  };
105
107
  const onAddEvent = async (filename, triggerRefresh, options) => {
108
+ if (watcherGate) {
109
+ await watcherGate;
110
+ }
106
111
  if (isMintIgnored(filename, getMintIgnoreGlobs())) {
107
112
  return;
108
113
  }
@@ -119,6 +124,9 @@ const onAddEvent = async (filename, triggerRefresh, options) => {
119
124
  }
120
125
  };
121
126
  const onChangeEvent = async (filename, triggerRefresh, options) => {
127
+ if (watcherGate) {
128
+ await watcherGate;
129
+ }
122
130
  if (isMintIgnored(filename, getMintIgnoreGlobs())) {
123
131
  return;
124
132
  }
@@ -135,6 +143,9 @@ const onChangeEvent = async (filename, triggerRefresh, options) => {
135
143
  }
136
144
  };
137
145
  const onUnlinkEvent = async (filename, triggerRefresh, options) => {
146
+ if (watcherGate) {
147
+ await watcherGate;
148
+ }
138
149
  if (isMintIgnored(filename, getMintIgnoreGlobs())) {
139
150
  return;
140
151
  }
@@ -0,0 +1,11 @@
1
+ export declare const markOnDemandPagesReady: () => void;
2
+ export type CompileEvent = {
3
+ path: string;
4
+ active: boolean;
5
+ };
6
+ type CompileListener = (event: CompileEvent) => void;
7
+ export declare const setCompileListener: (listener: CompileListener) => void;
8
+ export declare const ensurePageCompiled: (pathname: string, options?: {
9
+ silent?: boolean;
10
+ }) => Promise<void>;
11
+ export {};
@@ -0,0 +1,126 @@
1
+ import { findAndRemoveImports, getFileCategory, hasImports, isMintIgnored, stringifyTree, } from '@mintlify/common';
2
+ import { createPage, preparseMdxTree } from '@mintlify/prebuild';
3
+ import { promises as _promises } from 'fs';
4
+ import fse from 'fs-extra';
5
+ import pathUtil from 'path';
6
+ import { CMD_EXEC_PATH, NEXT_PROPS_PATH } from '../constants.js';
7
+ import { startDevTiming } from './dev-timing.js';
8
+ import { getImportedFilesFromCache } from './listener/importCache.js';
9
+ import { upsertPageMetadataCacheEntry } from './listener/page-metadata-cache.js';
10
+ import { resolvePageImports } from './listener/resolve-page-imports.js';
11
+ import { getMintIgnoreGlobs, handleParseError, suppressParseError } from './listener/utils.js';
12
+ const { readFile } = _promises;
13
+ let resolveReady = () => { };
14
+ const ready = new Promise((resolve) => {
15
+ resolveReady = resolve;
16
+ });
17
+ export const markOnDemandPagesReady = () => {
18
+ resolveReady();
19
+ };
20
+ let compileListener;
21
+ export const setCompileListener = (listener) => {
22
+ compileListener = listener;
23
+ };
24
+ const pagePathForFilename = (filename) => {
25
+ return `/${filename.replace(/\.mdx?$/, '')}`;
26
+ };
27
+ const trackCompile = async (filename, task) => {
28
+ const path = pagePathForFilename(filename);
29
+ compileListener?.({ path, active: true });
30
+ try {
31
+ return await task();
32
+ }
33
+ finally {
34
+ compileListener?.({ path, active: false });
35
+ }
36
+ };
37
+ const inflight = new Map();
38
+ const SKIPPED_PREFIXES = ['_next/', '_mintlify/', 'socket.io/', 'favicon'];
39
+ const decodePathname = (pathname) => {
40
+ try {
41
+ return decodeURIComponent(pathname);
42
+ }
43
+ catch {
44
+ return pathname;
45
+ }
46
+ };
47
+ const slugFromPathname = (pathname) => {
48
+ let slug = decodePathname(pathname).replace(/^\/+/, '').replace(/\/+$/, '');
49
+ if (slug.startsWith('_markdown/')) {
50
+ slug = slug.slice('_markdown/'.length);
51
+ }
52
+ if (slug === '') {
53
+ return 'index';
54
+ }
55
+ if (slug.includes('\0') ||
56
+ slug.includes('\\') ||
57
+ slug.split('/').some((segment) => segment === '..')) {
58
+ return undefined;
59
+ }
60
+ if (SKIPPED_PREFIXES.some((prefix) => slug.startsWith(prefix))) {
61
+ return undefined;
62
+ }
63
+ return slug;
64
+ };
65
+ const candidateFilenames = (slug) => [
66
+ `${slug}.mdx`,
67
+ `${slug}.md`,
68
+ `${slug}/index.mdx`,
69
+ `${slug}/index.md`,
70
+ ];
71
+ const failedCompiles = new Map();
72
+ const compilePage = async (filename, sourcePath, targetPath, sourceMtimeMs) => {
73
+ const finish = startDevTiming(`compile ${filename}`);
74
+ try {
75
+ let contentStr = (await readFile(sourcePath)).toString();
76
+ const tree = await preparseMdxTree(contentStr, CMD_EXEC_PATH, sourcePath, suppressParseError);
77
+ const importsResponse = await findAndRemoveImports(tree);
78
+ if (hasImports(importsResponse)) {
79
+ contentStr = stringifyTree(await resolvePageImports({ ...importsResponse, filename }));
80
+ }
81
+ const { pageContent, pageMetadata, slug } = await createPage(filename, contentStr, CMD_EXEC_PATH, [], [], suppressParseError);
82
+ await fse.outputFile(targetPath, pageContent, { flag: 'w' });
83
+ upsertPageMetadataCacheEntry(slug, pageMetadata);
84
+ failedCompiles.delete(filename);
85
+ finish();
86
+ }
87
+ catch (error) {
88
+ failedCompiles.set(filename, sourceMtimeMs);
89
+ handleParseError(`Failed to compile ${filename}: ${error instanceof Error ? error.message : String(error)}`);
90
+ finish('failed');
91
+ }
92
+ };
93
+ export const ensurePageCompiled = async (pathname, options = {}) => {
94
+ const slug = slugFromPathname(pathname);
95
+ if (slug === undefined) {
96
+ return;
97
+ }
98
+ for (const filename of candidateFilenames(slug)) {
99
+ const sourcePath = pathUtil.join(CMD_EXEC_PATH, filename);
100
+ if (!(await fse.pathExists(sourcePath))) {
101
+ continue;
102
+ }
103
+ if (isMintIgnored(filename, getMintIgnoreGlobs())) {
104
+ continue;
105
+ }
106
+ const sourceStat = await fse.stat(sourcePath);
107
+ const targetPath = pathUtil.join(NEXT_PROPS_PATH, filename);
108
+ const targetStat = await fse.stat(targetPath).catch(() => undefined);
109
+ if (targetStat !== undefined && targetStat.mtimeMs >= sourceStat.mtimeMs) {
110
+ return;
111
+ }
112
+ await ready;
113
+ if (getFileCategory(filename, { importedFiles: getImportedFilesFromCache() }) !== 'page') {
114
+ continue;
115
+ }
116
+ if (failedCompiles.get(filename) === sourceStat.mtimeMs) {
117
+ continue;
118
+ }
119
+ const pending = inflight.get(filename) ??
120
+ compilePage(filename, sourcePath, targetPath, sourceStat.mtimeMs).finally(() => {
121
+ inflight.delete(filename);
122
+ });
123
+ inflight.set(filename, pending);
124
+ return options.silent ? pending : trackCompile(filename, () => pending);
125
+ }
126
+ };
@@ -11,6 +11,7 @@ import { addDevTimingLog, startDevTiming, timeDev } from './dev-timing.js';
11
11
  import listener, { initializeDocsConfigHashCache, initializeFrontmatterHashCache, initializeImportCache, } from './listener/index.js';
12
12
  import { refreshTrackedReferencedFiles } from './listener/referencedFiles.js';
13
13
  import { getLocalNetworkIp } from './network.js';
14
+ import { ensurePageCompiled, markOnDemandPagesReady, setCompileListener } from './on-demand-page.js';
14
15
  import { setupNext } from './setupNext.js';
15
16
  const PROXY_RESPONSE_HEADER_DENYLIST = new Set([
16
17
  'connection',
@@ -60,6 +61,16 @@ export const run = async (argv) => {
60
61
  process.env.MINTLIFY_CLI_SUBDOMAIN = subdomain;
61
62
  // next-server is bugged, public files added after starting aren't served
62
63
  app.use('/', express.static(NEXT_PUBLIC_PATH));
64
+ app.use((req, _res, next) => {
65
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
66
+ next();
67
+ return;
68
+ }
69
+ const silent = req.headers['next-router-prefetch'] !== undefined ||
70
+ req.headers['purpose'] === 'prefetch' ||
71
+ req.headers['sec-purpose'] === 'prefetch';
72
+ ensurePageCompiled(req.path, { silent }).then(() => next(), () => next());
73
+ });
63
74
  app.post('/_mintlify/api-public/search/:subdomain', express.json(), async (req, res) => {
64
75
  const targetSubdomain = subdomain ?? req.params.subdomain;
65
76
  try {
@@ -121,6 +132,9 @@ export const run = async (argv) => {
121
132
  const onChange = () => {
122
133
  io.emit('reload');
123
134
  };
135
+ setCompileListener((event) => {
136
+ io.emit('compiling', event);
137
+ });
124
138
  const finishServerListenTiming = startDevTiming('server ready');
125
139
  server.listen(currentPort, () => {
126
140
  finishServerListenTiming();
@@ -147,10 +161,18 @@ export const run = async (argv) => {
147
161
  process.on('SIGINT', onExit);
148
162
  process.on('SIGTERM', onExit);
149
163
  });
164
+ // start watching immediately so edits made during cache initialization are
165
+ // not lost; handlers wait on the gate until the caches they rely on exist
166
+ let releaseWatcherGate = () => { };
167
+ const watcherGate = new Promise((resolve) => {
168
+ releaseWatcherGate = resolve;
169
+ });
170
+ addDevTimingLog('watching for file changes');
171
+ listener(onChange, {}, watcherGate);
150
172
  await timeDev('initialize import cache', () => initializeImportCache(CMD_EXEC_PATH, argv.fileImportsMap));
173
+ markOnDemandPagesReady();
151
174
  await timeDev('initialize frontmatter cache', () => initializeFrontmatterHashCache());
152
175
  await timeDev('initialize docs config cache', () => initializeDocsConfigHashCache());
153
176
  await timeDev('refresh referenced files', () => refreshTrackedReferencedFiles());
154
- addDevTimingLog('watching for file changes');
155
- listener(onChange);
177
+ releaseWatcherGate();
156
178
  };