@mintlify/package-builder 0.0.0-stage → 0.0.3
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/LICENSE +93 -0
- package/README.md +66 -2
- package/dist/build.d.ts +4 -0
- package/dist/build.js +338 -0
- package/dist/bundle.d.ts +44 -0
- package/dist/bundle.js +681 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +112 -0
- package/dist/diagnostics.d.ts +11 -0
- package/dist/diagnostics.js +32 -0
- package/dist/hashing.d.ts +28 -0
- package/dist/hashing.js +98 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/install.d.ts +34 -0
- package/dist/install.js +325 -0
- package/dist/local-files.d.ts +11 -0
- package/dist/local-files.js +120 -0
- package/dist/lockfile.d.ts +40 -0
- package/dist/lockfile.js +498 -0
- package/dist/package-json.d.ts +14 -0
- package/dist/package-json.js +170 -0
- package/dist/package-manager.d.ts +17 -0
- package/dist/package-manager.js +87 -0
- package/dist/react.d.ts +37 -0
- package/dist/react.js +264 -0
- package/dist/render.d.ts +37 -0
- package/dist/render.js +292 -0
- package/dist/runtime/client.d.ts +11 -0
- package/dist/runtime/client.js +54 -0
- package/dist/runtime/server.d.ts +6 -0
- package/dist/runtime/server.js +11 -0
- package/dist/runtime/shell.d.ts +21 -0
- package/dist/runtime/shell.js +68 -0
- package/dist/runtime/theme.d.ts +1 -0
- package/dist/runtime/theme.js +1 -0
- package/dist/sanitize.d.ts +9 -0
- package/dist/sanitize.js +650 -0
- package/dist/semver.d.ts +2 -0
- package/dist/semver.js +10 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +11 -0
- package/package.json +74 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright (c) 2022 Mintlify, Inc.
|
|
2
|
+
|
|
3
|
+
Elastic License 2.0
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
+
available, and prepare derivative works of the software, in each case subject to
|
|
14
|
+
the limitations and conditions below.
|
|
15
|
+
|
|
16
|
+
## Limitations
|
|
17
|
+
|
|
18
|
+
You may not provide the software to third parties as a hosted or managed
|
|
19
|
+
service, where the service provides users with access to any substantial set of
|
|
20
|
+
the features or functionality of the software.
|
|
21
|
+
|
|
22
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
+
software that is protected by the license key.
|
|
25
|
+
|
|
26
|
+
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
|
27
|
+
of the licensor in the software. Any use of the licensor’s trademarks is subject
|
|
28
|
+
to applicable law.
|
|
29
|
+
|
|
30
|
+
## Patents
|
|
31
|
+
|
|
32
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
+
sale, import and have imported the software, in each case subject to the
|
|
35
|
+
limitations and conditions in this license. This license does not cover any
|
|
36
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
+
the software. If you or your company make any written claim that the software
|
|
38
|
+
infringes or contributes to infringement of any patent, your patent license for
|
|
39
|
+
the software granted under these terms ends immediately. If your company makes
|
|
40
|
+
such a claim, your patent license ends immediately for work on behalf of your
|
|
41
|
+
company.
|
|
42
|
+
|
|
43
|
+
## Notices
|
|
44
|
+
|
|
45
|
+
You must ensure that anyone who gets a copy of any part of the software from you
|
|
46
|
+
also gets a copy of these terms.
|
|
47
|
+
|
|
48
|
+
If you modify the software, you must include in any modified copies of the
|
|
49
|
+
software prominent notices stating that you have modified the software.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
+
these terms.
|
|
55
|
+
|
|
56
|
+
## Termination
|
|
57
|
+
|
|
58
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
+
with a notice of your violation, and you cease all violation of this license no
|
|
61
|
+
later than 30 days after you receive that notice, your licenses will be
|
|
62
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
+
reinstatement, any additional violation of these terms will cause your licenses
|
|
64
|
+
to terminate automatically and permanently.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
_As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
+
legal claim._
|
|
72
|
+
|
|
73
|
+
## Definitions
|
|
74
|
+
|
|
75
|
+
The **licensor** is the entity offering these terms, and the **software** is the
|
|
76
|
+
software the licensor makes available under these terms, including any portion
|
|
77
|
+
of it.
|
|
78
|
+
|
|
79
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
+
|
|
81
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
+
organization that you work for, plus all organizations that have control over,
|
|
83
|
+
are under the control of, or are under common control with that
|
|
84
|
+
organization. **control** means ownership of substantially all the assets of an
|
|
85
|
+
entity, or the power to direct its management and policies by vote, contract, or
|
|
86
|
+
otherwise. Control can be direct or indirect.
|
|
87
|
+
|
|
88
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
+
these terms.
|
|
90
|
+
|
|
91
|
+
**use** means anything you do with the software requiring one of your licenses.
|
|
92
|
+
|
|
93
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mintlify/package-builder
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Builds npm package islands for docs that set a `packages` key in docs.json. The same code runs in the Cloudflare Sandbox build container and in `mint dev`.
|
|
4
|
+
|
|
5
|
+
`packages.isolation: "frame"` is not implemented yet. A build that sets it fails with `isolation-frame-not-implemented` instead of running the islands in the page.
|
|
6
|
+
|
|
7
|
+
Steps:
|
|
8
|
+
|
|
9
|
+
1. Validates the lockfile (`package-lock.json` v2/v3 or `pnpm-lock.yaml` v6+): every entry needs `integrity`; `git`, `file:`, `link:` and `http:` sources are rejected; https URLs must be on registry.npmjs.org or on the scope's registry from `packages.registries`. package.json dependencies, `overrides` and `pnpm` overrides/extensions must be registry versions, ranges or dist-tags (no `npm:` aliases, git, `file:`, `link:`, `workspace:`, `catalog:`, URLs or paths), `pnpm.patchedDependencies` is rejected, and the lockfile root must match package.json. npm entries marked `inBundle` cannot carry their own `resolved`, and pnpm resolutions may only have `integrity` and `tarball`. For `npm-ci` and `prepared`, package.json and the lockfile must be regular files, not symbolic links, and parse errors report only a line and column.
|
|
10
|
+
2. Installs. `npm-ci` copies only package.json and the lockfile into a temp dir, writes its own `.npmrc` (deleted as soon as the install exits, since it can hold registry tokens; registries and tokens containing `${` are rejected because npm fills them from environment variables) and runs `npm ci --ignore-scripts --omit=dev`. It retries once with `--legacy-peer-deps` on ERESOLVE. On Windows it never spawns a `.cmd` shim or a shell: it runs `npm-cli.js` (or pnpm's `pnpm.cjs`/`pnpm.mjs`/`pnpm.js`) with the current Node, or `pnpm.exe`, found from `npm_execpath`, PATH or the Node install, and fails with `package-manager-missing` otherwise. `local` (for `mint dev`) uses the docs folder's existing node_modules and turns lockfile and package.json errors into warnings. `prepared` is for a caller that installed packages itself, like the build worker: it uses the existing node_modules but keeps every check an error, including installed versions that differ from the lockfile, and never reads node_modules folders above the docs folder. The copied package.json drops `packageManager` and `devEngines`, Corepack is told not to switch versions, and pnpm runs with `package-import-method=copy`; `packageManagers.pnpm` maps a pnpm major to an installed binary, so a project asking for that major gets it and any other major gets the default pnpm with a warning. `registryTokens` is keyed by registry URL: a token is written only for scopes whose registry in `packages.registries` is that URL. `npm-ci` and `prepared` builds reject hard-linked files in packages and docs files.
|
|
11
|
+
3. Bundles every island in one esbuild build with code splitting. Imports cannot leave the docs folder or climb out of a package with `..`, `packages.css` entries must be package paths, and `packages.provider` must be a docs path that starts with `/`, such as `/snippets/provider.jsx` (without the `/` the entry would import it as an npm package). A docs or island `import()`/`require()` that builds a `./`, `../` or `/` path at runtime (a template literal with `${}` or string concatenation) fails with `import-runtime-path` before installing, because only statically imported docs files are hashed and copied; dynamic imports of package paths are left alone. Every file esbuild reads, from any importer (packages, their CSS `@import` and `url()`, glob imports, asset loaders), must have a real path (links and Windows 8.3 short names resolved on both sides, compared case-insensitively on Windows) inside the install root's node_modules, the job's docs files or the builder runtime, so a package or a symlink cannot pull in other files; `local` also accepts node_modules folders above the docs folder, where workspaces hoist packages. The metafile is checked again before anything is written. `react`, `react-dom` and their subpaths are aliased to the project's single copy. A separate Node build is used for server rendering.
|
|
12
|
+
4. Renders each island in its own child process with `renderToString`, using `identifierPrefix: mi-<id>-` and a 3 s timeout per island, then sanitizes the HTML (256 KB cap). A fresh process per island means module-level counters (lodash `uniqueId`, recharts clip paths, react-select instance ids) start over for every island, so two islands on a page could render the same ids. When the HTML has an `id`, the process renders the island a second time; every id that differs between the two renders (all ids when the second render throws or runs out of time) becomes `mi-<island id>-<old id>`, together with its references in `url(#...)` values, `href`/`xlink:href="#..."` and id-list attributes such as `for` and `aria-labelledby`. Ids from `useId` already carry the prefix, and ids that are the same in both renders, such as a gradient id written in the source, keep their names, so elements React mounts after hydration still find them. React keeps server attributes when it hydrates, so renamed ids stay in the page; the browser's own counter ids never collide with them. Code that runs after hydration only knows the browser's counter values, so an element mounted later that points at a renamed id (a recharts `ReferenceArea` with `ifOverflow="hidden"` shown after load) or a `getElementById` call for one finds nothing; without renaming, that only worked in the first island to hydrate. Islands render one at a time and a process only ever loads one island, so its code can only affect its own result. The parent accepts `start` and then one well-formed `done` (followed by the HTML and the second render's HTML or `null`) or `error` for that island's id, then kills the process; any other message fails that island, except that a malformed or oversized message after the HTML only drops the second render. On Node 20+ the render process runs under `--permission` with read access only to the server bundle and the render script, so an island cannot read other files, write to disk (including rewriting another island's bundle), or spawn a process or worker while it renders. On Windows the builder first renders a probe island under the same flags and drops them if it fails, so a Node permission model that rejects Windows paths degrades to an unsandboxed render instead of failing every island. Results come back as JSON lines on a plain pipe that the parent parses itself, capped at 1M characters per message and 16M characters of HTML, with log entries clipped to 20 of 2,000 characters, and each island's HTML is sanitized before the next island renders, so the builder holds one raw result at a time; Node IPC is not used because its own message handling throws in the parent on crafted input. Anything an island writes to the pipe fails only that island rather than crashing the build. Network egress is not restricted here; the build container blocks it at the container layer, and `mint dev` / `mint packages check` run the project's own code on the developer's machine. `node:sqlite`, which the permission model does not cover, is turned off with `--no-experimental-sqlite`. On Linux the parent stops a render whose resident memory passes `renderMemoryLimitBytes` (2 GiB by default). `npm-ci` and `prepared` builds never render without the permission model; `local` builds warn and render. An island that throws, times out, runs out of memory or cannot be sandboxed is reported as a warning and renders in the browser only.
|
|
13
|
+
5. Writes `manifest.json` with sha384 integrity and gzip sizes.
|
|
14
|
+
|
|
15
|
+
## Build hashes
|
|
16
|
+
|
|
17
|
+
`computeDepsHash` and `computeIslandsHash` in `src/hashing.ts` are the one definition of the build keys. The server and the package-builder worker carry ports, and all three must pass `__test__/fixtures/hash-vectors.json` (copied byte for byte into both server tests).
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
H(x) = lowercase hex sha256 of x (strings as UTF-8)
|
|
21
|
+
canonical() = JSON, no whitespace, object keys sorted by UTF-16 code unit, undefined keys dropped
|
|
22
|
+
depsHash = H(canonical({ algorithm: 'mint-pkg-deps/1', builderVersion, lockfileKind,
|
|
23
|
+
lockfile: H(lockfileBytes), dependencies, registries ?? {} }))
|
|
24
|
+
islandsHash = H(canonical({ algorithm: 'mint-pkg-islands/1',
|
|
25
|
+
islands: [{ id, kind, module: H(module) }] sorted by id,
|
|
26
|
+
snippets: { [path]: H(source) }, provider ?? null, css ?? [],
|
|
27
|
+
ssr ?? true, isolation ?? 'page' }))
|
|
28
|
+
buildId = H(depsHash + islandsHash).slice(0, 32)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`snippets` holds the job's `snippetFiles` plus every docs file that they, `packages.provider` or an island reach through relative or `/` imports, read from `docsRoot` (`collectLocalFiles`). The bundle reads docs files only from that set, so editing an imported helper, JSON or CSS file always changes the hash. A job that already sends every imported file, as the server does, gets the same hash. A `snippetFiles` string is hashed and bundled as its UTF-8 bytes, so it must be the file's exact text, byte order mark and CRLF line endings included. A file whose bytes are not valid UTF-8 (images, fonts, Latin-1 or UTF-16 text) stays out of `snippetFiles` and is read from `docsRoot` as bytes. The server splits files that way: text whose bytes survive a UTF-8 round trip goes in `snippetFiles`, everything else in `assetFiles`, which the worker writes into the docs folder.
|
|
32
|
+
|
|
33
|
+
A build job can pass `expected: { depsHash, islandsHash }`. The manifest then carries those values, and the build fails with `deps-hash-mismatch` or `islands-hash-mismatch` if the builder computes different ones, so a job keyed under one hash never publishes output built from different inputs.
|
|
34
|
+
|
|
35
|
+
## CLI
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
mint-package-builder build --input input.json --out dist [--installer local|npm-ci|prepared] [--json]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`input.json` is a `BuildInput` without `outDir`. The exit code is 1 when the build has errors.
|
|
42
|
+
|
|
43
|
+
## Output
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
<out>/manifest.json
|
|
47
|
+
<out>/client/islands/<id>.js exports hydrate(el, { identifierPrefix, props?, theme? })
|
|
48
|
+
<out>/client/*.js shared chunks, one React
|
|
49
|
+
<out>/client/styles.css packages.css plus CSS imported by packages
|
|
50
|
+
<out>/server/ server build (not served)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`styles.css` is one stylesheet for every island. It starts with the `packages.css` entries in docs.json order, then adds the stylesheets imported from JS: `packages.provider`'s first, then each island's in island id order, each walked depth first in the order esbuild uses for CSS imported from JS (static imports in source order, then `import()` and `require()`). A package `import()` or `require()` whose path is built at runtime, such as ``import(`./themes/${name}.css`)``, adds every file it matches at its place, sorted by path as esbuild sorts them, and JS files it matches are walked the same way. A `?query` or `#hash` suffix on a stylesheet import is dropped, and an import such as `./button.css` that resolves to a JS file (`button.css.js`) stays JS. When two islands import the same files in different orders, the island whose id sorts first decides. A stylesheet that JS imports again, or that `packages.css` already loads directly or through `@import`, keeps its first place. A file that stylesheets `@import` more than once keeps its last place, as in esbuild and the browser. The client build writes no other stylesheet: a stylesheet that JS loads in a way the walk cannot place, such as a runtime path that matches a symlink to a file elsewhere in the package, fails the build with `css-not-collected`. CSS modules (`.module.css`) fail the build too, whether JS imports them (including code that only the server build loads), a stylesheet `@import`s them or `packages.css` lists them, since styles.css loads them globally and their class names would not match.
|
|
54
|
+
|
|
55
|
+
Package snippets can read the docs theme with `import { useMintTheme } from '@mintlify/islands'`.
|
|
56
|
+
|
|
57
|
+
## React rules
|
|
58
|
+
|
|
59
|
+
| Check | Result |
|
|
60
|
+
| ----------------------------------------------------------------- | ------- |
|
|
61
|
+
| react and react-dom differ on 19.x, or majors differ | error |
|
|
62
|
+
| react and react-dom differ on 18.x | warning |
|
|
63
|
+
| more than one react or react-dom in a bundle | error |
|
|
64
|
+
| an island throws, logs an error or times out during SSR | warning |
|
|
65
|
+
| the alias replaced a package's own React | warning |
|
|
66
|
+
| a peer range excludes the installed React | warning |
|
|
67
|
+
| `findDOMNode`, `ReactDOM.render` or `.defaultProps =` on React 19 | warning |
|
package/dist/build.d.ts
ADDED
package/dist/build.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { bundle, canonical, inside } from './bundle.js';
|
|
6
|
+
import { Diagnostics } from './diagnostics.js';
|
|
7
|
+
import { computeBuildId, computeDepsHash, computeIslandsHash, gzipSize, islandPrefix, sha384Integrity, } from './hashing.js';
|
|
8
|
+
import { install } from './install.js';
|
|
9
|
+
import { collectLocalFiles, runtimeImports } from './local-files.js';
|
|
10
|
+
import { findLockfile, lockfileKind, validateLockfile, validateRegistries } from './lockfile.js';
|
|
11
|
+
import { dependencySpecs, readPackageJson, validatePackageJson } from './package-json.js';
|
|
12
|
+
import { checkDuplicateReact, checkPeerRanges, checkReactVersions, reactPackageDirs, readReactInfo, scanRemovedApis, } from './react.js';
|
|
13
|
+
import { renderEach, sandboxAvailable } from './render.js';
|
|
14
|
+
import { sanitizeIslandHtml, stateIdRenames } from './sanitize.js';
|
|
15
|
+
import { BUILDER_VERSION } from './version.js';
|
|
16
|
+
export const MAX_SERVER_BUNDLE_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
export const MAX_CLIENT_GZIP_BYTES = 5 * 1024 * 1024;
|
|
18
|
+
async function walk(dir) {
|
|
19
|
+
if (!existsSync(dir))
|
|
20
|
+
return [];
|
|
21
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
22
|
+
const nested = await Promise.all(entries.map((e) => e.isDirectory() ? walk(join(dir, e.name)) : Promise.resolve([join(dir, e.name)])));
|
|
23
|
+
return nested.flat().sort();
|
|
24
|
+
}
|
|
25
|
+
function toPosix(path) {
|
|
26
|
+
return path.split(sep).join('/');
|
|
27
|
+
}
|
|
28
|
+
function installedVersions(installRoot, names) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const name of [...names].sort()) {
|
|
31
|
+
try {
|
|
32
|
+
const json = JSON.parse(readFileSync(join(installRoot, 'node_modules', name, 'package.json'), 'utf8'));
|
|
33
|
+
if (json.version)
|
|
34
|
+
out[name] = { version: json.version };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function closureGzip(metafile, srcDir, gzipByPath) {
|
|
43
|
+
const outputs = new Map(Object.entries(metafile.outputs).map(([key, value]) => [resolve(srcDir, key), value]));
|
|
44
|
+
const result = {};
|
|
45
|
+
for (const [path, output] of outputs) {
|
|
46
|
+
const match = /[\\/]islands[\\/]([^\\/]+)\.js$/.exec(path);
|
|
47
|
+
if (!output.entryPoint || !match)
|
|
48
|
+
continue;
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
const stack = [path];
|
|
51
|
+
let total = 0;
|
|
52
|
+
while (stack.length > 0) {
|
|
53
|
+
const current = stack.pop();
|
|
54
|
+
if (seen.has(current))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(current);
|
|
57
|
+
total += gzipByPath.get(current) ?? 0;
|
|
58
|
+
for (const imp of outputs.get(current)?.imports ?? []) {
|
|
59
|
+
if (imp.kind === 'import-statement')
|
|
60
|
+
stack.push(resolve(srcDir, imp.path));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
result[match[1]] = total;
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
68
|
+
function resolveHashes(input, computed, diag) {
|
|
69
|
+
const expected = input.expected;
|
|
70
|
+
if (expected === undefined)
|
|
71
|
+
return computed;
|
|
72
|
+
if (!HASH_PATTERN.test(expected.depsHash) || !HASH_PATTERN.test(expected.islandsHash)) {
|
|
73
|
+
diag.error('expected-hash-invalid', 'The build job sent expected hashes that are not 64-character lowercase hex SHA-256 digests.');
|
|
74
|
+
return computed;
|
|
75
|
+
}
|
|
76
|
+
if (expected.depsHash !== computed.depsHash) {
|
|
77
|
+
diag.error('deps-hash-mismatch', `The build job's depsHash ${expected.depsHash} does not match the builder's ${computed.depsHash} ` +
|
|
78
|
+
`(@mintlify/package-builder ${BUILDER_VERSION}). The job was hashed from a different lockfile, ` +
|
|
79
|
+
'dependencies, registries or builder version; check that the server sends the builder version ' +
|
|
80
|
+
'installed in the build image.');
|
|
81
|
+
}
|
|
82
|
+
if (expected.islandsHash !== computed.islandsHash) {
|
|
83
|
+
diag.error('islands-hash-mismatch', `The build job's islandsHash ${expected.islandsHash} does not match the builder's ${computed.islandsHash}. ` +
|
|
84
|
+
'The job was hashed from different islands, snippet files or packages settings.');
|
|
85
|
+
}
|
|
86
|
+
return { depsHash: expected.depsHash, islandsHash: expected.islandsHash };
|
|
87
|
+
}
|
|
88
|
+
function downgrade(diagnostics) {
|
|
89
|
+
return diagnostics.map((d) => d.level === 'error'
|
|
90
|
+
? { ...d, level: 'warning', message: `${d.message} Deploys fail until this is fixed.` }
|
|
91
|
+
: d);
|
|
92
|
+
}
|
|
93
|
+
export async function build(input) {
|
|
94
|
+
const started = performance.now();
|
|
95
|
+
const diag = new Diagnostics();
|
|
96
|
+
const packages = input.packages;
|
|
97
|
+
const docsRoot = resolve(input.docsRoot);
|
|
98
|
+
const outDir = resolve(input.outDir);
|
|
99
|
+
const local = input.installer === 'local';
|
|
100
|
+
const stats = {
|
|
101
|
+
installMs: 0,
|
|
102
|
+
bundleMs: 0,
|
|
103
|
+
renderMs: 0,
|
|
104
|
+
totalMs: 0,
|
|
105
|
+
clientBytes: 0,
|
|
106
|
+
clientGzipBytes: 0,
|
|
107
|
+
serverBytes: 0,
|
|
108
|
+
islandClientGzipBytes: {},
|
|
109
|
+
reactDirs: { react: [], 'react-dom': [] },
|
|
110
|
+
};
|
|
111
|
+
if (packages.isolation === 'frame') {
|
|
112
|
+
diag.error('isolation-frame-not-implemented', 'packages.isolation "frame" is not implemented yet. Remove it or set it to "page".');
|
|
113
|
+
}
|
|
114
|
+
const registryErrors = validateRegistries(packages.registries);
|
|
115
|
+
diag.addAll(registryErrors);
|
|
116
|
+
const pkg = readPackageJson(docsRoot, { allowSymlink: local });
|
|
117
|
+
diag.addAll(pkg.diagnostics);
|
|
118
|
+
const packageJson = pkg.packageJson;
|
|
119
|
+
if (packageJson) {
|
|
120
|
+
const specProblems = validatePackageJson(packageJson);
|
|
121
|
+
diag.addAll(local ? downgrade(specProblems) : specProblems);
|
|
122
|
+
}
|
|
123
|
+
const dependencies = packageJson ? dependencySpecs(packageJson, 'dependencies') : {};
|
|
124
|
+
const found = findLockfile(docsRoot, { allowSymlink: local });
|
|
125
|
+
diag.addAll(local ? downgrade(found.diagnostics) : found.diagnostics);
|
|
126
|
+
let expectedVersions;
|
|
127
|
+
if (found.lockfile && registryErrors.length === 0) {
|
|
128
|
+
const validation = validateLockfile({
|
|
129
|
+
fileName: found.lockfile.fileName,
|
|
130
|
+
content: found.lockfile.content,
|
|
131
|
+
registries: packages.registries,
|
|
132
|
+
packageJson,
|
|
133
|
+
});
|
|
134
|
+
diag.addAll(local ? downgrade(validation.diagnostics) : validation.diagnostics);
|
|
135
|
+
expectedVersions = validation.topLevel;
|
|
136
|
+
}
|
|
137
|
+
const localFiles = collectLocalFiles({
|
|
138
|
+
docsRoot,
|
|
139
|
+
islands: input.islands,
|
|
140
|
+
snippetFiles: input.snippetFiles,
|
|
141
|
+
provider: packages.provider,
|
|
142
|
+
rejectHardLinks: !local,
|
|
143
|
+
});
|
|
144
|
+
for (const problem of await runtimeImports(localFiles, input.islands))
|
|
145
|
+
diag.error('import-runtime-path', problem);
|
|
146
|
+
const { depsHash, islandsHash } = resolveHashes(input, {
|
|
147
|
+
depsHash: computeDepsHash({
|
|
148
|
+
lockfileBytes: found.lockfile?.content ?? '',
|
|
149
|
+
lockfileKind: found.lockfile ? lockfileKind(found.lockfile.fileName) : 'npm',
|
|
150
|
+
dependencies,
|
|
151
|
+
registries: packages.registries,
|
|
152
|
+
builderVersion: BUILDER_VERSION,
|
|
153
|
+
}),
|
|
154
|
+
islandsHash: computeIslandsHash({
|
|
155
|
+
islands: input.islands,
|
|
156
|
+
snippetFiles: localFiles,
|
|
157
|
+
provider: packages.provider,
|
|
158
|
+
css: packages.css,
|
|
159
|
+
ssr: packages.ssr,
|
|
160
|
+
isolation: packages.isolation,
|
|
161
|
+
}),
|
|
162
|
+
}, diag);
|
|
163
|
+
const manifest = {
|
|
164
|
+
version: 1,
|
|
165
|
+
builderVersion: BUILDER_VERSION,
|
|
166
|
+
buildId: computeBuildId(depsHash, islandsHash),
|
|
167
|
+
depsHash,
|
|
168
|
+
islandsHash,
|
|
169
|
+
react: { version: '', domVersion: '' },
|
|
170
|
+
packages: {},
|
|
171
|
+
css: [],
|
|
172
|
+
islands: {},
|
|
173
|
+
files: [],
|
|
174
|
+
diagnostics: diag.list,
|
|
175
|
+
};
|
|
176
|
+
if (inside(canonical(outDir), canonical(docsRoot))) {
|
|
177
|
+
diag.error('out-dir-invalid', `The output folder ${outDir} contains the docs folder and would be deleted; use an empty folder.`);
|
|
178
|
+
stats.totalMs = performance.now() - started;
|
|
179
|
+
return { ok: false, manifest, stats };
|
|
180
|
+
}
|
|
181
|
+
await rm(outDir, { recursive: true, force: true });
|
|
182
|
+
await mkdir(outDir, { recursive: true });
|
|
183
|
+
const finish = async () => {
|
|
184
|
+
stats.totalMs = performance.now() - started;
|
|
185
|
+
await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
186
|
+
return { ok: !diag.hasErrors(), manifest, stats };
|
|
187
|
+
};
|
|
188
|
+
if (diag.hasErrors())
|
|
189
|
+
return finish();
|
|
190
|
+
const installed = await install({
|
|
191
|
+
docsRoot,
|
|
192
|
+
installer: input.installer,
|
|
193
|
+
lockfile: found.lockfile,
|
|
194
|
+
packageJson,
|
|
195
|
+
registries: packages.registries,
|
|
196
|
+
registryTokens: input.registryTokens,
|
|
197
|
+
packageManagers: input.packageManagers,
|
|
198
|
+
expectedVersions,
|
|
199
|
+
});
|
|
200
|
+
stats.installMs = installed.durationMs;
|
|
201
|
+
diag.addAll(installed.diagnostics);
|
|
202
|
+
let srcDir;
|
|
203
|
+
try {
|
|
204
|
+
if (!installed.ok)
|
|
205
|
+
return await finish();
|
|
206
|
+
const react = readReactInfo(installed.installRoot);
|
|
207
|
+
diag.addAll(react.diagnostics);
|
|
208
|
+
if (react.info) {
|
|
209
|
+
manifest.react = { version: react.info.version, domVersion: react.info.domVersion };
|
|
210
|
+
diag.addAll(checkReactVersions(react.info.version, react.info.domVersion));
|
|
211
|
+
}
|
|
212
|
+
manifest.packages = installedVersions(installed.installRoot, Object.keys(dependencies));
|
|
213
|
+
if (diag.hasErrors() || !react.info)
|
|
214
|
+
return await finish();
|
|
215
|
+
const bundled = await bundle({
|
|
216
|
+
installRoot: installed.installRoot,
|
|
217
|
+
islands: input.islands,
|
|
218
|
+
snippetFiles: localFiles,
|
|
219
|
+
packages,
|
|
220
|
+
outDir,
|
|
221
|
+
workParent: input.installer === 'npm-ci' ? installed.installRoot : undefined,
|
|
222
|
+
hoistedNodeModules: local,
|
|
223
|
+
rejectHardLinks: !local,
|
|
224
|
+
});
|
|
225
|
+
srcDir = bundled.srcDir;
|
|
226
|
+
stats.bundleMs = bundled.durationMs;
|
|
227
|
+
diag.addAll(bundled.diagnostics);
|
|
228
|
+
for (const replaced of bundled.replacedReact) {
|
|
229
|
+
diag.warn('react-alias-replaced', `${replaced.pkg} ships its own React${replaced.version ? ` ${replaced.version}` : ''}; it was replaced with the project's react ${react.info.version}. Move react to its peerDependencies.`, replaced.pkg);
|
|
230
|
+
}
|
|
231
|
+
if (bundled.client) {
|
|
232
|
+
const dirs = reactPackageDirs(bundled.client, bundled.srcDir);
|
|
233
|
+
stats.reactDirs = { react: dirs['react'] ?? [], 'react-dom': dirs['react-dom'] ?? [] };
|
|
234
|
+
diag.addAll(checkDuplicateReact(bundled.client, bundled.srcDir, 'client'));
|
|
235
|
+
diag.addAll(scanRemovedApis(bundled.client, bundled.srcDir, react.info.version));
|
|
236
|
+
diag.addAll(checkPeerRanges(bundled.client, bundled.srcDir, react.info));
|
|
237
|
+
}
|
|
238
|
+
if (bundled.server)
|
|
239
|
+
diag.addAll(checkDuplicateReact(bundled.server, bundled.srcDir, 'server'));
|
|
240
|
+
const clientFiles = await walk(join(outDir, 'client'));
|
|
241
|
+
const gzipByPath = new Map();
|
|
242
|
+
const files = [];
|
|
243
|
+
for (const file of clientFiles) {
|
|
244
|
+
const data = new Uint8Array(await readFile(file));
|
|
245
|
+
const gz = gzipSize(data);
|
|
246
|
+
gzipByPath.set(file, gz);
|
|
247
|
+
files.push({
|
|
248
|
+
path: toPosix(relative(outDir, file)),
|
|
249
|
+
bytes: data.length,
|
|
250
|
+
gzipBytes: gz,
|
|
251
|
+
integrity: sha384Integrity(data),
|
|
252
|
+
});
|
|
253
|
+
stats.clientBytes += data.length;
|
|
254
|
+
stats.clientGzipBytes += gz;
|
|
255
|
+
if (file === join(outDir, 'client', 'styles.css')) {
|
|
256
|
+
manifest.css.push({
|
|
257
|
+
path: 'client/styles.css',
|
|
258
|
+
integrity: sha384Integrity(data),
|
|
259
|
+
bytes: data.length,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
manifest.files = files;
|
|
264
|
+
for (const file of await walk(join(outDir, 'server')))
|
|
265
|
+
stats.serverBytes += (await readFile(file)).length;
|
|
266
|
+
if (bundled.client)
|
|
267
|
+
stats.islandClientGzipBytes = closureGzip(bundled.client, bundled.srcDir, gzipByPath);
|
|
268
|
+
if (stats.clientGzipBytes > MAX_CLIENT_GZIP_BYTES) {
|
|
269
|
+
diag.error('client-bundle-too-large', `Client bundles are ${stats.clientGzipBytes} bytes gzipped; the limit is ${MAX_CLIENT_GZIP_BYTES}.`);
|
|
270
|
+
}
|
|
271
|
+
for (const island of input.islands) {
|
|
272
|
+
manifest.islands[island.id] = {
|
|
273
|
+
entry: `client/islands/${island.id}.js`,
|
|
274
|
+
prefix: islandPrefix(island.id),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (diag.hasErrors() || input.islands.length === 0)
|
|
278
|
+
return await finish();
|
|
279
|
+
if (packages.ssr === false)
|
|
280
|
+
return await finish();
|
|
281
|
+
if (stats.serverBytes > MAX_SERVER_BUNDLE_BYTES) {
|
|
282
|
+
diag.warn('ssr-skipped-large-server-bundle', `The server bundle is ${stats.serverBytes} bytes, over ${MAX_SERVER_BUNDLE_BYTES}; islands render in the browser only.`);
|
|
283
|
+
return await finish();
|
|
284
|
+
}
|
|
285
|
+
const renderStart = performance.now();
|
|
286
|
+
const pageOf = new Map(input.islands.map((i) => [i.id, i.pagePath]));
|
|
287
|
+
if (!sandboxAvailable()) {
|
|
288
|
+
const message = 'This Node.js version has no permission model, so island code runs on the server without a sandbox. Use Node.js 20 or later.';
|
|
289
|
+
if (local)
|
|
290
|
+
diag.warn('render-sandbox-unavailable', message);
|
|
291
|
+
else {
|
|
292
|
+
diag.error('render-sandbox-unavailable', message);
|
|
293
|
+
return await finish();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for await (const outcome of renderEach({
|
|
297
|
+
serverDir: join(outDir, 'server'),
|
|
298
|
+
islands: input.islands.map((i) => ({ id: i.id, prefix: islandPrefix(i.id) })),
|
|
299
|
+
requireSandbox: !local,
|
|
300
|
+
...(input.renderMemoryLimitBytes !== undefined
|
|
301
|
+
? { memoryLimitBytes: input.renderMemoryLimitBytes }
|
|
302
|
+
: {}),
|
|
303
|
+
})) {
|
|
304
|
+
const where = `Island ${outcome.id} (${pageOf.get(outcome.id) ?? 'unknown page'})`;
|
|
305
|
+
const entry = manifest.islands[outcome.id];
|
|
306
|
+
if (!entry)
|
|
307
|
+
continue;
|
|
308
|
+
for (const warning of outcome.warnings.slice(0, 3))
|
|
309
|
+
diag.warn('island-ssr-warning', `${where} logged a warning while rendering: ${warning}`);
|
|
310
|
+
if (!outcome.ok) {
|
|
311
|
+
const code = /longer than/.test(outcome.error) ? 'island-ssr-timeout' : 'island-ssr-error';
|
|
312
|
+
diag.warn(code, `${where} failed to render on the server, so it renders in the browser only: ${outcome.error}`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
for (const error of outcome.errors.slice(0, 3))
|
|
316
|
+
diag.warn('island-ssr-error', `${where} logged an error while rendering: ${error}`);
|
|
317
|
+
entry.ssrMs = Math.round(outcome.ssrMs * 100) / 100;
|
|
318
|
+
const prefix = islandPrefix(outcome.id);
|
|
319
|
+
const renames = stateIdRenames(outcome.html, outcome.second, prefix);
|
|
320
|
+
const sanitized = sanitizeIslandHtml(outcome.html, prefix, renames);
|
|
321
|
+
if (sanitized.tooLarge) {
|
|
322
|
+
diag.warn('island-html-too-large', `${where} rendered ${sanitized.bytes} bytes of HTML, over the 256 KB cap; it renders in the browser only.`);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
entry.html = sanitized.html;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
stats.renderMs = performance.now() - renderStart;
|
|
329
|
+
return await finish();
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
if (!process.env.MINT_PACKAGE_BUILDER_KEEP_TMP) {
|
|
333
|
+
await installed.cleanup();
|
|
334
|
+
if (srcDir)
|
|
335
|
+
await rm(srcDir, { recursive: true, force: true });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as esbuild from 'esbuild';
|
|
2
|
+
import { type PlatformPath } from 'node:path';
|
|
3
|
+
import type { LocalFiles } from './local-files.js';
|
|
4
|
+
import { type ReplacedReact } from './react.js';
|
|
5
|
+
import type { BuildDiagnostic, IslandSpec, PackagesConfig } from './types.js';
|
|
6
|
+
export declare const ISLAND_ID: RegExp;
|
|
7
|
+
export declare const ISLANDS_HOOKS_SPECIFIER = "@mintlify/islands";
|
|
8
|
+
export declare const URL_LIKE: RegExp;
|
|
9
|
+
export type BundleOptions = {
|
|
10
|
+
installRoot: string;
|
|
11
|
+
islands: IslandSpec[];
|
|
12
|
+
snippetFiles: LocalFiles;
|
|
13
|
+
packages: PackagesConfig;
|
|
14
|
+
outDir: string;
|
|
15
|
+
workParent?: string;
|
|
16
|
+
hoistedNodeModules?: boolean;
|
|
17
|
+
rejectHardLinks?: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type BundleResult = {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
diagnostics: BuildDiagnostic[];
|
|
22
|
+
durationMs: number;
|
|
23
|
+
srcDir: string;
|
|
24
|
+
client?: esbuild.Metafile;
|
|
25
|
+
server?: esbuild.Metafile;
|
|
26
|
+
css?: esbuild.Metafile;
|
|
27
|
+
replacedReact: ReplacedReact[];
|
|
28
|
+
};
|
|
29
|
+
type PathOps = Pick<PlatformPath, 'relative' | 'isAbsolute' | 'resolve'>;
|
|
30
|
+
export declare function inside(parent: string, child: string, ops?: PathOps): boolean;
|
|
31
|
+
export declare function canonical(path: string): string;
|
|
32
|
+
export declare function within(roots: string[], real?: (path: string) => string, ops?: PathOps): (path: string) => boolean;
|
|
33
|
+
export declare function docsPathToRelative(path: string): string | undefined;
|
|
34
|
+
export declare function docsTarget(docsDir: string, path: string, ops?: PathOps): string | undefined;
|
|
35
|
+
export declare function shownPath(path: string, docsDir: string, ops?: PathOps): string;
|
|
36
|
+
export declare function formatMessages(messages: {
|
|
37
|
+
text: string;
|
|
38
|
+
location: {
|
|
39
|
+
file: string;
|
|
40
|
+
line: number;
|
|
41
|
+
} | null;
|
|
42
|
+
}[], srcDir: string, islands: Pick<IslandSpec, 'id' | 'pagePath'>[], ops?: PathOps): string[];
|
|
43
|
+
export declare function bundle(opts: BundleOptions): Promise<BundleResult>;
|
|
44
|
+
export {};
|