diff --git a/.codexdocsrc.sample b/.codexdocsrc.sample deleted file mode 100644 index 43b9f94..0000000 --- a/.codexdocsrc.sample +++ /dev/null @@ -1,15 +0,0 @@ -{ - "title": "CodeX Docs", - "description": "A block-styled editor with clean JSON output", - "menu": [ - "Guides", - {"title": "CodeX", "uri": "https://codex.so"} - ], - "startPage": "", - "misprintsChatId": "12344564", - "yandexMetrikaId": "", - "carbon": { - "serve": "", - "placement": "" - } -} diff --git a/.gitignore b/.gitignore index 167bc65..693fcab 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ typings/ # Database files .db/ +db/ .testdb/ # Cache of babel and others diff --git a/app-config.yaml b/app-config.yaml new file mode 100644 index 0000000..ec50658 --- /dev/null +++ b/app-config.yaml @@ -0,0 +1,31 @@ +port: 3000 +host: "localhost" +uploads: "./uploads" +password: secretpassword +frontend: + title: "CodeX Docs" + description: "A block-styled editor with clean JSON output" + startPage: "" + misprintsChatId: "12344564" + yandexMetrikaId: "" + carbon: + serve: "" + placement: "" + menu: + - "Guides" + - title: "CodeX" + uri: "https://codex.so" + +auth: + secret: supersecret + +hawk: +# frontendToken: "123" +# backendToken: "123" + +database: + driver: local + local: + path: ./db + mongodb: + uri: mongodb://localhost:27017 diff --git a/config/development.json b/config/development.json deleted file mode 100644 index c7968be..0000000 --- a/config/development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "port": 3000, - "database": ".db", - "rcFile": "./.codexdocsrc", - "uploads": "public/uploads", - "secret": "iamasecretstring", - "favicon": "" -} diff --git a/config/production.json b/config/production.json deleted file mode 100644 index 102f201..0000000 --- a/config/production.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "port": 3000, - "database": ".db", - "rcFile": "./.codexdocsrc", - "uploads": "/uploads", - "secret": "iamasecretstring", - "favicon": "" -} diff --git a/config/testing.json b/config/testing.json deleted file mode 100644 index cb29838..0000000 --- a/config/testing.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "port": 3001, - "database": ".testdb", - "rcFile": "./src/test/.codexdocsrc", - "uploads": "public/uploads_test", - "secret": "iamasecretstring", - "favicon": "" -} diff --git a/docker-compose.yml b/docker-compose.yml index 510e15d..1ca8082 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,6 @@ services: env_file: - .env volumes: - - ./.env:/usr/src/app/.env - - ./.codexdocsrc:/usr/src/app/.codexdocsrc:ro - - ./config/production.json:/usr/src/app/config/production.json:ro - ./public/uploads:/uploads - - ./.db:/usr/src/app/.db - - /usr/src/app/node_modules + - ./db:/usr/src/app/db + - ./app-config.yaml:/usr/src/app/app-config.yaml diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index d4e49b9..68055b6 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -2,7 +2,7 @@ FROM node:16.14.0-alpine3.15 as build ## Install build toolchain, install node deps and compile native add-ons -RUN apk add --no-cache python3 make g++ +RUN apk add --no-cache python3 make g++ git WORKDIR /usr/src/app diff --git a/package.json b/package.json index 2f0f951..6a33fb4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "@codexteam/shortcuts": "^1.2.0", "@hawk.so/javascript": "^3.0.1", "@hawk.so/nodejs": "^3.1.4", + "arg": "^5.0.2", "config": "^3.3.6", + "config-loader": "https://github.com/codex-team/config-loader#081ad636684e9d1e5efa6dd757e1e0535f0a2b26", "cookie-parser": "^1.4.5", "csurf": "^1.11.0", "debug": "^4.3.2", @@ -40,7 +42,8 @@ "node-fetch": "^3.2.10", "open-graph-scraper": "^4.9.0", "twig": "^1.15.4", - "uuid4": "^2.0.2" + "uuid4": "^2.0.2", + "zod": "^3.19.1" }, "devDependencies": { "@babel/core": "^7.17.5", diff --git a/src/backend/app.ts b/src/backend/app.ts index 23e184d..fd7b7ce 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -3,15 +3,13 @@ import path from 'path'; import { fileURLToPath } from 'url'; import cookieParser from 'cookie-parser'; import morgan from 'morgan'; -import rcParser from './utils/rcparser.js'; import routes from './routes/index.js'; import HttpException from './exceptions/httpException.js'; import * as dotenv from 'dotenv'; -import config from 'config'; import HawkCatcher from '@hawk.so/nodejs'; import os from 'os'; -import appConfig from 'config'; import { downloadFavicon, FaviconData } from './utils/downloadFavicon.js'; +import appConfig from "./utils/appConfig.js"; /** * The __dirname CommonJS variables are not available in ES modules. @@ -22,20 +20,20 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config(); const app = express(); -const localConfig = rcParser.getConfiguration(); +const localConfig = appConfig.frontend; // Initialize the backend error tracking catcher. -if (process.env.HAWK_TOKEN_BACKEND) { - HawkCatcher.init(process.env.HAWK_TOKEN_BACKEND); +if (appConfig.hawk?.backendToken) { + HawkCatcher.init(appConfig.hawk.backendToken); } // Get url to upload favicon from config -const favicon: string = appConfig.get('favicon'); +const favicon = appConfig.favicon app.locals.config = localConfig; // Set client error tracking token as app local. -if (process.env.HAWK_TOKEN_CLIENT) { - app.locals.config.hawkClientToken = process.env.HAWK_TOKEN_CLIENT; +if (appConfig.hawk?.frontendToken) { + app.locals.config.hawkClientToken = appConfig.hawk.frontendToken; } // view engine setup @@ -69,7 +67,7 @@ app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, '../../public'))); -app.use('/uploads', express.static(config.get('uploads'))); +app.use('/uploads', express.static(appConfig.uploads)); app.use('/favicon', express.static(downloadedFaviconFolder)); app.use('/', routes); @@ -78,7 +76,7 @@ app.use('/', routes); // global error handler app.use(function (err: unknown, req: Request, res: Response, next: NextFunction) { // send any type of error to hawk server. - if (process.env.HAWK_TOKEN_BACKEND && err instanceof Error) { + if (appConfig.hawk?.backendToken && err instanceof Error) { HawkCatcher.send(err); } // only send Http based exception to client. diff --git a/src/backend/controllers/transport.ts b/src/backend/controllers/transport.ts index 4bd5918..b81822c 100644 --- a/src/backend/controllers/transport.ts +++ b/src/backend/controllers/transport.ts @@ -2,10 +2,10 @@ import fileType from 'file-type'; import fetch from 'node-fetch'; import fs from 'fs'; import nodePath from 'path'; -import config from 'config'; import File, { FileData } from '../models/file.js'; import crypto from '../utils/crypto.js'; import deepMerge from '../utils/objects.js'; +import appConfig from "../utils/appConfig.js"; const random16 = crypto.random16; @@ -71,7 +71,7 @@ class Transport { const type = await fileType.fromBuffer(buffer); const ext = type ? type.ext : nodePath.extname(url).slice(1); - fs.writeFileSync(`${config.get('uploads')}/${filename}.${ext}`, buffer); + fs.writeFileSync(`${appConfig.uploads}/${filename}.${ext}`, buffer); const fetchedContentType: string | null = fetchedFile.headers.get('content-type'); let fetchedMimeType: string | undefined; @@ -87,7 +87,7 @@ class Transport { const file = new File({ name: url, filename: `${filename}.${ext}`, - path: `${config.get('uploads')}/${filename}.${ext}`, + path: `${appConfig.uploads}/${filename}.${ext}`, size: buffer.length, mimetype: mimeType, }); diff --git a/src/backend/routes/api/transport.ts b/src/backend/routes/api/transport.ts index f492506..47187a5 100644 --- a/src/backend/routes/api/transport.ts +++ b/src/backend/routes/api/transport.ts @@ -2,9 +2,9 @@ import { Request, Response, Router } from 'express'; import multer, { StorageEngine } from 'multer'; import mime from 'mime'; import mkdirp from 'mkdirp'; -import config from 'config'; import Transport from '../../controllers/transport.js'; import { random16 } from '../../utils/crypto.js'; +import appConfig from "../../utils/appConfig.js"; const router = Router(); @@ -15,7 +15,7 @@ const router = Router(); */ const storage: StorageEngine = multer.diskStorage({ destination: (req, file, cb) => { - const dir: string = config.get('uploads') || 'public/uploads'; + const dir: string = appConfig.uploads || 'public/uploads'; mkdirp(dir); cb(null, dir); diff --git a/src/backend/routes/auth.ts b/src/backend/routes/auth.ts index 8ee3a66..e3abdb9 100644 --- a/src/backend/routes/auth.ts +++ b/src/backend/routes/auth.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from 'express'; import jwt from 'jsonwebtoken'; -import config from 'config'; import csrf from 'csurf'; +import appConfig from "../utils/appConfig.js"; const router = express.Router(); const csrfProtection = csrf({ cookie: true }); @@ -22,7 +22,7 @@ router.get('/auth', csrfProtection, function (req: Request, res: Response) { */ router.post('/auth', parseForm, csrfProtection, async (req: Request, res: Response) => { try { - if (!process.env.PASSWORD) { + if (!appConfig.password) { res.render('auth', { title: 'Login page', header: 'Password not set', @@ -32,7 +32,7 @@ router.post('/auth', parseForm, csrfProtection, async (req: Request, res: Respon return; } - if (req.body.password !== process.env.PASSWORD) { + if (req.body.password !== appConfig.password) { res.render('auth', { title: 'Login page', header: 'Wrong password', @@ -46,7 +46,7 @@ router.post('/auth', parseForm, csrfProtection, async (req: Request, res: Respon iss: 'Codex Team', sub: 'auth', iat: Date.now(), - }, process.env.PASSWORD + config.get('secret')); + }, appConfig.password + appConfig.auth.secret); res.cookie('authToken', token, { httpOnly: true, diff --git a/src/backend/routes/middlewares/token.ts b/src/backend/routes/middlewares/token.ts index 2afb5c4..f8d4ded 100644 --- a/src/backend/routes/middlewares/token.ts +++ b/src/backend/routes/middlewares/token.ts @@ -1,6 +1,6 @@ -import config from 'config'; import { NextFunction, Request, Response } from 'express'; import jwt from 'jsonwebtoken'; +import appConfig from "../../utils/appConfig.js"; /** @@ -14,14 +14,14 @@ export default async function verifyToken(req: Request, res: Response, next: Nex const token = req.cookies.authToken; try { - if (!process.env.PASSWORD) { + if (!appConfig.password) { res.locals.isAuthorized = false; next(); return; } - const decodedToken = jwt.verify(token, process.env.PASSWORD + config.get('secret')); + const decodedToken = jwt.verify(token, appConfig.password + appConfig.auth.secret); res.locals.isAuthorized = !!decodedToken; diff --git a/src/backend/utils/appConfig.ts b/src/backend/utils/appConfig.ts new file mode 100644 index 0000000..2ab6b43 --- /dev/null +++ b/src/backend/utils/appConfig.ts @@ -0,0 +1,74 @@ +import { loadConfig } from 'config-loader'; +import * as process from 'process'; +import arg from 'arg'; +import path from 'path'; +import { z } from "zod"; + +/** + * Configuration for Hawk errors catcher + */ +const HawkConfig = z.object({ + backendToken: z.string().optional(), // Hawk backend token + frontendToken: z.string().optional(), // Hawk frontend token +}) + +const LocalDatabaseConfig = z.object({ + driver: z.literal('local'), + local: z.object({ + path: z.string() + }) +}) + +const AuthConfig = z.object({ + secret: z.string() // Secret for JWT +}) + +const FrontendConfig = z.object({ + title: z.string(), // Title for pages + description: z.string(), // Description for pages + startPage: z.string(), // Start page + misprintsChatId: z.string().optional(), // Telegram chat id for misprints + yandexMetrikaId: z.string().optional(), // Yandex metrika id + carbon: z.object({ + serve: z.string().optional(), // Carbon serve url + placement: z.string().optional(), // Carbon placement + }), + menu: z.array(z.union([z.string(), z.object({title: z.string(), uri: z.string()})])), // Menu for pages +}) + +/** + * Application configuration + */ +const AppConfig = z.object({ + port: z.number(), // Port to listen on + host: z.string(), // Host to listen on + favicon: z.string().optional(), // Path or URL to favicon + uploads: z.string(), // Path to uploads folder + hawk: HawkConfig.optional().nullable(), // Hawk configuration + password: z.string(), // Password for admin panel + frontend: FrontendConfig, // Frontend configuration + auth: AuthConfig, // Auth configuration + database: LocalDatabaseConfig, // Database configuration +}) + +export type AppConfig = z.infer; + +const args = arg({ /* eslint-disable @typescript-eslint/naming-convention */ + '--config': [ String ], + '-c': '--config', +}); + +const cwd = process.cwd(); +const paths = (args['--config'] || ['./app-config.yaml']).map((configPath) => { + if (path.isAbsolute(configPath)) { + return configPath; + } + + return path.join(cwd, configPath); +}); + +const loadedConfig = loadConfig(...paths); + +const appConfig = AppConfig.parse(loadedConfig) + +export default appConfig; diff --git a/src/backend/utils/database/initDb.ts b/src/backend/utils/database/initDb.ts index b48d4ce..ae24d41 100644 --- a/src/backend/utils/database/initDb.ts +++ b/src/backend/utils/database/initDb.ts @@ -1,6 +1,6 @@ import Datastore from 'nedb'; -import config from 'config'; import path from 'path'; +import appConfig from "../appConfig.js"; /** * Init function for nedb instance @@ -10,7 +10,7 @@ import path from 'path'; */ export default function initDb(name: string): Datastore { return new Datastore({ - filename: path.resolve(`${config.get('database')}/${name}.db`), + filename: path.resolve(`${appConfig.database.local.path}/${name}.db`), autoload: true, }); -} \ No newline at end of file +} diff --git a/src/backend/utils/rcparser.ts b/src/backend/utils/rcparser.ts deleted file mode 100644 index 1b1befd..0000000 --- a/src/backend/utils/rcparser.ts +++ /dev/null @@ -1,132 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import config from 'config'; -import { fileURLToPath } from 'url'; - -/** - * The __dirname CommonJS variables are not available in ES modules. - * https://nodejs.org/api/esm.html#no-__filename-or-__dirname - */ -// eslint-disable-next-line @typescript-eslint/naming-convention -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const rcPath = path.resolve(__dirname, '../../../', config.get('rcFile') || './.codexdocsrc'); - -/** - * @typedef {object} menu - * @property {string} title - menu option title - * @property {string} uri - menu option href - */ -interface Menu { - title: string; - uri: string; - [key: string]: string; -} - -/** - * @typedef {object} RCData - * @property {string} title - website title - * @property {Menu[]} menu - options for website menu - */ -interface RCData { - title: string; - menu: Menu[]; - [key: string]: string | Menu[]; -} - -/** - * @class RCParser - * @classdesc Class to parse runtime configuration file for CodeX Docs engine - */ -export default class RCParser { - /** - * Default CodeX Docs configuration - * - * @static - * @returns {{title: string, menu: Array}} - */ - public static get DEFAULTS():RCData { - return { - title: 'CodeX Docs', - menu: [], - }; - } - - /** - * Find and parse runtime configuration file - * - * @static - * @returns {{title: string, menu: []}} - */ - public static getConfiguration(): RCData { - if (!fs.existsSync(rcPath)) { - return RCParser.DEFAULTS; - } - - const file = fs.readFileSync(rcPath, 'utf-8'); - const rConfig = RCParser.DEFAULTS; - let userConfig; - - try { - userConfig = JSON.parse(file); - } catch (e) { - console.log('CodeX Docs rc file should be in JSON format.'); - - return RCParser.DEFAULTS; - } - - for (const option in userConfig) { - if (Object.prototype.hasOwnProperty.call(userConfig, option)) { - rConfig[option] = userConfig[option] || RCParser.DEFAULTS[option] || undefined; - } - } - - if (!(rConfig.menu instanceof Array)) { - console.log('Menu section in the rc file must be an array.'); - rConfig.menu = RCParser.DEFAULTS.menu; - } - - rConfig.menu = rConfig.menu.filter((option: string | Menu, i:number) => { - i = i + 1; - if (typeof option === 'string') { - return true; - } - - if (!option || option instanceof Array || typeof option !== 'object') { - console.log(`Menu option #${i} in rc file must be a string or an object`); - - return false; - } - - const { title, uri } = option; - - if (!title || typeof title !== 'string') { - console.log(`Menu option #${i} title must be a string.`); - - return false; - } - - if (!uri || typeof uri !== 'string') { - console.log(`Menu option #${i} uri must be a string.`); - - return false; - } - - return true; - }); - - rConfig.menu = rConfig.menu.map((option: string | Menu) => { - if (typeof option === 'string') { - return { - title: option, - /* Replace all non alpha- and numeric-symbols with '-' */ - uri: '/' + option.toLowerCase().replace(/[ -/:-@[-`{-~]+/, '-'), - }; - } - - return option; - }); - - return rConfig; - } -} diff --git a/src/bin/server.ts b/src/bin/server.ts index 9f6c225..141c6e8 100644 --- a/src/bin/server.ts +++ b/src/bin/server.ts @@ -3,15 +3,15 @@ */ import app from '../backend/app.js'; import http from 'http'; -import config from 'config'; import Debug from 'debug'; +import appConfig from "../backend/utils/appConfig.js"; const debug = Debug.debug('codex.editor.docs:server'); /** * Get port from environment and store in Express. */ -const port = normalizePort(config.get('port') || '3000'); +const port = normalizePort(appConfig.port.toString() || '3000'); app.set('port', port); diff --git a/src/test/rcparser.ts b/src/test/rcparser.ts deleted file mode 100644 index 0f45a54..0000000 --- a/src/test/rcparser.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { expect } from 'chai'; -import fs from 'fs'; -import path from 'path'; -import config from 'config'; -import sinon = require('sinon'); - -import rcParser from '../backend/utils/rcparser.js'; - -const rcPath = path.resolve(process.cwd(), config.get('rcFile')); - -describe('RC file parser test', () => { - afterEach(() => { - if (fs.existsSync(rcPath)) { - fs.unlinkSync(rcPath); - } - }); - - it('Default config', async () => { - const parsedConfig = rcParser.getConfiguration(); - - expect(parsedConfig).to.be.deep.equal(rcParser.DEFAULTS); - }); - - it('Invalid JSON formatted config', () => { - const invalidJson = '{title: "Codex Docs"}'; - const spy = sinon.spy(console, 'log'); - - fs.writeFileSync(rcPath, invalidJson, 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(spy.calledOnce).to.be.true; - expect(spy.calledWith('CodeX Docs rc file should be in JSON format.')).to.be.true; - - expect(parsedConfig).to.be.deep.equal(rcParser.DEFAULTS); - spy.restore(); - }); - - it('Normal config', () => { - const normalConfig = { - title: 'Documentation', - menu: [ - { title: 'Option 1', uri: '/option1' }, - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ], - }; - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(parsedConfig).to.be.deep.equal(normalConfig); - }); - - it('Missed title', () => { - const normalConfig = { - menu: [ - { title: 'Option 1', uri: '/option1' }, - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ], - }; - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(parsedConfig.menu).to.be.deep.equal(normalConfig.menu); - expect(parsedConfig.title).to.be.equal(rcParser.DEFAULTS.title); - }); - - it('Missed menu', () => { - const normalConfig = { - title: 'Documentation', - }; - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(parsedConfig.title).to.be.equal(normalConfig.title); - expect(parsedConfig.menu).to.be.deep.equal(rcParser.DEFAULTS.menu); - }); - - it('Menu is not an array', () => { - const normalConfig = { - title: 'Documentation', - menu: { - 0: { title: 'Option 1', uri: '/option1' }, - 1: { title: 'Option 2', uri: '/option2' }, - 2: { title: 'Option 3', uri: '/option3' }, - }, - }; - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - const spy = sinon.spy(console, 'log'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(spy.calledOnce).to.be.true; - expect(spy.calledWith('Menu section in the rc file must be an array.')).to.be.true; - - expect(parsedConfig.title).to.be.equal(normalConfig.title); - expect(parsedConfig.menu).to.be.deep.equal(rcParser.DEFAULTS.menu); - spy.restore(); - }); - - it('Menu option is a string', () => { - const normalConfig = { - title: 'Documentation', - menu: [ - 'Option 1', - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ], - }; - - const expectedMenu = [ - { title: 'Option 1', uri: '/option-1' }, - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ]; - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(parsedConfig.title).to.be.equal(normalConfig.title); - expect(parsedConfig.menu).to.be.deep.equal(expectedMenu); - }); - - it('Menu option is not a string or an object', () => { - const normalConfig = { - title: 'Documentation', - menu: [ - [ { title: 'Option 1', uri: '/option1' } ], - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ], - }; - - const expectedMenu = [ - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ]; - const spy = sinon.spy(console, 'log'); - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(spy.calledOnce).to.be.true; - expect(spy.calledWith('Menu option #1 in rc file must be a string or an object')).to.be.true; - - expect(parsedConfig.title).to.be.equal(normalConfig.title); - expect(parsedConfig.menu).to.be.deep.equal(expectedMenu); - spy.restore(); - }); - - it('Menu option title is undefined', () => { - const normalConfig = { - title: 'Documentation', - menu: [ - { uri: '/option1' }, - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ], - }; - - const expectedMenu = [ - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ]; - const spy = sinon.spy(console, 'log'); - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(spy.calledOnce).to.be.true; - expect(spy.calledWith('Menu option #1 title must be a string.')).to.be.true; - - expect(parsedConfig.title).to.be.equal(normalConfig.title); - expect(parsedConfig.menu).to.be.deep.equal(expectedMenu); - spy.restore(); - }); - - it('Menu option title is not a string', () => { - const normalConfig = { - title: 'Documentation', - menu: [ - { title: [], uri: '/option1' }, - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ], - }; - - const expectedMenu = [ - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ]; - const spy = sinon.spy(console, 'log'); - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(spy.calledOnce).to.be.true; - expect(spy.calledWith('Menu option #1 title must be a string.')).to.be.true; - - expect(parsedConfig.title).to.be.equal(normalConfig.title); - expect(parsedConfig.menu).to.be.deep.equal(expectedMenu); - spy.restore(); - }); - - it('Menu option uri is undefined', () => { - const normalConfig = { - title: 'Documentation', - menu: [ - { title: 'Option 1' }, - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ], - }; - - const expectedMenu = [ - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ]; - const spy = sinon.spy(console, 'log'); - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(spy.calledOnce).to.be.true; - expect(spy.calledWith('Menu option #1 uri must be a string.')).to.be.true; - - expect(parsedConfig.title).to.be.equal(normalConfig.title); - expect(parsedConfig.menu).to.be.deep.equal(expectedMenu); - spy.restore(); - }); - - it('Menu option title is not a string', () => { - const normalConfig = { - title: 'Documentation', - menu: [ - { title: 'Option 1', uri: [] }, - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ], - }; - - const expectedMenu = [ - { title: 'Option 2', uri: '/option2' }, - { title: 'Option 3', uri: '/option3' }, - ]; - const spy = sinon.spy(console, 'log'); - - fs.writeFileSync(rcPath, JSON.stringify(normalConfig), 'utf8'); - - const parsedConfig = rcParser.getConfiguration(); - - expect(spy.calledOnce).to.be.true; - expect(spy.calledWith('Menu option #1 uri must be a string.')).to.be.true; - - expect(parsedConfig.title).to.be.equal(normalConfig.title); - expect(parsedConfig.menu).to.be.deep.equal(expectedMenu); - spy.restore(); - }); -}); diff --git a/yarn.lock b/yarn.lock index c25a97d..e48e266 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1658,6 +1658,11 @@ arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -2182,6 +2187,14 @@ concurrently@^7.1.0: tree-kill "^1.2.2" yargs "^17.3.1" +"config-loader@https://github.com/codex-team/config-loader#081ad636684e9d1e5efa6dd757e1e0535f0a2b26": + version "0.0.1" + resolved "https://github.com/codex-team/config-loader#081ad636684e9d1e5efa6dd757e1e0535f0a2b26" + dependencies: + js-yaml "^4.1.0" + lodash.isarray "^4.0.0" + lodash.merge "^4.6.2" + config@^3.3.6: version "3.3.7" resolved "https://registry.yarnpkg.com/config/-/config-3.3.7.tgz#4310410dc2bf4e0effdca21a12a4035860a24ee4" @@ -3761,9 +3774,10 @@ js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" -js-yaml@4.1.0: +js-yaml@4.1.0, js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== dependencies: argparse "^2.0.1" @@ -3978,6 +3992,11 @@ lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" +lodash.isarray@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-4.0.0.tgz#2aca496b28c4ca6d726715313590c02e6ea34403" + integrity sha512-V8ViWvoNlXpCrB6Ewaj3ScRXUpmCvqp4tJUxa3dlovuJj/8lp3SND5Kw4v5OeuHgoyw4qJN+gl36qZqp6WYQ6g== + lodash.isboolean@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" @@ -6333,3 +6352,8 @@ yn@^2.0.0: yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + +zod@^3.19.1: + version "3.19.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.19.1.tgz#112f074a97b50bfc4772d4ad1576814bd8ac4473" + integrity sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==