Misc improvements for astro:assets
(#7759)
This commit is contained in:
parent
16a41528e4
commit
1792737dae
14 changed files with 50 additions and 100 deletions
5
.changeset/hot-buckets-tie.md
Normal file
5
.changeset/hot-buckets-tie.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix SharedImageService's types not properly reflecting that image services hooks can be async
|
|
@ -22,7 +22,7 @@ async function loadRemoteImage(src: URL) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint used in SSR to serve optimized images
|
* Endpoint used in dev and SSR to serve optimized images by the base image services
|
||||||
*/
|
*/
|
||||||
export const get: APIRoute = async ({ request }) => {
|
export const get: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -38,10 +38,10 @@ export async function getImage(
|
||||||
|
|
||||||
const service = await getConfiguredImageService();
|
const service = await getConfiguredImageService();
|
||||||
const validatedOptions = service.validateOptions
|
const validatedOptions = service.validateOptions
|
||||||
? service.validateOptions(options, serviceConfig)
|
? await service.validateOptions(options, serviceConfig)
|
||||||
: options;
|
: options;
|
||||||
|
|
||||||
let imageURL = service.getURL(validatedOptions, serviceConfig);
|
let imageURL = await service.getURL(validatedOptions, serviceConfig);
|
||||||
|
|
||||||
// In build and for local services, we need to collect the requested parameters so we can generate the final images
|
// In build and for local services, we need to collect the requested parameters so we can generate the final images
|
||||||
if (isLocalService(service) && globalThis.astroAsset.addStaticImage) {
|
if (isLocalService(service) && globalThis.astroAsset.addStaticImage) {
|
||||||
|
|
|
@ -32,7 +32,7 @@ interface SharedServiceProps {
|
||||||
* For external services, this should point to the URL your images are coming from, for instance, `/_vercel/image`
|
* For external services, this should point to the URL your images are coming from, for instance, `/_vercel/image`
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
getURL: (options: ImageTransform, serviceConfig: Record<string, any>) => string;
|
getURL: (options: ImageTransform, serviceConfig: Record<string, any>) => string | Promise<string>;
|
||||||
/**
|
/**
|
||||||
* Return any additional HTML attributes separate from `src` that your service requires to show the image properly.
|
* Return any additional HTML attributes separate from `src` that your service requires to show the image properly.
|
||||||
*
|
*
|
||||||
|
@ -42,7 +42,7 @@ interface SharedServiceProps {
|
||||||
getHTMLAttributes?: (
|
getHTMLAttributes?: (
|
||||||
options: ImageTransform,
|
options: ImageTransform,
|
||||||
serviceConfig: Record<string, any>
|
serviceConfig: Record<string, any>
|
||||||
) => Record<string, any>;
|
) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
/**
|
/**
|
||||||
* Validate and return the options passed by the user.
|
* Validate and return the options passed by the user.
|
||||||
*
|
*
|
||||||
|
@ -51,7 +51,10 @@ interface SharedServiceProps {
|
||||||
*
|
*
|
||||||
* This method should returns options, and can be used to set defaults (ex: a default output format to be used if the user didn't specify one.)
|
* This method should returns options, and can be used to set defaults (ex: a default output format to be used if the user didn't specify one.)
|
||||||
*/
|
*/
|
||||||
validateOptions?: (options: ImageTransform, serviceConfig: Record<string, any>) => ImageTransform;
|
validateOptions?: (
|
||||||
|
options: ImageTransform,
|
||||||
|
serviceConfig: Record<string, any>
|
||||||
|
) => ImageTransform | Promise<ImageTransform>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExternalImageService = SharedServiceProps;
|
export type ExternalImageService = SharedServiceProps;
|
||||||
|
@ -63,11 +66,14 @@ export type LocalImageTransform = {
|
||||||
|
|
||||||
export interface LocalImageService extends SharedServiceProps {
|
export interface LocalImageService extends SharedServiceProps {
|
||||||
/**
|
/**
|
||||||
* Parse the requested parameters passed in the URL from `getURL` back into an object to be used later by `transform`
|
* Parse the requested parameters passed in the URL from `getURL` back into an object to be used later by `transform`.
|
||||||
*
|
*
|
||||||
* In most cases, this will get query parameters using, for example, `params.get('width')` and return those.
|
* In most cases, this will get query parameters using, for example, `params.get('width')` and return those.
|
||||||
*/
|
*/
|
||||||
parseURL: (url: URL, serviceConfig: Record<string, any>) => LocalImageTransform | undefined;
|
parseURL: (
|
||||||
|
url: URL,
|
||||||
|
serviceConfig: Record<string, any>
|
||||||
|
) => LocalImageTransform | undefined | Promise<LocalImageTransform> | Promise<undefined>;
|
||||||
/**
|
/**
|
||||||
* Performs the image transformations on the input image and returns both the binary data and
|
* Performs the image transformations on the input image and returns both the binary data and
|
||||||
* final image format of the optimized image.
|
* final image format of the optimized image.
|
||||||
|
|
|
@ -1 +1,4 @@
|
||||||
export { emitESMImage } from './emitAsset.js';
|
export { emitESMImage } from './emitAsset.js';
|
||||||
|
export { imageMetadata } from './metadata.js';
|
||||||
|
export { getOrigQueryParams } from './queryParams.js';
|
||||||
|
export { hashTransform, propsToFilename } from './transformToPath.js';
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import { bold } from 'kleur/colors';
|
import { bold } from 'kleur/colors';
|
||||||
import MagicString from 'magic-string';
|
import MagicString from 'magic-string';
|
||||||
import mime from 'mime/lite.js';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import { Readable } from 'node:stream';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import type * as vite from 'vite';
|
import type * as vite from 'vite';
|
||||||
import { normalizePath } from 'vite';
|
import { normalizePath } from 'vite';
|
||||||
|
@ -16,10 +13,7 @@ import {
|
||||||
} from '../core/path.js';
|
} from '../core/path.js';
|
||||||
import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
|
import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
|
||||||
import { isESMImportedImage } from './internal.js';
|
import { isESMImportedImage } from './internal.js';
|
||||||
import { isLocalService } from './services/service.js';
|
|
||||||
import { emitESMImage } from './utils/emitAsset.js';
|
import { emitESMImage } from './utils/emitAsset.js';
|
||||||
import { imageMetadata } from './utils/metadata.js';
|
|
||||||
import { getOrigQueryParams } from './utils/queryParams.js';
|
|
||||||
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
|
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
|
||||||
|
|
||||||
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
|
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
|
||||||
|
@ -96,70 +90,6 @@ export default function assets({
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Handle serving images during development
|
|
||||||
configureServer(server) {
|
|
||||||
server.middlewares.use(async (req, res, next) => {
|
|
||||||
if (req.url?.startsWith('/_image')) {
|
|
||||||
// If the currently configured service isn't a local service, we don't need to do anything here.
|
|
||||||
// TODO: Support setting a specific service through a prop on Image / a parameter in getImage
|
|
||||||
if (!isLocalService(globalThis.astroAsset.imageService)) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url, 'file:');
|
|
||||||
if (!url.searchParams.has('href')) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = url.searchParams.get('href')?.slice('/@fs'.length);
|
|
||||||
const filePathURL = new URL('.' + filePath, 'file:');
|
|
||||||
const file = await fs.readFile(filePathURL);
|
|
||||||
|
|
||||||
// Get the file's metadata from the URL
|
|
||||||
let meta = getOrigQueryParams(filePathURL.searchParams);
|
|
||||||
|
|
||||||
// If we don't have them (ex: the image came from Markdown, let's calculate them again)
|
|
||||||
if (!meta) {
|
|
||||||
meta = await imageMetadata(filePathURL, file);
|
|
||||||
|
|
||||||
if (!meta) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const transform = await globalThis.astroAsset.imageService.parseURL(
|
|
||||||
url,
|
|
||||||
settings.config.image.service.config
|
|
||||||
);
|
|
||||||
|
|
||||||
if (transform === undefined) {
|
|
||||||
error(logging, 'image', `Failed to parse transform for ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no transforms were added, the original file will be returned as-is
|
|
||||||
let data = file;
|
|
||||||
let format: string = meta.format;
|
|
||||||
|
|
||||||
if (transform) {
|
|
||||||
const result = await globalThis.astroAsset.imageService.transform(
|
|
||||||
file,
|
|
||||||
transform,
|
|
||||||
settings.config.image.service.config
|
|
||||||
);
|
|
||||||
data = result.data;
|
|
||||||
format = result.format;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', mime.getType(format) ?? `image/${format}`);
|
|
||||||
res.setHeader('Cache-Control', 'max-age=360000');
|
|
||||||
|
|
||||||
const stream = Readable.from(data);
|
|
||||||
return stream.pipe(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
buildStart() {
|
buildStart() {
|
||||||
if (mode != 'build') {
|
if (mode != 'build') {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -26,9 +26,11 @@ export async function loadSettings({ cmd, flags, logging }: LoadSettingsOptions)
|
||||||
await handleConfigError(e, { cmd, cwd: root, flags, logging });
|
await handleConfigError(e, { cmd, cwd: root, flags, logging });
|
||||||
return {} as any;
|
return {} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mode = cmd === 'build' ? 'build' : 'dev';
|
||||||
if (!initialAstroConfig) return;
|
if (!initialAstroConfig) return;
|
||||||
telemetry.record(event.eventCliSession(cmd, initialUserConfig, flags));
|
telemetry.record(event.eventCliSession(cmd, initialUserConfig, flags));
|
||||||
return createSettings(initialAstroConfig, root);
|
return createSettings(initialAstroConfig, mode, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleConfigError(
|
export async function handleConfigError(
|
||||||
|
|
|
@ -35,7 +35,7 @@ export function getViteConfig(inlineConfig: UserConfig) {
|
||||||
level: 'info',
|
level: 'info',
|
||||||
};
|
};
|
||||||
const { astroConfig: config } = await openConfig({ cmd });
|
const { astroConfig: config } = await openConfig({ cmd });
|
||||||
const settings = createSettings(config, inlineConfig.root);
|
const settings = createSettings(config, cmd, inlineConfig.root);
|
||||||
await runHookConfigSetup({ settings, command: cmd, logging });
|
await runHookConfigSetup({ settings, command: cmd, logging });
|
||||||
const viteConfig = await createVite(
|
const viteConfig = await createVite(
|
||||||
{
|
{
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { createDefaultDevConfig } from './config.js';
|
||||||
import { AstroTimer } from './timer.js';
|
import { AstroTimer } from './timer.js';
|
||||||
import { loadTSConfig } from './tsconfig.js';
|
import { loadTSConfig } from './tsconfig.js';
|
||||||
|
|
||||||
export function createBaseSettings(config: AstroConfig): AstroSettings {
|
export function createBaseSettings(config: AstroConfig, mode: 'build' | 'dev'): AstroSettings {
|
||||||
const { contentDir } = getContentPaths(config);
|
const { contentDir } = getContentPaths(config);
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
|
@ -23,7 +23,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
|
||||||
|
|
||||||
adapter: undefined,
|
adapter: undefined,
|
||||||
injectedRoutes:
|
injectedRoutes:
|
||||||
config.experimental.assets && isServerLikeOutput(config)
|
config.experimental.assets && (isServerLikeOutput(config) || mode === 'dev')
|
||||||
? [{ pattern: '/_image', entryPoint: 'astro/assets/image-endpoint', prerender: false }]
|
? [{ pattern: '/_image', entryPoint: 'astro/assets/image-endpoint', prerender: false }]
|
||||||
: [],
|
: [],
|
||||||
pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS],
|
pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS],
|
||||||
|
@ -108,9 +108,13 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSettings(config: AstroConfig, cwd?: string): AstroSettings {
|
export function createSettings(
|
||||||
|
config: AstroConfig,
|
||||||
|
mode: 'build' | 'dev',
|
||||||
|
cwd?: string
|
||||||
|
): AstroSettings {
|
||||||
const tsconfig = loadTSConfig(cwd);
|
const tsconfig = loadTSConfig(cwd);
|
||||||
const settings = createBaseSettings(config);
|
const settings = createBaseSettings(config, mode);
|
||||||
|
|
||||||
const watchFiles = tsconfig?.exists ? [tsconfig.path, ...tsconfig.extendedPaths] : [];
|
const watchFiles = tsconfig?.exists ? [tsconfig.path, ...tsconfig.extendedPaths] : [];
|
||||||
|
|
||||||
|
@ -132,5 +136,5 @@ export async function createDefaultDevSettings(
|
||||||
root = fileURLToPath(root);
|
root = fileURLToPath(root);
|
||||||
}
|
}
|
||||||
const config = await createDefaultDevConfig(userConfig, root);
|
const config = await createDefaultDevConfig(userConfig, root);
|
||||||
return createBaseSettings(config);
|
return createBaseSettings(config, 'dev');
|
||||||
}
|
}
|
||||||
|
|
|
@ -92,7 +92,7 @@ export async function restartContainer({
|
||||||
});
|
});
|
||||||
info(logging, 'astro', logMsg + '\n');
|
info(logging, 'astro', logMsg + '\n');
|
||||||
let astroConfig = newConfig.astroConfig;
|
let astroConfig = newConfig.astroConfig;
|
||||||
const settings = createSettings(astroConfig, resolvedRoot);
|
const settings = createSettings(astroConfig, 'dev', resolvedRoot);
|
||||||
await close();
|
await close();
|
||||||
return {
|
return {
|
||||||
container: await createRestartedContainer(container, settings, needsStart),
|
container: await createRestartedContainer(container, settings, needsStart),
|
||||||
|
|
|
@ -138,8 +138,8 @@ export async function loadFixture(inlineConfig) {
|
||||||
* the `AstroSettings`. This function helps to create a fresh settings object that is used by the
|
* the `AstroSettings`. This function helps to create a fresh settings object that is used by the
|
||||||
* command functions below to prevent tests from polluting each other.
|
* command functions below to prevent tests from polluting each other.
|
||||||
*/
|
*/
|
||||||
const getSettings = async () => {
|
const getSettings = async (mode) => {
|
||||||
let settings = createSettings(config, fileURLToPath(cwd));
|
let settings = createSettings(config, mode, fileURLToPath(cwd));
|
||||||
if (config.integrations.find((integration) => integration.name === '@astrojs/mdx')) {
|
if (config.integrations.find((integration) => integration.name === '@astrojs/mdx')) {
|
||||||
// Enable default JSX integration. It needs to come first, so unshift rather than push!
|
// Enable default JSX integration. It needs to come first, so unshift rather than push!
|
||||||
const { default: jsxRenderer } = await import('astro/jsx/renderer.js');
|
const { default: jsxRenderer } = await import('astro/jsx/renderer.js');
|
||||||
|
@ -179,15 +179,15 @@ export async function loadFixture(inlineConfig) {
|
||||||
return {
|
return {
|
||||||
build: async (opts = {}) => {
|
build: async (opts = {}) => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = 'production';
|
||||||
return build(await getSettings(), { logging, ...opts });
|
return build(await getSettings('build'), { logging, ...opts });
|
||||||
},
|
},
|
||||||
sync: async (opts) => sync(await getSettings(), { logging, fs, ...opts }),
|
sync: async (opts) => sync(await getSettings('build'), { logging, fs, ...opts }),
|
||||||
check: async (opts) => {
|
check: async (opts) => {
|
||||||
return await check(await getSettings(), { logging, ...opts });
|
return await check(await getSettings('build'), { logging, ...opts });
|
||||||
},
|
},
|
||||||
startDevServer: async (opts = {}) => {
|
startDevServer: async (opts = {}) => {
|
||||||
process.env.NODE_ENV = 'development';
|
process.env.NODE_ENV = 'development';
|
||||||
devServer = await dev(await getSettings(), { logging, ...opts });
|
devServer = await dev(await getSettings('dev'), { logging, ...opts });
|
||||||
config.server.host = parseAddressToHost(devServer.address.address); // update host
|
config.server.host = parseAddressToHost(devServer.address.address); // update host
|
||||||
config.server.port = devServer.address.port; // update port
|
config.server.port = devServer.address.port; // update port
|
||||||
return devServer;
|
return devServer;
|
||||||
|
@ -209,7 +209,7 @@ export async function loadFixture(inlineConfig) {
|
||||||
},
|
},
|
||||||
preview: async (opts = {}) => {
|
preview: async (opts = {}) => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = 'production';
|
||||||
const previewServer = await preview(await getSettings(), { logging, ...opts });
|
const previewServer = await preview(await getSettings('build'), { logging, ...opts });
|
||||||
config.server.host = parseAddressToHost(previewServer.host); // update host
|
config.server.host = parseAddressToHost(previewServer.host); // update host
|
||||||
config.server.port = previewServer.port; // update port
|
config.server.port = previewServer.port; // update port
|
||||||
return previewServer;
|
return previewServer;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
|
|
||||||
|
import { createSettings, openConfig } from '../../../dist/core/config/index.js';
|
||||||
import { runInContainer } from '../../../dist/core/dev/index.js';
|
import { runInContainer } from '../../../dist/core/dev/index.js';
|
||||||
import { openConfig, createSettings } from '../../../dist/core/config/index.js';
|
|
||||||
import { createFs, defaultLogging } from '../test-utils.js';
|
import { createFs, defaultLogging } from '../test-utils.js';
|
||||||
|
|
||||||
const root = new URL('../../fixtures/tailwindcss-ts/', import.meta.url);
|
const root = new URL('../../fixtures/tailwindcss-ts/', import.meta.url);
|
||||||
|
@ -27,7 +27,7 @@ describe('Astro config formats', () => {
|
||||||
logging: defaultLogging,
|
logging: defaultLogging,
|
||||||
fsMod: fs,
|
fsMod: fs,
|
||||||
});
|
});
|
||||||
const settings = createSettings(astroConfig);
|
const settings = createSettings(astroConfig, 'dev');
|
||||||
|
|
||||||
await runInContainer({ fs, root, settings }, () => {
|
await runInContainer({ fs, root, settings }, () => {
|
||||||
expect(true).to.equal(
|
expect(true).to.equal(
|
||||||
|
|
|
@ -10,7 +10,7 @@ const logging = defaultLogging;
|
||||||
|
|
||||||
async function sync({ fs, config = {} }) {
|
async function sync({ fs, config = {} }) {
|
||||||
const astroConfig = await validateConfig(config, fileURLToPath(root), 'prod');
|
const astroConfig = await validateConfig(config, fileURLToPath(root), 'prod');
|
||||||
const settings = createSettings(astroConfig, fileURLToPath(root));
|
const settings = createSettings(astroConfig, 'build', fileURLToPath(root));
|
||||||
|
|
||||||
return _sync(settings, { logging, fs });
|
return _sync(settings, { logging, fs });
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { expect } from 'chai';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { createSettings, openConfig } from '../../../dist/core/config/index.js';
|
||||||
import {
|
import {
|
||||||
createContainerWithAutomaticRestart,
|
createContainerWithAutomaticRestart,
|
||||||
isStarted,
|
isStarted,
|
||||||
|
@ -13,7 +14,6 @@ import {
|
||||||
defaultLogging,
|
defaultLogging,
|
||||||
triggerFSEvent,
|
triggerFSEvent,
|
||||||
} from '../test-utils.js';
|
} from '../test-utils.js';
|
||||||
import { createSettings, openConfig } from '../../../dist/core/config/index.js';
|
|
||||||
|
|
||||||
const root = new URL('../../fixtures/alias/', import.meta.url);
|
const root = new URL('../../fixtures/alias/', import.meta.url);
|
||||||
|
|
||||||
|
@ -133,7 +133,7 @@ describe('dev container restarts', () => {
|
||||||
cmd: 'dev',
|
cmd: 'dev',
|
||||||
logging: defaultLogging,
|
logging: defaultLogging,
|
||||||
});
|
});
|
||||||
const settings = createSettings(astroConfig);
|
const settings = createSettings(astroConfig, 'dev');
|
||||||
|
|
||||||
let restart = await createContainerWithAutomaticRestart({
|
let restart = await createContainerWithAutomaticRestart({
|
||||||
params: { fs, root, settings },
|
params: { fs, root, settings },
|
||||||
|
@ -167,7 +167,7 @@ describe('dev container restarts', () => {
|
||||||
cmd: 'dev',
|
cmd: 'dev',
|
||||||
logging: defaultLogging,
|
logging: defaultLogging,
|
||||||
});
|
});
|
||||||
const settings = createSettings(astroConfig, fileURLToPath(root));
|
const settings = createSettings(astroConfig, 'dev', fileURLToPath(root));
|
||||||
|
|
||||||
let restart = await createContainerWithAutomaticRestart({
|
let restart = await createContainerWithAutomaticRestart({
|
||||||
params: { fs, root, settings },
|
params: { fs, root, settings },
|
||||||
|
@ -199,7 +199,7 @@ describe('dev container restarts', () => {
|
||||||
cmd: 'dev',
|
cmd: 'dev',
|
||||||
logging: defaultLogging,
|
logging: defaultLogging,
|
||||||
});
|
});
|
||||||
const settings = createSettings(astroConfig, fileURLToPath(root));
|
const settings = createSettings(astroConfig, 'dev', fileURLToPath(root));
|
||||||
|
|
||||||
let restart = await createContainerWithAutomaticRestart({
|
let restart = await createContainerWithAutomaticRestart({
|
||||||
params: { fs, root, settings },
|
params: { fs, root, settings },
|
||||||
|
|
Loading…
Add table
Reference in a new issue