feat(@astrojs/netlify): add build.split
support (#7615)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
This commit is contained in:
parent
b30a1bc2b8
commit
f21357b69d
20 changed files with 234 additions and 35 deletions
20
.changeset/happy-frogs-appear.md
Normal file
20
.changeset/happy-frogs-appear.md
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
'@astrojs/netlify': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
The Netlify adapter builds to a single function by default. Astro 2.7 added support for splitting your build into separate entry points per page. If you use this configuration, the Netlify adapter will generate a separate function for each page. This can help reduce the size of each function so they are only bundling code used on that page.
|
||||||
|
|
||||||
|
|
||||||
|
```js
|
||||||
|
// astro.config.mjs
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import netlify from '@astrojs/netlify/functions';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
|
adapter: netlify(),
|
||||||
|
build: {
|
||||||
|
split: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
5
.changeset/nasty-geckos-know.md
Normal file
5
.changeset/nasty-geckos-know.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@astrojs/underscore-redirects': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Refactor how the routes are passed.
|
|
@ -264,10 +264,14 @@ export default function createIntegration(args?: Options): AstroIntegration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectRoutes = routes.filter((r) => r.type === 'redirect');
|
const redirectRoutes: [RouteData, string][] = routes
|
||||||
|
.filter((r) => r.type === 'redirect')
|
||||||
|
.map((r) => {
|
||||||
|
return [r, ''];
|
||||||
|
});
|
||||||
const trueRedirects = createRedirectsFromAstroRoutes({
|
const trueRedirects = createRedirectsFromAstroRoutes({
|
||||||
config: _config,
|
config: _config,
|
||||||
routes: redirectRoutes,
|
routeToDynamicTargetMap: new Map(Array.from(redirectRoutes)),
|
||||||
dir,
|
dir,
|
||||||
});
|
});
|
||||||
if (!trueRedirects.empty()) {
|
if (!trueRedirects.empty()) {
|
||||||
|
|
|
@ -72,6 +72,24 @@ export default defineConfig({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Per-page functions
|
||||||
|
|
||||||
|
The Netlify adapter builds to a single function by default. Astro 2.7 added support for splitting your build into separate entry points per page. If you use this configuration, the Netlify adapter will generate a separate function for each page. This can help reduce the size of each function so they are only bundling code used on that page.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// astro.config.mjs
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import netlify from '@astrojs/netlify/functions';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
|
adapter: netlify(),
|
||||||
|
build: {
|
||||||
|
split: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
### Static sites
|
### Static sites
|
||||||
|
|
||||||
For static sites you usually don't need an adapter. However, if you use `redirects` configuration (experimental) in your Astro config, the Netlify adapter can be used to translate this to the proper `_redirects` format.
|
For static sites you usually don't need an adapter. However, if you use `redirects` configuration (experimental) in your Astro config, the Netlify adapter can be used to translate this to the proper `_redirects` format.
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
|
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
|
||||||
"build:ci": "astro-scripts build \"src/**/*.ts\"",
|
"build:ci": "astro-scripts build \"src/**/*.ts\"",
|
||||||
"dev": "astro-scripts dev \"src/**/*.ts\"",
|
"dev": "astro-scripts dev \"src/**/*.ts\"",
|
||||||
"test-fn": "mocha --exit --timeout 20000 test/functions/",
|
"test-fn": "mocha --exit --timeout 20000 --file \"./test/setup.js\" test/functions/",
|
||||||
"test-edge": "deno test --allow-run --allow-read --allow-net --allow-env ./test/edge-functions/",
|
"test-edge": "deno test --allow-run --allow-read --allow-net --allow-env ./test/edge-functions/",
|
||||||
"test": "npm run test-fn"
|
"test": "npm run test-fn"
|
||||||
},
|
},
|
||||||
|
@ -54,7 +54,8 @@
|
||||||
"chai": "^4.3.7",
|
"chai": "^4.3.7",
|
||||||
"cheerio": "1.0.0-rc.12",
|
"cheerio": "1.0.0-rc.12",
|
||||||
"mocha": "^9.2.2",
|
"mocha": "^9.2.2",
|
||||||
"vite": "^4.3.9"
|
"vite": "^4.3.9",
|
||||||
|
"chai-jest-snapshot": "^2.0.0"
|
||||||
},
|
},
|
||||||
"astro": {
|
"astro": {
|
||||||
"external": true
|
"external": true
|
||||||
|
|
|
@ -166,7 +166,12 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {})
|
||||||
'astro:build:done': async ({ routes, dir }) => {
|
'astro:build:done': async ({ routes, dir }) => {
|
||||||
await bundleServerEntry(_buildConfig, _vite);
|
await bundleServerEntry(_buildConfig, _vite);
|
||||||
await createEdgeManifest(routes, entryFile, _config.root);
|
await createEdgeManifest(routes, entryFile, _config.root);
|
||||||
await createRedirects(_config, routes, dir, entryFile, 'edge-functions');
|
const dynamicTarget = `/.netlify/edge-functions/${entryFile}`;
|
||||||
|
const map: [RouteData, string][] = routes.map((route) => {
|
||||||
|
return [route, dynamicTarget];
|
||||||
|
});
|
||||||
|
const routeToDynamicTargetMap = new Map(Array.from(map));
|
||||||
|
await createRedirects(_config, routeToDynamicTargetMap, dir);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro';
|
import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro';
|
||||||
import type { Args } from './netlify-functions.js';
|
import type { Args } from './netlify-functions.js';
|
||||||
import { createRedirects } from './shared.js';
|
import { createRedirects } from './shared.js';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { extname } from 'node:path';
|
||||||
|
|
||||||
export function getAdapter(args: Args = {}): AstroAdapter {
|
export function getAdapter(args: Args = {}): AstroAdapter {
|
||||||
return {
|
return {
|
||||||
|
@ -23,7 +25,8 @@ function netlifyFunctions({
|
||||||
binaryMediaTypes,
|
binaryMediaTypes,
|
||||||
}: NetlifyFunctionsOptions = {}): AstroIntegration {
|
}: NetlifyFunctionsOptions = {}): AstroIntegration {
|
||||||
let _config: AstroConfig;
|
let _config: AstroConfig;
|
||||||
let entryFile: string;
|
let _entryPoints: Map<RouteData, URL>;
|
||||||
|
let ssrEntryFile: string;
|
||||||
return {
|
return {
|
||||||
name: '@astrojs/netlify',
|
name: '@astrojs/netlify',
|
||||||
hooks: {
|
hooks: {
|
||||||
|
@ -37,10 +40,13 @@ function netlifyFunctions({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
'astro:build:ssr': ({ entryPoints }) => {
|
||||||
|
_entryPoints = entryPoints;
|
||||||
|
},
|
||||||
'astro:config:done': ({ config, setAdapter }) => {
|
'astro:config:done': ({ config, setAdapter }) => {
|
||||||
setAdapter(getAdapter({ binaryMediaTypes, builders }));
|
setAdapter(getAdapter({ binaryMediaTypes, builders }));
|
||||||
_config = config;
|
_config = config;
|
||||||
entryFile = config.build.serverEntry.replace(/\.m?js/, '');
|
ssrEntryFile = config.build.serverEntry.replace(/\.m?js/, '');
|
||||||
|
|
||||||
if (config.output === 'static') {
|
if (config.output === 'static') {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
@ -53,7 +59,32 @@ function netlifyFunctions({
|
||||||
},
|
},
|
||||||
'astro:build:done': async ({ routes, dir }) => {
|
'astro:build:done': async ({ routes, dir }) => {
|
||||||
const type = builders ? 'builders' : 'functions';
|
const type = builders ? 'builders' : 'functions';
|
||||||
await createRedirects(_config, routes, dir, entryFile, type);
|
const kind = type ?? 'functions';
|
||||||
|
|
||||||
|
if (_entryPoints.size) {
|
||||||
|
const routeToDynamicTargetMap = new Map();
|
||||||
|
for (const [route, entryFile] of _entryPoints) {
|
||||||
|
const wholeFileUrl = fileURLToPath(entryFile);
|
||||||
|
|
||||||
|
const extension = extname(wholeFileUrl);
|
||||||
|
const relative = wholeFileUrl
|
||||||
|
.replace(fileURLToPath(_config.build.server), '')
|
||||||
|
.replace(extension, '')
|
||||||
|
.replaceAll('\\', '/');
|
||||||
|
const dynamicTarget = `/.netlify/${kind}/${relative}`;
|
||||||
|
|
||||||
|
routeToDynamicTargetMap.set(route, dynamicTarget);
|
||||||
|
}
|
||||||
|
await createRedirects(_config, routeToDynamicTargetMap, dir);
|
||||||
|
} else {
|
||||||
|
const dynamicTarget = `/.netlify/${kind}/${ssrEntryFile}`;
|
||||||
|
const map: [RouteData, string][] = routes.map((route) => {
|
||||||
|
return [route, dynamicTarget];
|
||||||
|
});
|
||||||
|
const routeToDynamicTargetMap = new Map(Array.from(map));
|
||||||
|
|
||||||
|
await createRedirects(_config, routeToDynamicTargetMap, dir);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AstroIntegration } from 'astro';
|
import type { AstroIntegration, RouteData } from 'astro';
|
||||||
import { createRedirects } from './shared.js';
|
import { createRedirects } from './shared.js';
|
||||||
|
|
||||||
export function netlifyStatic(): AstroIntegration {
|
export function netlifyStatic(): AstroIntegration {
|
||||||
|
@ -18,7 +18,12 @@ export function netlifyStatic(): AstroIntegration {
|
||||||
_config = config;
|
_config = config;
|
||||||
},
|
},
|
||||||
'astro:build:done': async ({ dir, routes }) => {
|
'astro:build:done': async ({ dir, routes }) => {
|
||||||
await createRedirects(_config, routes, dir, '', 'static');
|
const mappedRoutes: [RouteData, string][] = routes.map((route) => [
|
||||||
|
route,
|
||||||
|
`/.netlify/static/`,
|
||||||
|
]);
|
||||||
|
const routesToDynamicTargetMap = new Map(Array.from(mappedRoutes));
|
||||||
|
await createRedirects(_config, routesToDynamicTargetMap, dir);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,20 +4,15 @@ import fs from 'node:fs';
|
||||||
|
|
||||||
export async function createRedirects(
|
export async function createRedirects(
|
||||||
config: AstroConfig,
|
config: AstroConfig,
|
||||||
routes: RouteData[],
|
routeToDynamicTargetMap: Map<RouteData, string>,
|
||||||
dir: URL,
|
dir: URL
|
||||||
entryFile: string,
|
|
||||||
type: 'functions' | 'edge-functions' | 'builders' | 'static'
|
|
||||||
) {
|
) {
|
||||||
const kind = type ?? 'functions';
|
|
||||||
const dynamicTarget = `/.netlify/${kind}/${entryFile}`;
|
|
||||||
const _redirectsURL = new URL('./_redirects', dir);
|
const _redirectsURL = new URL('./_redirects', dir);
|
||||||
|
|
||||||
const _redirects = createRedirectsFromAstroRoutes({
|
const _redirects = createRedirectsFromAstroRoutes({
|
||||||
config,
|
config,
|
||||||
routes,
|
routeToDynamicTargetMap,
|
||||||
dir,
|
dir,
|
||||||
dynamicTarget,
|
|
||||||
});
|
});
|
||||||
const content = _redirects.print();
|
const content = _redirects.print();
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Testing</title>
|
<title>Blog</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>testing</h1>
|
<h1>Blog</h1>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Testing</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>testing</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Blog</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Blog</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -46,5 +46,6 @@ describe('SSG - Redirects', () => {
|
||||||
'/.netlify/functions/entry',
|
'/.netlify/functions/entry',
|
||||||
'200',
|
'200',
|
||||||
]);
|
]);
|
||||||
|
expect(redirects).to.matchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`SSG - Redirects Creates a redirects file 1`] = `
|
||||||
|
"/other / 301
|
||||||
|
/nope /.netlify/functions/entry 200
|
||||||
|
/ /.netlify/functions/entry 200
|
||||||
|
/team/articles/* /.netlify/functions/entry 200"
|
||||||
|
`;
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import netlifyAdapter from '../../dist/index.js';
|
||||||
|
import { loadFixture, testIntegration } from './test-utils.js';
|
||||||
|
|
||||||
|
describe('Split support', () => {
|
||||||
|
/** @type {import('./test-utils').Fixture} */
|
||||||
|
let fixture;
|
||||||
|
let _entryPoints;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fixture = await loadFixture({
|
||||||
|
root: new URL('./fixtures/split-support/', import.meta.url).toString(),
|
||||||
|
output: 'server',
|
||||||
|
adapter: netlifyAdapter({
|
||||||
|
dist: new URL('./fixtures/split-support/dist/', import.meta.url),
|
||||||
|
}),
|
||||||
|
site: `http://example.com`,
|
||||||
|
integrations: [
|
||||||
|
testIntegration({
|
||||||
|
setEntryPoints(ep) {
|
||||||
|
_entryPoints = ep;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
split: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fixture.build();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outputs a correct redirect file', async () => {
|
||||||
|
const redir = await fixture.readFile('/_redirects');
|
||||||
|
const lines = redir.split(/[\r\n]+/);
|
||||||
|
expect(lines.length).to.equal(2);
|
||||||
|
|
||||||
|
expect(lines[0].includes('/blog')).to.be.true;
|
||||||
|
expect(lines[0].includes('blog.astro')).to.be.true;
|
||||||
|
expect(lines[0].includes('200')).to.be.true;
|
||||||
|
expect(lines[1].includes('/')).to.be.true;
|
||||||
|
expect(lines[1].includes('index.astro')).to.be.true;
|
||||||
|
expect(lines[1].includes('200')).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Should create multiple functions', () => {
|
||||||
|
it('and hit 200', async () => {
|
||||||
|
if (_entryPoints) {
|
||||||
|
for (const [, filePath] of _entryPoints) {
|
||||||
|
const { handler } = await import(filePath.toString());
|
||||||
|
const resp = await handler({
|
||||||
|
httpMethod: 'POST',
|
||||||
|
headers: {},
|
||||||
|
rawUrl: 'http://example.com/',
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
expect(resp.statusCode).to.equal(200);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expect(false).to.be.true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -7,7 +7,7 @@ export * from '../../../../astro/test/test-utils.js';
|
||||||
*
|
*
|
||||||
* @returns {import('../../../../astro/dist/types/@types/astro').AstroIntegration}
|
* @returns {import('../../../../astro/dist/types/@types/astro').AstroIntegration}
|
||||||
*/
|
*/
|
||||||
export function testIntegration() {
|
export function testIntegration({ setEntryPoints } = {}) {
|
||||||
return {
|
return {
|
||||||
name: '@astrojs/netlify/test-integration',
|
name: '@astrojs/netlify/test-integration',
|
||||||
hooks: {
|
hooks: {
|
||||||
|
@ -24,6 +24,11 @@ export function testIntegration() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
'astro:build:ssr': ({ entryPoints }) => {
|
||||||
|
if (entryPoints.size) {
|
||||||
|
setEntryPoints(entryPoints);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
12
packages/integrations/netlify/test/setup.js
Normal file
12
packages/integrations/netlify/test/setup.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { use } from 'chai';
|
||||||
|
import chaiJestSnapshot from 'chai-jest-snapshot';
|
||||||
|
|
||||||
|
use(chaiJestSnapshot);
|
||||||
|
|
||||||
|
before(function () {
|
||||||
|
chaiJestSnapshot.resetSnapshotRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
chaiJestSnapshot.configureUsingMochaContext(this);
|
||||||
|
});
|
|
@ -13,9 +13,11 @@ function getRedirectStatus(route: RouteData): ValidRedirectStatus {
|
||||||
|
|
||||||
interface CreateRedirectsFromAstroRoutesParams {
|
interface CreateRedirectsFromAstroRoutesParams {
|
||||||
config: Pick<AstroConfig, 'build' | 'output'>;
|
config: Pick<AstroConfig, 'build' | 'output'>;
|
||||||
routes: RouteData[];
|
/**
|
||||||
|
* Maps a `RouteData` to a dynamic target
|
||||||
|
*/
|
||||||
|
routeToDynamicTargetMap: Map<RouteData, string>;
|
||||||
dir: URL;
|
dir: URL;
|
||||||
dynamicTarget?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -23,18 +25,17 @@ interface CreateRedirectsFromAstroRoutesParams {
|
||||||
*/
|
*/
|
||||||
export function createRedirectsFromAstroRoutes({
|
export function createRedirectsFromAstroRoutes({
|
||||||
config,
|
config,
|
||||||
routes,
|
routeToDynamicTargetMap,
|
||||||
dir,
|
dir,
|
||||||
dynamicTarget = '',
|
|
||||||
}: CreateRedirectsFromAstroRoutesParams) {
|
}: CreateRedirectsFromAstroRoutesParams) {
|
||||||
const output = config.output;
|
const output = config.output;
|
||||||
const _redirects = new Redirects();
|
const _redirects = new Redirects();
|
||||||
|
|
||||||
for (const route of routes) {
|
for (const [route, dynamicTarget = ''] of routeToDynamicTargetMap) {
|
||||||
// A route with a `pathname` is as static route.
|
// A route with a `pathname` is as static route.
|
||||||
if (route.pathname) {
|
if (route.pathname) {
|
||||||
if (route.redirect) {
|
if (route.redirect) {
|
||||||
// A redirect route without dynamic parts. Get the redirect status
|
// A redirect route without dynami§c parts. Get the redirect status
|
||||||
// from the user if provided.
|
// from the user if provided.
|
||||||
_redirects.add({
|
_redirects.add({
|
||||||
dynamic: false,
|
dynamic: false,
|
||||||
|
|
|
@ -8,16 +8,22 @@ describe('Astro', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
it('Creates a Redirects object from routes', () => {
|
it('Creates a Redirects object from routes', () => {
|
||||||
const routes = [
|
const routeToDynamicTargetMap = new Map(
|
||||||
{ pathname: '/', distURL: new URL('./index.html', import.meta.url), segments: [] },
|
Array.from([
|
||||||
{ pathname: '/one', distURL: new URL('./one/index.html', import.meta.url), segments: [] },
|
[
|
||||||
];
|
{ pathname: '/', distURL: new URL('./index.html', import.meta.url), segments: [] },
|
||||||
const dynamicTarget = './.adapter/dist/entry.mjs';
|
'./.adapter/dist/entry.mjs',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ pathname: '/one', distURL: new URL('./one/index.html', import.meta.url), segments: [] },
|
||||||
|
'./.adapter/dist/entry.mjs',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
const _redirects = createRedirectsFromAstroRoutes({
|
const _redirects = createRedirectsFromAstroRoutes({
|
||||||
config: serverConfig,
|
config: serverConfig,
|
||||||
routes,
|
routeToDynamicTargetMap,
|
||||||
dir: new URL(import.meta.url),
|
dir: new URL(import.meta.url),
|
||||||
dynamicTarget,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(_redirects.definitions).to.have.a.lengthOf(2);
|
expect(_redirects.definitions).to.have.a.lengthOf(2);
|
||||||
|
|
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
@ -4431,6 +4431,9 @@ importers:
|
||||||
chai:
|
chai:
|
||||||
specifier: ^4.3.7
|
specifier: ^4.3.7
|
||||||
version: 4.3.7
|
version: 4.3.7
|
||||||
|
chai-jest-snapshot:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0(chai@4.3.7)
|
||||||
cheerio:
|
cheerio:
|
||||||
specifier: 1.0.0-rc.12
|
specifier: 1.0.0-rc.12
|
||||||
version: 1.0.0-rc.12
|
version: 1.0.0-rc.12
|
||||||
|
|
Loading…
Add table
Reference in a new issue