mirror of
https://github.com/coderaiser/cloudcmd.git
synced 2026-07-17 16:38:18 +00:00
feature: cloudcmd: migrate to ESM
This commit is contained in:
parent
e8a81c49ea
commit
4533a25c6f
184 changed files with 339 additions and 388 deletions
|
|
@ -1,12 +1,11 @@
|
|||
'use strict';
|
||||
import httpAuth from 'http-auth';
|
||||
import criton from 'criton';
|
||||
import currify from 'currify';
|
||||
|
||||
const httpAuth = require('http-auth');
|
||||
const criton = require('criton');
|
||||
const currify = require('currify');
|
||||
const middle = currify(_middle);
|
||||
const check = currify(_check);
|
||||
|
||||
module.exports = (config) => {
|
||||
export default (config) => {
|
||||
const auth = httpAuth.basic({
|
||||
realm: 'Cloud Commander',
|
||||
}, check(config));
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ import nomine from 'nomine';
|
|||
import fileop from '@cloudcmd/fileop';
|
||||
import * as cloudfunc from '#common/cloudfunc';
|
||||
import authentication from './auth.js';
|
||||
import {createConfig, configPath} from './config.mjs';
|
||||
import modulas from './modulas.mjs';
|
||||
import userMenu from './user-menu.mjs';
|
||||
import rest from './rest/index.mjs';
|
||||
import route from './route.mjs';
|
||||
import * as validate from './validate.mjs';
|
||||
import {createConfig, configPath} from './config.js';
|
||||
import modulas from './modulas.js';
|
||||
import userMenu from './user-menu.js';
|
||||
import rest from './rest/index.js';
|
||||
import route from './route.js';
|
||||
import * as validate from './validate.js';
|
||||
import prefixer from './prefixer.js';
|
||||
import terminal from './terminal.js';
|
||||
import {distributeExport} from './distribute/export.mjs';
|
||||
import {distributeExport} from './distribute/export.js';
|
||||
import {createDepStore} from './depstore.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -9,7 +9,7 @@ import cloudcmd, {
|
|||
_getPrefix,
|
||||
_initAuth,
|
||||
_getIndexPath,
|
||||
} from './cloudcmd.mjs';
|
||||
} from './cloudcmd.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -2,7 +2,7 @@ import {dirname} from 'node:path';
|
|||
import {fileURLToPath} from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
import test from 'supertape';
|
||||
import {getColumns, isDev} from './columns.mjs';
|
||||
import {getColumns, isDev} from './columns.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -1,32 +1,24 @@
|
|||
import path, {dirname} from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import Emitter from 'node:events';
|
||||
import {homedir} from 'node:os';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {dirname} from 'node:path';
|
||||
import {createRequire} from 'node:module';
|
||||
import currify from 'currify';
|
||||
import wraptile from 'wraptile';
|
||||
import {tryToCatch} from 'try-to-catch';
|
||||
import pullout from 'pullout';
|
||||
import ponse from 'ponse';
|
||||
import jonny from 'jonny';
|
||||
import jju from 'jju';
|
||||
import writejson from 'writejson';
|
||||
import {tryCatch} from 'try-catch';
|
||||
import criton from 'criton';
|
||||
import * as CloudFunc from '#common/cloudfunc';
|
||||
import exit from './exit.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
const DIR_SERVER = `${__dirname}/`;
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
const fs = require('node:fs');
|
||||
const Emitter = require('node:events');
|
||||
const {homedir} = require('node:os');
|
||||
|
||||
const currify = require('currify');
|
||||
|
||||
const wraptile = require('wraptile');
|
||||
const {tryToCatch} = require('try-to-catch');
|
||||
const pullout = require('pullout');
|
||||
const ponse = require('ponse');
|
||||
const jonny = require('jonny');
|
||||
const jju = require('jju');
|
||||
const writejson = require('writejson');
|
||||
const {tryCatch} = require('try-catch');
|
||||
const criton = require('criton');
|
||||
const exit = require(`${DIR_SERVER}exit`);
|
||||
|
||||
const CloudFunc = require('../common/cloudfunc.mjs');
|
||||
const isUndefined = (a) => typeof a === 'undefined';
|
||||
const DIR = `${DIR_SERVER}../`;
|
||||
const HOME = homedir();
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import {createRequire} from 'node:module';
|
||||
import {test, stub} from 'supertape';
|
||||
import {apiURL} from '#common/cloudfunc';
|
||||
import {createConfig, _cryptoPass} from './config.mjs';
|
||||
import {connect} from '../test/before.mjs';
|
||||
import {createConfig, _cryptoPass} from './config.js';
|
||||
import {connect} from '../test/before.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const fixture = require('./config.fixture.json');
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
'use strict';
|
||||
|
||||
module.exports.createDepStore = () => {
|
||||
export const createDepStore = () => {
|
||||
const deps = {};
|
||||
|
||||
return (name, value) => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import log, {
|
|||
getMessage,
|
||||
getDescription,
|
||||
logWrapped,
|
||||
} from './log.mjs';
|
||||
} from './log.js';
|
||||
|
||||
const omitList = [
|
||||
'auth',
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import {once} from 'node:events';
|
||||
import test from 'supertape';
|
||||
import io from 'socket.io-client';
|
||||
import * as Config from '../config.mjs';
|
||||
import {connect} from '../../test/before.mjs';
|
||||
import * as Config from '../config.js';
|
||||
import {connect} from '../../test/before.js';
|
||||
|
||||
const config = Config.createConfig();
|
||||
|
||||
|
|
@ -4,8 +4,8 @@ import squad from 'squad';
|
|||
import {fullstore} from 'fullstore';
|
||||
import io from 'socket.io-client';
|
||||
import _forEachKey from 'for-each-key';
|
||||
import log from './log.mjs';
|
||||
import * as env from '../env.mjs';
|
||||
import log from './log.js';
|
||||
import * as env from '../env.js';
|
||||
|
||||
const noop = () => {};
|
||||
const forEachKey = currify(_forEachKey);
|
||||
|
|
@ -2,9 +2,9 @@ import process from 'node:process';
|
|||
import {promisify} from 'node:util';
|
||||
import test from 'supertape';
|
||||
import {tryToCatch} from 'try-to-catch';
|
||||
import {connect} from '../../test/before.mjs';
|
||||
import {createConfigManager} from '../cloudcmd.mjs';
|
||||
import {distributeImport} from './import.mjs';
|
||||
import {connect} from '../../test/before.js';
|
||||
import {createConfigManager} from '../cloudcmd.js';
|
||||
import {distributeImport} from './import.js';
|
||||
|
||||
const distribute = {
|
||||
import: promisify(distributeImport),
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import test from 'supertape';
|
||||
import log from './log.mjs';
|
||||
import {createConfig} from '../config.mjs';
|
||||
import log from './log.js';
|
||||
import {createConfig} from '../config.js';
|
||||
|
||||
test('distribute: log: getMessage', (t) => {
|
||||
const e = 'hello';
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const process = require('node:process');
|
||||
const {test} = require('supertape');
|
||||
const env = require('./env.mjs');
|
||||
import process from 'node:process';
|
||||
import {test} from 'supertape';
|
||||
import * as env from './env.js';
|
||||
|
||||
test('cloudcmd: server: env: bool: upper case first', (t) => {
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
'use strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const process = require('node:process');
|
||||
const getMessage = (a) => a?.message || a;
|
||||
|
||||
module.exports = (...args) => {
|
||||
export default (...args) => {
|
||||
const messages = args.map(getMessage);
|
||||
|
||||
console.error(...messages);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const process = require('node:process');
|
||||
const {test, stub} = require('supertape');
|
||||
|
||||
const exit = require('./exit');
|
||||
import process from 'node:process';
|
||||
import {test, stub} from 'supertape';
|
||||
import exit from './exit.js';
|
||||
|
||||
test('cloudcmd: exit: process.exit', (t) => {
|
||||
const {exit: exitOriginal} = process;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
'use strict';
|
||||
import {callbackify} from 'node:util';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {dirname} from 'node:path';
|
||||
import pullout from 'pullout';
|
||||
import ponse from 'ponse';
|
||||
import {read} from 'redzip';
|
||||
import root from '../root.js';
|
||||
import parse from './worker.js';
|
||||
|
||||
const {callbackify} = require('node:util');
|
||||
|
||||
const pullout = require('pullout');
|
||||
const ponse = require('ponse');
|
||||
const {read} = require('redzip');
|
||||
|
||||
const root = require('../root');
|
||||
|
||||
const parse = require('./worker');
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const isString = (a) => typeof a === 'string';
|
||||
|
||||
// warm up
|
||||
|
|
@ -16,7 +16,7 @@ parse('');
|
|||
|
||||
const DIR_ROOT = `${__dirname}/../../`;
|
||||
|
||||
module.exports = callbackify(async (name, rootDir, request) => {
|
||||
export default callbackify(async (name, rootDir, request) => {
|
||||
check(name, request);
|
||||
|
||||
const {method} = request;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {tryToCatch} from 'try-to-catch';
|
|||
import test from 'supertape';
|
||||
import serveOnce from 'serve-once';
|
||||
import markdown from './index.js';
|
||||
import cloudcmd from '../cloudcmd.mjs';
|
||||
import cloudcmd from '../cloudcmd.js';
|
||||
|
||||
const config = {
|
||||
auth: false,
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
'use strict';
|
||||
import createMarkdownIt from 'markdown-it';
|
||||
|
||||
const markdownIt = require('markdown-it')();
|
||||
const markdownIt = createMarkdownIt();
|
||||
|
||||
module.exports = (a) => markdownIt.render(a);
|
||||
export default (a) => markdownIt.render(a);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const isString = (a) => typeof a === 'string';
|
||||
|
||||
module.exports = (value) => {
|
||||
export default (value) => {
|
||||
if (!isString(value))
|
||||
return '';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
const {test} = require('supertape');
|
||||
|
||||
const prefixer = require('./prefixer');
|
||||
import {test} from 'supertape';
|
||||
import prefixer from './prefixer.js';
|
||||
|
||||
test('prefixer: prefix without a slash', (t) => {
|
||||
t.equal(prefixer('hello'), '/hello', 'should add slash');
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
'use strict';
|
||||
import process from 'node:process';
|
||||
import net from 'node:net';
|
||||
import repl from 'node:repl';
|
||||
|
||||
const process = require('node:process');
|
||||
const net = require('node:net');
|
||||
const repl = require('node:repl');
|
||||
|
||||
module.exports = net
|
||||
export default net
|
||||
.createServer((socket) => {
|
||||
const {pid} = process;
|
||||
const addr = socket.remoteAddress;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
'use strict';
|
||||
|
||||
const {test} = require('supertape');
|
||||
const {tryToCatch} = require('try-to-catch');
|
||||
|
||||
const {
|
||||
import {test} from 'supertape';
|
||||
import {tryToCatch} from 'try-to-catch';
|
||||
import {
|
||||
_formatMsg,
|
||||
_getWin32RootMsg,
|
||||
_isRootWin32,
|
||||
_isRootAll,
|
||||
_onPUT,
|
||||
} = require('./index.mjs');
|
||||
} from './index.js';
|
||||
|
||||
test('rest: formatMsg', (t) => {
|
||||
const result = _formatMsg('hello', 'world');
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
'use strict';
|
||||
import process from 'node:process';
|
||||
import format from 'format-io';
|
||||
import info from '../../package.json' with {
|
||||
type: 'json',
|
||||
};
|
||||
|
||||
const process = require('node:process');
|
||||
const format = require('format-io');
|
||||
|
||||
const {version} = require('../../package');
|
||||
const {version} = info;
|
||||
|
||||
const getMemory = () => {
|
||||
const {rss} = process.memoryUsage();
|
||||
return format.size(rss);
|
||||
};
|
||||
|
||||
module.exports = (prefix) => ({
|
||||
export default (prefix) => ({
|
||||
version,
|
||||
prefix,
|
||||
memory: getMemory(),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const process = require('node:process');
|
||||
const {test, stub} = require('supertape');
|
||||
|
||||
const info = require('./info');
|
||||
import process from 'node:process';
|
||||
import {test, stub} from 'supertape';
|
||||
import info from './info.js';
|
||||
|
||||
test('cloudcmd: rest: info', (t) => {
|
||||
const {memoryUsage} = process;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
'use strict';
|
||||
import mellow from 'mellow';
|
||||
|
||||
const mellow = require('mellow');
|
||||
|
||||
module.exports = (dir, root, {webToWin = mellow.webToWin} = {}) => {
|
||||
export default (dir, root, {webToWin = mellow.webToWin} = {}) => {
|
||||
return webToWin(dir, root || '/');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
const {test, stub} = require('supertape');
|
||||
|
||||
const root = require('./root');
|
||||
import {test, stub} from 'supertape';
|
||||
import root from './root.js';
|
||||
|
||||
test('cloudcmd: root: mellow', (t) => {
|
||||
const webToWin = stub();
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import * as CloudFunc from '#common/cloudfunc';
|
|||
import root from './root.js';
|
||||
import prefixer from './prefixer.js';
|
||||
import Template from './template.js';
|
||||
import {getColumns} from './columns.mjs';
|
||||
import {getThemes} from './theme.mjs';
|
||||
import {getColumns} from './columns.js';
|
||||
import {getThemes} from './theme.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {stringify} = JSON;
|
||||
|
|
@ -5,8 +5,8 @@ import fs from 'node:fs';
|
|||
import {tryToCatch} from 'try-to-catch';
|
||||
import {test, stub} from 'supertape';
|
||||
import serveOnce from 'serve-once';
|
||||
import cloudcmd from './cloudcmd.mjs';
|
||||
import {_getReadDir} from './route.mjs';
|
||||
import cloudcmd from './cloudcmd.js';
|
||||
import {_getReadDir} from './route.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -11,7 +11,7 @@ import tryRequire from 'tryrequire';
|
|||
import wraptile from 'wraptile';
|
||||
import compression from 'compression';
|
||||
import exit from './exit.js';
|
||||
import cloudcmd from './cloudcmd.mjs';
|
||||
import cloudcmd from './cloudcmd.js';
|
||||
|
||||
const bind = (f, self) => f.bind(self);
|
||||
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
'use strict';
|
||||
|
||||
const {
|
||||
import {
|
||||
table,
|
||||
getBorderCharacters,
|
||||
} = require('table');
|
||||
} from 'table';
|
||||
|
||||
module.exports = (config) => {
|
||||
export const showConfig = (config) => {
|
||||
check(config);
|
||||
|
||||
const data = Object
|
||||
|
|
|
|||
37
server/show-config.spec.js
Normal file
37
server/show-config.spec.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {test} from 'supertape';
|
||||
import {tryCatch} from 'try-catch';
|
||||
import {showConfig} from './show-config.js';
|
||||
|
||||
test('cloudcmd: show-config: no arguments', (t) => {
|
||||
const [error] = tryCatch(showConfig);
|
||||
|
||||
t.equal(error.message, 'config could not be empty!', 'should throw when no config');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('cloudcmd: show-config: bad arguments', (t) => {
|
||||
const [error] = tryCatch(showConfig, 'hello');
|
||||
|
||||
t.equal(error.message, 'config should be an object!', 'should throw when config not object');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('cloudcmd: show-config: empty: return', (t) => {
|
||||
t.equal(showConfig({}), '', 'should return string');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('cloudcmd: show-config: return', (t) => {
|
||||
const config = {
|
||||
hello: 'world',
|
||||
};
|
||||
|
||||
const result = [
|
||||
'+-------+--------------------------------+\n',
|
||||
'| hello | world |\n',
|
||||
'+-------+--------------------------------+\n',
|
||||
].join('');
|
||||
|
||||
t.equal(showConfig(config), result, 'should return table');
|
||||
t.end();
|
||||
});
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
'use strict';
|
||||
import path, {dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import readFilesSync from '@cloudcmd/read-files-sync';
|
||||
|
||||
const path = require('node:path');
|
||||
const readFilesSync = require('@cloudcmd/read-files-sync');
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const templatePath = path.join(__dirname, '..', 'tmpl/fs');
|
||||
|
||||
module.exports = readFilesSync(templatePath, 'utf8');
|
||||
export default readFilesSync(templatePath, 'utf8');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {tryCatch} from 'try-catch';
|
||||
|
||||
const {tryCatch} = require('try-catch');
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const noop = (req, res, next) => {
|
||||
next && next();
|
||||
|
|
@ -10,7 +11,7 @@ noop.listen = noop;
|
|||
|
||||
const _getModule = (a) => require(a);
|
||||
|
||||
module.exports = (config, arg, overrides = {}) => {
|
||||
export default (config, arg, overrides = {}) => {
|
||||
const {
|
||||
getModule = _getModule,
|
||||
} = overrides;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {test, stub} from 'supertape';
|
||||
import terminal from './terminal.js';
|
||||
import {createConfigManager} from './cloudcmd.mjs';
|
||||
import {createConfigManager} from './cloudcmd.js';
|
||||
|
||||
test('cloudcmd: terminal: disabled', (t) => {
|
||||
const config = createConfigManager();
|
||||
|
|
@ -2,7 +2,7 @@ import {dirname} from 'node:path';
|
|||
import {fileURLToPath} from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
import test from 'supertape';
|
||||
import {getThemes, isDev} from './theme.mjs';
|
||||
import {getThemes, isDev} from './theme.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -3,14 +3,14 @@ import {fileURLToPath} from 'node:url';
|
|||
import {readFileSync} from 'node:fs';
|
||||
import {test, stub} from 'supertape';
|
||||
import serveOnce from 'serve-once';
|
||||
import userMenu from './user-menu.mjs';
|
||||
import {putout} from 'putout';
|
||||
import userMenu from './user-menu.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const {request} = serveOnce(userMenu);
|
||||
const userMenuPath = join(__dirname, '..', '.cloudcmd.menu.js');
|
||||
const userMenuFile = readFileSync(userMenuPath, 'utf8');
|
||||
|
||||
const fixtureDir = new URL('fixture-user-menu', import.meta.url).pathname;
|
||||
const fixtureMoveName = join(fixtureDir, 'io-mv.js');
|
||||
|
|
@ -24,6 +24,7 @@ const fixtureCopy = readFileSync(fixtureCopyName, 'utf8');
|
|||
const fixtureCopyFix = readFileSync(fixtureCopyFixName, 'utf8');
|
||||
|
||||
test('cloudcmd: user menu', async (t) => {
|
||||
const userMenuFile = getUserMenuFile();
|
||||
const options = {
|
||||
menuName: '.cloudcmd.menu.js',
|
||||
};
|
||||
|
|
@ -69,3 +70,15 @@ test('cloudcmd: user menu: io.cp', async (t) => {
|
|||
t.equal(body, fixtureCopyFix);
|
||||
t.end();
|
||||
});
|
||||
|
||||
function getUserMenuFile() {
|
||||
const userMenuFile = readFileSync(userMenuPath, 'utf8');
|
||||
const {code} = putout(userMenuFile, {
|
||||
rules: {
|
||||
'nodejs/convert-esm-to-commonjs': 'on',
|
||||
},
|
||||
plugins: ['nodejs'],
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import {statSync as _statSync} from 'node:fs';
|
||||
import {tryCatch} from 'try-catch';
|
||||
import _exit from './exit.js';
|
||||
import {getColumns as _getColumns} from './columns.mjs';
|
||||
import {getThemes as _getThemes} from './theme.mjs';
|
||||
import {getColumns as _getColumns} from './columns.js';
|
||||
import {getThemes as _getThemes} from './theme.js';
|
||||
|
||||
const isString = (a) => typeof a === 'string';
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import {test, stub} from 'supertape';
|
||||
import {tryCatch} from 'try-catch';
|
||||
import * as validate from './validate.mjs';
|
||||
import cloudcmd from './cloudcmd.mjs';
|
||||
import * as validate from './validate.js';
|
||||
import cloudcmd from './cloudcmd.js';
|
||||
|
||||
test('validate: root: bad', (t) => {
|
||||
const config = {
|
||||
Loading…
Add table
Add a link
Reference in a new issue