Merge branch 'v2' of https://github.com/Thomas-Smyth/SquadJS into Thomas-Smyth-v2

This commit is contained in:
SeanWalsh95 2020-11-05 11:02:16 -05:00
commit 013ace439f
9 changed files with 280 additions and 271 deletions

View File

@ -17,7 +17,9 @@ export default class LogParser extends EventEmitter {
this.eventStore = {}; this.eventStore = {};
this.linesPerMinute = 0; this.linesPerMinute = 0;
this.linesPerMinuteInterval = null; this.matchingLinesPerMinute = 0;
this.matchingLatency = 0;
this.parsingStatsInterval = null;
this.queue = async.queue(async (line) => { this.queue = async.queue(async (line) => {
Logger.verbose('LogParser', 4, `Matching on line: ${line}`); Logger.verbose('LogParser', 4, `Matching on line: ${line}`);
@ -33,6 +35,9 @@ export default class LogParser extends EventEmitter {
rule.onMatch(match, this); rule.onMatch(match, this);
this.matchingLinesPerMinute++;
this.matchingLatency += Date.now() - match[1];
break; break;
} }
@ -56,15 +61,27 @@ export default class LogParser extends EventEmitter {
await this.logReader.watch(); await this.logReader.watch();
Logger.verbose('LogParser', 1, 'Watching log file...'); Logger.verbose('LogParser', 1, 'Watching log file...');
this.linesPerMinuteInterval = setInterval(() => { this.parsingStatsInterval = setInterval(() => {
Logger.verbose('LogParser', 1, `Processing ${this.linesPerMinute} lines per minute.`); Logger.verbose(
'LogParser',
1,
`Lines parsed per minute: ${
this.linesPerMinute
} lines per minute | Matching lines per minute: ${
this.matchingLinesPerMinute
} matching lines per minute | Average matching latency: ${
this.matchingLatency / this.matchingLinesPerMinute
}ms`
);
this.linesPerMinute = 0; this.linesPerMinute = 0;
this.matchingLinesPerMinute = 0;
this.matchingLatency = 0;
}, 60 * 1000); }, 60 * 1000);
} }
async unwatch() { async unwatch() {
await this.logReader.unwatch(); await this.logReader.unwatch();
clearInterval(this.linesPerMinuteInterval); clearInterval(this.parsingStatsInterval);
} }
} }

View File

@ -14,9 +14,9 @@
], ],
"scripts": { "scripts": {
"lint": "eslint --fix . && prettier --write \"./**/*.js\"", "lint": "eslint --fix . && prettier --write \"./**/*.js\"",
"build-config": "node squad-server/scripts/build-config.js", "build-config": "node squad-server/scripts/build-config-file.js",
"build-readme": "node squad-server/scripts/build-readme.js", "build-readme": "node squad-server/scripts/build-readme.js",
"build-all": "node squad-server/scripts/build-config.js && node squad-server/scripts/build-readme.js" "build-all": "node squad-server/scripts/build-config-file.js && node squad-server/scripts/build-readme.js"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {

View File

@ -46,14 +46,18 @@ export default class Rcon extends EventEmitter {
this.autoReconnect = false; this.autoReconnect = false;
this.autoReconnectTimeout = null; this.autoReconnectTimeout = null;
this.responseActionQueue = []; this.incomingData = Buffer.from([]);
this.responsePacketQueue = []; this.incomingResponse = [];
this.responseCallbackQueue = [];
} }
onData(buf) { onData(data) {
Logger.verbose('RCON', 4, `Got data: ${this.bufToHexString(buf)}`); Logger.verbose('RCON', 4, `Got data: ${this.bufToHexString(data)}`);
const packets = this.splitPackets(buf); // the logic in this method simply splits data sent via the data event into packets regardless of how they're
// distributed in the event calls
const packets = this.decodeData(data);
for (const packet of packets) { for (const packet of packets) {
Logger.verbose('RCON', 4, `Processing packet: ${this.bufToHexString(packet)}`); Logger.verbose('RCON', 4, `Processing packet: ${this.bufToHexString(packet)}`);
@ -65,68 +69,85 @@ export default class Rcon extends EventEmitter {
`Processing decoded packet: ${this.decodedPacketToString(decodedPacket)}` `Processing decoded packet: ${this.decodedPacketToString(decodedPacket)}`
); );
if (decodedPacket.type === SERVERDATA_RESPONSE_VALUE) switch (decodedPacket.type) {
this.processResponsePacket(decodedPacket); case SERVERDATA_RESPONSE_VALUE:
else if (decodedPacket.type === SERVERDATA_AUTH_RESPONSE) case SERVERDATA_AUTH_RESPONSE:
this.processAuthPacket(decodedPacket); switch (decodedPacket.id) {
else if (decodedPacket.type === SERVERDATA_CHAT_VALUE) this.processChatPacket(decodedPacket); case MID_PACKET_ID:
else this.incomingResponse.push(decodedPacket);
Logger.verbose( break;
'RCON', case END_PACKET_ID:
2, this.responseCallbackQueue.shift()(
`Unknown packet type ${decodedPacket.type} in: ${this.decodedPacketToString( this.incomingResponse.map((packet) => packet.body).join()
decodedPacket );
)}` this.incomingResponse = [];
); break;
default:
Logger.verbose(
'RCON',
1,
`Unknown packet ID ${decodedPacket.id} in: ${this.decodedPacketToString(
decodedPacket
)}`
);
}
break;
case SERVERDATA_CHAT_VALUE:
this.processChatPacket(decodedPacket);
break;
default:
Logger.verbose(
'RCON',
1,
`Unknown packet type ${decodedPacket.type} in: ${this.decodedPacketToString(
decodedPacket
)}`
);
}
} }
} }
splitPackets(buf) { decodeData(data) {
this.incomingData = Buffer.concat([this.incomingData, data]);
const packets = []; const packets = [];
let offset = 0; while (this.incomingData.byteLength > 0) {
const size = this.incomingData.readInt32LE(0);
const packetSize = size + 4;
while (offset < buf.byteLength) { // The packet following an empty packet will report to be 10 long (14 including the size header bytes), but in
const size = buf.readInt32LE(offset); // it should report 17 long (21 including the size header bytes). Therefore, if the packet is 10 in size
// and there's enough data for it to be a longer packet then we need to probe to check it's this broken packet.
const probeSize = 17;
const probePacketSize = 21;
const endOfPacket = offset + size + 4; if (size === 10 && this.incomingData.byteLength >= probeSize) {
// copy the section of the incoming data of interest
// The packet following an empty pocked will appear to be 10 long, it's not. const probeBuf = this.incomingData.slice(0, probePacketSize);
if (size === 10) { // decode it
// it's 21 bytes long (or 17 when ignoring the 4 size bytes), 7 bytes longer than it should be. const decodedProbePacket = this.decodePacket(probeBuf);
const probeEndOfPacket = endOfPacket + 7; // check whether body matches
if (decodedProbePacket.body === '\x00\x00\x00\x01\x00\x00\x00') {
// check that there is room for the packet to be longer than it claims to be // it does so it's the broken packet
if (probeEndOfPacket <= buf.byteLength) { // remove the broken packet from the incoming data
// it is, so probe that section of the buffer this.incomingData = this.incomingData.slice(probePacketSize);
const probeBuf = buf.slice(offset, probeEndOfPacket); Logger.verbose('RCON', 4, `Ignoring some data: ${this.bufToHexString(probeBuf)}`);
continue;
// we decode to see it's contents
const decodedProbePacket = this.decodePacket(probeBuf);
// if it matches this body then it's the broken length packet
if (decodedProbePacket.body === '\x00\x00\x00\x01\x00\x00\x00') {
// update the offset with the new correct length, then skip this packet as we don't care about it anyway
offset = endOfPacket + 7;
Logger.verbose('RCON', 4, `Ignoring some data: ${this.bufToHexString(probeBuf)}`);
continue;
}
} }
} }
const packet = buf.slice(offset, endOfPacket); if (this.incomingData.byteLength < packetSize) {
Logger.verbose('RCON', 4, `Waiting for more data...`);
break;
}
const packet = this.incomingData.slice(0, packetSize);
packets.push(packet); packets.push(packet);
offset = endOfPacket; this.incomingData = this.incomingData.slice(packetSize);
}
if (packets.length !== 0) {
Logger.verbose(
'RCON',
4,
`Split data into packets: ${packets.map(this.bufToHexString).join(', ')}`
);
} }
return packets; return packets;
@ -141,46 +162,6 @@ export default class Rcon extends EventEmitter {
}; };
} }
processResponsePacket(decodedPacket) {
if (decodedPacket.id === MID_PACKET_ID) {
Logger.verbose(
'RCON',
3,
`Pushing packet to queue: ${this.decodedPacketToString(decodedPacket)}`
);
this.responsePacketQueue.push(decodedPacket);
} else if (decodedPacket.id === END_PACKET_ID) {
Logger.verbose('RCON', 3, 'Initiating processing of packet queue.');
this.processCompleteResponse(this.responsePacketQueue);
this.responsePacketQueue = [];
this.ignoreNextResponsePacket = true;
} else {
Logger.verbose(
'RCON',
1,
`Unknown packet id ${decodedPacket.id} in: ${this.decodedPacketToString(decodedPacket)}`
);
}
}
processCompleteResponse(decodedPackets) {
Logger.verbose(
'RCON',
3,
`Processing complete decoded packet response: ${decodedPackets
.map(this.decodedPacketToString)
.join(', ')}`
);
const response = decodedPackets.map((packet) => packet.body).join();
this.responseActionQueue.shift()(response);
}
processAuthPacket(decodedPacket) {
this.responseActionQueue.shift()(decodedPacket);
}
processChatPacket(decodedPacket) { processChatPacket(decodedPacket) {
const match = decodedPacket.body.match( const match = decodedPacket.body.match(
/\[(ChatAll|ChatTeam|ChatSquad|ChatAdmin)] \[SteamID:([0-9]{17})] (.+?) : (.*)/ /\[(ChatAll|ChatTeam|ChatSquad|ChatAdmin)] \[SteamID:([0-9]{17})] (.+?) : (.*)/
@ -301,9 +282,10 @@ export default class Rcon extends EventEmitter {
const encodedPacket = this.encodePacket( const encodedPacket = this.encodePacket(
type, type,
type === SERVERDATA_AUTH ? END_PACKET_ID : MID_PACKET_ID, type !== SERVERDATA_AUTH ? MID_PACKET_ID : END_PACKET_ID,
body body
); );
const encodedEmptyPacket = this.encodePacket(type, END_PACKET_ID, ''); const encodedEmptyPacket = this.encodePacket(type, END_PACKET_ID, '');
if (this.maximumPacketSize < encodedPacket.length) { if (this.maximumPacketSize < encodedPacket.length) {
@ -311,9 +293,16 @@ export default class Rcon extends EventEmitter {
return; return;
} }
let onResponse; const onError = (err) => {
Logger.verbose('RCON', 1, 'Error occurred. Wiping response action queue.', err);
this.responseCallbackQueue = [];
reject(err);
};
// the auth packet also sends a normal response, so we add an extra empty action to ignore it
if (type === SERVERDATA_AUTH) { if (type === SERVERDATA_AUTH) {
onResponse = (decodedPacket) => { this.responseCallbackQueue.push(() => {});
this.responseCallbackQueue.push((decodedPacket) => {
this.client.removeListener('error', onError); this.client.removeListener('error', onError);
if (decodedPacket.id === -1) { if (decodedPacket.id === -1) {
Logger.verbose('RCON', 1, 'Authentication failed.'); Logger.verbose('RCON', 1, 'Authentication failed.');
@ -322,32 +311,21 @@ export default class Rcon extends EventEmitter {
Logger.verbose('RCON', 1, 'Authentication succeeded.'); Logger.verbose('RCON', 1, 'Authentication succeeded.');
resolve(); resolve();
} }
}; });
} else { } else {
onResponse = (response) => { this.responseCallbackQueue.push((response) => {
this.client.removeListener('error', onError); this.client.removeListener('error', onError);
Logger.verbose( Logger.verbose(
'RCON', 'RCON',
2, 2,
`Processing complete response: ${response.replace(/\r\n|\r|\n/g, '\\n')}` `Returning complete response: ${response.replace(/\r\n|\r|\n/g, '\\n')}`
); );
resolve(response); resolve(response);
}; });
} }
const onError = (err) => {
Logger.verbose('RCON', 1, 'Error occurred. Wiping response action queue.', err);
this.responseActionQueue = [];
reject(err);
};
// the auth packet also sends a normal response, so we add an extra empty action to ignore it
if (type === SERVERDATA_AUTH) this.responseActionQueue.push(() => {});
this.responseActionQueue.push(onResponse);
this.client.once('error', onError); this.client.once('error', onError);
Logger.verbose('RCON', 4, `Sending packet: ${this.bufToHexString(encodedPacket)}`); Logger.verbose('RCON', 4, `Sending packet: ${this.bufToHexString(encodedPacket)}`);

View File

@ -59,6 +59,26 @@ export default class SquadServer extends EventEmitter {
this.pingSquadJSAPITimeout = null; this.pingSquadJSAPITimeout = null;
} }
async watch() {
await this.squadLayers.pull();
await this.rcon.connect();
await this.logParser.watch();
await this.updatePlayerList();
await this.updateLayerInformation();
await this.updateA2SInformation();
Logger.verbose('SquadServer', 1, `Watching ${this.serverName}...`);
await this.pingSquadJSAPI();
}
async unwatch() {
await this.rcon.disconnect();
await this.logParser.unwatch();
}
setupRCON() { setupRCON() {
this.rcon = new Rcon({ this.rcon = new Rcon({
host: this.options.host, host: this.options.host,
@ -234,13 +254,13 @@ export default class SquadServer extends EventEmitter {
async setupAdminList(remoteAdminLists) { async setupAdminList(remoteAdminLists) {
try { try {
let remoteAdmins = { const remoteAdmins = {
admins:{}, admins: {},
whitelist:{}, whitelist: {},
groups:{} groups: {}
}; };
for (let idx=0; idx < remoteAdminLists.length; idx++) { for (let idx = 0; idx < remoteAdminLists.length; idx++) {
const list = remoteAdminLists[idx]; const list = remoteAdminLists[idx];
const resp = await axios({ const resp = await axios({
@ -261,7 +281,7 @@ export default class SquadServer extends EventEmitter {
const perms = remoteAdmins.groups[`${idx}-${groupID}`]; const perms = remoteAdmins.groups[`${idx}-${groupID}`];
if (!(perms.includes('reserve') && perms.length === 1)) { if (!(perms.includes('reserve') && perms.length === 1)) {
remoteAdmins.admins[steamID] = `${idx}-${groupID}`; remoteAdmins.admins[steamID] = `${idx}-${groupID}`;
}else{ } else {
remoteAdmins.whitelist[steamID] = `${idx}-${groupID}`; remoteAdmins.whitelist[steamID] = `${idx}-${groupID}`;
} }
} }
@ -386,26 +406,54 @@ export default class SquadServer extends EventEmitter {
return this.getPlayerByCondition((player) => player.suffix === suffix, false); return this.getPlayerByCondition((player) => player.suffix === suffix, false);
} }
async watch() { async pingSquadJSAPI() {
await this.squadLayers.pull(); if (this.pingSquadJSAPITimeout) clearTimeout(this.pingSquadJSAPITimeout);
await this.rcon.connect(); Logger.verbose('SquadServer', 1, 'Pinging SquadJS API...');
await this.logParser.watch();
await this.updatePlayerList(); const config = {
await this.updateLayerInformation(); // send minimal information on server
await this.updateA2SInformation(); server: {
host: this.options.host,
queryPort: this.options.queryPort,
logReaderMode: this.options.logReaderMode
},
Logger.verbose('SquadServer', 1, `Watching ${this.serverName}...`); // we send all plugin information as none of that is sensitive.
plugins: this.plugins.map((plugin) => ({
...plugin.optionsRaw,
plugin: plugin.constructor.name
})),
await this.pingSquadJSAPI(); // send additional information about SquadJS
} version: SQUADJS_VERSION
};
async unwatch() {
await this.rcon.disconnect(); try {
await this.logParser.unwatch(); const { data } = await axios.post(SQUADJS_API_DOMAIN + '/api/v1/ping', { config });
if (data.error)
Logger.verbose(
'SquadServer',
1,
`Successfully pinged the SquadJS API. Got back error: ${data.error}`
);
else
Logger.verbose(
'SquadServer',
1,
`Successfully pinged the SquadJS API. Got back message: ${data.message}`
);
} catch (err) {
Logger.verbose('SquadServer', 1, 'Failed to ping the SquadJS API: ', err);
}
this.pingSquadJSAPITimeout = setTimeout(this.pingSquadJSAPI, this.pingSquadJSAPIInterval);
} }
/// ///////////////////////////////////////////////////////////////////////
// Should consider moving the following to a factory class of some kind. //
// ////////////////////////////////////////////////////////////////////////
static async buildFromConfig(config) { static async buildFromConfig(config) {
// Setup logging levels // Setup logging levels
for (const [module, verboseness] of Object.entries(config.verboseness)) { for (const [module, verboseness] of Object.entries(config.verboseness)) {
@ -458,7 +506,7 @@ export default class SquadServer extends EventEmitter {
connectorConfig.filter, connectorConfig.filter,
connectorConfig.activeLayerFilter connectorConfig.activeLayerFilter
); );
} else if(option.connector === 'remoteAdminLists'){ } else if (option.connector === 'remoteAdminLists') {
Logger.verbose('SquadServer', 1, `Starting remoteAdminList connector...`); Logger.verbose('SquadServer', 1, `Starting remoteAdminList connector...`);
connectors[connectorName] = await server.setupAdminList(connectorConfig); connectors[connectorName] = await server.setupAdminList(connectorConfig);
} else { } else {
@ -502,68 +550,111 @@ export default class SquadServer extends EventEmitter {
return server; return server;
} }
static buildFromConfigString(configString) { static parseConfig(configString) {
let config;
try { try {
config = JSON.parse(configString); return JSON.parse(configString);
} catch (err) { } catch (err) {
throw new Error('Unable to parse config file.'); throw new Error('Unable to parse config file.');
} }
return SquadServer.buildFromConfig(config);
} }
static buildFromConfigFile(configPath = './config.json') { static buildFromConfigString(configString) {
Logger.verbose('SquadServer', 1, 'Reading config file...'); Logger.verbose('SquadServer', 1, 'Parsing config string...');
return SquadServer.buildFromConfig(SquadServer.parseConfig(configString));
}
static readConfigFile(configPath = './config.json') {
configPath = path.resolve(__dirname, '../', configPath); configPath = path.resolve(__dirname, '../', configPath);
if (!fs.existsSync(configPath)) throw new Error('Config file does not exist.'); if (!fs.existsSync(configPath)) throw new Error('Config file does not exist.');
const configString = fs.readFileSync(configPath, 'utf8'); return fs.readFileSync(configPath, 'utf8');
return SquadServer.buildFromConfigString(configString);
} }
async pingSquadJSAPI() { static buildFromConfigFile(configPath) {
if (this.pingSquadJSAPITimeout) clearTimeout(this.pingSquadJSAPITimeout); Logger.verbose('SquadServer', 1, 'Reading config file...');
return SquadServer.buildFromConfigString(SquadServer.readConfigFile(configPath));
}
Logger.verbose('SquadServer', 1, 'Pinging SquadJS API...'); static buildConfig() {
const templatePath = path.resolve(__dirname, './templates/config-template.json');
const templateString = fs.readFileSync(templatePath, 'utf8');
const template = SquadServer.parseConfig(templateString);
const config = { const pluginKeys = Object.keys(plugins).sort((a, b) =>
// send minimal information on server a.name < b.name ? -1 : a.name > b.name ? 1 : 0
server: { );
host: this.options.host,
queryPort: this.options.queryPort,
logReaderMode: this.options.logReaderMode
},
// we send all plugin information as none of that is sensitive. for (const pluginKey of pluginKeys) {
plugins: this.plugins.map((plugin) => ({ const Plugin = plugins[pluginKey];
...plugin.optionsRaw,
plugin: plugin.constructor.name
})),
// send additional information about SquadJS const pluginConfig = { plugin: Plugin.name, enabled: Plugin.defaultEnabled };
version: SQUADJS_VERSION for (const [optionName, option] of Object.entries(Plugin.optionsSpecification)) {
}; pluginConfig[optionName] = option.default;
}
try { template.plugins.push(pluginConfig);
const { data } = await axios.post(SQUADJS_API_DOMAIN + '/api/v1/ping', { config });
if (data.error)
Logger.verbose(
'SquadServer',
1,
`Successfully pinged the SquadJS API. Got back error: ${data.error}`
);
else
Logger.verbose(
'SquadServer',
1,
`Successfully pinged the SquadJS API. Got back message: ${data.message}`
);
} catch (err) {
Logger.verbose('SquadServer', 1, 'Failed to ping the SquadJS API: ', err);
} }
this.pingSquadJSAPITimeout = setTimeout(this.pingSquadJSAPI, this.pingSquadJSAPIInterval); return template;
}
static buildConfigFile() {
const configPath = path.resolve(__dirname, '../config.json');
const config = JSON.stringify(SquadServer.buildConfig(), null, 2);
fs.writeFileSync(configPath, config);
}
static buildReadmeFile() {
const pluginKeys = Object.keys(plugins).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
const pluginInfo = [];
for (const pluginName of pluginKeys) {
const Plugin = plugins[pluginName];
const options = [];
for (const [optionName, option] of Object.entries(Plugin.optionsSpecification)) {
let optionInfo = `<h4>${optionName}${option.required ? ' (Required)' : ''}</h4>
<h6>Description</h6>
<p>${option.description}</p>
<h6>Default</h6>
<pre><code>${
typeof option.default === 'object'
? JSON.stringify(option.default, null, 2)
: option.default
}</code></pre>`;
if (option.example)
optionInfo += `<h6>Example</h6>
<pre><code>${
typeof option.example === 'object'
? JSON.stringify(option.example, null, 2)
: option.example
}</code></pre>`;
options.push(optionInfo);
}
pluginInfo.push(
`<details>
<summary>${Plugin.name}</summary>
<h2>${Plugin.name}</h2>
<p>${Plugin.description}</p>
<h3>Options</h3>
${options.join('\n')}
</details>`
);
}
const pluginInfoText = pluginInfo.join('\n\n');
const templatePath = path.resolve(__dirname, './templates/readme-template.md');
const template = fs.readFileSync(templatePath, 'utf8');
const readmePath = path.resolve(__dirname, '../README.md');
const readme = template.replace(/\/\/PLUGIN-INFO\/\//, pluginInfoText);
fs.writeFileSync(readmePath, readme);
} }
} }

View File

@ -0,0 +1,5 @@
import SquadServer from '../index.js';
console.log('Building config...');
SquadServer.buildConfigFile();
console.log('Done.');

View File

@ -1,28 +0,0 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import plugins from '../plugins/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const template = JSON.parse(
fs.readFileSync(path.resolve(__dirname, './templates/config-template.json'), 'utf8')
);
const pluginKeys = Object.keys(plugins).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
for (const pluginKey of pluginKeys) {
const Plugin = plugins[pluginKey];
const pluginConfig = { plugin: Plugin.name, enabled: Plugin.defaultEnabled };
for (const [optionName, option] of Object.entries(Plugin.optionsSpecification)) {
pluginConfig[optionName] = option.default;
}
template.plugins.push(pluginConfig);
}
fs.writeFileSync(path.resolve(__dirname, '../../config.json'), JSON.stringify(template, null, 2));

View File

@ -1,59 +1,5 @@
import fs from 'fs'; import SquadServer from '../index.js';
import { fileURLToPath } from 'url';
import path from 'path';
import plugins from '../plugins/index.js'; console.log('Building readme...');
SquadServer.buildReadmeFile();
const __dirname = path.dirname(fileURLToPath(import.meta.url)); console.log('Done.');
const pluginNames = Object.keys(plugins);
const sortedPluginNames = pluginNames.sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
const pluginInfo = [];
for (const pluginName of sortedPluginNames) {
const Plugin = plugins[pluginName];
const options = [];
for (const [optionName, option] of Object.entries(Plugin.optionsSpecification)) {
let optionInfo = `<h4>${optionName}${option.required ? ' (Required)' : ''}</h4>
<h6>Description</h6>
<p>${option.description}</p>
<h6>Default</h6>
<pre><code>${
typeof option.default === 'object'
? JSON.stringify(option.default, null, 2)
: option.default
}</code></pre>`;
if (option.example)
optionInfo += `<h6>Example</h6>
<pre><code>${
typeof option.example === 'object'
? JSON.stringify(option.example, null, 2)
: option.example
}</code></pre>`;
options.push(optionInfo);
}
pluginInfo.push(
`<details>
<summary>${Plugin.name}</summary>
<h2>${Plugin.name}</h2>
<p>${Plugin.description}</p>
<h3>Options</h3>
${options.join('\n')}
</details>`
);
}
const pluginInfoText = pluginInfo.join('\n\n');
fs.writeFileSync(
path.resolve(__dirname, '../../README.md'),
fs
.readFileSync(path.resolve(__dirname, './templates/readme-template.md'), 'utf8')
.replace(/\/\/PLUGIN-INFO\/\//, pluginInfoText)
);