mirror of https://github.com/vuejs/vitepress
chore: migrate build to tsdown (#5396)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>pull/4957/merge
parent
5a368d3198
commit
357b072a47
@ -0,0 +1,18 @@
|
||||
diff --git a/dist/rolldown.mjs b/dist/rolldown.mjs
|
||||
index 7e04d96c703656b81220193b1f01426778b6a42f..26e96378fc0ad985237302e673ed0ab07674bf41 100644
|
||||
--- a/dist/rolldown.mjs
|
||||
+++ b/dist/rolldown.mjs
|
||||
@@ -196,10 +196,11 @@ function resolveCache(options) {
|
||||
async function transpileScript(code, filename = "__sfc.ts") {
|
||||
const result = await transform(filename, code, {
|
||||
lang: "ts",
|
||||
- sourcemap: false
|
||||
+ sourcemap: false,
|
||||
+ typescript: { onlyRemoveTypeImports: true }
|
||||
});
|
||||
if (result.errors.length) throw new AggregateError(result.errors, `[vue-sfc-transformer] failed to transpile script in ${filename}`);
|
||||
- return result.code ?? code;
|
||||
+ return (result.code ?? code).replace(/\n?export \{\};?[\s\n]*$/, "");
|
||||
}
|
||||
function vueSfcPlugin(pluginOptions) {
|
||||
const cwd = pluginOptions.cwd ?? process.cwd();
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,115 +0,0 @@
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { builtinModules } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import alias from '@rollup/plugin-alias'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
import json from '@rollup/plugin-json'
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve'
|
||||
import replace from '@rollup/plugin-replace'
|
||||
import { type RollupOptions, defineConfig } from 'rollup'
|
||||
import dts from 'rollup-plugin-dts'
|
||||
import esbuild from 'rollup-plugin-esbuild'
|
||||
|
||||
import pkg from './package.json' with { type: 'json' }
|
||||
|
||||
const DEV = !!process.env.DEV
|
||||
const PROD = !DEV
|
||||
|
||||
const external = [
|
||||
...Object.keys(pkg.dependencies),
|
||||
...Object.keys(pkg.peerDependencies),
|
||||
...builtinModules.flatMap((m) =>
|
||||
m.includes('punycode') ? [] : [m, `node:${m}`]
|
||||
)
|
||||
]
|
||||
|
||||
const plugins = [
|
||||
alias({ entries: { 'readable-stream': 'stream' } }),
|
||||
replace({
|
||||
// polyfill broken browser check from bundled deps
|
||||
'navigator.userAgentData': 'undefined',
|
||||
'navigator.userAgent': 'undefined',
|
||||
preventAssignment: true
|
||||
}),
|
||||
commonjs(),
|
||||
nodeResolve({ preferBuiltins: false }),
|
||||
esbuild({ target: 'node22' }),
|
||||
json()
|
||||
]
|
||||
|
||||
const esmBuild: RollupOptions = {
|
||||
input: ['src/node/index.ts', 'src/node/cli.ts'],
|
||||
output: {
|
||||
format: 'esm',
|
||||
entryFileNames: `[name].js`,
|
||||
chunkFileNames: 'chunk-[hash].js',
|
||||
dir: 'dist/node',
|
||||
sourcemap: DEV
|
||||
},
|
||||
external,
|
||||
plugins,
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code !== 'EVAL') warn(warning)
|
||||
}
|
||||
}
|
||||
|
||||
// keep .d.ts files under the repo root (e.g. types/*) external so module
|
||||
// augmentations in the bundle still target the same files users reference.
|
||||
// compared on normalized resolved paths so this works regardless of the
|
||||
// checkout location, path separators, or drive-letter casing.
|
||||
const normalizePath = (id: string): string => {
|
||||
const normalized = id.replaceAll('\\', '/')
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
const root = normalizePath(fileURLToPath(new URL('.', import.meta.url)))
|
||||
|
||||
const typesExternal = (id: string): boolean => {
|
||||
if (external.includes(id) || /^markdown-it(?:\/|$)/.test(id)) return true
|
||||
const normalized = normalizePath(id)
|
||||
return (
|
||||
normalized.endsWith('.d.ts') &&
|
||||
normalized.startsWith(root) &&
|
||||
!normalized.startsWith(`${root}dist/`) &&
|
||||
!normalized.startsWith(`${root}node_modules/`)
|
||||
)
|
||||
}
|
||||
|
||||
const dtsNode = dts({
|
||||
respectExternal: true,
|
||||
tsconfig: 'src/node/tsconfig.json',
|
||||
compilerOptions: { preserveSymlinks: false }
|
||||
})
|
||||
|
||||
const nodeTypes: RollupOptions = {
|
||||
input: 'src/node/index.ts',
|
||||
output: {
|
||||
format: 'esm',
|
||||
file: 'dist/node/index.d.ts'
|
||||
},
|
||||
external: typesExternal,
|
||||
plugins: [dtsNode]
|
||||
}
|
||||
|
||||
const clientTypes: RollupOptions = {
|
||||
input: 'dist/client-types/index.d.ts',
|
||||
output: {
|
||||
format: 'esm',
|
||||
file: 'dist/client/index.d.ts'
|
||||
},
|
||||
external: typesExternal,
|
||||
plugins: [
|
||||
dts({ respectExternal: true }),
|
||||
{
|
||||
name: 'cleanup',
|
||||
async closeBundle() {
|
||||
if (PROD) {
|
||||
await rm('dist/client-types', { recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig([esmBuild, nodeTypes, clientTypes])
|
||||
@ -1,12 +0,0 @@
|
||||
import { cp } from 'node:fs/promises'
|
||||
|
||||
import { globSync } from 'tinyglobby'
|
||||
|
||||
function toDest(file: string) {
|
||||
return file.replace(/^src\//, 'dist/')
|
||||
}
|
||||
|
||||
globSync(['src/client/**']).forEach((file) => {
|
||||
if (/(\.ts|tsconfig\.json)$/.test(file)) return
|
||||
cp(file, toDest(file))
|
||||
})
|
||||
@ -1,10 +0,0 @@
|
||||
import { cp } from 'node:fs/promises'
|
||||
|
||||
import { globSync } from 'tinyglobby'
|
||||
|
||||
globSync(['src/shared/**/*.ts']).forEach(async (file) => {
|
||||
await Promise.all([
|
||||
cp(file, file.replace(/^src\/shared\//, 'src/node/')),
|
||||
cp(file, file.replace(/^src\/shared\//, 'src/client/'))
|
||||
])
|
||||
})
|
||||
@ -1,38 +0,0 @@
|
||||
import { cp, rm } from 'node:fs/promises'
|
||||
|
||||
import { watch } from 'chokidar'
|
||||
import { normalizePath } from 'vite'
|
||||
|
||||
function toClientAndNode(method: 'copy' | 'remove', file: string) {
|
||||
file = normalizePath(file)
|
||||
if (method === 'copy') {
|
||||
cp(file, file.replace(/^src\/shared\//, 'src/node/'))
|
||||
cp(file, file.replace(/^src\/shared\//, 'src/client/'))
|
||||
} else if (method === 'remove') {
|
||||
rm(file.replace(/^src\/shared\//, 'src/node/'), { force: true })
|
||||
rm(file.replace(/^src\/shared\//, 'src/client/'), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function toDist(file: string) {
|
||||
return normalizePath(file).replace(/^src\//, 'dist/')
|
||||
}
|
||||
|
||||
// copy shared files to the client and node directory whenever they change.
|
||||
watch('src/shared', {
|
||||
ignored: (path, stats) => !!stats?.isFile() && !path.endsWith('.ts')
|
||||
})
|
||||
.on('change', (file) => toClientAndNode('copy', file))
|
||||
.on('add', (file) => toClientAndNode('copy', file))
|
||||
.on('unlink', (file) => toClientAndNode('remove', file))
|
||||
|
||||
// copy non ts files, such as an html or css, to the dist directory whenever
|
||||
// they change.
|
||||
watch('src/client', {
|
||||
ignored: (path, stats) =>
|
||||
!!stats?.isFile() &&
|
||||
(path.endsWith('.ts') || path.endsWith('tsconfig.json'))
|
||||
})
|
||||
.on('change', (file) => cp(file, toDist(file)))
|
||||
.on('add', (file) => cp(file, toDist(file)))
|
||||
.on('unlink', (file) => rm(toDist(file), { force: true }))
|
||||
@ -0,0 +1,13 @@
|
||||
// Cross-environment globals that environment-neutral code may use, declared
|
||||
// merge-compatibly with lib.dom and @types/node (interface merging plus an
|
||||
// identically named var). Loaded only by projects without a DOM lib; keep
|
||||
// members to what shared code actually touches.
|
||||
|
||||
interface Console {
|
||||
debug(...data: unknown[]): void
|
||||
warn(...data: unknown[]): void
|
||||
}
|
||||
declare var console: Console
|
||||
|
||||
interface Document {}
|
||||
declare var document: Document
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/client",
|
||||
"declaration": true,
|
||||
"declarationDir": "../../dist/client-types",
|
||||
"types": ["../../client.d.ts", "@types/node"],
|
||||
"paths": {
|
||||
"vitepress": ["./index.ts"],
|
||||
"vitepress/theme": ["../../theme.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/node",
|
||||
"types": ["node"],
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["esnext", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"template",
|
||||
"bin",
|
||||
"docs/snippets"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["./client.d.ts"],
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"vitepress": ["./src/client/index.ts"],
|
||||
// default-theme.d.ts instead of theme.d.ts so type checks and the .vue
|
||||
// declaration emit don't depend on a built dist.
|
||||
"vitepress/theme": ["./types/default-theme.d.ts"],
|
||||
// dev-server virtual modules, resolved onto their ambient shims
|
||||
"@siteData": ["./src/client/shims.d.ts"],
|
||||
"@theme/index": ["./src/client/shims.d.ts"],
|
||||
"@localSearchIndex": ["./src/client/shims.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/client"]
|
||||
}
|
||||
@ -1,22 +1,3 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"jsx": "preserve",
|
||||
"lib": ["esnext", "dom", "dom.iterable"]
|
||||
},
|
||||
"exclude": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"template",
|
||||
"bin",
|
||||
"docs/snippets"
|
||||
]
|
||||
"extends": "./tsconfig.base.json"
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/node", "scripts", "tsdown.config.ts", "shared-globals.d.ts"]
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"],
|
||||
"types": [],
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true
|
||||
},
|
||||
"include": ["src/shared", "shared-globals.d.ts"]
|
||||
}
|
||||
@ -0,0 +1,241 @@
|
||||
import { readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { defineConfig, type Rolldown, type UserConfig } from 'tsdown'
|
||||
import { vueSfcPlugin } from 'vue-sfc-transformer/rolldown'
|
||||
|
||||
const ROOT = import.meta.dirname
|
||||
|
||||
// wipe all of dist (not just the outDirs) once per invocation; the configs
|
||||
// set `clean: false` so watch-mode rebuilds never clear it mid-session
|
||||
rmSync(path.join(ROOT, 'dist'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10
|
||||
})
|
||||
|
||||
const normalizePath = (p: string): string => {
|
||||
const normalized = p.replaceAll('\\', '/')
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
const TYPES_DIR = normalizePath(path.join(ROOT, 'types')) + '/'
|
||||
|
||||
function isRootTypes(id: string, importer: string | undefined): boolean {
|
||||
if (!importer || !/^\.\.?\//.test(id)) return false
|
||||
const resolved = normalizePath(path.resolve(path.dirname(importer), id))
|
||||
return resolved.startsWith(TYPES_DIR)
|
||||
}
|
||||
|
||||
// keep .d.ts files under types/* external so module augmentations in the
|
||||
// output still target the same files users reference, spelled with the .js
|
||||
// extension so they resolve under node16 too
|
||||
function rootTypesSpecifiers(): Rolldown.Plugin {
|
||||
return {
|
||||
name: 'vitepress:root-types-specifiers',
|
||||
resolveId: {
|
||||
order: 'pre',
|
||||
handler(id, importer) {
|
||||
if (
|
||||
importer &&
|
||||
/\.d\.[cm]?ts$/.test(importer) &&
|
||||
isRootTypes(id, importer)
|
||||
) {
|
||||
return { id: id.replace(/\.js$/, '') + '.js', external: 'relative' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// src/shared is compiled twice, once per environment, via gitignored copies.
|
||||
// Copies are written only when their content differs — they are watched
|
||||
// modules, so an unconditional write on every buildStart would retrigger the
|
||||
// watcher in an endless loop.
|
||||
function syncSharedFiles(
|
||||
dest: 'client' | 'node',
|
||||
track?: (file: string) => void
|
||||
): void {
|
||||
const src = path.join(ROOT, 'src/shared')
|
||||
for (const entry of readdirSync(src, {
|
||||
recursive: true,
|
||||
encoding: 'utf8'
|
||||
})) {
|
||||
if (!entry.endsWith('.ts')) continue
|
||||
const file = path.join(src, entry)
|
||||
track?.(file)
|
||||
const content = readFileSync(file)
|
||||
const copy = path.join(ROOT, 'src', dest, entry)
|
||||
let stale = true
|
||||
try {
|
||||
stale = !content.equals(readFileSync(copy))
|
||||
} catch {}
|
||||
if (stale) writeFileSync(copy, content)
|
||||
}
|
||||
}
|
||||
|
||||
// seed the copies now, before the entry globs below are resolved
|
||||
syncSharedFiles('client')
|
||||
syncSharedFiles('node')
|
||||
|
||||
// keeps the copies fresh across watch-mode rebuilds
|
||||
function syncShared(dest: 'client' | 'node'): Rolldown.Plugin {
|
||||
return {
|
||||
name: 'vitepress:sync-shared',
|
||||
buildStart() {
|
||||
syncSharedFiles(dest, (file) => this.addWatchFile(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ships styles and fonts as-is, keeps css imports as relative specifiers in
|
||||
// the output for the consumer's bundler, and drops them from declaration
|
||||
// files where they would dangle.
|
||||
function clientAssets(): Rolldown.Plugin {
|
||||
const src = path.join(ROOT, 'src/client')
|
||||
return {
|
||||
name: 'vitepress:client-assets',
|
||||
resolveId(id) {
|
||||
if (id.endsWith('.css') && id[0] === '.') {
|
||||
return { id, external: 'relative' }
|
||||
}
|
||||
},
|
||||
buildStart() {
|
||||
for (const entry of readdirSync(src, {
|
||||
recursive: true,
|
||||
encoding: 'utf8'
|
||||
})) {
|
||||
if (!/\.(css|woff2)$/.test(entry)) continue
|
||||
const file = path.join(src, entry)
|
||||
this.addWatchFile(file)
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: normalizePath(entry),
|
||||
source: readFileSync(file)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Declarations must resolve under node16, so relative specifiers need the .js
|
||||
// extension: vue-tsc keeps the SFC's extensionless ones in .d.vue.ts files,
|
||||
// and rolldown-plugin-dts drops the extension from chunk imports on Windows.
|
||||
// Dangling side-effect css imports are removed along the way.
|
||||
function fixDeclarationSpecifiers(): Rolldown.Plugin {
|
||||
const stripCssImports = (code: string) =>
|
||||
code.replace(/^\s*import\s+["'][^"']+\.css["'];?\s*\r?\n/gm, '')
|
||||
const addJsExtensions = (code: string) =>
|
||||
code.replace(
|
||||
/(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(\.\.?\/[^'"]*?)\2/g,
|
||||
(match, keyword, quote, spec) =>
|
||||
/\.[^/.]+$/.test(spec) ? match : `${keyword}${quote}${spec}.js${quote}`
|
||||
)
|
||||
const fix = (code: string) => addJsExtensions(stripCssImports(code))
|
||||
return {
|
||||
name: 'vitepress:fix-declaration-specifiers',
|
||||
generateBundle(_options, bundle) {
|
||||
for (const file of Object.values(bundle)) {
|
||||
if (!/\.d\.(?:vue\.)?ts$/.test(file.fileName)) continue
|
||||
if (file.type === 'chunk') file.code = fix(file.code)
|
||||
else if (typeof file.source === 'string') file.source = fix(file.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuilds re-emit every output; dropping byte-identical files before they
|
||||
// are written keeps their mtimes stable, so watchers of dist (the docs dev
|
||||
// server's vite) only react to files that actually changed.
|
||||
function skipUnchanged(): Rolldown.Plugin {
|
||||
return {
|
||||
name: 'vitepress:skip-unchanged',
|
||||
generateBundle: {
|
||||
order: 'post',
|
||||
handler(options, bundle) {
|
||||
for (const [key, file] of Object.entries(bundle)) {
|
||||
const next =
|
||||
file.type === 'chunk'
|
||||
? Buffer.from(file.code)
|
||||
: Buffer.from(file.source)
|
||||
try {
|
||||
const existing = readFileSync(
|
||||
path.resolve(ROOT, options.dir ?? '.', file.fileName)
|
||||
)
|
||||
if (existing.equals(next)) delete bundle[key]
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withStableOutputs(config: UserConfig): UserConfig {
|
||||
return {
|
||||
...config,
|
||||
clean: false,
|
||||
plugins: [
|
||||
...(config.plugins as Rolldown.Plugin[]),
|
||||
fixDeclarationSpecifiers(),
|
||||
skipUnchanged()
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const client: UserConfig = {
|
||||
entry: ['src/client/**/*.ts', '!src/client/**/*.d.ts'],
|
||||
outDir: 'dist/client',
|
||||
platform: 'neutral',
|
||||
unbundle: true,
|
||||
fixedExtension: false,
|
||||
dts: { vue: true },
|
||||
tsconfig: 'tsconfig.client.json',
|
||||
deps: {
|
||||
// self-imports and dev-server virtual modules, resolved at site build time
|
||||
neverBundle: [
|
||||
/^vitepress(?:\/|$)/,
|
||||
'@siteData',
|
||||
'@theme/index',
|
||||
'@localSearchIndex'
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
syncShared('client'),
|
||||
rootTypesSpecifiers(),
|
||||
clientAssets(),
|
||||
vueSfcPlugin({
|
||||
srcDir: 'src/client',
|
||||
cwd: ROOT,
|
||||
tsconfig: './tsconfig.client.json'
|
||||
})
|
||||
],
|
||||
checks: { pluginTimings: false }
|
||||
}
|
||||
|
||||
const node: UserConfig = {
|
||||
entry: ['src/node/index.ts', 'src/node/cli.ts'],
|
||||
outDir: 'dist/node',
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
fixedExtension: false,
|
||||
dts: true,
|
||||
tsconfig: 'tsconfig.node.json',
|
||||
// polyfill broken browser check from bundled deps
|
||||
define: {
|
||||
'navigator.userAgentData': 'undefined',
|
||||
'navigator.userAgent': 'undefined'
|
||||
},
|
||||
deps: {
|
||||
// devDependencies are bundled by design
|
||||
onlyBundle: false,
|
||||
// markdown-it types are provided by @types/markdown-it (a runtime dep)
|
||||
dts: { neverBundle: /^markdown-it(?:\/|$)/ }
|
||||
},
|
||||
// code-level compression only — no name mangling or whitespace removal
|
||||
minify: { compress: true, mangle: false, codegen: false },
|
||||
outputOptions: { chunkFileNames: 'chunk-[hash].js' },
|
||||
checks: { eval: false, pluginTimings: false },
|
||||
plugins: [syncShared('node'), rootTypesSpecifiers()]
|
||||
}
|
||||
|
||||
export default defineConfig([client, node].map(withStableOutputs))
|
||||
Loading…
Reference in new issue