[ci] format
This commit is contained in:
parent
72c2c86e9d
commit
43a5c06a93
15 changed files with 77 additions and 80 deletions
|
@ -12,9 +12,9 @@
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "astro build --legacy-build"
|
"build": "astro build --legacy-build"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -126,12 +126,12 @@
|
||||||
```typescript
|
```typescript
|
||||||
// src/pages/company.json.ts
|
// src/pages/company.json.ts
|
||||||
export async function get() {
|
export async function get() {
|
||||||
return {
|
return {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: 'Astro Technology Company',
|
name: 'Astro Technology Company',
|
||||||
url: 'https://astro.build/',
|
url: 'https://astro.build/',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -293,12 +293,12 @@
|
||||||
```typescript
|
```typescript
|
||||||
// src/pages/company.json.ts
|
// src/pages/company.json.ts
|
||||||
export async function get() {
|
export async function get() {
|
||||||
return {
|
return {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: 'Astro Technology Company',
|
name: 'Astro Technology Company',
|
||||||
url: 'https://astro.build/',
|
url: 'https://astro.build/',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -1653,10 +1653,10 @@ For convenience, you may now also move your `astro.config.js` file to a top-leve
|
||||||
|
|
||||||
```js
|
```js
|
||||||
export default {
|
export default {
|
||||||
markdownOptions: {
|
markdownOptions: {
|
||||||
remarkPlugins: ['remark-slug', ['remark-autolink-headings', { behavior: 'prepend' }]],
|
remarkPlugins: ['remark-slug', ['remark-autolink-headings', { behavior: 'prepend' }]],
|
||||||
rehypePlugins: ['rehype-slug', ['rehype-autolink-headings', { behavior: 'prepend' }]],
|
rehypePlugins: ['rehype-slug', ['rehype-autolink-headings', { behavior: 'prepend' }]],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -1676,10 +1676,10 @@ For convenience, you may now also move your `astro.config.js` file to a top-leve
|
||||||
|
|
||||||
```js
|
```js
|
||||||
export default {
|
export default {
|
||||||
name: '@matthewp/my-renderer',
|
name: '@matthewp/my-renderer',
|
||||||
server: './server.js',
|
server: './server.js',
|
||||||
client: './client.js',
|
client: './client.js',
|
||||||
hydrationPolyfills: ['./my-polyfill.js'],
|
hydrationPolyfills: ['./my-polyfill.js'],
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -483,7 +483,7 @@ function getOutFolder(astroConfig: AstroConfig, pathname: string, routeType: Rou
|
||||||
case 'page':
|
case 'page':
|
||||||
switch (astroConfig.buildOptions.pageUrlFormat) {
|
switch (astroConfig.buildOptions.pageUrlFormat) {
|
||||||
case 'directory': {
|
case 'directory': {
|
||||||
if(STATUS_CODE_PAGES.has(pathname)) {
|
if (STATUS_CODE_PAGES.has(pathname)) {
|
||||||
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
|
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
|
||||||
}
|
}
|
||||||
return new URL('.' + appendForwardSlash(pathname), outRoot);
|
return new URL('.' + appendForwardSlash(pathname), outRoot);
|
||||||
|
@ -491,7 +491,6 @@ function getOutFolder(astroConfig: AstroConfig, pathname: string, routeType: Rou
|
||||||
case 'file': {
|
case 'file': {
|
||||||
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
|
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -503,7 +502,7 @@ function getOutFile(astroConfig: AstroConfig, outFolder: URL, pathname: string,
|
||||||
case 'page':
|
case 'page':
|
||||||
switch (astroConfig.buildOptions.pageUrlFormat) {
|
switch (astroConfig.buildOptions.pageUrlFormat) {
|
||||||
case 'directory': {
|
case 'directory': {
|
||||||
if(STATUS_CODE_PAGES.has(pathname)) {
|
if (STATUS_CODE_PAGES.has(pathname)) {
|
||||||
const baseName = npath.basename(pathname);
|
const baseName = npath.basename(pathname);
|
||||||
return new URL('./' + (baseName || 'index') + '.html', outFolder);
|
return new URL('./' + (baseName || 'index') + '.html', outFolder);
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,9 +113,9 @@ function addTrailingSlash(str: string): string {
|
||||||
|
|
||||||
/** Convert the generic "yargs" flag object into our own, custom TypeScript object. */
|
/** Convert the generic "yargs" flag object into our own, custom TypeScript object. */
|
||||||
function resolveFlags(flags: Partial<Flags>): CLIFlags {
|
function resolveFlags(flags: Partial<Flags>): CLIFlags {
|
||||||
if(flags.experimentalStaticBuild) {
|
if (flags.experimentalStaticBuild) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn(`Passing --experimental-static-build is no longer necessary and is now the default. The flag will be removed in a future version of Astro.`)
|
console.warn(`Passing --experimental-static-build is no longer necessary and is now the default. The flag will be removed in a future version of Astro.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -29,13 +29,13 @@ const HAS_FILE_EXTENSION_REGEXP = /^.*\.[^\\]+$/;
|
||||||
export default async function preview(config: AstroConfig, { logging }: PreviewOptions): Promise<PreviewServer> {
|
export default async function preview(config: AstroConfig, { logging }: PreviewOptions): Promise<PreviewServer> {
|
||||||
const startServerTime = performance.now();
|
const startServerTime = performance.now();
|
||||||
const defaultOrigin = 'http://localhost';
|
const defaultOrigin = 'http://localhost';
|
||||||
const trailingSlash = config.devOptions.trailingSlash
|
const trailingSlash = config.devOptions.trailingSlash;
|
||||||
/** Base request URL. */
|
/** Base request URL. */
|
||||||
let baseURL = new URL(config.buildOptions.site || '/', defaultOrigin);
|
let baseURL = new URL(config.buildOptions.site || '/', defaultOrigin);
|
||||||
const staticFileServer = sirv(fileURLToPath(config.dist), {
|
const staticFileServer = sirv(fileURLToPath(config.dist), {
|
||||||
etag: true,
|
etag: true,
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
})
|
});
|
||||||
// Create the preview server, send static files out of the `dist/` directory.
|
// Create the preview server, send static files out of the `dist/` directory.
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const requestURL = new URL(req.url as string, defaultOrigin);
|
const requestURL = new URL(req.url as string, defaultOrigin);
|
||||||
|
@ -56,7 +56,7 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO
|
||||||
function sendError(message: string) {
|
function sendError(message: string) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end(notFoundTemplate(pathname, message));
|
res.end(notFoundTemplate(pathname, message));
|
||||||
};
|
}
|
||||||
|
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case hasTrailingSlash && trailingSlash == 'never' && !isRoot:
|
case hasTrailingSlash && trailingSlash == 'never' && !isRoot:
|
||||||
|
@ -67,7 +67,7 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO
|
||||||
return;
|
return;
|
||||||
default: {
|
default: {
|
||||||
// HACK: rewrite req.url so that sirv finds the file
|
// HACK: rewrite req.url so that sirv finds the file
|
||||||
req.url = '/' + req.url?.replace(baseURL.pathname, '')
|
req.url = '/' + req.url?.replace(baseURL.pathname, '');
|
||||||
staticFileServer(req, res, () => sendError('Not Found'));
|
staticFileServer(req, res, () => sendError('Not Found'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -125,7 +125,7 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO
|
||||||
server: httpServer!,
|
server: httpServer!,
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
httpServer.close((err) => err ? reject(err) : resolve(undefined));
|
httpServer.close((err) => (err ? reject(err) : resolve(undefined)));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -103,11 +103,11 @@ export async function render(opts: RenderOptions): Promise<string> {
|
||||||
let html = await renderToString(result, Component, pageProps, null);
|
let html = await renderToString(result, Component, pageProps, null);
|
||||||
|
|
||||||
// handle final head injection if it hasn't happened already
|
// handle final head injection if it hasn't happened already
|
||||||
if (html.indexOf("<!--astro:head:injected-->") == -1) {
|
if (html.indexOf('<!--astro:head:injected-->') == -1) {
|
||||||
html = await renderHead(result) + html;
|
html = (await renderHead(result)) + html;
|
||||||
}
|
}
|
||||||
// cleanup internal state flags
|
// cleanup internal state flags
|
||||||
html = html.replace("<!--astro:head:injected-->", '');
|
html = html.replace('<!--astro:head:injected-->', '');
|
||||||
|
|
||||||
// inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?)
|
// inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?)
|
||||||
if (!legacyBuild && !/<!doctype html/i.test(html)) {
|
if (!legacyBuild && !/<!doctype html/i.test(html)) {
|
||||||
|
|
|
@ -51,9 +51,7 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
|
||||||
const legacy = astroConfig.buildOptions.legacyBuild;
|
const legacy = astroConfig.buildOptions.legacyBuild;
|
||||||
|
|
||||||
// Add hoisted script tags
|
// Add hoisted script tags
|
||||||
const scripts = createModuleScriptElementWithSrcSet(
|
const scripts = createModuleScriptElementWithSrcSet(!legacy && mod.hasOwnProperty('$$metadata') ? Array.from(mod.$$metadata.hoistedScriptPaths()) : []);
|
||||||
!legacy && mod.hasOwnProperty('$$metadata') ? Array.from(mod.$$metadata.hoistedScriptPaths()) : []
|
|
||||||
);
|
|
||||||
|
|
||||||
// Inject HMR scripts
|
// Inject HMR scripts
|
||||||
if (mod.hasOwnProperty('$$metadata') && mode === 'development' && !legacy) {
|
if (mod.hasOwnProperty('$$metadata') && mode === 'development' && !legacy) {
|
||||||
|
@ -69,7 +67,7 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
|
||||||
|
|
||||||
// Pass framework CSS in as link tags to be appended to the page.
|
// Pass framework CSS in as link tags to be appended to the page.
|
||||||
let links = new Set<SSRElement>();
|
let links = new Set<SSRElement>();
|
||||||
if(!legacy) {
|
if (!legacy) {
|
||||||
[...getStylesForURL(filePath, viteServer)].forEach((href) => {
|
[...getStylesForURL(filePath, viteServer)].forEach((href) => {
|
||||||
if (mode === 'development' && svelteStylesRE.test(href)) {
|
if (mode === 'development' && svelteStylesRE.test(href)) {
|
||||||
scripts.add({
|
scripts.add({
|
||||||
|
@ -136,7 +134,7 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
|
||||||
}
|
}
|
||||||
|
|
||||||
// inject CSS
|
// inject CSS
|
||||||
if(legacy) {
|
if (legacy) {
|
||||||
[...getStylesForURL(filePath, viteServer)].forEach((href) => {
|
[...getStylesForURL(filePath, viteServer)].forEach((href) => {
|
||||||
if (mode === 'development' && svelteStylesRE.test(href)) {
|
if (mode === 'development' && svelteStylesRE.test(href)) {
|
||||||
tags.push({
|
tags.push({
|
||||||
|
@ -158,7 +156,6 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// add injected tags
|
// add injected tags
|
||||||
content = injectTags(content, tags);
|
content = injectTags(content, tags);
|
||||||
|
|
||||||
|
|
|
@ -116,7 +116,7 @@ ${extra}`
|
||||||
_metadata: {
|
_metadata: {
|
||||||
renderers,
|
renderers,
|
||||||
pathname,
|
pathname,
|
||||||
legacyBuild
|
legacyBuild,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -337,9 +337,7 @@ export function createAstro(filePathname: string, _site: string, projectRootStr:
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const toAttributeString = (value: any, shouldEscape = true) => shouldEscape ?
|
const toAttributeString = (value: any, shouldEscape = true) => (shouldEscape ? String(value).replace(/&/g, '&').replace(/"/g, '"') : value);
|
||||||
String(value).replace(/&/g, '&').replace(/"/g, '"') :
|
|
||||||
value;
|
|
||||||
|
|
||||||
const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']);
|
const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']);
|
||||||
|
|
||||||
|
@ -439,7 +437,6 @@ const uniqueElements = (item: any, index: number, all: any[]) => {
|
||||||
return index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children);
|
return index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Renders a page to completion by first calling the factory callback, waiting for its result, and then appending
|
// Renders a page to completion by first calling the factory callback, waiting for its result, and then appending
|
||||||
// styles and scripts into the head.
|
// styles and scripts into the head.
|
||||||
export async function renderHead(result: SSRResult) {
|
export async function renderHead(result: SSRResult) {
|
||||||
|
|
|
@ -55,29 +55,31 @@ export default function envVitePlugin({ config: astroConfig }: EnvPluginOptions)
|
||||||
async transform(source, id, options) {
|
async transform(source, id, options) {
|
||||||
const ssr = options?.ssr === true;
|
const ssr = options?.ssr === true;
|
||||||
|
|
||||||
if(!ssr) {
|
if (!ssr) {
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!source.includes('import.meta') || !/\benv\b/.test(source)) {
|
if (!source.includes('import.meta') || !/\benv\b/.test(source)) {
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof privateEnv === 'undefined') {
|
if (typeof privateEnv === 'undefined') {
|
||||||
privateEnv = getPrivateEnv(config, astroConfig);
|
privateEnv = getPrivateEnv(config, astroConfig);
|
||||||
if(privateEnv) {
|
if (privateEnv) {
|
||||||
const entries = Object.entries(privateEnv).map(([key, value]) => ([`import.meta.env.${key}`, value]));
|
const entries = Object.entries(privateEnv).map(([key, value]) => [`import.meta.env.${key}`, value]);
|
||||||
replacements = Object.fromEntries(entries);
|
replacements = Object.fromEntries(entries);
|
||||||
pattern = new RegExp(
|
pattern = new RegExp(
|
||||||
// Do not allow preceding '.', but do allow preceding '...' for spread operations
|
// Do not allow preceding '.', but do allow preceding '...' for spread operations
|
||||||
'(?<!(?<!\\.\\.)\\.)\\b(' +
|
'(?<!(?<!\\.\\.)\\.)\\b(' +
|
||||||
Object.keys(replacements)
|
Object.keys(replacements)
|
||||||
.map((str) => {
|
.map((str) => {
|
||||||
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
|
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
|
||||||
})
|
})
|
||||||
.join('|') +
|
.join('|') +
|
||||||
// prevent trailing assignments
|
// prevent trailing assignments
|
||||||
')\\b(?!\\s*?=[^=])', 'g');
|
')\\b(?!\\s*?=[^=])',
|
||||||
|
'g'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,13 +88,13 @@ export default function envVitePlugin({ config: astroConfig }: EnvPluginOptions)
|
||||||
|
|
||||||
// Find matches for *private* env and do our own replacement.
|
// Find matches for *private* env and do our own replacement.
|
||||||
const s = new MagicString(source);
|
const s = new MagicString(source);
|
||||||
let match: RegExpExecArray | null
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = pattern.exec(source))) {
|
while ((match = pattern.exec(source))) {
|
||||||
const start = match.index
|
const start = match.index;
|
||||||
const end = start + match[0].length
|
const end = start + match[0].length;
|
||||||
const replacement = '' + replacements[match[1]]
|
const replacement = '' + replacements[match[1]];
|
||||||
s.overwrite(start, end, replacement)
|
s.overwrite(start, end, replacement);
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.toString();
|
return s.toString();
|
||||||
|
|
|
@ -17,9 +17,9 @@ describe('CSS', function () {
|
||||||
renderers: ['@astrojs/renderer-react', '@astrojs/renderer-svelte', '@astrojs/renderer-vue'],
|
renderers: ['@astrojs/renderer-react', '@astrojs/renderer-svelte', '@astrojs/renderer-vue'],
|
||||||
vite: {
|
vite: {
|
||||||
build: {
|
build: {
|
||||||
assetsInlineLimit: 0
|
assetsInlineLimit: 0,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -15,9 +15,9 @@ describe('Assets', () => {
|
||||||
projectRoot: './fixtures/astro-assets/',
|
projectRoot: './fixtures/astro-assets/',
|
||||||
vite: {
|
vite: {
|
||||||
build: {
|
build: {
|
||||||
assetsInlineLimit: 0
|
assetsInlineLimit: 0,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
await fixture.build();
|
await fixture.build();
|
||||||
});
|
});
|
||||||
|
@ -26,7 +26,7 @@ describe('Assets', () => {
|
||||||
const html = await fixture.readFile('/index.html');
|
const html = await fixture.readFile('/index.html');
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const imgPath = $('img').attr('src');
|
const imgPath = $('img').attr('src');
|
||||||
const data = await fixture.readFile( imgPath);
|
const data = await fixture.readFile(imgPath);
|
||||||
expect(!!data).to.equal(true);
|
expect(!!data).to.equal(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ describe('Astro.*', () => {
|
||||||
projectRoot: './fixtures/astro-global/',
|
projectRoot: './fixtures/astro-global/',
|
||||||
buildOptions: {
|
buildOptions: {
|
||||||
site: 'https://mysite.dev/blog/',
|
site: 'https://mysite.dev/blog/',
|
||||||
sitemap: false
|
sitemap: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await fixture.build();
|
await fixture.build();
|
||||||
|
|
|
@ -26,11 +26,13 @@ describe('Partial HTML ', async () => {
|
||||||
|
|
||||||
// test 2: correct CSS present
|
// test 2: correct CSS present
|
||||||
const link = $('link').attr('href');
|
const link = $('link').attr('href');
|
||||||
const css = await fixture.fetch(link, {
|
const css = await fixture
|
||||||
headers: {
|
.fetch(link, {
|
||||||
accept: 'text/css'
|
headers: {
|
||||||
}
|
accept: 'text/css',
|
||||||
}).then(res => res.text());
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.text());
|
||||||
expect(css).to.match(/\.astro-[^{]+{color:red;}/);
|
expect(css).to.match(/\.astro-[^{]+{color:red;}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -11,9 +11,9 @@ describe('Scripts (hoisted and not)', () => {
|
||||||
projectRoot: './fixtures/astro-scripts/',
|
projectRoot: './fixtures/astro-scripts/',
|
||||||
vite: {
|
vite: {
|
||||||
build: {
|
build: {
|
||||||
assetsInlineLimit: 0
|
assetsInlineLimit: 0,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
await fixture.build();
|
await fixture.build();
|
||||||
});
|
});
|
||||||
|
@ -45,7 +45,7 @@ describe('Scripts (hoisted and not)', () => {
|
||||||
// test 2: attr removed
|
// test 2: attr removed
|
||||||
expect($('script').attr('data-astro')).to.equal(undefined);
|
expect($('script').attr('data-astro')).to.equal(undefined);
|
||||||
|
|
||||||
const entryURL = $('script').attr('src');
|
const entryURL = $('script').attr('src');
|
||||||
const inlineEntryJS = await fixture.readFile(entryURL);
|
const inlineEntryJS = await fixture.readFile(entryURL);
|
||||||
|
|
||||||
// test 3: the JS exists
|
// test 3: the JS exists
|
||||||
|
|
|
@ -31,7 +31,7 @@ describe('Static build - frameworks', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// SKIP: Lit polyfills the server in a way that breaks `sass` require/import
|
// SKIP: Lit polyfills the server in a way that breaks `sass` require/import
|
||||||
// Leads to CI bugs like: "Cannot read properties of undefined (reading 'length')"
|
// Leads to CI bugs like: "Cannot read properties of undefined (reading 'length')"
|
||||||
it.skip('can build lit', async () => {
|
it.skip('can build lit', async () => {
|
||||||
const html = await fixture.readFile('/lit/index.html');
|
const html = await fixture.readFile('/lit/index.html');
|
||||||
expect(html).to.be.a('string');
|
expect(html).to.be.a('string');
|
||||||
|
|
Loading…
Add table
Reference in a new issue