@mintlify/previewing 4.0.1504 → 4.0.1506
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/export-assets.test.d.ts +1 -0
- package/dist/__tests__/export-assets.test.js +144 -0
- package/dist/__tests__/export-fetch.test.d.ts +1 -0
- package/dist/__tests__/export-fetch.test.js +81 -0
- package/dist/__tests__/export.test.js +19 -1
- package/dist/local-preview/export-assets.d.ts +8 -0
- package/dist/local-preview/export-assets.js +135 -0
- package/dist/local-preview/export-fetch.d.ts +12 -0
- package/dist/local-preview/export-fetch.js +55 -0
- package/dist/local-preview/export-llms.d.ts +1 -0
- package/dist/local-preview/export-llms.js +39 -0
- package/dist/local-preview/export.d.ts +1 -0
- package/dist/local-preview/export.js +39 -11
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/export-scripts/README.md +15 -0
- package/export-scripts/serve.js +21 -2
- package/package.json +6 -6
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { toMintExportAssetPath } from '@mintlify/common';
|
|
2
|
+
import fse from 'fs-extra';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { collectAssetPaths, localizeAssets, rewriteCssUrls, } from '../local-preview/export-assets.js';
|
|
7
|
+
import { fetchPublicAsset } from '../local-preview/export-fetch.js';
|
|
8
|
+
import { generateExportLlmsTxt } from '../local-preview/export-llms.js';
|
|
9
|
+
const ICON_URL = 'https://d3gk2c5xim1je2.cloudfront.net/fontawesome/v7.2.0/regular/house.svg';
|
|
10
|
+
const GOOGLE_CSS_URL = 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400&display=swap';
|
|
11
|
+
const asset = (body, contentType) => ({
|
|
12
|
+
contentType,
|
|
13
|
+
body: Buffer.from(body),
|
|
14
|
+
});
|
|
15
|
+
describe('export assets', () => {
|
|
16
|
+
let exportDir;
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
exportDir = await fse.mkdtemp(path.join(os.tmpdir(), 'mint-export-assets-test-'));
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fse.remove(exportDir);
|
|
22
|
+
});
|
|
23
|
+
it('collects asset paths from html, including escaped style attributes', async () => {
|
|
24
|
+
const iconPath = toMintExportAssetPath(ICON_URL);
|
|
25
|
+
await fse.outputFile(path.join(exportDir, 'guides', 'index.html'), `<svg style="mask-image:url("${iconPath}")"></svg><script>self.__next_f.push([1,"${iconPath}"])</script>`);
|
|
26
|
+
await fse.outputFile(path.join(exportDir, 'app.js'), toMintExportAssetPath(GOOGLE_CSS_URL));
|
|
27
|
+
expect([...(await collectAssetPaths(exportDir))]).toEqual([iconPath]);
|
|
28
|
+
});
|
|
29
|
+
it('collects icons for every language in an exported dropdown code group', async () => {
|
|
30
|
+
const codeGroup = await fse.readFile(path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'code-group-dropdown.html'), 'utf8');
|
|
31
|
+
await fse.outputFile(path.join(exportDir, 'index.html'), codeGroup);
|
|
32
|
+
expect([...(await collectAssetPaths(exportDir))].sort()).toEqual([
|
|
33
|
+
'/_mintlify/export-assets/d3gk2c5xim1je2.cloudfront.net/devicon/java.svg',
|
|
34
|
+
'/_mintlify/export-assets/d3gk2c5xim1je2.cloudfront.net/devicon/ruby.svg',
|
|
35
|
+
'/_mintlify/export-assets/d3gk2c5xim1je2.cloudfront.net/devicon/rust.svg',
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
it('rewrites css urls to relative local paths', () => {
|
|
39
|
+
const cssAssetPath = toMintExportAssetPath(GOOGLE_CSS_URL, 'css');
|
|
40
|
+
const found = [];
|
|
41
|
+
const css = rewriteCssUrls("@font-face{src:url(https://fonts.gstatic.com/s/opensans/v40/a.woff2) format('woff2')}.x{background:url(data:image/png;base64,AA)}", GOOGLE_CSS_URL, cssAssetPath, (assetPath) => found.push(assetPath));
|
|
42
|
+
expect(found).toEqual(['/_mintlify/export-assets/fonts.gstatic.com/s/opensans/v40/a.woff2']);
|
|
43
|
+
expect(css).toBe("@font-face{src:url(../../../fonts.gstatic.com/s/opensans/v40/a.woff2) format('woff2')}.x{background:url(data:image/png;base64,AA)}");
|
|
44
|
+
expect(path.posix.resolve(path.posix.dirname(cssAssetPath), '../../../fonts.gstatic.com')).toBe('/_mintlify/export-assets/fonts.gstatic.com');
|
|
45
|
+
});
|
|
46
|
+
it('downloads referenced assets and follows css urls', async () => {
|
|
47
|
+
const iconPath = toMintExportAssetPath(ICON_URL);
|
|
48
|
+
const cssPath = toMintExportAssetPath(GOOGLE_CSS_URL, 'css');
|
|
49
|
+
const missingPath = toMintExportAssetPath('https://d3gk2c5xim1je2.cloudfront.net/missing.svg');
|
|
50
|
+
await fse.outputFile(path.join(exportDir, 'index.html'), `<link href="${cssPath}"><i style="mask:url(${iconPath})"></i><i style="mask:url(${missingPath})"></i>`);
|
|
51
|
+
const fetchAsset = vi.fn(async (url) => {
|
|
52
|
+
if (url === ICON_URL)
|
|
53
|
+
return asset('<svg/>', 'image/svg+xml');
|
|
54
|
+
if (url === GOOGLE_CSS_URL) {
|
|
55
|
+
return asset('@font-face{src:url(https://fonts.gstatic.com/s/a.woff2)}', 'text/css; charset=utf-8');
|
|
56
|
+
}
|
|
57
|
+
if (url === 'https://fonts.gstatic.com/s/a.woff2')
|
|
58
|
+
return asset(new Uint8Array([1, 2, 3]));
|
|
59
|
+
return undefined;
|
|
60
|
+
});
|
|
61
|
+
const result = await localizeAssets(exportDir, fetchAsset);
|
|
62
|
+
expect(result.failed).toEqual(['https://d3gk2c5xim1je2.cloudfront.net/missing.svg']);
|
|
63
|
+
await expect(fse.readFile(path.join(exportDir, iconPath), 'utf8')).resolves.toBe('<svg/>');
|
|
64
|
+
await expect(fse.readFile(path.join(exportDir, cssPath), 'utf8')).resolves.toBe('@font-face{src:url(../../../fonts.gstatic.com/s/a.woff2)}');
|
|
65
|
+
expect([
|
|
66
|
+
...(await fse.readFile(path.join(exportDir, '_mintlify/export-assets/fonts.gstatic.com/s/a.woff2'))),
|
|
67
|
+
]).toEqual([1, 2, 3]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('localizeAssets path safety', () => {
|
|
71
|
+
let exportDir;
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
exportDir = await fse.mkdtemp(path.join(os.tmpdir(), 'mint-export-safety-test-'));
|
|
74
|
+
});
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
await fse.remove(exportDir);
|
|
77
|
+
});
|
|
78
|
+
it('never writes outside the asset directory, even for encoded traversal', async () => {
|
|
79
|
+
const cssUrl = 'https://fonts.example.com/style.css';
|
|
80
|
+
await fse.outputFile(path.join(exportDir, 'serve.js'), 'original launcher');
|
|
81
|
+
await fse.outputFile(path.join(exportDir, 'index.html'), `<link href="${toMintExportAssetPath(cssUrl)}">`);
|
|
82
|
+
const fetchAsset = vi.fn(async (url) => url === cssUrl
|
|
83
|
+
? asset('@font-face{src:url(https://evil.example/%2e%2e%2f%2e%2e%2f%2e%2e%2fserve.js)}', 'text/css')
|
|
84
|
+
: asset('malicious launcher'));
|
|
85
|
+
const result = await localizeAssets(exportDir, fetchAsset);
|
|
86
|
+
await expect(fse.readFile(path.join(exportDir, 'serve.js'), 'utf8')).resolves.toBe('original launcher');
|
|
87
|
+
expect(result.failed).toContain('/_mintlify/export-assets/evil.example/%2e%2e%2f%2e%2e%2f%2e%2e%2fserve.js');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('localizeAssets private destinations', () => {
|
|
91
|
+
let exportDir;
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
exportDir = await fse.mkdtemp(path.join(os.tmpdir(), 'mint-export-private-test-'));
|
|
94
|
+
});
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
await fse.remove(exportDir);
|
|
97
|
+
});
|
|
98
|
+
it('does not download private addresses referenced by a remote stylesheet', async () => {
|
|
99
|
+
const cssUrl = 'https://fonts.example.com/style.css';
|
|
100
|
+
await fse.outputFile(path.join(exportDir, 'index.html'), `<link href="${toMintExportAssetPath(cssUrl)}">`);
|
|
101
|
+
const fetchAsset = vi.fn(async (url) => url === cssUrl
|
|
102
|
+
? asset('.a{background:url(http://127.0.0.1:9000/secret.png)}', 'text/css')
|
|
103
|
+
: fetchPublicAsset(url));
|
|
104
|
+
const result = await localizeAssets(exportDir, fetchAsset);
|
|
105
|
+
expect(result.failed).toEqual(['http://127.0.0.1:9000/secret.png']);
|
|
106
|
+
await expect(fse.pathExists(path.join(exportDir, '_mintlify/export-assets/_http/127.0.0.1~9000/secret.png'))).resolves.toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('localizeAssets stylesheets', () => {
|
|
110
|
+
let exportDir;
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
exportDir = await fse.mkdtemp(path.join(os.tmpdir(), 'mint-export-css-test-'));
|
|
113
|
+
});
|
|
114
|
+
afterEach(async () => {
|
|
115
|
+
await fse.remove(exportDir);
|
|
116
|
+
});
|
|
117
|
+
it('bundles absolute font urls from the client css and leaves relative urls alone', async () => {
|
|
118
|
+
const cssFile = path.join(exportDir, '_next', 'static', 'chunks', 'app.css');
|
|
119
|
+
await fse.outputFile(cssFile, '@font-face{src:url(https://cdn.jsdelivr.net/gh/x/latex-css/fonts/LM-regular.woff2)}@font-face{src:url(//fonts.cdnfonts.com/s/1/cmuntt.woff)}@font-face{src:url(../media/inter.woff2)}');
|
|
120
|
+
const fetchAsset = vi.fn(async () => asset(new Uint8Array([7])));
|
|
121
|
+
const result = await localizeAssets(exportDir, fetchAsset);
|
|
122
|
+
expect(result.failed).toEqual([]);
|
|
123
|
+
await expect(fse.readFile(cssFile, 'utf8')).resolves.toBe('@font-face{src:url(../../../_mintlify/export-assets/cdn.jsdelivr.net/gh/x/latex-css/fonts/LM-regular.woff2)}@font-face{src:url(../../../_mintlify/export-assets/fonts.cdnfonts.com/s/1/cmuntt.woff)}@font-face{src:url(../media/inter.woff2)}');
|
|
124
|
+
await expect(fse.pathExists(path.join(exportDir, '_mintlify/export-assets/fonts.cdnfonts.com/s/1/cmuntt.woff'))).resolves.toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('generateExportLlmsTxt', () => {
|
|
128
|
+
it('lists exported pages with titles and descriptions', () => {
|
|
129
|
+
const llmsTxt = generateExportLlmsTxt({ name: 'Conduit', description: 'Self-hosted docs' }, {
|
|
130
|
+
groups: [
|
|
131
|
+
{
|
|
132
|
+
group: 'Getting started',
|
|
133
|
+
pages: [
|
|
134
|
+
{ href: '/index', title: 'Introduction', description: 'Welcome' },
|
|
135
|
+
{ href: '/secret', title: 'Secret' },
|
|
136
|
+
{ group: 'Nested', pages: [{ href: '/guides/math', title: 'Math [LaTeX]' }] },
|
|
137
|
+
{ href: 'https://example.com', title: 'External' },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}, ['/', '/index', '/guides/math']);
|
|
142
|
+
expect(llmsTxt).toBe('# Conduit\n\n> Self-hosted docs\n\n## Docs\n\n- [Introduction](/index): Welcome\n- [Math \\[LaTeX\\]](/guides/math)\n');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { PrivateIPError } from '@mintlify/common/ssrf';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { fetchPublicAsset } from '../local-preview/export-fetch.js';
|
|
4
|
+
const loopbackLookup = (_hostname, options, callback) => options.all
|
|
5
|
+
? callback(null, [{ address: '127.0.0.1', family: 4 }])
|
|
6
|
+
: callback(null, '127.0.0.1', 4);
|
|
7
|
+
describe('fetchPublicAsset', () => {
|
|
8
|
+
let server;
|
|
9
|
+
let origin;
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
server = createServer((req, res) => {
|
|
12
|
+
switch (req.url) {
|
|
13
|
+
case '/icon.svg':
|
|
14
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml' }).end('<svg/>');
|
|
15
|
+
return;
|
|
16
|
+
case '/moved':
|
|
17
|
+
res.writeHead(302, { location: '/icon.svg' }).end();
|
|
18
|
+
return;
|
|
19
|
+
case '/to-literal':
|
|
20
|
+
res.writeHead(302, { location: 'http://127.0.0.1/secret' }).end();
|
|
21
|
+
return;
|
|
22
|
+
case '/to-metadata':
|
|
23
|
+
res.writeHead(302, { location: 'http://169.254.169.254/latest' }).end();
|
|
24
|
+
return;
|
|
25
|
+
case '/loop':
|
|
26
|
+
res.writeHead(302, { location: '/loop' }).end();
|
|
27
|
+
return;
|
|
28
|
+
case '/large':
|
|
29
|
+
res.writeHead(200).end(Buffer.alloc(2048));
|
|
30
|
+
return;
|
|
31
|
+
case '/hang':
|
|
32
|
+
return;
|
|
33
|
+
default:
|
|
34
|
+
res.writeHead(404).end('not found');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
38
|
+
const address = server.address();
|
|
39
|
+
if (!address || typeof address === 'string')
|
|
40
|
+
throw new Error('server did not bind a port');
|
|
41
|
+
origin = `http://assets.test:${address.port}`;
|
|
42
|
+
});
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
server.closeAllConnections();
|
|
45
|
+
await new Promise((resolve) => server.close(resolve));
|
|
46
|
+
});
|
|
47
|
+
it('downloads the body and content type', async () => {
|
|
48
|
+
const asset = await fetchPublicAsset(`${origin}/icon.svg`, { lookup: loopbackLookup });
|
|
49
|
+
expect(asset?.contentType).toBe('image/svg+xml');
|
|
50
|
+
expect(asset?.body.toString()).toBe('<svg/>');
|
|
51
|
+
});
|
|
52
|
+
it('follows redirects', async () => {
|
|
53
|
+
const asset = await fetchPublicAsset(`${origin}/moved`, { lookup: loopbackLookup });
|
|
54
|
+
expect(asset?.body.toString()).toBe('<svg/>');
|
|
55
|
+
});
|
|
56
|
+
it('refuses redirects to private ip literals', async () => {
|
|
57
|
+
await expect(fetchPublicAsset(`${origin}/to-literal`, { lookup: loopbackLookup })).resolves.toBeUndefined();
|
|
58
|
+
await expect(fetchPublicAsset(`${origin}/to-metadata`, { lookup: loopbackLookup })).resolves.toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
it.each([
|
|
61
|
+
'http://127.0.0.1/a',
|
|
62
|
+
'http://[::1]/a',
|
|
63
|
+
'http://[::ffff:7f00:1]/a',
|
|
64
|
+
'file:///etc/passwd',
|
|
65
|
+
])('refuses %s without connecting', async (url) => {
|
|
66
|
+
await expect(fetchPublicAsset(url)).resolves.toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
it('refuses hosts that resolve to private addresses', async () => {
|
|
69
|
+
await expect(fetchPublicAsset('http://localhost/a')).rejects.toBeInstanceOf(PrivateIPError);
|
|
70
|
+
});
|
|
71
|
+
it('returns undefined for errors and redirect loops', async () => {
|
|
72
|
+
await expect(fetchPublicAsset(`${origin}/missing`, { lookup: loopbackLookup })).resolves.toBeUndefined();
|
|
73
|
+
await expect(fetchPublicAsset(`${origin}/loop`, { lookup: loopbackLookup })).resolves.toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
it('caps the response size', async () => {
|
|
76
|
+
await expect(fetchPublicAsset(`${origin}/large`, { lookup: loopbackLookup, maxBytes: 1024 })).rejects.toThrow('asset exceeds 1024 bytes');
|
|
77
|
+
});
|
|
78
|
+
it('times out slow responses', async () => {
|
|
79
|
+
await expect(fetchPublicAsset(`${origin}/hang`, { lookup: loopbackLookup, timeoutMs: 100 })).rejects.toThrow();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -30,14 +30,32 @@ describe('generatePages', () => {
|
|
|
30
30
|
vi.unstubAllGlobals();
|
|
31
31
|
await fse.remove(outputDir);
|
|
32
32
|
});
|
|
33
|
+
const pageFetch = () => vi.fn(async (_input, init) => new Headers(init?.headers).get('RSC') === '1'
|
|
34
|
+
? new Response('0:{"b":"build"}\n', { headers: { 'content-type': 'text/x-component' } })
|
|
35
|
+
: new Response('<html>Introduction</html>', { headers: { 'content-type': 'text/html' } }));
|
|
33
36
|
it('uses the first accessible route for the root page', async () => {
|
|
34
|
-
const fetchMock =
|
|
37
|
+
const fetchMock = pageFetch();
|
|
35
38
|
vi.stubGlobal('fetch', fetchMock);
|
|
36
39
|
const failedPageCount = await generatePages('http://localhost:3000', ['/'], outputDir, '/introduction');
|
|
37
40
|
expect(failedPageCount).toBe(0);
|
|
38
41
|
expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/introduction');
|
|
39
42
|
await expect(fse.readFile(path.join(outputDir, 'index.html'), 'utf8')).resolves.toBe('<html>Introduction</html>');
|
|
40
43
|
});
|
|
44
|
+
it('saves the rsc payload next to each page for soft navigation', async () => {
|
|
45
|
+
const fetchMock = pageFetch();
|
|
46
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
47
|
+
const failedPageCount = await generatePages('http://localhost:3000', ['/', '/guides/math'], outputDir);
|
|
48
|
+
expect(failedPageCount).toBe(0);
|
|
49
|
+
expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/guides/math', {
|
|
50
|
+
headers: { RSC: '1' },
|
|
51
|
+
});
|
|
52
|
+
await expect(fse.readFile(path.join(outputDir, 'index.txt'), 'utf8')).resolves.toBe('0:{"b":"build"}\n');
|
|
53
|
+
await expect(fse.readFile(path.join(outputDir, 'guides', 'math', 'index.txt'), 'utf8')).resolves.toBe('0:{"b":"build"}\n');
|
|
54
|
+
});
|
|
55
|
+
it('fails the page when the rsc request does not return a payload', async () => {
|
|
56
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html></html>', { headers: { 'content-type': 'text/html' } })));
|
|
57
|
+
await expect(generatePages('http://localhost:3000', ['/quickstart'], outputDir)).resolves.toBe(1);
|
|
58
|
+
});
|
|
41
59
|
});
|
|
42
60
|
describe('writeStaticUserInfo', () => {
|
|
43
61
|
let outputDir;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type FetchAsset } from './export-fetch.js';
|
|
2
|
+
export type LocalizeAssetsResult = {
|
|
3
|
+
downloaded: string[];
|
|
4
|
+
failed: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function collectAssetPaths(exportDir: string): Promise<Set<string>>;
|
|
7
|
+
export declare function rewriteCssUrls(css: string, cssUrl: string | undefined, cssAssetPath: string, onAsset: (assetPath: string) => void): string;
|
|
8
|
+
export declare function localizeAssets(exportDir: string, fetchAsset?: FetchAsset): Promise<LocalizeAssetsResult>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { MINT_EXPORT_ASSETS_PREFIX, fromMintExportAssetPath, getLanguageIconUrl, toMintExportAssetPath, } from '@mintlify/common';
|
|
2
|
+
import fse from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fetchPublicAsset } from './export-fetch.js';
|
|
5
|
+
const ASSET_PATH_PATTERN = new RegExp(`${MINT_EXPORT_ASSETS_PREFIX.replace(/\//g, '\\/')}\\/[A-Za-z0-9._~\\-/%+@]+`, 'g');
|
|
6
|
+
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
7
|
+
const CODE_LANGUAGE_PATTERN = /\slanguage="([^"]+)"/g;
|
|
8
|
+
const CONCURRENCY = 10;
|
|
9
|
+
async function* walkFiles(dir) {
|
|
10
|
+
for (const entry of await fse.readdir(dir, { withFileTypes: true })) {
|
|
11
|
+
const entryPath = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
yield* walkFiles(entryPath);
|
|
14
|
+
}
|
|
15
|
+
else if (entry.isFile()) {
|
|
16
|
+
yield entryPath;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function collectAssetPaths(exportDir) {
|
|
21
|
+
const assetPaths = new Set();
|
|
22
|
+
for await (const file of walkFiles(exportDir)) {
|
|
23
|
+
if (!file.endsWith('.html'))
|
|
24
|
+
continue;
|
|
25
|
+
const html = await fse.readFile(file, 'utf8');
|
|
26
|
+
for (const match of html.matchAll(ASSET_PATH_PATTERN)) {
|
|
27
|
+
assetPaths.add(match[0]);
|
|
28
|
+
}
|
|
29
|
+
for (const [, language = ''] of html.matchAll(CODE_LANGUAGE_PATTERN)) {
|
|
30
|
+
const iconUrl = getLanguageIconUrl(language);
|
|
31
|
+
if (iconUrl)
|
|
32
|
+
assetPaths.add(toMintExportAssetPath(iconUrl));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return assetPaths;
|
|
36
|
+
}
|
|
37
|
+
const resolveOutputPath = (exportDir, assetPath) => {
|
|
38
|
+
let decoded;
|
|
39
|
+
try {
|
|
40
|
+
decoded = decodeURIComponent(assetPath);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const segments = decoded.split(/[\\/]/);
|
|
46
|
+
if (segments.some((segment) => segment === '..' || segment === '.'))
|
|
47
|
+
return undefined;
|
|
48
|
+
const assetsDir = path.resolve(exportDir, `.${MINT_EXPORT_ASSETS_PREFIX}`);
|
|
49
|
+
const outputPath = path.resolve(exportDir, `.${decoded}`);
|
|
50
|
+
return outputPath.startsWith(assetsDir + path.sep) ? outputPath : undefined;
|
|
51
|
+
};
|
|
52
|
+
const isCss = (url, contentType) => !!contentType?.includes('text/css') || new URL(url).pathname.endsWith('.css');
|
|
53
|
+
export function rewriteCssUrls(css, cssUrl, cssAssetPath, onAsset) {
|
|
54
|
+
return css.replace(CSS_URL_PATTERN, (match, quote, rawUrl) => {
|
|
55
|
+
if (rawUrl.startsWith('data:') || rawUrl.startsWith('#'))
|
|
56
|
+
return match;
|
|
57
|
+
let absoluteUrl;
|
|
58
|
+
try {
|
|
59
|
+
absoluteUrl = new URL(rawUrl.startsWith('//') ? `https:${rawUrl}` : rawUrl, cssUrl).toString();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return match;
|
|
63
|
+
}
|
|
64
|
+
const assetPath = toMintExportAssetPath(absoluteUrl);
|
|
65
|
+
if (assetPath === absoluteUrl)
|
|
66
|
+
return match;
|
|
67
|
+
onAsset(assetPath);
|
|
68
|
+
const relativePath = path.posix.relative(path.posix.dirname(cssAssetPath), assetPath);
|
|
69
|
+
return `url(${quote}${relativePath}${quote})`;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async function localizeStylesheets(exportDir, onAsset) {
|
|
73
|
+
const nextStaticDir = path.join(exportDir, '_next', 'static');
|
|
74
|
+
if (!(await fse.pathExists(nextStaticDir)))
|
|
75
|
+
return;
|
|
76
|
+
for await (const file of walkFiles(nextStaticDir)) {
|
|
77
|
+
if (!file.endsWith('.css'))
|
|
78
|
+
continue;
|
|
79
|
+
const css = await fse.readFile(file, 'utf8');
|
|
80
|
+
const cssAssetPath = `/${path.relative(exportDir, file).split(path.sep).join('/')}`;
|
|
81
|
+
const rewritten = rewriteCssUrls(css, undefined, cssAssetPath, onAsset);
|
|
82
|
+
if (rewritten !== css)
|
|
83
|
+
await fse.writeFile(file, rewritten, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function localizeAssets(exportDir, fetchAsset = fetchPublicAsset) {
|
|
87
|
+
const queue = [...(await collectAssetPaths(exportDir))];
|
|
88
|
+
const seen = new Set(queue);
|
|
89
|
+
const downloaded = [];
|
|
90
|
+
const failed = [];
|
|
91
|
+
const enqueue = (assetPath) => {
|
|
92
|
+
if (seen.has(assetPath))
|
|
93
|
+
return;
|
|
94
|
+
seen.add(assetPath);
|
|
95
|
+
queue.push(assetPath);
|
|
96
|
+
};
|
|
97
|
+
await localizeStylesheets(exportDir, enqueue);
|
|
98
|
+
const localize = async (assetPath) => {
|
|
99
|
+
const url = fromMintExportAssetPath(assetPath);
|
|
100
|
+
const outputPath = resolveOutputPath(exportDir, assetPath);
|
|
101
|
+
if (!url || !outputPath) {
|
|
102
|
+
failed.push(assetPath);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const asset = await fetchAsset(url);
|
|
106
|
+
if (!asset) {
|
|
107
|
+
failed.push(url);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await fse.ensureDir(path.dirname(outputPath));
|
|
111
|
+
if (isCss(url, asset.contentType)) {
|
|
112
|
+
const css = rewriteCssUrls(asset.body.toString('utf8'), url, assetPath, enqueue);
|
|
113
|
+
await fse.writeFile(outputPath, css, 'utf8');
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
await fse.writeFile(outputPath, asset.body);
|
|
117
|
+
}
|
|
118
|
+
downloaded.push(assetPath);
|
|
119
|
+
};
|
|
120
|
+
const worker = async () => {
|
|
121
|
+
while (queue.length > 0) {
|
|
122
|
+
const assetPath = queue.shift();
|
|
123
|
+
try {
|
|
124
|
+
await localize(assetPath);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
failed.push(fromMintExportAssetPath(assetPath) ?? assetPath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
while (queue.length > 0) {
|
|
132
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
133
|
+
}
|
|
134
|
+
return { downloaded, failed };
|
|
135
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type LookupFunction } from 'net';
|
|
2
|
+
export type PublicAsset = {
|
|
3
|
+
contentType: string | undefined;
|
|
4
|
+
body: Buffer;
|
|
5
|
+
};
|
|
6
|
+
export type FetchAsset = (url: string) => Promise<PublicAsset | undefined>;
|
|
7
|
+
export type FetchPublicAssetOptions = {
|
|
8
|
+
lookup?: LookupFunction;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
maxBytes?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function fetchPublicAsset(url: string, { lookup, timeoutMs, maxBytes, }?: FetchPublicAssetOptions): Promise<PublicAsset | undefined>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isPrivateIP, safeLookup } from '@mintlify/common/ssrf';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import https from 'https';
|
|
4
|
+
import { isIP } from 'net';
|
|
5
|
+
const MAX_REDIRECTS = 5;
|
|
6
|
+
const TIMEOUT_MS = 30_000;
|
|
7
|
+
const MAX_ASSET_BYTES = 25 * 1024 * 1024;
|
|
8
|
+
const BROWSER_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
|
|
9
|
+
const requestAsset = (url, { lookup, timeoutMs, maxBytes }) => new Promise((resolve, reject) => {
|
|
10
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
11
|
+
const req = client.get(url, {
|
|
12
|
+
headers: { 'user-agent': BROWSER_USER_AGENT },
|
|
13
|
+
lookup,
|
|
14
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
15
|
+
}, (res) => {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let size = 0;
|
|
18
|
+
res.on('data', (chunk) => {
|
|
19
|
+
size += chunk.byteLength;
|
|
20
|
+
if (size > maxBytes) {
|
|
21
|
+
res.destroy();
|
|
22
|
+
reject(new Error(`asset exceeds ${maxBytes} bytes: ${url}`));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
chunks.push(chunk);
|
|
26
|
+
});
|
|
27
|
+
res.on('end', () => resolve({
|
|
28
|
+
status: res.statusCode ?? 0,
|
|
29
|
+
location: res.headers.location,
|
|
30
|
+
contentType: res.headers['content-type'],
|
|
31
|
+
body: Buffer.concat(chunks),
|
|
32
|
+
}));
|
|
33
|
+
res.on('error', reject);
|
|
34
|
+
});
|
|
35
|
+
req.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
export async function fetchPublicAsset(url, { lookup = safeLookup, timeoutMs = TIMEOUT_MS, maxBytes = MAX_ASSET_BYTES, } = {}) {
|
|
38
|
+
let current = new URL(url);
|
|
39
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
40
|
+
if (current.protocol !== 'https:' && current.protocol !== 'http:')
|
|
41
|
+
return undefined;
|
|
42
|
+
const host = current.hostname.replace(/^\[|\]$/g, '');
|
|
43
|
+
if (isIP(host) && isPrivateIP(host))
|
|
44
|
+
return undefined;
|
|
45
|
+
const res = await requestAsset(current, { lookup, timeoutMs, maxBytes });
|
|
46
|
+
if (res.status >= 300 && res.status < 400 && res.location) {
|
|
47
|
+
current = new URL(res.location, current);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
return res.status >= 200 && res.status < 300
|
|
51
|
+
? { contentType: res.contentType, body: res.body }
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateExportLlmsTxt(docsConfig: unknown, generatedNav: unknown, routes: string[]): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { divisions } from '@mintlify/validation';
|
|
2
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
const getString = (record, key) => {
|
|
4
|
+
const value = record[key];
|
|
5
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
6
|
+
};
|
|
7
|
+
function collectPages(entry, pages) {
|
|
8
|
+
if (Array.isArray(entry)) {
|
|
9
|
+
entry.forEach((item) => collectPages(item, pages));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (!isRecord(entry))
|
|
13
|
+
return;
|
|
14
|
+
const href = getString(entry, 'href');
|
|
15
|
+
const title = getString(entry, 'sidebarTitle') ?? getString(entry, 'title');
|
|
16
|
+
if (href && title && 'title' in entry) {
|
|
17
|
+
if (href.startsWith('/') && !href.startsWith('//') && !pages.has(href)) {
|
|
18
|
+
pages.set(href, { title, href, description: getString(entry, 'description') });
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const key of ['root', 'pages', 'groups', ...divisions]) {
|
|
23
|
+
collectPages(entry[key], pages);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const escapeLinkText = (text) => text.replace(/([[\]])/g, '\\$1');
|
|
27
|
+
export function generateExportLlmsTxt(docsConfig, generatedNav, routes) {
|
|
28
|
+
const config = isRecord(docsConfig) ? docsConfig : {};
|
|
29
|
+
const name = getString(config, 'name') ?? 'Documentation';
|
|
30
|
+
const description = getString(config, 'description');
|
|
31
|
+
const pages = new Map();
|
|
32
|
+
collectPages(generatedNav, pages);
|
|
33
|
+
const header = `# ${name}\n${description ? `\n> ${description}\n` : ''}\n## Docs\n\n`;
|
|
34
|
+
const exportedRoutes = new Set(routes);
|
|
35
|
+
const lines = [...pages.values()]
|
|
36
|
+
.filter((page) => exportedRoutes.has(page.href))
|
|
37
|
+
.map((page) => `- [${escapeLinkText(page.title)}](${page.href})${page.description ? `: ${page.description}` : ''}\n`);
|
|
38
|
+
return header + lines.join('');
|
|
39
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ArgumentsCamelCase } from 'yargs';
|
|
2
2
|
export declare function generatePages(baseUrl: string, routes: string[], outputDir: string, rootRoute?: string): Promise<number>;
|
|
3
3
|
export declare function writeStaticUserInfo(tmpDir: string, groups: string[] | undefined): Promise<void>;
|
|
4
|
+
export declare function writeLlmsTxt(tmpDir: string, routes: string[]): Promise<void>;
|
|
4
5
|
export declare const exportSite: (argv: ArgumentsCamelCase) => Promise<void>;
|
|
@@ -11,11 +11,15 @@ import path from 'path';
|
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
12
|
import { CLIENT_PATH, CMD_EXEC_PATH, VERSION_PATH, NEXT_PUBLIC_PATH, NEXT_PROPS_PATH, PREVIEW_PATH, configureSharedPreviewPath, } from '../constants.js';
|
|
13
13
|
import { addLog, clearLogs } from '../logging-state.js';
|
|
14
|
-
import { ErrorLog, SpinnerLog, SuccessLog } from '../logs.js';
|
|
14
|
+
import { ErrorLog, SpinnerLog, SuccessLog, WarningLog } from '../logs.js';
|
|
15
|
+
import { localizeAssets } from './export-assets.js';
|
|
16
|
+
import { generateExportLlmsTxt } from './export-llms.js';
|
|
15
17
|
import { getGroupFilteredRoutes } from './getGroupFilteredRoutes.js';
|
|
16
18
|
import { setupNext } from './setupNext.js';
|
|
17
19
|
import { silentUpdateClient } from './update.js';
|
|
18
20
|
const CONCURRENCY = 10;
|
|
21
|
+
const RSC_CONTENT_TYPE = 'text/x-component';
|
|
22
|
+
const RSC_PAYLOAD_FILE = 'index.txt';
|
|
19
23
|
const EXPORT_SCRIPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'export-scripts');
|
|
20
24
|
function resolveRequestRoute(route, rootRoute) {
|
|
21
25
|
if (route === '/' && rootRoute)
|
|
@@ -92,17 +96,22 @@ export async function generatePages(baseUrl, routes, outputDir, rootRoute) {
|
|
|
92
96
|
const route = queue.shift();
|
|
93
97
|
try {
|
|
94
98
|
const requestRoute = resolveRequestRoute(route, rootRoute);
|
|
95
|
-
const res = await
|
|
96
|
-
|
|
99
|
+
const [res, rscRes] = await Promise.all([
|
|
100
|
+
fetch(`${baseUrl}${requestRoute}`),
|
|
101
|
+
fetch(`${baseUrl}${requestRoute}`, { headers: { RSC: '1' } }),
|
|
102
|
+
]);
|
|
103
|
+
const isRscPayload = rscRes.headers.get('content-type')?.startsWith(RSC_CONTENT_TYPE);
|
|
104
|
+
if (!res.ok || !rscRes.ok || !isRscPayload) {
|
|
97
105
|
failedPageCount += 1;
|
|
98
|
-
addLog(_jsx(ErrorLog, { message: `failed to generate ${route} (status ${res.status})` }));
|
|
106
|
+
addLog(_jsx(ErrorLog, { message: `failed to generate ${route} (status ${res.ok ? rscRes.status : res.status})` }));
|
|
99
107
|
continue;
|
|
100
108
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
const pageDir = route === '/' ? outputDir : path.join(outputDir, route);
|
|
110
|
+
await fse.ensureDir(pageDir);
|
|
111
|
+
await Promise.all([
|
|
112
|
+
fse.writeFile(path.join(pageDir, 'index.html'), await res.text(), 'utf8'),
|
|
113
|
+
fse.writeFile(path.join(pageDir, RSC_PAYLOAD_FILE), await rscRes.text(), 'utf8'),
|
|
114
|
+
]);
|
|
106
115
|
}
|
|
107
116
|
catch (err) {
|
|
108
117
|
failedPageCount += 1;
|
|
@@ -154,9 +163,20 @@ async function bundleStaticAssets(tmpDir, groups) {
|
|
|
154
163
|
}
|
|
155
164
|
await writeStaticUserInfo(tmpDir, groups);
|
|
156
165
|
await fse.copy(path.join(EXPORT_SCRIPTS_DIR, 'serve.js'), path.join(tmpDir, 'serve.js'));
|
|
166
|
+
await fse.copy(path.join(EXPORT_SCRIPTS_DIR, 'README.md'), path.join(tmpDir, 'README.md'));
|
|
157
167
|
await fse.copy(path.join(EXPORT_SCRIPTS_DIR, 'start-docs.sh'), path.join(tmpDir, 'Start Docs.command'));
|
|
158
168
|
await fse.copy(path.join(EXPORT_SCRIPTS_DIR, 'start-docs.bat'), path.join(tmpDir, 'Start Docs.bat'));
|
|
159
169
|
}
|
|
170
|
+
export async function writeLlmsTxt(tmpDir, routes) {
|
|
171
|
+
const llmsTxtPath = path.join(tmpDir, 'llms.txt');
|
|
172
|
+
if (await pathExists(llmsTxtPath))
|
|
173
|
+
return;
|
|
174
|
+
const [docsConfig, generatedNav] = await Promise.all([
|
|
175
|
+
fse.readJson(path.join(NEXT_PROPS_PATH, 'docs.json')).catch(() => undefined),
|
|
176
|
+
fse.readJson(path.join(NEXT_PROPS_PATH, 'generatedDocsNav.json')).catch(() => undefined),
|
|
177
|
+
]);
|
|
178
|
+
await fse.writeFile(llmsTxtPath, generateExportLlmsTxt(docsConfig, generatedNav, routes), 'utf8');
|
|
179
|
+
}
|
|
160
180
|
async function closeServer(server) {
|
|
161
181
|
await new Promise((resolve) => {
|
|
162
182
|
server.close(() => resolve());
|
|
@@ -179,11 +199,17 @@ async function generateExportArchive(routes, outputPath, groups) {
|
|
|
179
199
|
}
|
|
180
200
|
addLog(_jsx(SpinnerLog, { message: "collecting static assets..." }));
|
|
181
201
|
await bundleStaticAssets(exportDir, groups);
|
|
202
|
+
await writeLlmsTxt(exportDir, routes);
|
|
203
|
+
addLog(_jsx(SpinnerLog, { message: "bundling external assets..." }));
|
|
204
|
+
const { failed } = await localizeAssets(exportDir);
|
|
182
205
|
addLog(_jsx(SpinnerLog, { message: "creating zip archive..." }));
|
|
183
206
|
const zip = new AdmZip();
|
|
184
207
|
zip.addLocalFolder(exportDir);
|
|
185
208
|
zip.writeZip(outputPath);
|
|
186
209
|
clearLogs();
|
|
210
|
+
for (const url of failed) {
|
|
211
|
+
addLog(_jsx(WarningLog, { message: `could not bundle ${url}` }));
|
|
212
|
+
}
|
|
187
213
|
addLog(_jsx(SuccessLog, { message: `exported to ${outputPath}` }));
|
|
188
214
|
}
|
|
189
215
|
finally {
|
|
@@ -197,6 +223,7 @@ export const exportSite = async (argv) => {
|
|
|
197
223
|
configureSharedPreviewPath();
|
|
198
224
|
const hasInternet = await isOnline();
|
|
199
225
|
const clientVersion = argv['client-version'];
|
|
226
|
+
const localClientVersion = argv['local-client-version'];
|
|
200
227
|
const groups = argv.groups;
|
|
201
228
|
const cliVersion = argv.cliVersion;
|
|
202
229
|
const disableOpenApi = argv.disableOpenapi;
|
|
@@ -205,7 +232,7 @@ export const exportSite = async (argv) => {
|
|
|
205
232
|
const versionString = (await pathExists(VERSION_PATH))
|
|
206
233
|
? fse.readFileSync(VERSION_PATH, 'utf8')
|
|
207
234
|
: null;
|
|
208
|
-
if (!versionString && !hasInternet) {
|
|
235
|
+
if (!versionString && !localClientVersion && !hasInternet) {
|
|
209
236
|
await fatal('export requires an internet connection on first run.');
|
|
210
237
|
}
|
|
211
238
|
addLog(_jsx(SpinnerLog, { message: "preparing export..." }));
|
|
@@ -213,7 +240,7 @@ export const exportSite = async (argv) => {
|
|
|
213
240
|
versionString,
|
|
214
241
|
clientVersion,
|
|
215
242
|
cliVersion,
|
|
216
|
-
localClientVersion
|
|
243
|
+
localClientVersion,
|
|
217
244
|
});
|
|
218
245
|
if (error)
|
|
219
246
|
await fatal(error);
|
|
@@ -242,6 +269,7 @@ export const exportSite = async (argv) => {
|
|
|
242
269
|
await fatal(err instanceof Error && err.message ? err.message : 'failed to collect routes');
|
|
243
270
|
}
|
|
244
271
|
addLog(_jsx(SpinnerLog, { message: "starting local server..." }));
|
|
272
|
+
process.env.MINT_EXPORT = JSON.stringify({ groups });
|
|
245
273
|
try {
|
|
246
274
|
await generateExportArchive(routes, outputPath, groups);
|
|
247
275
|
}
|